From 95086cb3f69f2dc55b9f883cc8b1f8565f2dc9b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Fri, 7 Aug 2026 11:21:56 +0900 Subject: [PATCH] =?UTF-8?q?feat(negodata):=204=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=E2=80=94=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=EC=97=90=EC=84=9C=20'=EC=97=86=EC=9D=8C'=EA=B3=BC=20'=ED=99=95?= =?UTF-8?q?=EC=9D=B8=20=EB=AA=BB=ED=95=A8'=EC=9D=84=20=EA=B5=AC=EB=B6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 마지막 단계. 차단당해 못 본 몰이 화면에서 '–'(없음)로 보여 사용자가 "쿠팡엔 더 싼 게 없구나"로 오해하던 문제를 끝낸다. 안 본 걸 없다고 말하지 않는다. 사용자 화면은 **셋으로 접는다**(lps/docs/result-states.md 3-2절). 할 수 있는 행동이 '쓴다/다시 시도/넘어간다' 뿐이라, 원인이 달라도 다음 행동이 같으면 같은 표기다: matched → 가격 no_match · empty → '–' (확인했고 없었다) blocked · env_blocked · unavailable → '확인 못함' (못 봤다) 운영자 화면(lps-admin)은 같은 데이터로 7상태를 그대로 본다 — 목적이 진단이라 접지 않는다. 체인 전체를 이었다: - postgres-init/alters/2026-08-07-iilp-source-state.sql — item_internet_lowest_prices 에 sources/partial 추가(멱등, **운영 적용 필요**) - models.py / lps_sync_crud 읽기 계약 / lps_sync_service 미러링 / LowestPriceEntry 프로토콜 - orval 재생성(ORVAL_INPUT 으로 저장 스펙에서 — 서버 없이). 생성 diff 는 새 필드만. - PriceUpdateModal: 가격이 없는 몰이 '못 본 몰'이면 '–' 대신 '확인 못함'. partial 은 LPS 가 판단해 내려준 사실을 그대로 쓴다 — 화면이 '어떤 상태가 확인된 것인가'를 다시 판정하면 상태 정의가 LPS 와 negodata 두 곳으로 흩어진다. E2E 검증(실 DB 2시나리오): by_mall 은 둘 다 naver 뿐인데 쿠팡 칸이 '확인 못함'(차단) / '–'(0건) 으로 갈린다. tsc 오류 없음(기존 xlsx 미설치 오류는 무관). negodata 97 · lps 292 passed. > 폴더 관례상 negodata 는 인수인계 대상이나, 사용자 요청으로 이번 건도 예외 적용. Co-Authored-By: Claude Opus 5 --- lps/docs/result-states.md | 27 ++++++++++++-- .../backend/common/database/model/models.py | 10 +++++- negodata/backend/crud/lps_sync_crud.py | 6 +++- negodata/backend/router/v1/item/protocol.py | 9 ++++- negodata/backend/services/lps_sync_service.py | 13 +++++-- negodata/backend/tests/test_lps_sync_state.py | 33 +++++++++++++++++ .../front/src/api/generated/model/index.ts | 2 ++ .../api/generated/model/lowestPriceEntry.ts | 3 ++ .../model/lowestPriceEntrySources.ts | 9 +++++ .../model/lowestPriceEntrySourcesAnyOf.ts | 8 +++++ .../products/components/PriceUpdateModal.tsx | 36 +++++++++++++++---- .../alters/2026-08-07-iilp-source-state.sql | 34 ++++++++++++++++++ 12 files changed, 176 insertions(+), 14 deletions(-) create mode 100644 negodata/backend/tests/test_lps_sync_state.py create mode 100644 negodata/front/src/api/generated/model/lowestPriceEntrySources.ts create mode 100644 negodata/front/src/api/generated/model/lowestPriceEntrySourcesAnyOf.ts create mode 100644 postgres-init/alters/2026-08-07-iilp-source-state.sql diff --git a/lps/docs/result-states.md b/lps/docs/result-states.md index 4628667..3cd8069 100644 --- a/lps/docs/result-states.md +++ b/lps/docs/result-states.md @@ -203,8 +203,31 @@ per_source[src] = {"error": f"{type(res).__name__}: {res}"} # ← blocked/fata 검증: ASGI 직접 호출로 두 엔드포인트 모두 `sources`·`partial` 확인(한글 사유 포함). `tsc` 오류 없음. 테스트 3건 추가(목록 노출 / 시점별 상태 / 옛 행 호환). -**남은 것** +**4단계 — negodata 사용자 화면 ✅ 완료** (`feat/source-state`) -4. negodata 가 `–`(없음)와 `확인 못함`(미확인)을 구분하지 못한다 — 사용자 화면은 3가지로 접는다(3-2절) +3-2절대로 **셋으로 접었다**. 사용자가 할 수 있는 건 '쓴다/다시 시도/넘어간다' 뿐이라, 원인이 +달라도 다음 행동이 같으면 같은 표기다. + +| 몰 칸 | 조건 | +|------|------| +| 가격 | `matched` | +| `–` | `no_match` · `empty` — 확인했고 없었다 | +| **확인 못함** | `blocked` · `env_blocked` · `unavailable` — 못 봤다 | + +체인 전체를 이었다: `price_history.sources/partial` → negodata 읽기 계약 → 동기화 → +`item_internet_lowest_prices` → API(`LowestPriceEntry`) → 화면. +마이그레이션: `postgres-init/alters/2026-08-07-iilp-source-state.sql` (**운영 적용 필요**) + +E2E 검증(실 DB, 두 시나리오): `by_mall` 은 둘 다 `naver` 뿐인데 쿠팡 칸이 +`확인 못함`(차단) / `–`(0건)으로 갈린다. `tsc` 오류 없음, negodata 97 · lps 292 passed. + +> 폴더 관례상 negodata 는 인수인계 대상이나, 사용자 요청으로 이번 건도 예외 적용. + +--- + +**4단계 모두 완료.** 남은 개선 여지(선택): +- `no_match`(같은 상품 아님)와 `empty`(검색 0건)를 사용자 화면에서 굳이 나눌 필요는 없다고 봤다 — + 나중에 "왜 없지?"라는 문의가 잦아지면 재검토. +- 잡 목록(lps-admin Jobs)에는 아직 몰별 상태를 안 붙였다. 상품 화면에서 보이므로 우선순위는 낮다. > 이 문서는 **정의**다. 구현 전에 용어를 맞추기 위한 것이고, 실제 반영 여부는 위 4절이 소스다. diff --git a/negodata/backend/common/database/model/models.py b/negodata/backend/common/database/model/models.py index 1a327e4..f7cb178 100644 --- a/negodata/backend/common/database/model/models.py +++ b/negodata/backend/common/database/model/models.py @@ -136,8 +136,16 @@ class item_internet_lowest_prices(MainTableMixin, MAIN_BASE): lp_url = Column(String, nullable=True) # 찾은 판매 페이지 링크(TEXT) — 근거 검증용 # 몰별 최저가 스냅샷 — lps_db.price_history.by_mall 을 그대로 미러링한다(열린 스키마). # [{source, mall_name, price, shipping_fee, shipping_type, name, detail_url}, ...] 가격 오름차순. - # 매칭된 몰만 들어오므로, 특정 몰이 없으면 그 몰은 빈손이었다는 뜻이다(네이버 실패/쿠팡만 성공 구분). + # 매칭된 몰만 들어온다. 그래서 **특정 몰이 없는 이유**는 여기서 알 수 없다 — + # '그 몰엔 없었다'인지 '그 몰을 못 봤다(차단)'인지는 아래 sources 가 답한다. by_mall = Column(JSONB, nullable=True) + # 몰별 확인 상태 — lps_db.price_history.sources 미러링(열린 스키마). + # {"naver": {"state": "matched", "count": 40}, "coupang": {"state": "blocked", "error": "..."}} + # state 값 정의는 lps/docs/result-states.md (LPS common.enums.SourceState). + sources = Column(JSONB, nullable=True) + # 못 본 몰이 있어 결과가 최종이 아님. LPS 가 판단해 내려준 사실을 그대로 싣는다 — + # 화면이 '어떤 상태가 확인된 것인가'를 다시 판정하면 상태 정의가 두 곳으로 흩어진다. + partial = Column(Boolean, nullable=False, server_default=text("false")) 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 94126e6..42f63a7 100644 --- a/negodata/backend/crud/lps_sync_crud.py +++ b/negodata/backend/crud/lps_sync_crud.py @@ -29,7 +29,9 @@ _price_history = table( column("naver_url"), column("coupang_name"), # 쿠팡 최저가 상품명/링크 column("coupang_url"), - column("by_mall"), # 몰별 최저가 스냅샷(JSONB 배열) — 어느 몰이 건졌고 어느 쪽이 빈손인지 + column("by_mall"), # 몰별 최저가 스냅샷(JSONB 배열) — 가격이 **있는** 몰만 + column("sources"), # 몰별 확인 상태 — 빠진 몰이 '없었다'인지 '못 봤다'인지는 여기에만 있다 + column("partial"), # 못 본 몰이 있어 결과가 최종이 아님(LPS 판단 결과) column("created_at"), ) @@ -118,6 +120,8 @@ class LpsSyncCRUD(ILpsSyncCRUD): _price_history.c.coupang_name, _price_history.c.coupang_url, _price_history.c.by_mall, + _price_history.c.sources, + _price_history.c.partial, _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/protocol.py b/negodata/backend/router/v1/item/protocol.py index 1ef773d..930540f 100644 --- a/negodata/backend/router/v1/item/protocol.py +++ b/negodata/backend/router/v1/item/protocol.py @@ -145,9 +145,16 @@ class LowestPriceEntry(WebPacketProtocol): fail_reason: Optional[str] = None lp_name: Optional[str] = None # 찾은 상품명(판매 페이지 기준) — 근거 검증용 lp_url: Optional[str] = None # 찾은 판매 페이지 링크 - # 몰별 최저가 스냅샷(가격 오름차순). 매칭된 몰만 들어오므로, 없는 몰은 그 회차에 빈손이었다는 뜻. + # 몰별 최저가 스냅샷(가격 오름차순). **가격이 있는 몰만** 들어온다 — + # 그래서 없는 몰이 '거기엔 없었다'인지 '거기를 못 봤다'인지는 아래 sources 가 답한다. # [{source: 'naver'|'coupang'|…, mall_name, price, shipping_fee, shipping_type, name, detail_url}] by_mall: Optional[list[dict]] = None + # 몰별 확인 상태 — {"naver": {"state": "matched"}, "coupang": {"state": "blocked"}}. + # 화면은 이걸 셋으로 접어 쓴다: 가격 / '–'(없음) / '확인 못함'(미확인). + # state 정의는 lps/docs/result-states.md. + sources: Optional[dict] = None + # 못 본 몰이 있어 결과가 최종이 아님. LPS 가 판단한 사실이라 화면은 그대로 쓰면 된다. + partial: bool = False 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 4559ffd..fdd8e7c 100644 --- a/negodata/backend/services/lps_sync_service.py +++ b/negodata/backend/services/lps_sync_service.py @@ -159,7 +159,8 @@ class LpsSyncService: # product_code(uuid=item_id) 검증 — LPS 부하테스트 등 비상품 코드는 조용히 스킵 parsed = [] - for code, outcome, final_lowest, final_source, nv_name, nv_url, cp_name, cp_url, by_mall, created_at in rows: + for (code, outcome, final_lowest, final_source, nv_name, nv_url, cp_name, cp_url, + by_mall, sources, partial, created_at) in rows: try: iid = uuid.UUID(code) except (ValueError, AttributeError, TypeError): @@ -170,7 +171,8 @@ 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, by_mall, created_at)) + parsed.append((iid, outcome, final_lowest, final_source, src_name, src_url, + by_mall, sources, partial, created_at)) err, existing = await DB_SESSION_MNG.execute_lambda( DBType.MAIN.value, DBWRType.DB_READ.value, @@ -180,7 +182,8 @@ 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, by_mall, created_at in parsed: + for (item_id, outcome, final_lowest, final_source, src_name, src_url, + by_mall, sources, partial, created_at) in parsed: if item_id not in existing: results["skipped_unknown_item"] += 1 continue @@ -194,6 +197,10 @@ class LpsSyncService: lp_name=(src_name or None) and src_name[:300], lp_url=src_url or None, by_mall=by_mall or None, # 몰별 스냅샷 그대로 미러링 — 몰별 성공/실패 표시용 + # 몰별 확인 상태도 그대로 옮긴다. by_mall 에 없는 몰이 '없었다'인지 '못 봤다'인지는 + # 이 값에만 있어, 없으면 화면이 두 경우를 구분할 방법이 없다. + sources=sources or None, + partial=bool(partial), crawl_end_time=created_at, # 워터마크 기준값 — price_history.created_at 그대로 보존 )) results["found" if found else "not_found"] += 1 diff --git a/negodata/backend/tests/test_lps_sync_state.py b/negodata/backend/tests/test_lps_sync_state.py new file mode 100644 index 0000000..72df75d --- /dev/null +++ b/negodata/backend/tests/test_lps_sync_state.py @@ -0,0 +1,33 @@ +"""LPS 몰별 확인 상태 미러링 — '없었다'와 '못 봤다'를 화면까지 나르는 계약. + +by_mall 은 **가격이 있는 몰만** 담는다. 그래서 빠진 몰이 '거기엔 없더라'인지 '거기를 못 봤다'인지는 +sources/partial 에만 있다. 이 값이 중간에서 빠지면 화면은 두 경우를 구분할 방법이 없다 +(실제로 그래서 차단당한 몰이 '없음'으로 보였다 — lps/docs/result-states.md). +""" + +from crud.lps_sync_crud import _price_history +from router.v1.item.protocol import LowestPriceEntry + + +def test_read_contract_includes_source_state(): + """lps_db 읽기 계약에서 이 컬럼이 빠지면 화면까지 갈 값이 사라진다.""" + cols = {c.name for c in _price_history.columns} + assert {"sources", "partial"} <= cols, cols + assert "by_mall" in cols # 함께 읽어야 '가격 있는 몰'과 '확인한 몰'을 대조할 수 있다 + + +def test_api_entry_exposes_state_with_safe_defaults(): + """이 컬럼 추가 이전 이력(값 없음)도 화면이 깨지지 않아야 한다.""" + e = LowestPriceEntry() + assert e.sources is None + assert e.partial is False, "기본이 True 면 정상 결과가 '일부 확인 못함'으로 보인다" + + +def test_api_entry_carries_source_state(): + e = LowestPriceEntry( + lp_price=9000, success_yn=True, partial=True, + sources={"naver": {"state": "matched", "count": 40}, + "coupang": {"state": "blocked", "error": "차단"}}, + ) + assert e.partial is True + assert e.sources["coupang"]["state"] == "blocked" diff --git a/negodata/front/src/api/generated/model/index.ts b/negodata/front/src/api/generated/model/index.ts index 924c176..bd4888c 100644 --- a/negodata/front/src/api/generated/model/index.ts +++ b/negodata/front/src/api/generated/model/index.ts @@ -92,6 +92,8 @@ export * from './lowestPriceEntryFailReason'; export * from './lowestPriceEntryLpName'; export * from './lowestPriceEntryLpPrice'; export * from './lowestPriceEntryLpUrl'; +export * from './lowestPriceEntrySources'; +export * from './lowestPriceEntrySourcesAnyOf'; export * from './notificationData'; export * from './notificationDataCreatedAt'; export * from './notificationDataData'; diff --git a/negodata/front/src/api/generated/model/lowestPriceEntry.ts b/negodata/front/src/api/generated/model/lowestPriceEntry.ts index 105c96f..c793d83 100644 --- a/negodata/front/src/api/generated/model/lowestPriceEntry.ts +++ b/negodata/front/src/api/generated/model/lowestPriceEntry.ts @@ -9,6 +9,7 @@ import type { LowestPriceEntryFailReason } from './lowestPriceEntryFailReason'; import type { LowestPriceEntryLpName } from './lowestPriceEntryLpName'; import type { LowestPriceEntryLpUrl } from './lowestPriceEntryLpUrl'; import type { LowestPriceEntryByMall } from './lowestPriceEntryByMall'; +import type { LowestPriceEntrySources } from './lowestPriceEntrySources'; import type { LowestPriceEntryCrawlEndTime } from './lowestPriceEntryCrawlEndTime'; /** @@ -22,5 +23,7 @@ export interface LowestPriceEntry { lp_name?: LowestPriceEntryLpName; lp_url?: LowestPriceEntryLpUrl; by_mall?: LowestPriceEntryByMall; + sources?: LowestPriceEntrySources; + partial?: boolean; crawl_end_time?: LowestPriceEntryCrawlEndTime; } diff --git a/negodata/front/src/api/generated/model/lowestPriceEntrySources.ts b/negodata/front/src/api/generated/model/lowestPriceEntrySources.ts new file mode 100644 index 0000000..19dabb2 --- /dev/null +++ b/negodata/front/src/api/generated/model/lowestPriceEntrySources.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 { LowestPriceEntrySourcesAnyOf } from './lowestPriceEntrySourcesAnyOf'; + +export type LowestPriceEntrySources = LowestPriceEntrySourcesAnyOf | null; diff --git a/negodata/front/src/api/generated/model/lowestPriceEntrySourcesAnyOf.ts b/negodata/front/src/api/generated/model/lowestPriceEntrySourcesAnyOf.ts new file mode 100644 index 0000000..7f1c1a6 --- /dev/null +++ b/negodata/front/src/api/generated/model/lowestPriceEntrySourcesAnyOf.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type LowestPriceEntrySourcesAnyOf = { [key: string]: unknown }; diff --git a/negodata/front/src/features/products/components/PriceUpdateModal.tsx b/negodata/front/src/features/products/components/PriceUpdateModal.tsx index 21a18e1..aeb0293 100644 --- a/negodata/front/src/features/products/components/PriceUpdateModal.tsx +++ b/negodata/front/src/features/products/components/PriceUpdateModal.tsx @@ -9,6 +9,7 @@ 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 { LowestPriceEntrySources } from '@/api/generated/model/lowestPriceEntrySources'; import type { Product } from '../types'; type PriceUpdateModalProps = { @@ -35,9 +36,24 @@ const SOURCES = [ { key: 'coupang', label: '쿠팡' }, ] as const; -// 몰별 최저가 = by_mall 을 source 로 묶어 최저가만 남긴 것. 값이 없으면 그 몰은 못 찾은 것. +// 몰별 최저가 = by_mall 을 source 로 묶어 최저가만 남긴 것. type MallPrices = Record; +// 값이 없는 몰은 두 경우다 — '거기엔 없었다'와 '거기를 못 봤다'. 뜻이 정반대라 같이 보이면 안 된다. +// 사용자가 할 수 있는 건 '쓴다 / 다시 시도 / 넘어간다' 뿐이라, 원인이 달라도 다음 행동이 같으면 +// 같은 표기로 접는다(운영자 화면 lps-admin 은 7상태를 그대로 본다). +// matched → 가격 +// no_match · empty → '–' (결론은 '이 몰엔 없다') +// blocked · env_blocked · unavailable → '확인 못함' (행동은 '나중에 다시' 하나뿐) +// 상태 정의는 lps/docs/result-states.md. +const CONFIRMED_STATES = new Set(['matched', 'no_match', 'empty', 'skipped']); + +/** 그 몰을 확인하지 못했는가(=가격 자리에 '–' 대신 '확인 못함'을 써야 하는가). */ +const isUnconfirmed = (sources: LowestPriceEntrySources | undefined, source: string): boolean => { + const state = (sources as Record | null | undefined)?.[source]?.state; + return !!state && !CONFIRMED_STATES.has(state); +}; + const toMallPrices = (byMall: LowestPriceEntryByMall): MallPrices => { const out: MallPrices = {}; for (const entry of byMall ?? []) { @@ -52,8 +68,8 @@ const toMallPrices = (byMall: LowestPriceEntryByMall): MallPrices => { // 상품 1건의 처리 상태(화면 전체 상태와 구분해 ItemState 로 둔다). type ItemState = | { kind: 'pending' } // 접수됨 — 결과 대기 - | { kind: 'done'; price: number; malls: MallPrices } - | { kind: 'notfound'; malls: MallPrices } // 검색은 됐으나 같은 상품이 없었음(기존 값 유지) + | { kind: 'done'; price: number; malls: MallPrices; unconfirmed?: string[] } + | { kind: 'notfound'; malls: MallPrices; unconfirmed?: string[] } // 검색은 됐으나 같은 상품이 없었음(기존 값 유지) | { kind: 'failed'; reason?: string } // 접수 실패 또는 검색 실패(사이트 차단) — 재시도 대상 | { kind: 'timeout' } // 폴링 상한 초과 — 서버는 계속 검색 중 | { kind: 'stopped' }; // 사용자가 지켜보기를 중단 — 서버는 계속 검색 중 @@ -249,13 +265,15 @@ export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose if (!fresh) continue; pending.delete(id); const malls = toMallPrices(fresh.by_mall ?? null); + // 가격 자리에 '–'(없음) 대신 '확인 못함'을 써야 하는 몰들 + const unconfirmed = SOURCES.map((x) => x.key).filter((m) => isUnconfirmed(fresh.sources, m)); if (fresh.success_yn && fresh.lp_price != null) { found += 1; 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 }); + setItem(id, { kind: 'done', price: fresh.lp_price, malls, unconfirmed }); } else if (fresh.fail_reason === 'error') { // 검색 자체가 실패한 경우(모든 소스 차단). '못 찾음'과 뜻이 완전히 다르다 — // 상품이 없다는 게 아니라 **확인을 못 했다**는 뜻이라, 재시도 대상으로 분류한다. @@ -264,7 +282,7 @@ export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose // 문구는 '탐색 실패 사유: {reason}' 형태로 붙는다 — 사유만 간결하게 담는다. setItem(id, { kind: 'failed', reason: '사이트 차단으로 가격을 확인하지 못했습니다 (잠시 후 다시 시도해 주세요)' }); } else { - setItem(id, { kind: 'notfound', malls }); + setItem(id, { kind: 'notfound', malls, unconfirmed }); } } catch { /* 일시 오류는 다음 tick 재시도 */ @@ -311,7 +329,13 @@ export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose const prices = Object.values(malls); const best = prices.length > 0 ? Math.min(...prices) : null; const price = malls[source]; - if (price === undefined) return ; + if (price === undefined) { + // 안 본 걸 '없음(–)'으로 보여주면 사용자는 '이 몰엔 더 싼 게 없다'로 읽는다 — 사실이 아니다. + const unseen = (s.kind === 'done' || s.kind === 'notfound') && s.unconfirmed?.includes(source); + return unseen + ? 확인 못함 + : ; + } const isBest = price === best; return ( diff --git a/postgres-init/alters/2026-08-07-iilp-source-state.sql b/postgres-init/alters/2026-08-07-iilp-source-state.sql new file mode 100644 index 0000000..bc05c16 --- /dev/null +++ b/postgres-init/alters/2026-08-07-iilp-source-state.sql @@ -0,0 +1,34 @@ +-- 2026-08-07 · 최저가 수집 이력에 '몰별 확인 상태' 미러링 (기존 DB 보정) +-- 배경: '그 몰에 더 싼 게 없었다'와 '그 몰이 막혀서 못 봤다'가 화면에서 똑같이 '–' 로 보인다. +-- 사용자는 앞쪽으로 읽지만 실제로는 뒤쪽일 수 있다 — 안 본 걸 없다고 말하는 셈이다. +-- by_mall 은 **가격이 있는 몰만** 담으므로 그 구분이 담길 자리가 없었다. +-- LPS 가 lps_db.price_history.sources/partial 로 내려주기 시작했고(2026-08-07), +-- lps_sync_service 가 이 컬럼으로 미러링한다. 상태 정의는 lps/docs/result-states.md. +-- 멱등: ADD COLUMN IF NOT EXISTS — 여러 번 실행해도 안전. +-- 적용: psql -h -p -U -d -f postgres-init/alters/2026-08-07-iilp-source-state.sql + +\connect negosium_db + +-- 몰별 확인 상태 — {"naver": {"state": "matched", "count": 40}, +-- "coupang": {"state": "blocked", "error": "..."}} +-- state: matched / no_match / empty / blocked / env_blocked / unavailable / skipped +ALTER TABLE partner.item_internet_lowest_prices + ADD COLUMN IF NOT EXISTS sources JSONB NULL; + +-- 결과가 완전한가. true = 못 본 몰이 있어 이 값이 최종이 아니다. +-- sources 에서 유도할 수 있지만 컬럼으로 둔다 — 화면이 '어떤 상태가 확인된 것인가'라는 판단 +-- 규칙까지 알아야 하면 상태 정의가 LPS 와 negodata 두 곳으로 흩어진다. 판단은 LPS 가 끝낸다. +ALTER TABLE partner.item_internet_lowest_prices + ADD COLUMN IF NOT EXISTS partial BOOLEAN NOT NULL DEFAULT FALSE; + +COMMENT ON COLUMN partner.item_internet_lowest_prices.sources IS + '몰별 확인 상태 {몰: {state, count|error}} — lps_db.price_history.sources 미러링. state=SourceState'; +COMMENT ON COLUMN partner.item_internet_lowest_prices.partial IS + '못 본 몰이 있어 결과가 최종이 아님 — 화면은 이 값으로 "일부 확인 못함"을 표시'; + +-- 검증 +SELECT column_name, data_type, column_default + FROM information_schema.columns + WHERE table_schema = 'partner' AND table_name = 'item_internet_lowest_prices' + AND column_name IN ('by_mall', 'sources', 'partial') + ORDER BY column_name;