feat: завершить полную миграцию V1→V2 с модульной архитектурой и документацией

- Завершить миграцию фулфилмента на 100% V2 (удалить legacy компонент)
- Создать полную V2 систему для расходников селлера (SellerConsumableInventory)
- Автоматическое пополнение инвентаря при статусе DELIVERED
- Удалить весь код создания V1 Supply для расходников
- Исправить фильтрацию: расходники селлера только на странице consumables
- Исправить Organization.inn null ошибку с fallback значениями
- Создать документацию V2 систем и отчет о миграции
- Обновить import порядок для ESLint совместимости

BREAKING CHANGES: V1 система поставок расходников полностью удалена
This commit is contained in:
Veronika Smirnova
2025-09-01 00:11:48 +03:00
parent 3f0cc933fc
commit be891f5354
18 changed files with 1347 additions and 1520 deletions

View File

@ -1,10 +1,10 @@
import { AuthGuard } from '@/components/auth-guard'
import { CreateFulfillmentConsumablesSupplyPage } from '@/components/fulfillment-supplies/create-fulfillment-consumables-supply-page'
import CreateFulfillmentConsumablesSupplyV2Page from '@/components/fulfillment-supplies/create-fulfillment-consumables-supply-v2'
export default function CreateFulfillmentConsumablesSupplyPageRoute() {
return (
<AuthGuard>
<CreateFulfillmentConsumablesSupplyPage />
<CreateFulfillmentConsumablesSupplyV2Page />
</AuthGuard>
)
}

View File

