[fix/feat] 인터넷 최저가(LPS) — 로딩 무한 버그 수정 · 몰별 최저가 노출 · 탐색/상세 UI 재설계 (#10)
This commit is contained in:
commit
30f134836b
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
85
lps/config/config.prod.toml.example
Normal file
85
lps/config/config.prod.toml.example
Normal file
@ -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 = ["<API_KEY>"] # ★ 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 = "<DB_USER>"
|
||||
write_pw = "<DB_PASSWORD>"
|
||||
read_host = "host.docker.internal" # ★
|
||||
read_port = 5432
|
||||
read_id = "<DB_USER>"
|
||||
read_pw = "<DB_PASSWORD>"
|
||||
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 = "<NAVER_CLIENT_ID>"
|
||||
secret = "<NAVER_CLIENT_SECRET>"
|
||||
|
||||
[OpenAIConfig]
|
||||
api_key = "<OPENAI_API_KEY>" # 소진되면 크롤이 성공해도 AI 판정 실패로 잡이 DEAD 된다
|
||||
model = "gpt-4o-mini"
|
||||
|
||||
[DecodoConfig]
|
||||
host = "gate.decodo.com"
|
||||
username = "<DECODO_USERNAME>"
|
||||
password = "<DECODO_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분)
|
||||
@ -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:
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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()}
|
||||
|
||||
@ -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 []):
|
||||
|
||||
@ -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, 워터마크 기준)
|
||||
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 = <TError = void | HTTPValidationError,
|
||||
*/
|
||||
export const triggerLowestPrice = (
|
||||
itemId: string,
|
||||
params?: TriggerLowestPriceParams,
|
||||
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
|
||||
) => {
|
||||
|
||||
|
||||
return customFetch<ResLowestPriceTrigger>(
|
||||
{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 = <TError = void | HTTPValidationError,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof triggerLowestPrice>>, TError,{itemId: string}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||
): UseMutationOptions<Awaited<ReturnType<typeof triggerLowestPrice>>, TError,{itemId: string}, TContext> => {
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof triggerLowestPrice>>, TError,{itemId: string;params?: TriggerLowestPriceParams}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||
): UseMutationOptions<Awaited<ReturnType<typeof triggerLowestPrice>>, 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<Awaited<ReturnType<typeof triggerLowestPrice>>, {itemId: string}> = (props) => {
|
||||
const {itemId} = props ?? {};
|
||||
const mutationFn: MutationFunction<Awaited<ReturnType<typeof triggerLowestPrice>>, {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 = <TError = void | HTTPValidationError,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof triggerLowestPrice>>, TError,{itemId: string}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof triggerLowestPrice>>, TError,{itemId: string;params?: TriggerLowestPriceParams}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient): UseMutationResult<
|
||||
Awaited<ReturnType<typeof triggerLowestPrice>>,
|
||||
TError,
|
||||
{itemId: string},
|
||||
{itemId: string;params?: TriggerLowestPriceParams},
|
||||
TContext
|
||||
> => {
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
@ -5,4 +5,4 @@
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type ResTargetBreakdownTargetPriceMode = string | null;
|
||||
export type LowestPriceEntryByMallAnyOfItem = { [key: string]: unknown };
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
};
|
||||
@ -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<number, string> = {
|
||||
1: '네이버',
|
||||
2: '쿠팡',
|
||||
3: 'G마켓',
|
||||
4: '옥션',
|
||||
5: '11번가',
|
||||
99: '기타',
|
||||
};
|
||||
const WEBSITE_LABEL: Record<number, string> = { 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<string, string> = {
|
||||
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) => <circle cx={cx} cy={cy} r={4} fill="var(--primary)" />,
|
||||
peak: (cx: number, cy: number) => (
|
||||
<circle cx={cx} cy={cy} r={4.5} fill="var(--card)" stroke="var(--primary)" strokeWidth={2} />
|
||||
),
|
||||
latest: (cx: number, cy: number) => (
|
||||
<>
|
||||
<circle cx={cx} cy={cy} r={7} fill="var(--primary)" />
|
||||
<circle cx={cx} cy={cy} r={7} fill="none" stroke="var(--card)" strokeWidth={2.5} />
|
||||
</>
|
||||
),
|
||||
} 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 (
|
||||
<Card className="p-3 gap-2 rounded shadow-xs border-border/80">
|
||||
<div className="flex items-center justify-between border-b border-border pb-1">
|
||||
<span className="font-bold text-foreground text-[11px] font-sans">{title}</span>
|
||||
{action}
|
||||
<Card className="gap-0 rounded-xl border-border p-[18px] shadow-none">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className={cn('min-w-0 truncate text-foreground', T.title)}>{title}</span>
|
||||
{meta}
|
||||
</div>
|
||||
{children}
|
||||
{sub && <div className={cn('mt-0.5 text-muted-foreground', T.meta)}>{sub}</div>}
|
||||
<div className="mt-3.5">{children}</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 인터넷 최저가 상세 시트 — 대표값·출처(사이트/상품명/링크)·가격 추이 그래프·수집 이력.
|
||||
// 협상 근거로 쓰는 값이므로 "어디서 찾았는지"를 클릭 한 번으로 검증할 수 있게 한다.
|
||||
// 인터넷 최저가 상세 — 판매처별 실구매가·가격 추이·수집 이력.
|
||||
// 협상 근거로 쓰는 값이므로 "어디서 얼마에 찾았는지"를 클릭 한 번으로 검증할 수 있게 한다.
|
||||
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 (
|
||||
<Sheet open title="인터넷 최저가 상세" onClose={onClose}>
|
||||
<div className="space-y-4">
|
||||
{/* ── 헤드라인: 상품명 · 대표가 · 단가 대비 칩 ── */}
|
||||
<div className="rounded border border-border/80 bg-muted/25 p-3">
|
||||
<Typography as="p" variant="caption" className="font-medium">{product.name}</Typography>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
||||
<span className="font-mono text-[26px] leading-none font-bold text-rose-600 dark:text-rose-400">
|
||||
{representative != null ? won(Number(representative)) : '수집 전'}
|
||||
</span>
|
||||
{diff != null && (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ${
|
||||
diff <= 0
|
||||
? 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400'
|
||||
: 'bg-amber-500/10 text-amber-700 dark:text-amber-400'
|
||||
}`}
|
||||
>
|
||||
{diff <= 0 ? <TrendingDown size={12} /> : <TrendingUp size={12} />}
|
||||
단가 대비 {diff > 0 ? '+' : diff < 0 ? '-' : ''}{won(Math.abs(diff))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Typography as="p" variant="caption" className="mt-1.5">
|
||||
상품 단가 {product.price != null ? won(product.price) : '-'}
|
||||
{latest?.crawl_end_time && <> · 마지막 수집 {timeLabel(latest.crawl_end_time)}</>}
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 pt-3">
|
||||
|
||||
{/* ── 출처 — 최신 성공 수집의 사이트/상품명/링크 ── */}
|
||||
{latest && (
|
||||
<SectionCard
|
||||
title="최저가 출처"
|
||||
action={<Badge variant="secondary" className="text-[10px]">{websiteLabel(latest.website)}</Badge>}
|
||||
>
|
||||
{latest.lp_name ? (
|
||||
<Typography as="p" variant="small" className="text-[12.5px] leading-snug">{latest.lp_name}</Typography>
|
||||
) : (
|
||||
<Typography as="p" variant="caption">출처 상세 미수집(이전 버전 수집분)</Typography>
|
||||
)}
|
||||
{latest.lp_url && (
|
||||
<a
|
||||
href={latest.lp_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex w-fit items-center gap-1.5 rounded border border-border px-2.5 py-1.5 text-[11px] font-medium text-foreground transition-colors hover:border-rose-400/60 hover:bg-rose-500/10 hover:text-rose-600 dark:hover:text-rose-400"
|
||||
>
|
||||
판매 페이지에서 확인 <ExternalLink size={11} />
|
||||
</a>
|
||||
)}
|
||||
</SectionCard>
|
||||
)}
|
||||
{/* ── 요약: 판매처별 내역 → 합계 → 단가 대비 → 출처를 한 섹션으로 ── */}
|
||||
<SectionCard title={product.name}>
|
||||
{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)로 한 단계 진하게 쓴다. */}
|
||||
<div className="rounded-xl bg-accent px-4 py-4">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className={cn('text-accent-foreground', T.meta)}>
|
||||
{heroIsTotal ? '합계 (배송비 포함)' : '상품가 (배송비 미포함)'}
|
||||
</span>
|
||||
{latest.entry.crawl_end_time && (
|
||||
<span className={cn('shrink-0 text-accent-foreground', T.meta)}>
|
||||
{clockLabel(latest.entry.crawl_end_time)} 수집
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1.5">
|
||||
<span className={cn('tabular-nums text-primary', T.hero)}>{num(heroValue!)}</span>
|
||||
<span className={cn('ml-1 text-accent-foreground', T.body)}>원</span>
|
||||
</p>
|
||||
{diff != null && (
|
||||
<p
|
||||
className={cn(
|
||||
'mt-2 leading-snug',
|
||||
T.body,
|
||||
diff > 0 ? 'text-amber-800 dark:text-amber-400' : 'text-emerald-800 dark:text-emerald-300',
|
||||
)}
|
||||
>
|
||||
{priceLabel} {num(product.price!)}원보다{' '}
|
||||
<span className="font-medium tabular-nums">
|
||||
{num(Math.abs(diff))}원 {diff > 0 ? '높습니다' : '낮습니다'}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<MallTable malls={latest.malls} />
|
||||
</div>
|
||||
|
||||
{/* 검색어 — 이 가격의 근거가 된 상품이 무엇인지 밝힌다 */}
|
||||
<div className="mt-3 rounded-xl bg-muted/40 px-4 py-3">
|
||||
<p className={cn('text-muted-foreground', T.meta)}>검색어</p>
|
||||
<div className="mt-1 flex items-start justify-between gap-2">
|
||||
<p className={cn('leading-snug', queryText ? 'text-foreground' : 'text-muted-foreground', T.body)}>
|
||||
{queryText || '출처 상세 미수집(이전 버전 수집분)'}
|
||||
</p>
|
||||
{queryText && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyQuery()}
|
||||
className={cn(
|
||||
'shrink-0 cursor-pointer rounded border border-border bg-card px-2 py-1 text-foreground transition-colors hover:bg-muted',
|
||||
T.meta,
|
||||
)}
|
||||
>
|
||||
복사
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 출처 — 판매 페이지(수집 시각은 히어로 우측으로 올렸다) */}
|
||||
<div className="mt-3.5">
|
||||
{(latest.entry.lp_url || latest.winner?.url) && (
|
||||
<div className="flex justify-end">
|
||||
<a
|
||||
href={latest.entry.lp_url || latest.winner?.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn('rounded border border-border px-2.5 py-1 text-foreground transition-colors hover:bg-muted', T.meta)}
|
||||
>
|
||||
판매 페이지 링크 이동
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{/* 화면의 주인공(합계)과 시스템이 쓰는 값(상품가)이 다르므로 숨기지 않고 밝힌다 */}
|
||||
{heroIsTotal && latest.shipping! > 0 && (
|
||||
<p className={cn('mt-2 leading-relaxed text-muted-foreground', T.meta)}>
|
||||
목표가 산정에는 배송비를 뺀 상품가 {num(latest.itemPrice!)}원이 사용됩니다.
|
||||
</p>
|
||||
)}
|
||||
{!heroIsTotal && (
|
||||
<p className={cn('mt-2 leading-relaxed text-muted-foreground', T.meta)}>
|
||||
판매처가 배송비를 제공하지 않아 상품가만 표시합니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className={cn('text-muted-foreground', T.body)}>
|
||||
아직 수집된 최저가가 없습니다. {priceLabel} {product.price != null ? `${num(product.price)}원` : '-'}
|
||||
</p>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{/* ── 가격 추이 ── */}
|
||||
<SectionCard
|
||||
title="가격 추이"
|
||||
action={
|
||||
successes.length > 0 ? (
|
||||
<Typography variant="caption">{successes.length}회 수집</Typography>
|
||||
) : undefined
|
||||
meta={<span className={cn('shrink-0 text-muted-foreground', T.meta)}>상품가 기준</span>}
|
||||
sub={
|
||||
<>
|
||||
배송비 제외 · 성공 {successes.length}회
|
||||
{missCount > 0 && ` · 미발견 ${missCount}건 제외`}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{chartData.length >= 2 ? (
|
||||
<ChartContainer config={chartConfig} className="aspect-auto h-40 w-full">
|
||||
<LineChart data={chartData} margin={{ top: 10, right: 12, left: 4, bottom: 0 }}>
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||
<XAxis dataKey="t" tickLine={false} axisLine={false} tickMargin={8} tickFormatter={timeLabel} fontSize={10} />
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={52}
|
||||
domain={['dataMin', 'dataMax']}
|
||||
tickFormatter={(v) => Number(v).toLocaleString()}
|
||||
fontSize={10}
|
||||
/>
|
||||
<ChartTooltip cursor={{ strokeDasharray: '3 3' }} content={<PriceTooltip />} />
|
||||
<Line
|
||||
dataKey="price"
|
||||
type="monotone"
|
||||
stroke="var(--color-price)"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, fill: 'var(--color-price)', strokeWidth: 0 }}
|
||||
activeDot={{ r: 5 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
{chartData.length >= 2 && scale ? (
|
||||
<>
|
||||
<ChartContainer config={chartConfig} className="aspect-auto h-56 w-full">
|
||||
<LineChart data={chartData} margin={{ top: 18, right: 12, left: 4, bottom: 8 }}>
|
||||
<CartesianGrid vertical={false} stroke="var(--border)" />
|
||||
{/* 커스텀 tick 은 recharts 의 눈금 정렬 보정(가장자리 앵커 조정)을 건너뛰어
|
||||
점과 라벨이 어긋난다 — 기본 렌더러에 포맷터만 준다. */}
|
||||
<XAxis
|
||||
dataKey="t"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={10}
|
||||
interval="preserveStartEnd"
|
||||
minTickGap={16}
|
||||
tickFormatter={stampLabel}
|
||||
fontSize={T.tick}
|
||||
height={30}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={52}
|
||||
domain={scale.domain}
|
||||
ticks={scale.ticks}
|
||||
tickFormatter={(v) => num(Number(v))}
|
||||
fontSize={T.tick}
|
||||
/>
|
||||
{/* 계약/상품 단가 기준선 — 최저가가 이 선 위에 있으면 사는 게 손해다 */}
|
||||
{product.price != null && (
|
||||
<ReferenceLine
|
||||
y={product.price}
|
||||
stroke="var(--color-contract)"
|
||||
strokeDasharray="5 4"
|
||||
strokeWidth={1.5}
|
||||
label={{
|
||||
value: `${priceLabel} ${num(product.price)}`,
|
||||
position: 'insideTopRight',
|
||||
fontSize: T.tick,
|
||||
fill: 'var(--color-contract)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ChartTooltip cursor={{ strokeDasharray: '3 3' }} content={<PriceTooltip />} />
|
||||
{/* 기준 토글로 데이터가 통째로 바뀌므로 재생 애니메이션은 끈다(점이 튀어 보인다) */}
|
||||
<Line
|
||||
dataKey="price"
|
||||
type="linear"
|
||||
isAnimationActive={false}
|
||||
stroke="var(--color-price)"
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
dot={(props) => <PricePoint {...props} lastIndex={chartData.length - 1} maxIndex={maxAt} />}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
|
||||
{stats && (
|
||||
<div className="mt-3.5 grid grid-cols-3 gap-2">
|
||||
{([['최저', stats.min], ['평균', stats.avg], ['최고', stats.max]] as const).map(([label, v]) => (
|
||||
<div key={label} className="rounded-xl bg-muted/40 px-3 py-2.5">
|
||||
<div className={cn('text-muted-foreground', T.meta)}>{label}</div>
|
||||
<div className={cn('mt-0.5 tabular-nums text-foreground', T.major)}>{num(v)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 점 모양에 뜻이 있으므로 범례로 밝힌다 */}
|
||||
<DotLegend showPeak={stats != null && stats.max !== stats.min} />
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 py-3 text-muted-foreground">
|
||||
<ChartSpline size={14} className="shrink-0" />
|
||||
<Typography variant="caption">
|
||||
{successes.length === 1
|
||||
? '수집이 2회 이상 쌓이면 가격 추이 그래프가 표시됩니다.'
|
||||
: '성공한 수집이 쌓이면 가격 추이 그래프가 표시됩니다.'}
|
||||
</Typography>
|
||||
</div>
|
||||
<p className={cn('py-3 text-muted-foreground', T.body)}>
|
||||
{chartData.length === 1
|
||||
? '수집이 2회 이상 쌓이면 가격 추이 그래프가 표시됩니다.'
|
||||
: '성공한 수집이 쌓이면 가격 추이 그래프가 표시됩니다.'}
|
||||
</p>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{/* ── 수집 이력 ── */}
|
||||
<SectionCard title="최근 수집 이력">
|
||||
{/* ── 수집 이력 (최근 10건 고정 — 서버가 limit=10 으로 내려준다) ── */}
|
||||
<SectionCard
|
||||
title="수집 이력"
|
||||
meta={rows.length > 0 ? <span className={cn('shrink-0 text-muted-foreground', T.meta)}>최근 {rows.length}건</span> : undefined}
|
||||
sub={rows.length > 0 ? `성공 ${successes.length}건 · 미발견 ${missCount}건` : undefined}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-6 text-muted-foreground">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
<Typography variant="caption">이력을 불러오는 중...</Typography>
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="space-y-1 rounded border border-dashed border-border bg-muted/10 py-6 text-center">
|
||||
<TrendingDown size={18} className="mx-auto text-muted-foreground/60" />
|
||||
<Typography as="p" variant="caption">
|
||||
수집 이력이 없습니다 — 상품 목록에서 "최저가 업데이트하기"로 수집을 시작하세요.
|
||||
</Typography>
|
||||
</div>
|
||||
<p className={cn('py-6 text-center text-muted-foreground', T.body)}>이력을 불러오는 중...</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p className={cn('rounded-xl bg-muted/40 px-4 py-6 text-center text-muted-foreground', T.body)}>
|
||||
수집 이력이 없습니다 — 상품 목록에서 "최저가 업데이트하기"로 수집을 시작하세요.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border/60">
|
||||
{entries.map((e, i) => (
|
||||
<li key={i} className="flex items-center justify-between gap-2 py-2 first:pt-0.5 last:pb-0.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Badge variant="secondary" className="w-14 shrink-0 justify-center text-[10px]">
|
||||
{websiteLabel(e.website)}
|
||||
</Badge>
|
||||
<span className="truncate text-[11px] text-muted-foreground">
|
||||
{e.crawl_end_time ? timeLabel(e.crawl_end_time) : '-'}
|
||||
<ul>
|
||||
{rows.map((r, i) => {
|
||||
// 이력은 언제나 '실제 지불액' 기준 — 위 토글에 영향받지 않는다.
|
||||
const paid = r.total ?? r.itemPrice;
|
||||
const url = r.entry.lp_url || r.winner?.url;
|
||||
return (
|
||||
// 열 폭을 고정해 금액이 배송 주석 길이에 밀리지 않게 한다(모든 행의 금액 우측이 한 줄).
|
||||
<li
|
||||
key={i}
|
||||
className="grid min-h-[3.25rem] grid-cols-[auto_1fr_auto_auto] items-center gap-x-2 border-t border-border py-2.5 first:border-t-0 first:pt-0"
|
||||
>
|
||||
<span className={cn('tabular-nums text-foreground', T.body)}>
|
||||
{r.entry.crawl_end_time ? stampLabel(r.entry.crawl_end_time) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2.5 font-mono text-[12px]">
|
||||
{e.success_yn && e.lp_price != null ? (
|
||||
<span className="font-semibold text-rose-600 dark:text-rose-400">{won(e.lp_price)}</span>
|
||||
{r.ok ? (
|
||||
<>
|
||||
<span className="flex justify-center">
|
||||
<Badge variant="secondary" className={cn('rounded-md', T.fine)}>
|
||||
{r.winner?.label ?? websiteLabel(r.entry.website)}
|
||||
</Badge>
|
||||
</span>
|
||||
{/* 금액과 배송비 주석을 세로로 쌓는다 — 가로로 붙이면 주석이 길어질수록
|
||||
금액 시작점이 밀리고, 열을 넓히면 다른 열을 잡아먹는다. */}
|
||||
<span className="text-right">
|
||||
<span className={cn('block font-medium tabular-nums text-primary', T.body)}>{num(paid!)}</span>
|
||||
<span className={cn('mt-0.5 block whitespace-nowrap text-muted-foreground', T.fine)}>
|
||||
{shipNote(r.shipping)}
|
||||
</span>
|
||||
</span>
|
||||
{url ? (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn('rounded border border-border px-2 py-1 text-center text-foreground transition-colors hover:bg-muted', T.meta)}
|
||||
>
|
||||
링크 이동
|
||||
</a>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-amber-600 dark:text-amber-400">미발견</span>
|
||||
// 값이 없는 행은 열을 쪼개 봐야 빈칸만 남는다 — 시각을 뺀 나머지 폭에 사유를 가운데 둔다.
|
||||
<span className={cn('col-span-3 text-center text-muted-foreground', T.fine)}>
|
||||
조건에 맞는 상품 없음
|
||||
</span>
|
||||
)}
|
||||
{e.lp_url ? (
|
||||
<a
|
||||
href={e.lp_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground transition-colors hover:text-rose-600 dark:hover:text-rose-400"
|
||||
title="판매 페이지 열기"
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
) : (
|
||||
<span className="w-3" /> /* 링크 없는 행도 가격 우측 정렬 유지 */
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</SectionCard>
|
||||
@ -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 (
|
||||
<div className="mt-3.5 flex justify-end">
|
||||
<ul className={cn('flex flex-wrap items-center gap-x-3.5 gap-y-1 rounded-lg border border-border px-3 py-2 text-muted-foreground', T.fine)}>
|
||||
{items.map(({ key, label, draw }) => (
|
||||
<li key={key} className="inline-flex items-center gap-1.5">
|
||||
<svg width="18" height="18" aria-hidden className="shrink-0">
|
||||
{draw(9, 9)}
|
||||
</svg>
|
||||
{label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 꺾은선 점 — 마지막 수집은 크게(현재값), 최고가는 속 빈 원으로 구분한다. */
|
||||
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 <g />;
|
||||
if (index === lastIndex) return <g>{DOT.latest(cx, cy)}</g>;
|
||||
if (index === maxIndex) return <g>{DOT.peak(cx, cy)}</g>;
|
||||
return <g>{DOT.normal(cx, cy)}</g>;
|
||||
}
|
||||
|
||||
/** 판매처별 내역 — 상품가가 싼 곳과 배송비까지 더해 실제로 싼 곳이 다를 수 있어 둘 다 표시한다. */
|
||||
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 (
|
||||
<div className="rounded-xl bg-muted/40 px-4 py-2.5">
|
||||
<div className={cn('grid grid-cols-[4rem_1fr_1fr_1fr] gap-x-2 pb-2 text-muted-foreground', T.meta)}>
|
||||
<span>판매처</span>
|
||||
<span className="text-right">상품가</span>
|
||||
<span className="text-right">배송비</span>
|
||||
<span className="text-right">합계</span>
|
||||
</div>
|
||||
{keys.map((k) => {
|
||||
const m = malls.find((x) => x.source === k);
|
||||
const total = m && m.shipping != null ? m.price + m.shipping : null;
|
||||
return (
|
||||
<div key={k} className={cn('grid grid-cols-[4rem_1fr_1fr_1fr] items-center gap-x-2 border-t border-border py-2', T.body)}>
|
||||
<span className="truncate text-foreground">{SOURCE_LABEL[k] ?? k}</span>
|
||||
{m ? (
|
||||
<>
|
||||
<span className={cn('text-right tabular-nums', m.price === bestItem ? 'font-medium text-foreground' : 'text-muted-foreground')}>
|
||||
{num(m.price)}
|
||||
</span>
|
||||
<span className="text-right tabular-nums text-muted-foreground">
|
||||
{m.shipping == null ? '미상' : m.shipping === 0 ? '무료' : num(m.shipping)}
|
||||
</span>
|
||||
<span className={cn('text-right tabular-nums', total != null && total === bestTotal ? 'font-semibold text-foreground' : 'text-muted-foreground')}>
|
||||
{total != null ? num(total) : '—'}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="col-span-3 text-right text-muted-foreground">미발견</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{unknown && (
|
||||
<p className={cn('border-t border-border pt-2 leading-relaxed text-muted-foreground', T.fine)}>
|
||||
배송비를 제공하지 않는 판매처가 있어 합계 비교가 완전하지 않습니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 툴팁 — 시각 + 금액
|
||||
function PriceTooltip({ active, payload }: { active?: boolean; payload?: { payload: { t: string; price: number } }[] }) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const p = payload[0].payload;
|
||||
return (
|
||||
<div className="rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl">
|
||||
<Typography as="p" variant="caption" className="mb-0.5 font-medium text-foreground">{timeLabel(p.t)}</Typography>
|
||||
<Typography as="p" variant="caption" className="font-mono text-foreground">{won(p.price)}</Typography>
|
||||
<div className="rounded-lg border border-border bg-background px-2.5 py-1.5 shadow-xl">
|
||||
<p className={cn('font-medium text-foreground', T.meta)}>{stampLabel(p.t)}</p>
|
||||
<p className={cn('tabular-nums text-muted-foreground', T.meta)}>{num(p.price)}원</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<string, number>;
|
||||
|
||||
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<string[]>([]);
|
||||
const cancelledRef = useRef(false); // 닫기 시 폴링 루프 중단(서버 검색은 계속)
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [items, setItems] = useState<Record<string, ItemState>>({});
|
||||
const [elapsedSec, setElapsedSec] = useState(0); // 경과 시간 — "멈춘 건지 오래 걸리는 건지" 판단 근거
|
||||
// 검색 시작 시점의 대상 스냅샷. 결과를 계속 띄워둬야 하는데 부모의 selectedIds 는
|
||||
// 창을 닫을 때 비워지므로, 표는 이 스냅샷을 그린다.
|
||||
const [targetIds, setTargetIds] = useState<string[]>([]);
|
||||
// 재검색 대상 선택. null = 아직 손대지 않음(전체 선택으로 간주).
|
||||
// 검색이 끝나면 '성공하지 못한 행'만 남겨 두어, 이미 갱신된 상품에 크롤 비용을 다시 쓰지 않게 한다.
|
||||
const [checkedIds, setCheckedIds] = useState<Set<string> | 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<string, number>();
|
||||
|
||||
// 1) 상품별 수집 요청(POST) — 실패/중복은 로그로 구분하고 계속 진행
|
||||
// 1) 상품별 검색 요청(POST) — 실패/중복은 상태로 구분하고 계속 진행
|
||||
const pending = new Set<string>();
|
||||
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<string>(); // 실제로 값이 내려간 행 — 끝나면 선택에서 빼 준다
|
||||
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 <span className="text-muted-foreground">–</span>;
|
||||
if (s.kind === 'pending') {
|
||||
// 작은 회전 아이콘은 표 안에서 어수선하게 읽힌다. 값이 들어올 자리를 잡아 두는
|
||||
// 스켈레톤 막대로 대신한다(레이아웃 이동도 없음).
|
||||
return (
|
||||
<span className="inline-flex w-full justify-end">
|
||||
<span className="h-3 w-10 animate-pulse rounded-[3px] bg-muted" aria-hidden />
|
||||
<span className="sr-only">검색 중</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
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 <span className="text-muted-foreground">–</span>;
|
||||
const isBest = price === best;
|
||||
return (
|
||||
<span className={isBest ? 'font-semibold text-foreground' : 'text-muted-foreground'}>
|
||||
{price.toLocaleString()}
|
||||
{isBest && <span className="sr-only"> (최저가)</span>}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// 결과 열 — 완료 상태에서만 각 행의 '처리 결과 상태'를 적는다(검색 중에는 비워 둔다).
|
||||
// 금액이 아니라 상태어를 쓴다: 가격 자체는 왼쪽 몰 열에 이미 있고, 이 열은 "그래서 어떻게 됐나"를 답한다.
|
||||
const renderResultCell = (id: string) => {
|
||||
const s = items[id];
|
||||
if (status !== 'done' || !s) return <span className="text-muted-foreground">–</span>;
|
||||
if (savingOf(id) > 0) return <span className={cn('font-semibold', ACCENT_TEXT)}>탐색 성공</span>;
|
||||
switch (s.kind) {
|
||||
case 'done':
|
||||
case 'notfound':
|
||||
// 검색은 정상 수행됐으나 기존보다 낮은 가격이 없었음(미발견 포함) — 값이 안 바뀐 상태.
|
||||
return <span className="text-muted-foreground">변동 없음</span>;
|
||||
case 'failed':
|
||||
return <span className="text-foreground">탐색 실패</span>;
|
||||
case 'timeout':
|
||||
case 'stopped':
|
||||
return <span className="text-foreground">탐색 중</span>;
|
||||
default:
|
||||
return <span className="text-muted-foreground">–</span>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/55 backdrop-blur-xs">
|
||||
<div className="w-full max-w-lg bg-card border border-border rounded-lg shadow-2xl p-6 max-h-[90vh] overflow-y-auto animate-scale-up font-mono text-xs">
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) handleClose(); // 오버레이 클릭으로도 닫기
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-[8px] border border-border bg-card shadow-sm">
|
||||
|
||||
{/* Modal Title */}
|
||||
<div className="flex items-center justify-between pb-4 border-b border-border">
|
||||
<div className="flex items-center gap-2 text-foreground">
|
||||
<Globe className="text-rose-500 animate-pulse" size={18} />
|
||||
<Typography variant="h3">인터넷 최저가 실시간 수집 및 동기화</Typography>
|
||||
</div>
|
||||
{!isCrawling && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded text-muted-foreground hover:bg-muted cursor-pointer"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className="my-6 space-y-4">
|
||||
<div className="p-3 bg-rose-500/5 border border-rose-500/20 rounded-md">
|
||||
<Typography variant="small" className="text-[11.5px] leading-relaxed text-left flex items-start gap-2">
|
||||
<AlertCircle size={15} className="text-rose-500 shrink-0 mt-0.5" />
|
||||
<span>
|
||||
총 <span className="font-bold text-rose-600 dark:text-rose-400 underline decoration-rose-500/50 decoration-2">{selectedIds.length}개</span> 품목에 대하여 네이버 쇼핑·쿠팡의 최저가를 수집하고 AI 가 동일 상품을 판정하여 인터넷 최저가 필드로 동기화합니다. 상품당 수십 초가 소요될 수 있습니다.
|
||||
</span>
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
{/* Targeted Items List */}
|
||||
<div className="border border-border rounded p-2.5 bg-muted/20 space-y-1.5">
|
||||
<span className="text-[10px] text-muted-foreground font-bold block">수집 대상 상품 ({selectedIds.length})</span>
|
||||
<div className="max-h-24 overflow-y-auto space-y-1 pr-1">
|
||||
{selectedIds.map((id) => {
|
||||
const prod = products.find((p) => p.item_id === id);
|
||||
return prod ? (
|
||||
<div key={id} className="flex justify-between items-center text-[11px] py-1 border-b border-border/30 last:border-b-0">
|
||||
<span className="text-foreground truncate max-w-[200px] font-semibold">{prod.name}</span>
|
||||
<div className="flex items-center gap-1.5 font-mono">
|
||||
<span className="text-muted-foreground">{(prod.price ?? 0).toLocaleString()}원</span>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<span className="text-rose-500 font-bold">
|
||||
{prod.internet_lowest_price != null ? `${Number(prod.internet_lowest_price).toLocaleString()}원 갱신` : '신규 수집'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Crawling Progress View */}
|
||||
{isCrawling ? (
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="flex justify-between text-[11px] text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5 font-bold text-foreground">
|
||||
<Loader2 size={13} className="animate-spin text-rose-500" />
|
||||
최저가 검색·수집 진행 중... (닫아도 검색은 계속됩니다)
|
||||
</span>
|
||||
<span className="font-bold text-rose-500">{crawlingProgress}%</span>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar Container */}
|
||||
<div className="w-full bg-muted rounded-full h-2.5 overflow-hidden border border-border">
|
||||
<div
|
||||
className="bg-gradient-to-r from-rose-500 to-rose-600 h-full transition-all duration-300 rounded-full"
|
||||
style={{ width: `${crawlingProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Terminal Log Output */}
|
||||
<div className="bg-zinc-950 dark:bg-black text-rose-300 p-3 rounded font-mono text-[10px] border border-zinc-800 space-y-1.5 max-h-36 overflow-y-auto shadow-inner">
|
||||
{crawlerLogs.map((log, idx) => (
|
||||
<div key={idx} className="flex items-start gap-1">
|
||||
<span className="text-zinc-600 select-none shrink-0">></span>
|
||||
<span className={`text-left break-all ${log.includes('[System]') || log.includes('[Search]') ? 'text-zinc-400 font-bold' : log.includes('[완료]') ? 'text-emerald-400' : log.includes('[미발견]') || log.includes('[불가]') || log.includes('[오류]') || log.includes('[대기초과]') ? 'text-amber-400' : 'text-rose-300'}`}>
|
||||
{log}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-4 border border-dashed border-border rounded bg-muted/10 space-y-2">
|
||||
<Cpu size={24} className="mx-auto text-muted-foreground/60" />
|
||||
<Typography variant="muted" className="text-[11px]">
|
||||
"인터넷 최저가 가동" 버튼을 누르시면 실시간 수집이 시작됩니다.
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="flex justify-end gap-2 pt-4 border-t border-border">
|
||||
{/* 다이얼로그 헤더 — X 는 푸터 버튼과 같은 높이(size-7)로 맞춘다 */}
|
||||
<div className="flex items-center justify-between gap-3 px-5 py-4">
|
||||
<Typography variant="h4" as="h2">인터넷 최저가 검색</Typography>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
cancelledRef.current = true; // 진행 중이면 폴링만 중단(서버 검색은 계속 → 주기 동기화로 반영)
|
||||
onClose();
|
||||
}}
|
||||
size="icon-sm"
|
||||
className="rounded-[4px]"
|
||||
aria-label="닫기"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="px-5 pb-2">
|
||||
{/* 결과 표 — 데이터가 주인공이라 맨 위에 둔다 */}
|
||||
<div className="overflow-x-auto rounded-[6px] border border-border">
|
||||
<div className="min-w-[36rem]">
|
||||
<div className={cn(ROW_GRID, 'items-center border-b border-border bg-muted/60 py-1.5 text-xs text-muted-foreground')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={CHECKBOX}
|
||||
checked={allChecked}
|
||||
disabled={status === 'searching' || rows.length === 0}
|
||||
onChange={(e) => toggleAll(e.target.checked)}
|
||||
aria-label="전체 선택"
|
||||
/>
|
||||
<span>상품</span>
|
||||
<span className={NUM}>기존 최저가</span>
|
||||
{SOURCES.map(({ key, label }) => (
|
||||
<span key={key} className={NUM}>{label}</span>
|
||||
))}
|
||||
<span className={NUM}>결과</span>
|
||||
</div>
|
||||
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{rows.length === 0 && (
|
||||
<div className="px-3 py-6 text-center text-[13px] text-muted-foreground">
|
||||
선택된 상품이 없습니다. 목록에서 상품을 선택해 주십시오.
|
||||
</div>
|
||||
)}
|
||||
{rows.map((id) => (
|
||||
<label
|
||||
key={id}
|
||||
className={cn(
|
||||
ROW_GRID,
|
||||
'items-center py-1.5 text-[13px] border-b border-border/60 last:border-b-0',
|
||||
status !== 'searching' && 'cursor-pointer hover:bg-muted/40',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={CHECKBOX}
|
||||
checked={checked.has(id)}
|
||||
disabled={status === 'searching'}
|
||||
onChange={(e) => toggleOne(id, e.target.checked)}
|
||||
aria-label={`${nameOf(id)} 선택`}
|
||||
/>
|
||||
<span className="truncate text-foreground">{nameOf(id)}</span>
|
||||
<span className={cn(NUM, 'text-muted-foreground')}>{fmtPrice(prevPriceOf(id))}</span>
|
||||
{SOURCES.map(({ key }) => (
|
||||
<span key={key} className={NUM}>{renderMallCell(id, key)}</span>
|
||||
))}
|
||||
<span className={NUM}>{renderResultCell(id)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 상태 요약 — 한 화면에 한 상태만. 표(데이터)가 먼저 오고 진행/결과는 그 아래.
|
||||
confirm 단계는 별도 문구 없이 표와 하단 안내만으로 충분하다. */}
|
||||
<div role="status" aria-live="polite" className={hasStatusBlock ? 'mt-4' : undefined}>
|
||||
{status === 'searching' && (
|
||||
<div className="rounded-[6px] border border-border px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="flex items-center gap-2 text-[13px] font-medium text-foreground">
|
||||
{/* 동작 중임을 알리는 표시등 — 의미는 옆 문구가 진다(색 단독 의존 방지) */}
|
||||
<span
|
||||
className="size-1.5 shrink-0 animate-pulse rounded-full bg-indigo-800 dark:bg-indigo-300"
|
||||
aria-hidden
|
||||
/>
|
||||
더 낮은 가격 검색 중
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-xs tabular-nums text-muted-foreground">
|
||||
<Clock size={13} aria-hidden />
|
||||
<span className="sr-only">경과 시간 </span>
|
||||
{fmtClock(elapsedSec)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<span className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
|
||||
<span
|
||||
className="block h-full rounded-full bg-indigo-800 transition-all duration-500 dark:bg-indigo-300"
|
||||
style={{ width: `${rows.length ? (settled / rows.length) * 100 : 0}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="shrink-0 text-xs font-semibold tabular-nums text-foreground">
|
||||
{settled} / {rows.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 완료 — 행별 결과는 표의 '결과' 열이 말해주므로, 여기엔 절감액만 남긴다.
|
||||
절감이 없으면 시각적으로는 비우고 완료 사실만 스크린리더에 알린다. */}
|
||||
{status === 'done' && (
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
{totalSaved > 0 ? (
|
||||
<span className={cn('text-[22px] font-bold tabular-nums tracking-tight', ACCENT_TEXT)}>
|
||||
총 {totalSaved.toLocaleString()}원 절감
|
||||
</span>
|
||||
) : (
|
||||
<span className="sr-only">검색이 완료되었습니다. 갱신된 가격은 없습니다.</span>
|
||||
)}
|
||||
{failedIds.length > 0 && (
|
||||
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
||||
탐색 실패 {failedIds.length}개
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 보조 안내 — 할 말이 없으면 아예 그리지 않는다(빈 줄이 여백만 잡는 걸 막는다) */}
|
||||
{note && <p className="mt-4 text-xs text-muted-foreground">{note}</p>}
|
||||
</div>
|
||||
|
||||
{/* 액션 — 풀와이드 금지. 보조는 좌측 텍스트, 주 액션은 우측 */}
|
||||
<div className="flex items-center justify-between gap-3 px-5 pb-5 pt-4">
|
||||
<Button type="button" variant="outline" size="sm" className="rounded-[4px]" onClick={handleClose}>
|
||||
닫기
|
||||
</Button>
|
||||
<Button type="button" variant="destructive" size="sm" disabled={isCrawling} onClick={handleStartCrawling}>
|
||||
{isCrawling ? (
|
||||
<>
|
||||
<RefreshCw className="animate-spin" />
|
||||
실시간 수집 분석중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Globe />
|
||||
인터넷 최저가 가동
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{status === 'searching' ? (
|
||||
<Button type="button" variant="secondary" size="sm" className="rounded-[4px]" onClick={handleStop}>
|
||||
탐색 중단
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className={cn('rounded-[4px]', ACCENT_BTN)}
|
||||
disabled={checked.size === 0}
|
||||
// 완료 후 재검색은 force — 미발견은 24h 네거티브 캐시에 걸려 그냥 누르면 실제로 안 돈다.
|
||||
onClick={status === 'done' ? () => void runSearch([...checked], true) : handleStart}
|
||||
>
|
||||
{status === 'confirm' ? `선택 ${checked.size}개 검색` : `선택 ${checked.size}개 다시 검색`}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user