Создан единый источник истины rules-complete.md v9.1 с полной интеграцией всех правил системы. Консолидированы правила создания предметов по ролям, уточнен статус брака (НЕ РЕАЛИЗОВАНО), обновлен механизм учета ПЛАН/ФАКТ с заменой брака на потери при пересчете. Добавлен экономический учет расходников фулфилмента для селлера через рецептуру. Удалены дублирующие файлы правил (CLAUDE.md, development-checklist.md, work-protocols.md, violation-prevention-protocol.md, self-validation.md, description.md). Интегрированы UI структуры создания поставок и концепция многоуровневых таблиц.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Veronika Smirnova
2025-08-05 15:29:41 +03:00
parent ee72a9488b
commit d30e3f9666
23 changed files with 2038 additions and 6162 deletions

View File

@ -33,6 +33,10 @@ import {
import {
GET_MY_COUNTERPARTIES,
GET_ORGANIZATION_PRODUCTS,
GET_MY_SERVICES,
GET_MY_SUPPLIES,
GET_SELLER_SUPPLIES_ON_WAREHOUSE,
GET_MY_WILDBERRIES_SUPPLIES,
} from "@/graphql/queries";
import { CREATE_SUPPLY_ORDER } from "@/graphql/mutations";
import { OrganizationAvatar } from "@/components/market/organization-avatar";
@ -102,6 +106,47 @@ interface LogisticsCompany {
type: "EXPRESS" | "STANDARD" | "ECONOMY";
}
// Новые интерфейсы для компонентов рецептуры
interface FulfillmentService {
id: string;
name: string;
description?: string;
price: number;
category?: string;
}
interface FulfillmentConsumable {
id: string;
name: string;
price: number;
stock: number;
unit?: string;
}
interface SellerConsumable {
id: string;
name: string;
stock: number;
unit?: string;
supplierId: string;
}
interface WBCard {
id: string;
title: string;
nmID: string;
vendorCode?: string;
brand?: string;
}
interface ProductRecipe {
productId: string;
selectedServices: string[];
selectedFFConsumables: string[];
selectedSellerConsumables: string[];
selectedWBCard?: string;
}
export function CreateSuppliersSupplyPage() {
const router = useRouter();
const { user } = useAuth();
@ -127,8 +172,9 @@ export function CreateSuppliersSupplyPage() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isCreatingSupply, setIsCreatingSupply] = useState(false);
// Состояние количества товаров для карточек согласно rules2.md 13.3
// Состояния для компонентов рецептуры
const [productRecipes, setProductRecipes] = useState<Record<string, ProductRecipe>>({});
const [productQuantities, setProductQuantities] = useState<Record<string, number>>({});
// Загружаем партнеров-поставщиков согласно rules2.md 13.3
@ -191,9 +237,36 @@ export function CreateSuppliersSupplyPage() {
// Мутация создания поставки
const [createSupplyOrder] = useMutation(CREATE_SUPPLY_ORDER);
// Запросы для компонентов рецептуры
const { data: fulfillmentServicesData } = useQuery(GET_MY_SERVICES, {
skip: !selectedFulfillment,
errorPolicy: 'all'
});
const { data: fulfillmentConsumablesData } = useQuery(GET_MY_SUPPLIES, {
skip: !selectedFulfillment,
errorPolicy: 'all'
});
const { data: sellerConsumablesData } = useQuery(GET_SELLER_SUPPLIES_ON_WAREHOUSE, {
skip: !user?.organization?.id,
errorPolicy: 'all'
});
const { data: wbCardsData } = useQuery(GET_MY_WILDBERRIES_SUPPLIES, {
skip: !user?.organization?.id,
errorPolicy: 'all'
});
// Фильтруем только партнеров-поставщиков согласно rules2.md 13.3
const allCounterparties = counterpartiesData?.myCounterparties || [];
// Извлекаем данные для компонентов рецептуры
const fulfillmentServices: FulfillmentService[] = fulfillmentServicesData?.myServices || [];
const fulfillmentConsumables: FulfillmentConsumable[] = fulfillmentConsumablesData?.mySupplies || [];
const sellerConsumables: SellerConsumable[] = sellerConsumablesData?.sellerSuppliesOnWarehouse || [];
const wbCards: WBCard[] = (wbCardsData?.myWildberriesSupplies || []).flatMap((supply: any) => supply.cards || []);
// Показываем только партнеров с типом WHOLESALE согласно rules2.md 13.3
const wholesaleSuppliers = allCounterparties.filter((cp: any) => {
try {
@ -345,6 +418,106 @@ export function CreateSuppliersSupplyPage() {
setIsModalOpen(true);
};
// Функции для работы с рецептурой
const initializeProductRecipe = (productId: string) => {
if (!productRecipes[productId]) {
setProductRecipes(prev => ({
...prev,
[productId]: {
productId,
selectedServices: [],
selectedFFConsumables: [],
selectedSellerConsumables: [],
selectedWBCard: undefined
}
}));
}
};
const toggleService = (productId: string, serviceId: string) => {
initializeProductRecipe(productId);
setProductRecipes(prev => {
const recipe = prev[productId];
const isSelected = recipe.selectedServices.includes(serviceId);
return {
...prev,
[productId]: {
...recipe,
selectedServices: isSelected
? recipe.selectedServices.filter(id => id !== serviceId)
: [...recipe.selectedServices, serviceId]
}
};
});
};
const toggleFFConsumable = (productId: string, consumableId: string) => {
initializeProductRecipe(productId);
setProductRecipes(prev => {
const recipe = prev[productId];
const isSelected = recipe.selectedFFConsumables.includes(consumableId);
return {
...prev,
[productId]: {
...recipe,
selectedFFConsumables: isSelected
? recipe.selectedFFConsumables.filter(id => id !== consumableId)
: [...recipe.selectedFFConsumables, consumableId]
}
};
});
};
const toggleSellerConsumable = (productId: string, consumableId: string) => {
initializeProductRecipe(productId);
setProductRecipes(prev => {
const recipe = prev[productId];
const isSelected = recipe.selectedSellerConsumables.includes(consumableId);
return {
...prev,
[productId]: {
...recipe,
selectedSellerConsumables: isSelected
? recipe.selectedSellerConsumables.filter(id => id !== consumableId)
: [...recipe.selectedSellerConsumables, consumableId]
}
};
});
};
const setWBCard = (productId: string, cardId: string) => {
initializeProductRecipe(productId);
setProductRecipes(prev => ({
...prev,
[productId]: {
...prev[productId],
selectedWBCard: cardId
}
}));
};
// Расчет стоимости компонентов рецептуры
const calculateRecipeCost = (productId: string) => {
const recipe = productRecipes[productId];
if (!recipe) return { services: 0, consumables: 0, total: 0 };
const servicesTotal = recipe.selectedServices.reduce((sum, serviceId) => {
const service = fulfillmentServices.find(s => s.id === serviceId);
return sum + (service?.price || 0);
}, 0);
const consumablesTotal = recipe.selectedFFConsumables.reduce((sum, consumableId) => {
const consumable = fulfillmentConsumables.find(c => c.id === consumableId);
return sum + (consumable?.price || 0);
}, 0);
return {
services: servicesTotal,
consumables: consumablesTotal,
total: servicesTotal + consumablesTotal
};
};
// Добавление товара в корзину из модального окна с дополнительными данными
const addToCartFromModal = (
product: GoodsProduct,
@ -418,7 +591,7 @@ export function CreateSuppliersSupplyPage() {
const totalQuantity = selectedGoods.reduce((sum, item) => sum + item.selectedQuantity, 0);
const fulfillmentFee = totalGoodsAmount * 0.08; // 8% комиссия фулфилмента
const selectedLogisticsCompany = logisticsCompanies.find(lc => lc.id === selectedLogistics);
const logisticsCost = selectedLogistics === "auto" ? 1000 : (selectedLogisticsCompany?.estimatedCost || 0);
const logisticsCost = selectedLogistics === "auto" ? 0 : (selectedLogisticsCompany?.estimatedCost || 0);
const totalAmount = totalGoodsAmount + fulfillmentFee + logisticsCost;
// Валидация формы согласно rules2.md 9.7.6
@ -475,14 +648,14 @@ export function CreateSuppliersSupplyPage() {
return (
<div className="h-screen flex overflow-hidden">
<Sidebar />
<main className={`flex-1 ${getSidebarMargin()} overflow-hidden transition-all duration-300`}>
<div className="h-full flex flex-col">
<main className={`flex-1 ${getSidebarMargin()} overflow-hidden transition-all duration-300 p-4`}>
<div className="h-full flex flex-col gap-4">
{/* СТРУКТУРА ИЗ 3 БЛОКОВ согласно rules1.md 19.2.1 - блоки точно по уровню сайдбара */}
<div className="flex-1 flex gap-2 min-h-0">
<div className="flex-1 flex gap-4 min-h-0">
{/* ЛЕВЫЙ БЛОК: ПОСТАВЩИКИ И ТОВАРЫ */}
<div className="flex-1 flex flex-col gap-2 min-h-0">
<div className="flex-1 flex flex-col gap-4 min-h-0">
{/* БЛОК 1: ПОСТАВЩИКИ - обязательный блок согласно rules1.md 19.2.1 */}
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl flex-shrink-0 flex flex-col"
@ -702,107 +875,279 @@ export function CreateSuppliersSupplyPage() {
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="space-y-4">
{products.map((product: GoodsProduct) => (
<div
key={product.id}
className="glass-card hover:border-white/20 hover:bg-white/10 hover:scale-105 hover:shadow-xl hover:shadow-blue-500/20 transition-all duration-200 cursor-pointer group"
className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl p-4 hover:border-white/30 transition-all duration-200"
>
<div className="p-5 space-y-4">
{product.mainImage && (
<div className="w-full h-40 rounded-lg overflow-hidden bg-white/5">
<Image
src={product.mainImage}
alt={product.name}
width={280}
height={160}
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-300"
/>
</div>
)}
{/* ОСНОВНОЙ БЛОК: Информация о товаре + количество + сумма */}
<div className="flex items-start gap-6 mb-4">
<div className="space-y-3">
<div>
<h4 className="text-white font-semibold text-base line-clamp-2 group-hover:text-white transition-colors">
{product.name}
</h4>
<p className="text-white/60 text-sm font-mono mt-1">Артикул: {product.article}</p>
</div>
{product.category && (
<Badge className="bg-blue-500/20 text-blue-300 border border-blue-500/30 text-xs font-medium">
{product.category.name}
</Badge>
)}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-white font-bold text-lg">
{product.price.toLocaleString('ru-RU')}
</p>
<p className="text-white/60 text-xs">за единицу</p>
{/* ЛЕВЫЙ БЛОК: Изображение + основная информация */}
<div className="flex items-start gap-4 flex-1">
<div className="w-24 h-24 bg-white/5 rounded-lg overflow-hidden flex-shrink-0">
{product.mainImage ? (
<Image
src={product.mainImage}
alt={product.name}
width={96}
height={96}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Package className="h-8 w-8 text-white/40" />
</div>
)}
</div>
<div className="flex-1">
<h4 className="text-white font-semibold text-lg mb-1">{product.name}</h4>
<p className="text-white/60 text-sm mb-2 font-mono">Артикул: {product.article}</p>
{product.category && (
<Badge className="bg-blue-500/20 text-blue-300 border border-blue-500/30 text-xs font-medium mb-2">
{product.category.name}
</Badge>
)}
<div className="flex items-center gap-4">
<span className="text-white font-bold text-xl">{product.price.toLocaleString('ru-RU')} </span>
{product.quantity !== undefined && (
<div className="flex items-center gap-1">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${
product.quantity > 0 ? 'bg-green-400' : 'bg-red-400'
}`}></div>
<p className={`text-xs font-medium ${
<span className={`text-sm font-medium ${
product.quantity > 0 ? 'text-green-400' : 'text-red-400'
}`}>
{product.quantity > 0
? `Доступно: ${product.quantity}`
: 'Нет в наличии'
}
</p>
</span>
</div>
)}
</div>
{/* Поле количества с кнопками +/- согласно rules2.md 13.3 */}
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => updateProductQuantity(product.id, -1)}
className="h-8 w-8 p-0 bg-white/5 border-white/20 hover:bg-white/10 hover:border-white/30 text-white"
disabled={!getProductQuantity(product.id) || getProductQuantity(product.id) <= 0}
>
<Minus className="h-3 w-3" />
</Button>
<Input
type="number"
min="0"
max={product.quantity}
value={getProductQuantity(product.id) || ""}
onChange={(e) => setProductQuantity(product.id, parseInt(e.target.value) || 0)}
className="h-8 w-20 text-center bg-white/5 border-white/20 text-white placeholder:text-white/40 focus:border-white/40"
placeholder="00000"
/>
<Button
size="sm"
variant="outline"
onClick={() => updateProductQuantity(product.id, 1)}
className="h-8 w-8 p-0 bg-white/5 border-white/20 hover:bg-white/10 hover:border-white/30 text-white"
disabled={product.quantity === 0 || getProductQuantity(product.id) >= product.quantity}
>
<Plus className="h-3 w-3" />
</Button>
</div>
</div>
</div>
{/* ПРАВЫЙ БЛОК: Количество + общая сумма */}
<div className="flex items-center gap-4 flex-shrink-0">
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={() => addToCart(product)}
className="w-full bg-gradient-to-r from-green-500 to-emerald-500 hover:from-green-600 hover:to-emerald-600 text-white border border-green-500/30 hover:border-green-400/50 transition-all duration-200 h-8 text-xs"
disabled={product.quantity === 0 || !getProductQuantity(product.id)}
variant="outline"
onClick={() => updateProductQuantity(product.id, -1)}
className="h-8 w-8 p-0 bg-white/5 border-white/20 hover:bg-white/10 hover:border-white/30 text-white"
disabled={!getProductQuantity(product.id) || getProductQuantity(product.id) <= 0}
>
<ShoppingCart className="h-3 w-3 mr-1" />
В корзину
<Minus className="h-3 w-3" />
</Button>
<Input
type="number"
min="0"
max={product.quantity}
value={getProductQuantity(product.id) || ""}
onChange={(e) => setProductQuantity(product.id, parseInt(e.target.value) || 0)}
className="h-8 w-20 text-center bg-white/5 border-white/20 text-white placeholder:text-white/40 focus:border-white/40"
placeholder="0"
/>
<Button
size="sm"
variant="outline"
onClick={() => updateProductQuantity(product.id, 1)}
className="h-8 w-8 p-0 bg-white/5 border-white/20 hover:bg-white/10 hover:border-white/30 text-white"
disabled={product.quantity === 0 || getProductQuantity(product.id) >= (product.quantity || 0)}
>
<Plus className="h-3 w-3" />
</Button>
</div>
{getProductQuantity(product.id) > 0 && (
<div className="bg-gradient-to-r from-green-500/10 to-emerald-500/10 border border-green-500/20 rounded-lg px-4 py-2">
<span className="text-green-400 font-bold text-lg">
{(product.price * getProductQuantity(product.id)).toLocaleString('ru-RU')}
</span>
</div>
)}
</div>
</div>
{/* БЛОК РЕЦЕПТУРЫ: 4 колонки с чекбоксами */}
<div className="grid grid-cols-4 gap-4 pt-4 border-t border-white/10 mb-4">
{/* КОЛОНКА 1: Услуги фулфилмента */}
<div className="space-y-2">
<h5 className="text-white/80 font-medium text-sm flex items-center gap-2">
<Settings className="h-4 w-4 text-purple-400" />
Услуги ФФ
</h5>
<div className="space-y-1 max-h-32 overflow-y-auto">
{fulfillmentServices.length > 0 ? fulfillmentServices.map(service => {
const recipe = productRecipes[product.id];
const isSelected = recipe?.selectedServices.includes(service.id) || false;
return (
<label key={service.id} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-white/5 p-1 rounded">
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleService(product.id, service.id)}
className="w-3 h-3 rounded bg-white/10 border-white/20 text-purple-400 focus:ring-purple-400/50 focus:ring-offset-0"
/>
<span className="text-white/70 flex-1 truncate">{service.name}</span>
<span className="text-purple-400 text-xs font-medium">{service.price.toLocaleString('ru-RU')}</span>
</label>
);
}) : (
<div className="text-white/50 text-xs p-2 bg-white/5 rounded border border-white/10">
{selectedFulfillment ? 'Услуги загружаются...' : 'Выберите фулфилмент-центр'}
</div>
)}
</div>
</div>
{/* КОЛОНКА 2: Расходники фулфилмента */}
<div className="space-y-2">
<h5 className="text-white/80 font-medium text-sm flex items-center gap-2">
<Box className="h-4 w-4 text-orange-400" />
Расходники ФФ
</h5>
<div className="space-y-1 max-h-32 overflow-y-auto">
{fulfillmentConsumables.length > 0 ? fulfillmentConsumables.map(consumable => {
const recipe = productRecipes[product.id];
const isSelected = recipe?.selectedFFConsumables.includes(consumable.id) || false;
return (
<label key={consumable.id} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-white/5 p-1 rounded">
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleFFConsumable(product.id, consumable.id)}
className="w-3 h-3 rounded bg-white/10 border-white/20 text-orange-400 focus:ring-orange-400/50 focus:ring-offset-0"
/>
<span className="text-white/70 flex-1 truncate">{consumable.name}</span>
<span className="text-orange-400 text-xs font-medium">{consumable.price.toLocaleString('ru-RU')}</span>
</label>
);
}) : (
<div className="text-white/50 text-xs p-2 bg-white/5 rounded border border-white/10">
{selectedFulfillment ? 'Расходники загружаются...' : 'Выберите фулфилмент-центр'}
</div>
)}
</div>
</div>
{/* КОЛОНКА 3: Расходники селлера */}
<div className="space-y-2">
<h5 className="text-white/80 font-medium text-sm flex items-center gap-2">
<Package className="h-4 w-4 text-blue-400" />
Расходники селлера
</h5>
<div className="space-y-1 max-h-32 overflow-y-auto">
{sellerConsumables.length > 0 ? sellerConsumables.map(consumable => {
const recipe = productRecipes[product.id];
const isSelected = recipe?.selectedSellerConsumables.includes(consumable.id) || false;
return (
<label key={consumable.id} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-white/5 p-1 rounded">
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleSellerConsumable(product.id, consumable.id)}
className="w-3 h-3 rounded bg-white/10 border-white/20 text-blue-400 focus:ring-blue-400/50 focus:ring-offset-0"
/>
<span className="text-white/70 flex-1 truncate">{consumable.name}</span>
<span className="text-blue-400 text-xs">Склад: {consumable.stock}</span>
</label>
);
}) : (
<div className="text-white/50 text-xs p-2 bg-white/5 rounded border border-white/10">
Расходники селлера загружаются...
</div>
)}
</div>
</div>
{/* КОЛОНКА 4: Карточки Wildberries */}
<div className="space-y-2">
<h5 className="text-white/80 font-medium text-sm flex items-center gap-2">
<ShoppingCart className="h-4 w-4 text-pink-400" />
Карточки WB
</h5>
<div className="space-y-1 max-h-32 overflow-y-auto">
{wbCards.length > 0 ? wbCards.map(card => {
const recipe = productRecipes[product.id];
const isSelected = recipe?.selectedWBCard === card.id;
return (
<label key={card.id} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-white/5 p-1 rounded">
<input
type="radio"
name={`wb-card-${product.id}`}
checked={isSelected}
onChange={() => setWBCard(product.id, card.id)}
className="w-3 h-3 rounded-full bg-white/10 border-white/20 text-pink-400 focus:ring-pink-400/50 focus:ring-offset-0"
/>
<span className="text-white/70 flex-1 truncate">{card.title}</span>
<span className="text-pink-400 text-xs">{card.nmID}</span>
</label>
);
}) : (
<div className="text-white/50 text-xs p-2 bg-white/5 rounded border border-white/10">
Карточки WB загружаются...
</div>
)}
</div>
</div>
</div>
{/* НИЖНИЙ БЛОК: Итоговая стоимость рецептуры + кнопка добавления */}
<div className="flex items-center justify-between pt-4 border-t border-white/10">
<div className="flex items-center gap-6">
{(() => {
const quantity = getProductQuantity(product.id);
const recipeCost = calculateRecipeCost(product.id);
const productTotal = product.price * quantity;
const totalRecipePrice = productTotal + recipeCost.total;
return (
<>
<div className="text-sm text-white/70">
Товар: <span className="text-white font-semibold">{productTotal.toLocaleString('ru-RU')} </span>
</div>
<div className="text-sm text-white/70">
Услуги: <span className="text-purple-400 font-semibold">{recipeCost.services.toLocaleString('ru-RU')} </span>
</div>
<div className="text-sm text-white/70">
Расходники: <span className="text-orange-400 font-semibold">{recipeCost.consumables.toLocaleString('ru-RU')} </span>
</div>
</>
);
})()}
</div>
<div className="flex items-center gap-4">
{(() => {
const quantity = getProductQuantity(product.id);
const recipeCost = calculateRecipeCost(product.id);
const productTotal = product.price * quantity;
const totalRecipePrice = productTotal + recipeCost.total;
return (
<>
<div className="bg-gradient-to-r from-green-500/20 to-emerald-500/20 border border-green-500/30 rounded-lg px-4 py-2">
<span className="text-white/70 text-sm">Итого: </span>
<span className="text-green-400 font-bold text-lg">
{totalRecipePrice.toLocaleString('ru-RU')}
</span>
</div>
<Button
onClick={() => addToCart(product)}
disabled={quantity === 0}
className="bg-gradient-to-r from-green-500 to-emerald-500 hover:from-green-600 hover:to-emerald-600 text-white border border-green-500/30 hover:border-green-400/50 transition-all duration-200"
>
<ShoppingCart className="h-4 w-4 mr-2" />
Добавить рецептуру
</Button>
</>
);
})()}
</div>
</div>
</div>