Реализован функционал просмотра заявок покупателей на возврат от Wildberries API в фулфилмент-складе. Добавлена интеграция с WB API /api/v1/claims для получения заявок от всех партнеров-селлеров. Создан полнофункциональный интерфейс с поиском, фильтрацией по статусам, детальным просмотром заявок и отображением медиафайлов от покупателей.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Bivekich
2025-08-04 13:31:07 +03:00
parent 1d5d4906be
commit 17ffd6c9ed
6 changed files with 767 additions and 97 deletions

View File

@ -25,6 +25,7 @@ import {
GET_MY_FULFILLMENT_SUPPLIES, // Расходники фулфилмента
GET_FULFILLMENT_WAREHOUSE_STATS, // Статистика склада с изменениями за сутки
} from "@/graphql/queries";
import { WbReturnClaims } from "./wb-return-claims";
import { toast } from "sonner";
import {
Package,
@ -182,6 +183,7 @@ export function FulfillmentWarehouseDashboard() {
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [expandedStores, setExpandedStores] = useState<Set<string>>(new Set());
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
const [showReturnClaims, setShowReturnClaims] = useState(false);
const [showAdditionalValues, setShowAdditionalValues] = useState(true);
// Загружаем данные из GraphQL
@ -1258,6 +1260,22 @@ export function FulfillmentWarehouseDashboard() {
);
}
// Если показываем заявки на возврат, отображаем соответствующий компонент
if (showReturnClaims) {
return (
<div className="h-screen flex overflow-hidden">
<Sidebar />
<main
className={`flex-1 ${getSidebarMargin()} px-2 py-2 overflow-hidden transition-all duration-300`}
>
<div className="h-full bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl">
<WbReturnClaims onBack={() => setShowReturnClaims(false)} />
</div>
</main>
</div>
);
}
return (
<div className="h-screen flex overflow-hidden">
<Sidebar />
@ -1354,6 +1372,7 @@ export function FulfillmentWarehouseDashboard() {
?.percentChange
}
description="К обработке"
onClick={() => setShowReturnClaims(true)}
/>
<StatCard
title="Расходники селлеров"

View File

@ -0,0 +1,425 @@
"use client";
import React, { useState } from "react";
import { useQuery } from "@apollo/client";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
ArrowLeft,
Search,
Filter,
RefreshCw,
AlertTriangle,
Eye,
MessageSquare,
Clock,
CheckCircle,
XCircle,
Image as ImageIcon,
Play,
Calendar,
Package,
DollarSign,
Building2,
} from "lucide-react";
import { GET_WB_RETURN_CLAIMS } from "@/graphql/queries";
import { formatDistanceToNow } from "date-fns";
import { ru } from "date-fns/locale";
// Типы данных
interface WbReturnClaim {
id: string;
claimType: number;
status: number;
statusEx: number;
nmId: number;
userComment: string;
wbComment?: string;
dt: string;
imtName: string;
orderDt: string;
dtUpdate: string;
photos: string[];
videoPaths: string[];
actions: string[];
price: number;
currencyCode: string;
srid: string;
sellerOrganization: {
id: string;
name: string;
inn: string;
};
}
interface WbReturnClaimsResponse {
claims: WbReturnClaim[];
total: number;
}
// Функции для форматирования
const getStatusText = (status: number, statusEx: number) => {
const statusMap: { [key: number]: string } = {
1: "Новая",
2: "Рассматривается",
3: "Одобрена",
4: "Отклонена",
5: "Возврат завершен",
};
return statusMap[status] || `Статус ${status}`;
};
const getStatusColor = (status: number) => {
const colorMap: { [key: number]: string } = {
1: "bg-blue-500/20 text-blue-300 border-blue-500/30",
2: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30",
3: "bg-green-500/20 text-green-300 border-green-500/30",
4: "bg-red-500/20 text-red-300 border-red-500/30",
5: "bg-emerald-500/20 text-emerald-300 border-emerald-500/30",
};
return colorMap[status] || "bg-gray-500/20 text-gray-300 border-gray-500/30";
};
const formatPrice = (price: number) => {
return new Intl.NumberFormat("ru-RU").format(price);
};
interface WbReturnClaimsProps {
onBack: () => void;
}
export function WbReturnClaims({ onBack }: WbReturnClaimsProps) {
const [searchQuery, setSearchQuery] = useState("");
const [isArchive, setIsArchive] = useState(false);
const [selectedClaim, setSelectedClaim] = useState<WbReturnClaim | null>(null);
const { data, loading, error, refetch } = useQuery<{
wbReturnClaims: WbReturnClaimsResponse;
}>(GET_WB_RETURN_CLAIMS, {
variables: {
isArchive,
limit: 50,
offset: 0,
},
pollInterval: 30000, // Обновляем каждые 30 секунд
errorPolicy: "all",
notifyOnNetworkStatusChange: true,
});
const claims = data?.wbReturnClaims?.claims || [];
const total = data?.wbReturnClaims?.total || 0;
// Отладочный вывод
console.log("WB Claims Debug:", {
isArchive,
loading,
error: error?.message,
total,
claimsCount: claims.length,
hasData: !!data,
});
// Фильтрация заявок по поисковому запросу
const filteredClaims = claims.filter((claim) =>
claim.imtName.toLowerCase().includes(searchQuery.toLowerCase()) ||
claim.sellerOrganization.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
claim.nmId.toString().includes(searchQuery) ||
claim.userComment.toLowerCase().includes(searchQuery.toLowerCase())
);
return (
<div className="h-full flex flex-col">
{/* Заголовок */}
<div className="flex-shrink-0 p-6 border-b border-white/10">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="sm"
onClick={onBack}
className="text-white/70 hover:text-white"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Назад
</Button>
<div>
<h2 className="text-2xl font-bold text-white">
Заявки покупателей на возврат
</h2>
<p className="text-white/70">
Всего заявок: {total} | Показано: {filteredClaims.length} | Режим: {isArchive ? "Архив" : "Активные"}
</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => refetch()}
className="text-white/70 hover:text-white"
>
<RefreshCw className="h-4 w-4 mr-2" />
Обновить
</Button>
</div>
</div>
{/* Фильтры и поиск */}
<div className="flex-shrink-0 p-6 border-b border-white/10">
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-white/40 h-4 w-4" />
<Input
placeholder="Поиск по товару, селлеру, артикулу..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 bg-white/10 border-white/20 text-white placeholder:text-white/40"
/>
</div>
<div className="flex items-center gap-2">
<Button
variant={!isArchive ? "default" : "ghost"}
size="sm"
onClick={() => setIsArchive(false)}
className={
!isArchive
? "bg-white/20 text-white"
: "text-white/70 hover:text-white"
}
>
Активные
</Button>
<Button
variant={isArchive ? "default" : "ghost"}
size="sm"
onClick={() => setIsArchive(true)}
className={
isArchive
? "bg-white/20 text-white"
: "text-white/70 hover:text-white"
}
>
Архив
</Button>
</div>
</div>
</div>
{/* Список заявок */}
<div className="flex-1 overflow-auto p-6">
{error && (
<div className="mb-4 p-4 bg-red-500/20 border border-red-500/30 rounded-lg">
<div className="flex items-center gap-2 text-red-300 mb-2">
<AlertTriangle className="h-4 w-4" />
<span className="font-medium">Ошибка загрузки данных</span>
</div>
<p className="text-red-200 text-sm">
{error.message || "Не удалось получить заявки от Wildberries API"}
</p>
<Button
variant="ghost"
size="sm"
onClick={() => refetch()}
className="mt-2 text-red-300 hover:text-red-200"
>
<RefreshCw className="h-3 w-3 mr-1" />
Повторить
</Button>
</div>
)}
{loading ? (
<div className="flex items-center justify-center h-32">
<RefreshCw className="h-8 w-8 animate-spin text-white/50" />
<span className="ml-2 text-white/70">Загрузка заявок...</span>
</div>
) : filteredClaims.length === 0 ? (
<div className="text-center py-12">
<Package className="h-16 w-16 mx-auto text-white/30 mb-4" />
<h3 className="text-lg font-medium text-white mb-2">
Заявки не найдены
</h3>
<p className="text-white/60 mb-4">
{searchQuery
? "Попробуйте изменить критерии поиска"
: "Новых заявок на возврат пока нет"}
</p>
{!searchQuery && total === 0 && (
<div className="bg-blue-500/20 border border-blue-500/30 rounded-lg p-4 max-w-md mx-auto">
<p className="text-blue-300 text-sm">
💡 Заявки отображаются только от партнеров-селлеров с настроенными Wildberries API ключами
</p>
</div>
)}
</div>
) : (
<div className="space-y-4">
{filteredClaims.map((claim) => (
<Card
key={claim.id}
className="bg-white/10 border-white/20 p-4 hover:bg-white/15 transition-colors cursor-pointer"
onClick={() => setSelectedClaim(claim)}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<Badge className={getStatusColor(claim.status)}>
{getStatusText(claim.status, claim.statusEx)}
</Badge>
<span className="text-sm text-white/60">
{claim.nmId}
</span>
<span className="text-sm text-white/60">
{formatDistanceToNow(new Date(claim.dt), {
addSuffix: true,
locale: ru,
})}
</span>
</div>
<h3 className="text-white font-medium mb-2">
{claim.imtName}
</h3>
<div className="flex items-center gap-4 text-sm text-white/70 mb-2">
<span className="flex items-center gap-1">
<Building2 className="h-3 w-3" />
{claim.sellerOrganization.name}
</span>
<span className="flex items-center gap-1">
<DollarSign className="h-3 w-3" />
{formatPrice(claim.price)}
</span>
</div>
<p className="text-white/60 text-sm line-clamp-2">
{claim.userComment}
</p>
</div>
<div className="flex items-center gap-2 ml-4">
{claim.photos.length > 0 && (
<div className="flex items-center gap-1 text-xs text-white/60">
<ImageIcon className="h-3 w-3" />
{claim.photos.length}
</div>
)}
{claim.videoPaths.length > 0 && (
<div className="flex items-center gap-1 text-xs text-white/60">
<Play className="h-3 w-3" />
{claim.videoPaths.length}
</div>
)}
<Button variant="ghost" size="sm" className="text-white/70 hover:text-white">
<Eye className="h-4 w-4" />
</Button>
</div>
</div>
</Card>
))}
</div>
)}
</div>
{/* Диалог детального просмотра */}
<Dialog open={selectedClaim !== null} onOpenChange={() => setSelectedClaim(null)}>
<DialogContent className="bg-gray-900/95 border-white/20 text-white max-w-2xl">
{selectedClaim && (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-3">
<Badge className={getStatusColor(selectedClaim.status)}>
{getStatusText(selectedClaim.status, selectedClaim.statusEx)}
</Badge>
<span>Заявка {selectedClaim.nmId}</span>
</DialogTitle>
<DialogDescription className="text-white/70">
{selectedClaim.imtName}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-white/60">Дата заявки:</span>
<p className="text-white">
{new Date(selectedClaim.dt).toLocaleString("ru-RU")}
</p>
</div>
<div>
<span className="text-white/60">Дата заказа:</span>
<p className="text-white">
{new Date(selectedClaim.orderDt).toLocaleString("ru-RU")}
</p>
</div>
<div>
<span className="text-white/60">Стоимость:</span>
<p className="text-white">{formatPrice(selectedClaim.price)} </p>
</div>
<div>
<span className="text-white/60">SRID:</span>
<p className="text-white">{selectedClaim.srid}</p>
</div>
</div>
<div>
<span className="text-white/60 text-sm">Продавец:</span>
<p className="text-white">
{selectedClaim.sellerOrganization.name} (ИНН: {selectedClaim.sellerOrganization.inn})
</p>
</div>
<div>
<span className="text-white/60 text-sm">Комментарий покупателя:</span>
<p className="text-white bg-white/5 p-3 rounded-lg mt-1">
{selectedClaim.userComment}
</p>
</div>
{selectedClaim.wbComment && (
<div>
<span className="text-white/60 text-sm">Комментарий WB:</span>
<p className="text-white bg-blue-500/20 p-3 rounded-lg mt-1">
{selectedClaim.wbComment}
</p>
</div>
)}
{(selectedClaim.photos.length > 0 || selectedClaim.videoPaths.length > 0) && (
<div>
<span className="text-white/60 text-sm">Медиафайлы:</span>
<div className="flex gap-2 mt-2">
{selectedClaim.photos.map((photo, index) => (
<div key={index} className="flex items-center gap-1 bg-white/10 px-2 py-1 rounded text-xs">
<ImageIcon className="h-3 w-3" />
Фото {index + 1}
</div>
))}
{selectedClaim.videoPaths.map((video, index) => (
<div key={index} className="flex items-center gap-1 bg-white/10 px-2 py-1 rounded text-xs">
<Play className="h-3 w-3" />
Видео {index + 1}
</div>
))}
</div>
</div>
)}
</div>
</>
)}
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -78,9 +78,11 @@ export function SuppliesDashboard() {
useEffect(() => {
const tab = searchParams.get("tab");
if (tab === "consumables") {
setActiveTab("supplies");
setActiveTab("fulfillment");
setActiveSubTab("consumables");
} else if (tab === "goods") {
setActiveTab("goods");
setActiveTab("fulfillment");
setActiveSubTab("goods");
}
}, [searchParams]);
@ -194,36 +196,38 @@ export function SuppliesDashboard() {
</button>
<button
onClick={() => setActiveSubTab("consumables")}
className={`flex items-center gap-1 text-xs font-medium transition-all duration-150 rounded-md px-2 relative ${
className={`flex items-center justify-between text-xs font-medium transition-all duration-150 rounded-md px-2 relative ${
activeSubTab === "consumables"
? "bg-white/15 text-white border-white/20"
: "text-white/60 hover:text-white/80"
}`}
>
<Wrench className="h-3 w-3" />
<span className="hidden sm:inline">
Расходники селлера
</span>
<span className="sm:hidden">Р</span>
<NotificationBadge
count={pendingCount?.supplyOrders || 0}
/>
<div className="flex items-center gap-1">
<Wrench className="h-3 w-3" />
<span className="hidden sm:inline">
Расходники селлера
</span>
<span className="sm:hidden">Р</span>
<NotificationBadge
count={pendingCount?.supplyOrders || 0}
/>
</div>
{/* Кнопка создания внутри таба расходников */}
{activeSubTab === "consumables" && (
<div
onClick={(e) => {
e.stopPropagation();
window.location.href = "/supplies/create-consumables";
}}
className="h-6 px-2 py-1 bg-white/10 border border-white/20 hover:bg-white/20 text-xs font-medium text-white/80 hover:text-white rounded-md transition-all duration-150 flex items-center gap-1 cursor-pointer"
>
<Plus className="h-2.5 w-2.5" />
<span className="hidden lg:inline">Создать</span>
</div>
)}
</button>
</div>
{/* Кнопка создания для расходников селлера */}
{activeSubTab === "consumables" && (
<button
onClick={() => {
window.location.href = "/supplies/create-consumables";
}}
className="h-7 px-3 py-1 ml-2 bg-white/8 border border-white/20 hover:bg-white/12 text-xs font-medium text-white/80 hover:text-white rounded-lg transition-all duration-150 flex items-center gap-1"
>
<Plus className="h-3 w-3" />
<span className="hidden sm:inline">Создать поставку</span>
<span className="sm:hidden">Создать</span>
</button>
)}
</div>
</div>
)}
@ -236,57 +240,61 @@ export function SuppliesDashboard() {
<div className="grid grid-cols-2 flex-1">
<button
onClick={() => setActiveSubTab("wildberries")}
className={`flex items-center gap-1 text-xs font-medium transition-all duration-150 rounded-md px-2 ${
className={`flex items-center justify-between text-xs font-medium transition-all duration-150 rounded-md px-2 ${
activeSubTab === "wildberries"
? "bg-white/15 text-white border-white/20"
: "text-white/60 hover:text-white/80"
}`}
>
<ShoppingCart className="h-3 w-3" />
<span className="hidden sm:inline">Wildberries</span>
<span className="sm:hidden">W</span>
<div className="flex items-center gap-1">
<ShoppingCart className="h-3 w-3" />
<span className="hidden sm:inline">Wildberries</span>
<span className="sm:hidden">W</span>
</div>
{/* Кнопка создания внутри таба Wildberries */}
{activeSubTab === "wildberries" && (
<div
onClick={(e) => {
e.stopPropagation();
window.location.href = "/supplies/create-wildberries";
}}
className="h-6 px-2 py-1 bg-white/10 border border-white/20 hover:bg-white/20 text-xs font-medium text-white/80 hover:text-white rounded-md transition-all duration-150 flex items-center gap-1 cursor-pointer"
>
<Plus className="h-2.5 w-2.5" />
<span className="hidden lg:inline">Создать</span>
</div>
)}
</button>
<button
onClick={() => setActiveSubTab("ozon")}
className={`flex items-center gap-1 text-xs font-medium transition-all duration-150 rounded-md px-2 ${
className={`flex items-center justify-between text-xs font-medium transition-all duration-150 rounded-md px-2 ${
activeSubTab === "ozon"
? "bg-white/15 text-white border-white/20"
: "text-white/60 hover:text-white/80"
}`}
>
<ShoppingCart className="h-3 w-3" />
<span className="hidden sm:inline">Ozon</span>
<span className="sm:hidden">O</span>
<div className="flex items-center gap-1">
<ShoppingCart className="h-3 w-3" />
<span className="hidden sm:inline">Ozon</span>
<span className="sm:hidden">O</span>
</div>
{/* Кнопка создания внутри таба Ozon */}
{activeSubTab === "ozon" && (
<div
onClick={(e) => {
e.stopPropagation();
window.location.href = "/supplies/create-ozon";
}}
className="h-6 px-2 py-1 bg-white/10 border border-white/20 hover:bg-white/20 text-xs font-medium text-white/80 hover:text-white rounded-md transition-all duration-150 flex items-center gap-1 cursor-pointer"
>
<Plus className="h-2.5 w-2.5" />
<span className="hidden lg:inline">Создать</span>
</div>
)}
</button>
</div>
{/* Кнопка создания для Wildberries */}
{activeSubTab === "wildberries" && (
<button
onClick={() => {
window.location.href = "/supplies/create-wildberries";
}}
className="h-7 px-3 py-1 ml-2 bg-white/8 border border-white/20 hover:bg-white/12 text-xs font-medium text-white/80 hover:text-white rounded-lg transition-all duration-150 flex items-center gap-1"
>
<Plus className="h-3 w-3" />
<span className="hidden sm:inline">Создать поставку</span>
<span className="sm:hidden">Создать</span>
</button>
)}
{/* Кнопка создания для Ozon */}
{activeSubTab === "ozon" && (
<button
onClick={() => {
window.location.href = "/supplies/create-ozon";
}}
className="h-7 px-3 py-1 ml-2 bg-white/8 border border-white/20 hover:bg-white/12 text-xs font-medium text-white/80 hover:text-white rounded-lg transition-all duration-150 flex items-center gap-1"
>
<Plus className="h-3 w-3" />
<span className="hidden sm:inline">Создать поставку</span>
<span className="sm:hidden">Создать</span>
</button>
)}
</div>
</div>
)}
@ -299,57 +307,61 @@ export function SuppliesDashboard() {
<div className="grid grid-cols-2 flex-1">
<button
onClick={() => setActiveThirdTab("cards")}
className={`flex items-center gap-1 text-xs font-normal transition-all duration-150 rounded-sm px-2 ${
className={`flex items-center justify-between text-xs font-normal transition-all duration-150 rounded-sm px-2 ${
activeThirdTab === "cards"
? "bg-white/10 text-white"
: "text-white/50 hover:text-white/70"
}`}
>
<FileText className="h-2.5 w-2.5" />
<span className="hidden sm:inline">Карточки</span>
<span className="sm:hidden">К</span>
<div className="flex items-center gap-1">
<FileText className="h-2.5 w-2.5" />
<span className="hidden sm:inline">Карточки</span>
<span className="sm:hidden">К</span>
</div>
{/* Кнопка создания внутри таба карточек */}
{activeThirdTab === "cards" && (
<div
onClick={(e) => {
e.stopPropagation();
window.location.href = "/supplies/create-cards";
}}
className="h-5 px-1.5 py-0.5 bg-white/8 border border-white/15 hover:bg-white/15 text-xs font-normal text-white/60 hover:text-white/80 rounded-sm transition-all duration-150 flex items-center gap-0.5 cursor-pointer"
>
<Plus className="h-2 w-2" />
<span className="hidden xl:inline text-xs">Создать</span>
</div>
)}
</button>
<button
onClick={() => setActiveThirdTab("suppliers")}
className={`flex items-center gap-1 text-xs font-normal transition-all duration-150 rounded-sm px-2 ${
className={`flex items-center justify-between text-xs font-normal transition-all duration-150 rounded-sm px-2 ${
activeThirdTab === "suppliers"
? "bg-white/10 text-white"
: "text-white/50 hover:text-white/70"
}`}
>
<Building2 className="h-2.5 w-2.5" />
<span className="hidden sm:inline">Поставщики</span>
<span className="sm:hidden">П</span>
<div className="flex items-center gap-1">
<Building2 className="h-2.5 w-2.5" />
<span className="hidden sm:inline">Поставщики</span>
<span className="sm:hidden">П</span>
</div>
{/* Кнопка создания внутри таба поставщиков */}
{activeThirdTab === "suppliers" && (
<div
onClick={(e) => {
e.stopPropagation();
window.location.href = "/supplies/create-suppliers";
}}
className="h-5 px-1.5 py-0.5 bg-white/8 border border-white/15 hover:bg-white/15 text-xs font-normal text-white/60 hover:text-white/80 rounded-sm transition-all duration-150 flex items-center gap-0.5 cursor-pointer"
>
<Plus className="h-2 w-2" />
<span className="hidden xl:inline text-xs">Создать</span>
</div>
)}
</button>
</div>
{/* Кнопка создания для карточек */}
{activeThirdTab === "cards" && (
<button
onClick={() => {
window.location.href = "/supplies/create-cards";
}}
className="h-6 px-2 py-1 ml-2 bg-white/5 border border-white/15 hover:bg-white/8 text-xs font-normal text-white/60 hover:text-white/80 rounded-md transition-all duration-150 flex items-center gap-1"
>
<Plus className="h-3 w-3" />
<span className="hidden sm:inline">Создать поставку</span>
<span className="sm:hidden">Создать</span>
</button>
)}
{/* Кнопка создания для поставщиков */}
{activeThirdTab === "suppliers" && (
<button
onClick={() => {
window.location.href = "/supplies/create-suppliers";
}}
className="h-6 px-2 py-1 ml-2 bg-white/5 border border-white/15 hover:bg-white/8 text-xs font-normal text-white/60 hover:text-white/80 rounded-md transition-all duration-150 flex items-center gap-1"
>
<Plus className="h-3 w-3" />
<span className="hidden sm:inline">Создать поставку</span>
<span className="sm:hidden">Создать</span>
</button>
)}
</div>
</div>
)}

View File

@ -1,5 +1,38 @@
import { gql } from "graphql-tag";
// Запрос для получения заявок покупателей на возврат от Wildberries
export const GET_WB_RETURN_CLAIMS = gql`
query GetWbReturnClaims($isArchive: Boolean!, $limit: Int, $offset: Int) {
wbReturnClaims(isArchive: $isArchive, limit: $limit, offset: $offset) {
claims {
id
claimType
status
statusEx
nmId
userComment
wbComment
dt
imtName
orderDt
dtUpdate
photos
videoPaths
actions
price
currencyCode
srid
sellerOrganization {
id
name
inn
}
}
total
}
}
`;
export const GET_ME = gql`
query GetMe {
me {

View File

@ -8171,6 +8171,147 @@ const wildberriesQueries = {
};
}
},
// Получение заявок покупателей на возврат от Wildberries от всех партнеров-селлеров
wbReturnClaims: async (
_: unknown,
{ isArchive, limit, offset }: { isArchive: boolean; limit?: number; offset?: number },
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("У пользователя нет организации");
}
// Проверяем, что это фулфилмент организация
if (user.organization.type !== "FULFILLMENT") {
throw new GraphQLError("Доступ только для фулфилмент организаций");
}
// Получаем всех партнеров-селлеров с активными WB API ключами
const partnerSellerOrgs = await prisma.counterparty.findMany({
where: {
organizationId: user.organization.id,
},
include: {
counterparty: {
include: {
apiKeys: {
where: {
marketplace: "WILDBERRIES",
isActive: true,
},
},
},
},
},
});
// Фильтруем только селлеров с WB API ключами
const sellersWithWbKeys = partnerSellerOrgs.filter(
(partner) =>
partner.counterparty.type === "SELLER" &&
partner.counterparty.apiKeys.length > 0
);
if (sellersWithWbKeys.length === 0) {
return {
claims: [],
total: 0,
};
}
console.log(`Found ${sellersWithWbKeys.length} seller partners with WB keys`);
// Получаем заявки от всех селлеров параллельно
const claimsPromises = sellersWithWbKeys.map(async (partner) => {
const wbApiKey = partner.counterparty.apiKeys[0].apiKey;
const wbService = new WildberriesService(wbApiKey);
try {
const claimsResponse = await wbService.getClaims({
isArchive,
limit: Math.ceil((limit || 50) / sellersWithWbKeys.length), // Распределяем лимит между селлерами
offset: 0,
});
// Добавляем информацию о селлере к каждой заявке
const claimsWithSeller = claimsResponse.claims.map((claim) => ({
...claim,
sellerOrganization: {
id: partner.counterparty.id,
name: partner.counterparty.name || "Неизвестная организация",
inn: partner.counterparty.inn || "",
},
}));
console.log(`Got ${claimsWithSeller.length} claims from seller ${partner.counterparty.name}`);
return claimsWithSeller;
} catch (error) {
console.error(`Error fetching claims for seller ${partner.counterparty.name}:`, error);
return [];
}
});
const allClaims = (await Promise.all(claimsPromises)).flat();
console.log(`Total claims aggregated: ${allClaims.length}`);
// Сортируем по дате создания (новые первыми)
allClaims.sort((a, b) => new Date(b.dt).getTime() - new Date(a.dt).getTime());
// Применяем пагинацию
const paginatedClaims = allClaims.slice(offset || 0, (offset || 0) + (limit || 50));
console.log(`Paginated claims: ${paginatedClaims.length}`);
// Преобразуем в формат фронтенда
const transformedClaims = paginatedClaims.map((claim) => ({
id: claim.id,
claimType: claim.claim_type,
status: claim.status,
statusEx: claim.status_ex,
nmId: claim.nm_id,
userComment: claim.user_comment || "",
wbComment: claim.wb_comment || null,
dt: claim.dt,
imtName: claim.imt_name,
orderDt: claim.order_dt,
dtUpdate: claim.dt_update,
photos: claim.photos || [],
videoPaths: claim.video_paths || [],
actions: claim.actions || [],
price: claim.price,
currencyCode: claim.currency_code,
srid: claim.srid,
sellerOrganization: claim.sellerOrganization,
}));
console.log(`Returning ${transformedClaims.length} transformed claims to frontend`);
return {
claims: transformedClaims,
total: allClaims.length,
};
} catch (error) {
console.error("Error fetching WB return claims:", error);
throw new GraphQLError(
error instanceof Error ? error.message : "Ошибка получения заявок на возврат"
);
}
},
};
// Резолверы для внешней рекламы

View File

@ -118,6 +118,13 @@ export const typeDefs = gql`
# Список кампаний Wildberries
getWildberriesCampaignsList: WildberriesCampaignsListResponse!
# Заявки покупателей на возврат от Wildberries (для фулфилмента)
wbReturnClaims(
isArchive: Boolean!
limit: Int
offset: Int
): WbReturnClaimsResponse!
# Типы для внешней рекламы
getExternalAds(dateFrom: String!, dateTo: String!): ExternalAdsResponse!
@ -1338,6 +1345,39 @@ export const typeDefs = gql`
): WBWarehouseCacheResponse!
}
# Типы для заявок на возврат WB
type WbReturnClaim {
id: String!
claimType: Int!
status: Int!
statusEx: Int!
nmId: Int!
userComment: String!
wbComment: String
dt: String!
imtName: String!
orderDt: String!
dtUpdate: String!
photos: [String!]!
videoPaths: [String!]!
actions: [String!]!
price: Int!
currencyCode: String!
srid: String!
sellerOrganization: WbSellerOrganization!
}
type WbSellerOrganization {
id: String!
name: String!
inn: String!
}
type WbReturnClaimsResponse {
claims: [WbReturnClaim!]!
total: Int!
}
# Типы для статистики склада фулфилмента
type FulfillmentWarehouseStats {
products: WarehouseStatsItem!