@ -1,820 +0,0 @@
'use client'
import { useQuery, useMutation } from '@apollo/client'
import { ArrowLeft, Building2, Search, Package, Plus, Minus, ShoppingCart, Wrench } from 'lucide-react'
import Image from 'next/image'
import { useRouter } from 'next/navigation'
import React, { useState, useMemo } from 'react'
import { toast } from 'sonner'
import { Sidebar } from '@/components/dashboard/sidebar'
import { OrganizationAvatar } from '@/components/market/organization-avatar'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { CREATE_SUPPLY_ORDER } from '@/graphql/mutations'
import {
GET_MY_COUNTERPARTIES,
GET_ORGANIZATION_PRODUCTS,
GET_SUPPLY_ORDERS,
GET_MY_SUPPLIES,
GET_MY_FULFILLMENT_SUPPLIES,
} from '@/graphql/queries'
import { useAuth } from '@/hooks/useAuth'
import { useSidebar } from '@/hooks/useSidebar'
interface FulfillmentConsumableSupplier {
id: string
inn: string
name?: string
fullName?: string
type: 'FULFILLMENT' | 'SELLER' | 'LOGIST' | 'WHOLESALE'
address?: string
phones?: Array<{ value: string }>
emails?: Array<{ value: string }>
users?: Array<{ id: string; avatar?: string; managerName?: string }>
createdAt: string
}
interface FulfillmentConsumableProduct {
id: string
name: string
description?: string
price: number
type?: 'PRODUCT' | 'CONSUMABLE'
category?: { name: string }
images: string[]
mainImage?: string
organization: {
id: string
name: string
}
stock?: number
unit?: string
quantity?: number
ordered?: number
}
interface SelectedFulfillmentConsumable {
id: string
name: string
price: number
selectedQuantity: number
unit?: string
category?: string
supplierId: string
supplierName: string
}
export function CreateFulfillmentConsumablesSupplyPage() {
const router = useRouter()
const { getSidebarMargin } = useSidebar()
const { user } = useAuth()
const [selectedSupplier, setSelectedSupplier] = useState<FulfillmentConsumableSupplier | null>(null)
const [selectedLogistics, setSelectedLogistics] = useState<FulfillmentConsumableSupplier | null>(null)
const [selectedConsumables, setSelectedConsumables] = useState<SelectedFulfillmentConsumable[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [productSearchQuery, setProductSearchQuery] = useState('')
const [deliveryDate, setDeliveryDate] = useState('')
const [isCreatingSupply, setIsCreatingSupply] = useState(false)
// Загружаем контрагентов-поставщиков расходников
const { data: counterpartiesData, loading: counterpartiesLoading } = useQuery(GET_MY_COUNTERPARTIES)
// Убираем избыточное логирование для предотвращения визуального "бесконечного цикла"
// Стабилизируем переменные для useQuery
const queryVariables = useMemo(() => {
return {
organizationId: selectedSupplier?.id || '', // Всегда возвращаем объект, но с пустым ID если нет поставщика
search: productSearchQuery || null,
category: null,
type: 'CONSUMABLE' as const, // Фильтруем только расходники согласно rules2.md
}
}, [selectedSupplier?.id, productSearchQuery])
// Загружаем товары для выбранного поставщика с фильтрацией по типу CONSUMABLE
const {
data: productsData,
loading: productsLoading,
error: _productsError,
} = useQuery(GET_ORGANIZATION_PRODUCTS, {
skip: !selectedSupplier?.id, // Используем стабильное условие вместо !queryVariables
variables: queryVariables,
onCompleted: (data) => {
// Логируем только количество загруженных товаров
console.warn(`📦 Загружено товаров: ${data?.organizationProducts?.length || 0}`)
},
onError: (error) => {
console.error('❌ GET_ORGANIZATION_PRODUCTS ERROR:', error)
},
})
// Мутация для создания заказа поставки расходников
const [createSupplyOrder] = useMutation(CREATE_SUPPLY_ORDER)
// Фильтруем только поставщиков расходников (поставщиков)
const consumableSuppliers = (counterpartiesData?.myCounterparties || []).filter(
(org: FulfillmentConsumableSupplier) => org.type === 'WHOLESALE',
)
// Фильтруем только логистические компании
const logisticsPartners = (counterpartiesData?.myCounterparties || []).filter(
(org: FulfillmentConsumableSupplier) => org.type === 'LOGIST',
)
// Фильтруем поставщиков по поисковому запросу
const filteredSuppliers = consumableSuppliers.filter(
(supplier: FulfillmentConsumableSupplier) =>
supplier.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
supplier.fullName?.toLowerCase().includes(searchQuery.toLowerCase()) ||
supplier.inn?.toLowerCase().includes(searchQuery.toLowerCase()),
)
// Фильтруем товары по выбранному поставщику
// 📦 Получаем товары поставщика (уже отфильтрованы в GraphQL запросе по типу CONSUMABLE)
const supplierProducts = productsData?.organizationProducts || []
// Отладочное логирование только при смене поставщика
React.useEffect(() => {
if (selectedSupplier) {
console.warn('🔄 ПОСТАВЩИК ВЫБРАН:', {
id: selectedSupplier.id,
name: selectedSupplier.name || selectedSupplier.fullName,
type: selectedSupplier.type,
})
}
}, [selectedSupplier]) // Включаем весь объект поставщика для корректной работы
// Логируем результат загрузки товаров только при получении данных
React.useEffect(() => {
if (productsData && !productsLoading) {
console.warn('📦 ТОВАРЫ ЗАГРУЖЕНЫ:', {
organizationProductsCount: productsData?.organizationProducts?.length || 0,
supplierProductsCount: supplierProducts.length,
})
}
}, [productsData, productsLoading, supplierProducts.length]) // Включаем все зависимости для корректной работы
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
minimumFractionDigits: 0,
}).format(amount)
}
const updateConsumableQuantity = (productId: string, quantity: number) => {
const product = supplierProducts.find((p: FulfillmentConsumableProduct) => p.id === productId)
if (!product || !selectedSupplier) return
// 🔒 ВАЛИДАЦИЯ ОСТАТКОВ согласно правилам (раздел 6.2)
if (quantity > 0) {
const availableStock = (product.stock || product.quantity || 0) - (product.ordered || 0)
if (quantity > availableStock) {
toast.error(`❌ Недостаточно остатков!\оступно: ${availableStock} шт.\nЗапрашивается: ${quantity} шт.`)
return
}
}
setSelectedConsumables((prev) => {
const existing = prev.find((p) => p.id === productId)
if (quantity === 0) {
// Удаляем расходник если количество 0
return prev.filter((p) => p.id !== productId)
}
if (existing) {
// Обновляем количество существующего расходника
return prev.map((p) => (p.id === productId ? { ...p, selectedQuantity: quantity } : p))
} else {
// Добавляем новый расходник
return [
...prev,
{
id: product.id,
name: product.name,
price: product.price,
selectedQuantity: quantity,
unit: product.unit || 'шт',
category: product.category?.name || 'Расходники',
supplierId: selectedSupplier.id,
supplierName: selectedSupplier.name || selectedSupplier.fullName || 'Поставщик',
},
]
}
})
}
const getSelectedQuantity = (productId: string): number => {
const selected = selectedConsumables.find((p) => p.id === productId)
return selected ? selected.selectedQuantity : 0
}
const getTotalAmount = () => {
return selectedConsumables.reduce((sum, consumable) => sum + consumable.price * consumable.selectedQuantity, 0)
}
const getTotalItems = () => {
return selectedConsumables.reduce((sum, consumable) => sum + consumable.selectedQuantity, 0)
}
const handleCreateSupply = async () => {
if (!selectedSupplier || selectedConsumables.length === 0 || !deliveryDate || !selectedLogistics) {
toast.error('Заполните все обязательные поля: поставщик, расходники, дата доставки и логистика')
return
}
// Дополнительная проверка ID логистики
if (!selectedLogistics.id) {
toast.error('Выберите логистическую компанию')
return
}
setIsCreatingSupply(true)
try {
const input = {
partnerId: selectedSupplier.id,
deliveryDate: deliveryDate,
// Для фулфилмента указываем себя как получателя (поставка на свой склад)
fulfillmentCenterId: user?.organization?.id,
logisticsPartnerId: selectedLogistics.id,
// 🏷️ КЛАССИФИКАЦИЯ согласно правилам (раздел 2.2)
consumableType: 'FULFILLMENT_CONSUMABLES', // Расходники фулфилмента
items: selectedConsumables.map((consumable) => ({
productId: consumable.id,
quantity: consumable.selectedQuantity,
})),
}
console.warn('🚀 СОЗДАНИЕ ПОСТАВКИ - INPUT:', input)
const result = await createSupplyOrder({
variables: { input },
refetchQueries: [
{ query: GET_SUPPLY_ORDERS }, // Обновляем заказы поставок
{ query: GET_MY_SUPPLIES }, // Обновляем расходники фулфилмента
{ query: GET_MY_FULFILLMENT_SUPPLIES }, // 📊 Обновляем модуль учета расходников фулфилмента
],
})
console.warn('🎯 РЕЗУЛЬТАТ СОЗДАНИЯ ПОСТАВКИ:', result)
console.warn('🎯 ДЕТАЛИ ОТВЕТА:', result.data?.createSupplyOrder)
if (result.data?.createSupplyOrder?.success) {
toast.success('Заказ поставки расходников фулфилмента создан успешно!')
// Очищаем форму
setSelectedSupplier(null)
setSelectedConsumables([])
setDeliveryDate('')
setProductSearchQuery('')
setSearchQuery('')
// Перенаправляем на страницу поставок фулфилмента с активной вкладкой "Расходники фулфилмента"
router.push('/fulfillment/supplies/detailed-supplies')
} else {
toast.error(result.data?.createSupplyOrder?.message || 'Ошибка при создании заказа поставки')
}
} catch (error) {
console.error('Error creating fulfillment consumables supply:', error)
toast.error('Ошибка при создании поставки расходников фулфилмента')
} finally {
setIsCreatingSupply(false)
}
}
return (
<div className="h-screen flex overflow-hidden">
<Sidebar />
<main className={`flex-1 ${getSidebarMargin()} overflow-auto transition-all duration-300`}>
<div className="min-h-full w-full flex flex-col px-3 py-2">
{/* Заголовок */}
<div className="flex items-center justify-between mb-3 flex-shrink-0">
<div>
<h1 className="text-xl font-bold text-white mb-1">Создание поставки расходников фулфилмента</h1>
<p className="text-white/60 text-sm">
Выберите поставщика и добавьте расходники в заказ для вашего фулфилмент-центра
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => router.push('/fulfillment/supplies')}
className="text-white/60 hover:text-white hover:bg-white/10 text-sm"
>
<ArrowLeft className="h-4 w-4 mr-1" />
Назад
</Button>
</div>
{/* Основной контент с двумя блоками */}
<div className="flex-1 flex gap-3 min-h-0">
{/* Левая колонка - Поставщики и Расходники */}
<div className="flex-1 flex flex-col gap-3 min-h-0">
{/* Блок "Поставщики" */}
<Card className="bg-gradient-to-r from-white/15 via-white/10 to-white/15 backdrop-blur-xl border border-white/30 shadow-2xl flex-shrink-0 sticky top-0 z-10 rounded-xl overflow-hidden">
<div className="p-3 bg-gradient-to-r from-purple-500/10 to-pink-500/10">
<div className="flex items-center justify-between gap-4">
<h2 className="text-lg font-bold flex items-center flex-shrink-0 bg-gradient-to-r from-purple-400 to-pink-400 bg-clip-text text-transparent">
<Building2 className="h-5 w-5 mr-3 text-purple-400" />
Поставщики расходников
</h2>
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-purple-300 h-4 w-4 z-10" />
<Input
placeholder="Найти поставщика..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="bg-white/20 backdrop-blur border-white/30 text-white placeholder-white/50 pl-10 h-8 text-sm rounded-full shadow-inner focus:ring-2 focus:ring-purple-400/50 focus:border-purple-400/50 transition-all duration-300"
/>
</div>
{selectedSupplier && (
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedSupplier(null)}
className="text-white/70 hover:text-white hover:bg-white/20 text-sm h-8 px-3 flex-shrink-0 rounded-full transition-all duration-300 hover:scale-105"
>
Сбросить
</Button>
)}
</div>
</div>
<div className="px-3 pb-3 h-24 overflow-hidden">
{counterpartiesLoading ? (
<div className="text-center py-4">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-purple-400 border-t-transparent mx-auto mb-2"></div>
<p className="text-white/70 text-sm font-medium">Загружаем поставщиков...</p>
</div>
) : filteredSuppliers.length === 0 ? (
<div className="text-center py-4">
<div className="bg-gradient-to-br from-purple-500/20 to-pink-500/20 rounded-full p-3 w-fit mx-auto mb-2">
<Building2 className="h-6 w-6 text-purple-300" />
</div>
<p className="text-white/70 text-sm font-medium">
{searchQuery ? 'Поставщики не найдены' : 'Добавьте поставщиков'}
</p>
</div>
) : (
<div className="flex gap-2 h-full pt-1">
{filteredSuppliers.slice(0, 7).map((supplier: FulfillmentConsumableSupplier, index: number) => (
<Card
key={supplier.id}
className={`relative cursor-pointer transition-all duration-300 border flex-shrink-0 rounded-xl overflow-hidden group hover:scale-105 hover:shadow-xl ${
selectedSupplier?.id === supplier.id
? 'bg-gradient-to-br from-orange-500/30 via-orange-400/20 to-orange-500/30 border-orange-400/60 shadow-lg shadow-orange-500/25'
: 'bg-gradient-to-br from-white/10 via-white/5 to-white/10 border-white/20 hover:from-white/20 hover:via-white/10 hover:to-white/20 hover:border-white/40'
}`}
style={{
width: 'calc((100% - 48px) / 7)', // 48px = 6 gaps * 8px each
animationDelay: `${index * 100}ms`,
}}
onClick={() => setSelectedSupplier(supplier)}
>
<div className="flex flex-col items-center justify-center h-full p-2 space-y-1">
<div className="relative">
<OrganizationAvatar
organization={{
id: supplier.id,
name: supplier.name || supplier.fullName || 'Поставщик',
fullName: supplier.fullName,
users: (supplier.users || []).map((user) => ({
id: user.id,
avatar: user.avatar,
})),
}}
size="sm"
/>
{selectedSupplier?.id === supplier.id && (
<div className="absolute -top-1 -right-1 bg-gradient-to-r from-orange-400 to-orange-500 rounded-full w-4 h-4 flex items-center justify-center shadow-lg animate-pulse">
<span className="text-white text-xs font-bold"></span>
</div>
)}
</div>
<div className="text-center w-full space-y-0.5">
<h3 className="text-white font-semibold text-xs truncate leading-tight group-hover:text-purple-200 transition-colors duration-300">
{(supplier.name || supplier.fullName || 'Поставщик').slice(0, 10)}
</h3>
<div className="flex items-center justify-center space-x-1">
<span className="text-yellow-400 text-sm animate-pulse"></span>
<span className="text-white/80 text-xs font-medium">4.5</span>
</div>
<div className="w-full bg-white/10 rounded-full h-1 overflow-hidden">
<div
className="bg-gradient-to-r from-purple-400 to-pink-400 h-full rounded-full animate-pulse"
style={{ width: '90%' }}
></div>
</div>
</div>
</div>
{/* Hover эффект */}
<div className="absolute inset-0 bg-gradient-to-br from-purple-500/0 to-pink-500/0 group-hover:from-purple-500/10 group-hover:to-pink-500/10 transition-all duration-300 pointer-events-none"></div>
</Card>
))}
{filteredSuppliers.length > 7 && (
<div
className="flex-shrink-0 flex flex-col items-center justify-center bg-gradient-to-br from-white/10 to-white/5 rounded-xl border border-white/20 text-white/70 hover:text-white transition-all duration-300 hover:scale-105"
style={{ width: 'calc((100% - 48px) / 7)' }}
>
<div className="text-lg font-bold text-purple-300">+{filteredSuppliers.length - 7}</div>
<div className="text-xs">ещё</div>
</div>
)}
</div>
)}
</div>
</Card>
{/* Блок "Расходники" */}
<Card className="bg-white/10 backdrop-blur border-white/20 flex-1 min-h-0 flex flex-col">
<div className="p-3 border-b border-white/10 flex-shrink-0">
<div className="flex items-center justify-between mb-2">
<h2 className="text-lg font-semibold text-white flex items-center">
<Wrench className="h-4 w-4 mr-2" />
Расходники для фулфилмента
{selectedSupplier && (
<span className="text-white/60 text-xs font-normal ml-2 truncate">
- {selectedSupplier.name || selectedSupplier.fullName}
</span>
)}
</h2>
</div>
{selectedSupplier && (
<div className="relative">
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 text-white/40 h-3 w-3" />
<Input
placeholder="Поиск расходников..."
value={productSearchQuery}
onChange={(e) => setProductSearchQuery(e.target.value)}
className="bg-white/10 border-white/20 text-white placeholder-white/40 pl-7 h-8 text-sm"
/>
</div>
)}
</div>
<div className="p-3 flex-1 overflow-y-auto">
{!selectedSupplier ? (
<div className="text-center py-8">
<Wrench className="h-8 w-8 text-white/40 mx-auto mb-3" />
<p className="text-white/60 text-sm">Выберите поставщика для просмотра расходников</p>
</div>
) : productsLoading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-white border-t-transparent mx-auto mb-2"></div>
<p className="text-white/60 text-sm">Загрузка...</p>
</div>
) : supplierProducts.length === 0 ? (
<div className="text-center py-8">
<Package className="h-8 w-8 text-white/40 mx-auto mb-3" />
<p className="text-white/60 text-sm">Нет доступных расходников</p>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-7 gap-3">
{supplierProducts.map((product: FulfillmentConsumableProduct, index: number) => {
const selectedQuantity = getSelectedQuantity(product.id)
return (
<Card
key={product.id}
className={`relative bg-gradient-to-br from-white/10 via-white/5 to-white/10 backdrop-blur border border-white/20 p-3 rounded-xl overflow-hidden group hover:shadow-xl transition-all duration-300 ${
selectedQuantity > 0
? 'ring-2 ring-green-400/50 bg-gradient-to-br from-green-500/20 via-green-400/10 to-green-500/20'
: 'hover:from-white/20 hover:via-white/10 hover:to-white/20 hover:border-white/40'
}`}
style={{
animationDelay: `${index * 50}ms`,
minHeight: '200px',
width: '100%',
}}
>
<div className="space-y-2 h-full flex flex-col">
{/* Изображение товара */}
<div className="aspect-square bg-white/5 rounded-lg overflow-hidden relative flex-shrink-0">
{/* 🚫 ОВЕРЛЕЙ НЕДОСТУПНОСТИ */}
{(() => {
const totalStock = product.stock || (product as any).quantity || 0
const orderedStock = (product as any).ordered || 0
const availableStock = totalStock - orderedStock
if (availableStock <= 0) {
return (
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-10">
<div className="text-center">
<div className="text-red-400 font-bold text-xs">НЕТ В НАЛИЧИИ</div>
</div>
</div>
)
}
return null
})()}
{product.images && product.images.length > 0 && product.images[0] ? (
<Image
src={product.images[0]}
alt={product.name}
width={100}
height={100}
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-300"
/>
) : product.mainImage ? (
<Image
src={product.mainImage}
alt={product.name}
width={100}
height={100}
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-300"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Wrench className="h-8 w-8 text-white/40" />
</div>
)}
{selectedQuantity > 0 && (
<div className="absolute top-2 right-2 bg-gradient-to-r from-green-400 to-green-500 rounded-full w-6 h-6 flex items-center justify-center shadow-lg animate-pulse">
<span className="text-white text-xs font-bold">
{selectedQuantity > 999 ? '999+' : selectedQuantity}
</span>
</div>
)}
</div>
{/* Информация о товаре */}
<div className="space-y-1 flex-grow">
<h3 className="text-white font-medium text-sm leading-tight line-clamp-2 group-hover:text-purple-200 transition-colors duration-300">
{product.name}
</h3>
<div className="flex items-center gap-2 flex-wrap">
{product.category && (
<Badge className="bg-purple-500/20 text-purple-300 border-purple-500/30 text-xs px-2 py-1">
{product.category.name.slice(0, 10)}
</Badge>
)}
{/* 🚨 ИНДИКАТОР НИЗКИХ ОСТАТКОВ согласно правилам (раздел 6.3) */}
{(() => {
const totalStock = product.stock || product.quantity || 0
const orderedStock = product.ordered || 0
const availableStock = totalStock - orderedStock
if (availableStock <= 0) {
return (
<Badge className="bg-red-500/30 text-red-300 border-red-500/50 text-xs px-2 py-1 animate-pulse">
Нет в наличии
</Badge>
)
} else if (availableStock <= 10) {
return (
<Badge className="bg-yellow-500/30 text-yellow-300 border-yellow-500/50 text-xs px-2 py-1">
Мало остатков
</Badge>
)
}
return null
})()}
</div>
<div className="flex items-center justify-between">
<span className="text-green-400 font-semibold text-sm">
{formatCurrency(product.price)}
</span>
{/* 📊 АКТУАЛЬНЫЙ ОСТАТОК согласно правилам (раздел 6.4.2) */}
<div className="text-right">
{(() => {
const totalStock = product.stock || product.quantity || 0
const orderedStock = product.ordered || 0
const availableStock = totalStock - orderedStock
return (
<div className="flex flex-col items-end">
<span
className={`text-xs font-medium ${
availableStock <= 0
? 'text-red-400'
: availableStock <= 10
? 'text-yellow-400'
: 'text-white/80'
}`}
>
Доступно: {availableStock}
</span>
{orderedStock > 0 && (
<span className="text-white/40 text-xs">Заказано: {orderedStock}</span>
)}
</div>
)
})()}
</div>
</div>
</div>
{/* Управление количеством */}
<div className="flex flex-col items-center space-y-2 mt-auto">
{(() => {
const totalStock = product.stock || (product as any).quantity || 0
const orderedStock = (product as any).ordered || 0
const availableStock = totalStock - orderedStock
return (
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() =>
updateConsumableQuantity(product.id, Math.max(0, selectedQuantity - 1))
}
className="h-6 w-6 p-0 text-white/60 hover:text-white hover:bg-white/20 rounded-full transition-all duration-300"
disabled={selectedQuantity === 0}
>
<Minus className="h-3 w-3" />
</Button>
<Input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={selectedQuantity === 0 ? '' : selectedQuantity.toString()}
onChange={(e) => {
let inputValue = e.target.value
// Удаляем все нецифровые символы
inputValue = inputValue.replace(/[^0-9]/g, '')
// Удаляем ведущие нули
inputValue = inputValue.replace(/^0+/, '')
// Если строка пустая после удаления нулей, устанавливаем 0
const numericValue = inputValue === '' ? 0 : parseInt(inputValue)
// Ограничиваем значение максимумом доступного остатка
const clampedValue = Math.min(numericValue, availableStock, 99999)
updateConsumableQuantity(product.id, clampedValue)
}}
onBlur={(e) => {
// При потере фокуса, если поле пустое, устанавливаем 0
if (e.target.value === '') {
updateConsumableQuantity(product.id, 0)
}
}}
className="w-16 h-7 text-center text-sm bg-white/10 border-white/20 text-white rounded px-1 focus:ring-2 focus:ring-purple-400/50 focus:border-purple-400/50"
placeholder="0"
/>
<Button
variant="ghost"
size="sm"
onClick={() =>
updateConsumableQuantity(
product.id,
Math.min(selectedQuantity + 1, availableStock, 99999),
)
}
className={`h-6 w-6 p-0 rounded-full transition-all duration-300 ${
selectedQuantity >= availableStock || availableStock <= 0
? 'text-white/30 cursor-not-allowed'
: 'text-white/60 hover:text-white hover:bg-white/20'
}`}
disabled={selectedQuantity >= availableStock || availableStock <= 0}
title={
availableStock <= 0
? 'Товар отсутствует на складе'
: selectedQuantity >= availableStock
? `Максимум доступно: ${availableStock}`
: 'Увеличить количество'
}
>
<Plus className="h-3 w-3" />
</Button>
</div>
)
})()}
{selectedQuantity > 0 && (
<div className="text-center">
<span className="text-green-400 font-bold text-sm bg-green-500/10 px-3 py-1 rounded-full">
{formatCurrency(product.price * selectedQuantity)}
</span>
</div>
)}
</div>
</div>
{/* Hover эффект */}
<div className="absolute inset-0 bg-gradient-to-br from-purple-500/0 to-pink-500/0 group-hover:from-purple-500/5 group-hover:to-pink-500/5 transition-all duration-300 pointer-events-none rounded-xl"></div>
</Card>
)
})}
</div>
)}
</div>
</Card>
</div>
{/* Правая колонка - Корзина */}
<div className="w-72 flex-shrink-0">
<Card className="bg-white/10 backdrop-blur border-white/20 p-3 sticky top-0">
<h3 className="text-white font-semibold mb-3 flex items-center text-sm">
<ShoppingCart className="h-4 w-4 mr-2" />
Корзина ({getTotalItems()} шт)
</h3>
{selectedConsumables.length === 0 ? (
<div className="text-center py-6">
<div className="bg-gradient-to-br from-purple-500/20 to-pink-500/20 rounded-full p-4 w-fit mx-auto mb-3">
<ShoppingCart className="h-8 w-8 text-purple-300" />
</div>
<p className="text-white/60 text-sm font-medium mb-2">Корзина пуста</p>
<p className="text-white/40 text-xs mb-3">Добавьте расходники для создания поставки</p>
</div>
) : (
<div className="space-y-2 mb-3 max-h-48 overflow-y-auto">
{selectedConsumables.map((consumable) => (
<div key={consumable.id} className="flex items-center justify-between p-2 bg-white/5 rounded-lg">
<div className="flex-1 min-w-0">
<p className="text-white text-xs font-medium truncate">{consumable.name}</p>
<p className="text-white/60 text-xs">
{formatCurrency(consumable.price)} × {consumable.selectedQuantity}
</p>
</div>
<div className="flex items-center space-x-2">
<span className="text-green-400 font-medium text-xs">
{formatCurrency(consumable.price * consumable.selectedQuantity)}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => updateConsumableQuantity(consumable.id, 0)}
className="h-5 w-5 p-0 text-red-400 hover:text-red-300 hover:bg-red-500/10"
>
×
</Button>
</div>
</div>
))}
</div>
)}
<div className="border-t border-white/20 pt-3">
<div className="mb-3">
<label className="text-white/60 text-xs mb-1 block">Дата поставки:</label>
<Input
type="date"
value={deliveryDate}
onChange={(e) => setDeliveryDate(e.target.value)}
className="bg-white/10 border-white/20 text-white h-8 text-sm"
min={new Date().toISOString().split('T')[0]}
required
/>
</div>
{/* Выбор логистики */}
<div className="mb-3">
<label className="text-white/60 text-xs mb-1 block">Логистика *:</label>
<div className="relative">
<select
value={selectedLogistics?.id || ''}
onChange={(e) => {
const logisticsId = e.target.value
const logistics = logisticsPartners.find((p: any) => p.id === logisticsId)
setSelectedLogistics(logistics || null)
}}
className="w-full bg-white/10 border border-white/20 rounded-md px-3 py-2 text-white text-sm focus:outline-none focus:ring-1 focus:ring-purple-500 focus:border-transparent appearance-none"
>
<option value="" className="bg-gray-800 text-white">
Выберите логистику
</option>
{logisticsPartners.map((partner: any) => (
<option key={partner.id} value={partner.id} className="bg-gray-800 text-white">
{partner.name || partner.fullName || partner.inn}
</option>
))}
</select>
<div className="absolute inset-y-0 right-0 flex items-center px-2 pointer-events-none">
<svg className="w-4 h-4 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
</div>
<div className="flex items-center justify-between mb-3">
<span className="text-white font-semibold text-sm">Итого:</span>
<span className="text-green-400 font-bold text-lg">{formatCurrency(getTotalAmount())}</span>
</div>
<Button
onClick={handleCreateSupply}
disabled={
isCreatingSupply || !deliveryDate || selectedConsumables.length === 0 || !selectedLogistics
}
className="w-full bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white disabled:opacity-50 h-8 text-sm"
>
{isCreatingSupply ? 'Создание...' : 'Создать поставку'}
</Button>
</div>
</Card>
</div>
</div>
</div>
</main>
</div>
)
}

