import uuid from datetime import datetime from decimal import Decimal from typing import Optional from pydantic import ConfigDict from common.enums import ExternalPlaceSource, JobStatus, LinkChannel, PlaceCategory, PlaceStatus, SourceType from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol # 라우터 폴더마다 protocol.py 를 두고 Req_/Res_ 를 정의한다 (protocol 규약). class PlaceProtocol(WebPacketProtocol): pass class Req_CreatePlace(PlaceProtocol): # 상호명 하나로 시작한다. name: str = "" category: PlaceCategory = PlaceCategory.LODGING # 주인은 받지 않는다 — 토큰이 정한다(place_service.create_place). class Req_VerifyPlace(PlaceProtocol): """카카오 로컬 조회 결과를 사업장에 박제한다(동일 업소 확정).""" source: ExternalPlaceSource = ExternalPlaceSource.NAVER external_place_id: str = "" # 외부 장소 DB 가 함께 준 업체 홈페이지 URL. place_url: Optional[str] = None road_address: Optional[str] = None address: Optional[str] = None phone: Optional[str] = None latitude: Optional[Decimal] = None longitude: Optional[Decimal] = None region_code: Optional[str] = None # 외부 장소 DB 의 분류 문자열(후보의 category_name). category_name: Optional[str] = None class Req_VerifyPlaceByUrl(PlaceProtocol): """네이버 플레이스 URL 하나로 동일 업소를 확정한다.""" url: str = "" # 같은 가게를 이미 갖고 있을 때 그 사업장으로 이어붙일지. reuse_existing: bool = True class Req_UpdatePlace(PlaceProtocol): # 주인은 못 바꾼다(위 Req_CreatePlace 주석). name: Optional[str] = None status: Optional[PlaceStatus] = None # 미니 블로그 승인 메일 수신 주소. notify_email: Optional[str] = None class Req_CreateUnit(PlaceProtocol): name: str = "" sort_order: int = 0 class Req_CreateLink(PlaceProtocol): channel: LinkChannel = LinkChannel.ETC url: str = "" title: Optional[str] = None discovered_by: SourceType = SourceType.OWNER class PlaceData(WebPacketProtocol): model_config = ConfigDict(from_attributes=True) place_id: uuid.UUID name: str category: PlaceCategory status: PlaceStatus owner_user_id: Optional[uuid.UUID] = None external_source: Optional[ExternalPlaceSource] = None external_place_id: Optional[str] = None road_address: Optional[str] = None address: Optional[str] = None phone: Optional[str] = None latitude: Optional[Decimal] = None longitude: Optional[Decimal] = None region_code: Optional[str] = None verified_at: Optional[datetime] = None content_updated_at: Optional[datetime] = None # 노출값 변경 시각 — 개별 재빌드 대상 판별 notify_email: Optional[str] = None # 미니 블로그 승인 메일 수신 주소. created_at: Optional[datetime] = None class UnitData(WebPacketProtocol): model_config = ConfigDict(from_attributes=True) unit_id: uuid.UUID place_id: uuid.UUID name: str sort_order: int = 0 class LinkData(WebPacketProtocol): model_config = ConfigDict(from_attributes=True) link_id: uuid.UUID place_id: uuid.UUID channel: LinkChannel url: str title: Optional[str] = None discovered_by: SourceType discovered_at: Optional[datetime] = None confirmed_at: Optional[datetime] = None # NULL = 크롤링 대상 아님 class Res_PlaceList(Res_PageProtocol): places: list[PlaceData] = [] class Res_Place(Res_WebPacketProtocol): place: Optional[PlaceData] = None class Res_UnitList(Res_WebPacketProtocol): units: list[UnitData] = [] class Res_Unit(Res_WebPacketProtocol): unit: Optional[UnitData] = None class Res_LinkList(Res_WebPacketProtocol): links: list[LinkData] = [] confirmed: int = 0 # 크롤링 가능한 링크 수 class Res_Link(Res_WebPacketProtocol): link: Optional[LinkData] = None class Req_StartCollect(PlaceProtocol): """수집 시작.""" # 확정된 채널 URL 만 크롤링한다. link_ids: list[uuid.UUID] = [] # 이미 확보한 fact 를 다시 긁을지. force: bool = False # 상호·주소로 공개 채널 URL 을 Perplexity 에서 추가 탐색할지. discover_channels: bool = False class Res_StartCollect(Res_WebPacketProtocol): """수집 잡 적재 결과.""" job_id: Optional[uuid.UUID] = None status: Optional[JobStatus] = None created: bool = True confirmed_links: int = 0 class Req_StartVision(PlaceProtocol): """사진 분석 시작.""" # 이미 분석된 사진도 다시 태울지. force: bool = False class Res_StartVision(Res_WebPacketProtocol): job_id: Optional[uuid.UUID] = None status: Optional[JobStatus] = None created: bool = True pending_media: int = 0 # 분석 대상 사진 수 # 동일 업소 후보 (UI 가 사람에게 고르게 한다) class PlaceCandidate(WebPacketProtocol): """외부 장소 DB 에서 찾은 후보 1건.""" external_place_id: Optional[str] = None # 네이버는 안 준다 name: str = "" road_address: Optional[str] = None address: Optional[str] = None phone: Optional[str] = None # 네이버는 안 준다 latitude: Optional[Decimal] = None longitude: Optional[Decimal] = None category_name: Optional[str] = None place_url: Optional[str] = None # 업체 홈페이지 — 확정 시 공식 채널로 등록된다 naver_place_url: Optional[str] = None # 자동 발견한 네이버 플레이스. class Res_VerifyCandidates(Res_WebPacketProtocol): """동일 업소 후보 목록.""" source: Optional[ExternalPlaceSource] = None outcome: str = "" # matched | ambiguous | no_candidate reason: Optional[str] = None # 판정 근거(name_exact / name_duplicate / name_no_exact …) auto_selectable: bool = False # True 면 UI 가 '이거 맞나요?' 한 번만 물어도 된다 candidates: list[PlaceCandidate] = [] # 공개 상호명 검색 (랜딩 첫 화면) class PlaceSearchItem(WebPacketProtocol): """공개 검색 결과 1건.""" name: str = "" road_address: Optional[str] = None category_name: Optional[str] = None # 외부 DB 의 분류 문자열(예: "숙박>펜션") # 추정 업종. category: Optional[PlaceCategory] = None # 자동으로 찾은 네이버 플레이스 주소. naver_place_url: Optional[str] = None class Res_PlaceSearch(Res_WebPacketProtocol): """상호명 공개 검색 결과.""" source: Optional[ExternalPlaceSource] = None items: list[PlaceSearchItem] = [] class Req_StartCopy(PlaceProtocol): """소개문·FAQ 생성 시작.""" resume: bool = False class Res_StartCopy(Res_WebPacketProtocol): job_id: Optional[uuid.UUID] = None status: Optional[JobStatus] = None created: bool = True grounded_facts: int = 0 # 근거로 쓸 수 있는 확인된 fact 수