diff --git a/negodata/front/src/api/generated/item/item.ts b/negodata/front/src/api/generated/item/item.ts index 4aa5872..f2660ce 100644 --- a/negodata/front/src/api/generated/item/item.ts +++ b/negodata/front/src/api/generated/item/item.ts @@ -37,7 +37,8 @@ import type { ResItemImage, ResItemList, ResLowestPriceResult, - ResLowestPriceTrigger + ResLowestPriceTrigger, + TriggerLowestPriceParams } from '.././model'; import { customFetch } from '../../mutator/custom-fetch'; @@ -649,12 +650,14 @@ export const useDeleteItem = ,signal?: AbortSignal ) => { return customFetch( - {url: `/v1/item/${itemId}/lowest-price`, method: 'POST', signal + {url: `/v1/item/${itemId}/lowest-price`, method: 'POST', + params, signal }, options); } @@ -662,8 +665,8 @@ export const triggerLowestPrice = ( export const getTriggerLowestPriceMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{itemId: string}, TContext>, request?: SecondParameter} -): UseMutationOptions>, TError,{itemId: string}, TContext> => { + TContext = unknown>(options?: { mutation?:UseMutationOptions>, TError,{itemId: string;params?: TriggerLowestPriceParams}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{itemId: string;params?: TriggerLowestPriceParams}, TContext> => { const mutationKey = ['triggerLowestPrice']; const {mutation: mutationOptions, request: requestOptions} = options ? @@ -675,10 +678,10 @@ const {mutation: mutationOptions, request: requestOptions} = options ? - const mutationFn: MutationFunction>, {itemId: string}> = (props) => { - const {itemId} = props ?? {}; + const mutationFn: MutationFunction>, {itemId: string;params?: TriggerLowestPriceParams}> = (props) => { + const {itemId,params} = props ?? {}; - return triggerLowestPrice(itemId,requestOptions) + return triggerLowestPrice(itemId,params,requestOptions) } @@ -694,11 +697,11 @@ const {mutation: mutationOptions, request: requestOptions} = options ? * @summary 최저가 수집 요청 */ export const useTriggerLowestPrice = (options?: { mutation?:UseMutationOptions>, TError,{itemId: string}, TContext>, request?: SecondParameter} + TContext = unknown>(options?: { mutation?:UseMutationOptions>, TError,{itemId: string;params?: TriggerLowestPriceParams}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, - {itemId: string}, + {itemId: string;params?: TriggerLowestPriceParams}, TContext > => { diff --git a/negodata/front/src/api/generated/model/index.ts b/negodata/front/src/api/generated/model/index.ts index f5f166c..6573f67 100644 --- a/negodata/front/src/api/generated/model/index.ts +++ b/negodata/front/src/api/generated/model/index.ts @@ -84,6 +84,8 @@ export * from './listRequestsParams'; export * from './listSuppliersParams'; export * from './listUsersParams'; export * from './lowestPriceEntry'; +export * from './lowestPriceEntryByMall'; +export * from './lowestPriceEntryByMallAnyOfItem'; export * from './lowestPriceEntryCrawlEndTime'; export * from './lowestPriceEntryFailReason'; export * from './lowestPriceEntryLpName'; @@ -392,7 +394,6 @@ export * from './resTargetBreakdownMdPrice'; export * from './resTargetBreakdownMsg'; export * from './resTargetBreakdownPurchase'; export * from './resTargetBreakdownSelling'; -export * from './resTargetBreakdownTargetPriceMode'; export * from './resWebPacketProtocol'; export * from './resWebPacketProtocolMsg'; export * from './sessionData'; @@ -437,6 +438,7 @@ export * from './supplierItemDataItemCode'; export * from './supplierItemDataItemManufacturer'; export * from './supplierItemDataUpdatedAt'; export * from './targetCandidate'; +export * from './triggerLowestPriceParams'; export * from './userRole'; export * from './userStatus'; export * from './validationError'; diff --git a/negodata/front/src/api/generated/model/lowestPriceEntry.ts b/negodata/front/src/api/generated/model/lowestPriceEntry.ts index 54d102b..105c96f 100644 --- a/negodata/front/src/api/generated/model/lowestPriceEntry.ts +++ b/negodata/front/src/api/generated/model/lowestPriceEntry.ts @@ -8,6 +8,7 @@ import type { LowestPriceEntryLpPrice } from './lowestPriceEntryLpPrice'; import type { LowestPriceEntryFailReason } from './lowestPriceEntryFailReason'; import type { LowestPriceEntryLpName } from './lowestPriceEntryLpName'; import type { LowestPriceEntryLpUrl } from './lowestPriceEntryLpUrl'; +import type { LowestPriceEntryByMall } from './lowestPriceEntryByMall'; import type { LowestPriceEntryCrawlEndTime } from './lowestPriceEntryCrawlEndTime'; /** @@ -20,5 +21,6 @@ export interface LowestPriceEntry { fail_reason?: LowestPriceEntryFailReason; lp_name?: LowestPriceEntryLpName; lp_url?: LowestPriceEntryLpUrl; + by_mall?: LowestPriceEntryByMall; crawl_end_time?: LowestPriceEntryCrawlEndTime; } diff --git a/negodata/front/src/api/generated/model/lowestPriceEntryByMall.ts b/negodata/front/src/api/generated/model/lowestPriceEntryByMall.ts new file mode 100644 index 0000000..a301c21 --- /dev/null +++ b/negodata/front/src/api/generated/model/lowestPriceEntryByMall.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { LowestPriceEntryByMallAnyOfItem } from './lowestPriceEntryByMallAnyOfItem'; + +export type LowestPriceEntryByMall = LowestPriceEntryByMallAnyOfItem[] | null; diff --git a/negodata/front/src/api/generated/model/resTargetBreakdownTargetPriceMode.ts b/negodata/front/src/api/generated/model/lowestPriceEntryByMallAnyOfItem.ts similarity index 62% rename from negodata/front/src/api/generated/model/resTargetBreakdownTargetPriceMode.ts rename to negodata/front/src/api/generated/model/lowestPriceEntryByMallAnyOfItem.ts index baece13..819117e 100644 --- a/negodata/front/src/api/generated/model/resTargetBreakdownTargetPriceMode.ts +++ b/negodata/front/src/api/generated/model/lowestPriceEntryByMallAnyOfItem.ts @@ -5,4 +5,4 @@ * OpenAPI spec version: 0.1.0 */ -export type ResTargetBreakdownTargetPriceMode = string | null; +export type LowestPriceEntryByMallAnyOfItem = { [key: string]: unknown }; diff --git a/negodata/front/src/api/generated/model/resTargetBreakdown.ts b/negodata/front/src/api/generated/model/resTargetBreakdown.ts index 5a7eb00..3a3d7af 100644 --- a/negodata/front/src/api/generated/model/resTargetBreakdown.ts +++ b/negodata/front/src/api/generated/model/resTargetBreakdown.ts @@ -11,7 +11,6 @@ import type { ResTargetBreakdownInternetLowest } from './resTargetBreakdownInter import type { ResTargetBreakdownPurchase } from './resTargetBreakdownPurchase'; import type { ResTargetBreakdownSelling } from './resTargetBreakdownSelling'; import type { TargetCandidate } from './targetCandidate'; -import type { ResTargetBreakdownTargetPriceMode } from './resTargetBreakdownTargetPriceMode'; import type { ResTargetBreakdownChosenBasis } from './resTargetBreakdownChosenBasis'; import type { ResTargetBreakdownAnchoringPrice } from './resTargetBreakdownAnchoringPrice'; @@ -28,7 +27,6 @@ export interface ResTargetBreakdown { margin?: number; anchoring_value?: number; candidates?: TargetCandidate[]; - target_price_mode?: ResTargetBreakdownTargetPriceMode; hidden_price_fields?: string[]; chosen_basis?: ResTargetBreakdownChosenBasis; target_price?: number; diff --git a/negodata/front/src/api/generated/model/triggerLowestPriceParams.ts b/negodata/front/src/api/generated/model/triggerLowestPriceParams.ts new file mode 100644 index 0000000..7be74e3 --- /dev/null +++ b/negodata/front/src/api/generated/model/triggerLowestPriceParams.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type TriggerLowestPriceParams = { +/** + * LPS 네거티브 캐시(24h not_found)를 무시하고 재검색. 사용자가 '다시 검색'을 누른 경우만 true + */ +force?: boolean; +}; diff --git a/negodata/front/src/features/products/components/PriceUpdateModal.tsx b/negodata/front/src/features/products/components/PriceUpdateModal.tsx index 3240c42..4939418 100644 --- a/negodata/front/src/features/products/components/PriceUpdateModal.tsx +++ b/negodata/front/src/features/products/components/PriceUpdateModal.tsx @@ -1,251 +1,499 @@ -import { useRef, useState } from 'react'; +import { useEffect, 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 { Clock, X } from 'lucide-react'; +import { cn } from '@/lib/utils'; import { showToast } from '@/lib/notify'; -import { Typography } from '@/components/ui/typography'; import { Button } from '@/components/ui/button'; +import { Typography } from '@/components/ui/typography'; import { useScrollLock } from '@/lib/useScrollLock'; import { triggerLowestPrice, getLowestPrice } from '@/api/generated/item/item'; +import type { LowestPriceEntryByMall } from '@/api/generated/model/lowestPriceEntryByMall'; import type { Product } from '../types'; type PriceUpdateModalProps = { open: boolean; products: Product[]; selectedIds: string[]; - onDone: () => void; // 완료 시 선택 해제 + onDone: () => void; // 선택 해제 — 결과를 계속 보여줘야 해서 창을 닫을 때 호출한다 onClose: () => void; }; const POLL_INTERVAL_MS = 5_000; // GET lowest-price 폴링 간격(서버가 조회 시 lps_db 증분 동기화를 겸함) const POLL_TIMEOUT_MS = 300_000; // 상품당 수십 초 × 순차 처리 감안한 전체 상한(5분) -// 인터넷 최저가 실시간 수집 모달 — LPS 연동. +// 강조색 — 채도 낮춘 인디고 한 톤만 쓴다(절감액·주 버튼). 대비: #3730a3 on white 9.93:1. +// ⚠️ 앱 전역 --primary(#5e6ad2)보다 어두운 계열이라 이 모달에서만 국소 적용한다. +const ACCENT_TEXT = 'text-indigo-800 dark:text-indigo-300'; +const ACCENT_BTN = + 'bg-indigo-800 text-white hover:bg-indigo-900 dark:bg-indigo-300 dark:text-indigo-950 dark:hover:bg-indigo-200'; + +// 표에 열로 세우는 검색 소스. by_mall 에는 매칭된 몰만 담겨 오므로, 여기 없는 소스는 그 회차에 빈손이었다는 뜻. +// (오픈마켓 폴백은 기본 OFF — 켜지면 이 목록과 아래 ROW_GRID 열 개수를 함께 넓힌다.) +const SOURCES = [ + { key: 'naver', label: '네이버' }, + { key: 'coupang', label: '쿠팡' }, +] as const; + +// 몰별 최저가 = by_mall 을 source 로 묶어 최저가만 남긴 것. 값이 없으면 그 몰은 못 찾은 것. +type MallPrices = Record; + +const toMallPrices = (byMall: LowestPriceEntryByMall): MallPrices => { + const out: MallPrices = {}; + for (const entry of byMall ?? []) { + const source = String(entry.source ?? '').toLowerCase(); + const price = Number(entry.price); + if (!source || !Number.isFinite(price)) continue; + if (out[source] === undefined || price < out[source]) out[source] = price; + } + return out; +}; + +// 상품 1건의 처리 상태(화면 전체 상태와 구분해 ItemState 로 둔다). +type ItemState = + | { kind: 'pending' } // 접수됨 — 결과 대기 + | { kind: 'done'; price: number; malls: MallPrices } + | { kind: 'notfound'; malls: MallPrices } // 동일 상품 판정 실패(기존 값 유지) + | { kind: 'failed'; reason?: string } // 요청 자체가 접수되지 않음 — 재시도 대상 + | { kind: 'timeout' } // 폴링 상한 초과 — 서버는 계속 검색 중 + | { kind: 'stopped' }; // 사용자가 지켜보기를 중단 — 서버는 계속 검색 중 + +// 표 정렬 — 헤더와 본문 행이 같은 그리드를 써야 열이 어긋나지 않는다. +// [상품 | 기존 최저가 | 네이버 | 쿠팡 | 결과] +// ⚠️ Tailwind JIT 는 소스에 리터럴로 적힌 클래스만 생성한다 — SOURCES 를 늘리면 +// 여기 열 개수(5rem 반복)도 함께 손으로 맞춰야 한다(템플릿 문자열 금지). +const ROW_GRID = 'grid grid-cols-[1.5rem_minmax(0,1fr)_5.5rem_5rem_5rem_6.5rem] gap-x-3 px-3'; +const NUM = 'text-right tabular-nums'; // 금액 열은 등폭 숫자로 자릿수를 맞춘다 +// 표 체크박스 — DataTable 과 같은 규격을 쓴다(앱 전역 일관성) +const CHECKBOX = 'h-3.5 w-3.5 rounded border-border text-primary focus:ring-primary cursor-pointer accent-primary'; + +// 경과 시간 mm:ss — 등폭 숫자와 맞물려 자릿수가 흔들리지 않는다(1분 넘어가도 폭 유지). +const fmtClock = (sec: number) => + `${String(Math.floor(sec / 60)).padStart(2, '0')}:${String(sec % 60).padStart(2, '0')}`; +const fmtPrice = (v: number | undefined) => (v && v > 0 ? v.toLocaleString() : '–'); + +// 수집 시각을 epoch ms 로. 파싱 불가/누락이면 0(= 어떤 기준선도 넘지 못함). +const crawlMs = (iso: string | null | undefined) => { + const t = iso ? Date.parse(iso) : NaN; + return Number.isNaN(t) ? 0 : t; +}; +const latestCrawlMs = (entries: { crawl_end_time?: string | null }[] | undefined) => + (entries ?? []).reduce((max, e) => Math.max(max, crawlMs(e.crawl_end_time)), 0); + +// 인터넷 최저가 검색 모달 — LPS 연동. // 흐름: 선택 상품마다 POST(수집 요청, 큐 접수) → GET 폴링(요청 시각 이후의 수집 이력이 생기면 완료). -// 폴링이 시간을 초과해도 서버 검색은 계속되고, 5분 주기 동기화 배치가 결과를 자동 반영한다. +// 폴링이 시간을 초과하거나 사용자가 중단해도 서버 검색은 계속되고, 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); // 닫기 시 폴링 루프 중단(서버 검색은 계속) + const [isSearching, setIsSearching] = useState(false); + const [items, setItems] = useState>({}); + const [elapsedSec, setElapsedSec] = useState(0); // 경과 시간 — "멈춘 건지 오래 걸리는 건지" 판단 근거 + // 검색 시작 시점의 대상 스냅샷. 결과를 계속 띄워둬야 하는데 부모의 selectedIds 는 + // 창을 닫을 때 비워지므로, 표는 이 스냅샷을 그린다. + const [targetIds, setTargetIds] = useState([]); + // 재검색 대상 선택. null = 아직 손대지 않음(전체 선택으로 간주). + // 검색이 끝나면 '성공하지 못한 행'만 남겨 두어, 이미 갱신된 상품에 크롤 비용을 다시 쓰지 않게 한다. + const [checkedIds, setCheckedIds] = useState | null>(null); + const cancelledRef = useRef(false); // 중단/닫기 시 폴링 루프 탈출(서버 검색은 계속) + const closeRef = useRef<() => void>(() => {}); // ESC 핸들러가 최신 닫기 로직을 보게 하는 통로 + + useEffect(() => { + if (!isSearching) return; + const timer = setInterval(() => setElapsedSec((s) => s + 1), 1_000); + return () => clearInterval(timer); + }, [isSearching]); + + // ESC 로 닫기 — X 버튼/푸터 닫기와 함께 탈출구 3중 확보 + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') closeRef.current(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [open]); if (!open) return null; - const pushLog = (line: string) => setCrawlerLogs((prev) => [...prev, line]); - const nameOf = (id: string) => products.find((p) => p.item_id === id)?.name || id; + const rows = targetIds.length > 0 ? targetIds : selectedIds; + // 화면 전체 상태 — 한 화면에는 한 상태만 선언한다(검색 중에 완료 결론을 섞지 않는다). + const status: 'confirm' | 'searching' | 'done' = + targetIds.length === 0 ? 'confirm' : isSearching ? 'searching' : 'done'; - const handleStartCrawling = async () => { - if (selectedIds.length === 0) { - toast.error('업데이트할 상품을 1개 이상 선택해 주십시오.'); - return; - } + const checked = checkedIds ?? new Set(rows); + const allChecked = rows.length > 0 && rows.every((id) => checked.has(id)); + const toggleOne = (id: string, on: boolean) => + setCheckedIds(() => { + const next = new Set(checked); + if (on) next.add(id); + else next.delete(id); + return next; + }); + const toggleAll = (on: boolean) => setCheckedIds(on ? new Set(rows) : new Set()); + const setItem = (id: string, s: ItemState) => setItems((prev) => ({ ...prev, [id]: s })); + const productOf = (id: string) => products.find((p) => p.item_id === id); + const nameOf = (id: string) => productOf(id)?.name || id; + const prevPriceOf = (id: string) => Number(productOf(id)?.internet_lowest_price ?? 0); + const settled = rows.filter((id) => items[id] && items[id].kind !== 'pending').length; + const failedIds = rows.filter((id) => items[id]?.kind === 'failed'); + const failReason = failedIds + .map((id) => (items[id] as { kind: 'failed'; reason?: string }).reason) + .find(Boolean); + + // 한 행의 절감액 — 기존 최저가보다 싸게 찾았을 때만 값이 생긴다. + const savingOf = (id: string) => { + const s = items[id]; + if (s?.kind !== 'done') return 0; + const prev = prevPriceOf(id); + return prev > 0 && s.price < prev ? prev - s.price : 0; + }; + const updatedIds = rows.filter((id) => savingOf(id) > 0); + const totalSaved = updatedIds.reduce((sum, id) => sum + savingOf(id), 0); + const runningIds = rows.filter((id) => ['timeout', 'stopped'].includes(items[id]?.kind ?? '')); + + // 상태 블록·보조 안내는 내용이 있을 때만 그린다 — 빈 요소가 여백만 차지하지 않도록. + const hasStatusBlock = + status === 'searching' || (status === 'done' && (totalSaved > 0 || failedIds.length > 0)); + const note = + status === 'confirm' + ? '상품당 수십 초가 소요됩니다. 미발견 시 기존 최저가가 유지됩니다.' + : status === 'searching' + ? '창을 닫아도 검색은 계속됩니다.' + : failReason + ? `탐색 실패 사유: ${failReason}` + : runningIds.length > 0 + ? `${runningIds.length}개는 서버에서 검색 중입니다. 완료되면 자동 반영됩니다.` + : ''; + + const handleClose = () => { + cancelledRef.current = true; // 진행 중이면 폴링만 중단(서버 검색은 계속 → 주기 동기화로 반영) + if (targetIds.length > 0) onDone(); // 한 번이라도 돌렸으면 선택 해제 + onClose(); + }; + closeRef.current = handleClose; + + // 지켜보기 중단 — 서버 검색까지 취소하는 API 는 없으므로, 화면 감시만 멈추고 그 사실을 문구로 밝힌다. + const handleStop = () => { + cancelledRef.current = true; + setIsSearching(false); + setItems((prev) => { + const next = { ...prev }; + rows.forEach((id) => { + if (next[id]?.kind === 'pending') next[id] = { kind: 'stopped' }; + }); + return next; + }); + }; + + // 주어진 상품들만 검색한다. 재시도에서도 그대로 쓰므로 나머지 행의 결과는 건드리지 않는다. + // force=true 면 LPS 네거티브 캐시(24h not_found)를 무시하고 실제로 다시 크롤한다. + const runSearch = async (ids: string[], force = false) => { cancelledRef.current = false; - setIsCrawling(true); - setCrawlingProgress(5); - setCrawlerLogs([ - '[System] 인터넷 최저가 검색(LPS) 요청 접수 중...', - `[Target] 선택된 ${selectedIds.length}개 상품`, - ]); - const startedAt = new Date().toISOString(); // 이 시각 이후의 수집 이력만 "이번 요청 결과"로 인정 + setIsSearching(true); + setElapsedSec(0); + setItems((prev) => { + const next = { ...prev }; + ids.forEach((id) => delete next[id]); + return next; + }); + // "이번 요청 결과"의 판정 기준선 — 상품별로 '요청 직전의 최신 수집 시각'을 서버 값에서 읽어 둔다. + // 클라이언트 시각(new Date())을 기준으로 쓰면 브라우저 시계가 서버보다 조금만 앞서도 + // 어떤 결과도 기준을 넘지 못해 영영 대기한다. 서버 값끼리 비교해 시계 의존을 없앤다. + const baselineOf = new Map(); - // 1) 상품별 수집 요청(POST) — 실패/중복은 로그로 구분하고 계속 진행 + // 1) 상품별 검색 요청(POST) — 실패/중복은 상태로 구분하고 계속 진행 const pending = new Set(); - for (const id of selectedIds) { + for (const id of ids) { try { - const r = await triggerLowestPrice(id); - if (r.status === 'queued') { + const before = await getLowestPrice(id); + baselineOf.set(id, latestCrawlMs(before.results)); + } catch { + baselineOf.set(id, Date.now()); // 기준선을 못 읽으면 과거 이력을 결과로 오인하지 않도록 보수적으로 + } + try { + const r = await triggerLowestPrice(id, force ? { force: true } : undefined); + // 'duplicated' = 이미 진행 중 → 결과는 폴링으로 같이 받는다 + if (r.status === 'queued' || r.status === 'duplicated') { pending.add(id); - pushLog(`[접수] '${nameOf(id)}' 검색 큐 등록`); - } else if (r.status === 'duplicated') { - pending.add(id); // 이미 진행 중 → 결과는 폴링으로 같이 받는다 - pushLog(`[진행중] '${nameOf(id)}' 이미 검색이 진행 중 — 결과 대기에 합류`); + setItem(id, { kind: 'pending' }); } else { - pushLog(`[불가] '${nameOf(id)}' ${r.message || '검색 서비스 연결 불가'}`); + // 서버가 준 사유를 버리지 않는다 — 실패 원인을 화면에서 읽을 수 있어야 한다 + setItem(id, { kind: 'failed', reason: r.message || '검색 서비스에 연결하지 못했습니다' }); } } catch { - pushLog(`[오류] '${nameOf(id)}' 요청 실패`); + setItem(id, { kind: 'failed', reason: '요청을 보내지 못했습니다(네트워크 오류)' }); } } if (pending.size === 0) { showToast('접수된 상품이 없습니다. 검색 서비스 상태를 확인해 주세요.', 'error'); - setIsCrawling(false); - setCrawlingProgress(0); + setIsSearching(false); return; } - setCrawlingProgress(15); - pushLog(`[Search] ${pending.size}개 상품 크롤링 진행 — 네이버·쿠팡 수집 및 AI 동일상품 판정...`); // 2) 폴링 — GET 이 서버측 증분 동기화를 겸함. 요청 시각 이후 이력이 생긴 상품부터 완료 처리. - const total = pending.size; + const succeeded = new Set(); // 실제로 값이 내려간 행 — 끝나면 선택에서 빼 준다 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); + // 문자열 비교 금지 — 서버는 마이크로초(.755705Z), JS 는 밀리초(.755Z)라 자릿수가 달라 + // 사전순 비교가 시간순과 어긋난다('.' < 'Z'). 반드시 epoch ms 로 환산해 비교한다. + const baseline = baselineOf.get(id) ?? 0; + const fresh = (r.results ?? []).find((e) => crawlMs(e.crawl_end_time) > baseline); if (!fresh) continue; pending.delete(id); + const malls = toMallPrices(fresh.by_mall ?? null); if (fresh.success_yn && fresh.lp_price != null) { found += 1; - pushLog(`[완료] '${nameOf(id)}' 최저가 ${fresh.lp_price.toLocaleString()}원 반영`); + const prev = prevPriceOf(id); + if (prev <= 0 || fresh.lp_price < prev) succeeded.add(id); // 값이 실제로 갱신된 행 + // by_mall 이 비어 오는 옛 이력 대비 — 최소한 대표 최저가는 보이도록 폴백을 채운다. + if (Object.keys(malls).length === 0) malls.etc = fresh.lp_price; + setItem(id, { kind: 'done', price: fresh.lp_price, malls }); } else { - notFound += 1; - pushLog(`[미발견] '${nameOf(id)}' 동일 상품을 찾지 못함(기존 값 유지)`); + setItem(id, { kind: 'notfound', malls }); } } catch { /* 일시 오류는 다음 tick 재시도 */ } - setCrawlingProgress(15 + Math.round(((total - pending.size) / total) * 85)); } } - // 3) 마무리 — 목록 갱신(테이블 인터넷 최저가 컬럼 반영) 후 종료 - const timedOut = pending.size > 0 && !cancelledRef.current; - if (timedOut) { - pushLog(`[대기초과] ${pending.size}개 상품은 아직 검색 중 — 완료되면 주기 동기화로 자동 반영됩니다.`); - } + // 3) 마무리 — 목록만 갱신하고 창은 그대로 둔다(결과를 바로 확인할 수 있어야 하므로 자동으로 닫지 않는다). + if (pending.size > 0 && !cancelledRef.current) pending.forEach((id) => setItem(id, { kind: 'timeout' })); await queryClient.invalidateQueries({ queryKey: ['/v1/item/list'] }); - setCrawlingProgress(100); + // 다음 '다시 검색'의 기본 대상 = 이번에 갱신되지 않은 행. 이미 갱신된 상품엔 크롤 비용을 다시 쓰지 않는다. + setCheckedIds(new Set(ids.filter((id) => !succeeded.has(id)))); if (!cancelledRef.current) { - showToast( - `최저가 수집 완료: 반영 ${found} · 미발견 ${notFound}${timedOut ? ` · 검색중 ${pending.size}(자동 반영 예정)` : ''}`, - found > 0 ? 'success' : 'info', + setIsSearching(false); + if (found === 0) showToast('더 낮은 가격을 찾지 못했습니다.', 'info'); + } + }; + + const handleStart = () => { + if (checked.size === 0) { + toast.error('검색할 상품을 1개 이상 선택해 주십시오.'); + return; + } + setTargetIds([...rows]); // 표는 대상 전체를 계속 보여주고, 검색은 체크된 행만 돈다 + setItems({}); + void runSearch([...checked]); + }; + + // 몰 셀 — 검색 중에는 셀 단위 스피너로 진행 위치를 보여주고, 끝나면 금액을 찍는다. + const renderMallCell = (id: string, source: string) => { + const s = items[id]; + if (!s) return –; + if (s.kind === 'pending') { + // 작은 회전 아이콘은 표 안에서 어수선하게 읽힌다. 값이 들어올 자리를 잡아 두는 + // 스켈레톤 막대로 대신한다(레이아웃 이동도 없음). + return ( + + + 검색 중 + ); } - onDone(); - setIsCrawling(false); - onClose(); - setCrawlingProgress(0); - setCrawlerLogs([]); + const malls = s.kind === 'done' || s.kind === 'notfound' ? s.malls : {}; + const prices = Object.values(malls); + const best = prices.length > 0 ? Math.min(...prices) : null; + const price = malls[source]; + if (price === undefined) return –; + const isBest = price === best; + return ( + + {price.toLocaleString()} + {isBest && (최저가)} + + ); + }; + + // 결과 열 — 완료 상태에서만 각 행의 '처리 결과 상태'를 적는다(검색 중에는 비워 둔다). + // 금액이 아니라 상태어를 쓴다: 가격 자체는 왼쪽 몰 열에 이미 있고, 이 열은 "그래서 어떻게 됐나"를 답한다. + const renderResultCell = (id: string) => { + const s = items[id]; + if (status !== 'done' || !s) return –; + if (savingOf(id) > 0) return 탐색 성공; + switch (s.kind) { + case 'done': + case 'notfound': + // 검색은 정상 수행됐으나 기존보다 낮은 가격이 없었음(미발견 포함) — 값이 안 바뀐 상태. + return 변동 없음; + case 'failed': + return 탐색 실패; + case 'timeout': + case 'stopped': + return 탐색 중; + default: + return –; + } }; return ( -
-
+
{ + if (e.target === e.currentTarget) handleClose(); // 오버레이 클릭으로도 닫기 + }} + > +
- {/* Modal Title */} -
-
- - 인터넷 최저가 실시간 수집 및 동기화 -
- {!isCrawling && ( - - )} -
- - {/* Modal Body */} -
-
- - - - 총 {selectedIds.length}개 품목에 대하여 네이버 쇼핑·쿠팡의 최저가를 수집하고 AI 가 동일 상품을 판정하여 인터넷 최저가 필드로 동기화합니다. 상품당 수십 초가 소요될 수 있습니다. - - -
- - {/* Targeted Items List */} -
- 수집 대상 상품 ({selectedIds.length}) -
- {selectedIds.map((id) => { - const prod = products.find((p) => p.item_id === id); - return prod ? ( -
- {prod.name} -
- {(prod.price ?? 0).toLocaleString()}원 - → - - {prod.internet_lowest_price != null ? `${Number(prod.internet_lowest_price).toLocaleString()}원 갱신` : '신규 수집'} - -
-
- ) : null; - })} -
-
- - {/* Crawling Progress View */} - {isCrawling ? ( -
-
- - - 최저가 검색·수집 진행 중... (닫아도 검색은 계속됩니다) - - {crawlingProgress}% -
- - {/* Progress Bar Container */} -
-
-
- - {/* Terminal Log Output */} -
- {crawlerLogs.map((log, idx) => ( -
- > - - {log} - -
- ))} -
-
- ) : ( -
- - - "인터넷 최저가 가동" 버튼을 누르시면 실시간 수집이 시작됩니다. - -
- )} -
- - {/* Modal Footer */} -
+ {/* 다이얼로그 헤더 — X 는 푸터 버튼과 같은 높이(size-7)로 맞춘다 */} +
+ 인터넷 최저가 검색 +
+ +
+ {/* 결과 표 — 데이터가 주인공이라 맨 위에 둔다 */} +
+
+
+ toggleAll(e.target.checked)} + aria-label="전체 선택" + /> + 상품 + 기존 최저가 + {SOURCES.map(({ key, label }) => ( + {label} + ))} + 결과 +
+ +
+ {rows.length === 0 && ( +
+ 선택된 상품이 없습니다. 목록에서 상품을 선택해 주십시오. +
+ )} + {rows.map((id) => ( + + ))} +
+
+
+ + {/* 상태 요약 — 한 화면에 한 상태만. 표(데이터)가 먼저 오고 진행/결과는 그 아래. + confirm 단계는 별도 문구 없이 표와 하단 안내만으로 충분하다. */} +
+ {status === 'searching' && ( +
+
+ + {/* 동작 중임을 알리는 표시등 — 의미는 옆 문구가 진다(색 단독 의존 방지) */} + + 더 낮은 가격 검색 중 + + + + 경과 시간 + {fmtClock(elapsedSec)} + +
+
+ + + + + {settled} / {rows.length} + +
+
+ )} + + {/* 완료 — 행별 결과는 표의 '결과' 열이 말해주므로, 여기엔 절감액만 남긴다. + 절감이 없으면 시각적으로는 비우고 완료 사실만 스크린리더에 알린다. */} + {status === 'done' && ( +
+ {totalSaved > 0 ? ( + + 총 {totalSaved.toLocaleString()}원 절감 + + ) : ( + 검색이 완료되었습니다. 갱신된 가격은 없습니다. + )} + {failedIds.length > 0 && ( + + 탐색 실패 {failedIds.length}개 + + )} +
+ )} +
+ + {/* 보조 안내 — 할 말이 없으면 아예 그리지 않는다(빈 줄이 여백만 잡는 걸 막는다) */} + {note &&

{note}

} +
+ + {/* 액션 — 풀와이드 금지. 보조는 좌측 텍스트, 주 액션은 우측 */} +
+ - + {status === 'searching' ? ( + + ) : ( + + )}