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 ===
|
// === НОВЫЕ СВЯЗИ С ПРИЕМКОЙ ПОСТАВОК V2 ===
|
||||||
fulfillmentSupplyOrdersReceived FulfillmentConsumableSupplyOrder[] @relation("FFSupplyOrdersReceiver")
|
fulfillmentSupplyOrdersReceived FulfillmentConsumableSupplyOrder[] @relation("FFSupplyOrdersReceiver")
|
||||||
sellerSupplyOrdersReceived SellerConsumableSupplyOrder[] @relation("SellerSupplyOrdersReceiver")
|
sellerSupplyOrdersReceived SellerConsumableSupplyOrder[] @relation("SellerSupplyOrdersReceiver")
|
||||||
|
sellerGoodsSupplyOrdersReceived SellerGoodsSupplyOrder[] @relation("SellerGoodsSupplyOrdersReceiver")
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@ -144,6 +145,16 @@ model Organization {
|
|||||||
sellerInventoryAsOwner SellerConsumableInventory[] @relation("SellerInventory")
|
sellerInventoryAsOwner SellerConsumableInventory[] @relation("SellerInventory")
|
||||||
sellerInventoryAsWarehouse SellerConsumableInventory[] @relation("SellerInventoryWarehouse")
|
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([referralCode])
|
||||||
@@index([referredById])
|
@@index([referredById])
|
||||||
@@map("organizations")
|
@@map("organizations")
|
||||||
@ -315,6 +326,10 @@ model Product {
|
|||||||
inventoryRecords FulfillmentConsumableInventory[] @relation("InventoryProducts")
|
inventoryRecords FulfillmentConsumableInventory[] @relation("InventoryProducts")
|
||||||
sellerInventoryRecords SellerConsumableInventory[] @relation("SellerInventoryProducts")
|
sellerInventoryRecords SellerConsumableInventory[] @relation("SellerInventoryProducts")
|
||||||
|
|
||||||
|
// === СВЯЗИ С ТОВАРНЫМИ ПОСТАВКАМИ V2 ===
|
||||||
|
goodsSupplyRecipeItems GoodsSupplyRecipeItem[] @relation("GoodsSupplyRecipeItems")
|
||||||
|
sellerGoodsInventoryRecords SellerGoodsInventory[] @relation("SellerGoodsInventoryProduct")
|
||||||
|
|
||||||
@@unique([organizationId, article])
|
@@unique([organizationId, article])
|
||||||
@@map("products")
|
@@map("products")
|
||||||
}
|
}
|
||||||
@ -1043,3 +1058,126 @@ model SellerConsumableInventory {
|
|||||||
@@index([sellerId, lastSupplyDate])
|
@@index([sellerId, lastSupplyDate])
|
||||||
@@map("seller_consumable_inventory")
|
@@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 { AuthGuard } from '@/components/auth-guard'
|
||||||
|
import { CreateSuppliersSupplyPage } from '@/components/supplies/create-suppliers'
|
||||||
|
|
||||||
// TODO: Создать компонент для создания товарных поставок
|
|
||||||
export default function CreateSellerGoodsPage() {
|
export default function CreateSellerGoodsPage() {
|
||||||
return (
|
return (
|
||||||
<AuthGuard>
|
<AuthGuard>
|
||||||
<div className="p-6">
|
<CreateSuppliersSupplyPage />
|
||||||
<h1>Создание поставки товаров</h1>
|
|
||||||
<p>Страница в разработке</p>
|
|
||||||
</div>
|
|
||||||
</AuthGuard>
|
</AuthGuard>
|
||||||
)
|
)
|
||||||
}
|
}
|
@ -399,7 +399,7 @@ export function SuppliesDashboard() {
|
|||||||
<AllSuppliesTab
|
<AllSuppliesTab
|
||||||
pendingSupplyOrders={pendingCount?.supplyOrders || 0}
|
pendingSupplyOrders={pendingCount?.supplyOrders || 0}
|
||||||
goodsSupplies={(mySuppliesData?.mySupplyOrders || []).filter((supply: any) =>
|
goodsSupplies={(mySuppliesData?.mySupplyOrders || []).filter((supply: any) =>
|
||||||
supply.consumableType !== 'SELLER_CONSUMABLES'
|
supply.consumableType !== 'SELLER_CONSUMABLES',
|
||||||
)}
|
)}
|
||||||
loading={mySuppliesLoading}
|
loading={mySuppliesLoading}
|
||||||
/>
|
/>
|
||||||
|
@ -10,8 +10,9 @@ import { MarketplaceService } from '@/services/marketplace-service'
|
|||||||
import { SmsService } from '@/services/sms-service'
|
import { SmsService } from '@/services/sms-service'
|
||||||
import { WildberriesService } from '@/services/wildberries-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 { 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 { logisticsConsumableV2Queries, logisticsConsumableV2Mutations } from './resolvers/logistics-consumables-v2'
|
||||||
import { sellerInventoryV2Queries } from './resolvers/seller-inventory-v2'
|
import { sellerInventoryV2Queries } from './resolvers/seller-inventory-v2'
|
||||||
import { CommercialDataAudit } from './security/commercial-data-audit'
|
import { CommercialDataAudit } from './security/commercial-data-audit'
|
||||||
@ -2913,6 +2914,9 @@ export const resolvers = {
|
|||||||
|
|
||||||
// V2 система складских остатков расходников селлера
|
// V2 система складских остатков расходников селлера
|
||||||
...sellerInventoryV2Queries,
|
...sellerInventoryV2Queries,
|
||||||
|
|
||||||
|
// V2 система товарных поставок селлера
|
||||||
|
...sellerGoodsQueries,
|
||||||
},
|
},
|
||||||
|
|
||||||
Mutation: {
|
Mutation: {
|
||||||
@ -10298,6 +10302,9 @@ resolvers.Mutation = {
|
|||||||
|
|
||||||
// V2 mutations для логистики
|
// V2 mutations для логистики
|
||||||
...logisticsConsumableV2Mutations,
|
...logisticsConsumableV2Mutations,
|
||||||
|
|
||||||
|
// V2 mutations для товарных поставок селлера
|
||||||
|
...sellerGoodsMutations,
|
||||||
}
|
}
|
||||||
|
|
||||||
/* // Резолвер для парсинга JSON рецептуры в SupplyOrderItem
|
/* // Резолвер для парсинга JSON рецептуры в SupplyOrderItem
|
||||||
|
@ -1,848 +1,745 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// 🛒 РЕЗОЛВЕРЫ ДЛЯ СИСТЕМЫ ПОСТАВОК ТОВАРОВ СЕЛЛЕРА V2
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
import { GraphQLError } from 'graphql'
|
import { GraphQLError } from 'graphql'
|
||||||
|
|
||||||
|
import { processSellerGoodsSupplyReceipt } from '@/lib/inventory-management-goods'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { notifyOrganization } from '@/lib/realtime'
|
||||||
|
|
||||||
import { Context } from '../context'
|
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' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export const goodsSupplyV2Resolvers = {
|
try {
|
||||||
Query: {
|
const user = await prisma.user.findUnique({
|
||||||
// Товарные поставки селлера
|
where: { id: context.user.id },
|
||||||
myGoodsSupplyOrdersV2: async (_: unknown, __: unknown, context: Context) => {
|
include: { organization: true },
|
||||||
const { user } = context
|
})
|
||||||
|
|
||||||
if (!user?.organization || user.organization.type !== 'SELLER') {
|
if (!user?.organization || user.organization.type !== 'SELLER') {
|
||||||
throw new GraphQLError('Доступно только для селлеров', {
|
return []
|
||||||
extensions: { code: 'FORBIDDEN' },
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const supplies = await prisma.sellerGoodsSupplyOrder.findMany({
|
||||||
const orders = await prisma.goodsSupplyOrder.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: {
|
where: {
|
||||||
sellerId: user.organizationId!,
|
sellerId: user.organizationId!,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
seller: true,
|
seller: true,
|
||||||
fulfillmentCenter: {
|
fulfillmentCenter: true,
|
||||||
include: {
|
product: true,
|
||||||
phones: true,
|
|
||||||
emails: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
items: {
|
|
||||||
include: {
|
|
||||||
product: {
|
|
||||||
include: {
|
|
||||||
category: true,
|
|
||||||
sizes: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
recipe: {
|
|
||||||
include: {
|
|
||||||
components: {
|
|
||||||
include: {
|
|
||||||
material: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
services: {
|
|
||||||
include: {
|
|
||||||
service: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
requestedServices: {
|
|
||||||
include: {
|
|
||||||
service: true,
|
|
||||||
completedBy: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
logisticsPartner: {
|
|
||||||
include: {
|
|
||||||
phones: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
supplier: {
|
|
||||||
include: {
|
|
||||||
phones: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
receivedBy: true,
|
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
createdAt: 'desc',
|
lastSupplyDate: 'desc',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
} else if (user.organization.type === 'FULFILLMENT') {
|
||||||
return orders
|
// Фулфилмент видит все товары на своем складе
|
||||||
} catch (error) {
|
inventoryItems = await prisma.sellerGoodsInventory.findMany({
|
||||||
throw new GraphQLError('Ошибка получения товарных поставок', {
|
|
||||||
extensions: { code: 'INTERNAL_ERROR', originalError: error },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Входящие товарные поставки (для фулфилмента)
|
|
||||||
incomingGoodsSuppliesV2: async (_: unknown, __: unknown, context: Context) => {
|
|
||||||
const { user } = context
|
|
||||||
|
|
||||||
if (!user?.organization || user.organization.type !== 'FULFILLMENT') {
|
|
||||||
throw new GraphQLError('Доступно только для фулфилмент-центров', {
|
|
||||||
extensions: { code: 'FORBIDDEN' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const orders = await prisma.goodsSupplyOrder.findMany({
|
|
||||||
where: {
|
where: {
|
||||||
fulfillmentCenterId: user.organizationId!,
|
fulfillmentCenterId: user.organizationId!,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
seller: {
|
seller: true,
|
||||||
include: {
|
|
||||||
phones: true,
|
|
||||||
emails: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fulfillmentCenter: true,
|
fulfillmentCenter: true,
|
||||||
items: {
|
product: true,
|
||||||
include: {
|
|
||||||
product: {
|
|
||||||
include: {
|
|
||||||
category: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
recipe: {
|
|
||||||
include: {
|
|
||||||
components: {
|
|
||||||
include: {
|
|
||||||
material: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
unit: true,
|
|
||||||
// НЕ показываем цены селлера
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
services: {
|
|
||||||
include: {
|
|
||||||
service: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
requestedServices: {
|
|
||||||
include: {
|
|
||||||
service: true,
|
|
||||||
completedBy: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
logisticsPartner: {
|
|
||||||
include: {
|
|
||||||
phones: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
supplier: true,
|
|
||||||
receivedBy: true,
|
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
requestedDeliveryDate: 'asc',
|
lastSupplyDate: 'desc',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
// Фильтруем коммерческие данные селлера
|
return []
|
||||||
return orders.map(order => ({
|
|
||||||
...order,
|
|
||||||
items: order.items.map(item => ({
|
|
||||||
...item,
|
|
||||||
price: null, // Скрываем закупочную цену селлера
|
|
||||||
totalPrice: null, // Скрываем общую стоимость
|
|
||||||
})),
|
|
||||||
}))
|
|
||||||
} catch (error) {
|
|
||||||
throw new GraphQLError('Ошибка получения входящих поставок', {
|
|
||||||
extensions: { code: 'INTERNAL_ERROR', originalError: error },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Товарные заказы для поставщиков
|
|
||||||
myGoodsSupplyRequestsV2: async (_: unknown, __: unknown, context: Context) => {
|
|
||||||
const { user } = context
|
|
||||||
|
|
||||||
if (!user?.organization || user.organization.type !== 'WHOLESALE') {
|
|
||||||
throw new GraphQLError('Доступно только для поставщиков', {
|
|
||||||
extensions: { code: 'FORBIDDEN' },
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return inventoryItems
|
||||||
const orders = await prisma.goodsSupplyOrder.findMany({
|
} catch (error) {
|
||||||
where: {
|
console.error('Error fetching seller goods inventory:', error)
|
||||||
supplierId: user.organizationId!,
|
return []
|
||||||
},
|
}
|
||||||
include: {
|
},
|
||||||
seller: {
|
}
|
||||||
include: {
|
|
||||||
phones: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fulfillmentCenter: true,
|
|
||||||
items: {
|
|
||||||
include: {
|
|
||||||
product: {
|
|
||||||
include: {
|
|
||||||
category: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// НЕ включаем requestedServices - поставщик не видит услуги ФФ
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
requestedDeliveryDate: 'asc',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Показываем только релевантную для поставщика информацию
|
// =============================================================================
|
||||||
return orders.map(order => ({
|
// ✏️ MUTATION RESOLVERS V2
|
||||||
...order,
|
// =============================================================================
|
||||||
items: order.items.map(item => ({
|
|
||||||
...item,
|
|
||||||
recipe: null, // Поставщик не видит рецептуры
|
|
||||||
})),
|
|
||||||
}))
|
|
||||||
} catch (error) {
|
|
||||||
throw new GraphQLError('Ошибка получения заказов поставок', {
|
|
||||||
extensions: { code: 'INTERNAL_ERROR', originalError: error },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Детали конкретной поставки
|
export const sellerGoodsMutations = {
|
||||||
goodsSupplyOrderV2: async (_: unknown, args: { id: string }, context: Context) => {
|
// Создание поставки товаров селлера
|
||||||
const { user } = context
|
createSellerGoodsSupply: async (_: unknown, args: { input: any }, context: Context) => {
|
||||||
|
if (!context.user) {
|
||||||
|
throw new GraphQLError('Требуется авторизация', {
|
||||||
|
extensions: { code: 'UNAUTHENTICATED' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (!user?.organizationId) {
|
try {
|
||||||
throw new GraphQLError('Необходима авторизация', {
|
const user = await prisma.user.findUnique({
|
||||||
extensions: { code: 'UNAUTHENTICATED' },
|
where: { id: context.user.id },
|
||||||
})
|
include: { organization: true },
|
||||||
}
|
})
|
||||||
|
|
||||||
try {
|
|
||||||
const order = await prisma.goodsSupplyOrder.findUnique({
|
|
||||||
where: { id: args.id },
|
|
||||||
include: {
|
|
||||||
seller: {
|
|
||||||
include: {
|
|
||||||
phones: true,
|
|
||||||
emails: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fulfillmentCenter: {
|
|
||||||
include: {
|
|
||||||
phones: true,
|
|
||||||
emails: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
items: {
|
|
||||||
include: {
|
|
||||||
product: {
|
|
||||||
include: {
|
|
||||||
category: true,
|
|
||||||
sizes: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
recipe: {
|
|
||||||
include: {
|
|
||||||
components: {
|
|
||||||
include: {
|
|
||||||
material: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
services: {
|
|
||||||
include: {
|
|
||||||
service: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
requestedServices: {
|
|
||||||
include: {
|
|
||||||
service: true,
|
|
||||||
completedBy: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
logisticsPartner: {
|
|
||||||
include: {
|
|
||||||
phones: true,
|
|
||||||
emails: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
supplier: {
|
|
||||||
include: {
|
|
||||||
phones: true,
|
|
||||||
emails: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
receivedBy: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!order) {
|
|
||||||
throw new GraphQLError('Поставка не найдена', {
|
|
||||||
extensions: { code: 'NOT_FOUND' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверка прав доступа
|
|
||||||
const hasAccess =
|
|
||||||
order.sellerId === user.organizationId ||
|
|
||||||
order.fulfillmentCenterId === user.organizationId ||
|
|
||||||
order.supplierId === user.organizationId ||
|
|
||||||
order.logisticsPartnerId === user.organizationId
|
|
||||||
|
|
||||||
if (!hasAccess) {
|
|
||||||
throw new GraphQLError('Доступ запрещен', {
|
|
||||||
extensions: { code: 'FORBIDDEN' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Фильтрация данных в зависимости от роли
|
|
||||||
if (user.organization?.type === 'WHOLESALE') {
|
|
||||||
// Поставщик не видит рецептуры и услуги ФФ
|
|
||||||
return {
|
|
||||||
...order,
|
|
||||||
items: order.items.map(item => ({
|
|
||||||
...item,
|
|
||||||
recipe: null,
|
|
||||||
})),
|
|
||||||
requestedServices: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.organization?.type === 'FULFILLMENT') {
|
|
||||||
// ФФ не видит закупочные цены селлера
|
|
||||||
return {
|
|
||||||
...order,
|
|
||||||
items: order.items.map(item => ({
|
|
||||||
...item,
|
|
||||||
price: null,
|
|
||||||
totalPrice: null,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.organization?.type === 'LOGIST') {
|
|
||||||
// Логистика видит только логистическую информацию
|
|
||||||
return {
|
|
||||||
...order,
|
|
||||||
items: order.items.map(item => ({
|
|
||||||
...item,
|
|
||||||
price: null,
|
|
||||||
totalPrice: null,
|
|
||||||
recipe: null,
|
|
||||||
})),
|
|
||||||
requestedServices: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Селлер видит все свои данные
|
|
||||||
return order
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof GraphQLError) {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
throw new GraphQLError('Ошибка получения поставки', {
|
|
||||||
extensions: { code: 'INTERNAL_ERROR', originalError: error },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Рецептуры товаров селлера
|
|
||||||
myProductRecipes: async (_: unknown, __: unknown, context: Context) => {
|
|
||||||
const { user } = context
|
|
||||||
|
|
||||||
if (!user?.organization || user.organization.type !== 'SELLER') {
|
if (!user?.organization || user.organization.type !== 'SELLER') {
|
||||||
throw new GraphQLError('Доступно только для селлеров', {
|
throw new GraphQLError('Доступно только для селлеров')
|
||||||
extensions: { code: 'FORBIDDEN' },
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const { fulfillmentCenterId, supplierId, logisticsPartnerId, requestedDeliveryDate, notes, recipeItems } = args.input
|
||||||
const recipes = await prisma.productRecipe.findMany({
|
|
||||||
where: {
|
// 🔍 ВАЛИДАЦИЯ ПАРТНЕРОВ
|
||||||
product: {
|
|
||||||
organizationId: user.organizationId!,
|
// Проверяем фулфилмент-центр
|
||||||
},
|
const fulfillmentCenter = await prisma.organization.findUnique({
|
||||||
|
where: { id: fulfillmentCenterId },
|
||||||
|
include: {
|
||||||
|
counterpartiesAsCounterparty: {
|
||||||
|
where: { organizationId: user.organizationId! },
|
||||||
},
|
},
|
||||||
include: {
|
},
|
||||||
product: {
|
})
|
||||||
include: {
|
|
||||||
category: true,
|
if (!fulfillmentCenter || fulfillmentCenter.type !== 'FULFILLMENT') {
|
||||||
},
|
throw new GraphQLError('Фулфилмент-центр не найден или имеет неверный тип')
|
||||||
},
|
}
|
||||||
components: {
|
|
||||||
include: {
|
if (fulfillmentCenter.counterpartiesAsCounterparty.length === 0) {
|
||||||
material: true,
|
throw new GraphQLError('Нет партнерских отношений с данным фулфилмент-центром')
|
||||||
},
|
}
|
||||||
},
|
|
||||||
services: {
|
// Проверяем поставщика
|
||||||
include: {
|
const supplier = await prisma.organization.findUnique({
|
||||||
service: true,
|
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) {
|
for (const item of recipeItems) {
|
||||||
throw new GraphQLError('Ошибка получения рецептур', {
|
await tx.goodsSupplyRecipeItem.create({
|
||||||
extensions: { code: 'INTERNAL_ERROR', originalError: error },
|
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: {
|
// Обновление статуса товарной поставки
|
||||||
// Создание товарной поставки
|
updateSellerGoodsSupplyStatus: async (
|
||||||
createGoodsSupplyOrder: async (_: unknown, args: any, context: Context) => {
|
_: unknown,
|
||||||
const { user } = context
|
args: { id: string; status: string; notes?: string },
|
||||||
const { input } = args
|
context: Context,
|
||||||
|
) => {
|
||||||
|
if (!context.user) {
|
||||||
|
throw new GraphQLError('Требуется авторизация')
|
||||||
|
}
|
||||||
|
|
||||||
if (!user?.organization || user.organization.type !== 'SELLER') {
|
try {
|
||||||
throw new GraphQLError('Доступно только для селлеров', {
|
const user = await prisma.user.findUnique({
|
||||||
extensions: { code: 'FORBIDDEN' },
|
where: { id: context.user.id },
|
||||||
})
|
include: { organization: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!user?.organization) {
|
||||||
|
throw new GraphQLError('Организация не найдена')
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const supply = await prisma.sellerGoodsSupplyOrder.findUnique({
|
||||||
// Проверяем фулфилмент-центр
|
where: { id: args.id },
|
||||||
const fulfillmentCenter = await prisma.organization.findFirst({
|
include: {
|
||||||
where: {
|
seller: true,
|
||||||
id: input.fulfillmentCenterId,
|
supplier: true,
|
||||||
type: 'FULFILLMENT',
|
fulfillmentCenter: true,
|
||||||
|
recipeItems: {
|
||||||
|
include: {
|
||||||
|
product: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
},
|
||||||
|
})
|
||||||
|
|
||||||
if (!fulfillmentCenter) {
|
if (!supply) {
|
||||||
throw new GraphQLError('Фулфилмент-центр не найден', {
|
throw new GraphQLError('Поставка не найдена')
|
||||||
extensions: { code: 'NOT_FOUND' },
|
}
|
||||||
})
|
|
||||||
|
// 🔐 ПРОВЕРКА ПРАВ И ЛОГИКИ ПЕРЕХОДОВ СТАТУСОВ
|
||||||
|
const { status } = args
|
||||||
|
const currentStatus = supply.status
|
||||||
|
const orgType = user.organization.type
|
||||||
|
|
||||||
|
// Только поставщики могут переводить PENDING → APPROVED
|
||||||
|
if (status === 'APPROVED' && currentStatus === 'PENDING') {
|
||||||
|
if (orgType !== 'WHOLESALE' || supply.supplierId !== user.organizationId) {
|
||||||
|
throw new GraphQLError('Только поставщик может одобрить заказ')
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Проверяем товары и рецептуры
|
// Только поставщики могут переводить APPROVED → SHIPPED
|
||||||
for (const item of input.items) {
|
else if (status === 'SHIPPED' && currentStatus === 'APPROVED') {
|
||||||
const product = await prisma.product.findFirst({
|
if (orgType !== 'WHOLESALE' || supply.supplierId !== user.organizationId) {
|
||||||
where: {
|
throw new GraphQLError('Только поставщик может отметить отгрузку')
|
||||||
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' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Создаем поставку в транзакции
|
// Только фулфилмент может переводить SHIPPED → DELIVERED
|
||||||
const order = await prisma.$transaction(async (tx) => {
|
else if (status === 'DELIVERED' && currentStatus === 'SHIPPED') {
|
||||||
// Создаем основную запись
|
if (orgType !== 'FULFILLMENT' || supply.fulfillmentCenterId !== user.organizationId) {
|
||||||
const newOrder = await tx.goodsSupplyOrder.create({
|
throw new GraphQLError('Только фулфилмент-центр может подтвердить получение')
|
||||||
data: {
|
}
|
||||||
sellerId: user.organizationId!,
|
}
|
||||||
fulfillmentCenterId: input.fulfillmentCenterId,
|
|
||||||
requestedDeliveryDate: new Date(input.requestedDeliveryDate),
|
// Только фулфилмент может переводить DELIVERED → COMPLETED
|
||||||
notes: input.notes,
|
else if (status === 'COMPLETED' && currentStatus === 'DELIVERED') {
|
||||||
status: 'PENDING',
|
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
|
if (status === 'APPROVED') {
|
||||||
let totalItems = 0
|
await notifyOrganization(
|
||||||
|
supply.sellerId,
|
||||||
|
`Поставка товаров одобрена поставщиком ${user.organization.name}`,
|
||||||
|
'GOODS_SUPPLY_APPROVED',
|
||||||
|
{ orderId: args.id },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
for (const itemInput of input.items) {
|
if (status === 'SHIPPED') {
|
||||||
const itemTotal = itemInput.price * itemInput.quantity
|
await notifyOrganization(
|
||||||
totalAmount += itemTotal
|
supply.sellerId,
|
||||||
totalItems += itemInput.quantity
|
`Поставка товаров отгружена поставщиком ${user.organization.name}`,
|
||||||
|
'GOODS_SUPPLY_SHIPPED',
|
||||||
|
{ orderId: args.id },
|
||||||
|
)
|
||||||
|
|
||||||
await tx.goodsSupplyOrderItem.create({
|
await notifyOrganization(
|
||||||
data: {
|
supply.fulfillmentCenterId,
|
||||||
orderId: newOrder.id,
|
'Поставка товаров в пути. Ожидается доставка',
|
||||||
productId: itemInput.productId,
|
'GOODS_SUPPLY_IN_TRANSIT',
|
||||||
quantity: itemInput.quantity,
|
{ orderId: args.id },
|
||||||
price: itemInput.price,
|
)
|
||||||
totalPrice: itemTotal,
|
}
|
||||||
recipeId: itemInput.recipeId,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Создаем запросы услуг
|
if (status === 'DELIVERED') {
|
||||||
for (const serviceInput of input.requestedServices) {
|
// 📦 АВТОМАТИЧЕСКОЕ СОЗДАНИЕ/ОБНОВЛЕНИЕ ИНВЕНТАРЯ V2
|
||||||
const service = await tx.service.findUnique({
|
await processSellerGoodsSupplyReceipt(args.id)
|
||||||
where: { id: serviceInput.serviceId },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!service) {
|
await notifyOrganization(
|
||||||
throw new Error(`Услуга ${serviceInput.serviceId} не найдена`)
|
supply.sellerId,
|
||||||
}
|
`Поставка товаров доставлена в ${supply.fulfillmentCenter.name}`,
|
||||||
|
'GOODS_SUPPLY_DELIVERED',
|
||||||
|
{ orderId: args.id },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const serviceTotal = service.price * serviceInput.quantity
|
if (status === 'COMPLETED') {
|
||||||
totalAmount += serviceTotal
|
await notifyOrganization(
|
||||||
|
supply.sellerId,
|
||||||
|
`Поставка товаров завершена. Товары размещены на складе ${supply.fulfillmentCenter.name}`,
|
||||||
|
'GOODS_SUPPLY_COMPLETED',
|
||||||
|
{ orderId: args.id },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
await tx.fulfillmentServiceRequest.create({
|
return updatedSupply
|
||||||
data: {
|
} catch (error) {
|
||||||
orderId: newOrder.id,
|
console.error('Error updating seller goods supply status:', error)
|
||||||
serviceId: serviceInput.serviceId,
|
|
||||||
quantity: serviceInput.quantity,
|
|
||||||
price: service.price,
|
|
||||||
totalPrice: serviceTotal,
|
|
||||||
status: 'PENDING',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обновляем итоги
|
if (error instanceof GraphQLError) {
|
||||||
await tx.goodsSupplyOrder.update({
|
throw error
|
||||||
where: { id: newOrder.id },
|
}
|
||||||
data: {
|
|
||||||
totalAmount,
|
throw new GraphQLError('Ошибка обновления статуса товарной поставки')
|
||||||
totalItems,
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Отмена товарной поставки селлером
|
||||||
|
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('Поставка не найдена')
|
||||||
|
}
|
||||||
|
|
||||||
// Получаем созданную поставку с полными данными
|
if (supply.sellerId !== user.organizationId) {
|
||||||
const createdOrder = await prisma.goodsSupplyOrder.findUnique({
|
throw new GraphQLError('Вы можете отменить только свои поставки')
|
||||||
where: { id: order.id },
|
}
|
||||||
|
|
||||||
|
// ✅ ПРОВЕРКА ВОЗМОЖНОСТИ ОТМЕНЫ (только 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: {
|
include: {
|
||||||
seller: true,
|
seller: true,
|
||||||
fulfillmentCenter: true,
|
fulfillmentCenter: true,
|
||||||
items: {
|
supplier: true,
|
||||||
|
recipeItems: {
|
||||||
include: {
|
include: {
|
||||||
product: true,
|
product: true,
|
||||||
recipe: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
requestedServices: {
|
|
||||||
include: {
|
|
||||||
service: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
// Освобождаем зарезервированные товары у поставщика (только MAIN_PRODUCT)
|
||||||
success: true,
|
for (const item of supply.recipeItems) {
|
||||||
message: 'Товарная поставка успешно создана',
|
if (item.recipeType === 'MAIN_PRODUCT') {
|
||||||
order: createdOrder,
|
await tx.product.update({
|
||||||
}
|
where: { id: item.productId },
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof GraphQLError) {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
throw new GraphQLError('Ошибка создания поставки', {
|
|
||||||
extensions: { code: 'INTERNAL_ERROR', originalError: error },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Обновление статуса товарной поставки
|
|
||||||
updateGoodsSupplyOrderStatus: async (_: unknown, args: any, context: Context) => {
|
|
||||||
const { user } = context
|
|
||||||
const { id, status, notes } = args
|
|
||||||
|
|
||||||
if (!user?.organizationId) {
|
|
||||||
throw new GraphQLError('Необходима авторизация', {
|
|
||||||
extensions: { code: 'UNAUTHENTICATED' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const order = await prisma.goodsSupplyOrder.findUnique({
|
|
||||||
where: { id },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!order) {
|
|
||||||
throw new GraphQLError('Поставка не найдена', {
|
|
||||||
extensions: { code: 'NOT_FOUND' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверка прав на изменение статуса
|
|
||||||
const canUpdate =
|
|
||||||
(status === 'SUPPLIER_APPROVED' && order.supplierId === user.organizationId) ||
|
|
||||||
(status === 'LOGISTICS_CONFIRMED' && user.organization?.type === 'FULFILLMENT') ||
|
|
||||||
(status === 'SHIPPED' && order.supplierId === user.organizationId) ||
|
|
||||||
(status === 'IN_TRANSIT' && order.logisticsPartnerId === user.organizationId) ||
|
|
||||||
(status === 'RECEIVED' && order.fulfillmentCenterId === user.organizationId) ||
|
|
||||||
(status === 'CANCELLED' &&
|
|
||||||
(order.sellerId === user.organizationId || order.fulfillmentCenterId === user.organizationId))
|
|
||||||
|
|
||||||
if (!canUpdate) {
|
|
||||||
throw new GraphQLError('Недостаточно прав для изменения статуса', {
|
|
||||||
extensions: { code: 'FORBIDDEN' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateData: any = {
|
|
||||||
status,
|
|
||||||
notes: notes || order.notes,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Устанавливаем временные метки
|
|
||||||
if (status === 'SUPPLIER_APPROVED') {
|
|
||||||
updateData.supplierApprovedAt = new Date()
|
|
||||||
} else if (status === 'SHIPPED') {
|
|
||||||
updateData.shippedAt = new Date()
|
|
||||||
} else if (status === 'RECEIVED') {
|
|
||||||
updateData.receivedAt = new Date()
|
|
||||||
updateData.receivedById = user.id
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedOrder = await prisma.goodsSupplyOrder.update({
|
|
||||||
where: { id },
|
|
||||||
data: updateData,
|
|
||||||
include: {
|
|
||||||
receivedBy: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return updatedOrder
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof GraphQLError) {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
throw new GraphQLError('Ошибка обновления статуса', {
|
|
||||||
extensions: { code: 'INTERNAL_ERROR', originalError: error },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Приемка товарной поставки
|
|
||||||
receiveGoodsSupplyOrder: async (_: unknown, args: any, context: Context) => {
|
|
||||||
const { user } = context
|
|
||||||
const { id, items } = args
|
|
||||||
|
|
||||||
if (!user?.organization || user.organization.type !== 'FULFILLMENT') {
|
|
||||||
throw new GraphQLError('Доступно только для фулфилмент-центров', {
|
|
||||||
extensions: { code: 'FORBIDDEN' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const order = await prisma.goodsSupplyOrder.findUnique({
|
|
||||||
where: { id },
|
|
||||||
include: {
|
|
||||||
items: {
|
|
||||||
include: {
|
|
||||||
recipe: {
|
|
||||||
include: {
|
|
||||||
components: {
|
|
||||||
include: {
|
|
||||||
material: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!order) {
|
|
||||||
throw new GraphQLError('Поставка не найдена', {
|
|
||||||
extensions: { code: 'NOT_FOUND' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (order.fulfillmentCenterId !== user.organizationId) {
|
|
||||||
throw new GraphQLError('Доступ запрещен', {
|
|
||||||
extensions: { code: 'FORBIDDEN' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (order.status !== 'IN_TRANSIT') {
|
|
||||||
throw new GraphQLError('Поставка должна быть в статусе "В пути"', {
|
|
||||||
extensions: { code: 'BAD_REQUEST' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обрабатываем приемку в транзакции
|
|
||||||
const updatedOrder = await prisma.$transaction(async (tx) => {
|
|
||||||
// Обновляем данные приемки для каждого товара
|
|
||||||
for (const itemInput of items) {
|
|
||||||
const orderItem = order.items.find(item => item.id === itemInput.itemId)
|
|
||||||
if (!orderItem) {
|
|
||||||
throw new Error(`Товар ${itemInput.itemId} не найден в поставке`)
|
|
||||||
}
|
|
||||||
|
|
||||||
await tx.goodsSupplyOrderItem.update({
|
|
||||||
where: { id: itemInput.itemId },
|
|
||||||
data: {
|
data: {
|
||||||
receivedQuantity: itemInput.receivedQuantity,
|
ordered: {
|
||||||
damagedQuantity: itemInput.damagedQuantity || 0,
|
decrement: item.quantity,
|
||||||
acceptanceNotes: itemInput.acceptanceNotes,
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Обновляем остатки расходников по рецептуре
|
|
||||||
if (orderItem.recipe && itemInput.receivedQuantity > 0) {
|
|
||||||
for (const component of orderItem.recipe.components) {
|
|
||||||
const usedQuantity = component.quantity * itemInput.receivedQuantity
|
|
||||||
|
|
||||||
await tx.supply.update({
|
|
||||||
where: { id: component.materialId },
|
|
||||||
data: {
|
|
||||||
currentStock: {
|
|
||||||
decrement: usedQuantity,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обновляем статус поставки
|
|
||||||
const updated = await tx.goodsSupplyOrder.update({
|
|
||||||
where: { id },
|
|
||||||
data: {
|
|
||||||
status: 'RECEIVED',
|
|
||||||
receivedAt: new Date(),
|
|
||||||
receivedById: user.id,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
items: {
|
|
||||||
include: {
|
|
||||||
product: true,
|
|
||||||
recipe: {
|
|
||||||
include: {
|
|
||||||
components: {
|
|
||||||
include: {
|
|
||||||
material: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
requestedServices: {
|
|
||||||
include: {
|
|
||||||
service: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
receivedBy: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return updated
|
|
||||||
})
|
|
||||||
|
|
||||||
return updatedOrder
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof GraphQLError) {
|
|
||||||
throw error
|
|
||||||
}
|
}
|
||||||
throw new GraphQLError('Ошибка приемки поставки', {
|
|
||||||
extensions: { code: 'INTERNAL_ERROR', originalError: error },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Отмена товарной поставки
|
return updated
|
||||||
cancelGoodsSupplyOrder: async (_: unknown, args: any, context: Context) => {
|
})
|
||||||
const { user } = context
|
|
||||||
const { id, reason } = args
|
|
||||||
|
|
||||||
if (!user?.organizationId) {
|
// 📨 УВЕДОМЛЕНИЯ ОБ ОТМЕНЕ
|
||||||
throw new GraphQLError('Необходима авторизация', {
|
if (supply.supplierId) {
|
||||||
extensions: { code: 'UNAUTHENTICATED' },
|
await notifyOrganization(
|
||||||
})
|
supply.supplierId,
|
||||||
|
`Селлер ${user.organization.name} отменил заказ товаров`,
|
||||||
|
'GOODS_SUPPLY_CANCELLED',
|
||||||
|
{ orderId: args.id },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
await notifyOrganization(
|
||||||
const order = await prisma.goodsSupplyOrder.findUnique({
|
supply.fulfillmentCenterId,
|
||||||
where: { id },
|
`Селлер ${user.organization.name} отменил поставку товаров`,
|
||||||
})
|
'GOODS_SUPPLY_CANCELLED',
|
||||||
|
{ orderId: args.id },
|
||||||
|
)
|
||||||
|
|
||||||
if (!order) {
|
return cancelledSupply
|
||||||
throw new GraphQLError('Поставка не найдена', {
|
} catch (error) {
|
||||||
extensions: { code: 'NOT_FOUND' },
|
console.error('Error cancelling seller goods supply:', error)
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверка прав на отмену
|
if (error instanceof GraphQLError) {
|
||||||
const canCancel =
|
throw error
|
||||||
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 },
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
|
throw new GraphQLError('Ошибка отмены товарной поставки')
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
@ -4,11 +4,11 @@
|
|||||||
|
|
||||||
import { GraphQLError } from 'graphql'
|
import { GraphQLError } from 'graphql'
|
||||||
|
|
||||||
|
import { processSellerConsumableSupplyReceipt } from '@/lib/inventory-management'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { notifyOrganization } from '@/lib/realtime'
|
import { notifyOrganization } from '@/lib/realtime'
|
||||||
|
|
||||||
import { Context } from '../context'
|
import { Context } from '../context'
|
||||||
import { processSellerConsumableSupplyReceipt } from '@/lib/inventory-management'
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// 🔍 QUERY RESOLVERS
|
// 🔍 QUERY RESOLVERS
|
||||||
|
@ -1932,6 +1932,136 @@ export const typeDefs = gql`
|
|||||||
supplyOrder: SellerConsumableSupplyOrder
|
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 для селлерских поставок
|
# Расширяем Query для селлерских поставок
|
||||||
extend type Query {
|
extend type Query {
|
||||||
# Поставки селлера (мои заказы)
|
# Поставки селлера (мои заказы)
|
||||||
@ -1945,6 +2075,22 @@ export const typeDefs = gql`
|
|||||||
|
|
||||||
# Конкретная поставка селлера
|
# Конкретная поставка селлера
|
||||||
sellerConsumableSupply(id: ID!): SellerConsumableSupplyOrder
|
sellerConsumableSupply(id: ID!): SellerConsumableSupplyOrder
|
||||||
|
|
||||||
|
# === V2 ТОВАРНЫЕ ПОСТАВКИ СЕЛЛЕРА ===
|
||||||
|
# Поставки товаров селлера (мои заказы)
|
||||||
|
mySellerGoodsSupplies: [SellerGoodsSupplyOrder!]!
|
||||||
|
|
||||||
|
# Входящие товарные заказы от селлеров (для фулфилмента)
|
||||||
|
incomingSellerGoodsSupplies: [SellerGoodsSupplyOrder!]!
|
||||||
|
|
||||||
|
# Товарные поставки селлеров для поставщиков
|
||||||
|
mySellerGoodsSupplyRequests: [SellerGoodsSupplyOrder!]!
|
||||||
|
|
||||||
|
# Конкретная товарная поставка селлера
|
||||||
|
sellerGoodsSupply(id: ID!): SellerGoodsSupplyOrder
|
||||||
|
|
||||||
|
# Инвентарь товаров селлера (для фулфилмента и селлера)
|
||||||
|
mySellerGoodsInventory: [SellerGoodsInventory!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
# Расширяем Mutation для селлерских поставок
|
# Расширяем Mutation для селлерских поставок
|
||||||
@ -1963,6 +2109,16 @@ export const typeDefs = gql`
|
|||||||
|
|
||||||
# Отмена поставки селлером (только PENDING/APPROVED)
|
# Отмена поставки селлером (только PENDING/APPROVED)
|
||||||
cancelSellerSupply(id: ID!): SellerConsumableSupplyOrder!
|
cancelSellerSupply(id: ID!): SellerConsumableSupplyOrder!
|
||||||
|
|
||||||
|
# === V2 ТОВАРНЫЕ ПОСТАВКИ МУТАЦИИ ===
|
||||||
|
# Создание поставки товаров селлера
|
||||||
|
createSellerGoodsSupply(input: CreateSellerGoodsSupplyInput!): CreateSellerGoodsSupplyResult!
|
||||||
|
|
||||||
|
# Обновление статуса товарной поставки
|
||||||
|
updateSellerGoodsSupplyStatus(id: ID!, status: SellerSupplyOrderStatus!, notes: String): SellerGoodsSupplyOrder!
|
||||||
|
|
||||||
|
# Отмена товарной поставки селлером
|
||||||
|
cancelSellerGoodsSupply(id: ID!): SellerGoodsSupplyOrder!
|
||||||
}
|
}
|
||||||
|
|
||||||
# === V2 ЛОГИСТИКА РАСХОДНИКОВ ФУЛФИЛМЕНТА ===
|
# === 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