o2o-negosium-original/negodata/backend/router/v1/item/protocol.py
민헌 28b4a9e797 feat(negodata): lowest-price 스텁 API 실구현 — 즉시 트리거 + 온디맨드 동기화 조회
- POST /v1/item/{id}/lowest-price: 상품 1건 LPS 즉시 검색요청(manual
  우선순위). queued/duplicated/unavailable 상태 반환, 실패는
  ErrorType.LPS_UNAVAILABLE(2000 블록 신설)
- GET /v1/item/{id}/lowest-price: 대표 최저가 + 최근 수집 이력(최신순,
  LowestPriceEntry 타입화). 조회 전 lps_db 증분 동기화 1회 수행 —
  5분 크론을 기다리지 않는 실시간 폴링 UX(멱등·저비용)
- 동시성 방어 2겹: 프로세스 내 asyncio.Lock(크론·온디맨드 직렬화) +
  uq_iilp_item_crawl_time 유니크 인덱스(alters/2026-07-10, init.sql
  멱등 반영·dev DB 적용) — 경합 진 쪽 tx 실패 후 다음 tick 흡수
- LpsSyncService 무인자 생성자(FastAPI Depends 호환)
- compose: negodata-backend 에 LPS_DB_HOST·LPS_BASE_URL env(미설정 시
  연동 비활성으로 조용히 동작)

검증(도커 컨테이너 e2e): 인증→POST queued→LPS 크롤→GET 온디맨드
동기화로 이력 즉시 노출(not_found 정책: 대표값 미변경 확인),
company 스코프 차단(타사 상품 ITEM_NOT_FOUND) 확인

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:17:05 +09:00

150 lines
5.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import uuid
from datetime import datetime
from typing import Optional
from pydantic import ConfigDict
from common.enums import DeliveryType
from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol
class ItemProtocol(WebPacketProtocol):
pass
class Req_CreateItem(ItemProtocol):
name: str = ""
code: Optional[str] = None
category: Optional[str] = None
category_type: int = 1
image_url: Optional[str] = None
model_name: Optional[str] = None
spec: Optional[str] = None
manufacturer: Optional[str] = None
made_in: Optional[str] = None
price: Optional[int] = None
internet_lowest_price_yn: bool = False
internet_lowest_price: Optional[int] = None # 인터넷 최저가 실값(원). 목표가 산정용
purchase_price: Optional[int] = None # 매입가(원). 재견적 목표가 후보(그대로)
selling_price: Optional[int] = None # 판매가(원). 유통형 목표가 후보(×(1마진율))
moq: Optional[str] = None
lead_time: Optional[int] = None
quantity_unit: Optional[str] = None
delivery_type: Optional[int] = None
vat_yn: Optional[bool] = None
delivery_fee_yn: Optional[bool] = None
class Req_UpdateItem(ItemProtocol):
name: Optional[str] = None
code: Optional[str] = None
category: Optional[str] = None
category_type: Optional[int] = None
image_url: Optional[str] = None
model_name: Optional[str] = None
spec: Optional[str] = None
manufacturer: Optional[str] = None
made_in: Optional[str] = None
price: Optional[int] = None
internet_lowest_price_yn: Optional[bool] = None
internet_lowest_price: Optional[int] = None # 인터넷 최저가 실값(원). 목표가 산정용
purchase_price: Optional[int] = None # 매입가(원). 재견적 목표가 후보(그대로)
selling_price: Optional[int] = None # 판매가(원). 유통형 목표가 후보(×(1마진율))
moq: Optional[str] = None
lead_time: Optional[int] = None
quantity_unit: Optional[str] = None
delivery_type: Optional[int] = None
vat_yn: Optional[bool] = None
delivery_fee_yn: Optional[bool] = None
class ItemData(WebPacketProtocol):
model_config = ConfigDict(from_attributes=True)
item_id: uuid.UUID
company_id: uuid.UUID
user_id: uuid.UUID
creator_name: Optional[str] = None # 등록자(작성자) 이름. user_id→company.users.name 조인
name: str
code: Optional[str] = None
category: Optional[str] = None
category_type: int = 1
image_url: Optional[str] = None
model_name: Optional[str] = None
spec: Optional[str] = None
manufacturer: Optional[str] = None
made_in: Optional[str] = None
price: Optional[int] = None
internet_lowest_price_yn: bool = False
internet_lowest_price: Optional[int] = None # 인터넷 최저가 실값(원). 목표가 산정용
purchase_price: Optional[int] = None # 매입가(원). 재견적 목표가 후보(그대로)
selling_price: Optional[int] = None # 판매가(원). 유통형 목표가 후보(×(1마진율))
moq: Optional[str] = None
lead_time: Optional[int] = None
quantity_unit: Optional[str] = None
delivery_type: Optional[DeliveryType] = None
vat_yn: Optional[bool] = None
delivery_fee_yn: Optional[bool] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class Res_Item(Res_WebPacketProtocol):
item: Optional[ItemData] = None
class Res_ItemList(Res_PageProtocol):
items: list[ItemData] = []
class ItemCategory(WebPacketProtocol):
name: str
category_type: int = 1
class Res_ItemCategories(Res_WebPacketProtocol):
categories: list[ItemCategory] = []
class Req_CheckCodes(ItemProtocol):
codes: list[str] = []
class Res_CheckCodes(Res_WebPacketProtocol):
existing: list[str] = [] # codes 중 이미 DB(같은 회사)에 존재하는 코드들
class Res_DeleteItem(Res_WebPacketProtocol):
pass
class Res_ItemImage(Res_WebPacketProtocol):
image_url: Optional[str] = None
filename: Optional[str] = None
size: Optional[int] = None
class Res_LowestPriceTrigger(Res_WebPacketProtocol):
item_id: str = ""
status: str = "" # queued(접수) | duplicated(이미 진행 중) | unavailable(연동 비활성/LPS 다운)
message: str = ""
class LowestPriceEntry(WebPacketProtocol):
"""최저가 수집 이력 1건(partner.item_internet_lowest_prices)."""
model_config = ConfigDict(from_attributes=True)
lp_price: Optional[int] = None # 수집한 최저가(원). 실패면 None
website: int = 0 # LowestPriceWebsite 코드(1=naver 2=coupang …)
success_yn: bool = False
fail_reason: Optional[str] = None
crawl_end_time: Optional[datetime] = None
class Res_LowestPriceResult(Res_WebPacketProtocol):
item_id: str = ""
lowest_price: Optional[int] = None # 상품에 박제된 대표 최저가(items.internet_lowest_price)
results: list[LowestPriceEntry] = [] # 최근 수집 이력(최신순)
message: str = ""