diff --git a/lps/common/database/model/models.py b/lps/common/database/model/models.py index 44b26cf..78c0bce 100644 --- a/lps/common/database/model/models.py +++ b/lps/common/database/model/models.py @@ -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()")) diff --git a/lps/crud/price_history.py b/lps/crud/price_history.py index 904d147..9a40011 100644 --- a/lps/crud/price_history.py +++ b/lps/crud/price_history.py @@ -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 diff --git a/lps/docs/database.md b/lps/docs/database.md index 67f5c04..4a4b8c9 100644 --- a/lps/docs/database.md +++ b/lps/docs/database.md @@ -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`(그래프 선이 빈다 — 정상). diff --git a/lps/migrations/2026-07-09-price_history-by_mall.sql b/lps/migrations/2026-07-09-price_history-by_mall.sql new file mode 100644 index 0000000..53d664b --- /dev/null +++ b/lps/migrations/2026-07-09-price_history-by_mall.sql @@ -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; diff --git a/lps/router/v1/lps/protocol.py b/lps/router/v1/lps/protocol.py index 5851a36..4097f2d 100644 --- a/lps/router/v1/lps/protocol.py +++ b/lps/router/v1/lps/protocol.py @@ -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): diff --git a/lps/services/lps_service.py b/lps/services/lps_service.py index 0b8b4c8..30f29f6 100644 --- a/lps/services/lps_service.py +++ b/lps/services/lps_service.py @@ -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 ] diff --git a/lps/worker/handlers.py b/lps/worker/handlers.py index 3152b72..7e64693 100644 --- a/lps/worker/handlers.py +++ b/lps/worker/handlers.py @@ -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), # 몰별 최저가 스냅샷(열린 스키마) }