diff --git a/docker-compose.yml b/docker-compose.yml index a7dde9d..7b37086 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -160,6 +160,9 @@ services: build: context: ./lps dockerfile: Dockerfile.worker + # google-chrome-stable(Linux)은 amd64 전용 → 이미지 자체가 amd64. arm64 맥에선 명시 없으면 + # arm64 로 빌드를 시도하다 Chrome 의존성에서 실패한다(Rosetta 로 에뮬 실행). prod(amd64)에선 무영향. + platform: linux/amd64 container_name: lps-worker environment: APP_ENV: local diff --git a/lps/Dockerfile.worker b/lps/Dockerfile.worker index 795796b..be8a597 100644 --- a/lps/Dockerfile.worker +++ b/lps/Dockerfile.worker @@ -28,9 +28,12 @@ COPY . . # 실값은 compose env 로 주입: DB_*, OPENAI_API_KEY, DECODO_*(포트 포함), NAVER_KEYS. RUN cp config/config.local.toml.example config/config.local.toml +# Chrome 경로는 [WorkerConfig].chrome_executable(설정 파일)이 유일한 소스다. +# 예전에 LPS_CHROME_EXECUTABLE env 를 뒀지만 읽는 코드가 없어 '설정한 줄 알았는데 아니었다'는 +# 오진을 만들었다(2026-07-28 배포서버 크롤 조사) — 죽은 env 는 두지 않는다. +# 컨테이너에서는 config 에 "/opt/google/chrome/chrome" 을 넣어야 --no-sandbox 가 함께 붙는다. ENV APP_ENV=local \ PYTHONUNBUFFERED=1 \ - LPS_CHROME_EXECUTABLE=/opt/google/chrome/chrome \ DISPLAY=:99 \ LPS_HEARTBEAT_FILE=/tmp/lps_worker_heartbeat diff --git a/lps/config/config.local.toml.example b/lps/config/config.local.toml.example index f95981d..83b2056 100644 --- a/lps/config/config.local.toml.example +++ b/lps/config/config.local.toml.example @@ -49,7 +49,12 @@ sslmode = "" # 로컬: "" / 관리형 DB: "require"|"verify-ca"|"ve [WorkerConfig] concurrency = 1 # 상품 동시 검색 수(워커별 브라우저 세트, Chrome 최대 4×N). 로컬 권장 2~3 fallbacks = [] # 오픈마켓 폴백(기본 OFF). 예: ["gmarket", "auction", "st11"] — 켜기 전 라이브 스모크 -profile_dir = ".profiles" # Chrome 프로필 베이스. 영속 경로면 재시작에도 cf_clearance 유지(재웜업 회피) +# Chrome 프로필 베이스. 영속 경로면 재시작에도 쿠키 유지(재웜업 회피). +# ⚠️ 컨테이너로 띄우면 반드시 "/profiles" — compose 가 lps-profiles 볼륨을 그 경로에 마운트한다. +# ".profiles" 로 두면 /app/.profiles(컨테이너 레이어)에 쌓여 재생성 때마다 쿠키가 전부 날아가고, +# 볼륨은 붙어만 있고 아무 일도 하지 않는다(2026-07-28 로컬·배포서버 양쪽에서 실측). +# 호스트 직접 실행(run_local_worker.sh)일 때만 ".profiles" 를 쓴다. +profile_dir = ".profiles" job_deadline_sec = 300 # 잡 1건 처리 상한(크롤 행 방어). 0=무제한(테스트용) shutdown_grace_sec = 60 # graceful 종료 유예 — docker stop_grace_period 를 이보다 길게 chrome_channel = "chrome" # 로컬: 실제 Chrome diff --git a/lps/config/config.prod.toml.example b/lps/config/config.prod.toml.example new file mode 100644 index 0000000..4d67570 --- /dev/null +++ b/lps/config/config.prod.toml.example @@ -0,0 +1,85 @@ +# 배포서버용. 복사해서 사용: cp config.prod.toml.example config.prod.toml +# 실제 config.prod.toml 은 시크릿 포함이라 커밋하지 않는다. +# +# ⚠️ 로드 경로 주의 — docker-compose.prod.yml 이 이 파일을 config.local.toml 자리에 마운트한다: +# ./lps/config/config.prod.toml : /app/config/config.local.toml : ro +# 컨테이너는 APP_ENV=local 로 뜨므로 코드가 읽는 파일명은 config.local.toml 이지만 +# 내용은 이 파일이다. 파일명만 보고 "prod 설정이 안 읽힌다"고 오해하기 쉽다. +# +# 로컬(config.local.toml.example)과 다른 항목만 ★ 로 표시했다. + +[WebServerConfig] +server_name = "LpsServer" +port = 9600 +process_count = 1 +is_ssl = false # 리버스프록시 뒤 — TLS 는 프록시가 종단 +is_test = false # ★ prod +cors_origins = [] # ★ 브라우저가 직접 호출하지 않으면 비움 +api_keys = [""] # ★ prod 는 반드시 채운다(비면 무인증 개방). openssl rand -hex 32 + +[LogConfig] +print_console = true +log_level = "info" # ★ prod + +[MainDBConfig] +db_type = "postgresql" +name = "lps_db" +write_host = "host.docker.internal" # ★ 컨테이너 → 호스트 DB (compose extra_hosts) +write_port = 5432 +write_id = "" +write_pw = "" +read_host = "host.docker.internal" # ★ +read_port = 5432 +read_id = "" +read_pw = "" +show_log = false +pool_size = 10 +max_overflow = 20 +connection_budget = 40 +sslmode = "" # 관리형 DB 면 "require" + +[WorkerConfig] +concurrency = 1 # ★ 동시성을 올리면 차단 시 IP 소모도 비례해 늘어난다 +fallbacks = [] +profile_dir = "/profiles" # ★ 필수 — compose 가 lps-profiles 볼륨을 여기에 마운트한다. + # ".profiles" 로 두면 컨테이너 레이어에 쌓여 재생성마다 쿠키가 날아간다. +job_deadline_sec = 300 +shutdown_grace_sec = 60 +chrome_channel = "chrome" +chrome_executable = "/opt/google/chrome/chrome" # ★ 컨테이너 필수(설정 시 --no-sandbox 가 함께 붙음) +heartbeat_file = "/tmp/lps_worker_heartbeat" + +[AlertConfig] +webhook = "" # ★ Slack 호환 웹훅. 비우면 로그로만 알림 +cooldown_min = 30 +dead_1h = 20 +blocks_1h = 80 +queue_lag_sec = 300 +pool_pct = 90 +source_fail_30m = 5 +deadline_1h = 5 +cost_1h_usd = 1.0 +ports_low_pct = 30 +block_sessions_6h = 1 + +# ── 시크릿 ── + +[NaverConfig] +[[NaverConfig.keys]] +id = "" +secret = "" + +[OpenAIConfig] +api_key = "" # 소진되면 크롤이 성공해도 AI 판정 실패로 잡이 DEAD 된다 +model = "gpt-4o-mini" + +[DecodoConfig] +host = "gate.decodo.com" +username = "" +password = "" +port_start = 10001 +port_end = 10100 +session_minutes = 10 +cost_per_gb = 3.0 +ip_request_budget = 3 # IP당 요청 예산. 차단이 ip_req#1 에 몰리면 이 값과 무관한 문제다 +port_cooldown_sec = 0 # 0=자동 max(sticky, 30분) diff --git a/lps/crud/negative_cache.py b/lps/crud/negative_cache.py index f657045..ce323e9 100644 --- a/lps/crud/negative_cache.py +++ b/lps/crud/negative_cache.py @@ -26,6 +26,21 @@ class NegativeCache: finally: await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_READ.value) + async def drop(self, key: str): + """key 의 not_found 기록을 지운다 — 사용자가 강제 재검색(force)을 요청했을 때. + 캐시 키가 product_code 라 상품명·모델을 고쳐 다시 찾는 경우에도 이걸로 풀어줘야 한다.""" + if not key: + return + s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_WRITE.value) + try: + await s.execute(text("DELETE FROM search_negative WHERE key = :k"), {"k": key}) + await s.commit() + except Exception: + await s.rollback() + raise + finally: + await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_WRITE.value) + async def put(self, key: str, ttl_sec: int = 86400, reason: str = "not_found"): """key 를 ttl_sec 동안 not_found 로 기록(upsert).""" if not key: diff --git a/lps/router/v1/lps/protocol.py b/lps/router/v1/lps/protocol.py index 4097f2d..8deae0c 100644 --- a/lps/router/v1/lps/protocol.py +++ b/lps/router/v1/lps/protocol.py @@ -17,6 +17,7 @@ class SearchItem(BaseModel): specification: str = Field("", description="규격(용량/개입/수량 등)") company: str = Field("", description="제조사/브랜드") price: str = Field("", description="현재가(참고, 문자열)") + force: bool = Field(False, description="네거티브 캐시(24h not_found)를 무시하고 실제로 재검색할지. 사용자가 '다시 검색'을 누른 경우만 true") class Req_Search(Req_WebPacketProtocol): diff --git a/lps/worker/handlers.py b/lps/worker/handlers.py index 21be864..523fd27 100644 --- a/lps/worker/handlers.py +++ b/lps/worker/handlers.py @@ -192,8 +192,16 @@ def build_search_handler( cache_key = payload.get("product_code") or base_query metrics = SearchMetrics(ai_model, proxy_cost_per_gb) # 검색 1건의 리소스/비용/시간 계측 - # 0) 네거티브 캐시 — 최근 not_found면 재검색 생략 - if neg_cache is not None and await neg_cache.is_negative(cache_key): + # 0) 네거티브 캐시 — 최근 not_found면 재검색 생략. + # force=True(사용자가 '다시 검색'을 명시적으로 누름)면 기록을 지우고 실제 검색을 돌린다. + # 캐시 키가 product_code 라, 상품명·모델을 고쳐 재시도하는 경우 이 우회가 없으면 영원히 막힌다. + if neg_cache is not None and payload.get("force"): + await neg_cache.drop(cache_key) + if neg_cache is not None and not payload.get("force") and await neg_cache.is_negative(cache_key): + # 캐시 히트도 '이 잡의 결과'이므로 이력을 남긴다. 남기지 않으면 잡은 완료인데 + # price_history 에 새 행이 없어, 이를 폴링하는 소비자(negodata 최저가 모달)가 + # 결과를 영영 못 받고 로딩만 돈다(실측 버그). + await _record_history(cache_key, job.get("job_id"), "not_found", []) return {"outcome": "not_found", "cached": True, "query": base_query, "rounds_tried": 0, "lowest": None, "top": [], "stages": [], "sources": {}, "metrics": metrics.snapshot()} diff --git a/lps/worker_main.py b/lps/worker_main.py index 7af0cb7..aa25f34 100644 --- a/lps/worker_main.py +++ b/lps/worker_main.py @@ -164,11 +164,19 @@ async def run_ops_monitor(queue, bot_log, stop, interval: float = 30.0, adapters snap["proxy_ports_avail"], snap["proxy_ports_total"] = avail, total await alerts.check("proxy_ports_low", avail * 100 <= total * th.ports_low_pct, f"가용 프록시 포트 {avail}/{total} — 대규모 차단 진행 신호", snap) - # 예산 누수 — 요청 예산을 지켰는데도 차단된 IP 세션 발생 = 현재 예산이 안전하지 않다는 신호. + # 예산 누수 — 요청 예산을 지켰는데도 차단된 IP 세션 발생. + # 처방은 '몇 번째 요청에서 막혔나'로 갈린다: + # ip_req#1 위주 → 새 IP 첫 요청부터 차단 = IP 평판 문제. 예산을 낮춰도 소용없다. + # ip_req#2~ 위주 → 같은 IP 로 너무 많이 긁은 것 = 예산 하향이 유효. + # 예산 하향만 권하면 오진을 부른다(2026-07-28 배포서버 조사에서 전량 ip_req#1 이었다). block_sessions = (await ip_log.recent_stats(360)).get("block", 0) snap["block_sessions_6h"] = block_sessions - await alerts.check("budget_leak", block_sessions >= th.block_sessions_6h, - f"예산 회전에도 차단된 IP 세션 6h={block_sessions} — ip_request_budget 하향 검토", snap) + await alerts.check( + "budget_leak", block_sessions >= th.block_sessions_6h, + f"예산 회전에도 차단된 IP 세션 6h={block_sessions} — " + f"bot_detection.ip_request_no 분포 확인(1 위주면 IP 평판/프록시 대역, 2 이상이면 ip_request_budget 하향)", + snap, + ) # 소스별 장기 실패 — 최근 30분간 시도는 있는데 성공이 0건(쿼터 소진·셀렉터 드리프트·전면 차단 신호) per_source: dict[str, list[int]] = {} for ad in (adapters or []): diff --git a/negodata/backend/common/database/model/models.py b/negodata/backend/common/database/model/models.py index b4fa86f..83344ea 100644 --- a/negodata/backend/common/database/model/models.py +++ b/negodata/backend/common/database/model/models.py @@ -134,6 +134,10 @@ class item_internet_lowest_prices(MainTableMixin, MAIN_BASE): crawl_duration_ms = Column(Integer, nullable=True) # (예약) 수집 소요 — LPS 계약엔 미포함 lp_name = Column(String(300), nullable=True) # 찾은 상품명(판매 페이지 기준) — 근거 검증용 lp_url = Column(String, nullable=True) # 찾은 판매 페이지 링크(TEXT) — 근거 검증용 + # 몰별 최저가 스냅샷 — lps_db.price_history.by_mall 을 그대로 미러링한다(열린 스키마). + # [{source, mall_name, price, shipping_fee, shipping_type, name, detail_url}, ...] 가격 오름차순. + # 매칭된 몰만 들어오므로, 특정 몰이 없으면 그 몰은 빈손이었다는 뜻이다(네이버 실패/쿠팡만 성공 구분). + by_mall = Column(JSONB, nullable=True) crawl_end_time = Column(DateTime(timezone=True), nullable=False) # 수집 완료 시각(=price_history.created_at, 워터마크 기준) diff --git a/negodata/backend/crud/lps_sync_crud.py b/negodata/backend/crud/lps_sync_crud.py index 4d0ffeb..94126e6 100644 --- a/negodata/backend/crud/lps_sync_crud.py +++ b/negodata/backend/crud/lps_sync_crud.py @@ -29,6 +29,7 @@ _price_history = table( column("naver_url"), column("coupang_name"), # 쿠팡 최저가 상품명/링크 column("coupang_url"), + column("by_mall"), # 몰별 최저가 스냅샷(JSONB 배열) — 어느 몰이 건졌고 어느 쪽이 빈손인지 column("created_at"), ) @@ -116,6 +117,7 @@ class LpsSyncCRUD(ILpsSyncCRUD): _price_history.c.naver_url, _price_history.c.coupang_name, _price_history.c.coupang_url, + _price_history.c.by_mall, _price_history.c.created_at, ).order_by(_price_history.c.created_at.asc()) if since is not None: diff --git a/negodata/backend/router/v1/item/item.py b/negodata/backend/router/v1/item/item.py index a0abc14..e011635 100644 --- a/negodata/backend/router/v1/item/item.py +++ b/negodata/backend/router/v1/item/item.py @@ -86,12 +86,13 @@ async def trigger_lowest_price( service: ItemService = Depends(), lps: LpsSyncService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken), + force: bool = Query(False, description="LPS 네거티브 캐시(24h not_found)를 무시하고 재검색. 사용자가 '다시 검색'을 누른 경우만 true"), ): """상품 1건을 LPS 에 즉시 검색 요청(수동 트리거, manual 우선순위). 결과는 GET lowest-price 폴링.""" got = await service.get_item(user_info.company_id, str(item_id)) # 존재/소유(company 스코프) 확인 if got.item is None: return RemoveNoneResponse(got) - status, message = await lps.request_search_for_item(got.item) + status, message = await lps.request_search_for_item(got.item, force=force) res = Res_LowestPriceTrigger(item_id=str(item_id), status=status, message=message) if status == "unavailable": res.result.SetResult(ErrorType.LPS_UNAVAILABLE) diff --git a/negodata/backend/router/v1/item/protocol.py b/negodata/backend/router/v1/item/protocol.py index e00d98c..1ef773d 100644 --- a/negodata/backend/router/v1/item/protocol.py +++ b/negodata/backend/router/v1/item/protocol.py @@ -145,6 +145,9 @@ class LowestPriceEntry(WebPacketProtocol): fail_reason: Optional[str] = None lp_name: Optional[str] = None # 찾은 상품명(판매 페이지 기준) — 근거 검증용 lp_url: Optional[str] = None # 찾은 판매 페이지 링크 + # 몰별 최저가 스냅샷(가격 오름차순). 매칭된 몰만 들어오므로, 없는 몰은 그 회차에 빈손이었다는 뜻. + # [{source: 'naver'|'coupang'|…, mall_name, price, shipping_fee, shipping_type, name, detail_url}] + by_mall: Optional[list[dict]] = None crawl_end_time: Optional[datetime] = None diff --git a/negodata/backend/services/lps_sync_service.py b/negodata/backend/services/lps_sync_service.py index 2325ce1..9553cd7 100644 --- a/negodata/backend/services/lps_sync_service.py +++ b/negodata/backend/services/lps_sync_service.py @@ -57,8 +57,10 @@ class LpsSyncService: return DB_SESSION_MNG.is_registered(DBType.LPS.value) # ---- 단건 즉시 요청 (lowest-price 트리거 API 용) --------------------- - async def request_search_for_item(self, item) -> tuple: + async def request_search_for_item(self, item, force: bool = False) -> tuple: """상품 1건을 즉시 LPS 에 검색 요청(수동 트리거 — job_type=manual, 배치보다 높은 우선순위). + force=True 면 LPS 의 네거티브 캐시(24h not_found)를 무시하고 실제로 재검색한다 + (사용자가 '다시 검색'을 누른 경우. 상품명·모델을 고쳐 재시도하는 흐름에 필요). 반환: (status, message) — queued | duplicated | unavailable.""" if not self.available(): return "unavailable", "LPS 연동이 비활성 상태입니다(설정 없음)" @@ -70,6 +72,7 @@ class LpsSyncService: "specification": item.spec or "", "company": item.manufacturer or "", "price": str(item.price) if item.price else "", + "force": force, } base = web_server_config.lps_base_url.rstrip("/") # LPS API guard: prod 는 lps_api_key 를 채워 X-API-Key 로 인증(개발은 빈값=개방 모드). @@ -131,7 +134,7 @@ class LpsSyncService: # product_code(uuid=item_id) 검증 — LPS 부하테스트 등 비상품 코드는 조용히 스킵 parsed = [] - for code, outcome, final_lowest, final_source, nv_name, nv_url, cp_name, cp_url, created_at in rows: + for code, outcome, final_lowest, final_source, nv_name, nv_url, cp_name, cp_url, by_mall, created_at in rows: try: iid = uuid.UUID(code) except (ValueError, AttributeError, TypeError): @@ -142,7 +145,7 @@ class LpsSyncService: "naver": (nv_name, nv_url), "coupang": (cp_name, cp_url), }.get((final_source or "").lower(), (None, None)) - parsed.append((iid, outcome, final_lowest, final_source, src_name, src_url, created_at)) + parsed.append((iid, outcome, final_lowest, final_source, src_name, src_url, by_mall, created_at)) err, existing = await DB_SESSION_MNG.execute_lambda( DBType.MAIN.value, DBWRType.DB_READ.value, @@ -152,7 +155,7 @@ class LpsSyncService: return results history_rows, latest_found = [], {} # latest_found: item_id → (created_at, price) - for item_id, outcome, final_lowest, final_source, src_name, src_url, created_at in parsed: + for item_id, outcome, final_lowest, final_source, src_name, src_url, by_mall, created_at in parsed: if item_id not in existing: results["skipped_unknown_item"] += 1 continue @@ -165,6 +168,7 @@ class LpsSyncService: fail_reason=None if found else (outcome or "unknown")[:100], lp_name=(src_name or None) and src_name[:300], lp_url=src_url or None, + by_mall=by_mall or None, # 몰별 스냅샷 그대로 미러링 — 몰별 성공/실패 표시용 crawl_end_time=created_at, # 워터마크 기준값 — price_history.created_at 그대로 보존 )) results["found" if found else "not_found"] += 1 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/LowestPriceHistorySheet.tsx b/negodata/front/src/features/products/components/LowestPriceHistorySheet.tsx index 7d21e10..8d1b671 100644 --- a/negodata/front/src/features/products/components/LowestPriceHistorySheet.tsx +++ b/negodata/front/src/features/products/components/LowestPriceHistorySheet.tsx @@ -1,11 +1,12 @@ import type { ReactNode } from 'react'; -import { ChartSpline, ExternalLink, Loader2, TrendingDown, TrendingUp } from 'lucide-react'; -import { CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts'; +import { CartesianGrid, Line, LineChart, ReferenceLine, XAxis, YAxis } from 'recharts'; import { ChartContainer, ChartTooltip, type ChartConfig } from '@/components/ui/chart'; import { Badge } from '@/components/ui/badge'; import { Card } from '@/components/ui/card'; import { Sheet } from '@/components/ui/sheet'; -import { Typography } from '@/components/ui/typography'; +import { cn } from '@/lib/utils'; +import { showToast } from '@/lib/notify'; +import { useLabels } from '@/features/settings/useCompanySettings'; import { useGetLowestPrice } from '@/api/generated/item/item'; import type { LowestPriceEntry } from '@/api/generated/model'; import type { Product } from '../types'; @@ -16,201 +17,436 @@ type LowestPriceHistorySheetProps = { }; // LowestPriceWebsite 코드(백엔드 common/enums.py) → 표시 라벨 -const WEBSITE_LABEL: Record = { - 1: '네이버', - 2: '쿠팡', - 3: 'G마켓', - 4: '옥션', - 5: '11번가', - 99: '기타', -}; +const WEBSITE_LABEL: Record = { 1: '네이버', 2: '쿠팡', 3: 'G마켓', 4: '옥션', 5: '11번가', 99: '기타' }; const websiteLabel = (code?: number) => WEBSITE_LABEL[code ?? 99] ?? '기타'; -// 단일 시리즈(인터넷 최저가) — 가격 UI 컨벤션인 rose 를 라이트/다크 쌍으로(통계 팔레트와 동일 문법). +// by_mall 의 source 키 → 라벨. 기본 두 몰은 결과가 없어도 행을 만들어 "빈손"임을 드러낸다. +const BASE_SOURCES = ['naver', 'coupang'] as const; +const SOURCE_LABEL: Record = { + naver: '네이버', coupang: '쿠팡', gmarket: 'G마켓', auction: '옥션', st11: '11번가', +}; + +// 액센트는 브랜드 인디고(--primary) 하나로 통일한다. rose 는 파괴적 액션 뉘앙스라 쓰지 않는다. +// 대비: #5e6ad2 on white 4.7:1 / #8b93e8 on dark card 6.06:1 (둘 다 AA). const chartConfig = { - price: { label: '인터넷 최저가', theme: { light: '#f43f5e', dark: '#fb7185' } }, + // 브랜드 토큰을 그대로 넘긴다 — 범례는 차트 밖(=--color-price 스코프 밖)이라 + // 하드코딩 색을 쓰면 둘이 어긋난다. 같은 var 를 보게 해서 항상 일치시킨다. + price: { label: '최저가', color: 'var(--primary)' }, + // 단가 기준선 — 흰 배경 5.03:1(AA). 다크는 amber-400. + contract: { label: '단가 기준선', theme: { light: '#bb4d00', dark: '#fbbf24' } }, } satisfies ChartConfig; -const won = (n: number) => `₩${Math.round(n).toLocaleString()}`; -const timeLabel = (iso: string) => - new Date(iso).toLocaleString('ko-KR', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }); +// 꺾은선 점 3종의 모양 정의 — 차트(PricePoint)와 범례(DotLegend)가 이 한 곳을 공유한다. +// 색은 전역 토큰(var(--primary)/var(--card))이라 차트 안팎 어디서든 동일하게 그려진다. +const DOT = { + normal: (cx: number, cy: number) => , + peak: (cx: number, cy: number) => ( + + ), + latest: (cx: number, cy: number) => ( + <> + + + + ), +} as const; -/** 견적 상세 드로어와 같은 문법의 섹션 카드 — 11px 볼드 타이틀 + 하단 구분선. */ -function SectionCard({ title, action, children }: { title: string; action?: ReactNode; children: ReactNode }) { +// ── 타이포 스케일 — 이 화면에서 쓰는 크기는 이 7단계뿐이다(임의 크기 금지) ── +const T = { + hero: 'text-[32px] font-bold leading-none tracking-tight', // 합계 (화면당 1개) + major: 'text-[18px]', // 통계값 + title: 'text-[15px] font-medium', // 카드 제목 + body: 'text-[13px]', // 본문 — 라벨·상품명·경고·이력 시각·금액 + meta: 'text-xs', // 보조 — 메타·서브라인 (12px) + fine: 'text-[11px]', // 부가 — 배송비 주석·차트 눈금 + tick: 11, +} as const; + +const num = (n: number) => Math.round(n).toLocaleString(); +/** 이력용 짧은 타임스탬프 — 24시간제라 등폭 숫자와 맞물려 폭이 흔들리지 않는다. */ +const stampLabel = (iso: string) => { + const d = new Date(iso); + return `${d.getMonth() + 1}.${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; +}; +const clockLabel = (iso: string) => { + const d = new Date(iso); + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; +}; +/** 금액에 붙는 배송비 주석. 금액은 '이미 배송비가 더해진 합계'이므로 + * `+3,500` 처럼 연산으로 읽히는 표기를 쓰지 않는다(더 더해야 하나? 로 오해된다). */ +const shipNote = (shipping: number | null) => + shipping == null ? '배송비 미상' : shipping === 0 ? '무료배송' : `배송비 포함(${num(shipping)}원)`; + +/** Y축 눈금을 1·2·5 × 10ⁿ 단위로 떨어뜨린다 — 1원 단위 눈금이 나오지 않게. */ +function niceScale(min: number, max: number, target = 4) { + let lo = min; + let hi = max; + if (hi <= lo) { lo = min * 0.95; hi = max * 1.05; } // 값이 하나뿐이거나 전부 같을 때 + const mag = Math.pow(10, Math.floor(Math.log10(Math.max((hi - lo) / target, 1)))); + const step = [1, 2, 5, 10].map((m) => m * mag).find((s) => s >= (hi - lo) / target) ?? 10 * mag; + const from = Math.floor(lo / step) * step; + const to = Math.ceil(hi / step) * step; + const ticks: number[] = []; + for (let v = from; v <= to + step / 2; v += step) ticks.push(Math.round(v)); + return { domain: [from, to] as [number, number], ticks }; +} + +// 몰 1건의 가격 구성. shipping=null 은 "배송비를 모른다"(0원이 아니다). +// 네이버 쇼핑 API 는 배송비를 주지 않아 항상 null 이다 — 실측 39/39. +type Mall = { source: string; label: string; price: number; shipping: number | null; name?: string; url?: string }; + +const readMalls = (e: LowestPriceEntry): Mall[] => { + const out: Mall[] = []; + for (const raw of e.by_mall ?? []) { + const source = String(raw.source ?? '').toLowerCase(); + const price = Number(raw.price); + if (!source || !Number.isFinite(price)) continue; + // shipping_type 이 있어야 배송비를 신뢰한다. 없으면 미상(null) — 0원으로 뭉개면 무료배송처럼 읽힌다. + const shipping = raw.shipping_type != null ? Number(raw.shipping_fee ?? 0) : null; + out.push({ + source, + label: SOURCE_LABEL[source] ?? String(raw.mall_name ?? source), + price, + shipping: Number.isFinite(shipping as number) ? shipping : null, + name: raw.name ? String(raw.name) : undefined, + url: raw.detail_url ? String(raw.detail_url) : undefined, + }); + } + return out.sort((a, b) => a.price - b.price); +}; + +/** 수집 1건을 화면용으로 정리. total 은 배송비를 아는 경우에만 값이 생긴다. */ +function readEntry(e: LowestPriceEntry) { + const ok = !!e.success_yn && e.lp_price != null; + const malls = readMalls(e); + const itemPrice = ok ? Number(e.lp_price) : null; + const winner = malls[0]; // 최저가를 낸 몰(가격 오름차순 첫 행). 옛 이력엔 by_mall 이 없어 undefined + const shipping = winner?.shipping ?? null; + return { + entry: e, ok, malls, winner, itemPrice, shipping, + total: itemPrice != null && shipping != null ? itemPrice + shipping : null, + }; +} +/** 섹션 카드 — 제목 + 우측 메타 + 보조 설명 한 줄. */ +function SectionCard({ title, meta, sub, children }: { title: ReactNode; meta?: ReactNode; sub?: ReactNode; children: ReactNode }) { return ( - -
- {title} - {action} + +
+ {title} + {meta}
- {children} + {sub &&
{sub}
} +
{children}
); } -// 인터넷 최저가 상세 시트 — 대표값·출처(사이트/상품명/링크)·가격 추이 그래프·수집 이력. -// 협상 근거로 쓰는 값이므로 "어디서 찾았는지"를 클릭 한 번으로 검증할 수 있게 한다. +// 인터넷 최저가 상세 — 판매처별 실구매가·가격 추이·수집 이력. +// 협상 근거로 쓰는 값이므로 "어디서 얼마에 찾았는지"를 클릭 한 번으로 검증할 수 있게 한다. export function LowestPriceHistorySheet({ product, onClose }: LowestPriceHistorySheetProps) { // GET 이 서버에서 lps_db 증분 동기화를 겸하므로, 열 때마다 최신 상태가 온다. const { data, isLoading } = useGetLowestPrice(product.item_id); + const labels = useLabels(); // 회사가 '상품 단가'를 '계약 단가' 등으로 바꿔 쓸 수 있다 + const priceLabel = labels('item.price'); - const entries = data?.results ?? []; - const successes = entries.filter((e): e is LowestPriceEntry & { lp_price: number } => !!e.success_yn && e.lp_price != null); + const rows = (data?.results ?? []).map(readEntry); + const successes = rows.filter((r) => r.ok); const latest = successes[0]; // API 가 최신순으로 준다 - const representative = data?.lowest_price ?? product.internet_lowest_price ?? latest?.lp_price ?? null; - const diff = product.price != null && representative != null ? Number(representative) - product.price : null; + const missCount = rows.length - successes.length; - // 그래프는 시간 오름차순(성공 수집만). 점 1개는 추이가 아니므로 2건부터 그린다. + // 헤드라인 — 합계가 주인공. 배송비를 모르면 상품가로 라벨을 바꿔 '배송비 포함'을 참칭하지 않는다. + const heroValue = latest ? (latest.total ?? latest.itemPrice) : null; + const heroIsTotal = latest?.total != null; + const diff = product.price != null && heroValue != null ? heroValue - product.price : null; + + // 검색어(= 최저가의 근거가 된 상품명) — 판매처에서 다시 찾아볼 때 그대로 붙여넣을 수 있게 복사를 붙인다. + const queryText = latest?.entry.lp_name || latest?.winner?.name || ''; + const copyQuery = async () => { + try { + await navigator.clipboard.writeText(queryText); + showToast('검색어를 복사했습니다.', 'success'); + } catch { + showToast('복사할 수 없습니다. 텍스트를 직접 선택해 주세요.', 'error'); + } + }; + + // ── 추이는 '상품가' 단일 기준으로만 그린다. + // 합계(=상품가+배송비)는 배송비를 아는 회차에만 값이 생겨(네이버는 항상 미제공) + // 기준을 바꾸면 '값의 의미'와 '표본 수'가 동시에 변한다 — 선 모양의 변화가 + // 가격 변동인지 표본 변화인지 구분이 안 된다. 상품가는 모든 성공 수집에 존재해 + // 추이가 왜곡되지 않고, 기준선(상품 단가)·목표가 산정과도 단위가 같다. + // 실구매가(합계)는 '지금 얼마에 살 수 있나'라서 요약 카드가 맡는다. const chartData = [...successes] .reverse() - .map((e) => ({ t: e.crawl_end_time ?? '', price: e.lp_price })); + .map((r) => ({ t: r.entry.crawl_end_time ?? '', price: r.itemPrice! })); + const values = chartData.map((d) => d.price); + const stats = values.length + ? { min: Math.min(...values), max: Math.max(...values), avg: Math.round(values.reduce((a, b) => a + b, 0) / values.length) } + : null; + const maxAt = stats ? values.lastIndexOf(stats.max) : -1; + const scale = stats + ? niceScale(Math.min(stats.min, product.price ?? stats.min), Math.max(stats.max, product.price ?? stats.max)) + : null; return ( -
- {/* ── 헤드라인: 상품명 · 대표가 · 단가 대비 칩 ── */} -
- {product.name} -
- - {representative != null ? won(Number(representative)) : '수집 전'} - - {diff != null && ( - - {diff <= 0 ? : } - 단가 대비 {diff > 0 ? '+' : diff < 0 ? '-' : ''}{won(Math.abs(diff))} - - )} -
- - 상품 단가 {product.price != null ? won(product.price) : '-'} - {latest?.crawl_end_time && <> · 마지막 수집 {timeLabel(latest.crawl_end_time)}} - -
+
- {/* ── 출처 — 최신 성공 수집의 사이트/상품명/링크 ── */} - {latest && ( - {websiteLabel(latest.website)}} - > - {latest.lp_name ? ( - {latest.lp_name} - ) : ( - 출처 상세 미수집(이전 버전 수집분) - )} - {latest.lp_url && ( - - 판매 페이지에서 확인 - - )} - - )} + {/* ── 요약: 판매처별 내역 → 합계 → 단가 대비 → 출처를 한 섹션으로 ── */} + + {latest ? ( + <> + {/* 결론 먼저 — 카드를 열면 여기에 시선이 먼저 닿아야 한다. + 면은 브랜드보다 채도를 낮춘 인디고 계열 표면 토큰(--accent, #ececf4). + 이 면 위에서는 muted-foreground(4.16:1)·amber-700(4.28:1)이 AA 미달이라 + 라벨은 accent-foreground(9.85:1), 판정은 800 계열(6.0~6.5:1)로 한 단계 진하게 쓴다. */} +
+
+ + {heroIsTotal ? '합계 (배송비 포함)' : '상품가 (배송비 미포함)'} + + {latest.entry.crawl_end_time && ( + + {clockLabel(latest.entry.crawl_end_time)} 수집 + + )} +
+

+ {num(heroValue!)} + 원 +

+ {diff != null && ( +

0 ? 'text-amber-800 dark:text-amber-400' : 'text-emerald-800 dark:text-emerald-300', + )} + > + {priceLabel} {num(product.price!)}원보다{' '} + + {num(Math.abs(diff))}원 {diff > 0 ? '높습니다' : '낮습니다'} + +

+ )} +
+ +
+ +
+ + {/* 검색어 — 이 가격의 근거가 된 상품이 무엇인지 밝힌다 */} +
+

