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:
1045
prisma/schema.backup.20250901_020359.prisma
Normal file
1045
prisma/schema.backup.20250901_020359.prisma
Normal 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")
|
||||
}
|
@ -27,6 +27,7 @@ model User {
|
||||
// === НОВЫЕ СВЯЗИ С ПРИЕМКОЙ ПОСТАВОК V2 ===
|
||||
fulfillmentSupplyOrdersReceived FulfillmentConsumableSupplyOrder[] @relation("FFSupplyOrdersReceiver")
|
||||
sellerSupplyOrdersReceived SellerConsumableSupplyOrder[] @relation("SellerSupplyOrdersReceiver")
|
||||
sellerGoodsSupplyOrdersReceived SellerGoodsSupplyOrder[] @relation("SellerGoodsSupplyOrdersReceiver")
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@ -143,6 +144,16 @@ model Organization {
|
||||
// === СВЯЗИ С ИНВЕНТАРЕМ РАСХОДНИКОВ СЕЛЛЕРА V2 ===
|
||||
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])
|
||||
@ -314,6 +325,10 @@ model Product {
|
||||
// === НОВЫЕ СВЯЗИ СО СКЛАДСКИМИ ОСТАТКАМИ V2 ===
|
||||
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")
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
@ -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}
|
||||
/>
|
||||
|
@ -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
|
||||
|
@ -1,848 +1,745 @@
|
||||
// =============================================================================
|
||||
// 🛒 РЕЗОЛВЕРЫ ДЛЯ СИСТЕМЫ ПОСТАВОК ТОВАРОВ СЕЛЛЕРА 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 sellerGoodsQueries = {
|
||||
// Мои товарные поставки (для селлеров - заказы которые я создал)
|
||||
mySellerGoodsSupplies: async (_: unknown, __: unknown, context: Context) => {
|
||||
if (!context.user) {
|
||||
throw new GraphQLError('Требуется авторизация', {
|
||||
extensions: { code: 'UNAUTHENTICATED' },
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: context.user.id },
|
||||
include: { organization: true },
|
||||
})
|
||||
|
||||
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' },
|
||||
})
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const orders = await prisma.goodsSupplyOrder.findMany({
|
||||
const supplies = await prisma.sellerGoodsSupplyOrder.findMany({
|
||||
where: {
|
||||
sellerId: user.organizationId!,
|
||||
},
|
||||
include: {
|
||||
seller: true,
|
||||
fulfillmentCenter: true,
|
||||
supplier: true,
|
||||
logisticsPartner: true,
|
||||
receivedBy: true,
|
||||
recipeItems: {
|
||||
include: {
|
||||
product: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
})
|
||||
|
||||
return supplies
|
||||
} catch (error) {
|
||||
console.error('Error fetching seller goods supplies:', error)
|
||||
return []
|
||||
}
|
||||
},
|
||||
|
||||
// Входящие товарные заказы от селлеров (для фулфилмента)
|
||||
incomingSellerGoodsSupplies: async (_: unknown, __: unknown, context: Context) => {
|
||||
if (!context.user) {
|
||||
throw new GraphQLError('Требуется авторизация', {
|
||||
extensions: { code: 'UNAUTHENTICATED' },
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: context.user.id },
|
||||
include: { organization: true },
|
||||
})
|
||||
|
||||
if (!user?.organization || user.organization.type !== 'FULFILLMENT') {
|
||||
return []
|
||||
}
|
||||
|
||||
const supplies = await prisma.sellerGoodsSupplyOrder.findMany({
|
||||
where: {
|
||||
fulfillmentCenterId: user.organizationId!,
|
||||
},
|
||||
include: {
|
||||
seller: true,
|
||||
fulfillmentCenter: true,
|
||||
supplier: true,
|
||||
logisticsPartner: true,
|
||||
receivedBy: true,
|
||||
recipeItems: {
|
||||
include: {
|
||||
product: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
})
|
||||
|
||||
return supplies
|
||||
} catch (error) {
|
||||
console.error('Error fetching incoming seller goods supplies:', error)
|
||||
return []
|
||||
}
|
||||
},
|
||||
|
||||
// Товарные заказы от селлеров (для поставщиков)
|
||||
mySellerGoodsSupplyRequests: async (_: unknown, __: unknown, context: Context) => {
|
||||
if (!context.user) {
|
||||
throw new GraphQLError('Требуется авторизация', {
|
||||
extensions: { code: 'UNAUTHENTICATED' },
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: context.user.id },
|
||||
include: { organization: true },
|
||||
})
|
||||
|
||||
if (!user?.organization || user.organization.type !== 'WHOLESALE') {
|
||||
return []
|
||||
}
|
||||
|
||||
const supplies = await prisma.sellerGoodsSupplyOrder.findMany({
|
||||
where: {
|
||||
supplierId: user.organizationId!,
|
||||
},
|
||||
include: {
|
||||
seller: true,
|
||||
fulfillmentCenter: true,
|
||||
supplier: true,
|
||||
logisticsPartner: true,
|
||||
receivedBy: true,
|
||||
recipeItems: {
|
||||
include: {
|
||||
product: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
})
|
||||
|
||||
return supplies
|
||||
} catch (error) {
|
||||
console.error('Error fetching seller goods supply requests:', error)
|
||||
return []
|
||||
}
|
||||
},
|
||||
|
||||
// Получение конкретной товарной поставки селлера
|
||||
sellerGoodsSupply: async (_: unknown, args: { id: string }, context: Context) => {
|
||||
if (!context.user) {
|
||||
throw new GraphQLError('Требуется авторизация', {
|
||||
extensions: { code: 'UNAUTHENTICATED' },
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: context.user.id },
|
||||
include: { organization: true },
|
||||
})
|
||||
|
||||
if (!user?.organization) {
|
||||
throw new GraphQLError('Организация не найдена')
|
||||
}
|
||||
|
||||
const supply = await prisma.sellerGoodsSupplyOrder.findUnique({
|
||||
where: { id: args.id },
|
||||
include: {
|
||||
seller: true,
|
||||
fulfillmentCenter: true,
|
||||
supplier: true,
|
||||
logisticsPartner: true,
|
||||
receivedBy: true,
|
||||
recipeItems: {
|
||||
include: {
|
||||
product: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!supply) {
|
||||
throw new GraphQLError('Поставка не найдена')
|
||||
}
|
||||
|
||||
// Проверка доступа
|
||||
const hasAccess =
|
||||
(user.organization.type === 'SELLER' && supply.sellerId === user.organizationId) ||
|
||||
(user.organization.type === 'FULFILLMENT' && supply.fulfillmentCenterId === user.organizationId) ||
|
||||
(user.organization.type === 'WHOLESALE' && supply.supplierId === user.organizationId) ||
|
||||
(user.organization.type === 'LOGIST' && supply.logisticsPartnerId === user.organizationId)
|
||||
|
||||
if (!hasAccess) {
|
||||
throw new GraphQLError('Нет доступа к этой поставке')
|
||||
}
|
||||
|
||||
return supply
|
||||
} catch (error) {
|
||||
console.error('Error fetching seller goods supply:', error)
|
||||
if (error instanceof GraphQLError) {
|
||||
throw error
|
||||
}
|
||||
throw new GraphQLError('Ошибка получения товарной поставки')
|
||||
}
|
||||
},
|
||||
|
||||
// Инвентарь товаров селлера на складе фулфилмента
|
||||
mySellerGoodsInventory: async (_: unknown, __: unknown, context: Context) => {
|
||||
if (!context.user) {
|
||||
throw new GraphQLError('Требуется авторизация', {
|
||||
extensions: { code: 'UNAUTHENTICATED' },
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: context.user.id },
|
||||
include: { organization: true },
|
||||
})
|
||||
|
||||
if (!user?.organization) {
|
||||
return []
|
||||
}
|
||||
|
||||
let inventoryItems
|
||||
|
||||
if (user.organization.type === 'SELLER') {
|
||||
// Селлер видит свои товары на всех складах
|
||||
inventoryItems = await prisma.sellerGoodsInventory.findMany({
|
||||
where: {
|
||||
sellerId: user.organizationId!,
|
||||
},
|
||||
include: {
|
||||
seller: true,
|
||||
fulfillmentCenter: {
|
||||
include: {
|
||||
phones: true,
|
||||
emails: true,
|
||||
},
|
||||
},
|
||||
items: {
|
||||
include: {
|
||||
product: {
|
||||
include: {
|
||||
category: true,
|
||||
sizes: true,
|
||||
},
|
||||
},
|
||||
recipe: {
|
||||
include: {
|
||||
components: {
|
||||
include: {
|
||||
material: true,
|
||||
},
|
||||
},
|
||||
services: {
|
||||
include: {
|
||||
service: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
requestedServices: {
|
||||
include: {
|
||||
service: true,
|
||||
completedBy: true,
|
||||
},
|
||||
},
|
||||
logisticsPartner: {
|
||||
include: {
|
||||
phones: true,
|
||||
},
|
||||
},
|
||||
supplier: {
|
||||
include: {
|
||||
phones: true,
|
||||
},
|
||||
},
|
||||
receivedBy: true,
|
||||
fulfillmentCenter: true,
|
||||
product: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
lastSupplyDate: 'desc',
|
||||
},
|
||||
})
|
||||
|
||||
return orders
|
||||
} catch (error) {
|
||||
throw new GraphQLError('Ошибка получения товарных поставок', {
|
||||
extensions: { code: 'INTERNAL_ERROR', originalError: error },
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
// Входящие товарные поставки (для фулфилмента)
|
||||
incomingGoodsSuppliesV2: async (_: unknown, __: unknown, context: Context) => {
|
||||
const { user } = context
|
||||
|
||||
if (!user?.organization || user.organization.type !== 'FULFILLMENT') {
|
||||
throw new GraphQLError('Доступно только для фулфилмент-центров', {
|
||||
extensions: { code: 'FORBIDDEN' },
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const orders = await prisma.goodsSupplyOrder.findMany({
|
||||
} else if (user.organization.type === 'FULFILLMENT') {
|
||||
// Фулфилмент видит все товары на своем складе
|
||||
inventoryItems = await prisma.sellerGoodsInventory.findMany({
|
||||
where: {
|
||||
fulfillmentCenterId: user.organizationId!,
|
||||
},
|
||||
include: {
|
||||
seller: {
|
||||
include: {
|
||||
phones: true,
|
||||
emails: true,
|
||||
},
|
||||
},
|
||||
seller: 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,
|
||||
product: true,
|
||||
},
|
||||
orderBy: {
|
||||
requestedDeliveryDate: 'asc',
|
||||
lastSupplyDate: 'desc',
|
||||
},
|
||||
})
|
||||
|
||||
// Фильтруем коммерческие данные селлера
|
||||
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' },
|
||||
})
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
|
||||
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 inventoryItems
|
||||
} catch (error) {
|
||||
console.error('Error fetching seller goods inventory:', error)
|
||||
return []
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// Показываем только релевантную для поставщика информацию
|
||||
return orders.map(order => ({
|
||||
...order,
|
||||
items: order.items.map(item => ({
|
||||
...item,
|
||||
recipe: null, // Поставщик не видит рецептуры
|
||||
})),
|
||||
}))
|
||||
} catch (error) {
|
||||
throw new GraphQLError('Ошибка получения заказов поставок', {
|
||||
extensions: { code: 'INTERNAL_ERROR', originalError: error },
|
||||
})
|
||||
}
|
||||
},
|
||||
// =============================================================================
|
||||
// ✏️ MUTATION RESOLVERS V2
|
||||
// =============================================================================
|
||||
|
||||
// Детали конкретной поставки
|
||||
goodsSupplyOrderV2: async (_: unknown, args: { id: string }, context: Context) => {
|
||||
const { user } = context
|
||||
|
||||
if (!user?.organizationId) {
|
||||
throw new GraphQLError('Необходима авторизация', {
|
||||
extensions: { code: 'UNAUTHENTICATED' },
|
||||
})
|
||||
}
|
||||
export const sellerGoodsMutations = {
|
||||
// Создание поставки товаров селлера
|
||||
createSellerGoodsSupply: async (_: unknown, args: { input: any }, 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,
|
||||
},
|
||||
})
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: context.user.id },
|
||||
include: { organization: true },
|
||||
})
|
||||
|
||||
if (!order) {
|
||||
throw new GraphQLError('Поставка не найдена', {
|
||||
extensions: { code: 'NOT_FOUND' },
|
||||
})
|
||||
}
|
||||
|
||||
// Проверка прав доступа
|
||||
const hasAccess =
|
||||
order.sellerId === user.organizationId ||
|
||||
order.fulfillmentCenterId === user.organizationId ||
|
||||
order.supplierId === user.organizationId ||
|
||||
order.logisticsPartnerId === user.organizationId
|
||||
|
||||
if (!hasAccess) {
|
||||
throw new GraphQLError('Доступ запрещен', {
|
||||
extensions: { code: 'FORBIDDEN' },
|
||||
})
|
||||
}
|
||||
|
||||
// Фильтрация данных в зависимости от роли
|
||||
if (user.organization?.type === 'WHOLESALE') {
|
||||
// Поставщик не видит рецептуры и услуги ФФ
|
||||
return {
|
||||
...order,
|
||||
items: order.items.map(item => ({
|
||||
...item,
|
||||
recipe: null,
|
||||
})),
|
||||
requestedServices: [],
|
||||
}
|
||||
}
|
||||
|
||||
if (user.organization?.type === 'FULFILLMENT') {
|
||||
// ФФ не видит закупочные цены селлера
|
||||
return {
|
||||
...order,
|
||||
items: order.items.map(item => ({
|
||||
...item,
|
||||
price: null,
|
||||
totalPrice: null,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
if (user.organization?.type === 'LOGIST') {
|
||||
// Логистика видит только логистическую информацию
|
||||
return {
|
||||
...order,
|
||||
items: order.items.map(item => ({
|
||||
...item,
|
||||
price: null,
|
||||
totalPrice: null,
|
||||
recipe: null,
|
||||
})),
|
||||
requestedServices: [],
|
||||
}
|
||||
}
|
||||
|
||||
// Селлер видит все свои данные
|
||||
return order
|
||||
} catch (error) {
|
||||
if (error instanceof GraphQLError) {
|
||||
throw error
|
||||
}
|
||||
throw new GraphQLError('Ошибка получения поставки', {
|
||||
extensions: { code: 'INTERNAL_ERROR', originalError: error },
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
// Рецептуры товаров селлера
|
||||
myProductRecipes: async (_: unknown, __: unknown, context: Context) => {
|
||||
const { user } = context
|
||||
|
||||
if (!user?.organization || user.organization.type !== 'SELLER') {
|
||||
throw new GraphQLError('Доступно только для селлеров', {
|
||||
extensions: { code: 'FORBIDDEN' },
|
||||
})
|
||||
throw new GraphQLError('Доступно только для селлеров')
|
||||
}
|
||||
|
||||
try {
|
||||
const recipes = await prisma.productRecipe.findMany({
|
||||
where: {
|
||||
product: {
|
||||
organizationId: user.organizationId!,
|
||||
},
|
||||
const { fulfillmentCenterId, supplierId, logisticsPartnerId, requestedDeliveryDate, notes, recipeItems } = args.input
|
||||
|
||||
// 🔍 ВАЛИДАЦИЯ ПАРТНЕРОВ
|
||||
|
||||
// Проверяем фулфилмент-центр
|
||||
const fulfillmentCenter = await prisma.organization.findUnique({
|
||||
where: { id: fulfillmentCenterId },
|
||||
include: {
|
||||
counterpartiesAsCounterparty: {
|
||||
where: { organizationId: user.organizationId! },
|
||||
},
|
||||
include: {
|
||||
product: {
|
||||
include: {
|
||||
category: true,
|
||||
},
|
||||
},
|
||||
components: {
|
||||
include: {
|
||||
material: true,
|
||||
},
|
||||
},
|
||||
services: {
|
||||
include: {
|
||||
service: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
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! },
|
||||
},
|
||||
orderBy: {
|
||||
updatedAt: 'desc',
|
||||
},
|
||||
})
|
||||
|
||||
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(`Товар с ID ${item.productId} не найден`)
|
||||
}
|
||||
|
||||
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 supplyOrder = await prisma.$transaction(async (tx) => {
|
||||
// Создаем заказ поставки
|
||||
const newOrder = await tx.sellerGoodsSupplyOrder.create({
|
||||
data: {
|
||||
sellerId: user.organizationId!,
|
||||
fulfillmentCenterId,
|
||||
supplierId,
|
||||
logisticsPartnerId,
|
||||
requestedDeliveryDate: new Date(requestedDeliveryDate),
|
||||
notes,
|
||||
status: 'PENDING',
|
||||
totalCostWithDelivery: totalCost,
|
||||
},
|
||||
})
|
||||
|
||||
return recipes
|
||||
} catch (error) {
|
||||
throw new GraphQLError('Ошибка получения рецептур', {
|
||||
extensions: { code: 'INTERNAL_ERROR', originalError: error },
|
||||
})
|
||||
// Создаем записи рецептуры
|
||||
for (const item of recipeItems) {
|
||||
await tx.goodsSupplyRecipeItem.create({
|
||||
data: {
|
||||
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,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return newOrder
|
||||
})
|
||||
|
||||
// 📨 УВЕДОМЛЕНИЯ
|
||||
await notifyOrganization(
|
||||
supplierId,
|
||||
`Новый заказ товаров от селлера ${user.organization.name}`,
|
||||
'GOODS_SUPPLY_ORDER_CREATED',
|
||||
{ orderId: supplyOrder.id },
|
||||
)
|
||||
|
||||
await notifyOrganization(
|
||||
fulfillmentCenterId,
|
||||
`Селлер ${user.organization.name} оформил поставку товаров на ваш склад`,
|
||||
'INCOMING_GOODS_SUPPLY_ORDER',
|
||||
{ orderId: supplyOrder.id },
|
||||
)
|
||||
|
||||
// Получаем созданную поставку с полными данными
|
||||
const createdSupply = await prisma.sellerGoodsSupplyOrder.findUnique({
|
||||
where: { id: supplyOrder.id },
|
||||
include: {
|
||||
seller: true,
|
||||
fulfillmentCenter: true,
|
||||
supplier: true,
|
||||
logisticsPartner: true,
|
||||
recipeItems: {
|
||||
include: {
|
||||
product: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Поставка товаров успешно создана',
|
||||
supplyOrder: createdSupply,
|
||||
}
|
||||
},
|
||||
} catch (error) {
|
||||
console.error('Error creating seller goods supply:', error)
|
||||
|
||||
if (error instanceof GraphQLError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
throw new GraphQLError('Ошибка создания товарной поставки')
|
||||
}
|
||||
},
|
||||
|
||||
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' },
|
||||
})
|
||||
// Обновление статуса товарной поставки
|
||||
updateSellerGoodsSupplyStatus: async (
|
||||
_: unknown,
|
||||
args: { id: string; status: string; notes?: string },
|
||||
context: Context,
|
||||
) => {
|
||||
if (!context.user) {
|
||||
throw new GraphQLError('Требуется авторизация')
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: context.user.id },
|
||||
include: { organization: true },
|
||||
})
|
||||
|
||||
if (!user?.organization) {
|
||||
throw new GraphQLError('Организация не найдена')
|
||||
}
|
||||
|
||||
try {
|
||||
// Проверяем фулфилмент-центр
|
||||
const fulfillmentCenter = await prisma.organization.findFirst({
|
||||
where: {
|
||||
id: input.fulfillmentCenterId,
|
||||
type: 'FULFILLMENT',
|
||||
const supply = await prisma.sellerGoodsSupplyOrder.findUnique({
|
||||
where: { id: args.id },
|
||||
include: {
|
||||
seller: true,
|
||||
supplier: true,
|
||||
fulfillmentCenter: true,
|
||||
recipeItems: {
|
||||
include: {
|
||||
product: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
if (!fulfillmentCenter) {
|
||||
throw new GraphQLError('Фулфилмент-центр не найден', {
|
||||
extensions: { code: 'NOT_FOUND' },
|
||||
})
|
||||
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('Только поставщик может одобрить заказ')
|
||||
}
|
||||
}
|
||||
|
||||
// Проверяем товары и рецептуры
|
||||
for (const item of input.items) {
|
||||
const product = await prisma.product.findFirst({
|
||||
where: {
|
||||
id: item.productId,
|
||||
organizationId: user.organizationId!,
|
||||
},
|
||||
})
|
||||
|
||||
if (!product) {
|
||||
throw new GraphQLError(`Товар ${item.productId} не найден`, {
|
||||
extensions: { code: 'NOT_FOUND' },
|
||||
})
|
||||
}
|
||||
|
||||
if (item.recipeId) {
|
||||
const recipe = await prisma.productRecipe.findFirst({
|
||||
where: {
|
||||
id: item.recipeId,
|
||||
productId: item.productId,
|
||||
},
|
||||
})
|
||||
|
||||
if (!recipe) {
|
||||
throw new GraphQLError(`Рецептура ${item.recipeId} не найдена`, {
|
||||
extensions: { code: 'NOT_FOUND' },
|
||||
})
|
||||
}
|
||||
}
|
||||
// Только поставщики могут переводить APPROVED → SHIPPED
|
||||
else if (status === 'SHIPPED' && currentStatus === 'APPROVED') {
|
||||
if (orgType !== 'WHOLESALE' || supply.supplierId !== user.organizationId) {
|
||||
throw new GraphQLError('Только поставщик может отметить отгрузку')
|
||||
}
|
||||
}
|
||||
|
||||
// Создаем поставку в транзакции
|
||||
const order = await prisma.$transaction(async (tx) => {
|
||||
// Создаем основную запись
|
||||
const newOrder = await tx.goodsSupplyOrder.create({
|
||||
data: {
|
||||
sellerId: user.organizationId!,
|
||||
fulfillmentCenterId: input.fulfillmentCenterId,
|
||||
requestedDeliveryDate: new Date(input.requestedDeliveryDate),
|
||||
notes: input.notes,
|
||||
status: 'PENDING',
|
||||
// Только фулфилмент может переводить 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,
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Создаем товары
|
||||
let totalAmount = 0
|
||||
let totalItems = 0
|
||||
// 📨 УВЕДОМЛЕНИЯ О СМЕНЕ СТАТУСА
|
||||
if (status === 'APPROVED') {
|
||||
await notifyOrganization(
|
||||
supply.sellerId,
|
||||
`Поставка товаров одобрена поставщиком ${user.organization.name}`,
|
||||
'GOODS_SUPPLY_APPROVED',
|
||||
{ orderId: args.id },
|
||||
)
|
||||
}
|
||||
|
||||
for (const itemInput of input.items) {
|
||||
const itemTotal = itemInput.price * itemInput.quantity
|
||||
totalAmount += itemTotal
|
||||
totalItems += itemInput.quantity
|
||||
if (status === 'SHIPPED') {
|
||||
await notifyOrganization(
|
||||
supply.sellerId,
|
||||
`Поставка товаров отгружена поставщиком ${user.organization.name}`,
|
||||
'GOODS_SUPPLY_SHIPPED',
|
||||
{ orderId: args.id },
|
||||
)
|
||||
|
||||
await tx.goodsSupplyOrderItem.create({
|
||||
data: {
|
||||
orderId: newOrder.id,
|
||||
productId: itemInput.productId,
|
||||
quantity: itemInput.quantity,
|
||||
price: itemInput.price,
|
||||
totalPrice: itemTotal,
|
||||
recipeId: itemInput.recipeId,
|
||||
},
|
||||
})
|
||||
}
|
||||
await notifyOrganization(
|
||||
supply.fulfillmentCenterId,
|
||||
'Поставка товаров в пути. Ожидается доставка',
|
||||
'GOODS_SUPPLY_IN_TRANSIT',
|
||||
{ orderId: args.id },
|
||||
)
|
||||
}
|
||||
|
||||
// Создаем запросы услуг
|
||||
for (const serviceInput of input.requestedServices) {
|
||||
const service = await tx.service.findUnique({
|
||||
where: { id: serviceInput.serviceId },
|
||||
})
|
||||
if (status === 'DELIVERED') {
|
||||
// 📦 АВТОМАТИЧЕСКОЕ СОЗДАНИЕ/ОБНОВЛЕНИЕ ИНВЕНТАРЯ V2
|
||||
await processSellerGoodsSupplyReceipt(args.id)
|
||||
|
||||
await notifyOrganization(
|
||||
supply.sellerId,
|
||||
`Поставка товаров доставлена в ${supply.fulfillmentCenter.name}`,
|
||||
'GOODS_SUPPLY_DELIVERED',
|
||||
{ orderId: args.id },
|
||||
)
|
||||
}
|
||||
|
||||
if (!service) {
|
||||
throw new Error(`Услуга ${serviceInput.serviceId} не найдена`)
|
||||
}
|
||||
if (status === 'COMPLETED') {
|
||||
await notifyOrganization(
|
||||
supply.sellerId,
|
||||
`Поставка товаров завершена. Товары размещены на складе ${supply.fulfillmentCenter.name}`,
|
||||
'GOODS_SUPPLY_COMPLETED',
|
||||
{ orderId: args.id },
|
||||
)
|
||||
}
|
||||
|
||||
const serviceTotal = service.price * serviceInput.quantity
|
||||
totalAmount += serviceTotal
|
||||
return updatedSupply
|
||||
} catch (error) {
|
||||
console.error('Error updating seller goods supply status:', error)
|
||||
|
||||
await tx.fulfillmentServiceRequest.create({
|
||||
data: {
|
||||
orderId: newOrder.id,
|
||||
serviceId: serviceInput.serviceId,
|
||||
quantity: serviceInput.quantity,
|
||||
price: service.price,
|
||||
totalPrice: serviceTotal,
|
||||
status: 'PENDING',
|
||||
},
|
||||
})
|
||||
}
|
||||
if (error instanceof GraphQLError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
// Обновляем итоги
|
||||
await tx.goodsSupplyOrder.update({
|
||||
where: { id: newOrder.id },
|
||||
data: {
|
||||
totalAmount,
|
||||
totalItems,
|
||||
throw new GraphQLError('Ошибка обновления статуса товарной поставки')
|
||||
}
|
||||
},
|
||||
|
||||
// Отмена товарной поставки селлером
|
||||
cancelSellerGoodsSupply: async (_: unknown, args: { id: string }, context: Context) => {
|
||||
if (!context.user) {
|
||||
throw new GraphQLError('Требуется авторизация')
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: context.user.id },
|
||||
include: { organization: true },
|
||||
})
|
||||
|
||||
if (!user?.organization || user.organization.type !== 'SELLER') {
|
||||
throw new GraphQLError('Только селлеры могут отменять свои поставки')
|
||||
}
|
||||
|
||||
const supply = await prisma.sellerGoodsSupplyOrder.findUnique({
|
||||
where: { id: args.id },
|
||||
include: {
|
||||
seller: true,
|
||||
recipeItems: {
|
||||
include: {
|
||||
product: true,
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return newOrder
|
||||
})
|
||||
if (!supply) {
|
||||
throw new GraphQLError('Поставка не найдена')
|
||||
}
|
||||
|
||||
// Получаем созданную поставку с полными данными
|
||||
const createdOrder = await prisma.goodsSupplyOrder.findUnique({
|
||||
where: { id: order.id },
|
||||
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,
|
||||
items: {
|
||||
supplier: true,
|
||||
recipeItems: {
|
||||
include: {
|
||||
product: true,
|
||||
recipe: true,
|
||||
},
|
||||
},
|
||||
requestedServices: {
|
||||
include: {
|
||||
service: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Товарная поставка успешно создана',
|
||||
order: createdOrder,
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof GraphQLError) {
|
||||
throw error
|
||||
}
|
||||
throw new GraphQLError('Ошибка создания поставки', {
|
||||
extensions: { code: 'INTERNAL_ERROR', originalError: error },
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
// Обновление статуса товарной поставки
|
||||
updateGoodsSupplyOrderStatus: async (_: unknown, args: any, context: Context) => {
|
||||
const { user } = context
|
||||
const { id, status, notes } = args
|
||||
|
||||
if (!user?.organizationId) {
|
||||
throw new GraphQLError('Необходима авторизация', {
|
||||
extensions: { code: 'UNAUTHENTICATED' },
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const order = await prisma.goodsSupplyOrder.findUnique({
|
||||
where: { id },
|
||||
})
|
||||
|
||||
if (!order) {
|
||||
throw new GraphQLError('Поставка не найдена', {
|
||||
extensions: { code: 'NOT_FOUND' },
|
||||
})
|
||||
}
|
||||
|
||||
// Проверка прав на изменение статуса
|
||||
const canUpdate =
|
||||
(status === 'SUPPLIER_APPROVED' && order.supplierId === user.organizationId) ||
|
||||
(status === 'LOGISTICS_CONFIRMED' && user.organization?.type === 'FULFILLMENT') ||
|
||||
(status === 'SHIPPED' && order.supplierId === user.organizationId) ||
|
||||
(status === 'IN_TRANSIT' && order.logisticsPartnerId === user.organizationId) ||
|
||||
(status === 'RECEIVED' && order.fulfillmentCenterId === user.organizationId) ||
|
||||
(status === 'CANCELLED' &&
|
||||
(order.sellerId === user.organizationId || order.fulfillmentCenterId === user.organizationId))
|
||||
|
||||
if (!canUpdate) {
|
||||
throw new GraphQLError('Недостаточно прав для изменения статуса', {
|
||||
extensions: { code: 'FORBIDDEN' },
|
||||
})
|
||||
}
|
||||
|
||||
const updateData: any = {
|
||||
status,
|
||||
notes: notes || order.notes,
|
||||
}
|
||||
|
||||
// Устанавливаем временные метки
|
||||
if (status === 'SUPPLIER_APPROVED') {
|
||||
updateData.supplierApprovedAt = new Date()
|
||||
} else if (status === 'SHIPPED') {
|
||||
updateData.shippedAt = new Date()
|
||||
} else if (status === 'RECEIVED') {
|
||||
updateData.receivedAt = new Date()
|
||||
updateData.receivedById = user.id
|
||||
}
|
||||
|
||||
const updatedOrder = await prisma.goodsSupplyOrder.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: {
|
||||
receivedBy: true,
|
||||
},
|
||||
})
|
||||
|
||||
return updatedOrder
|
||||
} catch (error) {
|
||||
if (error instanceof GraphQLError) {
|
||||
throw error
|
||||
}
|
||||
throw new GraphQLError('Ошибка обновления статуса', {
|
||||
extensions: { code: 'INTERNAL_ERROR', originalError: error },
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
// Приемка товарной поставки
|
||||
receiveGoodsSupplyOrder: async (_: unknown, args: any, context: Context) => {
|
||||
const { user } = context
|
||||
const { id, items } = args
|
||||
|
||||
if (!user?.organization || user.organization.type !== 'FULFILLMENT') {
|
||||
throw new GraphQLError('Доступно только для фулфилмент-центров', {
|
||||
extensions: { code: 'FORBIDDEN' },
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const order = await prisma.goodsSupplyOrder.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
items: {
|
||||
include: {
|
||||
recipe: {
|
||||
include: {
|
||||
components: {
|
||||
include: {
|
||||
material: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!order) {
|
||||
throw new GraphQLError('Поставка не найдена', {
|
||||
extensions: { code: 'NOT_FOUND' },
|
||||
})
|
||||
}
|
||||
|
||||
if (order.fulfillmentCenterId !== user.organizationId) {
|
||||
throw new GraphQLError('Доступ запрещен', {
|
||||
extensions: { code: 'FORBIDDEN' },
|
||||
})
|
||||
}
|
||||
|
||||
if (order.status !== 'IN_TRANSIT') {
|
||||
throw new GraphQLError('Поставка должна быть в статусе "В пути"', {
|
||||
extensions: { code: 'BAD_REQUEST' },
|
||||
})
|
||||
}
|
||||
|
||||
// Обрабатываем приемку в транзакции
|
||||
const updatedOrder = await prisma.$transaction(async (tx) => {
|
||||
// Обновляем данные приемки для каждого товара
|
||||
for (const itemInput of items) {
|
||||
const orderItem = order.items.find(item => item.id === itemInput.itemId)
|
||||
if (!orderItem) {
|
||||
throw new Error(`Товар ${itemInput.itemId} не найден в поставке`)
|
||||
}
|
||||
|
||||
await tx.goodsSupplyOrderItem.update({
|
||||
where: { id: itemInput.itemId },
|
||||
// Освобождаем зарезервированные товары у поставщика (только MAIN_PRODUCT)
|
||||
for (const item of supply.recipeItems) {
|
||||
if (item.recipeType === 'MAIN_PRODUCT') {
|
||||
await tx.product.update({
|
||||
where: { id: item.productId },
|
||||
data: {
|
||||
receivedQuantity: itemInput.receivedQuantity,
|
||||
damagedQuantity: itemInput.damagedQuantity || 0,
|
||||
acceptanceNotes: itemInput.acceptanceNotes,
|
||||
ordered: {
|
||||
decrement: item.quantity,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Обновляем остатки расходников по рецептуре
|
||||
if (orderItem.recipe && itemInput.receivedQuantity > 0) {
|
||||
for (const component of orderItem.recipe.components) {
|
||||
const usedQuantity = component.quantity * itemInput.receivedQuantity
|
||||
|
||||
await tx.supply.update({
|
||||
where: { id: component.materialId },
|
||||
data: {
|
||||
currentStock: {
|
||||
decrement: usedQuantity,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем статус поставки
|
||||
const updated = await tx.goodsSupplyOrder.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'RECEIVED',
|
||||
receivedAt: new Date(),
|
||||
receivedById: user.id,
|
||||
},
|
||||
include: {
|
||||
items: {
|
||||
include: {
|
||||
product: true,
|
||||
recipe: {
|
||||
include: {
|
||||
components: {
|
||||
include: {
|
||||
material: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
requestedServices: {
|
||||
include: {
|
||||
service: true,
|
||||
},
|
||||
},
|
||||
receivedBy: true,
|
||||
},
|
||||
})
|
||||
|
||||
return updated
|
||||
})
|
||||
|
||||
return updatedOrder
|
||||
} catch (error) {
|
||||
if (error instanceof GraphQLError) {
|
||||
throw error
|
||||
}
|
||||
throw new GraphQLError('Ошибка приемки поставки', {
|
||||
extensions: { code: 'INTERNAL_ERROR', originalError: error },
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
// Отмена товарной поставки
|
||||
cancelGoodsSupplyOrder: async (_: unknown, args: any, context: Context) => {
|
||||
const { user } = context
|
||||
const { id, reason } = args
|
||||
|
||||
if (!user?.organizationId) {
|
||||
throw new GraphQLError('Необходима авторизация', {
|
||||
extensions: { code: 'UNAUTHENTICATED' },
|
||||
})
|
||||
return updated
|
||||
})
|
||||
|
||||
// 📨 УВЕДОМЛЕНИЯ ОБ ОТМЕНЕ
|
||||
if (supply.supplierId) {
|
||||
await notifyOrganization(
|
||||
supply.supplierId,
|
||||
`Селлер ${user.organization.name} отменил заказ товаров`,
|
||||
'GOODS_SUPPLY_CANCELLED',
|
||||
{ orderId: args.id },
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const order = await prisma.goodsSupplyOrder.findUnique({
|
||||
where: { id },
|
||||
})
|
||||
await notifyOrganization(
|
||||
supply.fulfillmentCenterId,
|
||||
`Селлер ${user.organization.name} отменил поставку товаров`,
|
||||
'GOODS_SUPPLY_CANCELLED',
|
||||
{ orderId: args.id },
|
||||
)
|
||||
|
||||
if (!order) {
|
||||
throw new GraphQLError('Поставка не найдена', {
|
||||
extensions: { code: 'NOT_FOUND' },
|
||||
})
|
||||
}
|
||||
return cancelledSupply
|
||||
} catch (error) {
|
||||
console.error('Error cancelling seller goods supply:', error)
|
||||
|
||||
// Проверка прав на отмену
|
||||
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 },
|
||||
})
|
||||
if (error instanceof GraphQLError) {
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
throw new GraphQLError('Ошибка отмены товарной поставки')
|
||||
}
|
||||
},
|
||||
}
|
@ -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
|
||||
|
@ -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 ЛОГИСТИКА РАСХОДНИКОВ ФУЛФИЛМЕНТА ===
|
||||
|
250
src/lib/inventory-management-goods.ts
Normal file
250
src/lib/inventory-management-goods.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
Reference in New Issue
Block a user