From 17ffd6c9ed8e93e6c944327c3d9fe532a80e22ed Mon Sep 17 00:00:00 2001 From: Bivekich Date: Mon, 4 Aug 2025 13:31:07 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=BE?= =?UTF-8?q?=D0=BD=D0=B0=D0=BB=20=D0=BF=D1=80=D0=BE=D1=81=D0=BC=D0=BE=D1=82?= =?UTF-8?q?=D1=80=D0=B0=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BE=D0=BA=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BA=D1=83=D0=BF=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=D0=B9=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=B2=D0=BE=D0=B7=D0=B2=D1=80=D0=B0=D1=82=20?= =?UTF-8?q?=D0=BE=D1=82=20Wildberries=20API=20=D0=B2=20=D1=84=D1=83=D0=BB?= =?UTF-8?q?=D1=84=D0=B8=D0=BB=D0=BC=D0=B5=D0=BD=D1=82-=D1=81=D0=BA=D0=BB?= =?UTF-8?q?=D0=B0=D0=B4=D0=B5.=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20=D1=81=20WB=20API=20/api/v1/claims=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D0=BF=D0=BE=D0=BB=D1=83=D1=87=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BE=D0=BA=20=D0=BE?= =?UTF-8?q?=D1=82=20=D0=B2=D1=81=D0=B5=D1=85=20=D0=BF=D0=B0=D1=80=D1=82?= =?UTF-8?q?=D0=BD=D0=B5=D1=80=D0=BE=D0=B2-=D1=81=D0=B5=D0=BB=D0=BB=D0=B5?= =?UTF-8?q?=D1=80=D0=BE=D0=B2.=20=D0=A1=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D0=BD=D0=BE=D1=84=D1=83=D0=BD=D0=BA=D1=86?= =?UTF-8?q?=D0=B8=D0=BE=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D0=B8?= =?UTF-8?q?=D0=BD=D1=82=D0=B5=D1=80=D1=84=D0=B5=D0=B9=D1=81=20=D1=81=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=D0=BE=D0=BC,=20=D1=84=D0=B8?= =?UTF-8?q?=D0=BB=D1=8C=D1=82=D1=80=D0=B0=D1=86=D0=B8=D0=B5=D0=B9=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=D0=B0=D0=BC,=20?= =?UTF-8?q?=D0=B4=D0=B5=D1=82=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=BC=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D1=81=D0=BC=D0=BE=D1=82=D1=80=D0=BE=D0=BC=20=D0=B7?= =?UTF-8?q?=D0=B0=D1=8F=D0=B2=D0=BE=D0=BA=20=D0=B8=20=D0=BE=D1=82=D0=BE?= =?UTF-8?q?=D0=B1=D1=80=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5=D0=BC=20=D0=BC?= =?UTF-8?q?=D0=B5=D0=B4=D0=B8=D0=B0=D1=84=D0=B0=D0=B9=D0=BB=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=BE=D1=82=20=D0=BF=D0=BE=D0=BA=D1=83=D0=BF=D0=B0=D1=82=D0=B5?= =?UTF-8?q?=D0=BB=D0=B5=D0=B9.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../fulfillment-warehouse-dashboard.tsx | 19 + .../wb-return-claims.tsx | 425 ++++++++++++++++++ .../supplies/supplies-dashboard.tsx | 206 +++++---- src/graphql/queries.ts | 33 ++ src/graphql/resolvers.ts | 141 ++++++ src/graphql/typedefs.ts | 40 ++ 6 files changed, 767 insertions(+), 97 deletions(-) create mode 100644 src/components/fulfillment-warehouse/wb-return-claims.tsx diff --git a/src/components/fulfillment-warehouse/fulfillment-warehouse-dashboard.tsx b/src/components/fulfillment-warehouse/fulfillment-warehouse-dashboard.tsx index a3778e0..311a117 100644 --- a/src/components/fulfillment-warehouse/fulfillment-warehouse-dashboard.tsx +++ b/src/components/fulfillment-warehouse/fulfillment-warehouse-dashboard.tsx @@ -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>(new Set()); const [expandedItems, setExpandedItems] = useState>(new Set()); + const [showReturnClaims, setShowReturnClaims] = useState(false); const [showAdditionalValues, setShowAdditionalValues] = useState(true); // Загружаем данные из GraphQL @@ -1258,6 +1260,22 @@ export function FulfillmentWarehouseDashboard() { ); } + // Если показываем заявки на возврат, отображаем соответствующий компонент + if (showReturnClaims) { + return ( +
+ +
+
+ setShowReturnClaims(false)} /> +
+
+
+ ); + } + return (
@@ -1354,6 +1372,7 @@ export function FulfillmentWarehouseDashboard() { ?.percentChange } description="К обработке" + onClick={() => setShowReturnClaims(true)} /> { + 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(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 ( +
+ {/* Заголовок */} +
+
+
+ +
+

+ Заявки покупателей на возврат +

+

+ Всего заявок: {total} | Показано: {filteredClaims.length} | Режим: {isArchive ? "Архив" : "Активные"} +

+
+
+ +
+
+ + {/* Фильтры и поиск */} +
+
+
+ + setSearchQuery(e.target.value)} + className="pl-10 bg-white/10 border-white/20 text-white placeholder:text-white/40" + /> +
+
+ + +
+
+
+ + {/* Список заявок */} +
+ {error && ( +
+
+ + Ошибка загрузки данных +
+

+ {error.message || "Не удалось получить заявки от Wildberries API"} +

+ +
+ )} + + {loading ? ( +
+ + Загрузка заявок... +
+ ) : filteredClaims.length === 0 ? ( +
+ +

+ Заявки не найдены +

+

+ {searchQuery + ? "Попробуйте изменить критерии поиска" + : "Новых заявок на возврат пока нет"} +

+ {!searchQuery && total === 0 && ( +
+

+ 💡 Заявки отображаются только от партнеров-селлеров с настроенными Wildberries API ключами +

+
+ )} +
+ ) : ( +
+ {filteredClaims.map((claim) => ( + setSelectedClaim(claim)} + > +
+
+
+ + {getStatusText(claim.status, claim.statusEx)} + + + №{claim.nmId} + + + {formatDistanceToNow(new Date(claim.dt), { + addSuffix: true, + locale: ru, + })} + +
+ +

+ {claim.imtName} +

+ +
+ + + {claim.sellerOrganization.name} + + + + {formatPrice(claim.price)} ₽ + +
+ +

+ {claim.userComment} +

+
+ +
+ {claim.photos.length > 0 && ( +
+ + {claim.photos.length} +
+ )} + {claim.videoPaths.length > 0 && ( +
+ + {claim.videoPaths.length} +
+ )} + +
+
+
+ ))} +
+ )} +
+ + {/* Диалог детального просмотра */} + setSelectedClaim(null)}> + + {selectedClaim && ( + <> + + + + {getStatusText(selectedClaim.status, selectedClaim.statusEx)} + + Заявка №{selectedClaim.nmId} + + + {selectedClaim.imtName} + + + +
+
+
+ Дата заявки: +

+ {new Date(selectedClaim.dt).toLocaleString("ru-RU")} +

+
+
+ Дата заказа: +

+ {new Date(selectedClaim.orderDt).toLocaleString("ru-RU")} +

+
+
+ Стоимость: +

{formatPrice(selectedClaim.price)} ₽

+
+
+ SRID: +

{selectedClaim.srid}

+
+
+ +
+ Продавец: +

+ {selectedClaim.sellerOrganization.name} (ИНН: {selectedClaim.sellerOrganization.inn}) +

+
+ +
+ Комментарий покупателя: +

+ {selectedClaim.userComment} +

+
+ + {selectedClaim.wbComment && ( +
+ Комментарий WB: +

+ {selectedClaim.wbComment} +

+
+ )} + + {(selectedClaim.photos.length > 0 || selectedClaim.videoPaths.length > 0) && ( +
+ Медиафайлы: +
+ {selectedClaim.photos.map((photo, index) => ( +
+ + Фото {index + 1} +
+ ))} + {selectedClaim.videoPaths.map((video, index) => ( +
+ + Видео {index + 1} +
+ ))} +
+
+ )} +
+ + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/supplies/supplies-dashboard.tsx b/src/components/supplies/supplies-dashboard.tsx index 6053ae4..5ed5c48 100644 --- a/src/components/supplies/supplies-dashboard.tsx +++ b/src/components/supplies/supplies-dashboard.tsx @@ -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() {
- - {/* Кнопка создания для расходников селлера */} - {activeSubTab === "consumables" && ( - - )} )} @@ -236,57 +240,61 @@ export function SuppliesDashboard() {
- - {/* Кнопка создания для Wildberries */} - {activeSubTab === "wildberries" && ( - - )} - - {/* Кнопка создания для Ozon */} - {activeSubTab === "ozon" && ( - - )} )} @@ -299,57 +307,61 @@ export function SuppliesDashboard() {
- - {/* Кнопка создания для карточек */} - {activeThirdTab === "cards" && ( - - )} - - {/* Кнопка создания для поставщиков */} - {activeThirdTab === "suppliers" && ( - - )} )} diff --git a/src/graphql/queries.ts b/src/graphql/queries.ts index 0de2cef..e10bd49 100644 --- a/src/graphql/queries.ts +++ b/src/graphql/queries.ts @@ -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 { diff --git a/src/graphql/resolvers.ts b/src/graphql/resolvers.ts index fb6fc0b..cbc28e4 100644 --- a/src/graphql/resolvers.ts +++ b/src/graphql/resolvers.ts @@ -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 : "Ошибка получения заявок на возврат" + ); + } + }, }; // Резолверы для внешней рекламы diff --git a/src/graphql/typedefs.ts b/src/graphql/typedefs.ts index d33ad6a..52b48c2 100644 --- a/src/graphql/typedefs.ts +++ b/src/graphql/typedefs.ts @@ -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!