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

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

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

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

View File

@ -0,0 +1,1045 @@
generator client {
provider = "prisma-client-js"
}
generator seed {
provider = "prisma-client-js"
output = "./generated/client"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
phone String @unique
avatar String?
managerName String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organizationId String?
sentMessages Message[] @relation("SentMessages")
smsCodes SmsCode[]
organization Organization? @relation(fields: [organizationId], references: [id])
// === НОВЫЕ СВЯЗИ С ПРИЕМКОЙ ПОСТАВОК V2 ===
fulfillmentSupplyOrdersReceived FulfillmentConsumableSupplyOrder[] @relation("FFSupplyOrdersReceiver")
sellerSupplyOrdersReceived SellerConsumableSupplyOrder[] @relation("SellerSupplyOrdersReceiver")
@@map("users")
}
model Admin {
id String @id @default(cuid())
username String @unique
password String
email String? @unique
isActive Boolean @default(true)
lastLogin DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("admins")
}
model SmsCode {
id String @id @default(cuid())
code String
phone String
expiresAt DateTime
isUsed Boolean @default(false)
attempts Int @default(0)
maxAttempts Int @default(3)
createdAt DateTime @default(now())
userId String?
user User? @relation(fields: [userId], references: [id])
@@map("sms_codes")
}
model Organization {
id String @id @default(cuid())
inn String @unique
kpp String?
name String?
fullName String?
ogrn String?
ogrnDate DateTime?
type OrganizationType
market String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
address String?
addressFull String?
status String?
actualityDate DateTime?
registrationDate DateTime?
liquidationDate DateTime?
managementName String?
managementPost String?
opfCode String?
opfFull String?
opfShort String?
okato String?
oktmo String?
okpo String?
okved String?
phones Json?
emails Json?
employeeCount Int?
revenue BigInt?
taxSystem String?
dadataData Json?
referralCode String? @unique
referredById String?
referralPoints Int @default(0)
apiKeys ApiKey[]
carts Cart?
counterpartyOf Counterparty[] @relation("CounterpartyOf")
organizationCounterparties Counterparty[] @relation("OrganizationCounterparties")
receivedRequests CounterpartyRequest[] @relation("ReceivedRequests")
sentRequests CounterpartyRequest[] @relation("SentRequests")
employees Employee[]
externalAds ExternalAd[] @relation("ExternalAds")
favorites Favorites[]
logistics Logistics[]
receivedMessages Message[] @relation("ReceivedMessages")
sentMessages Message[] @relation("SentMessages")
referredBy Organization? @relation("ReferralRelation", fields: [referredById], references: [id])
referrals Organization[] @relation("ReferralRelation")
products Product[]
referralTransactions ReferralTransaction[] @relation("ReferralTransactions")
referrerTransactions ReferralTransaction[] @relation("ReferrerTransactions")
sellerStatsCaches SellerStatsCache[] @relation("SellerStatsCaches")
services Service[]
supplies Supply[]
sellerSupplies Supply[] @relation("SellerSupplies")
fulfillmentSupplyOrders SupplyOrder[] @relation("SupplyOrderFulfillmentCenter")
logisticsSupplyOrders SupplyOrder[] @relation("SupplyOrderLogistics")
supplyOrders SupplyOrder[]
partnerSupplyOrders SupplyOrder[] @relation("SupplyOrderPartner")
supplySuppliers SupplySupplier[] @relation("SupplySuppliers")
users User[]
wbWarehouseCaches WBWarehouseCache[] @relation("WBWarehouseCaches")
wildberriesSupplies WildberriesSupply[]
// === НОВЫЕ СВЯЗИ С ПОСТАВКАМИ V2 ===
// Поставки расходников ФФ
fulfillmentSupplyOrdersAsFulfillment FulfillmentConsumableSupplyOrder[] @relation("FFSupplyOrdersFulfillment")
fulfillmentSupplyOrdersAsSupplier FulfillmentConsumableSupplyOrder[] @relation("FFSupplyOrdersSupplier")
fulfillmentSupplyOrdersAsLogistics FulfillmentConsumableSupplyOrder[] @relation("FFSupplyOrdersLogistics")
// Поставки расходников селлера
sellerSupplyOrdersAsSeller SellerConsumableSupplyOrder[] @relation("SellerSupplyOrdersSeller")
sellerSupplyOrdersAsFulfillment SellerConsumableSupplyOrder[] @relation("SellerSupplyOrdersFulfillment")
sellerSupplyOrdersAsSupplier SellerConsumableSupplyOrder[] @relation("SellerSupplyOrdersSupplier")
// === НОВЫЕ СВЯЗИ СО СКЛАДСКИМИ ОСТАТКАМИ V2 ===
fulfillmentInventory FulfillmentConsumableInventory[] @relation("FFInventory")
sellerSupplyOrdersAsLogistics SellerConsumableSupplyOrder[] @relation("SellerSupplyOrdersLogistics")
// === СВЯЗИ С ИНВЕНТАРЕМ РАСХОДНИКОВ СЕЛЛЕРА V2 ===
sellerInventoryAsOwner SellerConsumableInventory[] @relation("SellerInventory")
sellerInventoryAsWarehouse SellerConsumableInventory[] @relation("SellerInventoryWarehouse")
@@index([referralCode])
@@index([referredById])
@@map("organizations")
}
model ApiKey {
id String @id @default(cuid())
marketplace MarketplaceType
apiKey String
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
validationData Json?
organizationId String
organization Organization @relation(fields: [organizationId], references: [id])
@@unique([organizationId, marketplace])
@@map("api_keys")
}
model CounterpartyRequest {
id String @id @default(cuid())
status CounterpartyRequestStatus @default(PENDING)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
senderId String
receiverId String
message String?
receiver Organization @relation("ReceivedRequests", fields: [receiverId], references: [id])
sender Organization @relation("SentRequests", fields: [senderId], references: [id])
@@unique([senderId, receiverId])
@@map("counterparty_requests")
}
model Counterparty {
id String @id @default(cuid())
createdAt DateTime @default(now())
organizationId String
counterpartyId String
type CounterpartyType @default(MANUAL)
triggeredBy String?
triggerEntityId String?
counterparty Organization @relation("CounterpartyOf", fields: [counterpartyId], references: [id])
organization Organization @relation("OrganizationCounterparties", fields: [organizationId], references: [id])
@@unique([organizationId, counterpartyId])
@@index([type])
@@map("counterparties")
}
model Message {
id String @id @default(cuid())
content String?
type MessageType @default(TEXT)
voiceUrl String?
voiceDuration Int?
fileUrl String?
fileName String?
fileSize Int?
fileType String?
isRead Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
senderId String
senderOrganizationId String
receiverOrganizationId String
receiverOrganization Organization @relation("ReceivedMessages", fields: [receiverOrganizationId], references: [id])
sender User @relation("SentMessages", fields: [senderId], references: [id])
senderOrganization Organization @relation("SentMessages", fields: [senderOrganizationId], references: [id])
@@index([senderOrganizationId, receiverOrganizationId, createdAt])
@@index([receiverOrganizationId, isRead])
@@map("messages")
}
model Service {
id String @id @default(cuid())
name String
description String?
price Decimal @db.Decimal(10, 2)
imageUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
@@map("services")
}
model Supply {
id String @id @default(cuid())
name String
article String
description String?
price Decimal @db.Decimal(10, 2)
pricePerUnit Decimal? @db.Decimal(10, 2)
quantity Int @default(0)
unit String @default("шт")
category String @default("Расходники")
status String @default("planned")
date DateTime @default(now())
supplier String @default("Не указан")
minStock Int @default(0)
currentStock Int @default(0)
usedStock Int @default(0)
imageUrl String?
type SupplyType @default(FULFILLMENT_CONSUMABLES)
sellerOwnerId String?
shopLocation String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organizationId String
actualQuantity Int?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
sellerOwner Organization? @relation("SellerSupplies", fields: [sellerOwnerId], references: [id])
@@map("supplies")
}
model Category {
id String @id @default(cuid())
name String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
products Product[]
@@map("categories")
}
model Product {
id String @id @default(cuid())
name String
article String
description String?
price Decimal @db.Decimal(12, 2)
pricePerSet Decimal? @db.Decimal(12, 2)
quantity Int @default(0)
setQuantity Int?
ordered Int?
inTransit Int?
stock Int?
sold Int?
type ProductType @default(PRODUCT)
categoryId String?
brand String?
color String?
size String?
weight Decimal? @db.Decimal(8, 3)
dimensions String?
material String?
images Json @default("[]")
mainImage String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organizationId String
cartItems CartItem[]
favorites Favorites[]
category Category? @relation(fields: [categoryId], references: [id])
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
supplyOrderItems SupplyOrderItem[]
// === НОВЫЕ СВЯЗИ С ПОСТАВКАМИ V2 ===
fulfillmentSupplyItems FulfillmentConsumableSupplyItem[] @relation("FFSupplyItems")
sellerSupplyItems SellerConsumableSupplyItem[] @relation("SellerSupplyItems")
// === НОВЫЕ СВЯЗИ СО СКЛАДСКИМИ ОСТАТКАМИ V2 ===
inventoryRecords FulfillmentConsumableInventory[] @relation("InventoryProducts")
sellerInventoryRecords SellerConsumableInventory[] @relation("SellerInventoryProducts")
@@unique([organizationId, article])
@@map("products")
}
model Cart {
id String @id @default(cuid())
organizationId String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
items CartItem[]
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
@@map("carts")
}
model CartItem {
id String @id @default(cuid())
cartId String
productId String
quantity Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
cart Cart @relation(fields: [cartId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@unique([cartId, productId])
@@map("cart_items")
}
model Favorites {
id String @id @default(cuid())
organizationId String
productId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@unique([organizationId, productId])
@@map("favorites")
}
model Employee {
id String @id @default(cuid())
firstName String
lastName String
middleName String?
birthDate DateTime?
avatar String?
passportPhoto String?
passportSeries String?
passportNumber String?
passportIssued String?
passportDate DateTime?
address String?
position String
department String?
hireDate DateTime
salary Float?
status EmployeeStatus @default(ACTIVE)
phone String
email String?
telegram String?
whatsapp String?
emergencyContact String?
emergencyPhone String?
organizationId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
scheduleRecords EmployeeSchedule[]
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
supplyOrders SupplyOrder[] @relation("SupplyOrderResponsible")
@@map("employees")
}
model EmployeeSchedule {
id String @id @default(cuid())
date DateTime
status ScheduleStatus
hoursWorked Float?
overtimeHours Float?
notes String?
employeeId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
@@unique([employeeId, date])
@@map("employee_schedules")
}
model WildberriesSupply {
id String @id @default(cuid())
organizationId String
deliveryDate DateTime?
status WildberriesSupplyStatus @default(DRAFT)
totalAmount Decimal @db.Decimal(12, 2)
totalItems Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
cards WildberriesSupplyCard[]
@@map("wildberries_supplies")
}
model WildberriesSupplyCard {
id String @id @default(cuid())
supplyId String
nmId String
vendorCode String
title String
brand String?
price Decimal @db.Decimal(12, 2)
discountedPrice Decimal? @db.Decimal(12, 2)
quantity Int
selectedQuantity Int
selectedMarket String?
selectedPlace String?
sellerName String?
sellerPhone String?
deliveryDate DateTime?
mediaFiles Json @default("[]")
selectedServices Json @default("[]")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
supply WildberriesSupply @relation(fields: [supplyId], references: [id], onDelete: Cascade)
@@map("wildberries_supply_cards")
}
model Logistics {
id String @id @default(cuid())
fromLocation String
toLocation String
priceUnder1m3 Float
priceOver1m3 Float
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organizationId String
organization Organization @relation(fields: [organizationId], references: [id])
routes SupplyRoute[] @relation("SupplyRouteLogistics")
@@map("logistics")
}
model SupplyOrder {
id String @id @default(cuid())
partnerId String
deliveryDate DateTime
status SupplyOrderStatus @default(PENDING)
totalAmount Decimal @db.Decimal(12, 2)
totalItems Int
fulfillmentCenterId String?
logisticsPartnerId String?
consumableType String?
// Новые поля для многоуровневой системы поставок
packagesCount Int? // Количество грузовых мест (от поставщика)
volume Float? // Объём товара в м³ (от поставщика)
responsibleEmployee String? // ID ответственного сотрудника ФФ
notes String? // Заметки и комментарии
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organizationId String
items SupplyOrderItem[]
routes SupplyRoute[] // Связь с маршрутами поставки
fulfillmentCenter Organization? @relation("SupplyOrderFulfillmentCenter", fields: [fulfillmentCenterId], references: [id])
logisticsPartner Organization? @relation("SupplyOrderLogistics", fields: [logisticsPartnerId], references: [id])
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
partner Organization @relation("SupplyOrderPartner", fields: [partnerId], references: [id])
employee Employee? @relation("SupplyOrderResponsible", fields: [responsibleEmployee], references: [id])
@@map("supply_orders")
}
// Модель для маршрутов поставки (модульная архитектура)
model SupplyRoute {
id String @id @default(cuid())
supplyOrderId String
logisticsId String? // Ссылка на предустановленный маршрут из Logistics
fromLocation String // Точка забора (рынок/поставщик)
toLocation String // Точка доставки (фулфилмент)
fromAddress String? // Полный адрес точки забора
toAddress String? // Полный адрес точки доставки
distance Float? // Расстояние в км
estimatedTime Int? // Время доставки в часах
price Decimal? @db.Decimal(10, 2) // Стоимость логистики
status String? @default("pending") // Статус маршрута
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdDate DateTime @default(now()) // Дата создания маршрута (уровень 2)
supplyOrder SupplyOrder @relation(fields: [supplyOrderId], references: [id], onDelete: Cascade)
logistics Logistics? @relation("SupplyRouteLogistics", fields: [logisticsId], references: [id])
@@map("supply_routes")
}
model SupplyOrderItem {
id String @id @default(cuid())
supplyOrderId String
productId String
quantity Int
price Decimal @db.Decimal(12, 2)
totalPrice Decimal @db.Decimal(12, 2)
services String[] @default([])
fulfillmentConsumables String[] @default([])
sellerConsumables String[] @default([])
marketplaceCardId String?
// ОТКАТ: recipe Json? // Полная рецептура в JSON формате - ЗАКОММЕНТИРОВАНО
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
product Product @relation(fields: [productId], references: [id])
supplyOrder SupplyOrder @relation(fields: [supplyOrderId], references: [id], onDelete: Cascade)
@@unique([supplyOrderId, productId])
@@map("supply_order_items")
}
model SupplySupplier {
id String @id @default(cuid())
name String
contactName String
phone String
market String?
address String?
place String?
telegram String?
organizationId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation("SupplySuppliers", fields: [organizationId], references: [id], onDelete: Cascade)
@@map("supply_suppliers")
}
model ExternalAd {
id String @id @default(cuid())
name String
url String
cost Decimal @db.Decimal(12, 2)
date DateTime
nmId String
clicks Int @default(0)
organizationId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation("ExternalAds", fields: [organizationId], references: [id], onDelete: Cascade)
@@index([organizationId, date])
@@map("external_ads")
}
model WBWarehouseCache {
id String @id @default(cuid())
organizationId String
cacheDate DateTime
data Json
totalProducts Int @default(0)
totalStocks Int @default(0)
totalReserved Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation("WBWarehouseCaches", fields: [organizationId], references: [id], onDelete: Cascade)
@@unique([organizationId, cacheDate])
@@index([organizationId, cacheDate])
@@map("wb_warehouse_caches")
}
model SellerStatsCache {
id String @id @default(cuid())
organizationId String
cacheDate DateTime
period String
dateFrom DateTime?
dateTo DateTime?
productsData Json?
productsTotalSales Decimal? @db.Decimal(15, 2)
productsTotalOrders Int?
productsCount Int?
advertisingData Json?
advertisingTotalCost Decimal? @db.Decimal(15, 2)
advertisingTotalViews Int?
advertisingTotalClicks Int?
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation("SellerStatsCaches", fields: [organizationId], references: [id], onDelete: Cascade)
@@unique([organizationId, cacheDate, period, dateFrom, dateTo])
@@index([organizationId, cacheDate])
@@index([expiresAt])
@@map("seller_stats_caches")
}
model ReferralTransaction {
id String @id @default(cuid())
referrerId String
referralId String
points Int
type ReferralTransactionType
description String?
createdAt DateTime @default(now())
referral Organization @relation("ReferralTransactions", fields: [referralId], references: [id])
referrer Organization @relation("ReferrerTransactions", fields: [referrerId], references: [id])
@@index([referrerId, createdAt])
@@index([referralId])
@@map("referral_transactions")
}
enum OrganizationType {
FULFILLMENT
SELLER
LOGIST
WHOLESALE
}
enum MarketplaceType {
WILDBERRIES
OZON
}
enum CounterpartyRequestStatus {
PENDING
ACCEPTED
REJECTED
CANCELLED
}
enum MessageType {
TEXT
VOICE
IMAGE
FILE
}
enum EmployeeStatus {
ACTIVE
VACATION
SICK
FIRED
}
enum ScheduleStatus {
WORK
WEEKEND
VACATION
SICK
ABSENT
}
enum SupplyOrderStatus {
PENDING
CONFIRMED
IN_TRANSIT
SUPPLIER_APPROVED
LOGISTICS_CONFIRMED
SHIPPED
DELIVERED
CANCELLED
}
enum WildberriesSupplyStatus {
DRAFT
CREATED
IN_PROGRESS
DELIVERED
CANCELLED
}
enum ProductType {
PRODUCT
CONSUMABLE
}
enum SupplyType {
FULFILLMENT_CONSUMABLES
SELLER_CONSUMABLES
}
enum CounterpartyType {
MANUAL
REFERRAL
AUTO_BUSINESS
AUTO
}
enum ReferralTransactionType {
REGISTRATION
AUTO_PARTNERSHIP
FIRST_ORDER
MONTHLY_BONUS
}
model AuditLog {
id String @id @default(cuid())
userId String
organizationType OrganizationType
action String
resourceType String
resourceId String?
metadata Json @default("{}")
ipAddress String?
userAgent String?
timestamp DateTime @default(now())
@@index([userId])
@@index([timestamp])
@@index([action])
@@index([resourceType])
@@map("audit_logs")
}
model SecurityAlert {
id String @id @default(cuid())
type SecurityAlertType
severity SecurityAlertSeverity
userId String
message String
metadata Json @default("{}")
timestamp DateTime @default(now())
resolved Boolean @default(false)
@@index([userId])
@@index([timestamp])
@@index([resolved])
@@index([severity])
@@map("security_alerts")
}
enum SecurityAlertType {
EXCESSIVE_ACCESS
UNAUTHORIZED_ATTEMPT
DATA_LEAK_RISK
SUSPICIOUS_PATTERN
BULK_EXPORT_DETECTED
RULE_VIOLATION
}
enum SecurityAlertSeverity {
LOW
MEDIUM
HIGH
CRITICAL
}
// ===============================================
// НОВАЯ СИСТЕМА ПОСТАВОК V2.0
// ===============================================
// Новый enum для статусов поставок v2
enum SupplyOrderStatusV2 {
PENDING // Ожидает одобрения поставщика
SUPPLIER_APPROVED // Одобрено поставщиком
LOGISTICS_CONFIRMED // Логистика подтверждена
SHIPPED // Отгружено поставщиком
IN_TRANSIT // В пути
DELIVERED // Доставлено и принято
CANCELLED // Отменено
}
// 5-статусная система для поставок расходников селлера
enum SellerSupplyOrderStatus {
PENDING // Ожидает одобрения поставщика
APPROVED // Одобрено поставщиком
SHIPPED // Отгружено
DELIVERED // Доставлено
COMPLETED // Завершено
CANCELLED // Отменено
}
// Модель для поставок расходников фулфилмента
model FulfillmentConsumableSupplyOrder {
// === БАЗОВЫЕ ПОЛЯ ===
id String @id @default(cuid())
status SupplyOrderStatusV2 @default(PENDING)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// === ДАННЫЕ ФФ (создатель) ===
fulfillmentCenterId String // кто заказывает (FK: Organization)
requestedDeliveryDate DateTime // когда нужно
resalePricePerUnit Decimal? @db.Decimal(10, 2) // цена продажи селлерам
minStockLevel Int? // минимальный остаток
notes String? // заметки ФФ
// === ДАННЫЕ ПОСТАВЩИКА ===
supplierId String? // кто поставляет (FK: Organization)
supplierApprovedAt DateTime? // когда одобрил
packagesCount Int? // количество грузомест
estimatedVolume Decimal? @db.Decimal(8, 3) // объем груза в м³
supplierContractId String? // номер договора
supplierNotes String? // заметки поставщика
// === ДАННЫЕ ЛОГИСТИКИ ===
logisticsPartnerId String? // кто везет (FK: Organization)
estimatedDeliveryDate DateTime? // план доставки
routeId String? // маршрут (FK: LogisticsRoute)
logisticsCost Decimal? @db.Decimal(10, 2) // стоимость доставки
logisticsNotes String? // заметки логистики
// === ДАННЫЕ ОТГРУЗКИ ===
shippedAt DateTime? // факт отгрузки
trackingNumber String? // номер отслеживания
// === ДАННЫЕ ПРИЕМКИ ===
receivedAt DateTime? // факт приемки
receivedById String? // кто принял (FK: User)
actualQuantity Int? // принято количество
defectQuantity Int? // брак
receiptNotes String? // заметки приемки
// === СВЯЗИ ===
fulfillmentCenter Organization @relation("FFSupplyOrdersFulfillment", fields: [fulfillmentCenterId], references: [id])
supplier Organization? @relation("FFSupplyOrdersSupplier", fields: [supplierId], references: [id])
logisticsPartner Organization? @relation("FFSupplyOrdersLogistics", fields: [logisticsPartnerId], references: [id])
receivedBy User? @relation("FFSupplyOrdersReceiver", fields: [receivedById], references: [id])
items FulfillmentConsumableSupplyItem[]
@@map("fulfillment_consumable_supply_orders")
}
model FulfillmentConsumableSupplyItem {
id String @id @default(cuid())
supplyOrderId String // связь с поставкой
productId String // какой расходник (FK: Product)
// === КОЛИЧЕСТВА ===
requestedQuantity Int // запросили
approvedQuantity Int? // поставщик одобрил
shippedQuantity Int? // отгрузили
receivedQuantity Int? // приняли
defectQuantity Int? @default(0) // брак
// === ЦЕНЫ ===
unitPrice Decimal @db.Decimal(10, 2) // цена за единицу от поставщика
totalPrice Decimal @db.Decimal(12, 2) // общая стоимость
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// === СВЯЗИ ===
supplyOrder FulfillmentConsumableSupplyOrder @relation(fields: [supplyOrderId], references: [id], onDelete: Cascade)
product Product @relation("FFSupplyItems", fields: [productId], references: [id])
@@unique([supplyOrderId, productId])
@@map("fulfillment_consumable_supply_items")
}
// =============================================================================
// 📦 СИСТЕМА ПОСТАВОК РАСХОДНИКОВ СЕЛЛЕРА
// =============================================================================
// Модель для поставок расходников селлера
model SellerConsumableSupplyOrder {
// === БАЗОВЫЕ ПОЛЯ ===
id String @id @default(cuid())
status SellerSupplyOrderStatus @default(PENDING)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// === ДАННЫЕ СЕЛЛЕРА (создатель) ===
sellerId String // кто заказывает (FK: Organization SELLER)
fulfillmentCenterId String // куда доставлять (FK: Organization FULFILLMENT)
requestedDeliveryDate DateTime // когда нужно
notes String? // заметки селлера
// === ДАННЫЕ ПОСТАВЩИКА ===
supplierId String? // кто поставляет (FK: Organization WHOLESALE)
supplierApprovedAt DateTime? // когда одобрил
packagesCount Int? // количество грузомест
estimatedVolume Decimal? @db.Decimal(8, 3) // объем груза в м³
supplierContractId String? // номер договора
supplierNotes String? // заметки поставщика
// === ДАННЫЕ ЛОГИСТИКИ ===
logisticsPartnerId String? // кто везет (FK: Organization LOGIST)
estimatedDeliveryDate DateTime? // план доставки
routeId String? // маршрут (FK: LogisticsRoute)
logisticsCost Decimal? @db.Decimal(10, 2) // стоимость доставки
logisticsNotes String? // заметки логистики
// === ДАННЫЕ ОТГРУЗКИ ===
shippedAt DateTime? // факт отгрузки
trackingNumber String? // номер отслеживания
// === ДАННЫЕ ПРИЕМКИ ===
deliveredAt DateTime? // факт доставки в ФФ
receivedById String? // кто принял в ФФ (FK: User)
actualQuantity Int? // принято количество
defectQuantity Int? // брак
receiptNotes String? // заметки приемки
// === ЭКОНОМИКА (для будущего раздела экономики) ===
totalCostWithDelivery Decimal? @db.Decimal(12, 2) // общая стоимость с доставкой
estimatedStorageCost Decimal? @db.Decimal(10, 2) // оценочная стоимость хранения
// === СВЯЗИ ===
seller Organization @relation("SellerSupplyOrdersSeller", fields: [sellerId], references: [id])
fulfillmentCenter Organization @relation("SellerSupplyOrdersFulfillment", fields: [fulfillmentCenterId], references: [id])
supplier Organization? @relation("SellerSupplyOrdersSupplier", fields: [supplierId], references: [id])
logisticsPartner Organization? @relation("SellerSupplyOrdersLogistics", fields: [logisticsPartnerId], references: [id])
receivedBy User? @relation("SellerSupplyOrdersReceiver", fields: [receivedById], references: [id])
items SellerConsumableSupplyItem[]
@@map("seller_consumable_supply_orders")
}
// Позиции в поставке расходников селлера
model SellerConsumableSupplyItem {
id String @id @default(cuid())
supplyOrderId String // связь с поставкой
productId String // какой расходник (FK: Product)
// === КОЛИЧЕСТВА ===
requestedQuantity Int // запросили
approvedQuantity Int? // поставщик одобрил
shippedQuantity Int? // отгрузили
receivedQuantity Int? // приняли в ФФ
defectQuantity Int? @default(0) // брак
// === ЦЕНЫ ===
unitPrice Decimal @db.Decimal(10, 2) // цена за единицу от поставщика
totalPrice Decimal @db.Decimal(12, 2) // общая стоимость
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// === СВЯЗИ ===
supplyOrder SellerConsumableSupplyOrder @relation(fields: [supplyOrderId], references: [id], onDelete: Cascade)
product Product @relation("SellerSupplyItems", fields: [productId], references: [id])
@@unique([supplyOrderId, productId])
@@map("seller_consumable_supply_items")
}
// ===============================================
// INVENTORY SYSTEM V2.0 - СКЛАДСКИЕ ОСТАТКИ
// ===============================================
// Складские остатки расходников фулфилмента
model FulfillmentConsumableInventory {
// === ИДЕНТИФИКАЦИЯ ===
id String @id @default(cuid())
// === СВЯЗИ ===
fulfillmentCenterId String // где хранится (FK: Organization)
productId String // что хранится (FK: Product)
// === СКЛАДСКИЕ ДАННЫЕ ===
currentStock Int @default(0) // текущий остаток на складе
minStock Int @default(0) // минимальный порог для заказа
maxStock Int? // максимальный порог (опционально)
reservedStock Int @default(0) // зарезервировано для отгрузок
totalReceived Int @default(0) // всего получено с момента создания
totalShipped Int @default(0) // всего отгружено селлерам
// === ЦЕНЫ ===
averageCost Decimal @default(0) @db.Decimal(10, 2) // средняя себестоимость
resalePrice Decimal? @db.Decimal(10, 2) // цена продажи селлерам
// === МЕТАДАННЫЕ ===
lastSupplyDate DateTime? // последняя поставка
lastUsageDate DateTime? // последнее использование/отгрузка
notes String? // заметки по складскому учету
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// === СВЯЗИ ===
fulfillmentCenter Organization @relation("FFInventory", fields: [fulfillmentCenterId], references: [id])
product Product @relation("InventoryProducts", fields: [productId], references: [id])
// === ИНДЕКСЫ ===
@@unique([fulfillmentCenterId, productId]) // один товар = одна запись на фулфилмент
@@index([fulfillmentCenterId, currentStock])
@@index([currentStock, minStock]) // для поиска "заканчивающихся"
@@index([fulfillmentCenterId, lastSupplyDate])
@@map("fulfillment_consumable_inventory")
}
// === V2 SELLER CONSUMABLE INVENTORY SYSTEM ===
// Система складского учета расходников селлера на складе фулфилмента
model SellerConsumableInventory {
// === ИДЕНТИФИКАЦИЯ ===
id String @id @default(cuid())
// === СВЯЗИ ===
sellerId String // кому принадлежат расходники (FK: Organization SELLER)
fulfillmentCenterId String // где хранятся (FK: Organization FULFILLMENT)
productId String // что хранится (FK: Product)
// === СКЛАДСКИЕ ДАННЫЕ ===
currentStock Int @default(0) // текущий остаток на складе фулфилмента
minStock Int @default(0) // минимальный порог для автозаказа
maxStock Int? // максимальный порог (опционально)
reservedStock Int @default(0) // зарезервировано для использования селлером
totalReceived Int @default(0) // всего получено с момента создания
totalUsed Int @default(0) // всего использовано селлером
// === ЦЕНЫ ===
averageCost Decimal @default(0) @db.Decimal(10, 2) // средняя себестоимость покупки
usagePrice Decimal? @db.Decimal(10, 2) // цена списания/использования
// === МЕТАДАННЫЕ ===
lastSupplyDate DateTime? // последняя поставка
lastUsageDate DateTime? // последнее использование
notes String? // заметки по складскому учету
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// === СВЯЗИ ===
seller Organization @relation("SellerInventory", fields: [sellerId], references: [id])
fulfillmentCenter Organization @relation("SellerInventoryWarehouse", fields: [fulfillmentCenterId], references: [id])
product Product @relation("SellerInventoryProducts", fields: [productId], references: [id])
// === ИНДЕКСЫ ===
@@unique([sellerId, fulfillmentCenterId, productId]) // один товар = одна запись на связку селлер-фулфилмент
@@index([sellerId, currentStock])
@@index([fulfillmentCenterId, sellerId]) // для таблицы "Детализация по магазинам"
@@index([currentStock, minStock]) // для поиска "заканчивающихся"
@@index([sellerId, lastSupplyDate])
@@map("seller_consumable_inventory")
}

View File

@ -27,6 +27,7 @@ model User {
// === НОВЫЕ СВЯЗИ С ПРИЕМКОЙ ПОСТАВОК V2 ===
fulfillmentSupplyOrdersReceived FulfillmentConsumableSupplyOrder[] @relation("FFSupplyOrdersReceiver")
sellerSupplyOrdersReceived SellerConsumableSupplyOrder[] @relation("SellerSupplyOrdersReceiver")
sellerGoodsSupplyOrdersReceived SellerGoodsSupplyOrder[] @relation("SellerGoodsSupplyOrdersReceiver")
@@map("users")
}
@ -144,6 +145,16 @@ model Organization {
sellerInventoryAsOwner SellerConsumableInventory[] @relation("SellerInventory")
sellerInventoryAsWarehouse SellerConsumableInventory[] @relation("SellerInventoryWarehouse")
// === СВЯЗИ С ТОВАРНЫМИ ПОСТАВКАМИ СЕЛЛЕРА V2 ===
sellerGoodsSupplyOrdersAsSeller SellerGoodsSupplyOrder[] @relation("SellerGoodsSupplyOrdersSeller")
sellerGoodsSupplyOrdersAsFulfillment SellerGoodsSupplyOrder[] @relation("SellerGoodsSupplyOrdersFulfillment")
sellerGoodsSupplyOrdersAsSupplier SellerGoodsSupplyOrder[] @relation("SellerGoodsSupplyOrdersSupplier")
sellerGoodsSupplyOrdersAsLogistics SellerGoodsSupplyOrder[] @relation("SellerGoodsSupplyOrdersLogistics")
// === СВЯЗИ С ИНВЕНТАРЕМ ТОВАРОВ СЕЛЛЕРА V2 ===
sellerGoodsInventoryAsOwner SellerGoodsInventory[] @relation("SellerGoodsInventoryOwner")
sellerGoodsInventoryAsWarehouse SellerGoodsInventory[] @relation("SellerGoodsInventoryWarehouse")
@@index([referralCode])
@@index([referredById])
@@map("organizations")
@ -315,6 +326,10 @@ model Product {
inventoryRecords FulfillmentConsumableInventory[] @relation("InventoryProducts")
sellerInventoryRecords SellerConsumableInventory[] @relation("SellerInventoryProducts")
// === СВЯЗИ С ТОВАРНЫМИ ПОСТАВКАМИ V2 ===
goodsSupplyRecipeItems GoodsSupplyRecipeItem[] @relation("GoodsSupplyRecipeItems")
sellerGoodsInventoryRecords SellerGoodsInventory[] @relation("SellerGoodsInventoryProduct")
@@unique([organizationId, article])
@@map("products")
}
@ -1043,3 +1058,126 @@ model SellerConsumableInventory {
@@index([sellerId, lastSupplyDate])
@@map("seller_consumable_inventory")
}
// ===============================================
// 🛒 SELLER GOODS SUPPLY SYSTEM V2.0 - ТОВАРНЫЕ ПОСТАВКИ
// ===============================================
// Модель для поставок товаров селлера (V2)
model SellerGoodsSupplyOrder {
// === БАЗОВЫЕ ПОЛЯ ===
id String @id @default(cuid())
status SellerSupplyOrderStatus @default(PENDING)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// === ДАННЫЕ СЕЛЛЕРА (создатель) ===
sellerId String // кто заказывает (FK: Organization SELLER)
fulfillmentCenterId String // куда доставлять (FK: Organization FULFILLMENT)
requestedDeliveryDate DateTime // когда нужно
notes String? // заметки селлера
// === ДАННЫЕ ПОСТАВЩИКА ===
supplierId String? // кто поставляет (FK: Organization WHOLESALE)
supplierApprovedAt DateTime? // когда одобрил
packagesCount Int? // количество грузомест
estimatedVolume Decimal? @db.Decimal(8, 3) // объем груза в м³
supplierContractId String? // номер договора
supplierNotes String? // заметки поставщика
// === ДАННЫЕ ЛОГИСТИКИ ===
logisticsPartnerId String? // кто везет (FK: Organization LOGIST)
estimatedDeliveryDate DateTime? // план доставки
routeId String? // маршрут (FK: LogisticsRoute)
logisticsCost Decimal? @db.Decimal(10, 2) // стоимость доставки
logisticsNotes String? // заметки логистики
// === ДАННЫЕ ОТГРУЗКИ ===
shippedAt DateTime? // факт отгрузки
trackingNumber String? // номер отслеживания
// === ДАННЫЕ ПОЛУЧЕНИЯ ===
deliveredAt DateTime? // факт доставки
receivedById String? // кто принял (FK: User)
receiptNotes String? // заметки при получении
// === ФИНАНСЫ ===
totalCostWithDelivery Decimal @default(0) @db.Decimal(12, 2) // общая стоимость с доставкой
actualDeliveryCost Decimal @default(0) @db.Decimal(10, 2) // фактическая стоимость доставки
// === СВЯЗИ ===
seller Organization @relation("SellerGoodsSupplyOrdersSeller", fields: [sellerId], references: [id])
fulfillmentCenter Organization @relation("SellerGoodsSupplyOrdersFulfillment", fields: [fulfillmentCenterId], references: [id])
supplier Organization? @relation("SellerGoodsSupplyOrdersSupplier", fields: [supplierId], references: [id])
logisticsPartner Organization? @relation("SellerGoodsSupplyOrdersLogistics", fields: [logisticsPartnerId], references: [id])
receivedBy User? @relation("SellerGoodsSupplyOrdersReceiver", fields: [receivedById], references: [id])
recipeItems GoodsSupplyRecipeItem[] // нормализованная рецептура товаров
@@map("seller_goods_supply_orders")
}
// Нормализованная рецептура для товарных поставок
model GoodsSupplyRecipeItem {
id String @id @default(cuid())
supplyOrderId String // связь с поставкой (FK: SellerGoodsSupplyOrder)
productId String // какой товар (FK: Product)
quantity Int // количество в рецептуре
recipeType RecipeType // тип компонента в рецептуре
createdAt DateTime @default(now())
// === СВЯЗИ ===
supplyOrder SellerGoodsSupplyOrder @relation(fields: [supplyOrderId], references: [id], onDelete: Cascade)
product Product @relation("GoodsSupplyRecipeItems", fields: [productId], references: [id])
@@unique([supplyOrderId, productId]) // один товар = одна запись в рецептуре
@@map("goods_supply_recipe_items")
}
// Enum для типов компонентов в рецептуре
enum RecipeType {
MAIN_PRODUCT // Основной товар
COMPONENT // Компонент товара
PACKAGING // Упаковка
ACCESSORY // Аксессуар
}
// Инвентарь товаров селлера на складе фулфилмента (V2)
model SellerGoodsInventory {
// === ИДЕНТИФИКАЦИЯ ===
id String @id @default(cuid())
// === СВЯЗИ ===
sellerId String // кому принадлежат товары (FK: Organization SELLER)
fulfillmentCenterId String // где хранятся (FK: Organization FULFILLMENT)
productId String // что хранится (FK: Product)
// === СКЛАДСКИЕ ДАННЫЕ ===
currentStock Int @default(0) // текущий остаток на складе фулфилмента
reservedStock Int @default(0) // зарезервировано для отгрузок
inPreparationStock Int @default(0) // в подготовке к отгрузке
totalReceived Int @default(0) // всего получено с момента создания
totalShipped Int @default(0) // всего отгружено
// === ПОРОГИ ДЛЯ АВТОЗАКАЗА ===
minStock Int @default(0) // минимальный порог для автозаказа
maxStock Int? // максимальный порог (опционально)
// === ЦЕНЫ ===
averageCost Decimal @default(0) @db.Decimal(10, 2) // средняя себестоимость покупки
salePrice Decimal @default(0) @db.Decimal(10, 2) // цена продажи
// === МЕТАДАННЫЕ ===
lastSupplyDate DateTime? // последняя поставка
lastShipDate DateTime? // последняя отгрузка
notes String? // заметки по складскому учету
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// === СВЯЗИ ===
seller Organization @relation("SellerGoodsInventoryOwner", fields: [sellerId], references: [id])
fulfillmentCenter Organization @relation("SellerGoodsInventoryWarehouse", fields: [fulfillmentCenterId], references: [id])
product Product @relation("SellerGoodsInventoryProduct", fields: [productId], references: [id])
@@unique([sellerId, fulfillmentCenterId, productId]) // уникальность: селлер + фф + товар
@@map("seller_goods_inventory")
}

View File

@ -1,13 +1,10 @@
import { AuthGuard } from '@/components/auth-guard'
import { CreateSuppliersSupplyPage } from '@/components/supplies/create-suppliers'
// TODO: Создать компонент для создания товарных поставок
export default function CreateSellerGoodsPage() {
return (
<AuthGuard>
<div className="p-6">
<h1>Создание поставки товаров</h1>
<p>Страница в разработке</p>
</div>
<CreateSuppliersSupplyPage />
</AuthGuard>
)
}

View File

@ -399,7 +399,7 @@ export function SuppliesDashboard() {
<AllSuppliesTab
pendingSupplyOrders={pendingCount?.supplyOrders || 0}
goodsSupplies={(mySuppliesData?.mySupplyOrders || []).filter((supply: any) =>
supply.consumableType !== 'SELLER_CONSUMABLES'
supply.consumableType !== 'SELLER_CONSUMABLES',
)}
loading={mySuppliesLoading}
/>

View File

@ -10,8 +10,9 @@ import { MarketplaceService } from '@/services/marketplace-service'
import { SmsService } from '@/services/sms-service'
import { WildberriesService } from '@/services/wildberries-service'
import { fulfillmentInventoryV2Queries } from './resolvers/fulfillment-inventory-v2'
import { fulfillmentConsumableV2Queries as fulfillmentConsumableV2QueriesRestored, fulfillmentConsumableV2Mutations as fulfillmentConsumableV2MutationsRestored } from './resolvers/fulfillment-consumables-v2-restored'
import { fulfillmentInventoryV2Queries } from './resolvers/fulfillment-inventory-v2'
import { sellerGoodsQueries, sellerGoodsMutations } from './resolvers/goods-supply-v2'
import { logisticsConsumableV2Queries, logisticsConsumableV2Mutations } from './resolvers/logistics-consumables-v2'
import { sellerInventoryV2Queries } from './resolvers/seller-inventory-v2'
import { CommercialDataAudit } from './security/commercial-data-audit'
@ -2913,6 +2914,9 @@ export const resolvers = {
// V2 система складских остатков расходников селлера
...sellerInventoryV2Queries,
// V2 система товарных поставок селлера
...sellerGoodsQueries,
},
Mutation: {
@ -10298,6 +10302,9 @@ resolvers.Mutation = {
// V2 mutations для логистики
...logisticsConsumableV2Mutations,
// V2 mutations для товарных поставок селлера
...sellerGoodsMutations,
}
/* // Резолвер для парсинга JSON рецептуры в SupplyOrderItem

View File

@ -1,569 +1,448 @@
// =============================================================================
// 🛒 РЕЗОЛВЕРЫ ДЛЯ СИСТЕМЫ ПОСТАВОК ТОВАРОВ СЕЛЛЕРА V2
// =============================================================================
import { GraphQLError } from 'graphql'
import { processSellerGoodsSupplyReceipt } from '@/lib/inventory-management-goods'
import { prisma } from '@/lib/prisma'
import { notifyOrganization } from '@/lib/realtime'
import { Context } from '../context'
// ========== GOODS SUPPLY V2 RESOLVERS (ЗАКОММЕНТИРОВАНО) ==========
// Раскомментируйте для активации системы товарных поставок V2
// =============================================================================
// 🔍 QUERY RESOLVERS V2
// =============================================================================
// ========== V2 RESOLVERS START ==========
export const goodsSupplyV2Resolvers = {
Query: {
// Товарные поставки селлера
myGoodsSupplyOrdersV2: async (_: unknown, __: unknown, context: Context) => {
const { user } = context
if (!user?.organization || user.organization.type !== 'SELLER') {
throw new GraphQLError('Доступно только для селлеров', {
extensions: { code: 'FORBIDDEN' },
export const sellerGoodsQueries = {
// Мои товарные поставки (для селлеров - заказы которые я создал)
mySellerGoodsSupplies: async (_: unknown, __: unknown, context: Context) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' },
})
}
try {
const orders = await prisma.goodsSupplyOrder.findMany({
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: {
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,
},
},
fulfillmentCenter: true,
supplier: true,
logisticsPartner: true,
receivedBy: true,
recipeItems: {
include: {
product: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
})
return orders
return supplies
} catch (error) {
throw new GraphQLError('Ошибка получения товарных поставок', {
extensions: { code: 'INTERNAL_ERROR', originalError: error },
})
console.error('Error fetching seller goods supplies:', error)
return []
}
},
// Входящие товарные поставки (для фулфилмента)
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('Необходима авторизация', {
// Входящие товарные заказы от селлеров (для фулфилмента)
incomingSellerGoodsSupplies: async (_: unknown, __: unknown, context: Context) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' },
})
}
try {
const order = await prisma.goodsSupplyOrder.findUnique({
where: { id: args.id },
include: {
seller: {
include: {
phones: true,
emails: true,
},
},
fulfillmentCenter: {
include: {
phones: true,
emails: true,
},
},
items: {
include: {
product: {
include: {
category: true,
sizes: true,
},
},
recipe: {
include: {
components: {
include: {
material: true,
},
},
services: {
include: {
service: true,
},
},
},
},
},
},
requestedServices: {
include: {
service: true,
completedBy: true,
},
},
logisticsPartner: {
include: {
phones: true,
emails: true,
},
},
supplier: {
include: {
phones: true,
emails: true,
},
},
receivedBy: true,
},
const user = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true },
})
if (!order) {
throw new GraphQLError('Поставка не найдена', {
extensions: { code: 'NOT_FOUND' },
})
if (!user?.organization || user.organization.type !== 'FULFILLMENT') {
return []
}
// Проверка прав доступа
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({
const supplies = await prisma.sellerGoodsSupplyOrder.findMany({
where: {
product: {
organizationId: user.organizationId!,
},
fulfillmentCenterId: user.organizationId!,
},
include: {
product: {
seller: true,
fulfillmentCenter: true,
supplier: true,
logisticsPartner: true,
receivedBy: true,
recipeItems: {
include: {
category: true,
},
},
components: {
include: {
material: true,
},
},
services: {
include: {
service: true,
product: true,
},
},
},
orderBy: {
updatedAt: 'desc',
createdAt: 'desc',
},
})
return recipes
return supplies
} catch (error) {
throw new GraphQLError('Ошибка получения рецептур', {
extensions: { code: 'INTERNAL_ERROR', originalError: error },
})
console.error('Error fetching incoming seller goods supplies:', error)
return []
}
},
},
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' },
// Товарные заказы от селлеров (для поставщиков)
mySellerGoodsSupplyRequests: async (_: unknown, __: unknown, context: Context) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' },
})
}
try {
// Проверяем фулфилмент-центр
const fulfillmentCenter = await prisma.organization.findFirst({
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: {
id: input.fulfillmentCenterId,
type: 'FULFILLMENT',
supplierId: user.organizationId!,
},
include: {
seller: true,
fulfillmentCenter: true,
supplier: true,
logisticsPartner: true,
receivedBy: true,
recipeItems: {
include: {
product: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
})
if (!fulfillmentCenter) {
throw new GraphQLError('Фулфилмент-центр не найден', {
extensions: { code: 'NOT_FOUND' },
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' },
})
}
// Проверяем товары и рецептуры
for (const item of input.items) {
const product = await prisma.product.findFirst({
where: {
id: item.productId,
organizationId: user.organizationId!,
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: {
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 totalCost = 0
const mainProducts = recipeItems.filter((item: any) => item.recipeType === 'MAIN_PRODUCT')
if (mainProducts.length === 0) {
throw new GraphQLError('Должен быть хотя бы один основной товар')
}
// Проверяем все товары в рецептуре
for (const item of recipeItems) {
const product = await prisma.product.findUnique({
where: { id: item.productId },
})
if (!product) {
throw new GraphQLError(`Товар ${item.productId} не найден`, {
extensions: { code: 'NOT_FOUND' },
})
throw new GraphQLError(`Товар с ID ${item.productId} не найден`)
}
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' },
})
if (product.organizationId !== supplierId) {
throw new GraphQLError(`Товар ${product.name} не принадлежит выбранному поставщику`)
}
// Для основных товаров проверяем остатки
if (item.recipeType === 'MAIN_PRODUCT') {
const availableStock = (product.stock || product.quantity || 0) - (product.ordered || 0)
if (item.quantity > availableStock) {
throw new GraphQLError(
`Недостаточно остатков товара "${product.name}". ` +
`Доступно: ${availableStock} шт., запрашивается: ${item.quantity} шт.`,
)
}
totalCost += product.price.toNumber() * item.quantity
}
}
// Создаем поставку в транзакции
const order = await prisma.$transaction(async (tx) => {
// Создаем основную запись
const newOrder = await tx.goodsSupplyOrder.create({
// 🚀 СОЗДАНИЕ ПОСТАВКИ В ТРАНЗАКЦИИ
const supplyOrder = await prisma.$transaction(async (tx) => {
// Создаем заказ поставки
const newOrder = await tx.sellerGoodsSupplyOrder.create({
data: {
sellerId: user.organizationId!,
fulfillmentCenterId: input.fulfillmentCenterId,
requestedDeliveryDate: new Date(input.requestedDeliveryDate),
notes: input.notes,
fulfillmentCenterId,
supplierId,
logisticsPartnerId,
requestedDeliveryDate: new Date(requestedDeliveryDate),
notes,
status: 'PENDING',
totalCostWithDelivery: totalCost,
},
})
// Создаем товары
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({
// Создаем записи рецептуры
for (const item of recipeItems) {
await tx.goodsSupplyRecipeItem.create({
data: {
orderId: newOrder.id,
productId: itemInput.productId,
quantity: itemInput.quantity,
price: itemInput.price,
totalPrice: itemTotal,
recipeId: itemInput.recipeId,
supplyOrderId: newOrder.id,
productId: item.productId,
quantity: item.quantity,
recipeType: item.recipeType,
},
})
// Резервируем основные товары у поставщика
if (item.recipeType === 'MAIN_PRODUCT') {
await tx.product.update({
where: { id: item.productId },
data: {
ordered: {
increment: item.quantity,
},
},
})
}
// Создаем запросы услуг
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
})
// 📨 УВЕДОМЛЕНИЯ
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 createdOrder = await prisma.goodsSupplyOrder.findUnique({
where: { id: order.id },
const createdSupply = await prisma.sellerGoodsSupplyOrder.findUnique({
where: { id: supplyOrder.id },
include: {
seller: true,
fulfillmentCenter: true,
items: {
supplier: true,
logisticsPartner: true,
recipeItems: {
include: {
product: true,
recipe: true,
},
},
requestedServices: {
include: {
service: true,
},
},
},
@ -571,278 +450,296 @@ export const goodsSupplyV2Resolvers = {
return {
success: true,
message: 'Товарная поставка успешно создана',
order: createdOrder,
message: 'Поставка товаров успешно создана',
supplyOrder: createdSupply,
}
} catch (error) {
console.error('Error creating seller goods supply:', error)
if (error instanceof GraphQLError) {
throw error
}
throw new GraphQLError('Ошибка создания поставки', {
extensions: { code: 'INTERNAL_ERROR', originalError: error },
})
throw new GraphQLError('Ошибка создания товарной поставки')
}
},
// Обновление статуса товарной поставки
updateGoodsSupplyOrderStatus: async (_: unknown, args: any, context: Context) => {
const { user } = context
const { id, status, notes } = args
if (!user?.organizationId) {
throw new GraphQLError('Необходима авторизация', {
extensions: { code: 'UNAUTHENTICATED' },
})
updateSellerGoodsSupplyStatus: async (
_: unknown,
args: { id: string; status: string; notes?: string },
context: Context,
) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация')
}
try {
const order = await prisma.goodsSupplyOrder.findUnique({
where: { id },
const user = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true },
})
if (!order) {
throw new GraphQLError('Поставка не найдена', {
extensions: { code: 'NOT_FOUND' },
})
if (!user?.organization) {
throw new GraphQLError('Организация не найдена')
}
// Проверка прав на изменение статуса
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,
const supply = await prisma.sellerGoodsSupplyOrder.findUnique({
where: { id: args.id },
include: {
receivedBy: true,
seller: true,
supplier: true,
fulfillmentCenter: true,
recipeItems: {
include: {
product: true,
},
},
},
})
return updatedOrder
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('Ошибка обновления статуса', {
extensions: { code: 'INTERNAL_ERROR', originalError: error },
})
throw new GraphQLError('Ошибка обновления статуса товарной поставки')
}
},
// Приемка товарной поставки
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' },
})
// Отмена товарной поставки селлером
cancelSellerGoodsSupply: async (_: unknown, args: { id: string }, context: Context) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация')
}
try {
const order = await prisma.goodsSupplyOrder.findUnique({
where: { id },
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: {
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: {
seller: true,
recipeItems: {
include: {
product: true,
recipe: {
include: {
components: {
include: {
material: true,
},
},
},
},
},
},
requestedServices: {
include: {
service: true,
},
},
receivedBy: 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
})
return updatedOrder
// 📨 УВЕДОМЛЕНИЯ ОБ ОТМЕНЕ
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('Ошибка приемки поставки', {
extensions: { code: 'INTERNAL_ERROR', originalError: error },
})
throw new GraphQLError('Ошибка отмены товарной поставки')
}
},
// Отмена товарной поставки
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 },
})
}
},
},
}

View File

@ -4,11 +4,11 @@
import { GraphQLError } from 'graphql'
import { processSellerConsumableSupplyReceipt } from '@/lib/inventory-management'
import { prisma } from '@/lib/prisma'
import { notifyOrganization } from '@/lib/realtime'
import { Context } from '../context'
import { processSellerConsumableSupplyReceipt } from '@/lib/inventory-management'
// =============================================================================
// 🔍 QUERY RESOLVERS

View File

@ -1932,6 +1932,136 @@ export const typeDefs = gql`
supplyOrder: SellerConsumableSupplyOrder
}
# ===============================================
# 🛒 SELLER GOODS SUPPLY TYPES V2.0 - ТОВАРНЫЕ ПОСТАВКИ
# ===============================================
# Главный тип для товарных поставок селлера
type SellerGoodsSupplyOrder {
id: ID!
status: SellerSupplyOrderStatus!
# Данные селлера (создатель)
sellerId: ID!
seller: Organization!
fulfillmentCenterId: ID!
fulfillmentCenter: Organization!
requestedDeliveryDate: DateTime!
notes: String
# Данные поставщика
supplierId: ID
supplier: Organization
supplierApprovedAt: DateTime
packagesCount: Int
estimatedVolume: Float
supplierContractId: String
supplierNotes: String
# Данные логистики
logisticsPartnerId: ID
logisticsPartner: Organization
estimatedDeliveryDate: DateTime
routeId: ID
logisticsCost: Float
logisticsNotes: String
# Данные отгрузки
shippedAt: DateTime
trackingNumber: String
# Данные получения
deliveredAt: DateTime
receivedById: ID
receivedBy: User
receiptNotes: String
# Финансы
totalCostWithDelivery: Float!
actualDeliveryCost: Float!
# Нормализованная рецептура
recipeItems: [GoodsSupplyRecipeItem!]!
createdAt: DateTime!
updatedAt: DateTime!
}
# Нормализованная рецептура для товарных поставок
type GoodsSupplyRecipeItem {
id: ID!
productId: ID!
product: Product!
quantity: Int!
recipeType: RecipeType!
createdAt: DateTime!
}
# Типы компонентов в рецептуре
enum RecipeType {
MAIN_PRODUCT # Основной товар
COMPONENT # Компонент товара
PACKAGING # Упаковка
ACCESSORY # Аксессуар
}
# Инвентарь товаров селлера на складе
type SellerGoodsInventory {
id: ID!
sellerId: ID!
seller: Organization!
fulfillmentCenterId: ID!
fulfillmentCenter: Organization!
productId: ID!
product: Product!
# Складские данные
currentStock: Int!
reservedStock: Int!
inPreparationStock: Int!
totalReceived: Int!
totalShipped: Int!
# Пороги
minStock: Int!
maxStock: Int
# Цены
averageCost: Float!
salePrice: Float!
# Метаданные
lastSupplyDate: DateTime
lastShipDate: DateTime
notes: String
createdAt: DateTime!
updatedAt: DateTime!
}
# Input типы для создания товарных поставок
input CreateSellerGoodsSupplyInput {
fulfillmentCenterId: ID! # куда доставлять (FULFILLMENT партнер)
supplierId: ID! # от кого заказывать (WHOLESALE партнер)
logisticsPartnerId: ID # кто везет (LOGIST партнер, опционально)
requestedDeliveryDate: DateTime! # когда нужно
notes: String # заметки селлера
recipeItems: [GoodsSupplyRecipeItemInput!]! # нормализованная рецептура
}
# Input для компонентов рецептуры товарных поставок
input GoodsSupplyRecipeItemInput {
productId: ID! # какой товар
quantity: Int! # количество
recipeType: RecipeType! # тип компонента
}
# Результат создания товарной поставки
type CreateSellerGoodsSupplyResult {
success: Boolean!
message: String!
supplyOrder: SellerGoodsSupplyOrder
}
# Расширяем Query для селлерских поставок
extend type Query {
# Поставки селлера (мои заказы)
@ -1945,6 +2075,22 @@ export const typeDefs = gql`
# Конкретная поставка селлера
sellerConsumableSupply(id: ID!): SellerConsumableSupplyOrder
# === V2 ТОВАРНЫЕ ПОСТАВКИ СЕЛЛЕРА ===
# Поставки товаров селлера (мои заказы)
mySellerGoodsSupplies: [SellerGoodsSupplyOrder!]!
# Входящие товарные заказы от селлеров (для фулфилмента)
incomingSellerGoodsSupplies: [SellerGoodsSupplyOrder!]!
# Товарные поставки селлеров для поставщиков
mySellerGoodsSupplyRequests: [SellerGoodsSupplyOrder!]!
# Конкретная товарная поставка селлера
sellerGoodsSupply(id: ID!): SellerGoodsSupplyOrder
# Инвентарь товаров селлера (для фулфилмента и селлера)
mySellerGoodsInventory: [SellerGoodsInventory!]!
}
# Расширяем Mutation для селлерских поставок
@ -1963,6 +2109,16 @@ export const typeDefs = gql`
# Отмена поставки селлером (только PENDING/APPROVED)
cancelSellerSupply(id: ID!): SellerConsumableSupplyOrder!
# === V2 ТОВАРНЫЕ ПОСТАВКИ МУТАЦИИ ===
# Создание поставки товаров селлера
createSellerGoodsSupply(input: CreateSellerGoodsSupplyInput!): CreateSellerGoodsSupplyResult!
# Обновление статуса товарной поставки
updateSellerGoodsSupplyStatus(id: ID!, status: SellerSupplyOrderStatus!, notes: String): SellerGoodsSupplyOrder!
# Отмена товарной поставки селлером
cancelSellerGoodsSupply(id: ID!): SellerGoodsSupplyOrder!
}
# === V2 ЛОГИСТИКА РАСХОДНИКОВ ФУЛФИЛМЕНТА ===

View File

@ -0,0 +1,250 @@
// =============================================================================
// 📦 INVENTORY MANAGEMENT ДЛЯ ТОВАРОВ СЕЛЛЕРА V2
// =============================================================================
import { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
// =============================================================================
// 🔄 ПРИЕМКА ТОВАРОВ НА СКЛАД ФУЛФИЛМЕНТА
// =============================================================================
export async function processSellerGoodsSupplyReceipt(
supplyOrderId: string,
): Promise<void> {
console.log(`📦 Processing seller goods supply receipt: ${supplyOrderId}`)
const supplyOrder = await prisma.sellerGoodsSupplyOrder.findUnique({
where: { id: supplyOrderId },
include: {
seller: true,
fulfillmentCenter: true,
recipeItems: {
include: {
product: true,
},
},
},
})
if (!supplyOrder) {
throw new Error(`Seller goods supply order not found: ${supplyOrderId}`)
}
// Обрабатываем только товары типа MAIN_PRODUCT
const mainProducts = supplyOrder.recipeItems.filter(
item => item.recipeType === 'MAIN_PRODUCT',
)
for (const item of mainProducts) {
// Находим или создаем запись инвентаря
const existingInventory = await prisma.sellerGoodsInventory.findUnique({
where: {
sellerId_fulfillmentCenterId_productId: {
sellerId: supplyOrder.sellerId,
fulfillmentCenterId: supplyOrder.fulfillmentCenterId,
productId: item.productId,
},
},
})
if (existingInventory) {
// Обновляем существующий инвентарь
const newAverageCost = calculateWeightedAverageCost(
existingInventory.currentStock,
existingInventory.averageCost.toNumber(),
item.quantity,
item.product.price.toNumber(),
)
await prisma.sellerGoodsInventory.update({
where: { id: existingInventory.id },
data: {
currentStock: {
increment: item.quantity,
},
totalReceived: {
increment: item.quantity,
},
averageCost: newAverageCost,
lastSupplyDate: new Date(),
},
})
console.log(
`✅ Updated inventory for ${item.product.name}: +${item.quantity} units`,
)
} else {
// Создаем новую запись инвентаря
await prisma.sellerGoodsInventory.create({
data: {
sellerId: supplyOrder.sellerId,
fulfillmentCenterId: supplyOrder.fulfillmentCenterId,
productId: item.productId,
currentStock: item.quantity,
totalReceived: item.quantity,
averageCost: item.product.price,
salePrice: item.product.price, // Можно будет настроить отдельно
lastSupplyDate: new Date(),
},
})
console.log(
`✅ Created new inventory for ${item.product.name}: ${item.quantity} units`,
)
}
// Снимаем резерв у поставщика
await prisma.product.update({
where: { id: item.productId },
data: {
ordered: {
decrement: item.quantity,
},
},
})
}
console.log('✅ Supply receipt processed successfully')
}
// =============================================================================
// 🧮 РАСЧЕТ СРЕДНЕВЗВЕШЕННОЙ СТОИМОСТИ
// =============================================================================
function calculateWeightedAverageCost(
currentStock: number,
currentCost: number,
newQuantity: number,
newCost: number,
): Prisma.Decimal {
const totalValue = currentStock * currentCost + newQuantity * newCost
const totalQuantity = currentStock + newQuantity
const averageCost = totalQuantity > 0 ? totalValue / totalQuantity : 0
return new Prisma.Decimal(averageCost.toFixed(2))
}
// =============================================================================
// 📤 ИСПОЛЬЗОВАНИЕ ТОВАРОВ (заготовка для будущего)
// =============================================================================
export async function useSellerGoodsFromInventory(
sellerId: string,
fulfillmentCenterId: string,
productId: string,
quantity: number,
purpose: 'MARKETPLACE_SHIPMENT' | 'INTERNAL_USE' | 'DAMAGE',
): Promise<void> {
console.log(
`📤 Using ${quantity} units of product ${productId} for ${purpose}`,
)
const inventory = await prisma.sellerGoodsInventory.findUnique({
where: {
sellerId_fulfillmentCenterId_productId: {
sellerId,
fulfillmentCenterId,
productId,
},
},
})
if (!inventory) {
throw new Error('Inventory record not found')
}
if (inventory.currentStock < quantity) {
throw new Error(
`Insufficient stock. Available: ${inventory.currentStock}, requested: ${quantity}`,
)
}
// Обновляем инвентарь
await prisma.sellerGoodsInventory.update({
where: { id: inventory.id },
data: {
currentStock: {
decrement: quantity,
},
totalShipped: {
increment: purpose === 'MARKETPLACE_SHIPMENT' ? quantity : 0,
},
lastShipDate: purpose === 'MARKETPLACE_SHIPMENT' ? new Date() : undefined,
},
})
// TODO: Создать запись в таблице движений товаров (InventoryMovement)
console.log(`✅ Inventory updated: -${quantity} units`)
}
// =============================================================================
// 🔄 РЕЗЕРВИРОВАНИЕ ТОВАРОВ (заготовка для будущего)
// =============================================================================
export async function reserveSellerGoods(
sellerId: string,
fulfillmentCenterId: string,
productId: string,
quantity: number,
): Promise<void> {
const inventory = await prisma.sellerGoodsInventory.findUnique({
where: {
sellerId_fulfillmentCenterId_productId: {
sellerId,
fulfillmentCenterId,
productId,
},
},
})
if (!inventory) {
throw new Error('Inventory record not found')
}
const availableStock = inventory.currentStock - inventory.reservedStock
if (availableStock < quantity) {
throw new Error(
`Insufficient available stock. Available: ${availableStock}, requested: ${quantity}`,
)
}
await prisma.sellerGoodsInventory.update({
where: { id: inventory.id },
data: {
reservedStock: {
increment: quantity,
},
},
})
}
// =============================================================================
// 🏭 ПЕРЕВОД В ПОДГОТОВКУ (заготовка для будущего)
// =============================================================================
export async function moveToPreparation(
sellerId: string,
fulfillmentCenterId: string,
productId: string,
quantity: number,
): Promise<void> {
await prisma.sellerGoodsInventory.update({
where: {
sellerId_fulfillmentCenterId_productId: {
sellerId,
fulfillmentCenterId,
productId,
},
},
data: {
reservedStock: {
decrement: quantity,
},
inPreparationStock: {
increment: quantity,
},
},
})
}