Files
sfera-new/src/graphql/resolvers/goods-supply-v2.ts
Veronika Smirnova c344a177b5 feat: продолжение миграции V2 системы поставок товаров
- Добавлен feature flag USE_V2_GOODS_SYSTEM для переключения между V1 и V2
- Создан трансформер рецептур для конвертации V1 → V2 формата
- Интегрирована V2 мутация CREATE_SELLER_GOODS_SUPPLY в useSupplyCart
- Добавлен V2 запрос GET_MY_SELLER_GOODS_SUPPLIES в supplies-dashboard
- Исправлены связи counterpartyOf в goods-supply-v2 resolver
- Временно отключена валидация для не-MAIN_PRODUCT товаров в V2
- Создан новый компонент supplies-dashboard-v2 (в разработке)

Изменения являются частью поэтапной миграции с V1 на V2 систему поставок

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-01 19:21:13 +03:00

753 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// =============================================================================
// 🛒 РЕЗОЛВЕРЫ ДЛЯ СИСТЕМЫ ПОСТАВОК ТОВАРОВ СЕЛЛЕРА 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'
// =============================================================================
// 🔍 QUERY RESOLVERS V2
// =============================================================================
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 },
})
if (!user?.organization || user.organization.type !== 'SELLER') {
return []
}
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: true,
product: true,
},
orderBy: {
lastSupplyDate: 'desc',
},
})
} else if (user.organization.type === 'FULFILLMENT') {
// Фулфилмент видит все товары на своем складе
inventoryItems = await prisma.sellerGoodsInventory.findMany({
where: {
fulfillmentCenterId: user.organizationId!,
},
include: {
seller: true,
fulfillmentCenter: true,
product: true,
},
orderBy: {
lastSupplyDate: 'desc',
},
})
} else {
return []
}
return inventoryItems
} catch (error) {
console.error('Error fetching seller goods inventory:', error)
return []
}
},
}
// =============================================================================
// ✏️ MUTATION RESOLVERS V2
// =============================================================================
export const sellerGoodsMutations = {
// Создание поставки товаров селлера
createSellerGoodsSupply: async (_: unknown, args: { input: any }, 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 !== 'SELLER') {
throw new GraphQLError('Доступно только для селлеров')
}
const { fulfillmentCenterId, supplierId, logisticsPartnerId, requestedDeliveryDate, notes, recipeItems } = args.input
// 🔍 ВАЛИДАЦИЯ ПАРТНЕРОВ
// Проверяем фулфилмент-центр
const fulfillmentCenter = await prisma.organization.findUnique({
where: { id: fulfillmentCenterId },
include: {
counterpartyOf: {
where: { organizationId: user.organizationId! },
},
},
})
if (!fulfillmentCenter || fulfillmentCenter.type !== 'FULFILLMENT') {
throw new GraphQLError('Фулфилмент-центр не найден или имеет неверный тип')
}
if (fulfillmentCenter.counterpartyOf.length === 0) {
throw new GraphQLError('Нет партнерских отношений с данным фулфилмент-центром')
}
// Проверяем поставщика
const supplier = await prisma.organization.findUnique({
where: { id: supplierId },
include: {
counterpartyOf: {
where: { organizationId: user.organizationId! },
},
},
})
if (!supplier || supplier.type !== 'WHOLESALE') {
throw new GraphQLError('Поставщик не найден или имеет неверный тип')
}
if (supplier.counterpartyOf.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('Должен быть хотя бы один основной товар')
}
// Проверяем только основные товары (MAIN_PRODUCT) в рецептуре
for (const item of recipeItems) {
// В V2 временно валидируем только основные товары
if (item.recipeType !== 'MAIN_PRODUCT') {
console.log(`⚠️ Пропускаем валидацию ${item.recipeType} товара ${item.productId} - не поддерживается в V2`)
continue
}
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} не принадлежит выбранному поставщику`)
}
// Проверяем остатки основных товаров
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,
},
})
// Создаем записи рецептуры только для MAIN_PRODUCT
for (const item of recipeItems) {
// В V2 временно создаем только основные товары
if (item.recipeType !== 'MAIN_PRODUCT') {
console.log(`⚠️ Пропускаем создание записи для ${item.recipeType} товара ${item.productId}`)
continue
}
await tx.goodsSupplyRecipeItem.create({
data: {
supplyOrderId: newOrder.id,
productId: item.productId,
quantity: item.quantity,
recipeType: item.recipeType,
},
})
// Резервируем основные товары у поставщика
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('Ошибка создания товарной поставки')
}
},
// Обновление статуса товарной поставки
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('Организация не найдена')
}
const supply = await prisma.sellerGoodsSupplyOrder.findUnique({
where: { id: args.id },
include: {
seller: true,
supplier: true,
fulfillmentCenter: true,
recipeItems: {
include: {
product: true,
},
},
},
})
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('Только поставщик может одобрить заказ')
}
}
// Только поставщики могут переводить APPROVED → SHIPPED
else if (status === 'SHIPPED' && currentStatus === 'APPROVED') {
if (orgType !== 'WHOLESALE' || supply.supplierId !== user.organizationId) {
throw new GraphQLError('Только поставщик может отметить отгрузку')
}
}
// Только фулфилмент может переводить 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,
},
},
},
})
// 📨 УВЕДОМЛЕНИЯ О СМЕНЕ СТАТУСА
if (status === 'APPROVED') {
await notifyOrganization(
supply.sellerId,
`Поставка товаров одобрена поставщиком ${user.organization.name}`,
'GOODS_SUPPLY_APPROVED',
{ orderId: args.id },
)
}
if (status === 'SHIPPED') {
await notifyOrganization(
supply.sellerId,
`Поставка товаров отгружена поставщиком ${user.organization.name}`,
'GOODS_SUPPLY_SHIPPED',
{ orderId: args.id },
)
await notifyOrganization(
supply.fulfillmentCenterId,
'Поставка товаров в пути. Ожидается доставка',
'GOODS_SUPPLY_IN_TRANSIT',
{ orderId: args.id },
)
}
if (status === 'DELIVERED') {
// 📦 АВТОМАТИЧЕСКОЕ СОЗДАНИЕ/ОБНОВЛЕНИЕ ИНВЕНТАРЯ V2
await processSellerGoodsSupplyReceipt(args.id)
await notifyOrganization(
supply.sellerId,
`Поставка товаров доставлена в ${supply.fulfillmentCenter.name}`,
'GOODS_SUPPLY_DELIVERED',
{ orderId: args.id },
)
}
if (status === 'COMPLETED') {
await notifyOrganization(
supply.sellerId,
`Поставка товаров завершена. Товары размещены на складе ${supply.fulfillmentCenter.name}`,
'GOODS_SUPPLY_COMPLETED',
{ orderId: args.id },
)
}
return updatedSupply
} catch (error) {
console.error('Error updating seller goods supply status:', error)
if (error instanceof GraphQLError) {
throw error
}
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,
},
},
},
})
if (!supply) {
throw new GraphQLError('Поставка не найдена')
}
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,
supplier: true,
recipeItems: {
include: {
product: true,
},
},
},
})
// Освобождаем зарезервированные товары у поставщика (только MAIN_PRODUCT)
for (const item of supply.recipeItems) {
if (item.recipeType === 'MAIN_PRODUCT') {
await tx.product.update({
where: { id: item.productId },
data: {
ordered: {
decrement: item.quantity,
},
},
})
}
}
return updated
})
// 📨 УВЕДОМЛЕНИЯ ОБ ОТМЕНЕ
if (supply.supplierId) {
await notifyOrganization(
supply.supplierId,
`Селлер ${user.organization.name} отменил заказ товаров`,
'GOODS_SUPPLY_CANCELLED',
{ orderId: args.id },
)
}
await notifyOrganization(
supply.fulfillmentCenterId,
`Селлер ${user.organization.name} отменил поставку товаров`,
'GOODS_SUPPLY_CANCELLED',
{ orderId: args.id },
)
return cancelledSupply
} catch (error) {
console.error('Error cancelling seller goods supply:', error)
if (error instanceof GraphQLError) {
throw error
}
throw new GraphQLError('Ошибка отмены товарной поставки')
}
},
}