1단계에서 만든 SourceState 가 job.result 에만 있어 화면까지 못 갔다. by_mall 은 **가격이 있는
몰만** 담으므로, 빠진 몰이 '거기엔 없더라'인지 '거기를 못 봤다'인지 구분할 자리가 없었다.
- price_history.sources (JSONB): 몰별 상태를 그대로 담는다.
{"naver": {"state": "matched", "count": 40}, "coupang": {"state": "blocked", "error": "..."}}
열린 스키마라 몰이 늘거나 근거를 덧붙여도 마이그레이션이 필요 없다(by_mall 과 같은 방침).
- price_history.partial (bool): 결과가 완전한가. sources 에서 유도 가능하지만 컬럼으로 둔다 —
소비자가 '어떤 상태가 확인된 것인가'라는 판단 규칙까지 알아야 하면 **상태 정의가 두 곳으로
흩어진다**. 판단은 LPS 가 끝내고 소비자(negodata·lps-admin)는 사실 하나만 읽는다.
- 부분 인덱스 ix_price_history_partial — partial=true 행만 담아 작게 유지(운영 점검·알림용).
- _record_history 가 per_source 를 받아 partial 을 계산해 기록한다. 네거티브 캐시 히트 경로는
sources 없이 남긴다(부분 결과는 애초에 캐시하지 않으므로 항상 확정).
- 마이그레이션: postgres-init/dbeaver/7_lps_source_state_dbeaver.sql (재실행 안전, **운영 적용 필요**)
검증(로컬 실 DB): 쿠팡 차단 vs 쿠팡 0건은 by_mall 이 둘 다 ['naver'] 로 같지만
partial(true/false)·sources.coupang.state(blocked/empty)가 두 경우를 갈라낸다.
JSONB 는 ensure_ascii=False 로 한글 사유가 깨지지 않는 것도 테스트로 고정.
테스트 3건 추가, 전체 289 passed. 진행 상황은 docs/result-states.md 4절.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
99 lines
4.6 KiB
Python
99 lines
4.6 KiB
Python
"""최저가 스냅샷 CRUD — 트리거 시점마다 기록하고, 상품별 시계열로 조회(그래프)."""
|
|
|
|
import json
|
|
|
|
from sqlalchemy import text
|
|
|
|
from common.database.db_session_manager import DB_SESSION_MNG
|
|
from common.enums import DBType, DBWRType
|
|
|
|
_FIELDS = (
|
|
"product_code", "job_id", "outcome", "matched_count",
|
|
"naver_lowest", "naver_name", "naver_url",
|
|
"coupang_lowest", "coupang_name", "coupang_url",
|
|
"final_lowest", "final_source", "final_rating", "final_review_count",
|
|
"final_shipping_fee", "final_shipping_type", "final_shipping_label",
|
|
"partial",
|
|
)
|
|
|
|
|
|
class PriceHistory:
|
|
DB = DBType.MAIN.value
|
|
|
|
async def record(self, event: dict):
|
|
"""스냅샷 1건 저장. triggered_at 은 now()(관측 시각). 로깅 실패가 검색을 막지 않도록 호출부에서 예외 처리."""
|
|
sql = text("""
|
|
INSERT INTO price_history
|
|
(product_code, job_id, outcome, matched_count,
|
|
naver_lowest, naver_name, naver_url,
|
|
coupang_lowest, coupang_name, coupang_url,
|
|
final_lowest, final_source, final_rating, final_review_count,
|
|
final_shipping_fee, final_shipping_type, final_shipping_label,
|
|
by_mall, sources, partial)
|
|
VALUES
|
|
(:product_code, :job_id, :outcome, :matched_count,
|
|
:naver_lowest, :naver_name, :naver_url,
|
|
:coupang_lowest, :coupang_name, :coupang_url,
|
|
:final_lowest, :final_source, :final_rating, :final_review_count,
|
|
:final_shipping_fee, :final_shipping_type, :final_shipping_label,
|
|
CAST(:by_mall AS jsonb), CAST(:sources AS jsonb), COALESCE(:partial, FALSE))
|
|
""")
|
|
params = {k: event.get(k) for k in _FIELDS}
|
|
for col in ("by_mall", "sources"): # JSONB 는 문자열로 넘겨 CAST 한다
|
|
v = event.get(col)
|
|
params[col] = json.dumps(v, ensure_ascii=False) if v is not None else None
|
|
s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_WRITE.value)
|
|
try:
|
|
await s.execute(sql, params)
|
|
await s.commit()
|
|
except Exception:
|
|
await s.rollback()
|
|
raise
|
|
finally:
|
|
await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_WRITE.value)
|
|
|
|
async def list_by_product(self, product_code: str, limit: int = 100) -> list[dict]:
|
|
"""상품의 최근 스냅샷을 시각 오름차순(그래프 플롯용)으로 반환. 최근 limit 건."""
|
|
sql = text("""
|
|
SELECT * FROM (
|
|
SELECT triggered_at, outcome, matched_count,
|
|
naver_lowest, naver_name, naver_url,
|
|
coupang_lowest, coupang_name, coupang_url,
|
|
final_lowest, final_source, by_mall
|
|
FROM price_history
|
|
WHERE product_code = :pc
|
|
ORDER BY triggered_at DESC
|
|
LIMIT :lim
|
|
) t ORDER BY triggered_at ASC
|
|
""")
|
|
s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_READ.value)
|
|
try:
|
|
rows = (await s.execute(sql, {"pc": product_code, "lim": limit})).mappings().all()
|
|
return [dict(r) for r in rows]
|
|
finally:
|
|
await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_READ.value)
|
|
|
|
async def list_products(self, q: str | None = None, limit: int = 50) -> list[dict]:
|
|
"""이력이 있는 상품 목록(관리자 FE) — 상품별 최신 스냅샷 + 검색 횟수, 최근 검색순.
|
|
상품명은 price_history 에 없어 최신 스냅샷의 매칭 상품명(네이버 우선)으로 대신한다."""
|
|
where = "WHERE product_code ILIKE :q" if q else ""
|
|
sql = text(f"""
|
|
SELECT * FROM (
|
|
SELECT DISTINCT ON (product_code)
|
|
product_code, triggered_at, outcome,
|
|
naver_lowest, coupang_lowest, final_lowest, final_source,
|
|
COALESCE(naver_name, coupang_name) AS display_name,
|
|
count(*) OVER (PARTITION BY product_code) AS searches
|
|
FROM price_history {where}
|
|
ORDER BY product_code, triggered_at DESC
|
|
) t ORDER BY triggered_at DESC LIMIT :lim
|
|
""")
|
|
params: dict = {"lim": limit}
|
|
if q:
|
|
params["q"] = f"%{q}%"
|
|
s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_READ.value)
|
|
try:
|
|
return [dict(r) for r in (await s.execute(sql, params)).mappings().all()]
|
|
finally:
|
|
await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_READ.value)
|