feat: модульная архитектура sidebar и улучшения навигации

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Veronika Smirnova
2025-08-30 15:51:41 +03:00
parent 8391f40e87
commit b40ac083ab
128 changed files with 9366 additions and 17283 deletions

View File

@ -0,0 +1,848 @@
import { GraphQLError } from 'graphql'
import { prisma } from '@/lib/prisma'
import { Context } from '../context'
// ========== GOODS SUPPLY V2 RESOLVERS (ЗАКОММЕНТИРОВАНО) ==========
// Раскомментируйте для активации системы товарных поставок V2
// ========== V2 RESOLVERS START ==========
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' },
})
}
try {
const orders = await prisma.goodsSupplyOrder.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,
},
orderBy: {
createdAt: '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({
where: {
fulfillmentCenterId: user.organizationId!,
},
include: {
seller: {
include: {
phones: true,
emails: 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,
},
orderBy: {
requestedDeliveryDate: 'asc',
},
})
// Фильтруем коммерческие данные селлера
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' },
})
}
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 orders.map(order => ({
...order,
items: order.items.map(item => ({
...item,
recipe: null, // Поставщик не видит рецептуры
})),
}))
} catch (error) {
throw new GraphQLError('Ошибка получения заказов поставок', {
extensions: { code: 'INTERNAL_ERROR', originalError: error },
})
}
},
// Детали конкретной поставки
goodsSupplyOrderV2: async (_: unknown, args: { id: string }, context: Context) => {
const { user } = context
if (!user?.organizationId) {
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,
},
})
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' },
})
}
try {
const recipes = await prisma.productRecipe.findMany({
where: {
product: {
organizationId: user.organizationId!,
},
},
include: {
product: {
include: {
category: true,
},
},
components: {
include: {
material: true,
},
},
services: {
include: {
service: true,
},
},
},
orderBy: {
updatedAt: 'desc',
},
})
return recipes
} catch (error) {
throw new GraphQLError('Ошибка получения рецептур', {
extensions: { code: 'INTERNAL_ERROR', originalError: error },
})
}
},
},
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' },
})
}
try {
// Проверяем фулфилмент-центр
const fulfillmentCenter = await prisma.organization.findFirst({
where: {
id: input.fulfillmentCenterId,
type: 'FULFILLMENT',
},
})
if (!fulfillmentCenter) {
throw new GraphQLError('Фулфилмент-центр не найден', {
extensions: { code: 'NOT_FOUND' },
})
}
// Проверяем товары и рецептуры
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' },
})
}
}
}
// Создаем поставку в транзакции
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',
},
})
// Создаем товары
let totalAmount = 0
let totalItems = 0
for (const itemInput of input.items) {
const itemTotal = itemInput.price * itemInput.quantity
totalAmount += itemTotal
totalItems += itemInput.quantity
await tx.goodsSupplyOrderItem.create({
data: {
orderId: newOrder.id,
productId: itemInput.productId,
quantity: itemInput.quantity,
price: itemInput.price,
totalPrice: itemTotal,
recipeId: itemInput.recipeId,
},
})
}
// Создаем запросы услуг
for (const serviceInput of input.requestedServices) {
const service = await tx.service.findUnique({
where: { id: serviceInput.serviceId },
})
if (!service) {
throw new Error(`Услуга ${serviceInput.serviceId} не найдена`)
}
const serviceTotal = service.price * serviceInput.quantity
totalAmount += serviceTotal
await tx.fulfillmentServiceRequest.create({
data: {
orderId: newOrder.id,
serviceId: serviceInput.serviceId,
quantity: serviceInput.quantity,
price: service.price,
totalPrice: serviceTotal,
status: 'PENDING',
},
})
}
// Обновляем итоги
await tx.goodsSupplyOrder.update({
where: { id: newOrder.id },
data: {
totalAmount,
totalItems,
},
})
return newOrder
})
// Получаем созданную поставку с полными данными
const createdOrder = await prisma.goodsSupplyOrder.findUnique({
where: { id: order.id },
include: {
seller: true,
fulfillmentCenter: true,
items: {
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 },
data: {
receivedQuantity: itemInput.receivedQuantity,
damagedQuantity: itemInput.damagedQuantity || 0,
acceptanceNotes: itemInput.acceptanceNotes,
},
})
// Обновляем остатки расходников по рецептуре
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' },
})
}
try {
const order = await prisma.goodsSupplyOrder.findUnique({
where: { id },
})
if (!order) {
throw new GraphQLError('Поставка не найдена', {
extensions: { code: 'NOT_FOUND' },
})
}
// Проверка прав на отмену
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 },
})
}
},
},
}