diff --git a/prisma/schema.backup.20250901_020359.prisma b/prisma/schema.backup.20250901_020359.prisma new file mode 100644 index 0000000..0c8d1a3 --- /dev/null +++ b/prisma/schema.backup.20250901_020359.prisma @@ -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") +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0c8d1a3..80271c6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -27,6 +27,7 @@ model User { // === НОВЫЕ СВЯЗИ С ПРИЕМКОЙ ПОСТАВОК V2 === fulfillmentSupplyOrdersReceived FulfillmentConsumableSupplyOrder[] @relation("FFSupplyOrdersReceiver") sellerSupplyOrdersReceived SellerConsumableSupplyOrder[] @relation("SellerSupplyOrdersReceiver") + sellerGoodsSupplyOrdersReceived SellerGoodsSupplyOrder[] @relation("SellerGoodsSupplyOrdersReceiver") @@map("users") } @@ -143,6 +144,16 @@ model Organization { // === СВЯЗИ С ИНВЕНТАРЕМ РАСХОДНИКОВ СЕЛЛЕРА V2 === sellerInventoryAsOwner SellerConsumableInventory[] @relation("SellerInventory") sellerInventoryAsWarehouse SellerConsumableInventory[] @relation("SellerInventoryWarehouse") + + // === СВЯЗИ С ТОВАРНЫМИ ПОСТАВКАМИ СЕЛЛЕРА V2 === + sellerGoodsSupplyOrdersAsSeller SellerGoodsSupplyOrder[] @relation("SellerGoodsSupplyOrdersSeller") + sellerGoodsSupplyOrdersAsFulfillment SellerGoodsSupplyOrder[] @relation("SellerGoodsSupplyOrdersFulfillment") + sellerGoodsSupplyOrdersAsSupplier SellerGoodsSupplyOrder[] @relation("SellerGoodsSupplyOrdersSupplier") + sellerGoodsSupplyOrdersAsLogistics SellerGoodsSupplyOrder[] @relation("SellerGoodsSupplyOrdersLogistics") + + // === СВЯЗИ С ИНВЕНТАРЕМ ТОВАРОВ СЕЛЛЕРА V2 === + sellerGoodsInventoryAsOwner SellerGoodsInventory[] @relation("SellerGoodsInventoryOwner") + sellerGoodsInventoryAsWarehouse SellerGoodsInventory[] @relation("SellerGoodsInventoryWarehouse") @@index([referralCode]) @@index([referredById]) @@ -314,6 +325,10 @@ model Product { // === НОВЫЕ СВЯЗИ СО СКЛАДСКИМИ ОСТАТКАМИ V2 === inventoryRecords FulfillmentConsumableInventory[] @relation("InventoryProducts") sellerInventoryRecords SellerConsumableInventory[] @relation("SellerInventoryProducts") + + // === СВЯЗИ С ТОВАРНЫМИ ПОСТАВКАМИ V2 === + goodsSupplyRecipeItems GoodsSupplyRecipeItem[] @relation("GoodsSupplyRecipeItems") + sellerGoodsInventoryRecords SellerGoodsInventory[] @relation("SellerGoodsInventoryProduct") @@unique([organizationId, article]) @@map("products") @@ -1043,3 +1058,126 @@ model SellerConsumableInventory { @@index([sellerId, lastSupplyDate]) @@map("seller_consumable_inventory") } + +// =============================================== +// 🛒 SELLER GOODS SUPPLY SYSTEM V2.0 - ТОВАРНЫЕ ПОСТАВКИ +// =============================================== + +// Модель для поставок товаров селлера (V2) +model SellerGoodsSupplyOrder { + // === БАЗОВЫЕ ПОЛЯ === + id String @id @default(cuid()) + status SellerSupplyOrderStatus @default(PENDING) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // === ДАННЫЕ СЕЛЛЕРА (создатель) === + sellerId String // кто заказывает (FK: Organization SELLER) + fulfillmentCenterId String // куда доставлять (FK: Organization FULFILLMENT) + requestedDeliveryDate DateTime // когда нужно + notes String? // заметки селлера + + // === ДАННЫЕ ПОСТАВЩИКА === + supplierId String? // кто поставляет (FK: Organization WHOLESALE) + supplierApprovedAt DateTime? // когда одобрил + packagesCount Int? // количество грузомест + estimatedVolume Decimal? @db.Decimal(8, 3) // объем груза в м³ + supplierContractId String? // номер договора + supplierNotes String? // заметки поставщика + + // === ДАННЫЕ ЛОГИСТИКИ === + logisticsPartnerId String? // кто везет (FK: Organization LOGIST) + estimatedDeliveryDate DateTime? // план доставки + routeId String? // маршрут (FK: LogisticsRoute) + logisticsCost Decimal? @db.Decimal(10, 2) // стоимость доставки + logisticsNotes String? // заметки логистики + + // === ДАННЫЕ ОТГРУЗКИ === + shippedAt DateTime? // факт отгрузки + trackingNumber String? // номер отслеживания + + // === ДАННЫЕ ПОЛУЧЕНИЯ === + deliveredAt DateTime? // факт доставки + receivedById String? // кто принял (FK: User) + receiptNotes String? // заметки при получении + + // === ФИНАНСЫ === + totalCostWithDelivery Decimal @default(0) @db.Decimal(12, 2) // общая стоимость с доставкой + actualDeliveryCost Decimal @default(0) @db.Decimal(10, 2) // фактическая стоимость доставки + + // === СВЯЗИ === + seller Organization @relation("SellerGoodsSupplyOrdersSeller", fields: [sellerId], references: [id]) + fulfillmentCenter Organization @relation("SellerGoodsSupplyOrdersFulfillment", fields: [fulfillmentCenterId], references: [id]) + supplier Organization? @relation("SellerGoodsSupplyOrdersSupplier", fields: [supplierId], references: [id]) + logisticsPartner Organization? @relation("SellerGoodsSupplyOrdersLogistics", fields: [logisticsPartnerId], references: [id]) + receivedBy User? @relation("SellerGoodsSupplyOrdersReceiver", fields: [receivedById], references: [id]) + recipeItems GoodsSupplyRecipeItem[] // нормализованная рецептура товаров + + @@map("seller_goods_supply_orders") +} + +// Нормализованная рецептура для товарных поставок +model GoodsSupplyRecipeItem { + id String @id @default(cuid()) + supplyOrderId String // связь с поставкой (FK: SellerGoodsSupplyOrder) + productId String // какой товар (FK: Product) + quantity Int // количество в рецептуре + recipeType RecipeType // тип компонента в рецептуре + createdAt DateTime @default(now()) + + // === СВЯЗИ === + supplyOrder SellerGoodsSupplyOrder @relation(fields: [supplyOrderId], references: [id], onDelete: Cascade) + product Product @relation("GoodsSupplyRecipeItems", fields: [productId], references: [id]) + + @@unique([supplyOrderId, productId]) // один товар = одна запись в рецептуре + @@map("goods_supply_recipe_items") +} + +// Enum для типов компонентов в рецептуре +enum RecipeType { + MAIN_PRODUCT // Основной товар + COMPONENT // Компонент товара + PACKAGING // Упаковка + ACCESSORY // Аксессуар +} + +// Инвентарь товаров селлера на складе фулфилмента (V2) +model SellerGoodsInventory { + // === ИДЕНТИФИКАЦИЯ === + id String @id @default(cuid()) + + // === СВЯЗИ === + sellerId String // кому принадлежат товары (FK: Organization SELLER) + fulfillmentCenterId String // где хранятся (FK: Organization FULFILLMENT) + productId String // что хранится (FK: Product) + + // === СКЛАДСКИЕ ДАННЫЕ === + currentStock Int @default(0) // текущий остаток на складе фулфилмента + reservedStock Int @default(0) // зарезервировано для отгрузок + inPreparationStock Int @default(0) // в подготовке к отгрузке + totalReceived Int @default(0) // всего получено с момента создания + totalShipped Int @default(0) // всего отгружено + + // === ПОРОГИ ДЛЯ АВТОЗАКАЗА === + minStock Int @default(0) // минимальный порог для автозаказа + maxStock Int? // максимальный порог (опционально) + + // === ЦЕНЫ === + averageCost Decimal @default(0) @db.Decimal(10, 2) // средняя себестоимость покупки + salePrice Decimal @default(0) @db.Decimal(10, 2) // цена продажи + + // === МЕТАДАННЫЕ === + lastSupplyDate DateTime? // последняя поставка + lastShipDate DateTime? // последняя отгрузка + notes String? // заметки по складскому учету + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // === СВЯЗИ === + seller Organization @relation("SellerGoodsInventoryOwner", fields: [sellerId], references: [id]) + fulfillmentCenter Organization @relation("SellerGoodsInventoryWarehouse", fields: [fulfillmentCenterId], references: [id]) + product Product @relation("SellerGoodsInventoryProduct", fields: [productId], references: [id]) + + @@unique([sellerId, fulfillmentCenterId, productId]) // уникальность: селлер + фф + товар + @@map("seller_goods_inventory") +} diff --git a/src/app/seller/create/goods/page.tsx b/src/app/seller/create/goods/page.tsx index 335535d..18642b8 100644 --- a/src/app/seller/create/goods/page.tsx +++ b/src/app/seller/create/goods/page.tsx @@ -1,13 +1,10 @@ import { AuthGuard } from '@/components/auth-guard' +import { CreateSuppliersSupplyPage } from '@/components/supplies/create-suppliers' -// TODO: Создать компонент для создания товарных поставок export default function CreateSellerGoodsPage() { return ( -
-

