Обновлены модели и компоненты для управления поставками и расходниками. Добавлены новые поля в модели SupplyOrder и соответствующие резолверы для поддержки логистики. Реализованы компоненты уведомлений для отображения статуса логистических заявок и поставок. Оптимизирован интерфейс для улучшения пользовательского опыта, добавлены логи для диагностики запросов. Обновлены GraphQL схемы и мутации для поддержки новых функциональных возможностей.

This commit is contained in:
Veronika Smirnova
2025-08-03 17:04:29 +03:00
parent a33adda9d7
commit 8407ca397c
34 changed files with 5382 additions and 1795 deletions

22
src/graphql/context.ts Normal file
View File

@ -0,0 +1,22 @@
import { PrismaClient } from "@prisma/client";
export interface Context {
user: {
id: string;
organization?: {
id: string;
type: string;
};
} | null;
currentUser?: {
id: string;
organization: {
id: string;
type: string;
};
} | null;
admin?: {
id: string;
} | null;
prisma: PrismaClient;
}

View File

@ -746,6 +746,42 @@ export const CREATE_SUPPLY_ORDER = gql`
}
`;
// Мутация для назначения логистики на поставку фулфилментом
export const ASSIGN_LOGISTICS_TO_SUPPLY = gql`
mutation AssignLogisticsToSupply(
$supplyOrderId: ID!
$logisticsPartnerId: ID!
$responsibleId: ID
) {
assignLogisticsToSupply(
supplyOrderId: $supplyOrderId
logisticsPartnerId: $logisticsPartnerId
responsibleId: $responsibleId
) {
success
message
order {
id
status
logisticsPartnerId
responsibleId
logisticsPartner {
id
name
fullName
type
}
responsible {
id
firstName
lastName
email
}
}
}
}
`;
// Мутации для логистики
export const CREATE_LOGISTICS = gql`
mutation CreateLogistics($input: LogisticsInput!) {
@ -924,8 +960,16 @@ export const RELEASE_PRODUCT_RESERVE = gql`
// Мутация для обновления статуса "в пути"
export const UPDATE_PRODUCT_IN_TRANSIT = gql`
mutation UpdateProductInTransit($productId: ID!, $quantity: Int!, $operation: String!) {
updateProductInTransit(productId: $productId, quantity: $quantity, operation: $operation) {
mutation UpdateProductInTransit(
$productId: ID!
$quantity: Int!
$operation: String!
) {
updateProductInTransit(
productId: $productId
quantity: $quantity
operation: $operation
) {
success
message
product {

View File

@ -111,6 +111,44 @@ export const GET_MY_FULFILLMENT_SUPPLIES = gql`
}
`;
export const GET_SELLER_SUPPLIES_ON_WAREHOUSE = gql`
query GetSellerSuppliesOnWarehouse {
sellerSuppliesOnWarehouse {
id
name
description
price
quantity
unit
category
status
date
supplier
minStock
currentStock
usedStock
imageUrl
type
shopLocation
createdAt
updatedAt
organization {
id
name
fullName
type
}
sellerOwner {
id
name
fullName
inn
type
}
}
}
`;
export const GET_MY_LOGISTICS = gql`
query GetMyLogistics {
myLogistics {
@ -122,6 +160,25 @@ export const GET_MY_LOGISTICS = gql`
description
createdAt
updatedAt
organization {
id
name
fullName
}
}
}
`;
export const GET_LOGISTICS_PARTNERS = gql`
query GetLogisticsPartners {
logisticsPartners {
id
name
fullName
type
address
phones
emails
}
}
`;
@ -607,6 +664,8 @@ export const GET_MY_EMPLOYEES = gql`
firstName
lastName
middleName
fullName
name
birthDate
avatar
passportSeries
@ -927,6 +986,7 @@ export const GET_SUPPLY_ORDERS = gql`
supplyOrders {
id
organizationId
partnerId
deliveryDate
status
totalAmount
@ -1023,11 +1083,7 @@ export const GET_SELLER_STATS_CACHE = gql`
$dateFrom: String
$dateTo: String
) {
getSellerStatsCache(
period: $period
dateFrom: $dateFrom
dateTo: $dateTo
) {
getSellerStatsCache(period: $period, dateFrom: $dateFrom, dateTo: $dateTo) {
success
message
fromCache

View File

@ -900,7 +900,7 @@ export const resolvers = {
}
// Возвращаем заказы где текущая организация является заказчиком, поставщиком, получателем или логистическим партнером
return await prisma.supplyOrder.findMany({
const orders = await prisma.supplyOrder.findMany({
where: {
OR: [
{ organizationId: currentUser.organization.id }, // Заказы созданные организацией
@ -939,6 +939,8 @@ export const resolvers = {
},
orderBy: { createdAt: "desc" },
});
return orders;
},
// Счетчик поставок, требующих одобрения
@ -969,12 +971,17 @@ export const resolvers = {
},
});
// Расходники селлеров (созданные другими для нас) - требуют подтверждения получения
// Расходники селлеров (созданные другими для нас) - требуют действий фулфилмента
const sellerSupplyOrders = await prisma.supplyOrder.count({
where: {
fulfillmentCenterId: currentUser.organization.id, // Получатель - мы
organizationId: { not: currentUser.organization.id }, // Создали НЕ мы
status: "IN_TRANSIT", // В пути - нужно подтвердить получение
status: {
in: [
"SUPPLIER_APPROVED", // Поставщик подтвердил - нужно назначить логистику
"IN_TRANSIT", // В пути - нужно подтвердить получение
],
},
},
});
@ -986,9 +993,30 @@ export const resolvers = {
},
});
// Общий счетчик поставок
const pendingSupplyOrders =
ourSupplyOrders + sellerSupplyOrders + incomingSupplierOrders;
// 🚚 ЛОГИСТИЧЕСКИЕ ЗАЯВКИ ДЛЯ ЛОГИСТИКИ (LOGIST) - требуют действий логистики
const logisticsOrders = await prisma.supplyOrder.count({
where: {
logisticsPartnerId: currentUser.organization.id, // Мы - назначенная логистика
status: {
in: [
"CONFIRMED", // Подтверждено фулфилментом - нужно подтвердить логистикой
"LOGISTICS_CONFIRMED", // Подтверждено логистикой - нужно забрать товар у поставщика
],
},
},
});
// Общий счетчик поставок в зависимости от типа организации
let pendingSupplyOrders = 0;
if (currentUser.organization.type === "FULFILLMENT") {
pendingSupplyOrders = ourSupplyOrders + sellerSupplyOrders;
} else if (currentUser.organization.type === "WHOLESALE") {
pendingSupplyOrders = incomingSupplierOrders;
} else if (currentUser.organization.type === "LOGIST") {
pendingSupplyOrders = logisticsOrders;
} else if (currentUser.organization.type === "SELLER") {
pendingSupplyOrders = 0; // Селлеры не подтверждают поставки, только отслеживают
}
// Считаем входящие заявки на партнерство со статусом PENDING
const pendingIncomingRequests = await prisma.counterpartyRequest.count({
@ -1003,6 +1031,7 @@ export const resolvers = {
ourSupplyOrders: ourSupplyOrders, // Расходники фулфилмента
sellerSupplyOrders: sellerSupplyOrders, // Расходники селлеров
incomingSupplierOrders: incomingSupplierOrders, // 🔔 Входящие заказы для поставщиков
logisticsOrders: logisticsOrders, // 🚚 Логистические заявки для логистики
incomingRequests: pendingIncomingRequests,
total: pendingSupplyOrders + pendingIncomingRequests,
};
@ -1146,9 +1175,11 @@ export const resolvers = {
);
// Подсчитываем количество из таблицы Supply (актуальные остатки на складе фулфилмента)
// ИСПРАВЛЕНО: считаем только расходники фулфилмента, исключаем расходники селлеров
const fulfillmentSuppliesFromWarehouse = await prisma.supply.findMany({
where: {
organizationId: organizationId, // Склад фулфилмента
type: "FULFILLMENT_CONSUMABLES", // ТОЛЬКО расходники фулфилмента
},
});
@ -1203,39 +1234,40 @@ export const resolvers = {
`📊 FULFILLMENT SUPPLIES RECEIVED TODAY (ПРИБЫЛО): ${fulfillmentSuppliesReceivedToday.length} orders, ${fulfillmentSuppliesChangeToday} items`
);
// Расходники селлеров - получаем из заказов от селлеров (расходники = CONSUMABLE)
// Согласно правилам: селлеры заказывают расходники у поставщиков и доставляют на склад фулфилмента
const sellerSuppliesCount = sellerDeliveredOrders.reduce(
(sum, order) =>
sum +
order.items.reduce(
(itemSum, item) =>
itemSum +
(item.product.type === "CONSUMABLE" ? item.quantity : 0),
0
),
// Расходники селлеров - получаем из таблицы Supply (актуальные остатки на складе фулфилмента)
// ИСПРАВЛЕНО: считаем из Supply с типом SELLER_CONSUMABLES
const sellerSuppliesFromWarehouse = await prisma.supply.findMany({
where: {
organizationId: organizationId, // Склад фулфилмента
type: "SELLER_CONSUMABLES", // ТОЛЬКО расходники селлеров
},
});
const sellerSuppliesCount = sellerSuppliesFromWarehouse.reduce(
(sum, supply) => sum + (supply.currentStock || 0),
0
);
console.log(
`💼 SELLER SUPPLIES DEBUG: totalCount=${sellerSuppliesCount} (from delivered orders)`
`💼 SELLER SUPPLIES DEBUG: totalCount=${sellerSuppliesCount} (from Supply warehouse)`
);
// Изменения расходников селлеров за сутки - используем уже полученные данные
const sellerSuppliesChangeToday = recentSellerDeliveredOrders.reduce(
(sum, order) =>
sum +
order.items.reduce(
(itemSum, item) =>
itemSum +
(item.product.type === "CONSUMABLE" ? item.quantity : 0),
0
),
// Изменения расходников селлеров за сутки - считаем из Supply записей, созданных за сутки
const sellerSuppliesReceivedToday = await prisma.supply.findMany({
where: {
organizationId: organizationId, // Склад фулфилмента
type: "SELLER_CONSUMABLES", // ТОЛЬКО расходники селлеров
createdAt: { gte: oneDayAgo }, // Созданы за последние сутки
},
});
const sellerSuppliesChangeToday = sellerSuppliesReceivedToday.reduce(
(sum, supply) => sum + (supply.currentStock || 0),
0
);
console.log(
`📊 SELLER SUPPLIES RECEIVED TODAY: ${recentSellerDeliveredOrders.length} orders, ${sellerSuppliesChangeToday} items`
`📊 SELLER SUPPLIES RECEIVED TODAY: ${sellerSuppliesReceivedToday.length} supplies, ${sellerSuppliesChangeToday} items`
);
// Вычисляем процентные изменения
@ -1327,6 +1359,24 @@ export const resolvers = {
});
},
// Логистические партнеры (организации-логисты)
logisticsPartners: async (_: unknown, __: unknown, context: Context) => {
if (!context.user) {
throw new GraphQLError("Требуется авторизация", {
extensions: { code: "UNAUTHENTICATED" },
});
}
// Получаем все организации типа LOGIST
return await prisma.organization.findMany({
where: {
type: "LOGIST",
// Убираем фильтр по статусу пока не определим правильные значения
},
orderBy: { createdAt: "desc" }, // Сортируем по дате создания вместо name
});
},
// Мои поставки Wildberries
myWildberriesSupplies: async (
_: unknown,
@ -1358,6 +1408,94 @@ export const resolvers = {
});
},
// Расходники селлеров на складе фулфилмента (новый resolver)
sellerSuppliesOnWarehouse: async (
_: unknown,
__: unknown,
context: Context
) => {
if (!context.user) {
throw new GraphQLError("Требуется авторизация", {
extensions: { code: "UNAUTHENTICATED" },
});
}
const currentUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true },
});
if (!currentUser?.organization) {
throw new GraphQLError("У пользователя нет организации");
}
// Только фулфилмент может получать расходники селлеров на своем складе
if (currentUser.organization.type !== "FULFILLMENT") {
throw new GraphQLError("Доступ разрешен только для фулфилмент-центров");
}
// ИСПРАВЛЕНО: Усиленная фильтрация расходников селлеров
const sellerSupplies = await prisma.supply.findMany({
where: {
organizationId: currentUser.organization.id, // На складе этого фулфилмента
type: "SELLER_CONSUMABLES" as const, // Только расходники селлеров
sellerOwnerId: { not: null }, // ОБЯЗАТЕЛЬНО должен быть владелец-селлер
},
include: {
organization: true, // Фулфилмент-центр (хранитель)
sellerOwner: true, // Селлер-владелец расходников
},
orderBy: { createdAt: "desc" },
});
// Логирование для отладки
console.log(
"🔍 ИСПРАВЛЕНО: Запрос расходников селлеров на складе фулфилмента:",
{
fulfillmentId: currentUser.organization.id,
fulfillmentName: currentUser.organization.name,
totalSupplies: sellerSupplies.length,
sellerSupplies: sellerSupplies.map((supply) => ({
id: supply.id,
name: supply.name,
type: supply.type,
sellerOwnerId: supply.sellerOwnerId,
sellerOwnerName:
supply.sellerOwner?.name || supply.sellerOwner?.fullName,
currentStock: supply.currentStock,
})),
}
);
// ДВОЙНАЯ ПРОВЕРКА: Фильтруем на уровне кода для гарантии
const filteredSupplies = sellerSupplies.filter((supply) => {
const isValid =
supply.type === "SELLER_CONSUMABLES" &&
supply.sellerOwnerId != null &&
supply.sellerOwner != null;
if (!isValid) {
console.warn("⚠️ ОТФИЛЬТРОВАН некорректный расходник:", {
id: supply.id,
name: supply.name,
type: supply.type,
sellerOwnerId: supply.sellerOwnerId,
hasSellerOwner: !!supply.sellerOwner,
});
}
return isValid;
});
console.log("✅ ФИНАЛЬНЫЙ РЕЗУЛЬТАТ после фильтрации:", {
originalCount: sellerSupplies.length,
filteredCount: filteredSupplies.length,
removedCount: sellerSupplies.length - filteredSupplies.length,
});
return filteredSupplies;
},
// Мои товары и расходники (для поставщиков)
myProducts: async (_: unknown, __: unknown, context: Context) => {
console.log("🔍 MY_PRODUCTS RESOLVER - ВЫЗВАН:", {
@ -1830,34 +1968,52 @@ export const resolvers = {
// Сотрудники организации
myEmployees: async (_: unknown, __: unknown, context: Context) => {
console.log("🔍 myEmployees resolver called");
if (!context.user) {
console.log("❌ No user in context for myEmployees");
throw new GraphQLError("Требуется авторизация", {
extensions: { code: "UNAUTHENTICATED" },
});
}
const currentUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true },
});
console.log("✅ User authenticated for myEmployees:", context.user.id);
if (!currentUser?.organization) {
throw new GraphQLError("У пользователя нет организации");
try {
const currentUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true },
});
if (!currentUser?.organization) {
console.log("❌ User has no organization");
throw new GraphQLError("У пользователя нет организации");
}
console.log(
"📊 User organization type:",
currentUser.organization.type
);
if (currentUser.organization.type !== "FULFILLMENT") {
console.log("❌ Not a fulfillment center");
throw new GraphQLError("Доступно только для фулфилмент центров");
}
const employees = await prisma.employee.findMany({
where: { organizationId: currentUser.organization.id },
include: {
organization: true,
},
orderBy: { createdAt: "desc" },
});
console.log("👥 Found employees:", employees.length);
return employees;
} catch (error) {
console.error("❌ Error in myEmployees resolver:", error);
throw error;
}
if (currentUser.organization.type !== "FULFILLMENT") {
throw new GraphQLError("Доступно только для фулфилмент центров");
}
const employees = await prisma.employee.findMany({
where: { organizationId: currentUser.organization.id },
include: {
organization: true,
},
orderBy: { createdAt: "desc" },
});
return employees;
},
// Получение сотрудника по ID
@ -3937,7 +4093,19 @@ export const resolvers = {
include: { organization: true },
});
if (!currentUser?.organization) {
console.log("🔍 Проверка пользователя:", {
userId: context.user.id,
userFound: !!currentUser,
organizationFound: !!currentUser?.organization,
organizationType: currentUser?.organization?.type,
organizationId: currentUser?.organization?.id,
});
if (!currentUser) {
throw new GraphQLError("Пользователь не найден");
}
if (!currentUser.organization) {
throw new GraphQLError("У пользователя нет организации");
}
@ -4067,21 +4235,34 @@ export const resolvers = {
initialStatus = "CONFIRMED"; // Логист может сразу подтверждать заказы
}
const supplyOrder = await prisma.supplyOrder.create({
data: {
partnerId: args.input.partnerId,
deliveryDate: new Date(args.input.deliveryDate),
totalAmount: new Prisma.Decimal(totalAmount),
totalItems: totalItems,
organizationId: currentUser.organization.id,
fulfillmentCenterId: fulfillmentCenterId,
logisticsPartnerId: args.input.logisticsPartnerId,
consumableType: args.input.consumableType, // Классификация расходников
status: initialStatus,
items: {
create: orderItems,
},
// Подготавливаем данные для создания заказа
const createData: any = {
partnerId: args.input.partnerId,
deliveryDate: new Date(args.input.deliveryDate),
totalAmount: new Prisma.Decimal(totalAmount),
totalItems: totalItems,
organizationId: currentUser.organization.id,
fulfillmentCenterId: fulfillmentCenterId,
consumableType: args.input.consumableType,
status: initialStatus,
items: {
create: orderItems,
},
};
// 🔄 ЛОГИСТИКА ОПЦИОНАЛЬНА: добавляем только если передана
if (args.input.logisticsPartnerId) {
createData.logisticsPartnerId = args.input.logisticsPartnerId;
}
console.log("🔍 Создаем SupplyOrder с данными:", {
hasLogistics: !!args.input.logisticsPartnerId,
logisticsId: args.input.logisticsPartnerId,
createData: createData,
});
const supplyOrder = await prisma.supplyOrder.create({
data: createData,
include: {
partner: {
include: {
@ -5961,17 +6142,13 @@ export const resolvers = {
});
if (product) {
// Согласно правилам: Основные значения = Предыдущие остатки + Прибыло - Убыло
const currentStock = product.stock || product.quantity || 0;
const newStock = Math.max(currentStock - item.quantity, 0);
// ИСПРАВЛЕНО: НЕ списываем повторно, только переводим из inTransit в sold
// Остаток уже был уменьшен при создании/одобрении заказа
await prisma.product.update({
where: { id: item.product.id },
data: {
// Обновляем основные остатки (УБЫЛО)
stock: newStock,
quantity: newStock, // Синхронизируем оба поля для совместимости
// Обновляем дополнительные значения
// НЕ ТРОГАЕМ stock - он уже правильно уменьшен при заказе
// Только переводим из inTransit в sold
inTransit: Math.max(
(product.inTransit || 0) - item.quantity,
0
@ -5980,7 +6157,11 @@ export const resolvers = {
},
});
console.log(
`✅ Товар поставщика "${product.name}" обновлен: доставлено ${item.quantity} единиц (остаток: ${currentStock} -> ${newStock})`
`✅ Товар поставщика "${product.name}" обновлен: доставлено ${
item.quantity
} единиц (остаток НЕ ИЗМЕНЕН: ${
product.stock || product.quantity || 0
})`
);
}
}
@ -6073,6 +6254,117 @@ export const resolvers = {
}
},
// Назначение логистики фулфилментом на заказ селлера
assignLogisticsToSupply: async (
_: unknown,
args: {
supplyOrderId: string;
logisticsPartnerId: string;
responsibleId?: string;
},
context: Context
) => {
if (!context.user) {
throw new GraphQLError("Требуется авторизация", {
extensions: { code: "UNAUTHENTICATED" },
});
}
const currentUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true },
});
if (!currentUser?.organization) {
throw new GraphQLError("У пользователя нет организации");
}
// Проверяем, что пользователь - фулфилмент
if (currentUser.organization.type !== "FULFILLMENT") {
throw new GraphQLError("Только фулфилмент может назначать логистику");
}
try {
// Находим заказ
const existingOrder = await prisma.supplyOrder.findUnique({
where: { id: args.supplyOrderId },
include: {
partner: true,
fulfillmentCenter: true,
logisticsPartner: true,
items: {
include: { product: true },
},
},
});
if (!existingOrder) {
throw new GraphQLError("Заказ поставки не найден");
}
// Проверяем, что это заказ для нашего фулфилмент-центра
if (existingOrder.fulfillmentCenterId !== currentUser.organization.id) {
throw new GraphQLError("Нет доступа к этому заказу");
}
// Проверяем, что статус позволяет назначить логистику
if (existingOrder.status !== "SUPPLIER_APPROVED") {
throw new GraphQLError(
`Нельзя назначить логистику для заказа со статусом ${existingOrder.status}`
);
}
// Проверяем, что логистическая компания существует
const logisticsPartner = await prisma.organization.findUnique({
where: { id: args.logisticsPartnerId },
});
if (!logisticsPartner || logisticsPartner.type !== "LOGIST") {
throw new GraphQLError("Логистическая компания не найдена");
}
// Обновляем заказ
const updatedOrder = await prisma.supplyOrder.update({
where: { id: args.supplyOrderId },
data: {
logisticsPartner: {
connect: { id: args.logisticsPartnerId },
},
status: "CONFIRMED", // Переводим в статус "подтвержден фулфилментом"
},
include: {
partner: true,
fulfillmentCenter: true,
logisticsPartner: true,
items: {
include: { product: true },
},
},
});
console.log(`✅ Логистика назначена на заказ ${args.supplyOrderId}:`, {
logisticsPartner: logisticsPartner.name,
responsible: args.responsibleId,
newStatus: "CONFIRMED",
});
return {
success: true,
message: "Логистика успешно назначена",
order: updatedOrder,
};
} catch (error) {
console.error("❌ Ошибка при назначении логистики:", error);
return {
success: false,
message:
error instanceof Error
? error.message
: "Ошибка при назначении логистики",
};
}
},
// Резолверы для новых действий с заказами поставок
supplierApproveOrder: async (
_: unknown,
@ -6458,7 +6750,7 @@ export const resolvers = {
where: {
id: args.id,
logisticsPartnerId: currentUser.organization.id,
status: "SUPPLIER_APPROVED",
OR: [{ status: "SUPPLIER_APPROVED" }, { status: "CONFIRMED" }],
},
});
@ -6530,7 +6822,7 @@ export const resolvers = {
where: {
id: args.id,
logisticsPartnerId: currentUser.organization.id,
status: "SUPPLIER_APPROVED",
OR: [{ status: "SUPPLIER_APPROVED" }, { status: "CONFIRMED" }],
},
});
@ -6608,9 +6900,15 @@ export const resolvers = {
include: {
items: {
include: {
product: true,
product: {
include: {
category: true,
},
},
},
},
organization: true, // Селлер-создатель заказа
partner: true, // Поставщик
},
});
@ -6651,17 +6949,13 @@ export const resolvers = {
});
if (product) {
// Согласно правилам: Основные значения = Предыдущие остатки + Прибыло - Убыло
const currentStock = product.stock || product.quantity || 0;
const newStock = Math.max(currentStock - item.quantity, 0);
// ИСПРАВЛЕНО: НЕ списываем повторно, только переводим из inTransit в sold
// Остаток уже был уменьшен при создании/одобрении заказа
await prisma.product.update({
where: { id: item.product.id },
data: {
// Обновляем основные остатки (УБЫЛО)
stock: newStock,
quantity: newStock, // Синхронизируем оба поля для совместимости
// Обновляем дополнительные значения
// НЕ ТРОГАЕМ stock - он уже правильно уменьшен при заказе
// Только переводим из inTransit в sold
inTransit: Math.max(
(product.inTransit || 0) - item.quantity,
0
@ -6670,33 +6964,62 @@ export const resolvers = {
},
});
console.log(
`✅ Товар поставщика "${product.name}" обновлен: доставлено ${item.quantity} единиц`
`✅ Товар поставщика "${product.name}" обновлен: получено ${item.quantity} единиц`
);
console.log(
` 📊 Остатки: ${currentStock} -> ${newStock} (УБЫЛО: ${item.quantity})`
` 📊 Остаток: ${
product.stock || product.quantity || 0
} (НЕ ИЗМЕНЕН - уже списан при заказе)`
);
console.log(
` 🚚 В пути: ${product.inTransit || 0} -> ${Math.max(
(product.inTransit || 0) - item.quantity,
0
)}`
)} (УБЫЛО: ${item.quantity})`
);
console.log(
` 💰 Продано: ${product.sold || 0} -> ${
(product.sold || 0) + item.quantity
}`
} (ПРИБЫЛО: ${item.quantity})`
);
}
}
// Обновляем склад фулфилмента
// Обновляем склад фулфилмента с учетом типа расходников
console.log("📦 Обновляем склад фулфилмента...");
console.log(
`🏷️ Тип поставки: ${
existingOrder.consumableType || "FULFILLMENT_CONSUMABLES"
}`
);
for (const item of existingOrder.items) {
// Определяем тип расходников и владельца
const isSellerSupply =
existingOrder.consumableType === "SELLER_CONSUMABLES";
const supplyType = isSellerSupply
? "SELLER_CONSUMABLES"
: "FULFILLMENT_CONSUMABLES";
const sellerOwnerId = isSellerSupply
? updatedOrder.organization?.id
: null;
// Для расходников селлеров ищем по имени И по владельцу
const whereCondition = isSellerSupply
? {
organizationId: currentUser.organization.id,
name: item.product.name,
type: "SELLER_CONSUMABLES" as const,
sellerOwnerId: sellerOwnerId,
}
: {
organizationId: currentUser.organization.id,
name: item.product.name,
type: "FULFILLMENT_CONSUMABLES" as const,
};
const existingSupply = await prisma.supply.findFirst({
where: {
organizationId: currentUser.organization.id,
name: item.product.name,
},
where: whereCondition,
});
if (existingSupply) {
@ -6709,9 +7032,13 @@ export const resolvers = {
},
});
console.log(
`📈 Обновлен существующий расходник фулфилмента "${
item.product.name
}": ${existingSupply.currentStock} -> ${
`📈 Обновлен существующий ${
isSellerSupply ? "расходник селлера" : "расходник фулфилмента"
} "${item.product.name}" ${
isSellerSupply
? `(владелец: ${updatedOrder.organization?.name})`
: ""
}: ${existingSupply.currentStock} -> ${
existingSupply.currentStock + item.quantity
}`
);
@ -6719,9 +7046,13 @@ export const resolvers = {
await prisma.supply.create({
data: {
name: item.product.name,
description:
item.product.description ||
`Расходники от ${updatedOrder.partner.name}`,
description: isSellerSupply
? `Расходники селлера ${
updatedOrder.organization?.name ||
updatedOrder.organization?.fullName
}`
: item.product.description ||
`Расходники от ${updatedOrder.partner.name}`,
price: item.price,
quantity: item.quantity,
currentStock: item.quantity,
@ -6733,11 +7064,21 @@ export const resolvers = {
updatedOrder.partner.name ||
updatedOrder.partner.fullName ||
"Поставщик",
type: supplyType as
| "SELLER_CONSUMABLES"
| "FULFILLMENT_CONSUMABLES",
sellerOwnerId: sellerOwnerId,
organizationId: currentUser.organization.id,
},
});
console.log(
` Создан новый расходник фулфилмента "${item.product.name}": ${item.quantity} единиц`
` Создан новый ${
isSellerSupply ? "расходник селлера" : "расходник фулфилмента"
} "${item.product.name}" ${
isSellerSupply
? `(владелец: ${updatedOrder.organization?.name})`
: ""
}: ${item.quantity} единиц`
);
}
}
@ -6849,7 +7190,10 @@ export const resolvers = {
// Иначе загружаем отдельно
return await prisma.supply.findMany({
where: { organizationId: parent.id },
include: { organization: true },
include: {
organization: true,
sellerOwner: true, // Включаем информацию о селлере-владельце
},
orderBy: { createdAt: "desc" },
});
},
@ -6949,6 +7293,20 @@ export const resolvers = {
},
Employee: {
fullName: (parent: {
firstName: string;
lastName: string;
middleName?: string;
}) => {
const parts = [parent.lastName, parent.firstName];
if (parent.middleName) {
parts.push(parent.middleName);
}
return parts.join(" ");
},
name: (parent: { firstName: string; lastName: string }) => {
return `${parent.firstName} ${parent.lastName}`;
},
birthDate: (parent: { birthDate?: Date | string | null }) => {
if (!parent.birthDate) return null;
if (parent.birthDate instanceof Date) {

View File

@ -0,0 +1,6 @@
import { Context } from "../context";
export const authResolvers = {
Query: {},
Mutation: {},
};

View File

@ -0,0 +1,6 @@
import { Context } from "../context";
export const employeeResolvers = {
Query: {},
Mutation: {},
};

View File

@ -0,0 +1,84 @@
import { JSONScalar, DateTimeScalar } from "../scalars";
import { authResolvers } from "./auth";
import { employeeResolvers } from "./employees";
import { logisticsResolvers } from "./logistics";
import { suppliesResolvers } from "./supplies";
// Функция для объединения резолверов
const mergeResolvers = (...resolvers: any[]) => {
const result: any = {
Query: {},
Mutation: {},
};
for (const resolver of resolvers) {
if (resolver.Query) {
Object.assign(result.Query, resolver.Query);
}
if (resolver.Mutation) {
Object.assign(result.Mutation, resolver.Mutation);
}
// Объединяем другие типы резолверов (например, Employee, Organization и т.д.)
for (const [key, value] of Object.entries(resolver)) {
if (key !== "Query" && key !== "Mutation") {
if (!result[key]) {
result[key] = {};
}
Object.assign(result[key], value);
}
}
}
return result;
};
// Временно импортируем старые резолверы для частей, которые еще не вынесены
// TODO: Постепенно убрать это после полного рефакторинга
import { resolvers as oldResolvers } from "../resolvers";
// Объединяем новые модульные резолверы с остальными старыми
export const resolvers = mergeResolvers(
// Скалярные типы
{
JSON: JSONScalar,
DateTime: DateTimeScalar,
},
// Новые модульные резолверы
authResolvers,
employeeResolvers,
logisticsResolvers,
suppliesResolvers,
// Временно добавляем старые резолверы, исключая уже вынесенные
{
Query: {
...oldResolvers.Query,
// Исключаем уже вынесенные Query
myEmployees: undefined,
logisticsPartners: undefined,
pendingSuppliesCount: undefined,
},
Mutation: {
...oldResolvers.Mutation,
// Исключаем уже вынесенные Mutation
sendSmsCode: undefined,
verifySmsCode: undefined,
verifyInn: undefined,
registerFulfillmentOrganization: undefined,
createEmployee: undefined,
updateEmployee: undefined,
deleteEmployee: undefined,
assignLogisticsToSupply: undefined,
logisticsConfirmOrder: undefined,
logisticsRejectOrder: undefined,
},
// Остальные типы пока оставляем из старых резолверов
User: oldResolvers.User,
Organization: oldResolvers.Organization,
Product: oldResolvers.Product,
// SupplyOrder: oldResolvers.SupplyOrder, // Удалено: отсутствует в старых резолверах
// Employee берем из нового модуля
Employee: undefined,
}
);

View File

@ -0,0 +1,285 @@
import { GraphQLError } from "graphql";
import { Context } from "../context";
import { prisma } from "../../lib/prisma";
export const logisticsResolvers = {
Query: {
// Получить логистические компании-партнеры
logisticsPartners: async (_: unknown, __: unknown, context: Context) => {
if (!context.user) {
throw new GraphQLError("Требуется авторизация", {
extensions: { code: "UNAUTHENTICATED" },
});
}
// Получаем все организации типа LOGIST
return await prisma.organization.findMany({
where: {
type: "LOGIST",
// Убираем фильтр по статусу пока не определим правильные значения
},
orderBy: { createdAt: "desc" }, // Сортируем по дате создания вместо name
});
},
},
Mutation: {
// Назначить логистику на поставку (используется фулфилментом)
assignLogisticsToSupply: async (
_: unknown,
args: {
supplyOrderId: string;
logisticsPartnerId: string;
responsibleId?: string;
},
context: Context
) => {
if (!context.user) {
throw new GraphQLError("Требуется авторизация", {
extensions: { code: "UNAUTHENTICATED" },
});
}
const currentUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true },
});
if (!currentUser?.organization) {
throw new GraphQLError("У пользователя нет организации");
}
if (currentUser.organization.type !== "FULFILLMENT") {
throw new GraphQLError("Доступно только для фулфилмент центров");
}
try {
// Находим заказ
const existingOrder = await prisma.supplyOrder.findUnique({
where: { id: args.supplyOrderId },
include: {
partner: true,
fulfillmentCenter: true,
logisticsPartner: true,
items: {
include: { product: true },
},
},
});
if (!existingOrder) {
throw new GraphQLError("Заказ поставки не найден");
}
// Проверяем, что это заказ для нашего фулфилмент-центра
if (existingOrder.fulfillmentCenterId !== currentUser.organization.id) {
throw new GraphQLError("Нет доступа к этому заказу");
}
// Проверяем, что статус позволяет назначить логистику
if (existingOrder.status !== "SUPPLIER_APPROVED") {
throw new GraphQLError(
`Нельзя назначить логистику для заказа со статусом ${existingOrder.status}`
);
}
// Проверяем, что логистическая компания существует
const logisticsPartner = await prisma.organization.findUnique({
where: { id: args.logisticsPartnerId },
});
if (!logisticsPartner || logisticsPartner.type !== "LOGIST") {
throw new GraphQLError("Логистическая компания не найдена");
}
// Обновляем заказ
const updatedOrder = await prisma.supplyOrder.update({
where: { id: args.supplyOrderId },
data: {
logisticsPartner: {
connect: { id: args.logisticsPartnerId },
},
status: "CONFIRMED", // Переводим в статус "подтвержден фулфилментом"
},
include: {
partner: true,
fulfillmentCenter: true,
logisticsPartner: true,
items: {
include: { product: true },
},
},
});
console.log(`✅ Логистика назначена на заказ ${args.supplyOrderId}:`, {
logisticsPartner: logisticsPartner.name,
responsible: args.responsibleId,
newStatus: "CONFIRMED",
});
return {
success: true,
message: "Логистика успешно назначена",
order: updatedOrder,
};
} catch (error) {
console.error("❌ Ошибка при назначении логистики:", error);
return {
success: false,
message:
error instanceof Error
? error.message
: "Ошибка при назначении логистики",
};
}
},
// Подтвердить заказ логистической компанией
logisticsConfirmOrder: async (
_: unknown,
args: { id: string },
context: Context
) => {
if (!context.user) {
throw new GraphQLError("Требуется авторизация", {
extensions: { code: "UNAUTHENTICATED" },
});
}
const currentUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true },
});
if (!currentUser?.organization) {
throw new GraphQLError("У пользователя нет организации");
}
try {
const existingOrder = await prisma.supplyOrder.findFirst({
where: {
id: args.id,
logisticsPartnerId: currentUser.organization.id,
OR: [{ status: "SUPPLIER_APPROVED" }, { status: "CONFIRMED" }],
},
});
if (!existingOrder) {
return {
success: false,
message:
"Заказ не найден или недоступен для подтверждения логистикой",
};
}
const updatedOrder = await prisma.supplyOrder.update({
where: { id: args.id },
data: { status: "LOGISTICS_CONFIRMED" },
include: {
partner: true,
organization: true,
fulfillmentCenter: true,
logisticsPartner: true,
items: {
include: {
product: {
include: {
category: true,
organization: true,
},
},
},
},
},
});
return {
success: true,
message: "Заказ подтвержден логистической компанией",
order: updatedOrder,
};
} catch (error) {
console.error("Error confirming supply order:", error);
return {
success: false,
message: "Ошибка при подтверждении заказа",
};
}
},
// Отклонить заказ логистической компанией
logisticsRejectOrder: async (
_: unknown,
args: { id: string },
context: Context
) => {
if (!context.user) {
throw new GraphQLError("Требуется авторизация", {
extensions: { code: "UNAUTHENTICATED" },
});
}
const currentUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true },
});
if (!currentUser?.organization) {
throw new GraphQLError("У пользователя нет организации");
}
try {
const existingOrder = await prisma.supplyOrder.findFirst({
where: {
id: args.id,
logisticsPartnerId: currentUser.organization.id,
OR: [{ status: "SUPPLIER_APPROVED" }, { status: "CONFIRMED" }],
},
});
if (!existingOrder) {
return {
success: false,
message: "Заказ не найден или недоступен для отклонения логистикой",
};
}
const updatedOrder = await prisma.supplyOrder.update({
where: { id: args.id },
data: {
status: "CANCELLED",
logisticsPartnerId: null, // Убираем назначенную логистику
},
include: {
partner: true,
organization: true,
fulfillmentCenter: true,
logisticsPartner: true,
items: {
include: {
product: {
include: {
category: true,
organization: true,
},
},
},
},
},
});
return {
success: true,
message: "Заказ отклонен логистической компанией",
order: updatedOrder,
};
} catch (error) {
console.error("Error rejecting supply order:", error);
return {
success: false,
message: "Ошибка при отклонении заказа",
};
}
},
},
};

