feat(lps): price_history 몰별 최저가 스냅샷(by_mall JSONB)

몰이 열린 집합이라 와이드 컬럼(gmarket_*, st11_* …) 대신 JSONB 한 컬럼으로 담는다
— 몰 추가 시 마이그레이션 0. naver/coupang/final 3선 컬럼은 그래프 하위호환 유지.

- models: price_history.by_mall JSONB 추가
- crud: record/list 에 by_mall 왕복(json.dumps + CAST jsonb)
- handler: _price_snapshot 에 summarize_by_mall 적재, final 은 소스무관 전체 최저로
- protocol/service: history API 응답에 by_mall 노출
- migrations/2026-07-09: 기존 dev DB 동기화용 ALTER(추적 파일)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
민헌 2026-07-09 14:15:58 +09:00
parent 2eccf4de50
commit 3a2c6e43d4
7 changed files with 23 additions and 7 deletions

View File

@ -93,6 +93,9 @@ class price_history(MAIN_BASE):
coupang_url = Column(Text, nullable=True)
final_lowest = Column(Integer, nullable=True) # 전체 최저가(Y축 핵심)
final_source = Column(String(20), nullable=True) # 최종 최저가 소스
# 몰별 최저가 스냅샷(열린 스키마) — [{mall, source, price, shipping_fee, shipping_type, url}, ...].
# 몰이 늘어도 컬럼 추가/마이그레이션 없이 담는다(G마켓·옥션·11번가 등). naver/coupang 3선은 위 컬럼 유지.
by_mall = Column(JSONB, nullable=True)
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("now()"))

View File

@ -1,5 +1,7 @@
"""최저가 스냅샷 CRUD — 트리거 시점마다 기록하고, 상품별 시계열로 조회(그래프)."""
import json
from sqlalchemy import text
from common.database.db_session_manager import DB_SESSION_MNG
@ -23,14 +25,16 @@ class PriceHistory:
(product_code, job_id, outcome, matched_count,
naver_lowest, naver_name, naver_url,
coupang_lowest, coupang_name, coupang_url,
final_lowest, final_source)
final_lowest, final_source, by_mall)
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_lowest, :final_source, CAST(:by_mall AS jsonb))
""")
params = {k: event.get(k) for k in _FIELDS}
by_mall = event.get("by_mall")
params["by_mall"] = json.dumps(by_mall) if by_mall is not None else None
s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_WRITE.value)
try:
await s.execute(sql, params)
@ -48,7 +52,7 @@ class PriceHistory:
SELECT triggered_at, outcome, matched_count,
naver_lowest, naver_name, naver_url,
coupang_lowest, coupang_name, coupang_url,
final_lowest, final_source
final_lowest, final_source, by_mall
FROM price_history
WHERE product_code = :pc
ORDER BY triggered_at DESC

View File

@ -58,7 +58,8 @@
| `naver_lowest` / `naver_name` / `naver_url` | 네이버 최저가 + 상품명/링크 |
| `coupang_lowest` / `coupang_name` / `coupang_url` | 쿠팡 최저가 + 상품명/링크 |
| `final_lowest` | 전체 최저가 (**그래프 Y축 핵심**) |
| `final_source` | 최종 최저가가 나온 소스(naver/coupang) |
| `final_source` | 최종 최저가가 나온 소스(naver/coupang/gmarket/auction/st11) |
| `by_mall` | 몰별 최저가 스냅샷(JSONB, 열린 스키마) — `[{mall, source, price, shipping_fee, shipping_type, url}, …]`. G마켓·옥션·11번가 등이 늘어도 컬럼 추가 없이 담는다 |
| `job_id` / `created_at` | 검색 잡 연결 / 생성 시각 |
> 한쪽 소스에 그 상품이 없던 시점은 해당 컬럼이 `null`(그래프 선이 빈다 — 정상).

View File

@ -0,0 +1,5 @@
-- price_history 에 몰별 최저가 스냅샷(JSONB) 추가.
-- lps 스키마는 SQLAlchemy create_all 이 단일 소스라, 신규 DB 는 모델로 자동 생성된다.
-- 이 파일은 '이미 만들어진' dev/운영 DB 를 모델과 동기화하기 위한 것(인라인 즉석 ALTER 대신 추적 파일).
-- psql -h 127.0.0.1 -U postgres -d lps_db -f migrations/2026-07-09-price_history-by_mall.sql
ALTER TABLE price_history ADD COLUMN IF NOT EXISTS by_mall JSONB;

View File

@ -59,6 +59,7 @@ class PricePoint(BaseModel):
naver_url: Optional[str] = None
coupang_name: Optional[str] = None
coupang_url: Optional[str] = None
by_mall: Optional[list[dict]] = Field(None, description="몰별 최저가 스냅샷(G마켓·옥션·11번가 등 포함)")
class Res_PriceHistory(Res_WebPacketProtocol):

View File

@ -82,6 +82,7 @@ class LpsService:
final_source=r["final_source"],
naver_name=r["naver_name"], naver_url=r["naver_url"],
coupang_name=r["coupang_name"], coupang_url=r["coupang_url"],
by_mall=r.get("by_mall"),
)
for r in rows
]

View File

@ -19,7 +19,7 @@ from common.enums import JobType
from common.logger import LOG
from services.search.contract import SearchAdapter, NormalizedProduct
from services.search.util import parse_price
from services.pipeline.core import apply_filters, rank_result
from services.pipeline.core import apply_filters, rank_result, summarize_by_mall
def _price_snapshot(matched: list[NormalizedProduct]) -> dict:
@ -29,13 +29,14 @@ def _price_snapshot(matched: list[NormalizedProduct]) -> dict:
return min(items, key=lambda p: p.price) if items else None
n, c = lowest("naver"), lowest("coupang")
finals = [x for x in (n, c) if x]
f = min(finals, key=lambda p: p.price) if finals else None
# 최종 최저가는 소스 무관 전체 매칭 중 최저(G마켓·옥션·11번가 등 폴백 포함).
f = min(matched, key=lambda p: p.price) if matched else None
return {
"matched_count": len(matched),
"naver_lowest": n.price if n else None, "naver_name": n.name if n else None, "naver_url": n.detail_url if n else None,
"coupang_lowest": c.price if c else None, "coupang_name": c.name if c else None, "coupang_url": c.detail_url if c else None,
"final_lowest": f.price if f else None, "final_source": f.source if f else None,
"by_mall": summarize_by_mall(matched), # 몰별 최저가 스냅샷(열린 스키마)
}