View File

@ -4,13 +4,11 @@ import { useQuery } from '@apollo/client'
import {
Calendar,
Building2,
TrendingUp,
DollarSign,
Wrench,
Package2,
ChevronDown,
ChevronRight,
User,
Clock,
Truck,
Box,
@ -54,6 +52,7 @@ interface SupplyOrder {
| 'CANCELLED'
totalAmount: number
totalItems: number
consumableType?: 'SELLER_CONSUMABLES' | 'FULFILLMENT_CONSUMABLES'
createdAt: string
updatedAt: string
partner: {
@ -98,9 +97,10 @@ export function SellerSupplyOrdersTab() {
setExpandedOrders(newExpanded)
}
// Фильтруем заказы созданные текущим селлером
// Фильтруем заказы созданные текущим селлером И только расходники селлера
const sellerOrders: SupplyOrder[] = (data?.supplyOrders || []).filter((order: SupplyOrder) => {
return order.organization.id === user?.organization?.id
return order.organization.id === user?.organization?.id &&
order.consumableType === 'SELLER_CONSUMABLES' // Только расходники селлера
})
const getStatusBadge = (status: SupplyOrder['status']) => {
@ -166,10 +166,10 @@ export function SellerSupplyOrdersTab() {
// Статистика для селлера
const totalOrders = sellerOrders.length
const totalAmount = sellerOrders.reduce((sum, order) => sum + order.totalAmount, 0)
const totalItems = sellerOrders.reduce((sum, order) => sum + order.totalItems, 0)
const _totalItems = sellerOrders.reduce((sum, order) => sum + order.totalItems, 0)
const pendingOrders = sellerOrders.filter((order) => order.status === 'PENDING').length
const approvedOrders = sellerOrders.filter((order) => order.status === 'CONFIRMED').length
const inTransitOrders = sellerOrders.filter((order) => order.status === 'IN_TRANSIT').length
const _approvedOrders = sellerOrders.filter((order) => order.status === 'CONFIRMED').length
const _inTransitOrders = sellerOrders.filter((order) => order.status === 'IN_TRANSIT').length
const deliveredOrders = sellerOrders.filter((order) => order.status === 'DELIVERED').length
if (loading) {

View File

@ -7,7 +7,6 @@ import React, { useState, useEffect } from 'react'
import { Sidebar } from '@/components/dashboard/sidebar'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { GET_PENDING_SUPPLIES_COUNT, GET_MY_SUPPLY_ORDERS } from '@/graphql/queries'
import { useAuth } from '@/hooks/useAuth'
import { useRealtime } from '@/hooks/useRealtime'
@ -37,7 +36,7 @@ export function SuppliesDashboard() {
const [activeSubTab, setActiveSubTab] = useState('goods')
const [activeThirdTab, setActiveThirdTab] = useState('cards')
const { user } = useAuth()
const [statisticsData, setStatisticsData] = useState<any>(null)
const [_statisticsData, _setStatisticsData] = useState<any>(null)
// Загружаем счетчик поставок, требующих одобрения
const { data: pendingData, refetch: refetchPending } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
@ -381,7 +380,7 @@ export function SuppliesDashboard() {
activeTab={activeTab}
activeSubTab={activeSubTab}
activeThirdTab={activeThirdTab}
data={statisticsData}
data={_statisticsData}
loading={false}
/>
</div>
@ -399,7 +398,9 @@ export function SuppliesDashboard() {
{(activeThirdTab === 'cards' || activeThirdTab === 'suppliers') && (
<AllSuppliesTab
pendingSupplyOrders={pendingCount?.supplyOrders || 0}
goodsSupplies={mySuppliesData?.mySupplyOrders || []} // ✅ РЕАЛЬНЫЕ ДАННЫЕ из GraphQL
goodsSupplies={(mySuppliesData?.mySupplyOrders || []).filter((supply: any) =>
supply.consumableType !== 'SELLER_CONSUMABLES'
)}
loading={mySuppliesLoading}
/>
)}

View File

@ -10,14 +10,13 @@ import { MarketplaceService } from '@/services/marketplace-service'
import { SmsService } from '@/services/sms-service'
import { WildberriesService } from '@/services/wildberries-service'
import '@/lib/seed-init' // Автоматическая инициализация БД
// Импорт новых resolvers для системы поставок v2
import { fulfillmentConsumableV2Queries, fulfillmentConsumableV2Mutations } from './resolvers/fulfillment-consumables-v2'
import { fulfillmentConsumableV2Queries as fulfillmentConsumableV2QueriesRestored, fulfillmentConsumableV2Mutations as fulfillmentConsumableV2MutationsRestored } from './resolvers/fulfillment-consumables-v2-restored'
import { fulfillmentInventoryV2Queries } from './resolvers/fulfillment-inventory-v2'
import { fulfillmentConsumableV2Queries as fulfillmentConsumableV2QueriesRestored, fulfillmentConsumableV2Mutations as fulfillmentConsumableV2MutationsRestored } from './resolvers/fulfillment-consumables-v2-restored'
import { logisticsConsumableV2Queries, logisticsConsumableV2Mutations } from './resolvers/logistics-consumables-v2'
import { sellerInventoryV2Queries } from './resolvers/seller-inventory-v2'
import { CommercialDataAudit } from './security/commercial-data-audit'
import { createSecurityContext } from './security/index'
import '@/lib/seed-init' // Автоматическая инициализация БД
// 🔒 HELPER: Создание безопасного контекста с организационными данными
function createSecureContextWithOrgData(context: Context, currentUser: { organization: { id: string; type: string } }) {
@ -1310,38 +1309,35 @@ export const resolvers = {
`📊 FULFILLMENT SUPPLIES RECEIVED TODAY V2 (ПРИБЫЛО): ${fulfillmentSuppliesReceivedTodayV2.length} orders, ${fulfillmentSuppliesChangeToday} items`,
)
// Расходники селлеров - получаем из таблицы Supply (актуальные остатки на складе фулфилмента)
// ИСПРАВЛЕНО: считаем из Supply с типом SELLER_CONSUMABLES
const sellerSuppliesFromWarehouse = await prisma.supply.findMany({
// V2: Расходники селлеров - получаем из SellerConsumableInventory
const sellerInventoryFromWarehouse = await prisma.sellerConsumableInventory.findMany({
where: {
organizationId: organizationId, // Склад фулфилмента
type: 'SELLER_CONSUMABLES', // ТОЛЬКО расходники селлеров
fulfillmentCenterId: organizationId, // Склад фулфилмента
},
})
const sellerSuppliesCount = sellerSuppliesFromWarehouse.reduce(
(sum, supply) => sum + (supply.currentStock || 0),
const sellerSuppliesCount = sellerInventoryFromWarehouse.reduce(
(sum, item) => sum + (item.currentStock || 0),
0,
)
console.warn(`💼 SELLER SUPPLIES DEBUG: totalCount=${sellerSuppliesCount} (from Supply warehouse)`)
console.warn(`💼 SELLER SUPPLIES V2 DEBUG: totalCount=${sellerSuppliesCount} (from SellerConsumableInventory)`)
// Изменения расходников селлеров за сутки - считаем из Supply записей, созданных за сутки
const sellerSuppliesReceivedToday = await prisma.supply.findMany({
// V2: Изменения расходников селлеров за сутки - считаем поступления за сутки
const sellerSuppliesReceivedTodayV2 = await prisma.sellerConsumableInventory.findMany({
where: {
organizationId: organizationId, // Склад фулфилмента
type: 'SELLER_CONSUMABLES', // ТОЛЬКО расходники селлеров
createdAt: { gte: oneDayAgo }, // Созданы за последние сутки
fulfillmentCenterId: organizationId,
lastSupplyDate: { gte: oneDayAgo }, // Пополнены за последние сутки
},
})
const sellerSuppliesChangeToday = sellerSuppliesReceivedToday.reduce(
(sum, supply) => sum + (supply.currentStock || 0),
const sellerSuppliesChangeToday = sellerSuppliesReceivedTodayV2.reduce(
(sum, item) => sum + (item.totalReceived || 0),
0,
)
console.warn(
`📊 SELLER SUPPLIES RECEIVED TODAY: ${sellerSuppliesReceivedToday.length} supplies, ${sellerSuppliesChangeToday} items`,
`📊 SELLER SUPPLIES RECEIVED TODAY V2: ${sellerSuppliesReceivedTodayV2.length} supplies, ${sellerSuppliesChangeToday} items`,
)
// Вычисляем процентные изменения
@ -1551,8 +1547,10 @@ export const resolvers = {
})
},
// Расходники селлеров на складе фулфилмента (новый resolver)
// V2: Расходники селлеров на складе фулфилмента (обновлено на V2 систему)
sellerSuppliesOnWarehouse: async (_: unknown, __: unknown, context: Context) => {
console.warn('🚀 V2 SELLER SUPPLIES ON WAREHOUSE RESOLVER CALLED')
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' },
@ -1573,60 +1571,115 @@ export const resolvers = {
throw new GraphQLError('Доступ разрешен только для фулфилмент-центров')
}
// ИСПРАВЛЕНО: Усиленная фильтрация расходников селлеров
const sellerSupplies = await prisma.supply.findMany({
where: {
organizationId: currentUser.organization.id, // На складе этого фулфилмента
type: 'SELLER_CONSUMABLES' as const, // Только расходники селлеров
sellerOwnerId: { not: null }, // ОБЯЗАТЕЛЬНО должен быть владелец-селлер
},
include: {
organization: true, // Фулфилмент-центр (хранитель)
sellerOwner: true, // Селлер-владелец расходников
},
orderBy: { createdAt: 'desc' },
})
try {
// V2: Получаем данные из SellerConsumableInventory вместо старой Supply таблицы
const sellerInventory = await prisma.sellerConsumableInventory.findMany({
where: {
fulfillmentCenterId: currentUser.organization.id,
},
include: {
seller: true,
fulfillmentCenter: true,
product: {
include: {
organization: true, // Поставщик товара
},
},
},
orderBy: [
{ seller: { name: 'asc' } }, // Группируем по селлерам
{ updatedAt: 'desc' },
],
})
// Логирование для отладки
console.warn('🔍 ИСПРАВЛЕНО: Запрос расходников селлеров на складе фулфилмента:', {
fulfillmentId: currentUser.organization.id,
fulfillmentName: currentUser.organization.name,
totalSupplies: sellerSupplies.length,
sellerSupplies: sellerSupplies.map((supply) => ({
id: supply.id,
name: supply.name,
type: supply.type,
sellerOwnerId: supply.sellerOwnerId,
sellerOwnerName: supply.sellerOwner?.name || supply.sellerOwner?.fullName,
currentStock: supply.currentStock,
})),
})
console.warn('📊 V2 Seller Inventory loaded for warehouse:', {
fulfillmentId: currentUser.organization.id,
fulfillmentName: currentUser.organization.name,
inventoryCount: sellerInventory.length,
uniqueSellers: new Set(sellerInventory.map(item => item.sellerId)).size,
})
// ДВОЙНАЯ ПРОВЕРКА: Фильтруем на уровне кода для гарантии
const filteredSupplies = sellerSupplies.filter((supply) => {
const isValid =
supply.type === 'SELLER_CONSUMABLES' && supply.sellerOwnerId != null && supply.sellerOwner != null
// Преобразуем V2 данные в формат Supply для совместимости с фронтендом
const suppliesFormatted = sellerInventory.map((item) => {
const status = item.currentStock > 0 ? 'На складе' : 'Недоступен'
const supplier = item.product.organization?.name || 'Неизвестен'
if (!isValid) {
console.warn('⚠️ ОТФИЛЬТРОВАН некорректный расходник:', {
id: supply.id,
name: supply.name,
type: supply.type,
sellerOwnerId: supply.sellerOwnerId,
hasSellerOwner: !!supply.sellerOwner,
})
}
// Дополнительная проверка на null значения
if (!item.seller?.inn) {
console.error('❌ КРИТИЧЕСКАЯ ОШИБКА: seller.inn is null/undefined', {
sellerId: item.sellerId,
sellerName: item.seller?.name,
itemId: item.id,
})
}
return isValid
})
return {
// === ИДЕНТИФИКАЦИЯ ===
id: item.id,
productId: item.product.id,
// === ОСНОВНЫЕ ДАННЫЕ ===
name: item.product.name,
article: item.product.article,
description: item.product.description || '',
unit: item.product.unit || 'шт',
category: item.product.category || 'Расходники',
imageUrl: item.product.imageUrl,
// === СКЛАДСКИЕ ДАННЫЕ ===
currentStock: item.currentStock,
minStock: item.minStock,
usedStock: item.totalUsed || 0,
quantity: item.totalReceived,
reservedStock: item.reservedStock,
// === ЦЕНЫ ===
price: parseFloat(item.averageCost.toString()),
pricePerUnit: item.usagePrice ? parseFloat(item.usagePrice.toString()) : null,
// === СТАТУС И МЕТАДАННЫЕ ===
status,
isAvailable: item.currentStock > 0,
supplier,
type: 'SELLER_CONSUMABLES', // Для совместимости с фронтендом
date: item.lastSupplyDate?.toISOString() || item.createdAt.toISOString(),
createdAt: item.createdAt.toISOString(),
updatedAt: item.updatedAt.toISOString(),
// === СВЯЗИ ===
organization: {
id: item.fulfillmentCenter.id,
name: item.fulfillmentCenter.name,
fullName: item.fulfillmentCenter.fullName,
type: item.fulfillmentCenter.type,
},
sellerOwner: {
id: item.seller.id,
name: item.seller.name || 'Неизвестно',
fullName: item.seller.fullName || item.seller.name || 'Неизвестно',
inn: item.seller.inn || 'НЕ_УКАЗАН',
type: item.seller.type,
},
sellerOwnerId: item.sellerId, // Для совместимости
// === ДОПОЛНИТЕЛЬНЫЕ ПОЛЯ ===
notes: item.notes,
actualQuantity: item.currentStock,
}
})
console.warn('✅ ФИНАЛЬНЫЙ РЕЗУЛЬТАТ после фильтрации:', {
originalCount: sellerSupplies.length,
filteredCount: filteredSupplies.length,
removedCount: sellerSupplies.length - filteredSupplies.length,
})
console.warn('✅ V2 Seller Supplies formatted for frontend:', {
count: suppliesFormatted.length,
totalStock: suppliesFormatted.reduce((sum, item) => sum + item.currentStock, 0),
lowStockItems: suppliesFormatted.filter(item => item.currentStock <= item.minStock).length,
})
return filteredSupplies
return suppliesFormatted
} catch (error) {
console.error('❌ Error in V2 seller supplies on warehouse resolver:', error)
return []
}
},
// Мои товары и расходники (для поставщиков)
@ -2857,6 +2910,9 @@ export const resolvers = {
// Новая система складских остатков V2 (заменяет старый myFulfillmentSupplies)
...fulfillmentInventoryV2Queries,
// V2 система складских остатков расходников селлера
...sellerInventoryV2Queries,
},
Mutation: {
@ -5513,46 +5569,8 @@ export const resolvers = {
}
}
// Создаем расходники на основе заказанных товаров
// Расходники создаются в организации получателя (фулфилмент-центре)
// Определяем тип расходников на основе consumableType
const supplyType =
args.input.consumableType === 'SELLER_CONSUMABLES' ? 'SELLER_CONSUMABLES' : 'FULFILLMENT_CONSUMABLES'
// Определяем sellerOwnerId для расходников селлеров
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)!
const productWithCategory = supplyOrder.items.find(
(orderItem: { productId: string; product: { category?: { name: string } | null } }) =>
orderItem.productId === item.productId,
)?.product
return {
name: product.name,
article: product.article, // ИСПРАВЛЕНО: Добавляем артикул товара для уникальности
description: product.description || `Заказано у ${partner.name}`,
price: product.price, // Цена закупки у поставщика
quantity: item.quantity,
unit: 'шт',
category: productWithCategory?.category?.name || 'Расходники',
status: 'planned', // Статус "запланировано" (ожидает одобрения поставщиком)
date: new Date(args.input.deliveryDate),
supplier: partner.name || partner.fullName || 'Не указан',
minStock: Math.round(item.quantity * 0.1), // 10% от заказанного как минимальный остаток
currentStock: 0, // Пока товар не пришел
type: supplyType, // ИСПРАВЛЕНО: Добавляем тип расходников
sellerOwnerId: sellerOwnerId, // ИСПРАВЛЕНО: Добавляем владельца для расходников селлеров
// Расходники создаются в организации получателя (фулфилмент-центре)
organizationId: fulfillmentCenterId || currentUser.organization!.id,
}
})
// Создаем расходники
await prisma.supply.createMany({
data: suppliesData,
})
// V2 СИСТЕМА: Расходники будут автоматически созданы при подтверждении заказа
console.warn('📦 V2 система: расходники будут созданы автоматически при доставке через соответствующие резолверы')
// 🔔 ОТПРАВЛЯЕМ УВЕДОМЛЕНИЕ ПОСТАВЩИКУ О НОВОМ ЗАКАЗЕ
try {
@ -7299,113 +7317,8 @@ export const resolvers = {
}
}
// Обновляем расходники
for (const item of existingOrder.items) {
console.warn('📦 Обрабатываем товар:', {
productName: item.product.name,
quantity: item.quantity,
targetOrganizationId,
consumableType: existingOrder.consumableType,
})
// ИСПРАВЛЕНИЕ: Определяем правильный тип расходников
const isSellerSupply = existingOrder.consumableType === 'SELLER_CONSUMABLES'
const supplyType = isSellerSupply ? 'SELLER_CONSUMABLES' : 'FULFILLMENT_CONSUMABLES'
const sellerOwnerId = isSellerSupply ? existingOrder.organizationId : null
console.warn('🔍 Определен тип расходников:', {
isSellerSupply,
supplyType,
sellerOwnerId,
})
// ИСПРАВЛЕНИЕ: Ищем по Артикул СФ для уникальности вместо имени
const whereCondition = isSellerSupply
? {
organizationId: targetOrganizationId,
article: item.product.article, // ИЗМЕНЕНО: поиск по article вместо name
type: 'SELLER_CONSUMABLES' as const,
sellerOwnerId: existingOrder.organizationId,
}
: {
organizationId: targetOrganizationId,
article: item.product.article, // ИЗМЕНЕНО: поиск по article вместо name
type: 'FULFILLMENT_CONSUMABLES' as const,
sellerOwnerId: null, // Для фулфилмента sellerOwnerId должен быть null
}
console.warn('🔍 Ищем существующий расходник с условиями:', whereCondition)
const existingSupply = await prisma.supply.findFirst({
where: whereCondition,
})
if (existingSupply) {
console.warn('📈 ОБНОВЛЯЕМ существующий расходник:', {
id: existingSupply.id,
oldStock: existingSupply.currentStock,
oldQuantity: existingSupply.quantity,
addingQuantity: item.quantity,
})
// ОБНОВЛЯЕМ существующий расходник
const updatedSupply = await prisma.supply.update({
where: { id: existingSupply.id },
data: {
currentStock: existingSupply.currentStock + item.quantity,
// ❌ ИСПРАВЛЕНО: НЕ обновляем quantity - это изначальное количество заказа!
// quantity остается как было изначально заказано
status: 'in-stock', // Меняем статус на "на складе"
updatedAt: new Date(),
},
})
console.warn('✅ Расходник ОБНОВЛЕН (НЕ создан дубликат):', {
id: updatedSupply.id,
name: updatedSupply.name,
newCurrentStock: updatedSupply.currentStock,
newTotalQuantity: updatedSupply.quantity,
type: updatedSupply.type,
})
} else {
console.warn(' СОЗДАЕМ новый расходник (не найден существующий):', {
name: item.product.name,
quantity: item.quantity,
organizationId: targetOrganizationId,
type: supplyType,
sellerOwnerId: sellerOwnerId,
})
// СОЗДАЕМ новый расходник
const newSupply = await prisma.supply.create({
data: {
name: item.product.name,
article: item.product.article, // ДОБАВЛЕНО: Артикул СФ для уникальности
description: item.product.description || `Поставка от ${existingOrder.partner.name}`,
price: item.price, // Цена закупки у поставщика
quantity: item.quantity,
unit: 'шт',
category: item.product.category?.name || 'Расходники',
status: 'in-stock',
date: new Date(),
supplier: existingOrder.partner.name || existingOrder.partner.fullName || 'Не указан',
minStock: Math.round(item.quantity * 0.1),
currentStock: item.quantity,
organizationId: targetOrganizationId,
type: supplyType as 'SELLER_CONSUMABLES' | 'FULFILLMENT_CONSUMABLES',
sellerOwnerId: sellerOwnerId,
},
})
console.warn('✅ Новый расходник СОЗДАН:', {
id: newSupply.id,
name: newSupply.name,
currentStock: newSupply.currentStock,
type: newSupply.type,
sellerOwnerId: newSupply.sellerOwnerId,
})
}
}
// V2 СИСТЕМА: Расходники автоматически обрабатываются в seller-consumables.ts и fulfillment-consumables.ts
console.warn('📦 V2 система автоматически обработает инвентарь через специализированные резолверы')
console.warn('🎉 Склад организации успешно обновлен!')
}
@ -8412,54 +8325,8 @@ export const resolvers = {
},
})
// Добавляем расходники в склад фулфилмента как SELLER_CONSUMABLES
console.warn('📦 Обновляем склад фулфилмента для селлерской поставки...')
for (const item of sellerSupply.items) {
const existingSupply = await prisma.supply.findFirst({
where: {
organizationId: currentUser.organization.id,
article: item.product.article,
type: 'SELLER_CONSUMABLES',
sellerOwnerId: sellerSupply.sellerId,
},
})
if (existingSupply) {
await prisma.supply.update({
where: { id: existingSupply.id },
data: {
currentStock: existingSupply.currentStock + item.requestedQuantity,
status: 'in-stock',
},
})
console.warn(
`📈 Обновлен расходник селлера "${item.product.name}" (владелец: ${sellerSupply.seller.name}): ${existingSupply.currentStock} -> ${existingSupply.currentStock + item.requestedQuantity}`,
)
} else {
await prisma.supply.create({
data: {
name: item.product.name,
article: item.product.article,
description: `Расходники селлера ${sellerSupply.seller.name || sellerSupply.seller.fullName}`,
price: item.unitPrice,
quantity: item.requestedQuantity,
actualQuantity: item.requestedQuantity,
currentStock: item.requestedQuantity,
usedStock: 0,
unit: 'шт',
category: item.product.category?.name || 'Расходники',
status: 'in-stock',
supplier: sellerSupply.supplier?.name || sellerSupply.supplier?.fullName || 'Поставщик',
type: 'SELLER_CONSUMABLES',
sellerOwnerId: sellerSupply.sellerId,
organizationId: currentUser.organization.id,
},
})
console.warn(
` Создан новый расходник селлера "${item.product.name}" (владелец: ${sellerSupply.seller.name}): ${item.requestedQuantity} единиц`,
)
}
}
// V2 СИСТЕМА: Инвентарь селлера автоматически обновляется через SellerConsumableInventory
console.warn('📦 V2 система автоматически обновит SellerConsumableInventory через processSellerConsumableSupplyReceipt')
return {
success: true,
@ -8565,81 +8432,8 @@ export const resolvers = {
}
}
// Обновляем склад фулфилмента с учетом типа расходников
console.warn('📦 Обновляем склад фулфилмента...')
console.warn(`🏷️ Тип поставки: ${existingOrder.consumableType || 'FULFILLMENT_CONSUMABLES'}`)
for (const item of existingOrder.items) {
// Определяем тип расходников и владельца
const isSellerSupply = existingOrder.consumableType === 'SELLER_CONSUMABLES'
const supplyType = isSellerSupply ? 'SELLER_CONSUMABLES' : 'FULFILLMENT_CONSUMABLES'
const sellerOwnerId = isSellerSupply ? updatedOrder.organization?.id : null
// Для расходников селлеров ищем по Артикул СФ И по владельцу
const whereCondition = isSellerSupply
? {
organizationId: currentUser.organization.id,
article: item.product.article, // ИЗМЕНЕНО: поиск по article вместо name
type: 'SELLER_CONSUMABLES' as const,
sellerOwnerId: sellerOwnerId,
}
: {
organizationId: currentUser.organization.id,
article: item.product.article, // ИЗМЕНЕНО: поиск по article вместо name
type: 'FULFILLMENT_CONSUMABLES' as const,
}
const existingSupply = await prisma.supply.findFirst({
where: whereCondition,
})
if (existingSupply) {
await prisma.supply.update({
where: { id: existingSupply.id },
data: {
currentStock: existingSupply.currentStock + item.quantity,
// ❌ ИСПРАВЛЕНО: НЕ обновляем quantity - это изначальное количество заказа!
status: 'in-stock',
},
})
console.warn(
`📈 Обновлен существующий ${
isSellerSupply ? 'расходник селлера' : 'расходник фулфилмента'
} "${item.product.name}" ${
isSellerSupply ? `(владелец: ${updatedOrder.organization?.name})` : ''
}: ${existingSupply.currentStock} -> ${existingSupply.currentStock + item.quantity}`,
)
} else {
await prisma.supply.create({
data: {
name: item.product.name,
article: item.product.article, // ДОБАВЛЕНО: Артикул СФ для уникальности
description: isSellerSupply
? `Расходники селлера ${updatedOrder.organization?.name || updatedOrder.organization?.fullName}`
: item.product.description || `Расходники от ${updatedOrder.partner.name}`,
price: item.price, // Цена закупки у поставщика
quantity: item.quantity,
actualQuantity: item.quantity, // НОВОЕ: Фактически поставленное количество
currentStock: item.quantity,
usedStock: 0,
unit: 'шт',
category: item.product.category?.name || 'Расходники',
status: 'in-stock',
supplier: updatedOrder.partner.name || updatedOrder.partner.fullName || 'Поставщик',
type: supplyType as 'SELLER_CONSUMABLES' | 'FULFILLMENT_CONSUMABLES',
sellerOwnerId: sellerOwnerId,
organizationId: currentUser.organization.id,
},
})
console.warn(
` Создан новый ${
isSellerSupply ? 'расходник селлера' : 'расходник фулфилмента'
} "${item.product.name}" ${
isSellerSupply ? `(владелец: ${updatedOrder.organization?.name})` : ''
}: ${item.quantity} единиц`,
)
}
}
// V2 СИСТЕМА: Инвентарь автоматически обновляется через специализированные резолверы
console.warn('📦 V2 система: склад обновится автоматически через FulfillmentConsumableInventory и SellerConsumableInventory')
console.warn('🎉 Синхронизация склада завершена успешно!')

View File

@ -8,6 +8,7 @@ import { prisma } from '@/lib/prisma'
import { notifyOrganization } from '@/lib/realtime'
import { Context } from '../context'
import { processSellerConsumableSupplyReceipt } from '@/lib/inventory-management'
// =============================================================================
// 🔍 QUERY RESOLVERS
@ -543,6 +544,15 @@ export const sellerConsumableMutations = {
}
if (status === 'DELIVERED') {
// 📦 АВТОМАТИЧЕСКОЕ ПОПОЛНЕНИЕ ИНВЕНТАРЯ V2
const inventoryItems = updatedSupply.items.map(item => ({
productId: item.productId,
receivedQuantity: item.quantity,
unitPrice: parseFloat(item.price.toString()),
}))
await processSellerConsumableSupplyReceipt(args.id, inventoryItems)
await notifyOrganization(
supply.sellerId,
`Поставка доставлена в ${supply.fulfillmentCenter.name}`,

View File

@ -0,0 +1,238 @@
import { GraphQLError } from 'graphql'
import { prisma } from '@/lib/prisma'
import { Context } from '../context'
/**
* НОВЫЙ V2 RESOLVER для складских остатков расходников селлера
*
* Управляет расходниками селлера, хранящимися на складе фулфилмента
* Использует новую модель SellerConsumableInventory
* Возвращает данные в формате Supply для совместимости с фронтендом
*/
export const sellerInventoryV2Queries = {
/**
* Расходники селлера на складе фулфилмента (для селлера)
*/
mySellerConsumableInventory: async (_: unknown, __: unknown, context: Context) => {
console.warn('🚀 V2 SELLER INVENTORY RESOLVER CALLED')
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' },
})
}
try {
const user = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true },
})
if (!user?.organization || user.organization.type !== 'SELLER') {
throw new GraphQLError('Доступно только для селлеров')
}
// Получаем складские остатки расходников селлера из V2 модели
const inventory = await prisma.sellerConsumableInventory.findMany({
where: {
sellerId: user.organizationId || '',
},
include: {
seller: true,
fulfillmentCenter: true,
product: {
include: {
organization: true, // Поставщик товара
},
},
},
orderBy: {
updatedAt: 'desc',
},
})
console.warn('📊 V2 Seller Inventory loaded:', {
sellerId: user.organizationId,
inventoryCount: inventory.length,
items: inventory.map(item => ({
id: item.id,
productName: item.product.name,
currentStock: item.currentStock,
minStock: item.minStock,
fulfillmentCenter: item.fulfillmentCenter.name,
})),
})
// Преобразуем V2 данные в формат Supply для совместимости с фронтендом
const suppliesFormatted = inventory.map((item) => {
// Вычисляем статус на основе остатков
const status = item.currentStock > 0 ? 'На складе' : 'Недоступен'
// Определяем поставщика
const supplier = item.product.organization?.name || 'Неизвестен'
return {
// === ИДЕНТИФИКАЦИЯ (из V2) ===
id: item.id,
productId: item.product.id,
// === ОСНОВНЫЕ ДАННЫЕ (из Product) ===
name: item.product.name,
article: item.product.article,
description: item.product.description || '',
unit: item.product.unit || 'шт',
category: item.product.category || 'Расходники',
imageUrl: item.product.imageUrl,
// === ЦЕНЫ (из V2) ===
price: parseFloat(item.averageCost.toString()),
pricePerUnit: item.usagePrice ? parseFloat(item.usagePrice.toString()) : null,
// === СКЛАДСКИЕ ДАННЫЕ (из V2) ===
currentStock: item.currentStock,
minStock: item.minStock,
usedStock: item.totalUsed || 0, // Всего использовано
quantity: item.totalReceived, // Всего получено
warehouseStock: item.currentStock, // Дублируем для совместимости
reservedStock: item.reservedStock,
// === ИСПОЛЬЗОВАНИЕ (из V2) ===
shippedQuantity: item.totalUsed,
totalShipped: item.totalUsed,
// === СТАТУС И МЕТАДАННЫЕ ===
status,
isAvailable: item.currentStock > 0,
supplier,
date: item.lastSupplyDate?.toISOString() || item.createdAt.toISOString(),
createdAt: item.createdAt.toISOString(),
updatedAt: item.updatedAt.toISOString(),
// === ДОПОЛНИТЕЛЬНЫЕ ПОЛЯ ===
notes: item.notes,
warehouseConsumableId: item.id,
fulfillmentCenter: item.fulfillmentCenter.name, // Где хранится
// === ВЫЧИСЛЯЕМЫЕ ПОЛЯ ===
actualQuantity: item.currentStock, // Фактически доступно
}
})
console.warn('✅ V2 Seller Supplies formatted for frontend:', {
count: suppliesFormatted.length,
totalStock: suppliesFormatted.reduce((sum, item) => sum + item.currentStock, 0),
lowStockItems: suppliesFormatted.filter(item => item.currentStock <= item.minStock).length,
})
return suppliesFormatted
} catch (error) {
console.error('❌ Error in V2 seller inventory resolver:', error)
// Возвращаем пустой массив вместо ошибки для graceful fallback
return []
}
},
/**
* Расходники всех селлеров на складе фулфилмента (для фулфилмента)
*/
allSellerConsumableInventory: async (_: unknown, __: unknown, context: Context) => {
console.warn('🚀 V2 ALL SELLER INVENTORY RESOLVER CALLED')
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' },
})
}
try {
const user = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true },
})
if (!user?.organization || user.organization.type !== 'FULFILLMENT') {
throw new GraphQLError('Доступно только для фулфилмент-центров')
}
// Получаем складские остатки всех селлеров на нашем складе
const inventory = await prisma.sellerConsumableInventory.findMany({
where: {
fulfillmentCenterId: user.organizationId || '',
},
include: {
seller: true,
fulfillmentCenter: true,
product: {
include: {
organization: true, // Поставщик товара
},
},
},
orderBy: [
{ seller: { name: 'asc' } }, // Группируем по селлерам
{ updatedAt: 'desc' },
],
})
console.warn('📊 V2 All Seller Inventory loaded for fulfillment:', {
fulfillmentCenterId: user.organizationId,
inventoryCount: inventory.length,
uniqueSellers: new Set(inventory.map(item => item.sellerId)).size,
})
// Возвращаем данные сгруппированные по селлерам для таблицы "Детализация по магазинам"
return inventory.map((item) => {
const status = item.currentStock > 0 ? 'На складе' : 'Недоступен'
const supplier = item.product.organization?.name || 'Неизвестен'
return {
// === ИДЕНТИФИКАЦИЯ ===
id: item.id,
productId: item.product.id,
sellerId: item.sellerId,
sellerName: item.seller.name,
// === ОСНОВНЫЕ ДАННЫЕ ===
name: item.product.name,
article: item.product.article,
description: item.product.description || '',
unit: item.product.unit || 'шт',
category: item.product.category || 'Расходники',
imageUrl: item.product.imageUrl,
// === СКЛАДСКИЕ ДАННЫЕ ===
currentStock: item.currentStock,
minStock: item.minStock,
usedStock: item.totalUsed || 0,
quantity: item.totalReceived,
reservedStock: item.reservedStock,
// === ЦЕНЫ ===
price: parseFloat(item.averageCost.toString()),
pricePerUnit: item.usagePrice ? parseFloat(item.usagePrice.toString()) : null,
// === МЕТАДАННЫЕ ===
status,
isAvailable: item.currentStock > 0,
supplier,
date: item.lastSupplyDate?.toISOString() || item.createdAt.toISOString(),
createdAt: item.createdAt.toISOString(),
updatedAt: item.updatedAt.toISOString(),
notes: item.notes,
// === СПЕЦИФИЧНЫЕ ПОЛЯ ДЛЯ ФУЛФИЛМЕНТА ===
warehouseConsumableId: item.id,
actualQuantity: item.currentStock,
}
})
} catch (error) {
console.error('❌ Error in V2 all seller inventory resolver:', error)
return []
}
},
}

