diff --git a/negodata/front/src/api/generated/item/item.ts b/negodata/front/src/api/generated/item/item.ts index 7d2a8ce..4aa5872 100644 --- a/negodata/front/src/api/generated/item/item.ts +++ b/negodata/front/src/api/generated/item/item.ts @@ -644,7 +644,8 @@ export const useDeleteItem = (options?: { mutation?:UseMutationOptions>, TError,{itemId: string}, TContext>, request?: SecondParameter} @@ -706,7 +707,9 @@ export const useTriggerLowestPrice = & { queryKey: DataTag } /** - * @summary 최저가 수집 결과(스텁) + * @summary 최저가 수집 결과 */ export function useGetLowestPrice>, TError = void | HTTPValidationError>( diff --git a/negodata/front/src/api/generated/model/index.ts b/negodata/front/src/api/generated/model/index.ts index e4c514f..b752a35 100644 --- a/negodata/front/src/api/generated/model/index.ts +++ b/negodata/front/src/api/generated/model/index.ts @@ -78,6 +78,10 @@ export * from './listNotificationsParams'; export * from './listQuotationsParams'; export * from './listSuppliersParams'; export * from './listUsersParams'; +export * from './lowestPriceEntry'; +export * from './lowestPriceEntryCrawlEndTime'; +export * from './lowestPriceEntryFailReason'; +export * from './lowestPriceEntryLpPrice'; export * from './notificationData'; export * from './notificationDataCreatedAt'; export * from './notificationDataData'; @@ -278,6 +282,7 @@ export * from './resItemSupplyTypeListMsg'; export * from './resLogin'; export * from './resLoginMsg'; export * from './resLowestPriceResult'; +export * from './resLowestPriceResultLowestPrice'; export * from './resLowestPriceResultMsg'; export * from './resLowestPriceTrigger'; export * from './resLowestPriceTriggerMsg'; diff --git a/negodata/front/src/api/generated/model/lowestPriceEntry.ts b/negodata/front/src/api/generated/model/lowestPriceEntry.ts new file mode 100644 index 0000000..572a880 --- /dev/null +++ b/negodata/front/src/api/generated/model/lowestPriceEntry.ts @@ -0,0 +1,20 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { LowestPriceEntryLpPrice } from './lowestPriceEntryLpPrice'; +import type { LowestPriceEntryFailReason } from './lowestPriceEntryFailReason'; +import type { LowestPriceEntryCrawlEndTime } from './lowestPriceEntryCrawlEndTime'; + +/** + * 최저가 수집 이력 1건(partner.item_internet_lowest_prices). + */ +export interface LowestPriceEntry { + lp_price?: LowestPriceEntryLpPrice; + website?: number; + success_yn?: boolean; + fail_reason?: LowestPriceEntryFailReason; + crawl_end_time?: LowestPriceEntryCrawlEndTime; +} diff --git a/negodata/front/src/api/generated/model/lowestPriceEntryCrawlEndTime.ts b/negodata/front/src/api/generated/model/lowestPriceEntryCrawlEndTime.ts new file mode 100644 index 0000000..24b52a3 --- /dev/null +++ b/negodata/front/src/api/generated/model/lowestPriceEntryCrawlEndTime.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type LowestPriceEntryCrawlEndTime = string | null; diff --git a/negodata/front/src/api/generated/model/lowestPriceEntryFailReason.ts b/negodata/front/src/api/generated/model/lowestPriceEntryFailReason.ts new file mode 100644 index 0000000..69e91be --- /dev/null +++ b/negodata/front/src/api/generated/model/lowestPriceEntryFailReason.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type LowestPriceEntryFailReason = string | null; diff --git a/negodata/front/src/api/generated/model/lowestPriceEntryLpPrice.ts b/negodata/front/src/api/generated/model/lowestPriceEntryLpPrice.ts new file mode 100644 index 0000000..409f334 --- /dev/null +++ b/negodata/front/src/api/generated/model/lowestPriceEntryLpPrice.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type LowestPriceEntryLpPrice = number | null; diff --git a/negodata/front/src/api/generated/model/resLowestPriceResult.ts b/negodata/front/src/api/generated/model/resLowestPriceResult.ts index 494eb35..8edff1e 100644 --- a/negodata/front/src/api/generated/model/resLowestPriceResult.ts +++ b/negodata/front/src/api/generated/model/resLowestPriceResult.ts @@ -6,11 +6,14 @@ */ import type { ErrorInfo } from './errorInfo'; import type { ResLowestPriceResultMsg } from './resLowestPriceResultMsg'; +import type { ResLowestPriceResultLowestPrice } from './resLowestPriceResultLowestPrice'; +import type { LowestPriceEntry } from './lowestPriceEntry'; export interface ResLowestPriceResult { result?: ErrorInfo; msg?: ResLowestPriceResultMsg; item_id?: string; - results?: unknown[]; + lowest_price?: ResLowestPriceResultLowestPrice; + results?: LowestPriceEntry[]; message?: string; } diff --git a/negodata/front/src/api/generated/model/resLowestPriceResultLowestPrice.ts b/negodata/front/src/api/generated/model/resLowestPriceResultLowestPrice.ts new file mode 100644 index 0000000..5ee8acd --- /dev/null +++ b/negodata/front/src/api/generated/model/resLowestPriceResultLowestPrice.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ResLowestPriceResultLowestPrice = number | null; diff --git a/negodata/front/src/features/products/components/PriceUpdateModal.tsx b/negodata/front/src/features/products/components/PriceUpdateModal.tsx index 5d8b2f2..3240c42 100644 --- a/negodata/front/src/features/products/components/PriceUpdateModal.tsx +++ b/negodata/front/src/features/products/components/PriceUpdateModal.tsx @@ -1,10 +1,12 @@ -import { useState } from 'react'; +import { useRef, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { Globe, X, AlertCircle, Loader2, Cpu, RefreshCw } from 'lucide-react'; import { showToast } from '@/lib/notify'; import { Typography } from '@/components/ui/typography'; import { Button } from '@/components/ui/button'; import { useScrollLock } from '@/lib/useScrollLock'; +import { triggerLowestPrice, getLowestPrice } from '@/api/generated/item/item'; import type { Product } from '../types'; type PriceUpdateModalProps = { @@ -15,62 +17,113 @@ type PriceUpdateModalProps = { onClose: () => void; }; -// 인터넷 최저가 실시간 수집 데모 모달. 크롤링 진행 state는 이 컴포넌트가 소유한다. -// NOTE: 서버 미연동 — 진행 애니메이션/로그만 데모. +const POLL_INTERVAL_MS = 5_000; // GET lowest-price 폴링 간격(서버가 조회 시 lps_db 증분 동기화를 겸함) +const POLL_TIMEOUT_MS = 300_000; // 상품당 수십 초 × 순차 처리 감안한 전체 상한(5분) + +// 인터넷 최저가 실시간 수집 모달 — LPS 연동. +// 흐름: 선택 상품마다 POST(수집 요청, 큐 접수) → GET 폴링(요청 시각 이후의 수집 이력이 생기면 완료). +// 폴링이 시간을 초과해도 서버 검색은 계속되고, 5분 주기 동기화 배치가 결과를 자동 반영한다. export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose }: PriceUpdateModalProps) { useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금 + const queryClient = useQueryClient(); const [isCrawling, setIsCrawling] = useState(false); const [crawlingProgress, setCrawlingProgress] = useState(0); const [crawlerLogs, setCrawlerLogs] = useState([]); + const cancelledRef = useRef(false); // 닫기 시 폴링 루프 중단(서버 검색은 계속) if (!open) return null; - const handleStartCrawling = () => { + const pushLog = (line: string) => setCrawlerLogs((prev) => [...prev, line]); + const nameOf = (id: string) => products.find((p) => p.item_id === id)?.name || id; + + const handleStartCrawling = async () => { if (selectedIds.length === 0) { toast.error('업데이트할 상품을 1개 이상 선택해 주십시오.'); return; } + cancelledRef.current = false; setIsCrawling(true); - setCrawlingProgress(10); + setCrawlingProgress(5); setCrawlerLogs([ - '[System] 실시간 최저가 수집용 웹 크롤러 엔진 가동...', - '[System] API Endpoint: https://api.commerce-crawler.co.kr/v2/itemsync', - `[Target] 선택된 ${selectedIds.length}개 상품의 고유 코드 및 품목 매핑 중...`, + '[System] 인터넷 최저가 검색(LPS) 요청 접수 중...', + `[Target] 선택된 ${selectedIds.length}개 상품`, ]); + const startedAt = new Date().toISOString(); // 이 시각 이후의 수집 이력만 "이번 요청 결과"로 인정 - // Fast simulation timers - setTimeout(() => { - setCrawlingProgress(35); - setCrawlerLogs((prev) => [ - ...prev, - ...selectedIds.map((id) => { - const p = products.find((prod) => prod.item_id === id); - return `[크롤링] '${p?.name || id}' 인터넷 최저가 비교 검색 수집 진행`; - }), - `[Search] 외부 커머스 유통 플랫폼(Coupang, Gmarket, Danawa) 지표 추출 시작...`, - ]); - }, 500); + // 1) 상품별 수집 요청(POST) — 실패/중복은 로그로 구분하고 계속 진행 + const pending = new Set(); + for (const id of selectedIds) { + try { + const r = await triggerLowestPrice(id); + if (r.status === 'queued') { + pending.add(id); + pushLog(`[접수] '${nameOf(id)}' 검색 큐 등록`); + } else if (r.status === 'duplicated') { + pending.add(id); // 이미 진행 중 → 결과는 폴링으로 같이 받는다 + pushLog(`[진행중] '${nameOf(id)}' 이미 검색이 진행 중 — 결과 대기에 합류`); + } else { + pushLog(`[불가] '${nameOf(id)}' ${r.message || '검색 서비스 연결 불가'}`); + } + } catch { + pushLog(`[오류] '${nameOf(id)}' 요청 실패`); + } + } - setTimeout(() => { - setCrawlingProgress(72); - setCrawlerLogs((prev) => [ - ...prev, - `[OCR] 수집 완료된 실시간 HTML/DOM 가격 노드 데이터 분석 중...`, - `[Sync] 정제 단가 적용 (부가세 보정 및 할인 쿠폰 혜택가 산정)`, - ]); - }, 1100); - - setTimeout(() => { - setCrawlingProgress(100); - // NOTE: 인터넷 최저가 동기화는 전용 엔드포인트 연동 예정. 현재는 진행 애니메이션만 데모(서버 미반영). - showToast(`선택한 ${selectedIds.length}개 상품의 인터넷 최저가 동기화는 준비 중입니다(데모).`, 'info'); - onDone(); + if (pending.size === 0) { + showToast('접수된 상품이 없습니다. 검색 서비스 상태를 확인해 주세요.', 'error'); setIsCrawling(false); - onClose(); setCrawlingProgress(0); - setCrawlerLogs([]); - }, 2000); + return; + } + setCrawlingProgress(15); + pushLog(`[Search] ${pending.size}개 상품 크롤링 진행 — 네이버·쿠팡 수집 및 AI 동일상품 판정...`); + + // 2) 폴링 — GET 이 서버측 증분 동기화를 겸함. 요청 시각 이후 이력이 생긴 상품부터 완료 처리. + const total = pending.size; + let found = 0; + let notFound = 0; + const deadline = Date.now() + POLL_TIMEOUT_MS; + while (pending.size > 0 && Date.now() < deadline && !cancelledRef.current) { + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); + for (const id of [...pending]) { + try { + const r = await getLowestPrice(id); + const fresh = (r.results ?? []).find((e) => (e.crawl_end_time ?? '') >= startedAt); + if (!fresh) continue; + pending.delete(id); + if (fresh.success_yn && fresh.lp_price != null) { + found += 1; + pushLog(`[완료] '${nameOf(id)}' 최저가 ${fresh.lp_price.toLocaleString()}원 반영`); + } else { + notFound += 1; + pushLog(`[미발견] '${nameOf(id)}' 동일 상품을 찾지 못함(기존 값 유지)`); + } + } catch { + /* 일시 오류는 다음 tick 재시도 */ + } + setCrawlingProgress(15 + Math.round(((total - pending.size) / total) * 85)); + } + } + + // 3) 마무리 — 목록 갱신(테이블 인터넷 최저가 컬럼 반영) 후 종료 + const timedOut = pending.size > 0 && !cancelledRef.current; + if (timedOut) { + pushLog(`[대기초과] ${pending.size}개 상품은 아직 검색 중 — 완료되면 주기 동기화로 자동 반영됩니다.`); + } + await queryClient.invalidateQueries({ queryKey: ['/v1/item/list'] }); + setCrawlingProgress(100); + if (!cancelledRef.current) { + showToast( + `최저가 수집 완료: 반영 ${found} · 미발견 ${notFound}${timedOut ? ` · 검색중 ${pending.size}(자동 반영 예정)` : ''}`, + found > 0 ? 'success' : 'info', + ); + } + onDone(); + setIsCrawling(false); + onClose(); + setCrawlingProgress(0); + setCrawlerLogs([]); }; return ( @@ -81,7 +134,7 @@ export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose
- 인터넷 최저가 API 실시간 수집 및 동기화 + 인터넷 최저가 실시간 수집 및 동기화
{!isCrawling && (
@@ -116,7 +169,9 @@ export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose
{(prod.price ?? 0).toLocaleString()}원 → - 자동 가공 수집 + + {prod.internet_lowest_price != null ? `${Number(prod.internet_lowest_price).toLocaleString()}원 갱신` : '신규 수집'} +
) : null; @@ -130,7 +185,7 @@ export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose
- 실시간 크롤링 엔진 데이터 동기화 동작 중... + 최저가 검색·수집 진행 중... (닫아도 검색은 계속됩니다) {crawlingProgress}%
@@ -148,7 +203,7 @@ export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose {crawlerLogs.map((log, idx) => (
> - + {log}
@@ -159,7 +214,7 @@ export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose
- "최저가 업데이트 시작" 버튼을 누르시면 실시간 스크래핑 엔진이 시작됩니다. + "인터넷 최저가 가동" 버튼을 누르시면 실시간 수집이 시작됩니다.
)} @@ -167,7 +222,15 @@ export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose {/* Modal Footer */}
-