View File

@ -0,0 +1,6 @@
import { Context } from "../context";
export const suppliesResolvers = {
Query: {},
Mutation: {},
};

51
src/graphql/scalars.ts Normal file
View File

@ -0,0 +1,51 @@
import { GraphQLScalarType, Kind } from "graphql";
export const JSONScalar = new GraphQLScalarType({
name: "JSON",
serialize: (value) => value,
parseValue: (value) => value,
parseLiteral: (ast) => {
switch (ast.kind) {
case Kind.STRING:
case Kind.BOOLEAN:
return ast.value;
case Kind.INT:
case Kind.FLOAT:
return parseFloat(ast.value);
case Kind.OBJECT:
return ast.fields.reduce(
(accumulator, field) => ({
...accumulator,
[field.name.value]: JSONScalar.parseLiteral(field.value),
}),
{}
);
case Kind.LIST:
return ast.values.map((n) => JSONScalar.parseLiteral(n));
default:
return null;
}
},
});
export const DateTimeScalar = new GraphQLScalarType({
name: "DateTime",
serialize: (value) => {
if (value instanceof Date) {
return value.toISOString();
}
return value;
},
parseValue: (value) => {
if (typeof value === "string") {
return new Date(value);
}
return value;
},
parseLiteral: (ast) => {
if (ast.kind === Kind.STRING) {
return new Date(ast.value);
}
return null;
},
});