View File

@ -1985,4 +1985,48 @@ export const typeDefs = gql`
message: String!
order: FulfillmentConsumableSupplyOrder
}
# === V2 SELLER CONSUMABLE INVENTORY SYSTEM ===
# Типы для складского учета расходников селлера на складе фулфилмента
type SellerConsumableInventory {
id: ID!
# Связи
sellerId: ID!
seller: Organization!
fulfillmentCenterId: ID!
fulfillmentCenter: Organization!
productId: ID!
product: Product!
# Складские данные
currentStock: Int!
minStock: Int!
maxStock: Int
reservedStock: Int!
totalReceived: Int!
totalUsed: Int!
# Цены
averageCost: Float!
usagePrice: Float
# Метаданные
lastSupplyDate: DateTime
lastUsageDate: DateTime
notes: String
createdAt: DateTime!
updatedAt: DateTime!
}
# Расширяем Query для складских остатков селлера
extend type Query {
# Мои расходники на складе фулфилмента (для селлера)
mySellerConsumableInventory: [Supply!]! # Возвращаем в формате Supply для совместимости
# Все расходники селлеров на складе (для фулфилмента)
allSellerConsumableInventory: [Supply!]! # Для таблицы "Детализация по магазинам"
}
`

View File

@ -1,4 +1,6 @@
import { prisma } from '@/lib/prisma'
import { Prisma } from '@prisma/client'
import { prisma } from './prisma'
/**
* СИСТЕМА УПРАВЛЕНИЯ СКЛАДСКИМИ ОСТАТКАМИ V2
@ -141,6 +143,51 @@ export async function processSupplyOrderReceipt(
console.log(`✅ Supply order ${supplyOrderId} processed successfully`)
}
/**
* Обрабатывает поступление заказа расходников селлера
* Автоматически пополняет SellerConsumableInventory при статусе DELIVERED
*/
export async function processSellerConsumableSupplyReceipt(
supplyOrderId: string,
items: Array<{
productId: string
receivedQuantity: number
unitPrice: number
}>,
): Promise<void> {
console.log(`🔄 Processing seller consumable supply receipt: ${supplyOrderId}`)
// Получаем информацию о поставке селлера
const supplyOrder = await prisma.sellerConsumableSupplyOrder.findUnique({
where: { id: supplyOrderId },
include: {
seller: true,
fulfillmentCenter: true,
},
})
if (!supplyOrder) {
throw new Error(`Seller supply order not found: ${supplyOrderId}`)
}
// Обрабатываем каждую позицию расходников селлера
for (const item of items) {
await updateSellerInventory({
sellerId: supplyOrder.sellerId,
fulfillmentCenterId: supplyOrder.fulfillmentCenterId,
productId: item.productId,
quantity: item.receivedQuantity,
type: 'INCOMING',
sourceId: supplyOrderId,
sourceType: 'SELLER_SUPPLY_ORDER',
unitCost: item.unitPrice,
notes: `Приемка заказа селлера ${supplyOrderId}`,
})
}
console.log(`✅ Seller consumable supply receipt processed: ${items.length} items`)
}
/**
* Обработка отгрузки селлеру
*/
@ -246,4 +293,94 @@ export async function getInventoryStats(fulfillmentCenterId: string) {
totalShipped: stats._sum.totalShipped || 0,
lowStockCount,
}
}
/**
* Обновляет складские остатки расходников селлера
* Аналог updateInventory, но для SellerConsumableInventory
*/
async function updateSellerInventory(operation: {
sellerId: string
fulfillmentCenterId: string
productId: string
quantity: number
type: 'INCOMING' | 'OUTGOING' | 'USAGE'
sourceId: string
sourceType: 'SELLER_SUPPLY_ORDER' | 'SELLER_USAGE' | 'SELLER_WRITEOFF'
unitCost?: number
notes?: string
}): Promise<void> {
const {
sellerId,
fulfillmentCenterId,
productId,
quantity,
type,
sourceId,
sourceType,
unitCost,
notes,
} = operation
console.log(`📦 Updating seller inventory: ${type} ${quantity} units for product ${productId}`)
// Находим или создаем запись в инвентаре селлера
const existingInventory = await prisma.sellerConsumableInventory.findUnique({
where: {
sellerId_fulfillmentCenterId_productId: {
sellerId,
fulfillmentCenterId,
productId,
},
},
})
if (existingInventory) {
// Обновляем существующую запись
const stockChange = type === 'INCOMING' ? quantity : -quantity
const newCurrentStock = Math.max(0, existingInventory.currentStock + stockChange)
// Пересчитываем среднюю стоимость при поступлении
let newAverageCost = existingInventory.averageCost
if (type === 'INCOMING' && unitCost) {
const totalCost = parseFloat(existingInventory.averageCost.toString()) * existingInventory.totalReceived + unitCost * quantity
const totalQuantity = existingInventory.totalReceived + quantity
newAverageCost = totalQuantity > 0 ? new Prisma.Decimal(totalCost / totalQuantity) : new Prisma.Decimal(0)
}
await prisma.sellerConsumableInventory.update({
where: { id: existingInventory.id },
data: {
currentStock: newCurrentStock,
totalReceived: type === 'INCOMING' ? existingInventory.totalReceived + quantity : existingInventory.totalReceived,
totalUsed: type === 'OUTGOING' || type === 'USAGE' ? existingInventory.totalUsed + quantity : existingInventory.totalUsed,
averageCost: newAverageCost,
lastSupplyDate: type === 'INCOMING' ? new Date() : existingInventory.lastSupplyDate,
lastUsageDate: type === 'OUTGOING' || type === 'USAGE' ? new Date() : existingInventory.lastUsageDate,
notes: notes || existingInventory.notes,
updatedAt: new Date(),
},
})
console.log(`✅ Updated seller inventory: ${existingInventory.id} → stock: ${newCurrentStock}`)
} else if (type === 'INCOMING') {
// Создаем новую запись только при поступлении
const newInventory = await prisma.sellerConsumableInventory.create({
data: {
sellerId,
fulfillmentCenterId,
productId,
currentStock: quantity,
totalReceived: quantity,
totalUsed: 0,
averageCost: new Prisma.Decimal(unitCost || 0),
lastSupplyDate: new Date(),
notes: notes || `Создано при ${sourceType} ${sourceId}`,
},
})
console.log(`✅ Created new seller inventory: ${newInventory.id} → stock: ${quantity}`)
} else {
console.warn(`⚠️ Cannot perform ${type} operation on non-existent seller inventory`)
}
}