feat: реализовать V2 backend для товарных поставок селлера

- Создать модели: SellerGoodsSupplyOrder, SellerGoodsInventory, GoodsSupplyRecipeItem
- Реализовать полные GraphQL resolvers с валидацией и авторизацией
- Добавить автоматическое создание инвентаря при статусе DELIVERED
- Внедрить нормализованную рецептуру с RecipeType enum
- Подготовить функции для будущих отгрузок на маркетплейсы
- Интегрировать V2 resolvers модульно в основную схему
- Протестировать создание таблиц в БД

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Veronika Smirnova
2025-09-01 14:39:33 +03:00
parent be891f5354
commit a5816518be
9 changed files with 2257 additions and 767 deletions

View File

@ -1,848 +1,745 @@
// =============================================================================
// 🛒 РЕЗОЛВЕРЫ ДЛЯ СИСТЕМЫ ПОСТАВОК ТОВАРОВ СЕЛЛЕРА V2
// =============================================================================
import { GraphQLError } from 'graphql'
import { processSellerGoodsSupplyReceipt } from '@/lib/inventory-management-goods'
import { prisma } from '@/lib/prisma'
import { notifyOrganization } from '@/lib/realtime'
import { Context } from '../context'
// ========== GOODS SUPPLY V2 RESOLVERS (ЗАКОММЕНТИРОВАНО) ==========
// Раскомментируйте для активации системы товарных поставок V2
// =============================================================================
// 🔍 QUERY RESOLVERS V2
// =============================================================================
// ========== V2 RESOLVERS START ==========
export const sellerGoodsQueries = {
// Мои товарные поставки (для селлеров - заказы которые я создал)
mySellerGoodsSupplies: async (_: unknown, __: unknown, context: Context) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' },
})
}
try {
const user = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true },
})
export const goodsSupplyV2Resolvers = {
Query: {
// Товарные поставки селлера
myGoodsSupplyOrdersV2: async (_: unknown, __: unknown, context: Context) => {
const { user } = context
if (!user?.organization || user.organization.type !== 'SELLER') {
throw new GraphQLError('Доступно только для селлеров', {
extensions: { code: 'FORBIDDEN' },
})
return []
}
try {
const orders = await prisma.goodsSupplyOrder.findMany({
const supplies = await prisma.sellerGoodsSupplyOrder.findMany({
where: {
sellerId: user.organizationId!,
},
include: {
seller: true,
fulfillmentCenter: true,
supplier: true,
logisticsPartner: true,
receivedBy: true,
recipeItems: {
include: {
product: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
})
return supplies
} catch (error) {
console.error('Error fetching seller goods supplies:', error)
return []
}
},
// Входящие товарные заказы от селлеров (для фулфилмента)
incomingSellerGoodsSupplies: async (_: unknown, __: unknown, context: Context) => {
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') {
return []
}
const supplies = await prisma.sellerGoodsSupplyOrder.findMany({
where: {
fulfillmentCenterId: user.organizationId!,
},
include: {
seller: true,
fulfillmentCenter: true,
supplier: true,
logisticsPartner: true,
receivedBy: true,
recipeItems: {
include: {
product: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
})
return supplies
} catch (error) {
console.error('Error fetching incoming seller goods supplies:', error)
return []
}
},
// Товарные заказы от селлеров (для поставщиков)
mySellerGoodsSupplyRequests: async (_: unknown, __: unknown, context: Context) => {
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 !== 'WHOLESALE') {
return []
}
const supplies = await prisma.sellerGoodsSupplyOrder.findMany({
where: {
supplierId: user.organizationId!,
},
include: {
seller: true,
fulfillmentCenter: true,
supplier: true,
logisticsPartner: true,
receivedBy: true,
recipeItems: {
include: {
product: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
})
return supplies
} catch (error) {
console.error('Error fetching seller goods supply requests:', error)
return []
}
},
// Получение конкретной товарной поставки селлера
sellerGoodsSupply: async (_: unknown, args: { id: string }, context: Context) => {
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) {
throw new GraphQLError('Организация не найдена')
}
const supply = await prisma.sellerGoodsSupplyOrder.findUnique({
where: { id: args.id },
include: {
seller: true,
fulfillmentCenter: true,
supplier: true,
logisticsPartner: true,
receivedBy: true,
recipeItems: {
include: {
product: true,
},
},
},
})
if (!supply) {
throw new GraphQLError('Поставка не найдена')
}
// Проверка доступа
const hasAccess =
(user.organization.type === 'SELLER' && supply.sellerId === user.organizationId) ||
(user.organization.type === 'FULFILLMENT' && supply.fulfillmentCenterId === user.organizationId) ||
(user.organization.type === 'WHOLESALE' && supply.supplierId === user.organizationId) ||
(user.organization.type === 'LOGIST' && supply.logisticsPartnerId === user.organizationId)
if (!hasAccess) {
throw new GraphQLError('Нет доступа к этой поставке')
}
return supply
} catch (error) {
console.error('Error fetching seller goods supply:', error)
if (error instanceof GraphQLError) {
throw error
}
throw new GraphQLError('Ошибка получения товарной поставки')
}
},
// Инвентарь товаров селлера на складе фулфилмента
mySellerGoodsInventory: async (_: unknown, __: unknown, context: Context) => {
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) {
return []
}
let inventoryItems
if (user.organization.type === 'SELLER') {
// Селлер видит свои товары на всех складах
inventoryItems = await prisma.sellerGoodsInventory.findMany({
where: {
sellerId: user.organizationId!,
},
include: {
seller: true,
fulfillmentCenter: {
include: {
phones: true,
emails: true,
},
},
items: {
include: {
product: {
include: {
category: true,
sizes: true,
},
},
recipe: {
include: {
components: {
include: {
material: true,
},
},
services: {
include: {
service: true,
},
},
},
},
},
},
requestedServices: {
include: {
service: true,
completedBy: true,
},
},
logisticsPartner: {
include: {
phones: true,
},
},
supplier: {
include: {
phones: true,
},
},
receivedBy: true,
fulfillmentCenter: true,
product: true,
},
orderBy: {
createdAt: 'desc',
lastSupplyDate: 'desc',
},
})
return orders
} catch (error) {
throw new GraphQLError('Ошибка получения товарных поставок', {
extensions: { code: 'INTERNAL_ERROR', originalError: error },
})
}
},
// Входящие товарные поставки (для фулфилмента)
incomingGoodsSuppliesV2: async (_: unknown, __: unknown, context: Context) => {
const { user } = context
if (!user?.organization || user.organization.type !== 'FULFILLMENT') {
throw new GraphQLError('Доступно только для фулфилмент-центров', {
extensions: { code: 'FORBIDDEN' },
})
}
try {
const orders = await prisma.goodsSupplyOrder.findMany({
} else if (user.organization.type === 'FULFILLMENT') {
// Фулфилмент видит все товары на своем складе
inventoryItems = await prisma.sellerGoodsInventory.findMany({
where: {
fulfillmentCenterId: user.organizationId!,
},
include: {
seller: {
include: {
phones: true,
emails: true,
},
},
seller: true,
fulfillmentCenter: true,
items: {
include: {
product: {
include: {
category: true,
},
},
recipe: {
include: {
components: {
include: {
material: {
select: {
id: true,
name: true,
unit: true,
// НЕ показываем цены селлера
},
},
},
},
services: {
include: {
service: true,
},
},
},
},
},
},
requestedServices: {
include: {
service: true,
completedBy: true,
},
},
logisticsPartner: {
include: {
phones: true,
},
},
supplier: true,
receivedBy: true,
product: true,
},
orderBy: {
requestedDeliveryDate: 'asc',
lastSupplyDate: 'desc',
},
})
// Фильтруем коммерческие данные селлера
return orders.map(order => ({
...order,
items: order.items.map(item => ({
...item,
price: null, // Скрываем закупочную цену селлера
totalPrice: null, // Скрываем общую стоимость
})),
}))
} catch (error) {
throw new GraphQLError('Ошибка получения входящих поставок', {
extensions: { code: 'INTERNAL_ERROR', originalError: error },
})
}
},
// Товарные заказы для поставщиков
myGoodsSupplyRequestsV2: async (_: unknown, __: unknown, context: Context) => {
const { user } = context
if (!user?.organization || user.organization.type !== 'WHOLESALE') {
throw new GraphQLError('Доступно только для поставщиков', {
extensions: { code: 'FORBIDDEN' },
})
} else {
return []
}
try {
const orders = await prisma.goodsSupplyOrder.findMany({
where: {
supplierId: user.organizationId!,
},
include: {
seller: {
include: {
phones: true,
},
},
fulfillmentCenter: true,
items: {
include: {
product: {
include: {
category: true,
},
},
},
},
// НЕ включаем requestedServices - поставщик не видит услуги ФФ
},
orderBy: {
requestedDeliveryDate: 'asc',
},
})
return inventoryItems
} catch (error) {
console.error('Error fetching seller goods inventory:', error)
return []
}
},
}
// Показываем только релевантную для поставщика информацию
return orders.map(order => ({
...order,
items: order.items.map(item => ({
...item,
recipe: null, // Поставщик не видит рецептуры
})),
}))
} catch (error) {
throw new GraphQLError('Ошибка получения заказов поставок', {
extensions: { code: 'INTERNAL_ERROR', originalError: error },
})
}
},
// =============================================================================
// ✏️ MUTATION RESOLVERS V2
// =============================================================================
// Детали конкретной поставки
goodsSupplyOrderV2: async (_: unknown, args: { id: string }, context: Context) => {
const { user } = context
if (!user?.organizationId) {
throw new GraphQLError('Необходима авторизация', {
extensions: { code: 'UNAUTHENTICATED' },
})
}
export const sellerGoodsMutations = {
// Создание поставки товаров селлера
createSellerGoodsSupply: async (_: unknown, args: { input: any }, context: Context) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' },
})
}
try {
const order = await prisma.goodsSupplyOrder.findUnique({
where: { id: args.id },
include: {
seller: {
include: {
phones: true,
emails: true,
},
},
fulfillmentCenter: {
include: {
phones: true,
emails: true,
},
},
items: {
include: {
product: {
include: {
category: true,
sizes: true,
},
},
recipe: {
include: {
components: {
include: {
material: true,
},
},
services: {
include: {
service: true,
},
},
},
},
},
},
requestedServices: {
include: {
service: true,
completedBy: true,
},
},
logisticsPartner: {
include: {
phones: true,
emails: true,
},
},
supplier: {
include: {
phones: true,
emails: true,
},
},
receivedBy: true,
},
})
try {
const user = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true },
})
if (!order) {
throw new GraphQLError('Поставка не найдена', {
extensions: { code: 'NOT_FOUND' },
})
}
// Проверка прав доступа
const hasAccess =
order.sellerId === user.organizationId ||
order.fulfillmentCenterId === user.organizationId ||
order.supplierId === user.organizationId ||
order.logisticsPartnerId === user.organizationId
if (!hasAccess) {
throw new GraphQLError('Доступ запрещен', {
extensions: { code: 'FORBIDDEN' },
})
}
// Фильтрация данных в зависимости от роли
if (user.organization?.type === 'WHOLESALE') {
// Поставщик не видит рецептуры и услуги ФФ
return {
...order,
items: order.items.map(item => ({
...item,
recipe: null,
})),
requestedServices: [],
}
}
if (user.organization?.type === 'FULFILLMENT') {
// ФФ не видит закупочные цены селлера
return {
...order,
items: order.items.map(item => ({
...item,
price: null,
totalPrice: null,
})),
}
}
if (user.organization?.type === 'LOGIST') {
// Логистика видит только логистическую информацию
return {
...order,
items: order.items.map(item => ({
...item,
price: null,
totalPrice: null,
recipe: null,
})),
requestedServices: [],
}
}
// Селлер видит все свои данные
return order
} catch (error) {
if (error instanceof GraphQLError) {
throw error
}
throw new GraphQLError('Ошибка получения поставки', {
extensions: { code: 'INTERNAL_ERROR', originalError: error },
})
}
},
// Рецептуры товаров селлера
myProductRecipes: async (_: unknown, __: unknown, context: Context) => {
const { user } = context
if (!user?.organization || user.organization.type !== 'SELLER') {
throw new GraphQLError('Доступно только для селлеров', {
extensions: { code: 'FORBIDDEN' },
})
throw new GraphQLError('Доступно только для селлеров')
}
try {
const recipes = await prisma.productRecipe.findMany({
where: {
product: {
organizationId: user.organizationId!,
},
const { fulfillmentCenterId, supplierId, logisticsPartnerId, requestedDeliveryDate, notes, recipeItems } = args.input
// 🔍 ВАЛИДАЦИЯ ПАРТНЕРОВ
// Проверяем фулфилмент-центр
const fulfillmentCenter = await prisma.organization.findUnique({
where: { id: fulfillmentCenterId },
include: {
counterpartiesAsCounterparty: {
where: { organizationId: user.organizationId! },
},
include: {
product: {
include: {
category: true,
},
},
components: {
include: {
material: true,
},
},
services: {
include: {
service: true,
},
},
},
})
if (!fulfillmentCenter || fulfillmentCenter.type !== 'FULFILLMENT') {
throw new GraphQLError('Фулфилмент-центр не найден или имеет неверный тип')
}
if (fulfillmentCenter.counterpartiesAsCounterparty.length === 0) {
throw new GraphQLError('Нет партнерских отношений с данным фулфилмент-центром')
}
// Проверяем поставщика
const supplier = await prisma.organization.findUnique({
where: { id: supplierId },
include: {
counterpartiesAsCounterparty: {
where: { organizationId: user.organizationId! },
},
orderBy: {
updatedAt: 'desc',
},
})
if (!supplier || supplier.type !== 'WHOLESALE') {
throw new GraphQLError('Поставщик не найден или имеет неверный тип')
}
if (supplier.counterpartiesAsCounterparty.length === 0) {
throw new GraphQLError('Нет партнерских отношений с данным поставщиком')
}
// 🔍 ВАЛИДАЦИЯ ТОВАРОВ И ОСТАТКОВ
let totalCost = 0
const mainProducts = recipeItems.filter((item: any) => item.recipeType === 'MAIN_PRODUCT')
if (mainProducts.length === 0) {
throw new GraphQLError('Должен быть хотя бы один основной товар')
}
// Проверяем все товары в рецептуре
for (const item of recipeItems) {
const product = await prisma.product.findUnique({
where: { id: item.productId },
})
if (!product) {
throw new GraphQLError(`Товар с ID ${item.productId} не найден`)
}
if (product.organizationId !== supplierId) {
throw new GraphQLError(`Товар ${product.name} не принадлежит выбранному поставщику`)
}
// Для основных товаров проверяем остатки
if (item.recipeType === 'MAIN_PRODUCT') {
const availableStock = (product.stock || product.quantity || 0) - (product.ordered || 0)
if (item.quantity > availableStock) {
throw new GraphQLError(
`Недостаточно остатков товара "${product.name}". ` +
`Доступно: ${availableStock} шт., запрашивается: ${item.quantity} шт.`,
)
}
totalCost += product.price.toNumber() * item.quantity
}
}
// 🚀 СОЗДАНИЕ ПОСТАВКИ В ТРАНЗАКЦИИ
const supplyOrder = await prisma.$transaction(async (tx) => {
// Создаем заказ поставки
const newOrder = await tx.sellerGoodsSupplyOrder.create({
data: {
sellerId: user.organizationId!,
fulfillmentCenterId,
supplierId,
logisticsPartnerId,
requestedDeliveryDate: new Date(requestedDeliveryDate),
notes,
status: 'PENDING',
totalCostWithDelivery: totalCost,
},
})
return recipes
} catch (error) {
throw new GraphQLError('Ошибка получения рецептур', {
extensions: { code: 'INTERNAL_ERROR', originalError: error },
})
// Создаем записи рецептуры
for (const item of recipeItems) {
await tx.goodsSupplyRecipeItem.create({
data: {
supplyOrderId: newOrder.id,
productId: item.productId,
quantity: item.quantity,
recipeType: item.recipeType,
},
})
// Резервируем основные товары у поставщика
if (item.recipeType === 'MAIN_PRODUCT') {
await tx.product.update({
where: { id: item.productId },
data: {
ordered: {
increment: item.quantity,
},
},
})
}
}
return newOrder
})
// 📨 УВЕДОМЛЕНИЯ
await notifyOrganization(
supplierId,
`Новый заказ товаров от селлера ${user.organization.name}`,
'GOODS_SUPPLY_ORDER_CREATED',
{ orderId: supplyOrder.id },
)
await notifyOrganization(
fulfillmentCenterId,
`Селлер ${user.organization.name} оформил поставку товаров на ваш склад`,
'INCOMING_GOODS_SUPPLY_ORDER',
{ orderId: supplyOrder.id },
)
// Получаем созданную поставку с полными данными
const createdSupply = await prisma.sellerGoodsSupplyOrder.findUnique({
where: { id: supplyOrder.id },
include: {
seller: true,
fulfillmentCenter: true,
supplier: true,
logisticsPartner: true,
recipeItems: {
include: {
product: true,
},
},
},
})
return {
success: true,
message: 'Поставка товаров успешно создана',
supplyOrder: createdSupply,
}
},
} catch (error) {
console.error('Error creating seller goods supply:', error)
if (error instanceof GraphQLError) {
throw error
}
throw new GraphQLError('Ошибка создания товарной поставки')
}
},
Mutation: {
// Создание товарной поставки
createGoodsSupplyOrder: async (_: unknown, args: any, context: Context) => {
const { user } = context
const { input } = args
if (!user?.organization || user.organization.type !== 'SELLER') {
throw new GraphQLError('Доступно только для селлеров', {
extensions: { code: 'FORBIDDEN' },
})
// Обновление статуса товарной поставки
updateSellerGoodsSupplyStatus: async (
_: unknown,
args: { id: string; status: string; notes?: string },
context: Context,
) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация')
}
try {
const user = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true },
})
if (!user?.organization) {
throw new GraphQLError('Организация не найдена')
}
try {
// Проверяем фулфилмент-центр
const fulfillmentCenter = await prisma.organization.findFirst({
where: {
id: input.fulfillmentCenterId,
type: 'FULFILLMENT',
const supply = await prisma.sellerGoodsSupplyOrder.findUnique({
where: { id: args.id },
include: {
seller: true,
supplier: true,
fulfillmentCenter: true,
recipeItems: {
include: {
product: true,
},
},
})
},
})
if (!fulfillmentCenter) {
throw new GraphQLError('Фулфилмент-центр не найден', {
extensions: { code: 'NOT_FOUND' },
})
if (!supply) {
throw new GraphQLError('Поставка не найдена')
}
// 🔐 ПРОВЕРКА ПРАВ И ЛОГИКИ ПЕРЕХОДОВ СТАТУСОВ
const { status } = args
const currentStatus = supply.status
const orgType = user.organization.type
// Только поставщики могут переводить PENDING → APPROVED
if (status === 'APPROVED' && currentStatus === 'PENDING') {
if (orgType !== 'WHOLESALE' || supply.supplierId !== user.organizationId) {
throw new GraphQLError('Только поставщик может одобрить заказ')
}
}
// Проверяем товары и рецептуры
for (const item of input.items) {
const product = await prisma.product.findFirst({
where: {
id: item.productId,
organizationId: user.organizationId!,
},
})
if (!product) {
throw new GraphQLError(`Товар ${item.productId} не найден`, {
extensions: { code: 'NOT_FOUND' },
})
}
if (item.recipeId) {
const recipe = await prisma.productRecipe.findFirst({
where: {
id: item.recipeId,
productId: item.productId,
},
})
if (!recipe) {
throw new GraphQLError(`Рецептура ${item.recipeId} не найдена`, {
extensions: { code: 'NOT_FOUND' },
})
}
}
// Только поставщики могут переводить APPROVED → SHIPPED
else if (status === 'SHIPPED' && currentStatus === 'APPROVED') {
if (orgType !== 'WHOLESALE' || supply.supplierId !== user.organizationId) {
throw new GraphQLError('Только поставщик может отметить отгрузку')
}
}
// Создаем поставку в транзакции
const order = await prisma.$transaction(async (tx) => {
// Создаем основную запись
const newOrder = await tx.goodsSupplyOrder.create({
data: {
sellerId: user.organizationId!,
fulfillmentCenterId: input.fulfillmentCenterId,
requestedDeliveryDate: new Date(input.requestedDeliveryDate),
notes: input.notes,
status: 'PENDING',
// Только фулфилмент может переводить SHIPPED → DELIVERED
else if (status === 'DELIVERED' && currentStatus === 'SHIPPED') {
if (orgType !== 'FULFILLMENT' || supply.fulfillmentCenterId !== user.organizationId) {
throw new GraphQLError('Только фулфилмент-центр может подтвердить получение')
}
}
// Только фулфилмент может переводить DELIVERED → COMPLETED
else if (status === 'COMPLETED' && currentStatus === 'DELIVERED') {
if (orgType !== 'FULFILLMENT' || supply.fulfillmentCenterId !== user.organizationId) {
throw new GraphQLError('Только фулфилмент-центр может завершить поставку')
}
} else {
throw new GraphQLError('Недопустимый переход статуса')
}
// 📅 ОБНОВЛЕНИЕ ВРЕМЕННЫХ МЕТОК
const updateData: any = {
status,
updatedAt: new Date(),
}
if (status === 'APPROVED' && orgType === 'WHOLESALE') {
updateData.supplierApprovedAt = new Date()
updateData.supplierNotes = args.notes
}
if (status === 'SHIPPED' && orgType === 'WHOLESALE') {
updateData.shippedAt = new Date()
}
if (status === 'DELIVERED' && orgType === 'FULFILLMENT') {
updateData.deliveredAt = new Date()
updateData.receivedById = user.id
updateData.receiptNotes = args.notes
}
// 🔄 ОБНОВЛЕНИЕ В БАЗЕ
const updatedSupply = await prisma.sellerGoodsSupplyOrder.update({
where: { id: args.id },
data: updateData,
include: {
seller: true,
fulfillmentCenter: true,
supplier: true,
logisticsPartner: true,
receivedBy: true,
recipeItems: {
include: {
product: true,
},
})
},
},
})
// Создаем товары
let totalAmount = 0
let totalItems = 0
// 📨 УВЕДОМЛЕНИЯ О СМЕНЕ СТАТУСА
if (status === 'APPROVED') {
await notifyOrganization(
supply.sellerId,
`Поставка товаров одобрена поставщиком ${user.organization.name}`,
'GOODS_SUPPLY_APPROVED',
{ orderId: args.id },
)
}
for (const itemInput of input.items) {
const itemTotal = itemInput.price * itemInput.quantity
totalAmount += itemTotal
totalItems += itemInput.quantity
if (status === 'SHIPPED') {
await notifyOrganization(
supply.sellerId,
`Поставка товаров отгружена поставщиком ${user.organization.name}`,
'GOODS_SUPPLY_SHIPPED',
{ orderId: args.id },
)
await tx.goodsSupplyOrderItem.create({
data: {
orderId: newOrder.id,
productId: itemInput.productId,
quantity: itemInput.quantity,
price: itemInput.price,
totalPrice: itemTotal,
recipeId: itemInput.recipeId,
},
})
}
await notifyOrganization(
supply.fulfillmentCenterId,
'Поставка товаров в пути. Ожидается доставка',
'GOODS_SUPPLY_IN_TRANSIT',
{ orderId: args.id },
)
}
// Создаем запросы услуг
for (const serviceInput of input.requestedServices) {
const service = await tx.service.findUnique({
where: { id: serviceInput.serviceId },
})
if (status === 'DELIVERED') {
// 📦 АВТОМАТИЧЕСКОЕ СОЗДАНИЕ/ОБНОВЛЕНИЕ ИНВЕНТАРЯ V2
await processSellerGoodsSupplyReceipt(args.id)
await notifyOrganization(
supply.sellerId,
`Поставка товаров доставлена в ${supply.fulfillmentCenter.name}`,
'GOODS_SUPPLY_DELIVERED',
{ orderId: args.id },
)
}
if (!service) {
throw new Error(`Услуга ${serviceInput.serviceId} не найдена`)
}
if (status === 'COMPLETED') {
await notifyOrganization(
supply.sellerId,
`Поставка товаров завершена. Товары размещены на складе ${supply.fulfillmentCenter.name}`,
'GOODS_SUPPLY_COMPLETED',
{ orderId: args.id },
)
}
const serviceTotal = service.price * serviceInput.quantity
totalAmount += serviceTotal
return updatedSupply
} catch (error) {
console.error('Error updating seller goods supply status:', error)
await tx.fulfillmentServiceRequest.create({
data: {
orderId: newOrder.id,
serviceId: serviceInput.serviceId,
quantity: serviceInput.quantity,
price: service.price,
totalPrice: serviceTotal,
status: 'PENDING',
},
})
}
if (error instanceof GraphQLError) {
throw error
}
// Обновляем итоги
await tx.goodsSupplyOrder.update({
where: { id: newOrder.id },
data: {
totalAmount,
totalItems,
throw new GraphQLError('Ошибка обновления статуса товарной поставки')
}
},
// Отмена товарной поставки селлером
cancelSellerGoodsSupply: async (_: unknown, args: { id: string }, context: Context) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация')
}
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('Только селлеры могут отменять свои поставки')
}
const supply = await prisma.sellerGoodsSupplyOrder.findUnique({
where: { id: args.id },
include: {
seller: true,
recipeItems: {
include: {
product: true,
},
})
},
},
})
return newOrder
})
if (!supply) {
throw new GraphQLError('Поставка не найдена')
}
// Получаем созданную поставку с полными данными
const createdOrder = await prisma.goodsSupplyOrder.findUnique({
where: { id: order.id },
if (supply.sellerId !== user.organizationId) {
throw new GraphQLError('Вы можете отменить только свои поставки')
}
// ✅ ПРОВЕРКА ВОЗМОЖНОСТИ ОТМЕНЫ (только PENDING и APPROVED)
if (!['PENDING', 'APPROVED'].includes(supply.status)) {
throw new GraphQLError('Поставку можно отменить только в статусе PENDING или APPROVED')
}
// 🔄 ОТМЕНА В ТРАНЗАКЦИИ
const cancelledSupply = await prisma.$transaction(async (tx) => {
// Обновляем статус
const updated = await tx.sellerGoodsSupplyOrder.update({
where: { id: args.id },
data: {
status: 'CANCELLED',
updatedAt: new Date(),
},
include: {
seller: true,
fulfillmentCenter: true,
items: {
supplier: true,
recipeItems: {
include: {
product: true,
recipe: true,
},
},
requestedServices: {
include: {
service: true,
},
},
},
})
return {
success: true,
message: 'Товарная поставка успешно создана',
order: createdOrder,
}
} catch (error) {
if (error instanceof GraphQLError) {
throw error
}
throw new GraphQLError('Ошибка создания поставки', {
extensions: { code: 'INTERNAL_ERROR', originalError: error },
})
}
},
// Обновление статуса товарной поставки
updateGoodsSupplyOrderStatus: async (_: unknown, args: any, context: Context) => {
const { user } = context
const { id, status, notes } = args
if (!user?.organizationId) {
throw new GraphQLError('Необходима авторизация', {
extensions: { code: 'UNAUTHENTICATED' },
})
}
try {
const order = await prisma.goodsSupplyOrder.findUnique({
where: { id },
})
if (!order) {
throw new GraphQLError('Поставка не найдена', {
extensions: { code: 'NOT_FOUND' },
})
}
// Проверка прав на изменение статуса
const canUpdate =
(status === 'SUPPLIER_APPROVED' && order.supplierId === user.organizationId) ||
(status === 'LOGISTICS_CONFIRMED' && user.organization?.type === 'FULFILLMENT') ||
(status === 'SHIPPED' && order.supplierId === user.organizationId) ||
(status === 'IN_TRANSIT' && order.logisticsPartnerId === user.organizationId) ||
(status === 'RECEIVED' && order.fulfillmentCenterId === user.organizationId) ||
(status === 'CANCELLED' &&
(order.sellerId === user.organizationId || order.fulfillmentCenterId === user.organizationId))
if (!canUpdate) {
throw new GraphQLError('Недостаточно прав для изменения статуса', {
extensions: { code: 'FORBIDDEN' },
})
}
const updateData: any = {
status,
notes: notes || order.notes,
}
// Устанавливаем временные метки
if (status === 'SUPPLIER_APPROVED') {
updateData.supplierApprovedAt = new Date()
} else if (status === 'SHIPPED') {
updateData.shippedAt = new Date()
} else if (status === 'RECEIVED') {
updateData.receivedAt = new Date()
updateData.receivedById = user.id
}
const updatedOrder = await prisma.goodsSupplyOrder.update({
where: { id },
data: updateData,
include: {
receivedBy: true,
},
})
return updatedOrder
} catch (error) {
if (error instanceof GraphQLError) {
throw error
}
throw new GraphQLError('Ошибка обновления статуса', {
extensions: { code: 'INTERNAL_ERROR', originalError: error },
})
}
},
// Приемка товарной поставки
receiveGoodsSupplyOrder: async (_: unknown, args: any, context: Context) => {
const { user } = context
const { id, items } = args
if (!user?.organization || user.organization.type !== 'FULFILLMENT') {
throw new GraphQLError('Доступно только для фулфилмент-центров', {
extensions: { code: 'FORBIDDEN' },
})
}
try {
const order = await prisma.goodsSupplyOrder.findUnique({
where: { id },
include: {
items: {
include: {
recipe: {
include: {
components: {
include: {
material: true,
},
},
},
},
},
},
},
})
if (!order) {
throw new GraphQLError('Поставка не найдена', {
extensions: { code: 'NOT_FOUND' },
})
}
if (order.fulfillmentCenterId !== user.organizationId) {
throw new GraphQLError('Доступ запрещен', {
extensions: { code: 'FORBIDDEN' },
})
}
if (order.status !== 'IN_TRANSIT') {
throw new GraphQLError('Поставка должна быть в статусе "В пути"', {
extensions: { code: 'BAD_REQUEST' },
})
}
// Обрабатываем приемку в транзакции
const updatedOrder = await prisma.$transaction(async (tx) => {
// Обновляем данные приемки для каждого товара
for (const itemInput of items) {
const orderItem = order.items.find(item => item.id === itemInput.itemId)
if (!orderItem) {
throw new Error(`Товар ${itemInput.itemId} не найден в поставке`)
}
await tx.goodsSupplyOrderItem.update({
where: { id: itemInput.itemId },
// Освобождаем зарезервированные товары у поставщика (только MAIN_PRODUCT)
for (const item of supply.recipeItems) {
if (item.recipeType === 'MAIN_PRODUCT') {
await tx.product.update({
where: { id: item.productId },
data: {
receivedQuantity: itemInput.receivedQuantity,
damagedQuantity: itemInput.damagedQuantity || 0,
acceptanceNotes: itemInput.acceptanceNotes,
ordered: {
decrement: item.quantity,
},
},
})
// Обновляем остатки расходников по рецептуре
if (orderItem.recipe && itemInput.receivedQuantity > 0) {
for (const component of orderItem.recipe.components) {
const usedQuantity = component.quantity * itemInput.receivedQuantity
await tx.supply.update({
where: { id: component.materialId },
data: {
currentStock: {
decrement: usedQuantity,
},
},
})
}
}
}
// Обновляем статус поставки
const updated = await tx.goodsSupplyOrder.update({
where: { id },
data: {
status: 'RECEIVED',
receivedAt: new Date(),
receivedById: user.id,
},
include: {
items: {
include: {
product: true,
recipe: {
include: {
components: {
include: {
material: true,
},
},
},
},
},
},
requestedServices: {
include: {
service: true,
},
},
receivedBy: true,
},
})
return updated
})
return updatedOrder
} catch (error) {
if (error instanceof GraphQLError) {
throw error
}
throw new GraphQLError('Ошибка приемки поставки', {
extensions: { code: 'INTERNAL_ERROR', originalError: error },
})
}
},
// Отмена товарной поставки
cancelGoodsSupplyOrder: async (_: unknown, args: any, context: Context) => {
const { user } = context
const { id, reason } = args
if (!user?.organizationId) {
throw new GraphQLError('Необходима авторизация', {
extensions: { code: 'UNAUTHENTICATED' },
})
return updated
})
// 📨 УВЕДОМЛЕНИЯ ОБ ОТМЕНЕ
if (supply.supplierId) {
await notifyOrganization(
supply.supplierId,
`Селлер ${user.organization.name} отменил заказ товаров`,
'GOODS_SUPPLY_CANCELLED',
{ orderId: args.id },
)
}
try {
const order = await prisma.goodsSupplyOrder.findUnique({
where: { id },
})
await notifyOrganization(
supply.fulfillmentCenterId,
`Селлер ${user.organization.name} отменил поставку товаров`,
'GOODS_SUPPLY_CANCELLED',
{ orderId: args.id },
)
if (!order) {
throw new GraphQLError('Поставка не найдена', {
extensions: { code: 'NOT_FOUND' },
})
}
return cancelledSupply
} catch (error) {
console.error('Error cancelling seller goods supply:', error)
// Проверка прав на отмену
const canCancel =
order.sellerId === user.organizationId ||
order.fulfillmentCenterId === user.organizationId ||
(order.supplierId === user.organizationId && order.status === 'PENDING')
if (!canCancel) {
throw new GraphQLError('Недостаточно прав для отмены поставки', {
extensions: { code: 'FORBIDDEN' },
})
}
if (['RECEIVED', 'PROCESSING', 'COMPLETED'].includes(order.status)) {
throw new GraphQLError('Нельзя отменить поставку в текущем статусе', {
extensions: { code: 'BAD_REQUEST' },
})
}
const cancelledOrder = await prisma.goodsSupplyOrder.update({
where: { id },
data: {
status: 'CANCELLED',
notes: `${order.notes ? order.notes + '\n' : ''}ОТМЕНЕНО: ${reason}`,
},
})
return cancelledOrder
} catch (error) {
if (error instanceof GraphQLError) {
throw error
}
throw new GraphQLError('Ошибка отмены поставки', {
extensions: { code: 'INTERNAL_ERROR', originalError: error },
})
if (error instanceof GraphQLError) {
throw error
}
},
throw new GraphQLError('Ошибка отмены товарной поставки')
}
},
}