feat(negodata): 4단계 — 사용자 화면에서 '없음'과 '확인 못함'을 구분

마지막 단계. 차단당해 못 본 몰이 화면에서 '–'(없음)로 보여 사용자가 "쿠팡엔 더 싼 게 없구나"로
오해하던 문제를 끝낸다. 안 본 걸 없다고 말하지 않는다.

사용자 화면은 **셋으로 접는다**(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 <noreply@anthropic.com>
This commit is contained in:
민헌 2026-08-07 11:21:56 +09:00
parent d1d1ed6ec5
commit 95086cb3f6
12 changed files with 176 additions and 14 deletions

View File

@ -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절이 소스다.

View File

@ -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, 워터마크 기준)

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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';

View File

@ -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;
}

View File

@ -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;

View File

@ -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 };

View File

@ -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<string, number>;
// 값이 없는 몰은 두 경우다 — '거기엔 없었다'와 '거기를 못 봤다'. 뜻이 정반대라 같이 보이면 안 된다.
// 사용자가 할 수 있는 건 '쓴다 / 다시 시도 / 넘어간다' 뿐이라, 원인이 달라도 다음 행동이 같으면
// 같은 표기로 접는다(운영자 화면 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<string, { state?: string }> | 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 <span className="text-muted-foreground">–</span>;
if (price === undefined) {
// 안 본 걸 '없음(–)'으로 보여주면 사용자는 '이 몰엔 더 싼 게 없다'로 읽는다 — 사실이 아니다.
const unseen = (s.kind === 'done' || s.kind === 'notfound') && s.unconfirmed?.includes(source);
return unseen
? <span className="text-amber-600" title="사이트 차단 등으로 이 몰의 가격을 확인하지 못했습니다">확인 못함</span>
: <span className="text-muted-foreground">–</span>;
}
const isBest = price === best;
return (
<span className={isBest ? 'font-semibold text-foreground' : 'text-muted-foreground'}>

View File

@ -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 <host> -p <port> -U <user> -d <db> -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;