Добавлено новое поле consumableType в модель SupplyOrder для классификации расходников. Обновлены компоненты и резолверы GraphQL для поддержки нового поля. Реализована валидация остатков и обновление данных о запасах при создании и отклонении заказов. Оптимизирован интерфейс для управления расходниками, добавлены уведомления о доступности товаров.
This commit is contained in:
@ -483,6 +483,7 @@ model SupplyOrder {
|
|||||||
totalItems Int
|
totalItems Int
|
||||||
fulfillmentCenterId String?
|
fulfillmentCenterId String?
|
||||||
logisticsPartnerId String
|
logisticsPartnerId String
|
||||||
|
consumableType String? // Классификация расходников: FULFILLMENT_CONSUMABLES, SELLER_CONSUMABLES
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
organizationId String
|
organizationId String
|
||||||
|
@ -29,6 +29,7 @@ import {
|
|||||||
GET_ALL_PRODUCTS,
|
GET_ALL_PRODUCTS,
|
||||||
GET_SUPPLY_ORDERS,
|
GET_SUPPLY_ORDERS,
|
||||||
GET_MY_SUPPLIES,
|
GET_MY_SUPPLIES,
|
||||||
|
GET_MY_FULFILLMENT_SUPPLIES,
|
||||||
} from "@/graphql/queries";
|
} from "@/graphql/queries";
|
||||||
import { CREATE_SUPPLY_ORDER } from "@/graphql/mutations";
|
import { CREATE_SUPPLY_ORDER } from "@/graphql/mutations";
|
||||||
import { OrganizationAvatar } from "@/components/market/organization-avatar";
|
import { OrganizationAvatar } from "@/components/market/organization-avatar";
|
||||||
@ -129,10 +130,12 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Фильтруем товары по выбранному поставщику
|
// Фильтруем товары по выбранному поставщику
|
||||||
|
// 📦 ТОЛЬКО РАСХОДНИКИ согласно правилам (раздел 2.1)
|
||||||
const supplierProducts = selectedSupplier
|
const supplierProducts = selectedSupplier
|
||||||
? (productsData?.allProducts || []).filter(
|
? (productsData?.allProducts || []).filter(
|
||||||
(product: FulfillmentConsumableProduct) =>
|
(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;
|
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) => {
|
setSelectedConsumables((prev) => {
|
||||||
const existing = prev.find((p) => p.id === productId);
|
const existing = prev.find((p) => p.id === productId);
|
||||||
|
|
||||||
@ -267,6 +280,8 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
|||||||
// Для фулфилмента указываем себя как получателя (поставка на свой склад)
|
// Для фулфилмента указываем себя как получателя (поставка на свой склад)
|
||||||
fulfillmentCenterId: user?.organization?.id,
|
fulfillmentCenterId: user?.organization?.id,
|
||||||
logisticsPartnerId: selectedLogistics?.id,
|
logisticsPartnerId: selectedLogistics?.id,
|
||||||
|
// 🏷️ КЛАССИФИКАЦИЯ согласно правилам (раздел 2.2)
|
||||||
|
consumableType: "FULFILLMENT_CONSUMABLES", // Расходники фулфилмента
|
||||||
items: selectedConsumables.map((consumable) => ({
|
items: selectedConsumables.map((consumable) => ({
|
||||||
productId: consumable.id,
|
productId: consumable.id,
|
||||||
quantity: consumable.selectedQuantity,
|
quantity: consumable.selectedQuantity,
|
||||||
@ -276,6 +291,7 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
|||||||
refetchQueries: [
|
refetchQueries: [
|
||||||
{ query: GET_SUPPLY_ORDERS }, // Обновляем заказы поставок
|
{ query: GET_SUPPLY_ORDERS }, // Обновляем заказы поставок
|
||||||
{ query: GET_MY_SUPPLIES }, // Обновляем расходники фулфилмента
|
{ 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">
|
<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="p-3 bg-gradient-to-r from-purple-500/10 to-pink-500/10">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<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" />
|
<Building2 className="h-5 w-5 mr-3 text-purple-400" />
|
||||||
Поставщики расходников
|
Поставщики расходников
|
||||||
</h2>
|
</h2>
|
||||||
@ -556,6 +572,23 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
|||||||
<div className="space-y-2 h-full flex flex-col">
|
<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">
|
<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 &&
|
||||||
product.images.length > 0 &&
|
product.images.length > 0 &&
|
||||||
product.images[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">
|
<h3 className="text-white font-medium text-sm leading-tight line-clamp-2 group-hover:text-purple-200 transition-colors duration-300">
|
||||||
{product.name}
|
{product.name}
|
||||||
</h3>
|
</h3>
|
||||||
{product.category && (
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<Badge className="bg-purple-500/20 text-purple-300 border-purple-500/30 text-xs px-2 py-1">
|
{product.category && (
|
||||||
{product.category.name.slice(0, 10)}
|
<Badge className="bg-purple-500/20 text-purple-300 border-purple-500/30 text-xs px-2 py-1">
|
||||||
</Badge>
|
{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">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-green-400 font-semibold text-sm">
|
<span className="text-green-400 font-semibold text-sm">
|
||||||
{formatCurrency(product.price)}
|
{formatCurrency(product.price)}
|
||||||
</span>
|
</span>
|
||||||
{product.stock && (
|
{/* 📊 АКТУАЛЬНЫЙ ОСТАТОК согласно правилам (раздел 6.4.2) */}
|
||||||
<span className="text-white/60 text-xs">
|
<div className="text-right">
|
||||||
{product.stock}
|
{(() => {
|
||||||
</span>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Управление количеством */}
|
{/* Управление количеством */}
|
||||||
<div className="flex flex-col items-center space-y-2 mt-auto">
|
<div className="flex flex-col items-center space-y-2 mt-auto">
|
||||||
<div className="flex items-center space-x-2">
|
{(() => {
|
||||||
<Button
|
const totalStock = product.stock || product.quantity || 0;
|
||||||
variant="ghost"
|
const orderedStock = product.ordered || 0;
|
||||||
size="sm"
|
const availableStock = totalStock - orderedStock;
|
||||||
onClick={() =>
|
|
||||||
updateConsumableQuantity(
|
return (
|
||||||
product.id,
|
<div className="flex items-center space-x-2">
|
||||||
Math.max(0, selectedQuantity - 1)
|
<Button
|
||||||
)
|
variant="ghost"
|
||||||
}
|
size="sm"
|
||||||
className="h-6 w-6 p-0 text-white/60 hover:text-white hover:bg-white/20 rounded-full transition-all duration-300"
|
onClick={() =>
|
||||||
disabled={selectedQuantity === 0}
|
updateConsumableQuantity(
|
||||||
>
|
product.id,
|
||||||
<Minus className="h-3 w-3" />
|
Math.max(0, selectedQuantity - 1)
|
||||||
</Button>
|
)
|
||||||
|
}
|
||||||
|
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
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
@ -659,9 +743,10 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
|||||||
? 0
|
? 0
|
||||||
: parseInt(inputValue);
|
: parseInt(inputValue);
|
||||||
|
|
||||||
// Ограничиваем значение максимумом 99999
|
// Ограничиваем значение максимумом доступного остатка
|
||||||
const clampedValue = Math.min(
|
const clampedValue = Math.min(
|
||||||
numericValue,
|
numericValue,
|
||||||
|
availableStock,
|
||||||
99999
|
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"
|
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"
|
placeholder="0"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
updateConsumableQuantity(
|
updateConsumableQuantity(
|
||||||
product.id,
|
product.id,
|
||||||
Math.min(selectedQuantity + 1, 99999)
|
Math.min(selectedQuantity + 1, availableStock, 99999)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className="h-6 w-6 p-0 text-white/60 hover:text-white hover:bg-white/20 rounded-full transition-all duration-300"
|
className={`h-6 w-6 p-0 rounded-full transition-all duration-300 ${
|
||||||
>
|
selectedQuantity >= availableStock || availableStock <= 0
|
||||||
<Plus className="h-3 w-3" />
|
? 'text-white/30 cursor-not-allowed'
|
||||||
</Button>
|
: 'text-white/60 hover:text-white hover:bg-white/20'
|
||||||
</div>
|
}`}
|
||||||
|
disabled={selectedQuantity >= availableStock || availableStock <= 0}
|
||||||
|
title={
|
||||||
|
availableStock <= 0
|
||||||
|
? 'Товар отсутствует на складе'
|
||||||
|
: selectedQuantity >= availableStock
|
||||||
|
? `Максимум доступно: ${availableStock}`
|
||||||
|
: 'Увеличить количество'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{selectedQuantity > 0 && (
|
{selectedQuantity > 0 && (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
|
@ -3829,6 +3829,7 @@ export const resolvers = {
|
|||||||
logisticsPartnerId?: string; // ID логистической компании
|
logisticsPartnerId?: string; // ID логистической компании
|
||||||
items: Array<{ productId: string; quantity: number }>;
|
items: Array<{ productId: string; quantity: number }>;
|
||||||
notes?: string; // Дополнительные заметки к заказу
|
notes?: string; // Дополнительные заметки к заказу
|
||||||
|
consumableType?: string; // Классификация расходников
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
context: Context
|
context: Context
|
||||||
@ -3983,6 +3984,7 @@ export const resolvers = {
|
|||||||
organizationId: currentUser.organization.id,
|
organizationId: currentUser.organization.id,
|
||||||
fulfillmentCenterId: fulfillmentCenterId,
|
fulfillmentCenterId: fulfillmentCenterId,
|
||||||
logisticsPartnerId: args.input.logisticsPartnerId,
|
logisticsPartnerId: args.input.logisticsPartnerId,
|
||||||
|
consumableType: args.input.consumableType, // Классификация расходников
|
||||||
status: initialStatus,
|
status: initialStatus,
|
||||||
items: {
|
items: {
|
||||||
create: orderItems,
|
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) => {
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: args.reason
|
message: args.reason
|
||||||
|
@ -629,6 +629,7 @@ export const typeDefs = gql`
|
|||||||
logisticsPartnerId: ID! # ID логистической компании (обязательно)
|
logisticsPartnerId: ID! # ID логистической компании (обязательно)
|
||||||
items: [SupplyOrderItemInput!]!
|
items: [SupplyOrderItemInput!]!
|
||||||
notes: String # Дополнительные заметки к заказу
|
notes: String # Дополнительные заметки к заказу
|
||||||
|
consumableType: String # Классификация расходников: FULFILLMENT_CONSUMABLES, SELLER_CONSUMABLES
|
||||||
}
|
}
|
||||||
|
|
||||||
input SupplyOrderItemInput {
|
input SupplyOrderItemInput {
|
||||||
|
Reference in New Issue
Block a user