검색어

+
+

+ {queryText || '출처 상세 미수집(이전 버전 수집분)'} +

+ {queryText && ( + + )} +
+
+ + {/* 출처 — 판매 페이지(수집 시각은 히어로 우측으로 올렸다) */} +
+ {(latest.entry.lp_url || latest.winner?.url) && ( + + )} + {/* 화면의 주인공(합계)과 시스템이 쓰는 값(상품가)이 다르므로 숨기지 않고 밝힌다 */} + {heroIsTotal && latest.shipping! > 0 && ( +

+ 목표가 산정에는 배송비를 뺀 상품가 {num(latest.itemPrice!)}원이 사용됩니다. +

+ )} + {!heroIsTotal && ( +

+ 판매처가 배송비를 제공하지 않아 상품가만 표시합니다. +

+ )} +
+ + ) : ( +

+ 아직 수집된 최저가가 없습니다. {priceLabel} {product.price != null ? `${num(product.price)}원` : '-'} +

+ )} +
{/* ── 가격 추이 ── */} 0 ? ( - {successes.length}회 수집 - ) : undefined + meta={상품가 기준} + sub={ + <> + 배송비 제외 · 성공 {successes.length}회 + {missCount > 0 && ` · 미발견 ${missCount}건 제외`} + } > - {chartData.length >= 2 ? ( - - - - - Number(v).toLocaleString()} - fontSize={10} - /> - } /> - - - + {chartData.length >= 2 && scale ? ( + <> + + + + {/* 커스텀 tick 은 recharts 의 눈금 정렬 보정(가장자리 앵커 조정)을 건너뛰어 + 점과 라벨이 어긋난다 — 기본 렌더러에 포맷터만 준다. */} + + num(Number(v))} + fontSize={T.tick} + /> + {/* 계약/상품 단가 기준선 — 최저가가 이 선 위에 있으면 사는 게 손해다 */} + {product.price != null && ( + + )} + } /> + {/* 기준 토글로 데이터가 통째로 바뀌므로 재생 애니메이션은 끈다(점이 튀어 보인다) */} + } + activeDot={{ r: 6 }} + /> + + + + {stats && ( +
+ {([['최저', stats.min], ['평균', stats.avg], ['최고', stats.max]] as const).map(([label, v]) => ( +
+
{label}
+
{num(v)}
+
+ ))} +
+ )} + + {/* 점 모양에 뜻이 있으므로 범례로 밝힌다 */} + + ) : ( -
- - - {successes.length === 1 - ? '수집이 2회 이상 쌓이면 가격 추이 그래프가 표시됩니다.' - : '성공한 수집이 쌓이면 가격 추이 그래프가 표시됩니다.'} - -
+

+ {chartData.length === 1 + ? '수집이 2회 이상 쌓이면 가격 추이 그래프가 표시됩니다.' + : '성공한 수집이 쌓이면 가격 추이 그래프가 표시됩니다.'} +

)}
- {/* ── 수집 이력 ── */} - + {/* ── 수집 이력 (최근 10건 고정 — 서버가 limit=10 으로 내려준다) ── */} + 0 ? 최근 {rows.length}건 : undefined} + sub={rows.length > 0 ? `성공 ${successes.length}건 · 미발견 ${missCount}건` : undefined} + > {isLoading ? ( -
- - 이력을 불러오는 중... -
- ) : entries.length === 0 ? ( -
- - - 수집 이력이 없습니다 — 상품 목록에서 "최저가 업데이트하기"로 수집을 시작하세요. - -
+

이력을 불러오는 중...

+ ) : rows.length === 0 ? ( +

+ 수집 이력이 없습니다 — 상품 목록에서 "최저가 업데이트하기"로 수집을 시작하세요. +

) : ( -
    - {entries.map((e, i) => ( -
  • -
    - - {websiteLabel(e.website)} - - - {e.crawl_end_time ? timeLabel(e.crawl_end_time) : '-'} +
      + {rows.map((r, i) => { + // 이력은 언제나 '실제 지불액' 기준 — 위 토글에 영향받지 않는다. + const paid = r.total ?? r.itemPrice; + const url = r.entry.lp_url || r.winner?.url; + return ( + // 열 폭을 고정해 금액이 배송 주석 길이에 밀리지 않게 한다(모든 행의 금액 우측이 한 줄). +
    • + + {r.entry.crawl_end_time ? stampLabel(r.entry.crawl_end_time) : '-'} -
    -
    - {e.success_yn && e.lp_price != null ? ( - {won(e.lp_price)} + {r.ok ? ( + <> + + + {r.winner?.label ?? websiteLabel(r.entry.website)} + + + {/* 금액과 배송비 주석을 세로로 쌓는다 — 가로로 붙이면 주석이 길어질수록 + 금액 시작점이 밀리고, 열을 넓히면 다른 열을 잡아먹는다. */} + + {num(paid!)} + + {shipNote(r.shipping)} + + + {url ? ( + + 링크 이동 + + ) : ( + + )} + ) : ( - 미발견 + // 값이 없는 행은 열을 쪼개 봐야 빈칸만 남는다 — 시각을 뺀 나머지 폭에 사유를 가운데 둔다. + + 조건에 맞는 상품 없음 + )} - {e.lp_url ? ( - - - - ) : ( - /* 링크 없는 행도 가격 우측 정렬 유지 */ - )} -
    -
  • - ))} + + ); + })}
)}
@@ -219,14 +455,97 @@ export function LowestPriceHistorySheet({ product, onClose }: LowestPriceHistory ); } -// 툴팁 — 시각 + 가격(텍스트 토큰, 시리즈색은 마크에만) +/** 점 모양 범례 — 채움/속 빔/크기가 각각 뜻이 있으므로 밝힌다. + * 스와치는 DOT 정의를 그대로 써서 차트와 모양·색이 어긋날 수 없다. */ +function DotLegend({ showPeak }: { showPeak: boolean }) { + const items = [ + { key: 'normal', label: '수집 시점', draw: DOT.normal }, + ...(showPeak ? [{ key: 'peak', label: '기간 내 최고가', draw: DOT.peak }] : []), + { key: 'latest', label: '최근 수집(현재값)', draw: DOT.latest }, + ]; + return ( +
+
    + {items.map(({ key, label, draw }) => ( +
  • + + {draw(9, 9)} + + {label} +
  • + ))} +
+
+ ); +} + +/** 꺾은선 점 — 마지막 수집은 크게(현재값), 최고가는 속 빈 원으로 구분한다. */ +function PricePoint(props: { cx?: number; cy?: number; index?: number; lastIndex: number; maxIndex: number }) { + const { cx, cy, index = 0, lastIndex, maxIndex } = props; + if (cx == null || cy == null) return ; + if (index === lastIndex) return {DOT.latest(cx, cy)}; + if (index === maxIndex) return {DOT.peak(cx, cy)}; + return {DOT.normal(cx, cy)}; +} + +/** 판매처별 내역 — 상품가가 싼 곳과 배송비까지 더해 실제로 싼 곳이 다를 수 있어 둘 다 표시한다. */ +function MallTable({ malls }: { malls: Mall[] }) { + if (malls.length === 0) return null; + const keys = [...malls.map((m) => m.source), ...BASE_SOURCES.filter((s) => !malls.some((m) => m.source === s))]; + const bestItem = Math.min(...malls.map((m) => m.price)); + const totals = malls.filter((m) => m.shipping != null).map((m) => m.price + m.shipping!); + const bestTotal = totals.length > 0 ? Math.min(...totals) : null; + const unknown = malls.some((m) => m.shipping == null); + + return ( +
+
+ 판매처 + 상품가 + 배송비 + 합계 +
+ {keys.map((k) => { + const m = malls.find((x) => x.source === k); + const total = m && m.shipping != null ? m.price + m.shipping : null; + return ( +
+ {SOURCE_LABEL[k] ?? k} + {m ? ( + <> + + {num(m.price)} + + + {m.shipping == null ? '미상' : m.shipping === 0 ? '무료' : num(m.shipping)} + + + {total != null ? num(total) : '—'} + + + ) : ( + 미발견 + )} +
+ ); + })} + {unknown && ( +

+ 배송비를 제공하지 않는 판매처가 있어 합계 비교가 완전하지 않습니다. +

+ )} +
+ ); +} + +// 툴팁 — 시각 + 금액 function PriceTooltip({ active, payload }: { active?: boolean; payload?: { payload: { t: string; price: number } }[] }) { if (!active || !payload?.length) return null; const p = payload[0].payload; return ( -
- {timeLabel(p.t)} - {won(p.price)} +
+

