From 5e24741395cce8a631ef93d0c7ad9a05b9962df9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Tue, 28 Jul 2026 11:56:00 +0900 Subject: [PATCH] =?UTF-8?q?[refactor]=20negodata/front:=20=EC=B5=9C?= =?UTF-8?q?=EC=A0=80=EA=B0=80=20=ED=83=90=EC=83=89=20=EB=AA=A8=EB=8B=AC=20?= =?UTF-8?q?B2B=20=EC=9E=AC=EC=84=A4=EA=B3=84=20+=20=EC=99=84=EB=A3=8C=20?= =?UTF-8?q?=ED=8C=90=EC=A0=95=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 완료 판정 버그(로딩이 안 멈추던 원인 중 하나) 폴링이 crawl_end_time 을 문자열로 비교했다. 서버는 마이크로초 6자리(.755705Z), JS toISOString() 은 밀리초 3자리(.755Z)라 자릿수가 달라 사전순이 시간순과 어긋난다 ('.' < 'Z'). 게다가 클라이언트 시각을 서버 타임스탬프의 기준점으로 써서 브라우저 시계가 조금만 앞서도 어떤 결과도 기준을 넘지 못했다. → 요청 직전 서버가 준 최신 수집 시각을 상품별 기준선으로 읽고 epoch ms 로 비교. UI 재설계 (소비자 앱풍 → B2B 실무 툴) - 상태 3분리: confirm | searching | done. 검색 중에 완료 결론을 섞지 않는다 - 표가 주인공: [선택 | 상품 | 기존 최저가 | 네이버 | 쿠팡 | 결과], 컴팩트 행, tabular-nums - 결과 열은 상태어(탐색 성공 / 변동 없음 / 탐색 실패 / 탐색 중) - 진행 카드: 표시등 + mm:ss 경과 + 진행바 + N/M, 셀 단위 스켈레톤으로 진행 위치 표시 - radius 8/6/4, shadow 최소화, CTA 우측 정렬(풀와이드 금지), 강조는 indigo-800 한 톤 - 완료 후 자동으로 닫지 않는다(결과 확인). 선택 해제는 닫을 때로 미룸 선택 재검색 체크박스로 대상을 고르고, 검색이 끝나면 갱신되지 않은 행만 자동 선택된다 (이미 갱신된 상품에 크롤 비용을 다시 쓰지 않도록). 재검색은 force=true 로 나가 네거티브 캐시를 우회한다. 접근성 (스킬 기준 + 대비 실측) - 보조 텍스트 muted-foreground 4.88:1, indigo-800 9.93:1 (기존 1.92:1 FAIL 해소) - 상태는 색 단독이 아니라 텍스트/굵기/sr-only 병행 - role=status aria-live, 탈출구 3중(X·ESC·오버레이), 버튼 높이 통일 api/generated 는 orval 재생성분. resTargetBreakdown 변경은 이전 백엔드 수정과의 스펙 동기화(이번 작업과 무관하지만 재생성으로 함께 정리됨). Co-Authored-By: Claude Opus 5 (1M context) --- negodata/front/src/api/generated/item/item.ts | 21 +- .../front/src/api/generated/model/index.ts | 4 +- .../api/generated/model/lowestPriceEntry.ts | 2 + .../generated/model/lowestPriceEntryByMall.ts | 9 + ....ts => lowestPriceEntryByMallAnyOfItem.ts} | 2 +- .../api/generated/model/resTargetBreakdown.ts | 2 - .../model/triggerLowestPriceParams.ts | 13 + .../products/components/PriceUpdateModal.tsx | 590 +++++++++++++----- 8 files changed, 459 insertions(+), 184 deletions(-) create mode 100644 negodata/front/src/api/generated/model/lowestPriceEntryByMall.ts rename negodata/front/src/api/generated/model/{resTargetBreakdownTargetPriceMode.ts => lowestPriceEntryByMallAnyOfItem.ts} (62%) create mode 100644 negodata/front/src/api/generated/model/triggerLowestPriceParams.ts 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' ? ( + + ) : ( + + )}