o2o-negosium-original/negodata/backend/crud/lps_sync_crud.py
민헌 95086cb3f6 feat(negodata): 4단계 — 사용자 화면에서 '없음'과 '확인 못함'을 구분
마지막 단계. 차단당해 못 본 몰이 화면에서 '–'(없음)로 보여 사용자가 "쿠팡엔 더 싼 게 없구나"로
오해하던 문제를 끝낸다. 안 본 걸 없다고 말하지 않는다.

사용자 화면은 **셋으로 접는다**(lps/docs/result-states.md 3-2절). 할 수 있는 행동이
'쓴다/다시 시도/넘어간다' 뿐이라, 원인이 달라도 다음 행동이 같으면 같은 표기다:
  matched                            → 가격
  no_match · empty                   → '–'        (확인했고 없었다)
  blocked · env_blocked · unavailable → '확인 못함' (못 봤다)
운영자 화면(lps-admin)은 같은 데이터로 7상태를 그대로 본다 — 목적이 진단이라 접지 않는다.

체인 전체를 이었다:
- postgres-init/alters/2026-08-07-iilp-source-state.sql — item_internet_lowest_prices 에
  sources/partial 추가(멱등, **운영 적용 필요**)
- models.py / lps_sync_crud 읽기 계약 / lps_sync_service 미러링 / LowestPriceEntry 프로토콜
- orval 재생성(ORVAL_INPUT 으로 저장 스펙에서 — 서버 없이). 생성 diff 는 새 필드만.
- PriceUpdateModal: 가격이 없는 몰이 '못 본 몰'이면 '–' 대신 '확인 못함'.

partial 은 LPS 가 판단해 내려준 사실을 그대로 쓴다 — 화면이 '어떤 상태가 확인된 것인가'를
다시 판정하면 상태 정의가 LPS 와 negodata 두 곳으로 흩어진다.

E2E 검증(실 DB 2시나리오): by_mall 은 둘 다 naver 뿐인데 쿠팡 칸이 '확인 못함'(차단) / '–'(0건)
으로 갈린다. tsc 오류 없음(기존 xlsx 미설치 오류는 무관). negodata 97 · lps 292 passed.

> 폴더 관례상 negodata 는 인수인계 대상이나, 사용자 요청으로 이번 건도 예외 적용.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 11:21:56 +09:00

149 lines
6.9 KiB
Python

"""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("naver_name"), # 네이버 최저가 상품명/링크 — final_source 에 맞는 출처를 실어온다
column("naver_url"),
column("coupang_name"), # 쿠팡 최저가 상품명/링크
column("coupang_url"),
column("by_mall"), # 몰별 최저가 스냅샷(JSONB 배열) — 가격이 **있는** 몰만
column("sources"), # 몰별 확인 상태 — 빠진 몰이 '없었다'인지 '못 봤다'인지는 여기에만 있다
column("partial"), # 못 본 몰이 있어 결과가 최종이 아님(LPS 판단 결과)
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.naver_name,
_price_history.c.naver_url,
_price_history.c.coupang_name,
_price_history.c.coupang_url,
_price_history.c.by_mall,
_price_history.c.sources,
_price_history.c.partial,
_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}")