{stampLabel(p.t)}

+

{num(p.price)}원

); } 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' ? ( + + ) : ( + + )}
diff --git a/postgres-init/init-data/init.sql b/postgres-init/init-data/init.sql index 7074e97..e2c7d14 100644 --- a/postgres-init/init-data/init.sql +++ b/postgres-init/init-data/init.sql @@ -186,6 +186,7 @@ CREATE TABLE IF NOT EXISTS partner.item_internet_lowest_prices ( crawl_duration_ms INTEGER NULL, lp_name VARCHAR(300) NULL, -- [2026-07-10] 찾은 상품명(판매 페이지 기준) lp_url TEXT NULL, -- [2026-07-10] 찾은 판매 페이지 링크(근거 검증용) -- 크롤링 소요 시간(ms) + by_mall JSONB NULL, -- [2026-07-28] 몰별 최저가 스냅샷(lps_db.price_history.by_mall 미러링, 가격 오름차순) crawl_end_time TIMESTAMPTZ NOT NULL, -- 크롤링 종료 시각 created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC) updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신) @@ -683,6 +684,10 @@ ALTER TABLE partner.suppliers ADD COLUMN IF NOT EXISTS custom JSONB NULL; -- [2026-07-22] 협상완료 부가정보(표준납기/MOQ/발주배수/배송유형) — 회사 정의(session_fields) 값 저장(#5) ALTER TABLE negotiation.sessions ADD COLUMN IF NOT EXISTS custom JSONB NULL; +-- [2026-07-28] 몰별 최저가 스냅샷 — 네이버/쿠팡 중 어느 쪽이 건졌고 어느 쪽이 빈손인지 UI 에 표시하기 위해 +-- lps_db.price_history.by_mall 을 그대로 미러링한다(열린 스키마 — 폴백몰이 늘어도 스키마 변경 불필요). +ALTER TABLE partner.item_internet_lowest_prices ADD COLUMN IF NOT EXISTS by_mall JSONB NULL; + -- ============================================================ -- LPS (인터넷 최저가 검색) — 별도 database lps_db