View File

@ -43,6 +43,9 @@ export const typeDefs = gql`
# Расходники фулфилмента (материалы для работы фулфилмента)
myFulfillmentSupplies: [Supply!]!
# Расходники селлеров на складе фулфилмента (только для фулфилмента)
sellerSuppliesOnWarehouse: [Supply!]!
# Заказы поставок расходников
supplyOrders: [SupplyOrder!]!
@ -52,6 +55,9 @@ export const typeDefs = gql`
# Логистика организации
myLogistics: [Logistics!]!
# Логистические партнеры (организации-логисты)
logisticsPartners: [Organization!]!
# Поставки Wildberries
myWildberriesSupplies: [WildberriesSupply!]!
@ -206,16 +212,23 @@ export const typeDefs = gql`
id: ID!
status: SupplyOrderStatus!
): SupplyOrderResponse!
# Назначение логистики фулфилментом
assignLogisticsToSupply(
supplyOrderId: ID!
logisticsPartnerId: ID!
responsibleId: ID
): SupplyOrderResponse!
# Действия поставщика
supplierApproveOrder(id: ID!): SupplyOrderResponse!
supplierRejectOrder(id: ID!, reason: String): SupplyOrderResponse!
supplierShipOrder(id: ID!): SupplyOrderResponse!
# Действия логиста
logisticsConfirmOrder(id: ID!): SupplyOrderResponse!
logisticsRejectOrder(id: ID!, reason: String): SupplyOrderResponse!
# Действия фулфилмента
fulfillmentReceiveOrder(id: ID!): SupplyOrderResponse!
@ -228,12 +241,19 @@ export const typeDefs = gql`
createProduct(input: ProductInput!): ProductResponse!
updateProduct(id: ID!, input: ProductInput!): ProductResponse!
deleteProduct(id: ID!): Boolean!
# Валидация и управление остатками товаров
checkArticleUniqueness(article: String!, excludeId: ID): ArticleUniquenessResponse!
checkArticleUniqueness(
article: String!
excludeId: ID
): ArticleUniquenessResponse!
reserveProductStock(productId: ID!, quantity: Int!): ProductStockResponse!
releaseProductReserve(productId: ID!, quantity: Int!): ProductStockResponse!
updateProductInTransit(productId: ID!, quantity: Int!, operation: String!): ProductStockResponse!
updateProductInTransit(
productId: ID!
quantity: Int!
operation: String!
): ProductStockResponse!
# Работа с категориями
createCategory(input: CategoryInput!): CategoryResponse!
@ -535,6 +555,11 @@ export const typeDefs = gql`
}
# Типы для расходников
enum SupplyType {
FULFILLMENT_CONSUMABLES # Расходники фулфилмента (купленные фулфилментом для себя)
SELLER_CONSUMABLES # Расходники селлеров (принятые от селлеров для хранения)
}
type Supply {
id: ID!
name: String!
@ -550,6 +575,9 @@ export const typeDefs = gql`
currentStock: Int
usedStock: Int
imageUrl: String
type: SupplyType!
sellerOwner: Organization # Селлер-владелец (для расходников селлеров)
shopLocation: String # Местоположение в магазине фулфилмента
createdAt: DateTime!
updatedAt: DateTime!
organization: Organization!
@ -594,8 +622,8 @@ export const typeDefs = gql`
totalItems: Int!
fulfillmentCenterId: ID
fulfillmentCenter: Organization
logisticsPartnerId: ID!
logisticsPartner: Organization!
logisticsPartnerId: ID
logisticsPartner: Organization
items: [SupplyOrderItem!]!
createdAt: DateTime!
updatedAt: DateTime!
@ -612,21 +640,21 @@ export const typeDefs = gql`
}
enum SupplyOrderStatus {
PENDING # Ожидает одобрения поставщика
CONFIRMED # Устаревший статус (для обратной совместимости)
IN_TRANSIT # Устаревший статус (для обратной совместимости)
SUPPLIER_APPROVED # Поставщик одобрил, ожидает подтверждения логистики
LOGISTICS_CONFIRMED # Логистика подтвердила, ожидает отправки
SHIPPED # Отправлено поставщиком, в пути
DELIVERED # Доставлено и принято фулфилментом
CANCELLED # Отменено (любой участник может отменить)
PENDING # Ожидает одобрения поставщика
CONFIRMED # Устаревший статус (для обратной совместимости)
IN_TRANSIT # Устаревший статус (для обратной совместимости)
SUPPLIER_APPROVED # Поставщик одобрил, ожидает подтверждения логистики
LOGISTICS_CONFIRMED # Логистика подтвердила, ожидает отправки
SHIPPED # Отправлено поставщиком, в пути
DELIVERED # Доставлено и принято фулфилментом
CANCELLED # Отменено (любой участник может отменить)
}
input SupplyOrderInput {
partnerId: ID!
deliveryDate: DateTime!
fulfillmentCenterId: ID # ID фулфилмент-центра для доставки
logisticsPartnerId: ID! # ID логистической компании (обязательно)
logisticsPartnerId: ID # ID логистической компании (опционально - может выбрать селлер или фулфилмент)
items: [SupplyOrderItemInput!]!
notes: String # Дополнительные заметки к заказу
consumableType: String # Классификация расходников: FULFILLMENT_CONSUMABLES, SELLER_CONSUMABLES
@ -642,6 +670,7 @@ export const typeDefs = gql`
ourSupplyOrders: Int! # Расходники фулфилмента
sellerSupplyOrders: Int! # Расходники селлеров
incomingSupplierOrders: Int! # 🔔 Входящие заказы для поставщиков
logisticsOrders: Int! # 🚚 Логистические заявки для логистики
incomingRequests: Int!
total: Int!
}
@ -819,6 +848,8 @@ export const typeDefs = gql`
firstName: String!
lastName: String!
middleName: String
fullName: String
name: String
birthDate: DateTime
avatar: String
passportPhoto: String