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:
parent
d1d1ed6ec5
commit
95086cb3f6
@ -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절이 소스다.
|
||||
|
||||
@ -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, 워터마크 기준)
|
||||
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
33
negodata/backend/tests/test_lps_sync_state.py
Normal file
33
negodata/backend/tests/test_lps_sync_state.py
Normal 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"
|
||||
@ -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';
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
@ -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 };
|
||||
@ -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'}>
|
||||
|
||||
34
postgres-init/alters/2026-08-07-iilp-source-state.sql
Normal file
34
postgres-init/alters/2026-08-07-iilp-source-state.sql
Normal 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;
|
||||
Loading…
Reference in New Issue
Block a user