diff --git a/src/graphql/queries/seller-consumables-v2.ts b/src/graphql/queries/seller-consumables-v2.ts new file mode 100644 index 0000000..effaa14 --- /dev/null +++ b/src/graphql/queries/seller-consumables-v2.ts @@ -0,0 +1,320 @@ +// ============================================================================= +// 📦 GraphQL ЗАПРОСЫ ДЛЯ СИСТЕМЫ ПОСТАВОК РАСХОДНИКОВ СЕЛЛЕРА V2 +// ============================================================================= + +import { gql } from '@apollo/client' + +// ============================================================================= +// 🔍 QUERY - ПОЛУЧЕНИЕ ДАННЫХ +// ============================================================================= + +export const GET_MY_SELLER_CONSUMABLE_SUPPLIES = gql` + query GetMySellerConsumableSupplies { + mySellerConsumableSupplies { + id + status + sellerId + seller { + id + name + inn + } + fulfillmentCenterId + fulfillmentCenter { + id + name + inn + } + requestedDeliveryDate + notes + + # Данные поставщика + supplierId + supplier { + id + name + inn + } + supplierApprovedAt + packagesCount + estimatedVolume + supplierContractId + supplierNotes + + # Данные логистики + logisticsPartnerId + logisticsPartner { + id + name + inn + } + estimatedDeliveryDate + routeId + logisticsCost + logisticsNotes + + # Данные отгрузки + shippedAt + trackingNumber + + # Данные приемки + deliveredAt + receivedById + receivedBy { + id + managerName + phone + } + actualQuantity + defectQuantity + receiptNotes + + # Экономика + totalCostWithDelivery + estimatedStorageCost + + items { + id + productId + product { + id + name + article + price + quantity + mainImage + } + requestedQuantity + approvedQuantity + shippedQuantity + receivedQuantity + defectQuantity + unitPrice + totalPrice + } + + createdAt + updatedAt + } + } +` + +export const GET_SELLER_CONSUMABLE_SUPPLY = gql` + query GetSellerConsumableSupply($id: ID!) { + sellerConsumableSupply(id: $id) { + id + status + sellerId + seller { + id + name + inn + } + fulfillmentCenterId + fulfillmentCenter { + id + name + inn + } + requestedDeliveryDate + notes + + # Данные поставщика + supplierId + supplier { + id + name + inn + } + supplierApprovedAt + packagesCount + estimatedVolume + supplierContractId + supplierNotes + + # Данные логистики + logisticsPartnerId + logisticsPartner { + id + name + inn + } + estimatedDeliveryDate + routeId + logisticsCost + logisticsNotes + + # Данные отгрузки + shippedAt + trackingNumber + + # Данные приемки + deliveredAt + receivedById + receivedBy { + id + managerName + phone + } + actualQuantity + defectQuantity + receiptNotes + + # Экономика + totalCostWithDelivery + estimatedStorageCost + + items { + id + productId + product { + id + name + article + price + quantity + mainImage + } + requestedQuantity + approvedQuantity + shippedQuantity + receivedQuantity + defectQuantity + unitPrice + totalPrice + } + + createdAt + updatedAt + } + } +` + +// Для других типов организаций (фулфилмент, поставщики) +export const GET_INCOMING_SELLER_SUPPLIES = gql` + query GetIncomingSellerSupplies { + incomingSellerSupplies { + id + status + sellerId + seller { + id + name + inn + } + fulfillmentCenterId + fulfillmentCenter { + id + name + inn + } + requestedDeliveryDate + notes + + supplierId + supplier { + id + name + inn + } + + totalCostWithDelivery + + items { + id + product { + id + name + article + } + requestedQuantity + unitPrice + totalPrice + } + + createdAt + } + } +` + +export const GET_MY_SELLER_SUPPLY_REQUESTS = gql` + query GetMySellerSupplyRequests { + mySellerSupplyRequests { + id + status + sellerId + seller { + id + name + inn + } + fulfillmentCenterId + fulfillmentCenter { + id + name + inn + } + requestedDeliveryDate + notes + + totalCostWithDelivery + + items { + id + product { + id + name + article + } + requestedQuantity + unitPrice + totalPrice + } + + createdAt + } + } +` + +// ============================================================================= +// ✏️ MUTATIONS - ИЗМЕНЕНИЕ ДАННЫХ +// ============================================================================= + +export const CREATE_SELLER_CONSUMABLE_SUPPLY = gql` + mutation CreateSellerConsumableSupply($input: CreateSellerConsumableSupplyInput!) { + createSellerConsumableSupply(input: $input) { + success + message + supplyOrder { + id + status + createdAt + } + } + } +` + +export const UPDATE_SELLER_SUPPLY_STATUS = gql` + mutation UpdateSellerSupplyStatus($id: ID!, $status: SellerSupplyOrderStatus!, $notes: String) { + updateSellerSupplyStatus(id: $id, status: $status, notes: $notes) { + id + status + updatedAt + supplierApprovedAt + shippedAt + deliveredAt + supplierNotes + receiptNotes + } + } +` + +export const CANCEL_SELLER_SUPPLY = gql` + mutation CancelSellerSupply($id: ID!) { + cancelSellerSupply(id: $id) { + id + status + updatedAt + } + } +` diff --git a/src/graphql/resolvers/seller-consumables.ts b/src/graphql/resolvers/seller-consumables.ts new file mode 100644 index 0000000..3ff65ce --- /dev/null +++ b/src/graphql/resolvers/seller-consumables.ts @@ -0,0 +1,701 @@ +// ============================================================================= +// 📦 РЕЗОЛВЕРЫ ДЛЯ СИСТЕМЫ ПОСТАВОК РАСХОДНИКОВ СЕЛЛЕРА +// ============================================================================= + +import { GraphQLError } from 'graphql' + +import { prisma } from '@/lib/prisma' +import { notifyOrganization } from '@/lib/realtime' + +import { Context } from '../context' + +// ============================================================================= +// 🔍 QUERY RESOLVERS +// ============================================================================= + +export const sellerConsumableQueries = { + // Мои поставки (для селлеров - заказы которые я создал) + mySellerConsumableSupplies: 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.sellerConsumableSupplyOrder.findMany({ + where: { + sellerId: user.organizationId!, + }, + include: { + seller: true, + fulfillmentCenter: true, + supplier: true, + logisticsPartner: true, + receivedBy: true, + items: { + include: { + product: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }) + + return supplies + } catch (error) { + console.error('Error fetching seller consumable supplies:', error) + return [] // Возвращаем пустой массив вместо throw + } + }, + + // Входящие заказы от селлеров (для фулфилмента - заказы в мой ФФ) + incomingSellerSupplies: 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.sellerConsumableSupplyOrder.findMany({ + where: { + fulfillmentCenterId: user.organizationId!, + }, + include: { + seller: true, + fulfillmentCenter: true, + supplier: true, + logisticsPartner: true, + receivedBy: true, + items: { + include: { + product: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }) + + return supplies + } catch (error) { + console.error('Error fetching incoming seller supplies:', error) + return [] + } + }, + + // Заказы от селлеров (для поставщиков - заказы которые нужно выполнить) + mySellerSupplyRequests: 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.sellerConsumableSupplyOrder.findMany({ + where: { + supplierId: user.organizationId!, + }, + include: { + seller: true, + fulfillmentCenter: true, + supplier: true, + logisticsPartner: true, + receivedBy: true, + items: { + include: { + product: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }) + + return supplies + } catch (error) { + console.error('Error fetching seller supply requests:', error) + return [] + } + }, + + // Получение конкретной поставки селлера + sellerConsumableSupply: 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.sellerConsumableSupplyOrder.findUnique({ + where: { id: args.id }, + include: { + seller: true, + fulfillmentCenter: true, + supplier: true, + logisticsPartner: true, + receivedBy: true, + items: { + 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 consumable supply:', error) + throw new GraphQLError('Ошибка получения поставки') + } + }, +} + +// ============================================================================= +// ✏️ MUTATION RESOLVERS +// ============================================================================= + +export const sellerConsumableMutations = { + // Создание поставки расходников селлера + createSellerConsumableSupply: 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, items, notes } = args.input + + // 🔍 ВАЛИДАЦИЯ ПАРТНЕРОВ + + // Проверяем что фулфилмент-центр существует и является партнером + const fulfillmentCenter = await prisma.organization.findUnique({ + where: { id: fulfillmentCenterId }, + include: { + counterpartiesAsCounterparty: { + where: { organizationId: user.organizationId! }, + }, + }, + }) + + 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! }, + }, + }, + }) + + if (!supplier || supplier.type !== 'WHOLESALE') { + throw new GraphQLError('Поставщик не найден или имеет неверный тип') + } + + if (supplier.counterpartiesAsCounterparty.length === 0) { + throw new GraphQLError('Нет партнерских отношений с данным поставщиком') + } + + // 🔍 ВАЛИДАЦИЯ ТОВАРОВ И ОСТАТКОВ + let totalAmount = 0 + const validatedItems = [] + + for (const item of items) { + 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 (product.type !== 'CONSUMABLE') { + throw new GraphQLError(`Товар ${product.name} не является расходником`) + } + + // ✅ ПРОВЕРКА ОСТАТКОВ У ПОСТАВЩИКА + const availableStock = (product.stock || product.quantity || 0) - (product.ordered || 0) + + if (item.requestedQuantity > availableStock) { + throw new GraphQLError( + `Недостаточно остатков товара "${product.name}". ` + + `Доступно: ${availableStock} шт., запрашивается: ${item.requestedQuantity} шт.`, + ) + } + + const itemTotalPrice = product.price.toNumber() * item.requestedQuantity + totalAmount += itemTotalPrice + + validatedItems.push({ + productId: item.productId, + requestedQuantity: item.requestedQuantity, + unitPrice: product.price, + totalPrice: itemTotalPrice, + }) + } + + // 🚀 СОЗДАНИЕ ПОСТАВКИ В ТРАНЗАКЦИИ + const supplyOrder = await prisma.$transaction(async (tx) => { + // Создаем заказ поставки + const newOrder = await tx.sellerConsumableSupplyOrder.create({ + data: { + sellerId: user.organizationId!, + fulfillmentCenterId, + supplierId, + logisticsPartnerId, + requestedDeliveryDate: new Date(requestedDeliveryDate), + notes, + status: 'PENDING', + totalCostWithDelivery: totalAmount, + }, + include: { + seller: true, + fulfillmentCenter: true, + supplier: true, + logisticsPartner: true, + items: { + include: { + product: true, + }, + }, + }, + }) + + // Создаем позиции заказа + for (const item of validatedItems) { + await tx.sellerConsumableSupplyItem.create({ + data: { + supplyOrderId: newOrder.id, + ...item, + }, + }) + + // Резервируем товар у поставщика (увеличиваем ordered) + await tx.product.update({ + where: { id: item.productId }, + data: { + ordered: { + increment: item.requestedQuantity, + }, + }, + }) + } + + return newOrder + }) + + // 📨 УВЕДОМЛЕНИЯ + // Уведомляем поставщика о новом заказе + await notifyOrganization(supplierId, `Новый заказ от селлера ${user.organization.name}`, 'SUPPLY_ORDER_CREATED', { + orderId: supplyOrder.id, + }) + + // Уведомляем фулфилмент о входящей поставке + await notifyOrganization( + fulfillmentCenterId, + `Селлер ${user.organization.name} оформил поставку на ваш склад`, + 'INCOMING_SUPPLY_ORDER', + { orderId: supplyOrder.id }, + ) + + return { + success: true, + message: 'Поставка успешно создана', + supplyOrder: await prisma.sellerConsumableSupplyOrder.findUnique({ + where: { id: supplyOrder.id }, + include: { + seller: true, + fulfillmentCenter: true, + supplier: true, + logisticsPartner: true, + receivedBy: true, + items: { + include: { + product: true, + }, + }, + }, + }), + } + } catch (error) { + console.error('Error creating seller consumable supply:', error) + + if (error instanceof GraphQLError) { + throw error + } + + throw new GraphQLError('Ошибка создания поставки') + } + }, + + // Обновление статуса поставки (для поставщиков и фулфилмента) + updateSellerSupplyStatus: 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.sellerConsumableSupplyOrder.findUnique({ + where: { id: args.id }, + include: { + seller: true, + supplier: true, + fulfillmentCenter: 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.sellerConsumableSupplyOrder.update({ + where: { id: args.id }, + data: updateData, + include: { + seller: true, + fulfillmentCenter: true, + supplier: true, + logisticsPartner: true, + receivedBy: true, + items: { + include: { + product: true, + }, + }, + }, + }) + + // 📨 УВЕДОМЛЕНИЯ О СМЕНЕ СТАТУСА + if (status === 'APPROVED') { + await notifyOrganization( + supply.sellerId, + `Поставка одобрена поставщиком ${user.organization.name}`, + 'SUPPLY_APPROVED', + { orderId: args.id }, + ) + } + + if (status === 'SHIPPED') { + await notifyOrganization( + supply.sellerId, + `Поставка отгружена поставщиком ${user.organization.name}`, + 'SUPPLY_SHIPPED', + { orderId: args.id }, + ) + + await notifyOrganization( + supply.fulfillmentCenterId, + 'Поставка в пути. Ожидается доставка', + 'SUPPLY_IN_TRANSIT', + { orderId: args.id }, + ) + } + + if (status === 'DELIVERED') { + await notifyOrganization( + supply.sellerId, + `Поставка доставлена в ${supply.fulfillmentCenter.name}`, + 'SUPPLY_DELIVERED', + { orderId: args.id }, + ) + } + + if (status === 'COMPLETED') { + // 📦 СОЗДАНИЕ РАСХОДНИКОВ НА СКЛАДЕ ФУЛФИЛМЕНТА + + for (const item of updatedSupply.items) { + await prisma.supply.create({ + data: { + name: item.product.name, + article: item.product.article || `SELLER-${item.product.id}`, + description: `Расходники селлера ${supply.seller.name}`, + price: item.unitPrice, + quantity: item.receivedQuantity || item.requestedQuantity, + currentStock: item.receivedQuantity || item.requestedQuantity, + usedStock: 0, + type: 'SELLER_CONSUMABLES', // ✅ Тип для селлерских расходников + sellerOwnerId: supply.sellerId, // ✅ Владелец - селлер + organizationId: supply.fulfillmentCenterId, // ✅ Хранитель - фулфилмент + category: item.product.category || 'Расходники селлера', + status: 'available', + }, + }) + } + + await notifyOrganization( + supply.sellerId, + `Поставка завершена. Расходники размещены на складе ${supply.fulfillmentCenter.name}`, + 'SUPPLY_COMPLETED', + { orderId: args.id }, + ) + } + + return updatedSupply + } catch (error) { + console.error('Error updating seller supply status:', error) + + if (error instanceof GraphQLError) { + throw error + } + + throw new GraphQLError('Ошибка обновления статуса поставки') + } + }, + + // Отмена поставки селлером (только PENDING/APPROVED) + cancelSellerSupply: 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.sellerConsumableSupplyOrder.findUnique({ + where: { id: args.id }, + include: { + seller: true, + items: { + 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.sellerConsumableSupplyOrder.update({ + where: { id: args.id }, + data: { + status: 'CANCELLED', + updatedAt: new Date(), + }, + include: { + seller: true, + fulfillmentCenter: true, + supplier: true, + items: { + include: { + product: true, + }, + }, + }, + }) + + // Освобождаем зарезервированные товары у поставщика + for (const item of supply.items) { + await tx.product.update({ + where: { id: item.productId }, + data: { + ordered: { + decrement: item.requestedQuantity, + }, + }, + }) + } + + return updated + }) + + // 📨 УВЕДОМЛЕНИЯ ОБ ОТМЕНЕ + if (supply.supplierId) { + await notifyOrganization( + supply.supplierId, + `Селлер ${user.organization.name} отменил заказ`, + 'SUPPLY_CANCELLED', + { orderId: args.id }, + ) + } + + await notifyOrganization( + supply.fulfillmentCenterId, + `Селлер ${user.organization.name} отменил поставку`, + 'SUPPLY_CANCELLED', + { orderId: args.id }, + ) + + return cancelledSupply + } catch (error) { + console.error('Error cancelling seller supply:', error) + + if (error instanceof GraphQLError) { + throw error + } + + throw new GraphQLError('Ошибка отмены поставки') + } + }, +}