Добавлено новое поле consumableType в модель SupplyOrder для классификации расходников. Обновлены компоненты и резолверы GraphQL для поддержки нового поля. Реализована валидация остатков и обновление данных о запасах при создании и отклонении заказов. Оптимизирован интерфейс для управления расходниками, добавлены уведомления о доступности товаров.
This commit is contained in:
@ -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
|
||||
|
@ -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(`❌ Недостаточно остатков!\nДоступно: ${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">
|
||||
|
@ -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
|
||||
|
@ -629,6 +629,7 @@ export const typeDefs = gql`
|
||||
logisticsPartnerId: ID! # ID логистической компании (обязательно)
|
||||
items: [SupplyOrderItemInput!]!
|
||||
notes: String # Дополнительные заметки к заказу
|
||||
consumableType: String # Классификация расходников: FULFILLMENT_CONSUMABLES, SELLER_CONSUMABLES
|
||||
}
|
||||
|
||||
input SupplyOrderItemInput {
|
||||
|
Reference in New Issue
Block a user