Создание поставки товаров

-

Страница в разработке

-
+
) } \ No newline at end of file diff --git a/src/components/supplies/supplies-dashboard.tsx b/src/components/supplies/supplies-dashboard.tsx index e2cff93..a484602 100644 --- a/src/components/supplies/supplies-dashboard.tsx +++ b/src/components/supplies/supplies-dashboard.tsx @@ -399,7 +399,7 @@ export function SuppliesDashboard() { - supply.consumableType !== 'SELLER_CONSUMABLES' + supply.consumableType !== 'SELLER_CONSUMABLES', )} loading={mySuppliesLoading} /> diff --git a/src/graphql/resolvers.ts b/src/graphql/resolvers.ts index cf4dbfb..04330e2 100644 --- a/src/graphql/resolvers.ts +++ b/src/graphql/resolvers.ts @@ -10,8 +10,9 @@ import { MarketplaceService } from '@/services/marketplace-service' import { SmsService } from '@/services/sms-service' import { WildberriesService } from '@/services/wildberries-service' -import { fulfillmentInventoryV2Queries } from './resolvers/fulfillment-inventory-v2' import { fulfillmentConsumableV2Queries as fulfillmentConsumableV2QueriesRestored, fulfillmentConsumableV2Mutations as fulfillmentConsumableV2MutationsRestored } from './resolvers/fulfillment-consumables-v2-restored' +import { fulfillmentInventoryV2Queries } from './resolvers/fulfillment-inventory-v2' +import { sellerGoodsQueries, sellerGoodsMutations } from './resolvers/goods-supply-v2' import { logisticsConsumableV2Queries, logisticsConsumableV2Mutations } from './resolvers/logistics-consumables-v2' import { sellerInventoryV2Queries } from './resolvers/seller-inventory-v2' import { CommercialDataAudit } from './security/commercial-data-audit' @@ -2913,6 +2914,9 @@ export const resolvers = { // V2 система складских остатков расходников селлера ...sellerInventoryV2Queries, + + // V2 система товарных поставок селлера + ...sellerGoodsQueries, }, Mutation: { @@ -10298,6 +10302,9 @@ resolvers.Mutation = { // V2 mutations для логистики ...logisticsConsumableV2Mutations, + + // V2 mutations для товарных поставок селлера + ...sellerGoodsMutations, } /* // Резолвер для парсинга JSON рецептуры в SupplyOrderItem diff --git a/src/graphql/resolvers/goods-supply-v2.ts b/src/graphql/resolvers/goods-supply-v2.ts index ffbc2c8..5066ad0 100644 --- a/src/graphql/resolvers/goods-supply-v2.ts +++ b/src/graphql/resolvers/goods-supply-v2.ts @@ -1,848 +1,745 @@ +// ============================================================================= +// 🛒 РЕЗОЛВЕРЫ ДЛЯ СИСТЕМЫ ПОСТАВОК ТОВАРОВ СЕЛЛЕРА V2 +// ============================================================================= + import { GraphQLError } from 'graphql' +import { processSellerGoodsSupplyReceipt } from '@/lib/inventory-management-goods' import { prisma } from '@/lib/prisma' +import { notifyOrganization } from '@/lib/realtime' import { Context } from '../context' -// ========== GOODS SUPPLY V2 RESOLVERS (ЗАКОММЕНТИРОВАНО) ========== -// Раскомментируйте для активации системы товарных поставок V2 +// ============================================================================= +// 🔍 QUERY RESOLVERS V2 +// ============================================================================= -// ========== V2 RESOLVERS START ========== +export const sellerGoodsQueries = { + // Мои товарные поставки (для селлеров - заказы которые я создал) + mySellerGoodsSupplies: async (_: unknown, __: unknown, context: Context) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + try { + const user = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) -export const goodsSupplyV2Resolvers = { - Query: { - // Товарные поставки селлера - myGoodsSupplyOrdersV2: async (_: unknown, __: unknown, context: Context) => { - const { user } = context - if (!user?.organization || user.organization.type !== 'SELLER') { - throw new GraphQLError('Доступно только для селлеров', { - extensions: { code: 'FORBIDDEN' }, - }) + return [] } - try { - const orders = await prisma.goodsSupplyOrder.findMany({ + const supplies = await prisma.sellerGoodsSupplyOrder.findMany({ + where: { + sellerId: user.organizationId!, + }, + include: { + seller: true, + fulfillmentCenter: true, + supplier: true, + logisticsPartner: true, + receivedBy: true, + recipeItems: { + include: { + product: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }) + + return supplies + } catch (error) { + console.error('Error fetching seller goods supplies:', error) + return [] + } + }, + + // Входящие товарные заказы от селлеров (для фулфилмента) + incomingSellerGoodsSupplies: async (_: unknown, __: unknown, context: Context) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + try { + const user = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!user?.organization || user.organization.type !== 'FULFILLMENT') { + return [] + } + + const supplies = await prisma.sellerGoodsSupplyOrder.findMany({ + where: { + fulfillmentCenterId: user.organizationId!, + }, + include: { + seller: true, + fulfillmentCenter: true, + supplier: true, + logisticsPartner: true, + receivedBy: true, + recipeItems: { + include: { + product: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }) + + return supplies + } catch (error) { + console.error('Error fetching incoming seller goods supplies:', error) + return [] + } + }, + + // Товарные заказы от селлеров (для поставщиков) + mySellerGoodsSupplyRequests: async (_: unknown, __: unknown, context: Context) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + try { + const user = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!user?.organization || user.organization.type !== 'WHOLESALE') { + return [] + } + + const supplies = await prisma.sellerGoodsSupplyOrder.findMany({ + where: { + supplierId: user.organizationId!, + }, + include: { + seller: true, + fulfillmentCenter: true, + supplier: true, + logisticsPartner: true, + receivedBy: true, + recipeItems: { + include: { + product: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }) + + return supplies + } catch (error) { + console.error('Error fetching seller goods supply requests:', error) + return [] + } + }, + + // Получение конкретной товарной поставки селлера + sellerGoodsSupply: async (_: unknown, args: { id: string }, context: Context) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + try { + const user = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!user?.organization) { + throw new GraphQLError('Организация не найдена') + } + + const supply = await prisma.sellerGoodsSupplyOrder.findUnique({ + where: { id: args.id }, + include: { + seller: true, + fulfillmentCenter: true, + supplier: true, + logisticsPartner: true, + receivedBy: true, + recipeItems: { + include: { + product: true, + }, + }, + }, + }) + + if (!supply) { + throw new GraphQLError('Поставка не найдена') + } + + // Проверка доступа + const hasAccess = + (user.organization.type === 'SELLER' && supply.sellerId === user.organizationId) || + (user.organization.type === 'FULFILLMENT' && supply.fulfillmentCenterId === user.organizationId) || + (user.organization.type === 'WHOLESALE' && supply.supplierId === user.organizationId) || + (user.organization.type === 'LOGIST' && supply.logisticsPartnerId === user.organizationId) + + if (!hasAccess) { + throw new GraphQLError('Нет доступа к этой поставке') + } + + return supply + } catch (error) { + console.error('Error fetching seller goods supply:', error) + if (error instanceof GraphQLError) { + throw error + } + throw new GraphQLError('Ошибка получения товарной поставки') + } + }, + + // Инвентарь товаров селлера на складе фулфилмента + mySellerGoodsInventory: async (_: unknown, __: unknown, context: Context) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + try { + const user = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!user?.organization) { + return [] + } + + let inventoryItems + + if (user.organization.type === 'SELLER') { + // Селлер видит свои товары на всех складах + inventoryItems = await prisma.sellerGoodsInventory.findMany({ where: { sellerId: user.organizationId!, }, include: { seller: true, - fulfillmentCenter: { - include: { - phones: true, - emails: true, - }, - }, - items: { - include: { - product: { - include: { - category: true, - sizes: true, - }, - }, - recipe: { - include: { - components: { - include: { - material: true, - }, - }, - services: { - include: { - service: true, - }, - }, - }, - }, - }, - }, - requestedServices: { - include: { - service: true, - completedBy: true, - }, - }, - logisticsPartner: { - include: { - phones: true, - }, - }, - supplier: { - include: { - phones: true, - }, - }, - receivedBy: true, + fulfillmentCenter: true, + product: true, }, orderBy: { - createdAt: 'desc', + lastSupplyDate: 'desc', }, }) - - return orders - } catch (error) { - throw new GraphQLError('Ошибка получения товарных поставок', { - extensions: { code: 'INTERNAL_ERROR', originalError: error }, - }) - } - }, - - // Входящие товарные поставки (для фулфилмента) - incomingGoodsSuppliesV2: async (_: unknown, __: unknown, context: Context) => { - const { user } = context - - if (!user?.organization || user.organization.type !== 'FULFILLMENT') { - throw new GraphQLError('Доступно только для фулфилмент-центров', { - extensions: { code: 'FORBIDDEN' }, - }) - } - - try { - const orders = await prisma.goodsSupplyOrder.findMany({ + } else if (user.organization.type === 'FULFILLMENT') { + // Фулфилмент видит все товары на своем складе + inventoryItems = await prisma.sellerGoodsInventory.findMany({ where: { fulfillmentCenterId: user.organizationId!, }, include: { - seller: { - include: { - phones: true, - emails: true, - }, - }, + seller: true, fulfillmentCenter: true, - items: { - include: { - product: { - include: { - category: true, - }, - }, - recipe: { - include: { - components: { - include: { - material: { - select: { - id: true, - name: true, - unit: true, - // НЕ показываем цены селлера - }, - }, - }, - }, - services: { - include: { - service: true, - }, - }, - }, - }, - }, - }, - requestedServices: { - include: { - service: true, - completedBy: true, - }, - }, - logisticsPartner: { - include: { - phones: true, - }, - }, - supplier: true, - receivedBy: true, + product: true, }, orderBy: { - requestedDeliveryDate: 'asc', + lastSupplyDate: 'desc', }, }) - - // Фильтруем коммерческие данные селлера - return orders.map(order => ({ - ...order, - items: order.items.map(item => ({ - ...item, - price: null, // Скрываем закупочную цену селлера - totalPrice: null, // Скрываем общую стоимость - })), - })) - } catch (error) { - throw new GraphQLError('Ошибка получения входящих поставок', { - extensions: { code: 'INTERNAL_ERROR', originalError: error }, - }) - } - }, - - // Товарные заказы для поставщиков - myGoodsSupplyRequestsV2: async (_: unknown, __: unknown, context: Context) => { - const { user } = context - - if (!user?.organization || user.organization.type !== 'WHOLESALE') { - throw new GraphQLError('Доступно только для поставщиков', { - extensions: { code: 'FORBIDDEN' }, - }) + } else { + return [] } - try { - const orders = await prisma.goodsSupplyOrder.findMany({ - where: { - supplierId: user.organizationId!, - }, - include: { - seller: { - include: { - phones: true, - }, - }, - fulfillmentCenter: true, - items: { - include: { - product: { - include: { - category: true, - }, - }, - }, - }, - // НЕ включаем requestedServices - поставщик не видит услуги ФФ - }, - orderBy: { - requestedDeliveryDate: 'asc', - }, - }) + return inventoryItems + } catch (error) { + console.error('Error fetching seller goods inventory:', error) + return [] + } + }, +} - // Показываем только релевантную для поставщика информацию - return orders.map(order => ({ - ...order, - items: order.items.map(item => ({ - ...item, - recipe: null, // Поставщик не видит рецептуры - })), - })) - } catch (error) { - throw new GraphQLError('Ошибка получения заказов поставок', { - extensions: { code: 'INTERNAL_ERROR', originalError: error }, - }) - } - }, +// ============================================================================= +// ✏️ MUTATION RESOLVERS V2 +// ============================================================================= - // Детали конкретной поставки - goodsSupplyOrderV2: async (_: unknown, args: { id: string }, context: Context) => { - const { user } = context - - if (!user?.organizationId) { - throw new GraphQLError('Необходима авторизация', { - extensions: { code: 'UNAUTHENTICATED' }, - }) - } +export const sellerGoodsMutations = { + // Создание поставки товаров селлера + createSellerGoodsSupply: async (_: unknown, args: { input: any }, context: Context) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } - try { - const order = await prisma.goodsSupplyOrder.findUnique({ - where: { id: args.id }, - include: { - seller: { - include: { - phones: true, - emails: true, - }, - }, - fulfillmentCenter: { - include: { - phones: true, - emails: true, - }, - }, - items: { - include: { - product: { - include: { - category: true, - sizes: true, - }, - }, - recipe: { - include: { - components: { - include: { - material: true, - }, - }, - services: { - include: { - service: true, - }, - }, - }, - }, - }, - }, - requestedServices: { - include: { - service: true, - completedBy: true, - }, - }, - logisticsPartner: { - include: { - phones: true, - emails: true, - }, - }, - supplier: { - include: { - phones: true, - emails: true, - }, - }, - receivedBy: true, - }, - }) + try { + const user = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) - if (!order) { - throw new GraphQLError('Поставка не найдена', { - extensions: { code: 'NOT_FOUND' }, - }) - } - - // Проверка прав доступа - const hasAccess = - order.sellerId === user.organizationId || - order.fulfillmentCenterId === user.organizationId || - order.supplierId === user.organizationId || - order.logisticsPartnerId === user.organizationId - - if (!hasAccess) { - throw new GraphQLError('Доступ запрещен', { - extensions: { code: 'FORBIDDEN' }, - }) - } - - // Фильтрация данных в зависимости от роли - if (user.organization?.type === 'WHOLESALE') { - // Поставщик не видит рецептуры и услуги ФФ - return { - ...order, - items: order.items.map(item => ({ - ...item, - recipe: null, - })), - requestedServices: [], - } - } - - if (user.organization?.type === 'FULFILLMENT') { - // ФФ не видит закупочные цены селлера - return { - ...order, - items: order.items.map(item => ({ - ...item, - price: null, - totalPrice: null, - })), - } - } - - if (user.organization?.type === 'LOGIST') { - // Логистика видит только логистическую информацию - return { - ...order, - items: order.items.map(item => ({ - ...item, - price: null, - totalPrice: null, - recipe: null, - })), - requestedServices: [], - } - } - - // Селлер видит все свои данные - return order - } catch (error) { - if (error instanceof GraphQLError) { - throw error - } - throw new GraphQLError('Ошибка получения поставки', { - extensions: { code: 'INTERNAL_ERROR', originalError: error }, - }) - } - }, - - // Рецептуры товаров селлера - myProductRecipes: async (_: unknown, __: unknown, context: Context) => { - const { user } = context - if (!user?.organization || user.organization.type !== 'SELLER') { - throw new GraphQLError('Доступно только для селлеров', { - extensions: { code: 'FORBIDDEN' }, - }) + throw new GraphQLError('Доступно только для селлеров') } - try { - const recipes = await prisma.productRecipe.findMany({ - where: { - product: { - organizationId: user.organizationId!, - }, + const { fulfillmentCenterId, supplierId, logisticsPartnerId, requestedDeliveryDate, notes, recipeItems } = args.input + + // 🔍 ВАЛИДАЦИЯ ПАРТНЕРОВ + + // Проверяем фулфилмент-центр + const fulfillmentCenter = await prisma.organization.findUnique({ + where: { id: fulfillmentCenterId }, + include: { + counterpartiesAsCounterparty: { + where: { organizationId: user.organizationId! }, }, - include: { - product: { - include: { - category: true, - }, - }, - components: { - include: { - material: true, - }, - }, - services: { - include: { - service: true, - }, - }, + }, + }) + + if (!fulfillmentCenter || fulfillmentCenter.type !== 'FULFILLMENT') { + throw new GraphQLError('Фулфилмент-центр не найден или имеет неверный тип') + } + + if (fulfillmentCenter.counterpartiesAsCounterparty.length === 0) { + throw new GraphQLError('Нет партнерских отношений с данным фулфилмент-центром') + } + + // Проверяем поставщика + const supplier = await prisma.organization.findUnique({ + where: { id: supplierId }, + include: { + counterpartiesAsCounterparty: { + where: { organizationId: user.organizationId! }, }, - orderBy: { - updatedAt: 'desc', + }, + }) + + if (!supplier || supplier.type !== 'WHOLESALE') { + throw new GraphQLError('Поставщик не найден или имеет неверный тип') + } + + if (supplier.counterpartiesAsCounterparty.length === 0) { + throw new GraphQLError('Нет партнерских отношений с данным поставщиком') + } + + // 🔍 ВАЛИДАЦИЯ ТОВАРОВ И ОСТАТКОВ + let totalCost = 0 + const mainProducts = recipeItems.filter((item: any) => item.recipeType === 'MAIN_PRODUCT') + + if (mainProducts.length === 0) { + throw new GraphQLError('Должен быть хотя бы один основной товар') + } + + // Проверяем все товары в рецептуре + for (const item of recipeItems) { + const product = await prisma.product.findUnique({ + where: { id: item.productId }, + }) + + if (!product) { + throw new GraphQLError(`Товар с ID ${item.productId} не найден`) + } + + if (product.organizationId !== supplierId) { + throw new GraphQLError(`Товар ${product.name} не принадлежит выбранному поставщику`) + } + + // Для основных товаров проверяем остатки + if (item.recipeType === 'MAIN_PRODUCT') { + const availableStock = (product.stock || product.quantity || 0) - (product.ordered || 0) + + if (item.quantity > availableStock) { + throw new GraphQLError( + `Недостаточно остатков товара "${product.name}". ` + + `Доступно: ${availableStock} шт., запрашивается: ${item.quantity} шт.`, + ) + } + + totalCost += product.price.toNumber() * item.quantity + } + } + + // 🚀 СОЗДАНИЕ ПОСТАВКИ В ТРАНЗАКЦИИ + const supplyOrder = await prisma.$transaction(async (tx) => { + // Создаем заказ поставки + const newOrder = await tx.sellerGoodsSupplyOrder.create({ + data: { + sellerId: user.organizationId!, + fulfillmentCenterId, + supplierId, + logisticsPartnerId, + requestedDeliveryDate: new Date(requestedDeliveryDate), + notes, + status: 'PENDING', + totalCostWithDelivery: totalCost, }, }) - return recipes - } catch (error) { - throw new GraphQLError('Ошибка получения рецептур', { - extensions: { code: 'INTERNAL_ERROR', originalError: error }, - }) + // Создаем записи рецептуры + for (const item of recipeItems) { + await tx.goodsSupplyRecipeItem.create({ + data: { + supplyOrderId: newOrder.id, + productId: item.productId, + quantity: item.quantity, + recipeType: item.recipeType, + }, + }) + + // Резервируем основные товары у поставщика + if (item.recipeType === 'MAIN_PRODUCT') { + await tx.product.update({ + where: { id: item.productId }, + data: { + ordered: { + increment: item.quantity, + }, + }, + }) + } + } + + return newOrder + }) + + // 📨 УВЕДОМЛЕНИЯ + await notifyOrganization( + supplierId, + `Новый заказ товаров от селлера ${user.organization.name}`, + 'GOODS_SUPPLY_ORDER_CREATED', + { orderId: supplyOrder.id }, + ) + + await notifyOrganization( + fulfillmentCenterId, + `Селлер ${user.organization.name} оформил поставку товаров на ваш склад`, + 'INCOMING_GOODS_SUPPLY_ORDER', + { orderId: supplyOrder.id }, + ) + + // Получаем созданную поставку с полными данными + const createdSupply = await prisma.sellerGoodsSupplyOrder.findUnique({ + where: { id: supplyOrder.id }, + include: { + seller: true, + fulfillmentCenter: true, + supplier: true, + logisticsPartner: true, + recipeItems: { + include: { + product: true, + }, + }, + }, + }) + + return { + success: true, + message: 'Поставка товаров успешно создана', + supplyOrder: createdSupply, } - }, + } catch (error) { + console.error('Error creating seller goods supply:', error) + + if (error instanceof GraphQLError) { + throw error + } + + throw new GraphQLError('Ошибка создания товарной поставки') + } }, - Mutation: { - // Создание товарной поставки - createGoodsSupplyOrder: async (_: unknown, args: any, context: Context) => { - const { user } = context - const { input } = args - - if (!user?.organization || user.organization.type !== 'SELLER') { - throw new GraphQLError('Доступно только для селлеров', { - extensions: { code: 'FORBIDDEN' }, - }) + // Обновление статуса товарной поставки + updateSellerGoodsSupplyStatus: async ( + _: unknown, + args: { id: string; status: string; notes?: string }, + context: Context, + ) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация') + } + + try { + const user = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!user?.organization) { + throw new GraphQLError('Организация не найдена') } - try { - // Проверяем фулфилмент-центр - const fulfillmentCenter = await prisma.organization.findFirst({ - where: { - id: input.fulfillmentCenterId, - type: 'FULFILLMENT', + const supply = await prisma.sellerGoodsSupplyOrder.findUnique({ + where: { id: args.id }, + include: { + seller: true, + supplier: true, + fulfillmentCenter: true, + recipeItems: { + include: { + product: true, + }, }, - }) + }, + }) - if (!fulfillmentCenter) { - throw new GraphQLError('Фулфилмент-центр не найден', { - extensions: { code: 'NOT_FOUND' }, - }) + if (!supply) { + throw new GraphQLError('Поставка не найдена') + } + + // 🔐 ПРОВЕРКА ПРАВ И ЛОГИКИ ПЕРЕХОДОВ СТАТУСОВ + const { status } = args + const currentStatus = supply.status + const orgType = user.organization.type + + // Только поставщики могут переводить PENDING → APPROVED + if (status === 'APPROVED' && currentStatus === 'PENDING') { + if (orgType !== 'WHOLESALE' || supply.supplierId !== user.organizationId) { + throw new GraphQLError('Только поставщик может одобрить заказ') } + } - // Проверяем товары и рецептуры - for (const item of input.items) { - const product = await prisma.product.findFirst({ - where: { - id: item.productId, - organizationId: user.organizationId!, - }, - }) - - if (!product) { - throw new GraphQLError(`Товар ${item.productId} не найден`, { - extensions: { code: 'NOT_FOUND' }, - }) - } - - if (item.recipeId) { - const recipe = await prisma.productRecipe.findFirst({ - where: { - id: item.recipeId, - productId: item.productId, - }, - }) - - if (!recipe) { - throw new GraphQLError(`Рецептура ${item.recipeId} не найдена`, { - extensions: { code: 'NOT_FOUND' }, - }) - } - } + // Только поставщики могут переводить APPROVED → SHIPPED + else if (status === 'SHIPPED' && currentStatus === 'APPROVED') { + if (orgType !== 'WHOLESALE' || supply.supplierId !== user.organizationId) { + throw new GraphQLError('Только поставщик может отметить отгрузку') } + } - // Создаем поставку в транзакции - const order = await prisma.$transaction(async (tx) => { - // Создаем основную запись - const newOrder = await tx.goodsSupplyOrder.create({ - data: { - sellerId: user.organizationId!, - fulfillmentCenterId: input.fulfillmentCenterId, - requestedDeliveryDate: new Date(input.requestedDeliveryDate), - notes: input.notes, - status: 'PENDING', + // Только фулфилмент может переводить SHIPPED → DELIVERED + else if (status === 'DELIVERED' && currentStatus === 'SHIPPED') { + if (orgType !== 'FULFILLMENT' || supply.fulfillmentCenterId !== user.organizationId) { + throw new GraphQLError('Только фулфилмент-центр может подтвердить получение') + } + } + + // Только фулфилмент может переводить DELIVERED → COMPLETED + else if (status === 'COMPLETED' && currentStatus === 'DELIVERED') { + if (orgType !== 'FULFILLMENT' || supply.fulfillmentCenterId !== user.organizationId) { + throw new GraphQLError('Только фулфилмент-центр может завершить поставку') + } + } else { + throw new GraphQLError('Недопустимый переход статуса') + } + + // 📅 ОБНОВЛЕНИЕ ВРЕМЕННЫХ МЕТОК + const updateData: any = { + status, + updatedAt: new Date(), + } + + if (status === 'APPROVED' && orgType === 'WHOLESALE') { + updateData.supplierApprovedAt = new Date() + updateData.supplierNotes = args.notes + } + + if (status === 'SHIPPED' && orgType === 'WHOLESALE') { + updateData.shippedAt = new Date() + } + + if (status === 'DELIVERED' && orgType === 'FULFILLMENT') { + updateData.deliveredAt = new Date() + updateData.receivedById = user.id + updateData.receiptNotes = args.notes + } + + // 🔄 ОБНОВЛЕНИЕ В БАЗЕ + const updatedSupply = await prisma.sellerGoodsSupplyOrder.update({ + where: { id: args.id }, + data: updateData, + include: { + seller: true, + fulfillmentCenter: true, + supplier: true, + logisticsPartner: true, + receivedBy: true, + recipeItems: { + include: { + product: true, }, - }) + }, + }, + }) - // Создаем товары - let totalAmount = 0 - let totalItems = 0 + // 📨 УВЕДОМЛЕНИЯ О СМЕНЕ СТАТУСА + if (status === 'APPROVED') { + await notifyOrganization( + supply.sellerId, + `Поставка товаров одобрена поставщиком ${user.organization.name}`, + 'GOODS_SUPPLY_APPROVED', + { orderId: args.id }, + ) + } - for (const itemInput of input.items) { - const itemTotal = itemInput.price * itemInput.quantity - totalAmount += itemTotal - totalItems += itemInput.quantity + if (status === 'SHIPPED') { + await notifyOrganization( + supply.sellerId, + `Поставка товаров отгружена поставщиком ${user.organization.name}`, + 'GOODS_SUPPLY_SHIPPED', + { orderId: args.id }, + ) - await tx.goodsSupplyOrderItem.create({ - data: { - orderId: newOrder.id, - productId: itemInput.productId, - quantity: itemInput.quantity, - price: itemInput.price, - totalPrice: itemTotal, - recipeId: itemInput.recipeId, - }, - }) - } + await notifyOrganization( + supply.fulfillmentCenterId, + 'Поставка товаров в пути. Ожидается доставка', + 'GOODS_SUPPLY_IN_TRANSIT', + { orderId: args.id }, + ) + } - // Создаем запросы услуг - for (const serviceInput of input.requestedServices) { - const service = await tx.service.findUnique({ - where: { id: serviceInput.serviceId }, - }) + if (status === 'DELIVERED') { + // 📦 АВТОМАТИЧЕСКОЕ СОЗДАНИЕ/ОБНОВЛЕНИЕ ИНВЕНТАРЯ V2 + await processSellerGoodsSupplyReceipt(args.id) + + await notifyOrganization( + supply.sellerId, + `Поставка товаров доставлена в ${supply.fulfillmentCenter.name}`, + 'GOODS_SUPPLY_DELIVERED', + { orderId: args.id }, + ) + } - if (!service) { - throw new Error(`Услуга ${serviceInput.serviceId} не найдена`) - } + if (status === 'COMPLETED') { + await notifyOrganization( + supply.sellerId, + `Поставка товаров завершена. Товары размещены на складе ${supply.fulfillmentCenter.name}`, + 'GOODS_SUPPLY_COMPLETED', + { orderId: args.id }, + ) + } - const serviceTotal = service.price * serviceInput.quantity - totalAmount += serviceTotal + return updatedSupply + } catch (error) { + console.error('Error updating seller goods supply status:', error) - await tx.fulfillmentServiceRequest.create({ - data: { - orderId: newOrder.id, - serviceId: serviceInput.serviceId, - quantity: serviceInput.quantity, - price: service.price, - totalPrice: serviceTotal, - status: 'PENDING', - }, - }) - } + if (error instanceof GraphQLError) { + throw error + } - // Обновляем итоги - await tx.goodsSupplyOrder.update({ - where: { id: newOrder.id }, - data: { - totalAmount, - totalItems, + throw new GraphQLError('Ошибка обновления статуса товарной поставки') + } + }, + + // Отмена товарной поставки селлером + cancelSellerGoodsSupply: async (_: unknown, args: { id: string }, context: Context) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация') + } + + try { + const user = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!user?.organization || user.organization.type !== 'SELLER') { + throw new GraphQLError('Только селлеры могут отменять свои поставки') + } + + const supply = await prisma.sellerGoodsSupplyOrder.findUnique({ + where: { id: args.id }, + include: { + seller: true, + recipeItems: { + include: { + product: true, }, - }) + }, + }, + }) - return newOrder - }) + if (!supply) { + throw new GraphQLError('Поставка не найдена') + } - // Получаем созданную поставку с полными данными - const createdOrder = await prisma.goodsSupplyOrder.findUnique({ - where: { id: order.id }, + if (supply.sellerId !== user.organizationId) { + throw new GraphQLError('Вы можете отменить только свои поставки') + } + + // ✅ ПРОВЕРКА ВОЗМОЖНОСТИ ОТМЕНЫ (только PENDING и APPROVED) + if (!['PENDING', 'APPROVED'].includes(supply.status)) { + throw new GraphQLError('Поставку можно отменить только в статусе PENDING или APPROVED') + } + + // 🔄 ОТМЕНА В ТРАНЗАКЦИИ + const cancelledSupply = await prisma.$transaction(async (tx) => { + // Обновляем статус + const updated = await tx.sellerGoodsSupplyOrder.update({ + where: { id: args.id }, + data: { + status: 'CANCELLED', + updatedAt: new Date(), + }, include: { seller: true, fulfillmentCenter: true, - items: { + supplier: true, + recipeItems: { include: { product: true, - recipe: true, - }, - }, - requestedServices: { - include: { - service: true, }, }, }, }) - return { - success: true, - message: 'Товарная поставка успешно создана', - order: createdOrder, - } - } catch (error) { - if (error instanceof GraphQLError) { - throw error - } - throw new GraphQLError('Ошибка создания поставки', { - extensions: { code: 'INTERNAL_ERROR', originalError: error }, - }) - } - }, - - // Обновление статуса товарной поставки - updateGoodsSupplyOrderStatus: async (_: unknown, args: any, context: Context) => { - const { user } = context - const { id, status, notes } = args - - if (!user?.organizationId) { - throw new GraphQLError('Необходима авторизация', { - extensions: { code: 'UNAUTHENTICATED' }, - }) - } - - try { - const order = await prisma.goodsSupplyOrder.findUnique({ - where: { id }, - }) - - if (!order) { - throw new GraphQLError('Поставка не найдена', { - extensions: { code: 'NOT_FOUND' }, - }) - } - - // Проверка прав на изменение статуса - const canUpdate = - (status === 'SUPPLIER_APPROVED' && order.supplierId === user.organizationId) || - (status === 'LOGISTICS_CONFIRMED' && user.organization?.type === 'FULFILLMENT') || - (status === 'SHIPPED' && order.supplierId === user.organizationId) || - (status === 'IN_TRANSIT' && order.logisticsPartnerId === user.organizationId) || - (status === 'RECEIVED' && order.fulfillmentCenterId === user.organizationId) || - (status === 'CANCELLED' && - (order.sellerId === user.organizationId || order.fulfillmentCenterId === user.organizationId)) - - if (!canUpdate) { - throw new GraphQLError('Недостаточно прав для изменения статуса', { - extensions: { code: 'FORBIDDEN' }, - }) - } - - const updateData: any = { - status, - notes: notes || order.notes, - } - - // Устанавливаем временные метки - if (status === 'SUPPLIER_APPROVED') { - updateData.supplierApprovedAt = new Date() - } else if (status === 'SHIPPED') { - updateData.shippedAt = new Date() - } else if (status === 'RECEIVED') { - updateData.receivedAt = new Date() - updateData.receivedById = user.id - } - - const updatedOrder = await prisma.goodsSupplyOrder.update({ - where: { id }, - data: updateData, - include: { - receivedBy: true, - }, - }) - - return updatedOrder - } catch (error) { - if (error instanceof GraphQLError) { - throw error - } - throw new GraphQLError('Ошибка обновления статуса', { - extensions: { code: 'INTERNAL_ERROR', originalError: error }, - }) - } - }, - - // Приемка товарной поставки - receiveGoodsSupplyOrder: async (_: unknown, args: any, context: Context) => { - const { user } = context - const { id, items } = args - - if (!user?.organization || user.organization.type !== 'FULFILLMENT') { - throw new GraphQLError('Доступно только для фулфилмент-центров', { - extensions: { code: 'FORBIDDEN' }, - }) - } - - try { - const order = await prisma.goodsSupplyOrder.findUnique({ - where: { id }, - include: { - items: { - include: { - recipe: { - include: { - components: { - include: { - material: true, - }, - }, - }, - }, - }, - }, - }, - }) - - if (!order) { - throw new GraphQLError('Поставка не найдена', { - extensions: { code: 'NOT_FOUND' }, - }) - } - - if (order.fulfillmentCenterId !== user.organizationId) { - throw new GraphQLError('Доступ запрещен', { - extensions: { code: 'FORBIDDEN' }, - }) - } - - if (order.status !== 'IN_TRANSIT') { - throw new GraphQLError('Поставка должна быть в статусе "В пути"', { - extensions: { code: 'BAD_REQUEST' }, - }) - } - - // Обрабатываем приемку в транзакции - const updatedOrder = await prisma.$transaction(async (tx) => { - // Обновляем данные приемки для каждого товара - for (const itemInput of items) { - const orderItem = order.items.find(item => item.id === itemInput.itemId) - if (!orderItem) { - throw new Error(`Товар ${itemInput.itemId} не найден в поставке`) - } - - await tx.goodsSupplyOrderItem.update({ - where: { id: itemInput.itemId }, + // Освобождаем зарезервированные товары у поставщика (только MAIN_PRODUCT) + for (const item of supply.recipeItems) { + if (item.recipeType === 'MAIN_PRODUCT') { + await tx.product.update({ + where: { id: item.productId }, data: { - receivedQuantity: itemInput.receivedQuantity, - damagedQuantity: itemInput.damagedQuantity || 0, - acceptanceNotes: itemInput.acceptanceNotes, + ordered: { + decrement: item.quantity, + }, }, }) - - // Обновляем остатки расходников по рецептуре - if (orderItem.recipe && itemInput.receivedQuantity > 0) { - for (const component of orderItem.recipe.components) { - const usedQuantity = component.quantity * itemInput.receivedQuantity - - await tx.supply.update({ - where: { id: component.materialId }, - data: { - currentStock: { - decrement: usedQuantity, - }, - }, - }) - } - } } - - // Обновляем статус поставки - const updated = await tx.goodsSupplyOrder.update({ - where: { id }, - data: { - status: 'RECEIVED', - receivedAt: new Date(), - receivedById: user.id, - }, - include: { - items: { - include: { - product: true, - recipe: { - include: { - components: { - include: { - material: true, - }, - }, - }, - }, - }, - }, - requestedServices: { - include: { - service: true, - }, - }, - receivedBy: true, - }, - }) - - return updated - }) - - return updatedOrder - } catch (error) { - if (error instanceof GraphQLError) { - throw error } - throw new GraphQLError('Ошибка приемки поставки', { - extensions: { code: 'INTERNAL_ERROR', originalError: error }, - }) - } - }, - // Отмена товарной поставки - cancelGoodsSupplyOrder: async (_: unknown, args: any, context: Context) => { - const { user } = context - const { id, reason } = args - - if (!user?.organizationId) { - throw new GraphQLError('Необходима авторизация', { - extensions: { code: 'UNAUTHENTICATED' }, - }) + return updated + }) + + // 📨 УВЕДОМЛЕНИЯ ОБ ОТМЕНЕ + if (supply.supplierId) { + await notifyOrganization( + supply.supplierId, + `Селлер ${user.organization.name} отменил заказ товаров`, + 'GOODS_SUPPLY_CANCELLED', + { orderId: args.id }, + ) } - try { - const order = await prisma.goodsSupplyOrder.findUnique({ - where: { id }, - }) + await notifyOrganization( + supply.fulfillmentCenterId, + `Селлер ${user.organization.name} отменил поставку товаров`, + 'GOODS_SUPPLY_CANCELLED', + { orderId: args.id }, + ) - if (!order) { - throw new GraphQLError('Поставка не найдена', { - extensions: { code: 'NOT_FOUND' }, - }) - } + return cancelledSupply + } catch (error) { + console.error('Error cancelling seller goods supply:', error) - // Проверка прав на отмену - const canCancel = - order.sellerId === user.organizationId || - order.fulfillmentCenterId === user.organizationId || - (order.supplierId === user.organizationId && order.status === 'PENDING') - - if (!canCancel) { - throw new GraphQLError('Недостаточно прав для отмены поставки', { - extensions: { code: 'FORBIDDEN' }, - }) - } - - if (['RECEIVED', 'PROCESSING', 'COMPLETED'].includes(order.status)) { - throw new GraphQLError('Нельзя отменить поставку в текущем статусе', { - extensions: { code: 'BAD_REQUEST' }, - }) - } - - const cancelledOrder = await prisma.goodsSupplyOrder.update({ - where: { id }, - data: { - status: 'CANCELLED', - notes: `${order.notes ? order.notes + '\n' : ''}ОТМЕНЕНО: ${reason}`, - }, - }) - - return cancelledOrder - } catch (error) { - if (error instanceof GraphQLError) { - throw error - } - throw new GraphQLError('Ошибка отмены поставки', { - extensions: { code: 'INTERNAL_ERROR', originalError: error }, - }) + if (error instanceof GraphQLError) { + throw error } - }, + + throw new GraphQLError('Ошибка отмены товарной поставки') + } }, } \ No newline at end of file diff --git a/src/graphql/resolvers/seller-consumables.ts b/src/graphql/resolvers/seller-consumables.ts index a388c3b..c74ed64 100644 --- a/src/graphql/resolvers/seller-consumables.ts +++ b/src/graphql/resolvers/seller-consumables.ts @@ -4,11 +4,11 @@ import { GraphQLError } from 'graphql' +import { processSellerConsumableSupplyReceipt } from '@/lib/inventory-management' import { prisma } from '@/lib/prisma' import { notifyOrganization } from '@/lib/realtime' import { Context } from '../context' -import { processSellerConsumableSupplyReceipt } from '@/lib/inventory-management' // ============================================================================= // 🔍 QUERY RESOLVERS diff --git a/src/graphql/typedefs.ts b/src/graphql/typedefs.ts index 80cb7f3..fc30973 100644 --- a/src/graphql/typedefs.ts +++ b/src/graphql/typedefs.ts @@ -1932,6 +1932,136 @@ export const typeDefs = gql` supplyOrder: SellerConsumableSupplyOrder } + # =============================================== + # 🛒 SELLER GOODS SUPPLY TYPES V2.0 - ТОВАРНЫЕ ПОСТАВКИ + # =============================================== + + # Главный тип для товарных поставок селлера + type SellerGoodsSupplyOrder { + id: ID! + status: SellerSupplyOrderStatus! + + # Данные селлера (создатель) + sellerId: ID! + seller: Organization! + fulfillmentCenterId: ID! + fulfillmentCenter: Organization! + requestedDeliveryDate: DateTime! + notes: String + + # Данные поставщика + supplierId: ID + supplier: Organization + supplierApprovedAt: DateTime + packagesCount: Int + estimatedVolume: Float + supplierContractId: String + supplierNotes: String + + # Данные логистики + logisticsPartnerId: ID + logisticsPartner: Organization + estimatedDeliveryDate: DateTime + routeId: ID + logisticsCost: Float + logisticsNotes: String + + # Данные отгрузки + shippedAt: DateTime + trackingNumber: String + + # Данные получения + deliveredAt: DateTime + receivedById: ID + receivedBy: User + receiptNotes: String + + # Финансы + totalCostWithDelivery: Float! + actualDeliveryCost: Float! + + # Нормализованная рецептура + recipeItems: [GoodsSupplyRecipeItem!]! + + createdAt: DateTime! + updatedAt: DateTime! + } + + # Нормализованная рецептура для товарных поставок + type GoodsSupplyRecipeItem { + id: ID! + productId: ID! + product: Product! + quantity: Int! + recipeType: RecipeType! + createdAt: DateTime! + } + + # Типы компонентов в рецептуре + enum RecipeType { + MAIN_PRODUCT # Основной товар + COMPONENT # Компонент товара + PACKAGING # Упаковка + ACCESSORY # Аксессуар + } + + # Инвентарь товаров селлера на складе + type SellerGoodsInventory { + id: ID! + sellerId: ID! + seller: Organization! + fulfillmentCenterId: ID! + fulfillmentCenter: Organization! + productId: ID! + product: Product! + + # Складские данные + currentStock: Int! + reservedStock: Int! + inPreparationStock: Int! + totalReceived: Int! + totalShipped: Int! + + # Пороги + minStock: Int! + maxStock: Int + + # Цены + averageCost: Float! + salePrice: Float! + + # Метаданные + lastSupplyDate: DateTime + lastShipDate: DateTime + notes: String + createdAt: DateTime! + updatedAt: DateTime! + } + + # Input типы для создания товарных поставок + input CreateSellerGoodsSupplyInput { + fulfillmentCenterId: ID! # куда доставлять (FULFILLMENT партнер) + supplierId: ID! # от кого заказывать (WHOLESALE партнер) + logisticsPartnerId: ID # кто везет (LOGIST партнер, опционально) + requestedDeliveryDate: DateTime! # когда нужно + notes: String # заметки селлера + recipeItems: [GoodsSupplyRecipeItemInput!]! # нормализованная рецептура + } + + # Input для компонентов рецептуры товарных поставок + input GoodsSupplyRecipeItemInput { + productId: ID! # какой товар + quantity: Int! # количество + recipeType: RecipeType! # тип компонента + } + + # Результат создания товарной поставки + type CreateSellerGoodsSupplyResult { + success: Boolean! + message: String! + supplyOrder: SellerGoodsSupplyOrder + } + # Расширяем Query для селлерских поставок extend type Query { # Поставки селлера (мои заказы) @@ -1945,6 +2075,22 @@ export const typeDefs = gql` # Конкретная поставка селлера sellerConsumableSupply(id: ID!): SellerConsumableSupplyOrder + + # === V2 ТОВАРНЫЕ ПОСТАВКИ СЕЛЛЕРА === + # Поставки товаров селлера (мои заказы) + mySellerGoodsSupplies: [SellerGoodsSupplyOrder!]! + + # Входящие товарные заказы от селлеров (для фулфилмента) + incomingSellerGoodsSupplies: [SellerGoodsSupplyOrder!]! + + # Товарные поставки селлеров для поставщиков + mySellerGoodsSupplyRequests: [SellerGoodsSupplyOrder!]! + + # Конкретная товарная поставка селлера + sellerGoodsSupply(id: ID!): SellerGoodsSupplyOrder + + # Инвентарь товаров селлера (для фулфилмента и селлера) + mySellerGoodsInventory: [SellerGoodsInventory!]! } # Расширяем Mutation для селлерских поставок @@ -1963,6 +2109,16 @@ export const typeDefs = gql` # Отмена поставки селлером (только PENDING/APPROVED) cancelSellerSupply(id: ID!): SellerConsumableSupplyOrder! + + # === V2 ТОВАРНЫЕ ПОСТАВКИ МУТАЦИИ === + # Создание поставки товаров селлера + createSellerGoodsSupply(input: CreateSellerGoodsSupplyInput!): CreateSellerGoodsSupplyResult! + + # Обновление статуса товарной поставки + updateSellerGoodsSupplyStatus(id: ID!, status: SellerSupplyOrderStatus!, notes: String): SellerGoodsSupplyOrder! + + # Отмена товарной поставки селлером + cancelSellerGoodsSupply(id: ID!): SellerGoodsSupplyOrder! } # === V2 ЛОГИСТИКА РАСХОДНИКОВ ФУЛФИЛМЕНТА === diff --git a/src/lib/inventory-management-goods.ts b/src/lib/inventory-management-goods.ts new file mode 100644 index 0000000..7fe855f --- /dev/null +++ b/src/lib/inventory-management-goods.ts @@ -0,0 +1,250 @@ +// ============================================================================= +// 📦 INVENTORY MANAGEMENT ДЛЯ ТОВАРОВ СЕЛЛЕРА V2 +// ============================================================================= + +import { Prisma } from '@prisma/client' + +import { prisma } from '@/lib/prisma' + +// ============================================================================= +// 🔄 ПРИЕМКА ТОВАРОВ НА СКЛАД ФУЛФИЛМЕНТА +// ============================================================================= + +export async function processSellerGoodsSupplyReceipt( + supplyOrderId: string, +): Promise { + 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 { + 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 { + 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 { + await prisma.sellerGoodsInventory.update({ + where: { + sellerId_fulfillmentCenterId_productId: { + sellerId, + fulfillmentCenterId, + productId, + }, + }, + data: { + reservedStock: { + decrement: quantity, + }, + inPreparationStock: { + increment: quantity, + }, + }, + }) +} \ No newline at end of file