Добавлено новое поле consumableType в модель SupplyOrder для классификации расходников. Обновлены компоненты и резолверы GraphQL для поддержки нового поля. Реализована валидация остатков и обновление данных о запасах при создании и отклонении заказов. Оптимизирован интерфейс для управления расходниками, добавлены уведомления о доступности товаров.

This commit is contained in:
Bivekich
2025-08-01 12:39:49 +03:00
parent b095b3a5a7
commit 80d33b46b8
4 changed files with 178 additions and 42 deletions

View File

@ -483,6 +483,7 @@ model SupplyOrder {
totalItems Int
fulfillmentCenterId String?
logisticsPartnerId String
consumableType String? // Классификация расходников: FULFILLMENT_CONSUMABLES, SELLER_CONSUMABLES
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organizationId String

View File

@ -29,6 +29,7 @@ import {
GET_ALL_PRODUCTS,
GET_SUPPLY_ORDERS,
GET_MY_SUPPLIES,
GET_MY_FULFILLMENT_SUPPLIES,
} from "@/graphql/queries";
import { CREATE_SUPPLY_ORDER } from "@/graphql/mutations";
import { OrganizationAvatar } from "@/components/market/organization-avatar";
@ -129,10 +130,12 @@ export function CreateFulfillmentConsumablesSupplyPage() {
);
// Фильтруем товары по выбранному поставщику
// 📦 ТОЛЬКО РАСХОДНИКИ согласно правилам (раздел 2.1)
const supplierProducts = selectedSupplier
? (productsData?.allProducts || []).filter(
(product: FulfillmentConsumableProduct) =>
product.organization.id === selectedSupplier.id
product.organization.id === selectedSupplier.id &&
product.type === "CONSUMABLE" // Только расходники для фулфилмента
)
: [];
@ -193,6 +196,16 @@ export function CreateFulfillmentConsumablesSupplyPage() {
);
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);
@ -267,6 +280,8 @@ export function CreateFulfillmentConsumablesSupplyPage() {
// Для фулфилмента указываем себя как получателя (поставка на свой склад)
fulfillmentCenterId: user?.organization?.id,
logisticsPartnerId: selectedLogistics?.id,
// 🏷️ КЛАССИФИКАЦИЯ согласно правилам (раздел 2.2)
consumableType: "FULFILLMENT_CONSUMABLES", // Расходники фулфилмента
items: selectedConsumables.map((consumable) => ({
productId: consumable.id,
quantity: consumable.selectedQuantity,
@ -276,6 +291,7 @@ export function CreateFulfillmentConsumablesSupplyPage() {
refetchQueries: [
{ query: GET_SUPPLY_ORDERS }, // Обновляем заказы поставок
{ query: GET_MY_SUPPLIES }, // Обновляем расходники фулфилмента
{ query: GET_MY_FULFILLMENT_SUPPLIES }, // 📊 Обновляем модуль учета расходников фулфилмента
],
});
@ -341,7 +357,7 @@ export function CreateFulfillmentConsumablesSupplyPage() {
<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 text-white flex items-center flex-shrink-0 bg-gradient-to-r from-purple-400 to-pink-400 bg-clip-text text-transparent">
<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>
@ -556,6 +572,23 @@ export function CreateFulfillmentConsumablesSupplyPage() {
<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.quantity || 0;
const orderedStock = product.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] ? (
@ -595,40 +628,91 @@ export function CreateFulfillmentConsumablesSupplyPage() {
<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>
{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>
)}
<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>
{product.stock && (
<span className="text-white/60 text-xs">
{product.stock}
</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">
<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>
{(() => {
const totalStock = product.stock || product.quantity || 0;
const orderedStock = product.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"
@ -659,9 +743,10 @@ export function CreateFulfillmentConsumablesSupplyPage() {
? 0
: parseInt(inputValue);
// Ограничиваем значение максимумом 99999
// Ограничиваем значение максимумом доступного остатка
const clampedValue = Math.min(
numericValue,
availableStock,
99999
);
@ -682,20 +767,34 @@ export function CreateFulfillmentConsumablesSupplyPage() {
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, 99999)
)
}
className="h-6 w-6 p-0 text-white/60 hover:text-white hover:bg-white/20 rounded-full transition-all duration-300"
>
<Plus className="h-3 w-3" />
</Button>
</div>
<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">

View File

@ -3829,6 +3829,7 @@ export const resolvers = {
logisticsPartnerId?: string; // ID логистической компании
items: Array<{ productId: string; quantity: number }>;
notes?: string; // Дополнительные заметки к заказу
consumableType?: string; // Классификация расходников
};
},
context: Context
@ -3983,6 +3984,7 @@ export const resolvers = {
organizationId: currentUser.organization.id,
fulfillmentCenterId: fulfillmentCenterId,
logisticsPartnerId: args.input.logisticsPartnerId,
consumableType: args.input.consumableType, // Классификация расходников
status: initialStatus,
items: {
create: orderItems,
@ -4022,6 +4024,23 @@ export const resolvers = {
},
});
// 📦 РЕЗЕРВИРУЕМ ТОВАРЫ У ПОСТАВЩИКА
// Увеличиваем поле "ordered" для каждого заказанного товара
for (const item of args.input.items) {
await prisma.product.update({
where: { id: item.productId },
data: {
ordered: {
increment: item.quantity,
},
},
});
}
console.log(`📦 Зарезервированы товары для заказа ${supplyOrder.id}:`,
args.input.items.map(item => `${item.productId}: +${item.quantity} шт.`).join(', '));
// Создаем расходники на основе заказанных товаров
// Расходники создаются в организации получателя (фулфилмент-центре)
const suppliesData = args.input.items.map((item) => {
@ -6109,6 +6128,22 @@ export const resolvers = {
},
});
// 📦 СНИМАЕМ РЕЗЕРВАЦИЮ ПРИ ОТКЛОНЕНИИ
// Уменьшаем поле "ordered" для каждого отклоненного товара
for (const item of updatedOrder.items) {
await prisma.product.update({
where: { id: item.productId },
data: {
ordered: {
decrement: item.quantity,
},
},
});
}
console.log(`📦 Снята резервация при отклонении заказа ${updatedOrder.id}:`,
updatedOrder.items.map(item => `${item.productId}: -${item.quantity} шт.`).join(', '));
return {
success: true,
message: args.reason

View File

@ -629,6 +629,7 @@ export const typeDefs = gql`
logisticsPartnerId: ID! # ID логистической компании (обязательно)
items: [SupplyOrderItemInput!]!
notes: String # Дополнительные заметки к заказу
consumableType: String # Классификация расходников: FULFILLMENT_CONSUMABLES, SELLER_CONSUMABLES
}
input SupplyOrderItemInput {