몰별 성공/실패 표시
lps_db.price_history 에는 몰별 정보가 다 있는데(naver_lowest·coupang_lowest·by_mall),
동기화가 final_source(이긴 몰) 하나만 남기고 나머지를 버려서 API 로는
"네이버는 어땠는지"를 알 수 없었다. 실제로 네이버는 빈손이고 쿠팡만 성공하는
케이스가 기본값처럼 나오는 중이라 화면에 드러낼 필요가 있다.
→ price_history.by_mall(JSONB)을 partner.item_internet_lowest_prices 로 그대로
미러링한다(열린 스키마 — 오픈마켓 폴백이 늘어도 스키마 변경 불필요).
- crud: 읽기 계약에 by_mall 추가 + SELECT 포함
- model: item_internet_lowest_prices.by_mall
- service: 언팩·저장
- protocol: LowestPriceEntry.by_mall
- init.sql: 테이블 정의 + 하단 보정 ALTER(기존 DB 반영용)
강제 재검색
POST /v1/item/{id}/lowest-price?force=true → LPS 네거티브 캐시 우회.
사용자가 '다시 검색'을 누른 경우에만 true.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
159 lines
5.9 KiB
Python
159 lines
5.9 KiB
Python
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
|
||
custom: Optional[dict] = None # 회사 커스텀필드 값 {key: value} (정의는 companies.settings.item_fields)
|
||
|
||
|
||
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
|
||
custom: Optional[dict] = None # 회사 커스텀필드 값 {key: value}
|
||
|
||
|
||
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
|
||
custom: Optional[dict] = None # 회사 커스텀필드 값 {key: value}
|
||
supplier_names: list[str] = [] # 이 상품을 취급(계약)하는 공급사명 목록(목록 컬럼용, 배치 조인)
|
||
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
|
||
lp_name: Optional[str] = None # 찾은 상품명(판매 페이지 기준) — 근거 검증용
|
||
lp_url: Optional[str] = None # 찾은 판매 페이지 링크
|
||
# 몰별 최저가 스냅샷(가격 오름차순). 매칭된 몰만 들어오므로, 없는 몰은 그 회차에 빈손이었다는 뜻.
|
||
# [{source: 'naver'|'coupang'|…, mall_name, price, shipping_fee, shipping_type, name, detail_url}]
|
||
by_mall: Optional[list[dict]] = 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 = ""
|