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:
parent
2eccf4de50
commit
3a2c6e43d4
@ -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()"))
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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`(그래프 선이 빈다 — 정상).
|
||||
|
||||
5
lps/migrations/2026-07-09-price_history-by_mall.sql
Normal file
5
lps/migrations/2026-07-09-price_history-by_mall.sql
Normal 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;
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
]
|
||||
|
||||
@ -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), # 몰별 최저가 스냅샷(열린 스키마)
|
||||
}
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user