"""LPS 동기화 CRUD — negosium(main) 쪽 조회/반영 + lps_db(읽기전용) 결과 조회. 경계 규칙 - lps_db 는 LPS 서비스 소유라 ORM 모델을 만들지 않는다 — anchoring reader 선례대로 raw table() 로 **price_history 만** 읽는다(그 외 테이블은 LPS 내부 구현으로 간주). - 워터마크는 별도 상태 테이블 없이 partner.item_internet_lowest_prices 의 max(crawl_end_time) 을 쓴다(crawl_end_time = price_history.created_at 을 그대로 보존). 반영이 중간에 실패해도 다음 tick 이 같은 지점부터 다시 읽는다(자기 치유). """ from abc import ABC, abstractmethod from typing import Tuple from sqlalchemy import column, func, select, table, update from sqlalchemy.ext.asyncio import AsyncSession from common.database.db_session_manager import DB_SESSION_MNG from common.database.model.models import item_internet_lowest_prices, items from common.enums import ErrorType from common.logger import LOG # lps_db.price_history — LPS 가 검색 1건마다 남기는 append-only 스냅샷(읽기 계약). _price_history = table( "price_history", column("product_code"), # = negosium items.item_id (요청 시 그대로 넣는다) column("outcome"), # found | not_found column("final_lowest"), # 전체 최저가(원) column("final_source"), # naver | coupang | (폴백몰) column("created_at"), ) class ILpsSyncCRUD(ABC): @abstractmethod async def watermark(self, cdb: AsyncSession) -> Tuple[ErrorType, object]: pass @abstractmethod async def existing_item_ids(self, cdb: AsyncSession, item_ids: list) -> Tuple[ErrorType, set]: pass @abstractmethod async def fetch_results_since(self, ldb: AsyncSession, since) -> Tuple[ErrorType, list]: pass @abstractmethod async def recent_history(self, cdb: AsyncSession, item_id, limit) -> Tuple[ErrorType, list]: pass @abstractmethod async def add_history_rows(self, cdb: AsyncSession, rows: list) -> ErrorType: pass @abstractmethod async def update_item_lowest(self, cdb: AsyncSession, item_id, price) -> ErrorType: pass class LpsSyncCRUD(ILpsSyncCRUD): # ---- negosium(main) 조회 ------------------------------------------- async def watermark(self, cdb: AsyncSession): """수집 이력의 최신 crawl_end_time — 증분 동기화 기준점. 이력이 없으면 None(전체 수집).""" try: q = select(func.max(item_internet_lowest_prices.crawl_end_time)).where( item_internet_lowest_prices.deleted == False # noqa: E712 ) return ErrorType.SUCCESS, (await cdb.execute(q)).scalar() except Exception as ex: LOG.e_no_callstack(f"[lps-sync] watermark 조회 실패: {ex}") return ErrorType.DB_RUN_FAILED, None async def existing_item_ids(self, cdb: AsyncSession, item_ids: list): """전달된 id 중 실재하는 활성 상품 id 집합 — 결과 반영 전 매핑 검증용.""" if not item_ids: return ErrorType.SUCCESS, set() try: q = select(items.item_id).where( items.item_id.in_(item_ids), items.deleted == False, # noqa: E712 ) return ErrorType.SUCCESS, {r[0] for r in (await cdb.execute(q)).all()} except Exception as ex: LOG.e_no_callstack(f"[lps-sync] existing_item_ids 조회 실패: {ex}") return ErrorType.DB_RUN_FAILED, set() async def recent_history(self, cdb: AsyncSession, item_id, limit: int): """상품의 최근 수집 이력(최신순) — lowest-price 조회 API 용. idx_iilp_item_crawl_time 사용.""" try: q = ( select(item_internet_lowest_prices) .where( item_internet_lowest_prices.item_id == item_id, item_internet_lowest_prices.deleted == False, # noqa: E712 ) .order_by(item_internet_lowest_prices.crawl_end_time.desc()) .limit(limit) ) return ErrorType.SUCCESS, (await cdb.execute(q)).scalars().all() except Exception as ex: LOG.e_no_callstack(f"[lps-sync] recent_history 조회 실패: {ex}") return ErrorType.DB_RUN_FAILED, [] # ---- lps_db(읽기전용) 조회 ----------------------------------------- async def fetch_results_since(self, ldb: AsyncSession, since): """워터마크 이후의 price_history 증분. since 가 None 이면 전체(첫 동기화).""" try: q = select( _price_history.c.product_code, _price_history.c.outcome, _price_history.c.final_lowest, _price_history.c.final_source, _price_history.c.created_at, ).order_by(_price_history.c.created_at.asc()) if since is not None: q = q.where(_price_history.c.created_at > since) return ErrorType.SUCCESS, (await ldb.execute(q)).all() except Exception as ex: LOG.e_no_callstack(f"[lps-sync] price_history 조회 실패: {ex}") return ErrorType.DB_RUN_FAILED, [] # ---- negosium(main) 반영 (execute_lambda_run 안에서 호출) ----------- async def add_history_rows(self, cdb: AsyncSession, rows: list) -> ErrorType: """수집 이력 일괄 insert. rows = item_internet_lowest_prices ORM 객체 리스트.""" if not rows: return ErrorType.SUCCESS return await DB_SESSION_MNG.insert(cdb, rows, err_msg="lps-sync history insert 실패") async def update_item_lowest(self, cdb: AsyncSession, item_id, price) -> ErrorType: """상품 대표 최저가 박제 — items.internet_lowest_price + yn 플래그.""" q = ( update(items) .where(items.item_id == item_id, items.deleted == False) # noqa: E712 .values(internet_lowest_price=price, internet_lowest_price_yn=True) ) return await DB_SESSION_MNG.add(cdb, q, err_msg=f"lps-sync 최저가 반영 실패 item={item_id}")