- Added 'cho': '' parameter to enable searching both domestic and import cars - This fixes Toyota Prius (and other import cars) not appearing in search results Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2700 lines
108 KiB
Python
2700 lines
108 KiB
Python
"""
|
|
Carmodoo API - 실제 카모두 API 연동
|
|
"""
|
|
|
|
import asyncio
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from fastapi.responses import FileResponse
|
|
from sqlalchemy.orm import Session
|
|
from typing import Optional, List
|
|
from pydantic import BaseModel
|
|
import httpx
|
|
import json
|
|
import re
|
|
import os
|
|
from pathlib import Path
|
|
from lxml import etree
|
|
from lxml import html as lxml_html
|
|
from ..database import get_db
|
|
from ..models import Car, CarMaker, CarModel, CarImage, CarOption, CarPerformanceCheck, CarSpecification, PerformanceCheckView, CarView, User, VehicleRequest, RequestVehicle
|
|
from ..models.settings import SystemSettings
|
|
from ..api.auth import get_current_user, get_current_user_optional, get_current_admin_user
|
|
from ..services.cache_service import CacheService
|
|
from ..services.pdf_service import capture_performance_check_pdf, get_pdf_full_path, get_pdf_failures, clear_pdf_failure
|
|
from ..services.spec_service import get_specifications_from_carmodoo, spec_to_dict
|
|
from ..services.sensitive_filter import detect_sensitive_info, mask_sensitive_info, highlight_sensitive_info, has_sensitive_info, get_sensitivity_summary
|
|
from ..services.translation_service import translate_dealer_description, get_translation_service
|
|
|
|
router = APIRouter(prefix="/carmodoo", tags=["carmodoo"])
|
|
|
|
# Carmodoo 설정
|
|
CARMODOO_BASE_URL = "https://dealer.carmodoo.com"
|
|
CARMODOO_USER_ID = os.getenv("CARMODOO_USER_ID", "01033315258")
|
|
CARMODOO_PASSWORD = os.getenv("CARMODOO_PASSWORD", "alskfl@1122")
|
|
|
|
# JSON 데이터 로드
|
|
DATA_PATH = Path(__file__).parent.parent / "data" / "carmodoo_makers_models.json"
|
|
|
|
def load_makers_models():
|
|
"""JSON 파일에서 제조사/모델 데이터 로드"""
|
|
try:
|
|
with open(DATA_PATH, 'r', encoding='utf-8') as f:
|
|
return json.load(f)
|
|
except Exception as e:
|
|
print(f"Error loading makers/models: {e}")
|
|
return {"makers": [], "models": {}}
|
|
|
|
CARMODOO_DATA = load_makers_models()
|
|
|
|
|
|
# Response schemas
|
|
class CarmodooMaker(BaseModel):
|
|
code: str
|
|
name: str
|
|
|
|
|
|
class CarmodooModel(BaseModel):
|
|
code: str
|
|
name: str
|
|
|
|
|
|
class CarmodooSearchResultItem(BaseModel):
|
|
id: str
|
|
car_name: str
|
|
maker_name: Optional[str] = None
|
|
model_name: Optional[str] = None
|
|
year: Optional[int] = None
|
|
mileage: Optional[int] = None
|
|
original_price: Optional[int] = None
|
|
korea_margin: Optional[int] = None
|
|
mongolia_margin: Optional[int] = None
|
|
final_price: Optional[int] = None
|
|
fuel: Optional[str] = None
|
|
transmission: Optional[str] = None
|
|
color: Optional[str] = None
|
|
displacement: Optional[int] = None
|
|
main_image: Optional[str] = None
|
|
image_count: int = 20
|
|
check_num: Optional[str] = None # 성능점검번호 (Performance check number)
|
|
car_key: Optional[str] = None # 암호화된 차량 키 (dealerCarviewPopup용)
|
|
|
|
|
|
class VehicleRequestSearchResponse(BaseModel):
|
|
total: int
|
|
cars: List[CarmodooSearchResultItem]
|
|
|
|
|
|
class CarmodooClient:
|
|
"""카모두 API 클라이언트"""
|
|
|
|
def __init__(self):
|
|
self.cookies = {}
|
|
self.is_logged_in = False
|
|
self.headers = {
|
|
'User-Agent': 'Mozilla/5.0 (Windows NT 6.2; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Safari/537.36',
|
|
'Accept-Language': 'ko-KR,ko;q=0.9',
|
|
}
|
|
|
|
def _decode_response(self, content: bytes) -> str:
|
|
"""EUC-KR 응답 디코딩"""
|
|
try:
|
|
return content.decode('euc-kr')
|
|
except UnicodeDecodeError:
|
|
try:
|
|
return content.decode('utf-8')
|
|
except UnicodeDecodeError:
|
|
return content.decode('latin-1')
|
|
|
|
def _clean_xml_bytes(self, content: bytes) -> bytes:
|
|
"""XML 정리"""
|
|
try:
|
|
text = content.decode('euc-kr')
|
|
except UnicodeDecodeError:
|
|
try:
|
|
text = content.decode('utf-8')
|
|
except UnicodeDecodeError:
|
|
text = content.decode('latin-1')
|
|
|
|
text = re.sub(r'^[0-9a-fA-F]+\r?\n', '', text, flags=re.MULTILINE)
|
|
text = text.strip()
|
|
|
|
if not text.startswith('<?xml'):
|
|
xml_start = text.find('<?xml')
|
|
if xml_start > 0:
|
|
text = text[xml_start:]
|
|
|
|
text = re.sub(r'encoding=["\'][^"\']*["\']', 'encoding="UTF-8"', text)
|
|
return text.encode('utf-8')
|
|
|
|
async def login(self) -> bool:
|
|
"""카모두 로그인"""
|
|
url = f"{CARMODOO_BASE_URL}/member/login_ok.html"
|
|
data = {
|
|
'prevURL': '',
|
|
'id': CARMODOO_USER_ID,
|
|
'passwd': CARMODOO_PASSWORD,
|
|
'idSave': 'Y',
|
|
'button': 'LOGIN'
|
|
}
|
|
|
|
headers = {
|
|
**self.headers,
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'Origin': CARMODOO_BASE_URL,
|
|
'Referer': f'{CARMODOO_BASE_URL}/member/login_v2.html',
|
|
}
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
response = await client.post(
|
|
url,
|
|
data=data,
|
|
headers=headers,
|
|
follow_redirects=False
|
|
)
|
|
|
|
text = self._decode_response(response.content)
|
|
|
|
if 'goMain' in text or 'PHPSESSID' in str(response.cookies):
|
|
self.cookies = dict(response.cookies)
|
|
self.is_logged_in = True
|
|
print("Carmodoo login successful")
|
|
return True
|
|
else:
|
|
print("Carmodoo login failed")
|
|
return False
|
|
except Exception as e:
|
|
print(f"Carmodoo login error: {e}")
|
|
return False
|
|
|
|
async def search_cars(
|
|
self,
|
|
maker_code: Optional[str] = None,
|
|
model_code: Optional[str] = None,
|
|
year_min: Optional[int] = None,
|
|
year_max: Optional[int] = None,
|
|
mileage_min: Optional[int] = None,
|
|
mileage_max: Optional[int] = None,
|
|
price_min: Optional[int] = None,
|
|
price_max: Optional[int] = None,
|
|
fuel: Optional[str] = None,
|
|
transmission: Optional[str] = None,
|
|
page: int = 1,
|
|
page_size: int = 50
|
|
) -> List[dict]:
|
|
"""차량 검색 (POST 방식 AJAX API 호출)"""
|
|
|
|
print(f"[search_cars] Called with maker={maker_code}, model={model_code}, is_logged_in={self.is_logged_in}")
|
|
|
|
if not self.is_logged_in:
|
|
print("[search_cars] Not logged in, attempting login...")
|
|
login_result = await self.login()
|
|
print(f"[search_cars] Login result: {login_result}")
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=30.0, cookies=self.cookies) as client:
|
|
# AJAX 엔드포인트 - POST 방식으로 호출
|
|
ajax_url = f"{CARMODOO_BASE_URL}/car/_inc_carListPhoto.html"
|
|
|
|
# POST form data
|
|
form_data = {
|
|
'sf_page': str(page - 1), # 0-based
|
|
'sf_pageSize': str(page_size),
|
|
'cho': '', # 국산/수입 구분 (빈값이면 전체 - 수입차 검색을 위해 필수)
|
|
}
|
|
|
|
if maker_code:
|
|
form_data['c_bmNo'] = maker_code # 제조사 (Brand/Maker)
|
|
if model_code:
|
|
form_data['c_boInitNo'] = model_code # 모델 이니셜 (Model Init - c_boInitNo가 정확한 파라미터)
|
|
if year_min:
|
|
form_data['c_year1'] = str(year_min)
|
|
if year_max:
|
|
form_data['c_year2'] = str(year_max)
|
|
if mileage_min:
|
|
form_data['c_mileage1'] = str(mileage_min)
|
|
if mileage_max:
|
|
form_data['c_mileage2'] = str(mileage_max)
|
|
if price_min:
|
|
form_data['c_price1'] = str(price_min)
|
|
if price_max:
|
|
form_data['c_price2'] = str(price_max)
|
|
if fuel:
|
|
# 연료 타입 매핑 (프론트엔드 값 -> 카모두 API 값)
|
|
# 카모두 API는 dFuel[] 파라미터 (체크박스 배열)를 사용함
|
|
fuel_map = {
|
|
'가솔린': '휘발유',
|
|
'디젤': '경유',
|
|
'LPG': 'LPG',
|
|
'하이브리드': '하이브리드',
|
|
'전기': '전기',
|
|
'CNG': 'CNG',
|
|
}
|
|
if fuel in fuel_map:
|
|
form_data['dFuel[]'] = fuel_map[fuel]
|
|
if transmission:
|
|
# 변속기 타입 매핑 (프론트엔드 값 -> 카모두 API 값)
|
|
# 카모두 API는 dGA 파라미터에 한글 텍스트 값을 사용함
|
|
trans_map = {
|
|
'자동': '오토',
|
|
'수동': '수동',
|
|
'세미오토': '세미오토',
|
|
'CVT': 'CVT',
|
|
}
|
|
if transmission in trans_map:
|
|
form_data['dGA'] = trans_map[transmission]
|
|
|
|
ajax_headers = {
|
|
**self.headers,
|
|
'Accept': 'text/html, */*; q=0.01',
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
'Content-Type': 'application/x-www-form-urlencoded; charset=EUC-KR',
|
|
'Origin': CARMODOO_BASE_URL,
|
|
'Referer': f'{CARMODOO_BASE_URL}/car/carListPhoto.html',
|
|
}
|
|
|
|
# EUC-KR 인코딩으로 form data 생성 (한글 필터 지원)
|
|
import urllib.parse
|
|
encoded_parts = []
|
|
for key, value in form_data.items():
|
|
# 한글 값을 EUC-KR로 인코딩 후 URL 인코딩
|
|
if isinstance(value, str):
|
|
try:
|
|
encoded_value = urllib.parse.quote(value.encode('euc-kr'), safe='')
|
|
except UnicodeEncodeError:
|
|
encoded_value = urllib.parse.quote(value, safe='')
|
|
else:
|
|
encoded_value = str(value)
|
|
encoded_parts.append(f"{key}={encoded_value}")
|
|
encoded_body = '&'.join(encoded_parts).encode('ascii')
|
|
|
|
response = await client.post(
|
|
ajax_url,
|
|
content=encoded_body,
|
|
headers=ajax_headers,
|
|
)
|
|
|
|
print(f"[search_cars] Response status: {response.status_code}, content length: {len(response.content)}")
|
|
|
|
if response.status_code == 200:
|
|
html = self._decode_response(response.content)
|
|
print(f"[search_cars] Decoded HTML length: {len(html)}")
|
|
|
|
# 전체 개수 파싱 (totalCntUpdate(N) 패턴)
|
|
total_count = 0
|
|
total_match = re.search(r'totalCntUpdate\((\d+)\)', html)
|
|
if total_match:
|
|
total_count = int(total_match.group(1))
|
|
print(f"[search_cars] Total count from API: {total_count}")
|
|
|
|
cars = self._parse_car_list_html(html)
|
|
print(f"[search_cars] Parsed {len(cars)} cars from HTML")
|
|
|
|
# 전체 개수를 메타데이터로 저장
|
|
if cars:
|
|
cars[0]["_total_count"] = total_count
|
|
|
|
# check_num이 없는 차량에 대해 상세 페이지에서 재시도
|
|
cars_without_check = [c for c in cars if not c.get("check_num")]
|
|
if cars_without_check:
|
|
print(f"[Carmodoo] {len(cars_without_check)}개 차량의 성능점검번호를 상세 페이지에서 가져옵니다...")
|
|
retry_count = len(cars_without_check)
|
|
retry_success = 0
|
|
for car in cars_without_check:
|
|
try:
|
|
await asyncio.sleep(0.3) # 서버 부하 방지
|
|
check_num = await self.get_car_check_num(car["car_no"], car.get("car_key", ""))
|
|
if check_num:
|
|
car["check_num"] = check_num
|
|
retry_success += 1
|
|
print(f" - {car['car_no']}: check_num={check_num} (재시도 성공)")
|
|
else:
|
|
print(f" - {car['car_no']}: check_num 없음 (재시도 실패)")
|
|
except Exception as e:
|
|
print(f" - {car['car_no']}: 재시도 오류 - {e}")
|
|
|
|
# 재시도 통계를 첫 번째 차량에 포함
|
|
if cars:
|
|
cars[0]["_retry_count"] = retry_count
|
|
cars[0]["_retry_success"] = retry_success
|
|
|
|
return cars
|
|
|
|
except Exception as e:
|
|
print(f"Search error: {e}")
|
|
|
|
return []
|
|
|
|
async def search_cars_by_year_segment(
|
|
self,
|
|
maker_code: str,
|
|
model_code: str,
|
|
year_start: int = 2010,
|
|
year_end: int = None
|
|
) -> List[dict]:
|
|
"""연도별 분할 검색으로 더 많은 차량 수집
|
|
|
|
카모두 API는 한 번에 최대 50대만 반환하므로,
|
|
연도별로 나누어 검색하여 더 많은 차량을 수집합니다.
|
|
"""
|
|
from datetime import datetime
|
|
|
|
if not self.is_logged_in:
|
|
await self.login()
|
|
|
|
if year_end is None:
|
|
year_end = datetime.now().year + 1
|
|
|
|
all_cars = []
|
|
seen_car_ids = set()
|
|
|
|
for year in range(year_end, year_start - 1, -1):
|
|
try:
|
|
cars = await self.search_cars(
|
|
maker_code=maker_code,
|
|
model_code=model_code,
|
|
year_min=year,
|
|
year_max=year,
|
|
page=1,
|
|
page_size=50
|
|
)
|
|
|
|
# 중복 제거
|
|
for car in cars:
|
|
car_id = car.get("car_no")
|
|
if car_id and car_id not in seen_car_ids:
|
|
seen_car_ids.add(car_id)
|
|
all_cars.append(car)
|
|
|
|
# 카모두 서버 부하 방지
|
|
await asyncio.sleep(0.3)
|
|
|
|
except Exception as e:
|
|
print(f"Error fetching year {year}: {e}")
|
|
continue
|
|
|
|
return all_cars
|
|
|
|
def _parse_car_list_html(self, html: str) -> List[dict]:
|
|
"""HTML에서 차량 목록 파싱"""
|
|
cars = []
|
|
|
|
try:
|
|
tree = lxml_html.fromstring(html)
|
|
|
|
# 각 차량 행 찾기 (tr id="trCtl_XXXXXXX")
|
|
car_rows = tree.xpath('//tr[starts-with(@id, "trCtl_")]')
|
|
|
|
for row in car_rows:
|
|
try:
|
|
# 차량 번호 추출
|
|
tr_id = row.get('id', '')
|
|
car_no = tr_id.replace('trCtl_', '')
|
|
|
|
# 차량번호판
|
|
car_plate = ""
|
|
plate_elem = row.xpath('.//td[@class="center"]/strong/text()')
|
|
if plate_elem:
|
|
car_plate = plate_elem[0].strip()
|
|
|
|
# 제조사/모델명
|
|
maker_name = ""
|
|
model_name = ""
|
|
car_name_full = ""
|
|
|
|
maker_elem = row.xpath('.//span[@class="maker"]/text()')
|
|
if maker_elem:
|
|
maker_name = maker_elem[0].strip().strip('[]')
|
|
|
|
model_elem = row.xpath('.//span[@class="model"]/text()')
|
|
if model_elem:
|
|
model_name = model_elem[0].strip()
|
|
|
|
# 전체 차량명 (링크 텍스트)
|
|
car_name_elem = row.xpath('.//td[@class="carName"]/a/text()')
|
|
if car_name_elem:
|
|
car_name_parts = [t.strip() for t in car_name_elem if t.strip()]
|
|
if car_name_parts:
|
|
car_name_full = ' '.join(car_name_parts)
|
|
|
|
if not car_name_full and maker_name:
|
|
car_name_full = f"{maker_name} {model_name}".strip()
|
|
|
|
# 미션
|
|
transmission = ""
|
|
td_centers = row.xpath('.//td[@class="center"]/text()')
|
|
for t in td_centers:
|
|
t = t.strip()
|
|
if t in ['오토', '수동', '자동']:
|
|
transmission = t
|
|
break
|
|
|
|
# 연식
|
|
year = None
|
|
year_elem = row.xpath('.//span[@class="year_2"]/text()')
|
|
if year_elem:
|
|
try:
|
|
year = int(year_elem[0].strip())
|
|
except:
|
|
pass
|
|
|
|
# 연료
|
|
fuel = ""
|
|
for t in td_centers:
|
|
t = t.strip()
|
|
if t in ['휘발유', '경유', 'LPG', '전기', '하이브리드', '가솔린', '디젤']:
|
|
fuel = t
|
|
break
|
|
|
|
# 주행거리
|
|
mileage = None
|
|
mileage_elem = row.xpath('.//td[@class="right"]/text()')
|
|
if mileage_elem:
|
|
mileage_str = mileage_elem[0].strip().replace(',', '').replace('km', '')
|
|
try:
|
|
mileage = int(mileage_str)
|
|
except:
|
|
pass
|
|
|
|
# 색상
|
|
color = ""
|
|
# 색상은 마지막 center 셀 중 하나
|
|
for t in td_centers:
|
|
t = t.strip()
|
|
if t in ['흰색', '검정', '은색', '회색', '파랑', '빨강', '갈색', '녹색', '기타', '노랑', '주황']:
|
|
color = t
|
|
|
|
# 배기량 (cc) - td_centers에서 숫자만 있는 값 확인
|
|
displacement = None
|
|
for t in td_centers:
|
|
t = t.strip()
|
|
# 배기량은 보통 숫자로만 이루어져 있음 (1999, 2497 등)
|
|
if t.isdigit() and 500 <= int(t) <= 10000:
|
|
displacement = int(t)
|
|
break
|
|
|
|
# 가격 (만원)
|
|
price = None
|
|
price_elem = row.xpath('.//td[@class="price"]/text()')
|
|
if price_elem:
|
|
try:
|
|
price_str = price_elem[0].strip().replace(',', '')
|
|
price = int(price_str) * 10000 # 만원 -> 원
|
|
except:
|
|
pass
|
|
|
|
# 이미지 URL 생성
|
|
car_no_padded = car_no.zfill(9)
|
|
main_image = f"{CARMODOO_BASE_URL}/data/__carPhoto/{car_no_padded[:3]}/{car_no_padded[3:6]}/{car_no_padded[6:]}/cmcar_0.jpg"
|
|
|
|
# 성능점검번호 및 key 추출 시도
|
|
check_num = ""
|
|
car_key = ""
|
|
|
|
# 전체 row HTML에서 dealerCarviewPopup('...') 패턴으로 key와 checkNum 추출
|
|
row_html = etree.tostring(row, encoding='unicode')
|
|
|
|
key_match = re.search(r"dealerCarviewPopup\s*\(\s*['\"]([^'\"]+)['\"]", row_html)
|
|
if key_match:
|
|
car_key = key_match.group(1)
|
|
|
|
# checkNum 추출 (주석 포함)
|
|
check_match = re.search(r'checkNum=(\d+)', row_html)
|
|
if check_match:
|
|
check_num = check_match.group(1)
|
|
|
|
# data 속성에서 checkNum 추출 (대안)
|
|
if not check_num:
|
|
check_num_attr = row.get('data-checknum', '')
|
|
if not check_num_attr:
|
|
check_elem = row.xpath('.//*[@data-checknum]/@data-checknum')
|
|
if check_elem:
|
|
check_num_attr = check_elem[0]
|
|
check_num = check_num_attr
|
|
|
|
# 성능점검 링크에서 checkNum 추출 (대안)
|
|
if not check_num:
|
|
check_link = row.xpath('.//a[contains(@href, "checkNum")]/@href')
|
|
if check_link:
|
|
check_match = re.search(r'checkNum=(\d+)', check_link[0])
|
|
if check_match:
|
|
check_num = check_match.group(1)
|
|
|
|
if car_no:
|
|
cars.append({
|
|
"car_no": car_no,
|
|
"car_plate": car_plate,
|
|
"car_name": car_name_full,
|
|
"maker_name": maker_name,
|
|
"model_name": model_name,
|
|
"year": year,
|
|
"mileage": mileage,
|
|
"price": price,
|
|
"fuel": fuel,
|
|
"transmission": transmission,
|
|
"color": color,
|
|
"displacement": displacement,
|
|
"main_image": main_image,
|
|
"car_key": car_key,
|
|
"check_num": check_num,
|
|
})
|
|
|
|
except Exception as e:
|
|
print(f"Error parsing car row: {e}")
|
|
continue
|
|
|
|
except Exception as e:
|
|
print(f"Error parsing HTML: {e}")
|
|
|
|
return cars
|
|
|
|
|
|
async def get_grades(self, maker_code: str, model_code: str) -> List[dict]:
|
|
"""등급 목록 가져오기 (카모두 API에서 c_boNo)"""
|
|
if not self.is_logged_in:
|
|
await self.login()
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=30.0, cookies=self.cookies) as client:
|
|
# 카모두 API - 등급 목록 조회
|
|
# mode=getCarModel: 제조사(company)와 모델이니셜(c_nameInit)을 받아서 등급 목록 반환
|
|
url = f"{CARMODOO_BASE_URL}/common/ajax/AutoDBCode.html"
|
|
params = {
|
|
'mode': 'getCarModel',
|
|
'ctl': 'car',
|
|
'cho': '', # 국산/수입 구분 (빈값이면 전체)
|
|
'company': maker_code,
|
|
'c_nameInit': model_code,
|
|
'selected': ''
|
|
}
|
|
|
|
headers = {
|
|
**self.headers,
|
|
'Accept': 'application/xml, text/xml, */*; q=0.01',
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
'Referer': f'{CARMODOO_BASE_URL}/car/carListPhoto.html',
|
|
}
|
|
|
|
response = await client.get(url, params=params, headers=headers)
|
|
|
|
if response.status_code == 200:
|
|
xml_content = self._clean_xml_bytes(response.content)
|
|
return self._parse_grades_xml(xml_content)
|
|
|
|
except Exception as e:
|
|
print(f"Get grades error: {e}")
|
|
|
|
return []
|
|
|
|
def _parse_grades_xml(self, xml_content: bytes) -> List[dict]:
|
|
"""등급 XML 파싱"""
|
|
grades = []
|
|
try:
|
|
root = etree.fromstring(xml_content)
|
|
items = root.findall('.//item')
|
|
for item in items:
|
|
key_elem = item.find('key')
|
|
name_elem = item.find('name')
|
|
if key_elem is not None and name_elem is not None:
|
|
grades.append({
|
|
'code': key_elem.text.strip() if key_elem.text else '',
|
|
'name': name_elem.text.strip() if name_elem.text else ''
|
|
})
|
|
except Exception as e:
|
|
print(f"Parse grades XML error: {e}")
|
|
return grades
|
|
|
|
async def get_car_check_num(self, car_no: str, car_key: str = "") -> str:
|
|
"""차량 상세 정보에서 성능점검번호(checkNum) 가져오기"""
|
|
if not self.is_logged_in:
|
|
await self.login()
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=30.0, cookies=self.cookies) as client:
|
|
url = f"{CARMODOO_BASE_URL}/common/ajax/AutoDB.html"
|
|
|
|
headers = {
|
|
**self.headers,
|
|
'Accept': 'application/xml, text/xml, */*; q=0.01',
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
'Referer': f'{CARMODOO_BASE_URL}/car/carListPhoto.html',
|
|
}
|
|
|
|
# key가 있으면 key로 조회
|
|
if car_key:
|
|
params = {"mode": "view", "key": car_key}
|
|
response = await client.get(url, params=params, headers=headers)
|
|
print(f" AutoDB with key: status={response.status_code}, length={len(response.content)}")
|
|
|
|
if response.status_code == 200 and len(response.content) > 100:
|
|
try:
|
|
xml_content = self._clean_xml_bytes(response.content)
|
|
root = etree.fromstring(xml_content)
|
|
check_num_elem = root.find('.//c_checkNum')
|
|
if check_num_elem is not None and check_num_elem.text:
|
|
return check_num_elem.text.strip()
|
|
except Exception as e:
|
|
print(f" XML parse error with key: {e}")
|
|
|
|
# key가 없으면 여러 모드 시도
|
|
modes_to_try = [
|
|
{"mode": "view", "c_carNo": car_no},
|
|
{"mode": "viewNo", "no": car_no},
|
|
]
|
|
|
|
for params in modes_to_try:
|
|
response = await client.get(url, params=params, headers=headers)
|
|
|
|
if response.status_code == 200 and len(response.content) > 100:
|
|
try:
|
|
xml_content = self._clean_xml_bytes(response.content)
|
|
root = etree.fromstring(xml_content)
|
|
check_num_elem = root.find('.//c_checkNum')
|
|
if check_num_elem is not None and check_num_elem.text:
|
|
return check_num_elem.text.strip()
|
|
except Exception as e:
|
|
pass
|
|
|
|
except Exception as e:
|
|
print(f"Get car check num error: {e}")
|
|
|
|
return ""
|
|
|
|
async def get_car_detail(self, car_no: str, car_key: str = "") -> dict:
|
|
"""차량 상세 정보 가져오기 (딜러 설명 포함)
|
|
|
|
Args:
|
|
car_no: 차량 번호
|
|
car_key: 암호화된 차량 키 (dealerCarviewPopup용)
|
|
|
|
Returns:
|
|
dict with dealer_description and other details
|
|
"""
|
|
if not self.is_logged_in:
|
|
await self.login()
|
|
|
|
result = {
|
|
"car_no": car_no,
|
|
"dealer_description": "",
|
|
"found": False
|
|
}
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=30.0, cookies=self.cookies) as client:
|
|
# car_key가 있으면 dealerCarView.html 사용
|
|
if car_key:
|
|
url = f"{CARMODOO_BASE_URL}/car/dealerCarView.html"
|
|
params = {"key": car_key, "tabStart": "1"}
|
|
|
|
response = await client.get(url, params=params, headers=self.headers)
|
|
|
|
if response.status_code == 200:
|
|
# EUC-KR로 디코딩
|
|
try:
|
|
html = response.content.decode('euc-kr')
|
|
except:
|
|
html = response.content.decode('utf-8', errors='replace')
|
|
|
|
# 상세설명 추출 - carViewMemoWrap 내의 memo div
|
|
# <div class="carViewMemoWrap"><h3>상세설명</h3><div class="memo">...</div></div>
|
|
memo_match = re.search(
|
|
r'<div[^>]*class=["\'][^"\']*carViewMemoWrap[^"\']*["\'][^>]*>.*?'
|
|
r'<h3>\s*상세설명\s*</h3>\s*<div[^>]*class=["\'][^"\']*memo[^"\']*["\'][^>]*>(.*?)</div>',
|
|
html,
|
|
re.DOTALL | re.IGNORECASE
|
|
)
|
|
|
|
if memo_match:
|
|
desc_html = memo_match.group(1)
|
|
# HTML 태그 제거 (br 태그는 줄바꿈으로)
|
|
desc = re.sub(r'<br\s*/?>', '\n', desc_html)
|
|
desc = re.sub(r'<[^>]+>', '', desc)
|
|
desc = desc.strip()
|
|
|
|
if desc and len(desc) > 5:
|
|
result["dealer_description"] = desc
|
|
result["found"] = True
|
|
|
|
# raw_html 저장 (디버깅용, 크기 제한)
|
|
result["raw_html"] = html[:10000] if len(html) > 10000 else html
|
|
|
|
except Exception as e:
|
|
print(f"Get car detail error: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
|
|
return result
|
|
|
|
async def get_performance_check(self, car_no: str, car_key: str = "", check_num: str = "") -> dict:
|
|
"""성능점검표 가져오기 - ck.carmodoo.com에서 조회
|
|
|
|
Args:
|
|
car_no: 차량 번호
|
|
car_key: 암호화된 key (검색 결과에서 추출)
|
|
check_num: 성능점검번호 (이미 알고 있는 경우)
|
|
"""
|
|
if not self.is_logged_in:
|
|
await self.login()
|
|
|
|
result = {
|
|
"car_no": car_no,
|
|
"found": False,
|
|
"data": {},
|
|
"raw_html": "",
|
|
"check_num": ""
|
|
}
|
|
|
|
try:
|
|
# 1. checkNum이 없으면 차량 상세에서 가져오기
|
|
if not check_num:
|
|
check_num = await self.get_car_check_num(car_no, car_key)
|
|
|
|
if not check_num:
|
|
print(f"No checkNum found for car_no: {car_no}")
|
|
return result
|
|
|
|
result["check_num"] = check_num
|
|
print(f"Found checkNum: {check_num}")
|
|
|
|
# 2. 성능점검표 페이지 가져오기
|
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
perf_url = f"https://ck.carmodoo.com/carCheck/carmodooPrint.do"
|
|
params = {"print": "0", "checkNum": check_num}
|
|
|
|
headers = {
|
|
'User-Agent': self.headers['User-Agent'],
|
|
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
'Accept-Language': 'ko-KR,ko;q=0.9',
|
|
}
|
|
|
|
response = await client.get(perf_url, params=params, headers=headers)
|
|
|
|
if response.status_code == 200:
|
|
# UTF-8로 디코딩 (ck.carmodoo.com은 UTF-8 사용)
|
|
try:
|
|
html = response.content.decode('utf-8')
|
|
except:
|
|
html = response.content.decode('euc-kr', errors='ignore')
|
|
|
|
result["raw_html"] = html
|
|
result["data"] = self._parse_performance_check_html(html)
|
|
result["data"]["check_number"] = check_num # checkNum 저장
|
|
result["found"] = bool(result["data"])
|
|
|
|
except Exception as e:
|
|
print(f"Get performance check error: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
|
|
return result
|
|
|
|
def _parse_performance_check_html(self, html: str) -> dict:
|
|
"""성능점검표 HTML 파싱"""
|
|
data = {}
|
|
|
|
try:
|
|
tree = lxml_html.fromstring(html)
|
|
|
|
# 성능점검번호
|
|
check_num_elem = tree.xpath('//th[contains(text(), "점검번호")]/following-sibling::td/text()')
|
|
if check_num_elem:
|
|
data["check_number"] = check_num_elem[0].strip()
|
|
|
|
# 점검일자
|
|
check_date_elem = tree.xpath('//th[contains(text(), "점검일")]/following-sibling::td/text()')
|
|
if check_date_elem:
|
|
data["check_date"] = check_date_elem[0].strip()
|
|
|
|
# 유효기간
|
|
valid_until_elem = tree.xpath('//th[contains(text(), "유효기간")]/following-sibling::td/text()')
|
|
if valid_until_elem:
|
|
data["valid_until"] = valid_until_elem[0].strip()
|
|
|
|
# 차량번호 (자동차등록번호로 표시됨)
|
|
car_num_elem = tree.xpath('//th[contains(text(), "자동차등록번호")]/following-sibling::td/text()')
|
|
if not car_num_elem:
|
|
# Fallback to old pattern
|
|
car_num_elem = tree.xpath('//th[contains(text(), "차량번호")]/following-sibling::td/text()')
|
|
if car_num_elem:
|
|
data["car_number"] = car_num_elem[0].strip()
|
|
|
|
# 최초등록일
|
|
first_reg_elem = tree.xpath('//th[contains(text(), "최초등록")]/following-sibling::td/text()')
|
|
if first_reg_elem:
|
|
data["first_registration"] = first_reg_elem[0].strip()
|
|
|
|
# 주행거리
|
|
mileage_elem = tree.xpath('//th[contains(text(), "주행거리")]/following-sibling::td/text()')
|
|
if mileage_elem:
|
|
mileage_str = mileage_elem[0].strip().replace(',', '').replace('km', '').replace(' ', '')
|
|
try:
|
|
data["mileage"] = int(mileage_str)
|
|
except:
|
|
data["mileage_text"] = mileage_elem[0].strip()
|
|
|
|
# 주행거리 상태 (정상/조작의심/교환됨)
|
|
mileage_status_elem = tree.xpath('//th[contains(text(), "계기상태")]/following-sibling::td/text()')
|
|
if mileage_status_elem:
|
|
data["mileage_status"] = mileage_status_elem[0].strip()
|
|
|
|
# 압류/저당
|
|
seize_elem = tree.xpath('//th[contains(text(), "압류")]/following-sibling::td/text()')
|
|
if seize_elem:
|
|
seize_str = seize_elem[0].strip()
|
|
try:
|
|
data["seize_count"] = int(re.search(r'(\d+)', seize_str).group(1)) if re.search(r'(\d+)', seize_str) else 0
|
|
except:
|
|
data["seize_count"] = 0
|
|
|
|
collateral_elem = tree.xpath('//th[contains(text(), "저당")]/following-sibling::td/text()')
|
|
if collateral_elem:
|
|
collateral_str = collateral_elem[0].strip()
|
|
try:
|
|
data["collateral_count"] = int(re.search(r'(\d+)', collateral_str).group(1)) if re.search(r'(\d+)', collateral_str) else 0
|
|
except:
|
|
data["collateral_count"] = 0
|
|
|
|
# 침수/화재/전손 - 체크박스 상태로 확인
|
|
# bc_41_1 = 침수, bc_41_2 = 화재
|
|
flood_checkbox = tree.xpath('//input[@id="bc_41_1"]/@checked')
|
|
fire_checkbox = tree.xpath('//input[@id="bc_41_2"]/@checked')
|
|
# 전손은 특별이력 섹션의 "있음" 체크박스와 함께 확인
|
|
special_history_yes = tree.xpath('//input[@id="bc_4_2"]/@checked')
|
|
|
|
data["is_flood_damaged"] = len(flood_checkbox) > 0
|
|
data["is_fire_damaged"] = len(fire_checkbox) > 0
|
|
# 전손은 별도 체크박스가 없으므로 텍스트로 확인 (fallback)
|
|
total_loss_text = tree.xpath('//th[contains(text(), "특별이력")]/following-sibling::td//text()')
|
|
if total_loss_text:
|
|
history_text = ' '.join([t.strip() for t in total_loss_text])
|
|
data["is_total_loss"] = "전손" in history_text and ("유" in history_text or "있음" in history_text)
|
|
else:
|
|
data["is_total_loss"] = False
|
|
|
|
# 용도이력
|
|
usage_elem = tree.xpath('//th[contains(text(), "용도이력")]/following-sibling::td/text()')
|
|
if usage_elem:
|
|
data["usage_history"] = usage_elem[0].strip()
|
|
|
|
# 렌트이력
|
|
rental_elem = tree.xpath('//th[contains(text(), "렌트")]/following-sibling::td//text()')
|
|
if rental_elem:
|
|
rental_text = ' '.join([t.strip() for t in rental_elem]).lower()
|
|
data["is_rental_used"] = "유" in rental_text or "있음" in rental_text
|
|
|
|
# 주요장치 상태
|
|
device_status_map = {
|
|
"원동기": "engine_status",
|
|
"변속기": "transmission_status",
|
|
"동력전달": "power_delivery_status",
|
|
"조향장치": "steering_status",
|
|
"제동장치": "brake_status",
|
|
"전기장치": "electrical_status",
|
|
"연료장치": "fuel_system_status",
|
|
}
|
|
|
|
for korean, english in device_status_map.items():
|
|
elem = tree.xpath(f'//th[contains(text(), "{korean}")]/following-sibling::td/text()')
|
|
if elem:
|
|
data[english] = elem[0].strip()
|
|
|
|
# 외판 부위 상태 (사고이력)
|
|
body_parts_map = {
|
|
"후드": "hood",
|
|
"프론트휀더(좌)": "front_fender_left",
|
|
"프론트휀더(우)": "front_fender_right",
|
|
"프론트도어(좌)": "front_door_left",
|
|
"프론트도어(우)": "front_door_right",
|
|
"리어도어(좌)": "rear_door_left",
|
|
"리어도어(우)": "rear_door_right",
|
|
"트렁크리드": "trunk_lid",
|
|
"라디에이터서포트": "radiator_support",
|
|
"루프패널": "roof_panel",
|
|
"쿼터패널(좌)": "quarter_panel_left",
|
|
"쿼터패널(우)": "quarter_panel_right",
|
|
"사이드실패널(좌)": "side_sill_left",
|
|
"사이드실패널(우)": "side_sill_right",
|
|
}
|
|
|
|
# 주요골격 부위
|
|
frame_parts_map = {
|
|
"프론트패널": "front_panel",
|
|
"크로스멤버": "cross_member",
|
|
"인사이드패널(좌)": "inside_panel_left",
|
|
"인사이드패널(우)": "inside_panel_right",
|
|
"사이드멤버(좌)": "side_member_left",
|
|
"사이드멤버(우)": "side_member_right",
|
|
"휠하우스(좌)": "wheel_house_left",
|
|
"휠하우스(우)": "wheel_house_right",
|
|
"대쉬패널": "dash_panel",
|
|
"플로어패널": "floor_panel",
|
|
"트렁크플로어": "trunk_floor",
|
|
"리어패널": "rear_panel",
|
|
"필러A(좌)": "pillar_a_left",
|
|
"필러A(우)": "pillar_a_right",
|
|
"필러B(좌)": "pillar_b_left",
|
|
"필러B(우)": "pillar_b_right",
|
|
"필러C(좌)": "pillar_c_left",
|
|
"필러C(우)": "pillar_c_right",
|
|
"패키지트레이": "package_tray",
|
|
}
|
|
|
|
# 모든 부위 파싱
|
|
all_parts = {**body_parts_map, **frame_parts_map}
|
|
accident_history = {}
|
|
|
|
for korean, english in all_parts.items():
|
|
elem = tree.xpath(f'//th[contains(text(), "{korean}")]/following-sibling::td/text()')
|
|
if elem:
|
|
status = elem[0].strip()
|
|
data[english] = status
|
|
if status and status not in ["없음", "무", "-", ""]:
|
|
accident_history[english] = status
|
|
|
|
if accident_history:
|
|
data["accident_history"] = accident_history
|
|
|
|
# 타이어 상태
|
|
tire_map = {
|
|
"전좌": "tire_front_left",
|
|
"전우": "tire_front_right",
|
|
"후좌": "tire_rear_left",
|
|
"후우": "tire_rear_right",
|
|
}
|
|
|
|
for korean, english in tire_map.items():
|
|
elem = tree.xpath(f'//th[contains(text(), "타이어")]/following-sibling::td//text()[contains(., "{korean}")]')
|
|
if elem:
|
|
# 타이어 상태 추출 로직
|
|
data[english] = "확인필요"
|
|
|
|
# 성능점검표 이미지 URL
|
|
img_elem = tree.xpath('//img[contains(@src, "prfcheck") or contains(@src, "performance")]/@src')
|
|
if img_elem:
|
|
img_url = img_elem[0]
|
|
if not img_url.startswith('http'):
|
|
img_url = f"{CARMODOO_BASE_URL}{img_url}"
|
|
data["report_image_url"] = img_url
|
|
|
|
except Exception as e:
|
|
print(f"Parse performance check HTML error: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
|
|
return data
|
|
|
|
|
|
# 싱글톤 클라이언트
|
|
carmodoo_client = CarmodooClient()
|
|
|
|
|
|
@router.get("/makers")
|
|
def get_makers():
|
|
"""제조사 목록 (실제 카모두 데이터)"""
|
|
return [CarmodooMaker(**m) for m in CARMODOO_DATA.get("makers", [])]
|
|
|
|
|
|
@router.get("/models/{maker_code}")
|
|
def get_models(maker_code: str):
|
|
"""모델 목록 (실제 카모두 데이터)"""
|
|
models = CARMODOO_DATA.get("models", {}).get(maker_code, [])
|
|
return [CarmodooModel(**m) for m in models]
|
|
|
|
|
|
@router.get("/grades/{maker_code}/{model_code}")
|
|
async def get_grades(maker_code: str, model_code: str):
|
|
"""등급 목록 (카모두에서 동적 조회)"""
|
|
grades = await carmodoo_client.get_grades(maker_code, model_code)
|
|
if not grades:
|
|
# 빈 목록 반환 (등급 선택 없이 검색 가능)
|
|
return []
|
|
return grades
|
|
|
|
|
|
class AdminSearchResultItem(BaseModel):
|
|
id: str
|
|
car_name: str
|
|
maker_code: Optional[str] = None
|
|
maker_name: Optional[str] = None
|
|
model_code: Optional[str] = None
|
|
model_name: Optional[str] = None
|
|
car_type: Optional[str] = None
|
|
car_type_name: Optional[str] = None
|
|
grade: Optional[str] = None
|
|
grade_name: Optional[str] = None
|
|
year: Optional[int] = None
|
|
month: Optional[int] = None
|
|
mileage: Optional[int] = None
|
|
price: Optional[int] = None
|
|
fuel: Optional[str] = None
|
|
transmission: Optional[str] = None
|
|
color: Optional[str] = None
|
|
displacement: Optional[int] = None
|
|
car_number: Optional[str] = None
|
|
main_image: Optional[str] = None
|
|
images: Optional[List[str]] = None
|
|
options: Optional[List[str]] = None
|
|
dealer_name: Optional[str] = None
|
|
shop_name: Optional[str] = None
|
|
seize_count: Optional[int] = None
|
|
collateral_count: Optional[int] = None
|
|
check_num: Optional[str] = None # 성능점검번호
|
|
car_key: Optional[str] = None # 암호화된 차량 키
|
|
|
|
class AdminSearchResponse(BaseModel):
|
|
total: int
|
|
cars: List[AdminSearchResultItem]
|
|
check_num_retried: int = 0 # 성능점검번호 재시도한 차량 수
|
|
check_num_retry_success: int = 0 # 재시도 성공 수
|
|
|
|
|
|
@router.get("/search", response_model=AdminSearchResponse)
|
|
async def admin_carmodoo_search(
|
|
maker_code: Optional[str] = None,
|
|
model_code: Optional[str] = None,
|
|
car_type: Optional[str] = None,
|
|
grade: Optional[str] = None,
|
|
year_min: Optional[int] = None,
|
|
year_max: Optional[int] = None,
|
|
price_min: Optional[int] = None,
|
|
price_max: Optional[int] = None,
|
|
mileage_max: Optional[int] = None,
|
|
fuel: Optional[str] = None,
|
|
displacement_min: Optional[int] = None,
|
|
displacement_max: Optional[int] = None,
|
|
page: int = Query(1, ge=1),
|
|
page_size: int = Query(20, ge=1, le=50),
|
|
current_user = Depends(get_current_admin_user),
|
|
):
|
|
"""
|
|
관리자용 카모두 차량 검색
|
|
- 직접 카모두 API를 호출하여 검색
|
|
- 배기량 필터는 결과에서 클라이언트 사이드 필터링
|
|
"""
|
|
|
|
# Debug logging
|
|
print(f"[Admin Search] Starting search with maker={maker_code}, model={model_code}")
|
|
print(f"[Admin Search] Client state: is_logged_in={carmodoo_client.is_logged_in}, cookies={bool(carmodoo_client.cookies)}")
|
|
|
|
# 카모두 API 호출
|
|
cars = await carmodoo_client.search_cars(
|
|
maker_code=maker_code,
|
|
model_code=model_code,
|
|
year_min=year_min,
|
|
year_max=year_max,
|
|
mileage_max=mileage_max,
|
|
price_min=price_min,
|
|
price_max=price_max,
|
|
fuel=fuel,
|
|
page=page,
|
|
page_size=page_size
|
|
)
|
|
|
|
# Debug logging
|
|
print(f"[Admin Search] Search returned {len(cars)} cars")
|
|
print(f"[Admin Search] Client state after: is_logged_in={carmodoo_client.is_logged_in}")
|
|
|
|
# 배기량 필터링 (카모두 API에서 지원하지 않는 경우)
|
|
if displacement_min or displacement_max:
|
|
filtered_cars = []
|
|
for car in cars:
|
|
car_displacement = car.get("displacement") or 0
|
|
if displacement_min and car_displacement < displacement_min:
|
|
continue
|
|
if displacement_max and car_displacement > displacement_max:
|
|
continue
|
|
filtered_cars.append(car)
|
|
cars = filtered_cars
|
|
|
|
# 제조사/모델 이름 조회
|
|
maker_name = ""
|
|
model_name = ""
|
|
if maker_code:
|
|
for m in CARMODOO_DATA.get("makers", []):
|
|
if m["code"] == maker_code:
|
|
maker_name = m["name"]
|
|
break
|
|
if maker_code and model_code:
|
|
for m in CARMODOO_DATA.get("models", {}).get(maker_code, []):
|
|
if m["code"] == model_code:
|
|
model_name = m["name"]
|
|
break
|
|
|
|
# 결과 변환
|
|
result_cars = []
|
|
for car in cars:
|
|
result_cars.append(AdminSearchResultItem(
|
|
id=car.get("car_no", ""),
|
|
car_name=car.get("car_name", ""),
|
|
maker_code=maker_code,
|
|
maker_name=car.get("maker_name", maker_name),
|
|
model_code=model_code,
|
|
model_name=car.get("model_name", model_name),
|
|
year=car.get("year"),
|
|
month=car.get("month"),
|
|
mileage=car.get("mileage"),
|
|
price=car.get("price") or car.get("original_price"),
|
|
fuel=car.get("fuel"),
|
|
transmission=car.get("transmission"),
|
|
color=car.get("color"),
|
|
displacement=car.get("displacement"),
|
|
car_number=car.get("car_number"),
|
|
main_image=car.get("main_image"),
|
|
seize_count=car.get("seize_count", 0),
|
|
collateral_count=car.get("collateral_count", 0),
|
|
check_num=car.get("check_num"), # 성능점검번호
|
|
car_key=car.get("car_key"), # 암호화된 차량 키
|
|
))
|
|
|
|
# 메타데이터 추출
|
|
total_count = cars[0].get("_total_count", len(result_cars)) if cars else len(result_cars)
|
|
retry_count = cars[0].get("_retry_count", 0) if cars else 0
|
|
retry_success = cars[0].get("_retry_success", 0) if cars else 0
|
|
|
|
print(f"[Admin Search] Returning total={total_count}, cars in page={len(result_cars)}")
|
|
|
|
return AdminSearchResponse(
|
|
total=total_count,
|
|
cars=result_cars,
|
|
check_num_retried=retry_count,
|
|
check_num_retry_success=retry_success
|
|
)
|
|
|
|
|
|
@router.get("/request-search", response_model=VehicleRequestSearchResponse)
|
|
async def vehicle_request_search(
|
|
maker_code: str = Query(..., description="제조사 코드 (필수)"),
|
|
model_code: str = Query(..., description="모델 코드 (필수)"),
|
|
year_min: int = Query(..., description="시작 연도 (필수)"),
|
|
year_max: int = Query(..., description="종료 연도 (필수)"),
|
|
grade: Optional[str] = None,
|
|
mileage_min: Optional[int] = None,
|
|
mileage_max: Optional[int] = None,
|
|
price_min: Optional[int] = None,
|
|
price_max: Optional[int] = None,
|
|
fuel: Optional[str] = None,
|
|
transmission: Optional[str] = None,
|
|
displacement_min: Optional[int] = None,
|
|
displacement_max: Optional[int] = None,
|
|
page: int = Query(1, ge=1),
|
|
page_size: int = Query(20, ge=1, le=100),
|
|
db: Session = Depends(get_db),
|
|
current_user = Depends(get_current_user_optional),
|
|
):
|
|
"""
|
|
차량 요청 검색 - 캐시 우선 조회
|
|
|
|
1. 캐시에서 maker+model 전체 데이터 조회
|
|
2. 캐시 MISS 시 카모두에서 전체 수집 후 캐시 저장
|
|
3. 로컬에서 year, mileage, price, fuel, transmission 필터링
|
|
4. 마진 계산 후 페이징하여 반환
|
|
"""
|
|
|
|
# 마진율 (관리자 설정에서 가져오기)
|
|
system_settings = db.query(SystemSettings).first()
|
|
if system_settings:
|
|
KOREA_MARGIN_RATE = system_settings.korea_margin_percent / 100
|
|
MONGOLIA_MARGIN_RATE = system_settings.mongolia_margin_percent / 100
|
|
else:
|
|
# 기본값 (설정이 없는 경우)
|
|
KOREA_MARGIN_RATE = 0.05
|
|
MONGOLIA_MARGIN_RATE = 0.05
|
|
|
|
# 제조사/모델 이름 조회
|
|
maker_name = ""
|
|
model_name = ""
|
|
for m in CARMODOO_DATA.get("makers", []):
|
|
if m["code"] == maker_code:
|
|
maker_name = m["name"]
|
|
break
|
|
for m in CARMODOO_DATA.get("models", {}).get(maker_code, []):
|
|
if m["code"] == model_code:
|
|
model_name = m["name"]
|
|
break
|
|
|
|
# 캐시 서비스 생성
|
|
cache_service = CacheService(db, carmodoo_client)
|
|
cache_key = cache_service.get_cache_key(maker_code, model_code)
|
|
|
|
# 1. 캐시 조회
|
|
cache = cache_service.get_cache(cache_key)
|
|
|
|
if cache:
|
|
# 캐시 HIT - 로컬에서 필터링
|
|
cars = cache_service.get_cars_from_cache(cache)
|
|
else:
|
|
# 캐시 MISS - 카모두에서 전체 수집
|
|
cars = await cache_service.fetch_all_cars_for_cache(
|
|
maker_code=maker_code,
|
|
model_code=model_code,
|
|
maker_name=maker_name,
|
|
model_name=model_name
|
|
)
|
|
|
|
# 캐시 저장
|
|
if cars:
|
|
cache_service.save_cache(
|
|
cache_key=cache_key,
|
|
maker_code=maker_code,
|
|
maker_name=maker_name,
|
|
model_code=model_code,
|
|
model_name=model_name,
|
|
cars=cars
|
|
)
|
|
|
|
# 2. 로컬 필터링
|
|
filtered_cars = cache_service.filter_cars(
|
|
cars=cars,
|
|
year_min=year_min,
|
|
year_max=year_max,
|
|
mileage_min=mileage_min,
|
|
mileage_max=mileage_max,
|
|
price_min=price_min * 10000 if price_min else None, # 만원 -> 원
|
|
price_max=price_max * 10000 if price_max else None,
|
|
fuel=fuel,
|
|
transmission=transmission,
|
|
displacement_min=displacement_min,
|
|
displacement_max=displacement_max
|
|
)
|
|
|
|
# 3. 페이징
|
|
paginated_cars, total = cache_service.paginate_cars(
|
|
filtered_cars, page=page, page_size=page_size
|
|
)
|
|
|
|
# 4. 마진 계산 및 응답 생성
|
|
result_cars = []
|
|
for car in paginated_cars:
|
|
original_price = car.get("price") or car.get("original_price") or 0
|
|
korea_margin = int(original_price * KOREA_MARGIN_RATE)
|
|
mongolia_margin = int(original_price * MONGOLIA_MARGIN_RATE)
|
|
final_price = original_price + korea_margin + mongolia_margin
|
|
|
|
result_cars.append(CarmodooSearchResultItem(
|
|
id=car.get("car_no", car.get("id", "")),
|
|
car_name=car.get("car_name", ""),
|
|
maker_name=car.get("maker_name", maker_name),
|
|
model_name=car.get("model_name", model_name),
|
|
year=car.get("year"),
|
|
mileage=car.get("mileage"),
|
|
original_price=original_price,
|
|
korea_margin=korea_margin,
|
|
mongolia_margin=mongolia_margin,
|
|
final_price=final_price,
|
|
fuel=car.get("fuel"),
|
|
transmission=car.get("transmission"),
|
|
color=car.get("color"),
|
|
displacement=car.get("displacement"),
|
|
main_image=car.get("main_image"),
|
|
image_count=car.get("image_count", 20),
|
|
check_num=car.get("check_num"), # 성능점검번호
|
|
car_key=car.get("car_key"), # 암호화된 차량 키
|
|
))
|
|
|
|
return VehicleRequestSearchResponse(
|
|
total=total,
|
|
cars=result_cars
|
|
)
|
|
|
|
|
|
class ImportCarRequest(BaseModel):
|
|
car_no: str
|
|
car_name: str
|
|
maker_name: Optional[str] = None
|
|
model_name: Optional[str] = None
|
|
year: Optional[int] = None
|
|
mileage: Optional[int] = None
|
|
price: Optional[int] = None # 원가 (KRW)
|
|
fuel: Optional[str] = None
|
|
transmission: Optional[str] = None
|
|
color: Optional[str] = None
|
|
displacement: Optional[int] = None
|
|
main_image: Optional[str] = None
|
|
check_num: Optional[str] = None # 성능점검번호
|
|
car_key: Optional[str] = None # 암호화된 차량 키 (dealerCarviewPopup용)
|
|
dealer_description: Optional[str] = None # 편집된 딜러 설명 (민감정보 제거됨)
|
|
|
|
|
|
class ImportCarsRequest(BaseModel):
|
|
cars: List[ImportCarRequest]
|
|
|
|
|
|
async def download_image(url: str, save_path: str) -> bool:
|
|
"""이미지를 다운로드해서 로컬에 저장"""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
response = await client.get(url)
|
|
if response.status_code == 200:
|
|
os.makedirs(os.path.dirname(save_path), exist_ok=True)
|
|
with open(save_path, 'wb') as f:
|
|
f.write(response.content)
|
|
return True
|
|
except Exception as e:
|
|
print(f"Image download failed: {url} - {e}")
|
|
return False
|
|
|
|
|
|
@router.post("/import")
|
|
async def import_cars_from_carmodoo(
|
|
request: ImportCarsRequest,
|
|
db: Session = Depends(get_db),
|
|
current_user = Depends(get_current_user),
|
|
):
|
|
"""카모두에서 선택한 차량들을 로컬 DB로 가져오기 (이미지 포함)"""
|
|
|
|
imported = []
|
|
skipped = []
|
|
errors = []
|
|
|
|
for car_data in request.cars:
|
|
try:
|
|
# 이미 존재하는지 확인
|
|
existing = db.query(Car).filter(
|
|
Car.source == "carmodoo",
|
|
Car.source_id == car_data.car_no
|
|
).first()
|
|
|
|
if existing:
|
|
skipped.append({
|
|
"car_no": car_data.car_no,
|
|
"car_id": existing.id,
|
|
"reason": "already exists"
|
|
})
|
|
continue
|
|
|
|
# 제조사 찾기/생성
|
|
maker = None
|
|
maker_code = None
|
|
if car_data.maker_name:
|
|
# 제조사 코드 찾기
|
|
for m in CARMODOO_DATA.get("makers", []):
|
|
if m["name"] == car_data.maker_name:
|
|
maker_code = m["code"]
|
|
break
|
|
|
|
if maker_code:
|
|
maker = db.query(CarMaker).filter(CarMaker.code == maker_code).first()
|
|
if not maker:
|
|
maker = CarMaker(
|
|
code=maker_code,
|
|
name=car_data.maker_name,
|
|
name_en=car_data.maker_name
|
|
)
|
|
db.add(maker)
|
|
db.flush()
|
|
|
|
# 모델 찾기/생성
|
|
model = None
|
|
if maker and car_data.model_name and maker_code:
|
|
models_list = CARMODOO_DATA.get("models", {}).get(maker_code, [])
|
|
model_code = None
|
|
for m in models_list:
|
|
if m["name"] == car_data.model_name:
|
|
model_code = m["code"]
|
|
break
|
|
|
|
if model_code:
|
|
model = db.query(CarModel).filter(
|
|
CarModel.code == model_code,
|
|
CarModel.maker_id == maker.id
|
|
).first()
|
|
if not model:
|
|
model = CarModel(
|
|
code=model_code,
|
|
maker_id=maker.id,
|
|
name=car_data.model_name,
|
|
name_en=car_data.model_name
|
|
)
|
|
db.add(model)
|
|
db.flush()
|
|
|
|
# 차량 생성
|
|
car = Car(
|
|
source="carmodoo",
|
|
source_id=car_data.car_no,
|
|
maker_id=maker.id if maker else None,
|
|
model_id=model.id if model else None,
|
|
car_name=car_data.car_name,
|
|
year=car_data.year,
|
|
mileage=car_data.mileage,
|
|
price_krw=car_data.price,
|
|
fuel=car_data.fuel,
|
|
transmission=car_data.transmission,
|
|
color=car_data.color,
|
|
displacement=car_data.displacement,
|
|
is_displayed=True, # Banner용은 바로 표시
|
|
status="active"
|
|
)
|
|
db.add(car)
|
|
db.flush()
|
|
|
|
# 이미지 다운로드 (20장)
|
|
car_no_padded = car_data.car_no.zfill(9)
|
|
image_base_dir = f"./uploads/cars/{car.id}"
|
|
os.makedirs(image_base_dir, exist_ok=True)
|
|
|
|
downloaded_images = []
|
|
for i in range(20):
|
|
image_url = f"{CARMODOO_BASE_URL}/data/__carPhoto/{car_no_padded[:3]}/{car_no_padded[3:6]}/{car_no_padded[6:]}/cmcar_{i}.jpg"
|
|
local_filename = f"image_{i}.jpg"
|
|
local_path = f"{image_base_dir}/{local_filename}"
|
|
|
|
if await download_image(image_url, local_path):
|
|
car_image = CarImage(
|
|
car_id=car.id,
|
|
url=f"/uploads/cars/{car.id}/{local_filename}",
|
|
local_path=local_path,
|
|
is_main=(i == 0),
|
|
sort_order=i
|
|
)
|
|
db.add(car_image)
|
|
downloaded_images.append(local_filename)
|
|
else:
|
|
# 이미지 없으면 중단 (보통 20장 미만인 경우)
|
|
if i > 0:
|
|
break
|
|
|
|
# 성능점검표 가져오기 (check_num이 있으면 직접 사용)
|
|
perf_check_result = await carmodoo_client.get_performance_check(
|
|
car_data.car_no,
|
|
check_num=car_data.check_num or ""
|
|
)
|
|
performance_check_saved = False
|
|
|
|
if perf_check_result.get("found") and perf_check_result.get("data"):
|
|
perf_data = perf_check_result["data"]
|
|
|
|
# 차량번호를 cars 테이블에 저장 (원자성)
|
|
if perf_data.get("car_number"):
|
|
car.car_number = perf_data.get("car_number")
|
|
|
|
# 차량 상세정보 가져오기 (딜러 설명)
|
|
# 관리자가 편집한 dealer_description이 있으면 사용, 없으면 원본 가져와서 자동 마스킹
|
|
if car_data.dealer_description:
|
|
# 관리자가 편집한 설명 사용
|
|
car.dealer_description = car_data.dealer_description
|
|
else:
|
|
# 원본 가져와서 자동 마스킹
|
|
car_detail = await carmodoo_client.get_car_detail(car_data.car_no, car_data.car_key or "")
|
|
if car_detail.get("found") and car_detail.get("dealer_description"):
|
|
original_desc = car_detail.get("dealer_description")
|
|
# 민감정보 자동 마스킹 적용
|
|
car.dealer_description = mask_sensitive_info(original_desc)
|
|
|
|
# 딜러 설명 번역 (한국어 → 영어/몽골어/러시아어)
|
|
if car.dealer_description:
|
|
try:
|
|
translations = await translate_dealer_description(car.dealer_description)
|
|
car.dealer_description_en = translations.get('en')
|
|
car.dealer_description_mn = translations.get('mn')
|
|
car.dealer_description_ru = translations.get('ru')
|
|
print(f"[Translation] Dealer description translated for car {car.id}")
|
|
except Exception as trans_error:
|
|
print(f"[Translation] Failed for car {car.id}: {trans_error}")
|
|
# 번역 실패 시 원문 유지 (번역 필드는 None 상태)
|
|
|
|
# CarPerformanceCheck 생성 (성능점검 데이터가 있을 때만)
|
|
pdf_status = {"success": False, "attempts": 0, "error": None, "check_num": None}
|
|
if perf_check_result.get("found") and perf_check_result.get("data"):
|
|
performance_check = CarPerformanceCheck(
|
|
car_id=car.id,
|
|
check_number=perf_data.get("check_number"),
|
|
check_date=perf_data.get("check_date"),
|
|
valid_until=perf_data.get("valid_until"),
|
|
first_registration=perf_data.get("first_registration"),
|
|
mileage=perf_data.get("mileage"),
|
|
mileage_status=perf_data.get("mileage_status"),
|
|
seize_count=perf_data.get("seize_count", 0),
|
|
collateral_count=perf_data.get("collateral_count", 0),
|
|
is_flood_damaged=perf_data.get("is_flood_damaged", False),
|
|
is_fire_damaged=perf_data.get("is_fire_damaged", False),
|
|
is_total_loss=perf_data.get("is_total_loss", False),
|
|
usage_history=perf_data.get("usage_history"),
|
|
is_rental_used=perf_data.get("is_rental_used", False),
|
|
engine_status=perf_data.get("engine_status"),
|
|
transmission_status=perf_data.get("transmission_status"),
|
|
power_delivery_status=perf_data.get("power_delivery_status"),
|
|
steering_status=perf_data.get("steering_status"),
|
|
brake_status=perf_data.get("brake_status"),
|
|
electrical_status=perf_data.get("electrical_status"),
|
|
fuel_system_status=perf_data.get("fuel_system_status"),
|
|
tire_front_left=perf_data.get("tire_front_left"),
|
|
tire_front_right=perf_data.get("tire_front_right"),
|
|
tire_rear_left=perf_data.get("tire_rear_left"),
|
|
tire_rear_right=perf_data.get("tire_rear_right"),
|
|
hood=perf_data.get("hood"),
|
|
front_fender_left=perf_data.get("front_fender_left"),
|
|
front_fender_right=perf_data.get("front_fender_right"),
|
|
front_door_left=perf_data.get("front_door_left"),
|
|
front_door_right=perf_data.get("front_door_right"),
|
|
rear_door_left=perf_data.get("rear_door_left"),
|
|
rear_door_right=perf_data.get("rear_door_right"),
|
|
trunk_lid=perf_data.get("trunk_lid"),
|
|
radiator_support=perf_data.get("radiator_support"),
|
|
roof_panel=perf_data.get("roof_panel"),
|
|
quarter_panel_left=perf_data.get("quarter_panel_left"),
|
|
quarter_panel_right=perf_data.get("quarter_panel_right"),
|
|
side_sill_left=perf_data.get("side_sill_left"),
|
|
side_sill_right=perf_data.get("side_sill_right"),
|
|
front_panel=perf_data.get("front_panel"),
|
|
cross_member=perf_data.get("cross_member"),
|
|
inside_panel_left=perf_data.get("inside_panel_left"),
|
|
inside_panel_right=perf_data.get("inside_panel_right"),
|
|
side_member_left=perf_data.get("side_member_left"),
|
|
side_member_right=perf_data.get("side_member_right"),
|
|
wheel_house_left=perf_data.get("wheel_house_left"),
|
|
wheel_house_right=perf_data.get("wheel_house_right"),
|
|
dash_panel=perf_data.get("dash_panel"),
|
|
floor_panel=perf_data.get("floor_panel"),
|
|
trunk_floor=perf_data.get("trunk_floor"),
|
|
rear_panel=perf_data.get("rear_panel"),
|
|
pillar_a_left=perf_data.get("pillar_a_left"),
|
|
pillar_a_right=perf_data.get("pillar_a_right"),
|
|
pillar_b_left=perf_data.get("pillar_b_left"),
|
|
pillar_b_right=perf_data.get("pillar_b_right"),
|
|
pillar_c_left=perf_data.get("pillar_c_left"),
|
|
pillar_c_right=perf_data.get("pillar_c_right"),
|
|
package_tray=perf_data.get("package_tray"),
|
|
accident_history=perf_data.get("accident_history"),
|
|
raw_data=perf_data,
|
|
raw_html=perf_check_result.get("raw_html", "")[:50000], # 50KB 제한
|
|
report_image_url=perf_data.get("report_image_url"),
|
|
)
|
|
db.add(performance_check)
|
|
db.flush() # Get ID for PDF filename
|
|
|
|
# PDF 캡처 (성능점검표) - capture_performance_check_pdf가 내부적으로 3회 재시도
|
|
check_num = perf_data.get("check_number")
|
|
pdf_status["check_num"] = check_num
|
|
if check_num:
|
|
try:
|
|
print(f"[PDF] Starting capture for car {car.id}, check_num={check_num}")
|
|
# 함수 내부에서 3회 재시도 수행
|
|
pdf_path = await capture_performance_check_pdf(check_num, car.id)
|
|
if pdf_path:
|
|
performance_check.pdf_path = pdf_path
|
|
pdf_status["success"] = True
|
|
pdf_status["path"] = pdf_path
|
|
pdf_status["attempts"] = 1
|
|
print(f"[PDF] Success: {pdf_path}")
|
|
else:
|
|
pdf_status["error"] = "No path returned after 3 retries"
|
|
pdf_status["attempts"] = 3
|
|
print(f"[PDF] Failed after internal retries: No path returned")
|
|
except Exception as pdf_error:
|
|
pdf_status["error"] = str(pdf_error)
|
|
pdf_status["attempts"] = 3
|
|
print(f"[PDF] Exception: {pdf_error}")
|
|
|
|
performance_check_saved = True
|
|
|
|
# 상세사양 조회 및 저장 (차량번호가 있는 경우만)
|
|
spec_saved = False
|
|
if car.car_number:
|
|
try:
|
|
print(f"Fetching specifications for car_number: {car.car_number}")
|
|
spec_data = await get_specifications_from_carmodoo(car.car_number)
|
|
if spec_data:
|
|
spec_dict = spec_to_dict(spec_data)
|
|
car_spec = CarSpecification(
|
|
car_id=car.id,
|
|
manufacturer=spec_dict.get("manufacturer"),
|
|
model_name=spec_dict.get("model_name"),
|
|
grade=spec_dict.get("grade"),
|
|
model_year=spec_dict.get("model_year"),
|
|
displacement=spec_dict.get("displacement"),
|
|
fuel_type=spec_dict.get("fuel_type"),
|
|
transmission=spec_dict.get("transmission"),
|
|
drive_type=spec_dict.get("drive_type"),
|
|
max_power=spec_dict.get("max_power"),
|
|
max_torque=spec_dict.get("max_torque"),
|
|
fuel_efficiency=spec_dict.get("fuel_efficiency"),
|
|
body_type=spec_dict.get("body_type"),
|
|
door_count=spec_dict.get("door_count"),
|
|
seating_capacity=spec_dict.get("seating_capacity"),
|
|
length=spec_dict.get("length"),
|
|
width=spec_dict.get("width"),
|
|
height=spec_dict.get("height"),
|
|
wheelbase=spec_dict.get("wheelbase"),
|
|
curb_weight=spec_dict.get("curb_weight"),
|
|
safety_options=spec_dict.get("safety_options"),
|
|
comfort_options=spec_dict.get("comfort_options"),
|
|
exterior_options=spec_dict.get("exterior_options"),
|
|
interior_options=spec_dict.get("interior_options"),
|
|
raw_data=spec_dict.get("raw_data"),
|
|
)
|
|
db.add(car_spec)
|
|
spec_saved = True
|
|
print(f"Specifications saved for car {car.id}")
|
|
except Exception as spec_error:
|
|
print(f"Specification fetch failed for car {car.id}: {spec_error}")
|
|
|
|
db.commit()
|
|
|
|
imported.append({
|
|
"car_no": car_data.car_no,
|
|
"car_id": car.id,
|
|
"car_name": car_data.car_name,
|
|
"images_downloaded": len(downloaded_images),
|
|
"performance_check_saved": performance_check_saved,
|
|
"specification_saved": spec_saved,
|
|
"pdf_status": pdf_status
|
|
})
|
|
|
|
except Exception as e:
|
|
db.rollback()
|
|
errors.append({
|
|
"car_no": car_data.car_no,
|
|
"error": str(e)
|
|
})
|
|
|
|
# PDF 상태 요약
|
|
pdf_success_count = sum(1 for i in imported if i.get("pdf_status", {}).get("success"))
|
|
pdf_failed_count = len(imported) - pdf_success_count
|
|
|
|
return {
|
|
"imported": imported,
|
|
"skipped": skipped,
|
|
"errors": errors,
|
|
"summary": {
|
|
"imported_count": len(imported),
|
|
"skipped_count": len(skipped),
|
|
"error_count": len(errors),
|
|
"pdf_success_count": pdf_success_count,
|
|
"pdf_failed_count": pdf_failed_count
|
|
}
|
|
}
|
|
|
|
|
|
@router.get("/performance-check/{car_no}")
|
|
async def get_performance_check(
|
|
car_no: str,
|
|
current_user = Depends(get_current_user),
|
|
):
|
|
"""카모두에서 성능점검표 조회 (미리보기용)"""
|
|
result = await carmodoo_client.get_performance_check(car_no)
|
|
return result
|
|
|
|
|
|
@router.get("/car/{car_id}/performance-check")
|
|
async def get_car_performance_check(
|
|
car_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user = Depends(get_current_user_optional),
|
|
):
|
|
"""저장된 차량의 성능점검표 조회 (0.1 CC 결제 필요)"""
|
|
car = db.query(Car).filter(Car.id == car_id).first()
|
|
if not car:
|
|
raise HTTPException(status_code=404, detail="Car not found")
|
|
|
|
performance_check = db.query(CarPerformanceCheck).filter(
|
|
CarPerformanceCheck.car_id == car_id
|
|
).first()
|
|
|
|
if not performance_check:
|
|
return {"car_id": car_id, "found": False, "has_access": False, "data": None}
|
|
|
|
# Check if user has paid for performance check
|
|
has_access = False
|
|
if current_user:
|
|
# Admin always has access
|
|
if current_user.is_admin:
|
|
has_access = True
|
|
else:
|
|
# Check 1: User purchased this performance check (0.1 CC)
|
|
existing_perf_view = db.query(PerformanceCheckView).filter(
|
|
PerformanceCheckView.user_id == current_user.id,
|
|
PerformanceCheckView.car_id == car_id
|
|
).first()
|
|
|
|
# Check 2: User purchased full car view (1 CC) -> performance check included
|
|
existing_car_view = db.query(CarView).filter(
|
|
CarView.user_id == current_user.id,
|
|
CarView.car_id == car_id
|
|
).first()
|
|
|
|
# Check 3: This car was recommended to the user (paid 1 CC for recommendation)
|
|
recommended_vehicle = db.query(RequestVehicle).join(VehicleRequest).filter(
|
|
VehicleRequest.user_id == current_user.id,
|
|
RequestVehicle.car_id == car_id,
|
|
RequestVehicle.is_approved == True
|
|
).first()
|
|
|
|
has_access = (existing_perf_view is not None) or (existing_car_view is not None) or (recommended_vehicle is not None)
|
|
|
|
# If no access, return only basic info (that performance check exists)
|
|
if not has_access:
|
|
return {
|
|
"car_id": car_id,
|
|
"found": True,
|
|
"has_access": False,
|
|
"preview": {
|
|
"check_number": performance_check.check_number,
|
|
"check_date": performance_check.check_date,
|
|
"mileage": performance_check.mileage,
|
|
},
|
|
"data": None
|
|
}
|
|
|
|
# 응답 데이터 구성
|
|
data = {
|
|
"id": performance_check.id,
|
|
"car_id": performance_check.car_id,
|
|
"check_number": performance_check.check_number,
|
|
"check_date": performance_check.check_date,
|
|
"valid_until": performance_check.valid_until,
|
|
"car_number": car.car_number, # car_number is in Car model, not PerformanceCheck
|
|
"first_registration": performance_check.first_registration,
|
|
"mileage": performance_check.mileage,
|
|
"mileage_status": performance_check.mileage_status,
|
|
"seize_count": performance_check.seize_count,
|
|
"collateral_count": performance_check.collateral_count,
|
|
"is_flood_damaged": performance_check.is_flood_damaged,
|
|
"is_fire_damaged": performance_check.is_fire_damaged,
|
|
"is_total_loss": performance_check.is_total_loss,
|
|
"usage_history": performance_check.usage_history,
|
|
"is_rental_used": performance_check.is_rental_used,
|
|
"device_status": {
|
|
"engine": performance_check.engine_status,
|
|
"transmission": performance_check.transmission_status,
|
|
"power_delivery": performance_check.power_delivery_status,
|
|
"steering": performance_check.steering_status,
|
|
"brake": performance_check.brake_status,
|
|
"electrical": performance_check.electrical_status,
|
|
"fuel_system": performance_check.fuel_system_status,
|
|
},
|
|
"tire_status": {
|
|
"front_left": performance_check.tire_front_left,
|
|
"front_right": performance_check.tire_front_right,
|
|
"rear_left": performance_check.tire_rear_left,
|
|
"rear_right": performance_check.tire_rear_right,
|
|
},
|
|
"body_parts": {
|
|
"hood": performance_check.hood,
|
|
"front_fender_left": performance_check.front_fender_left,
|
|
"front_fender_right": performance_check.front_fender_right,
|
|
"front_door_left": performance_check.front_door_left,
|
|
"front_door_right": performance_check.front_door_right,
|
|
"rear_door_left": performance_check.rear_door_left,
|
|
"rear_door_right": performance_check.rear_door_right,
|
|
"trunk_lid": performance_check.trunk_lid,
|
|
"radiator_support": performance_check.radiator_support,
|
|
"roof_panel": performance_check.roof_panel,
|
|
"quarter_panel_left": performance_check.quarter_panel_left,
|
|
"quarter_panel_right": performance_check.quarter_panel_right,
|
|
"side_sill_left": performance_check.side_sill_left,
|
|
"side_sill_right": performance_check.side_sill_right,
|
|
},
|
|
"frame_parts": {
|
|
"front_panel": performance_check.front_panel,
|
|
"cross_member": performance_check.cross_member,
|
|
"inside_panel_left": performance_check.inside_panel_left,
|
|
"inside_panel_right": performance_check.inside_panel_right,
|
|
"side_member_left": performance_check.side_member_left,
|
|
"side_member_right": performance_check.side_member_right,
|
|
"wheel_house_left": performance_check.wheel_house_left,
|
|
"wheel_house_right": performance_check.wheel_house_right,
|
|
"dash_panel": performance_check.dash_panel,
|
|
"floor_panel": performance_check.floor_panel,
|
|
"trunk_floor": performance_check.trunk_floor,
|
|
"rear_panel": performance_check.rear_panel,
|
|
"pillar_a_left": performance_check.pillar_a_left,
|
|
"pillar_a_right": performance_check.pillar_a_right,
|
|
"pillar_b_left": performance_check.pillar_b_left,
|
|
"pillar_b_right": performance_check.pillar_b_right,
|
|
"pillar_c_left": performance_check.pillar_c_left,
|
|
"pillar_c_right": performance_check.pillar_c_right,
|
|
"package_tray": performance_check.package_tray,
|
|
},
|
|
"accident_history": performance_check.accident_history,
|
|
"report_image_url": performance_check.report_image_url,
|
|
"created_at": performance_check.created_at.isoformat() if performance_check.created_at else None,
|
|
"pdf_path": performance_check.pdf_path, # PDF 파일 경로 추가
|
|
}
|
|
|
|
return {"car_id": car_id, "found": True, "has_access": True, "data": data}
|
|
|
|
|
|
@router.get("/car/{car_id}/performance-check/pdf")
|
|
async def get_car_performance_check_pdf(
|
|
car_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user = Depends(get_current_user),
|
|
):
|
|
"""성능점검표 PDF 다운로드 (0.1 CC 결제 필요)"""
|
|
car = db.query(Car).filter(Car.id == car_id).first()
|
|
if not car:
|
|
raise HTTPException(status_code=404, detail="Car not found")
|
|
|
|
performance_check = db.query(CarPerformanceCheck).filter(
|
|
CarPerformanceCheck.car_id == car_id
|
|
).first()
|
|
|
|
if not performance_check:
|
|
raise HTTPException(status_code=404, detail="Performance check not found")
|
|
|
|
if not performance_check.pdf_path:
|
|
raise HTTPException(status_code=404, detail="PDF not available for this car")
|
|
|
|
# Check access (admin, purchased performance check, or purchased car view)
|
|
has_access = False
|
|
if current_user.is_admin:
|
|
has_access = True
|
|
else:
|
|
# Check 1: Purchased performance check (0.1 CC)
|
|
existing_perf_view = db.query(PerformanceCheckView).filter(
|
|
PerformanceCheckView.user_id == current_user.id,
|
|
PerformanceCheckView.car_id == car_id
|
|
).first()
|
|
|
|
# Check 2: Purchased full car view (1 CC) -> performance check included
|
|
existing_car_view = db.query(CarView).filter(
|
|
CarView.user_id == current_user.id,
|
|
CarView.car_id == car_id
|
|
).first()
|
|
|
|
has_access = (existing_perf_view is not None) or (existing_car_view is not None)
|
|
|
|
if not has_access:
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="Access denied. Please purchase access with 0.1 CC or 1 CC for full car view."
|
|
)
|
|
|
|
# Get full path and return file
|
|
pdf_full_path = get_pdf_full_path(performance_check.pdf_path)
|
|
if not pdf_full_path or not pdf_full_path.exists():
|
|
raise HTTPException(status_code=404, detail="PDF file not found on server")
|
|
|
|
return FileResponse(
|
|
path=str(pdf_full_path),
|
|
media_type="application/pdf",
|
|
filename=f"performance_check_{car_id}.pdf"
|
|
)
|
|
|
|
|
|
@router.post("/car/{car_id}/fetch-performance-check")
|
|
async def fetch_performance_check_for_car(
|
|
car_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user = Depends(get_current_admin_user),
|
|
):
|
|
"""기존 차량의 성능점검표 데이터 가져오기 (관리자 전용)
|
|
|
|
차량이 import될 때 성능점검표가 없었거나, 나중에 추가된 경우 사용
|
|
"""
|
|
car = db.query(Car).filter(Car.id == car_id).first()
|
|
if not car:
|
|
raise HTTPException(status_code=404, detail="Car not found")
|
|
|
|
if not car.source_id:
|
|
raise HTTPException(status_code=400, detail="Car has no source_id (car_no)")
|
|
|
|
# 기존 성능점검 데이터가 있는지 확인
|
|
existing_check = db.query(CarPerformanceCheck).filter(
|
|
CarPerformanceCheck.car_id == car_id
|
|
).first()
|
|
|
|
if existing_check:
|
|
return {
|
|
"message": "Performance check already exists",
|
|
"car_id": car_id,
|
|
"check_number": existing_check.check_number,
|
|
"has_pdf": bool(existing_check.pdf_path)
|
|
}
|
|
|
|
# Carmodoo에서 성능점검 데이터 가져오기
|
|
perf_check_result = await carmodoo_client.get_performance_check(car.source_id)
|
|
|
|
if not perf_check_result.get("found") or not perf_check_result.get("data"):
|
|
raise HTTPException(status_code=404, detail="Performance check not found on Carmodoo")
|
|
|
|
perf_data = perf_check_result["data"]
|
|
|
|
# CarPerformanceCheck 생성
|
|
performance_check = CarPerformanceCheck(
|
|
car_id=car.id,
|
|
check_number=perf_data.get("check_number"),
|
|
check_date=perf_data.get("check_date"),
|
|
valid_until=perf_data.get("valid_until"),
|
|
car_number=perf_data.get("car_number"),
|
|
first_registration=perf_data.get("first_registration"),
|
|
mileage=perf_data.get("mileage"),
|
|
mileage_status=perf_data.get("mileage_status"),
|
|
seize_count=perf_data.get("seize_count", 0),
|
|
collateral_count=perf_data.get("collateral_count", 0),
|
|
is_flood_damaged=perf_data.get("is_flood_damaged", False),
|
|
is_fire_damaged=perf_data.get("is_fire_damaged", False),
|
|
is_total_loss=perf_data.get("is_total_loss", False),
|
|
usage_history=perf_data.get("usage_history"),
|
|
is_rental_used=perf_data.get("is_rental_used", False),
|
|
engine_status=perf_data.get("engine_status"),
|
|
transmission_status=perf_data.get("transmission_status"),
|
|
power_delivery_status=perf_data.get("power_delivery_status"),
|
|
steering_status=perf_data.get("steering_status"),
|
|
brake_status=perf_data.get("brake_status"),
|
|
electrical_status=perf_data.get("electrical_status"),
|
|
fuel_system_status=perf_data.get("fuel_system_status"),
|
|
tire_front_left=perf_data.get("tire_front_left"),
|
|
tire_front_right=perf_data.get("tire_front_right"),
|
|
tire_rear_left=perf_data.get("tire_rear_left"),
|
|
tire_rear_right=perf_data.get("tire_rear_right"),
|
|
hood=perf_data.get("hood"),
|
|
front_fender_left=perf_data.get("front_fender_left"),
|
|
front_fender_right=perf_data.get("front_fender_right"),
|
|
front_door_left=perf_data.get("front_door_left"),
|
|
front_door_right=perf_data.get("front_door_right"),
|
|
rear_door_left=perf_data.get("rear_door_left"),
|
|
rear_door_right=perf_data.get("rear_door_right"),
|
|
trunk_lid=perf_data.get("trunk_lid"),
|
|
radiator_support=perf_data.get("radiator_support"),
|
|
roof_panel=perf_data.get("roof_panel"),
|
|
quarter_panel_left=perf_data.get("quarter_panel_left"),
|
|
quarter_panel_right=perf_data.get("quarter_panel_right"),
|
|
side_sill_left=perf_data.get("side_sill_left"),
|
|
side_sill_right=perf_data.get("side_sill_right"),
|
|
front_panel=perf_data.get("front_panel"),
|
|
cross_member=perf_data.get("cross_member"),
|
|
inside_panel_left=perf_data.get("inside_panel_left"),
|
|
inside_panel_right=perf_data.get("inside_panel_right"),
|
|
side_member_left=perf_data.get("side_member_left"),
|
|
side_member_right=perf_data.get("side_member_right"),
|
|
wheel_house_left=perf_data.get("wheel_house_left"),
|
|
wheel_house_right=perf_data.get("wheel_house_right"),
|
|
dash_panel=perf_data.get("dash_panel"),
|
|
floor_panel=perf_data.get("floor_panel"),
|
|
trunk_floor=perf_data.get("trunk_floor"),
|
|
rear_panel=perf_data.get("rear_panel"),
|
|
pillar_a_left=perf_data.get("pillar_a_left"),
|
|
pillar_a_right=perf_data.get("pillar_a_right"),
|
|
pillar_b_left=perf_data.get("pillar_b_left"),
|
|
pillar_b_right=perf_data.get("pillar_b_right"),
|
|
pillar_c_left=perf_data.get("pillar_c_left"),
|
|
pillar_c_right=perf_data.get("pillar_c_right"),
|
|
package_tray=perf_data.get("package_tray"),
|
|
accident_history=perf_data.get("accident_history"),
|
|
report_image_url=perf_data.get("report_image_url"),
|
|
)
|
|
db.add(performance_check)
|
|
db.flush()
|
|
|
|
# PDF 캡처
|
|
pdf_path = None
|
|
if performance_check.check_number:
|
|
try:
|
|
pdf_path = await capture_performance_check_pdf(
|
|
performance_check.check_number,
|
|
car.id
|
|
)
|
|
if pdf_path:
|
|
performance_check.pdf_path = pdf_path
|
|
except Exception as e:
|
|
print(f"PDF capture failed for car {car.id}: {e}")
|
|
|
|
db.commit()
|
|
|
|
return {
|
|
"message": "Performance check fetched successfully",
|
|
"car_id": car_id,
|
|
"check_number": performance_check.check_number,
|
|
"has_pdf": bool(pdf_path)
|
|
}
|
|
|
|
|
|
@router.post("/car/{car_id}/regenerate-pdf")
|
|
async def regenerate_performance_check_pdf(
|
|
car_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user = Depends(get_current_admin_user),
|
|
):
|
|
"""성능점검표 PDF 재생성 (관리자 전용)"""
|
|
|
|
performance_check = db.query(CarPerformanceCheck).filter(
|
|
CarPerformanceCheck.car_id == car_id
|
|
).first()
|
|
|
|
if not performance_check:
|
|
raise HTTPException(status_code=404, detail="Performance check not found")
|
|
|
|
if not performance_check.check_number:
|
|
raise HTTPException(status_code=400, detail="No check number available")
|
|
|
|
try:
|
|
pdf_path = await capture_performance_check_pdf(
|
|
performance_check.check_number,
|
|
car_id
|
|
)
|
|
if pdf_path:
|
|
performance_check.pdf_path = pdf_path
|
|
db.commit()
|
|
return {"message": "PDF regenerated successfully", "pdf_path": pdf_path}
|
|
else:
|
|
raise HTTPException(status_code=500, detail="Failed to generate PDF")
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"PDF generation error: {str(e)}")
|
|
|
|
|
|
@router.post("/car/{car_id}/fetch-check-num")
|
|
async def fetch_car_check_num(
|
|
car_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user = Depends(get_current_admin_user),
|
|
):
|
|
"""차량의 성능점검번호를 카모두에서 가져와서 저장 (관리자 전용)"""
|
|
|
|
car = db.query(Car).filter(Car.id == car_id).first()
|
|
if not car:
|
|
raise HTTPException(status_code=404, detail="Car not found")
|
|
|
|
if not car.car_number:
|
|
raise HTTPException(status_code=400, detail="Car number not available")
|
|
|
|
# 기존 성능점검 레코드 확인
|
|
performance_check = db.query(CarPerformanceCheck).filter(
|
|
CarPerformanceCheck.car_id == car_id
|
|
).first()
|
|
|
|
# 이미 check_number가 있으면 바로 PDF 생성 시도 (최대 3회 재시도)
|
|
if performance_check and performance_check.check_number:
|
|
pdf_error = None
|
|
pdf_path = None
|
|
max_retries = 3
|
|
|
|
for attempt in range(1, max_retries + 1):
|
|
try:
|
|
print(f"[PDF API] Attempt {attempt}/{max_retries} for car_id={car_id}, check_num={performance_check.check_number}")
|
|
pdf_path = await capture_performance_check_pdf(
|
|
performance_check.check_number,
|
|
car_id
|
|
)
|
|
print(f"[PDF API] Attempt {attempt} result: pdf_path={pdf_path}")
|
|
|
|
if pdf_path:
|
|
performance_check.pdf_path = pdf_path
|
|
db.commit()
|
|
return {
|
|
"message": f"PDF generated successfully (attempt {attempt})",
|
|
"check_number": performance_check.check_number,
|
|
"pdf_path": pdf_path
|
|
}
|
|
else:
|
|
pdf_error = f"Attempt {attempt}: PDF generation returned None"
|
|
except Exception as e:
|
|
import traceback
|
|
pdf_error = f"Attempt {attempt}: {str(e)}"
|
|
print(f"[PDF API] Exception on attempt {attempt}: {pdf_error}")
|
|
|
|
# 재시도 전 대기
|
|
if attempt < max_retries:
|
|
await asyncio.sleep(2)
|
|
|
|
return {
|
|
"message": f"Check number exists but PDF generation failed after {max_retries} attempts: {pdf_error}",
|
|
"check_number": performance_check.check_number,
|
|
"pdf_path": None
|
|
}
|
|
|
|
# 카모두에서 check_num 가져오기
|
|
try:
|
|
check_num = await carmodoo_client.get_car_check_num(car.car_number, "")
|
|
if not check_num:
|
|
raise HTTPException(status_code=404, detail="Could not find check number from Carmodoo")
|
|
|
|
# 성능점검 레코드 생성 또는 업데이트
|
|
if not performance_check:
|
|
performance_check = CarPerformanceCheck(
|
|
car_id=car_id,
|
|
check_number=check_num
|
|
)
|
|
db.add(performance_check)
|
|
else:
|
|
performance_check.check_number = check_num
|
|
|
|
# PDF 생성 시도 (최대 3회 재시도)
|
|
pdf_path = None
|
|
pdf_error = None
|
|
max_retries = 3
|
|
|
|
for attempt in range(1, max_retries + 1):
|
|
try:
|
|
print(f"[PDF API New] Attempt {attempt}/{max_retries} for car_id={car_id}, check_num={check_num}")
|
|
pdf_path = await capture_performance_check_pdf(check_num, car_id)
|
|
|
|
if pdf_path:
|
|
performance_check.pdf_path = pdf_path
|
|
print(f"[PDF API New] Success on attempt {attempt}: {pdf_path}")
|
|
break
|
|
else:
|
|
pdf_error = f"Attempt {attempt}: returned None"
|
|
except Exception as e:
|
|
pdf_error = f"Attempt {attempt}: {str(e)}"
|
|
print(f"[PDF API New] Exception on attempt {attempt}: {pdf_error}")
|
|
|
|
if attempt < max_retries:
|
|
await asyncio.sleep(2)
|
|
|
|
db.commit()
|
|
|
|
return {
|
|
"message": "Check number fetched successfully" + (f" (PDF: {pdf_error})" if not pdf_path else ""),
|
|
"check_number": check_num,
|
|
"pdf_path": pdf_path
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Error fetching check number: {str(e)}")
|
|
|
|
|
|
@router.get("/admin/pdf-failures")
|
|
async def get_pdf_generation_failures(
|
|
current_user: User = Depends(get_current_admin_user)
|
|
):
|
|
"""Get list of recent PDF generation failures (Admin only)"""
|
|
failures = get_pdf_failures()
|
|
return {
|
|
"failures": failures,
|
|
"count": len(failures)
|
|
}
|
|
|
|
|
|
@router.post("/admin/retry-all-failed-pdfs")
|
|
async def retry_all_failed_pdfs(
|
|
current_user: User = Depends(get_current_admin_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Retry PDF generation for all cars with missing PDFs (Admin only)"""
|
|
results = []
|
|
carmodoo_client = CarmodooClient()
|
|
|
|
# 1. 배너 차량 중 car_performance_checks 레코드가 없는 차량 찾기
|
|
cars_without_check = db.query(Car).outerjoin(
|
|
CarPerformanceCheck, Car.id == CarPerformanceCheck.car_id
|
|
).filter(
|
|
Car.is_displayed == True,
|
|
CarPerformanceCheck.id.is_(None)
|
|
).all()
|
|
|
|
for car in cars_without_check:
|
|
try:
|
|
# 차량번호로 성능점검 데이터 조회
|
|
check_num = car.check_num or ""
|
|
if not check_num and car.car_number:
|
|
# check_num이 없으면 조회 시도
|
|
check_num = await carmodoo_client.get_car_check_num(car.source_id, car.source_key or "")
|
|
|
|
if not check_num:
|
|
results.append({
|
|
"car_id": car.id,
|
|
"car_name": car.car_name,
|
|
"status": "skipped",
|
|
"reason": "No check_num available"
|
|
})
|
|
continue
|
|
|
|
# 성능점검 데이터 가져오기
|
|
perf_result = await carmodoo_client.get_performance_check(car.source_id, car.source_key or "", check_num)
|
|
|
|
if perf_result.get("found") and perf_result.get("data"):
|
|
perf_data = perf_result["data"]
|
|
# CarPerformanceCheck 레코드 생성
|
|
performance_check = CarPerformanceCheck(
|
|
car_id=car.id,
|
|
check_number=perf_data.get("check_number") or check_num,
|
|
check_date=perf_data.get("check_date"),
|
|
valid_until=perf_data.get("valid_until"),
|
|
first_registration=perf_data.get("first_registration"),
|
|
mileage=perf_data.get("mileage"),
|
|
mileage_status=perf_data.get("mileage_status"),
|
|
seize_count=perf_data.get("seize_count", 0),
|
|
collateral_count=perf_data.get("collateral_count", 0),
|
|
is_flood_damaged=perf_data.get("is_flood_damaged", False),
|
|
is_fire_damaged=perf_data.get("is_fire_damaged", False),
|
|
is_total_loss=perf_data.get("is_total_loss", False),
|
|
engine_status=perf_data.get("engine_status"),
|
|
transmission_status=perf_data.get("transmission_status"),
|
|
power_delivery_status=perf_data.get("power_delivery_status"),
|
|
raw_data=perf_data,
|
|
raw_html=perf_result.get("raw_html", "")[:50000],
|
|
)
|
|
db.add(performance_check)
|
|
db.flush()
|
|
|
|
# PDF 캡처
|
|
pdf_path = await capture_performance_check_pdf(performance_check.check_number, car.id)
|
|
if pdf_path:
|
|
performance_check.pdf_path = pdf_path
|
|
db.commit()
|
|
results.append({
|
|
"car_id": car.id,
|
|
"car_name": car.car_name,
|
|
"check_number": performance_check.check_number,
|
|
"status": "success",
|
|
"pdf_path": pdf_path,
|
|
"action": "created_and_captured"
|
|
})
|
|
else:
|
|
db.commit()
|
|
results.append({
|
|
"car_id": car.id,
|
|
"car_name": car.car_name,
|
|
"check_number": performance_check.check_number,
|
|
"status": "partial",
|
|
"reason": "Record created but PDF capture failed"
|
|
})
|
|
else:
|
|
results.append({
|
|
"car_id": car.id,
|
|
"car_name": car.car_name,
|
|
"status": "failed",
|
|
"reason": "Could not fetch performance check data"
|
|
})
|
|
except Exception as e:
|
|
results.append({
|
|
"car_id": car.id,
|
|
"car_name": car.car_name,
|
|
"status": "error",
|
|
"error": str(e)
|
|
})
|
|
|
|
# 2. 기존 로직: check_number가 있지만 pdf_path가 없는 레코드
|
|
missing_pdfs = db.query(CarPerformanceCheck).filter(
|
|
CarPerformanceCheck.check_number.isnot(None),
|
|
CarPerformanceCheck.check_number != '',
|
|
(CarPerformanceCheck.pdf_path.is_(None)) | (CarPerformanceCheck.pdf_path == '')
|
|
).all()
|
|
|
|
for check in missing_pdfs:
|
|
try:
|
|
pdf_path = await capture_performance_check_pdf(check.check_number, check.car_id)
|
|
if pdf_path:
|
|
check.pdf_path = pdf_path
|
|
db.commit()
|
|
results.append({
|
|
"car_id": check.car_id,
|
|
"check_number": check.check_number,
|
|
"status": "success",
|
|
"pdf_path": pdf_path,
|
|
"action": "captured"
|
|
})
|
|
else:
|
|
results.append({
|
|
"car_id": check.car_id,
|
|
"check_number": check.check_number,
|
|
"status": "failed",
|
|
"pdf_path": None
|
|
})
|
|
except Exception as e:
|
|
results.append({
|
|
"car_id": check.car_id,
|
|
"check_number": check.check_number,
|
|
"status": "error",
|
|
"error": str(e)
|
|
})
|
|
|
|
success_count = sum(1 for r in results if r["status"] == "success")
|
|
return {
|
|
"total": len(results),
|
|
"success": success_count,
|
|
"failed": len(results) - success_count,
|
|
"results": results
|
|
}
|
|
|
|
|
|
@router.post("/pdf-status")
|
|
async def get_pdf_status_batch(
|
|
car_ids: List[int],
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""여러 차량의 PDF 상태를 한번에 조회"""
|
|
if not car_ids:
|
|
return {}
|
|
|
|
checks = db.query(CarPerformanceCheck).filter(
|
|
CarPerformanceCheck.car_id.in_(car_ids)
|
|
).all()
|
|
|
|
# Create a map of car_id -> has_pdf
|
|
result = {}
|
|
for check in checks:
|
|
result[check.car_id] = bool(check.pdf_path)
|
|
|
|
# Fill in missing car_ids with False
|
|
for car_id in car_ids:
|
|
if car_id not in result:
|
|
result[car_id] = False
|
|
|
|
return result
|
|
|
|
|
|
@router.get("/specifications/{car_number}")
|
|
async def get_car_specifications(
|
|
car_number: str,
|
|
current_user = Depends(get_current_admin_user),
|
|
):
|
|
"""차량번호로 상세사양 조회 (관리자 전용)
|
|
|
|
AUTOBEGINS 서비스를 통해 차량의 상세사양을 조회합니다.
|
|
- 제조사, 모델명, 년형, 연료, 배기량
|
|
- 출고가, 기본가, 옵션가
|
|
- 기본 옵션, 선택 옵션
|
|
"""
|
|
try:
|
|
spec_data = await get_specifications_from_carmodoo(car_number)
|
|
if spec_data:
|
|
return {
|
|
"found": True,
|
|
"car_number": car_number,
|
|
"specifications": spec_to_dict(spec_data),
|
|
"raw": {
|
|
"manufacturer": spec_data.manufacturer,
|
|
"model_name": spec_data.model_name,
|
|
"model_year": spec_data.model_year,
|
|
"fuel_type": spec_data.fuel_type,
|
|
"displacement": spec_data.displacement,
|
|
"transmission": spec_data.transmission,
|
|
"body_type": spec_data.body_type,
|
|
"color": spec_data.color,
|
|
"mileage": spec_data.mileage,
|
|
"vin": spec_data.vin,
|
|
"release_price": spec_data.release_price,
|
|
"base_price": spec_data.base_price,
|
|
}
|
|
}
|
|
else:
|
|
return {
|
|
"found": False,
|
|
"car_number": car_number,
|
|
"message": "No specifications found for this car number"
|
|
}
|
|
except Exception as e:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Failed to fetch specifications: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.get("/car/{car_id}/specifications")
|
|
async def get_car_specifications_by_id(
|
|
car_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user = Depends(get_current_user),
|
|
):
|
|
"""저장된 차량의 상세사양 조회"""
|
|
car = db.query(Car).filter(Car.id == car_id).first()
|
|
if not car:
|
|
raise HTTPException(status_code=404, detail="Car not found")
|
|
|
|
# Check if specifications exist in database
|
|
spec = db.query(CarSpecification).filter(CarSpecification.car_id == car_id).first()
|
|
|
|
if spec:
|
|
return {
|
|
"found": True,
|
|
"car_id": car_id,
|
|
"from_db": True,
|
|
"specifications": {
|
|
"manufacturer": spec.manufacturer,
|
|
"model_name": spec.model_name,
|
|
"grade": spec.grade,
|
|
"model_year": spec.model_year,
|
|
"fuel_type": spec.fuel_type,
|
|
"displacement": spec.displacement,
|
|
"transmission": spec.transmission,
|
|
"drive_type": spec.drive_type,
|
|
"body_type": spec.body_type,
|
|
"max_power": spec.max_power,
|
|
"max_torque": spec.max_torque,
|
|
"fuel_efficiency": spec.fuel_efficiency,
|
|
"seating_capacity": spec.seating_capacity,
|
|
"comfort_options": spec.comfort_options,
|
|
"interior_options": spec.interior_options,
|
|
"raw_data": spec.raw_data,
|
|
}
|
|
}
|
|
|
|
# If not in DB but car has car_number, offer to fetch (admin only)
|
|
if current_user.is_admin and car.car_number:
|
|
return {
|
|
"found": False,
|
|
"car_id": car_id,
|
|
"car_number": car.car_number,
|
|
"message": "Specifications not saved. Use /specifications/{car_number} to fetch."
|
|
}
|
|
|
|
return {
|
|
"found": False,
|
|
"car_id": car_id,
|
|
"message": "No specifications available for this car"
|
|
}
|
|
|
|
|
|
# ==================== Sensitive Info Detection ====================
|
|
|
|
class SensitiveInfoRequest(BaseModel):
|
|
text: str
|
|
|
|
|
|
class SensitiveInfoResponse(BaseModel):
|
|
has_sensitive: bool
|
|
summary: dict
|
|
highlighted_html: str
|
|
masked_text: str
|
|
|
|
|
|
@router.post("/check-sensitive-info", response_model=SensitiveInfoResponse)
|
|
async def check_sensitive_info(
|
|
request: SensitiveInfoRequest,
|
|
current_user = Depends(get_current_admin_user),
|
|
):
|
|
"""
|
|
Check text for sensitive information (phone numbers, addresses, etc.)
|
|
Returns highlighted HTML for preview and masked text for storage.
|
|
Admin only.
|
|
"""
|
|
text = request.text or ""
|
|
|
|
return SensitiveInfoResponse(
|
|
has_sensitive=has_sensitive_info(text),
|
|
summary=get_sensitivity_summary(text),
|
|
highlighted_html=highlight_sensitive_info(text),
|
|
masked_text=mask_sensitive_info(text)
|
|
)
|
|
|
|
|
|
@router.post("/preview-dealer-description/{car_no}")
|
|
async def preview_dealer_description(
|
|
car_no: str,
|
|
car_key: Optional[str] = None,
|
|
current_user = Depends(get_current_admin_user),
|
|
):
|
|
"""
|
|
Fetch dealer description from Carmodoo and return with sensitivity analysis.
|
|
Admin only.
|
|
"""
|
|
car_detail = await carmodoo_client.get_car_detail(car_no, car_key or "")
|
|
|
|
if not car_detail.get("found") or not car_detail.get("dealer_description"):
|
|
return {
|
|
"found": False,
|
|
"message": "No dealer description found"
|
|
}
|
|
|
|
original = car_detail.get("dealer_description", "")
|
|
|
|
return {
|
|
"found": True,
|
|
"original": original,
|
|
"has_sensitive": has_sensitive_info(original),
|
|
"summary": get_sensitivity_summary(original),
|
|
"highlighted_html": highlight_sensitive_info(original),
|
|
"masked_text": mask_sensitive_info(original)
|
|
}
|
|
|
|
|
|
# ===== 딜러 설명 번역 관리 API =====
|
|
|
|
class TranslationUpdateRequest(BaseModel):
|
|
"""번역 수정 요청"""
|
|
dealer_description: Optional[str] = None # 한국어 원문
|
|
dealer_description_en: Optional[str] = None
|
|
dealer_description_mn: Optional[str] = None
|
|
dealer_description_ru: Optional[str] = None
|
|
|
|
|
|
@router.get("/car/{car_id}/translations")
|
|
async def get_car_translations(
|
|
car_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user = Depends(get_current_admin_user),
|
|
):
|
|
"""차량의 딜러 설명 번역 정보 조회 (관리자용)"""
|
|
car = db.query(Car).filter(Car.id == car_id).first()
|
|
if not car:
|
|
raise HTTPException(status_code=404, detail="Car not found")
|
|
|
|
translation_service = get_translation_service()
|
|
|
|
return {
|
|
"car_id": car_id,
|
|
"car_name": car.car_name,
|
|
"dealer_description": car.dealer_description,
|
|
"translations": {
|
|
"en": car.dealer_description_en,
|
|
"mn": car.dealer_description_mn,
|
|
"ru": car.dealer_description_ru
|
|
},
|
|
"has_translations": bool(
|
|
car.dealer_description_en or
|
|
car.dealer_description_mn or
|
|
car.dealer_description_ru
|
|
),
|
|
"papago_configured": translation_service.is_configured
|
|
}
|
|
|
|
|
|
@router.put("/car/{car_id}/translations")
|
|
async def update_car_translations(
|
|
car_id: int,
|
|
request: TranslationUpdateRequest,
|
|
db: Session = Depends(get_db),
|
|
current_user = Depends(get_current_admin_user),
|
|
):
|
|
"""차량의 딜러 설명 번역 수정 (관리자용)"""
|
|
car = db.query(Car).filter(Car.id == car_id).first()
|
|
if not car:
|
|
raise HTTPException(status_code=404, detail="Car not found")
|
|
|
|
# 한국어 원문 업데이트
|
|
if request.dealer_description is not None:
|
|
car.dealer_description = request.dealer_description
|
|
|
|
# 번역 업데이트
|
|
if request.dealer_description_en is not None:
|
|
car.dealer_description_en = request.dealer_description_en
|
|
if request.dealer_description_mn is not None:
|
|
car.dealer_description_mn = request.dealer_description_mn
|
|
if request.dealer_description_ru is not None:
|
|
car.dealer_description_ru = request.dealer_description_ru
|
|
|
|
db.commit()
|
|
|
|
return {
|
|
"message": "Translations updated successfully",
|
|
"car_id": car_id,
|
|
"dealer_description": car.dealer_description,
|
|
"translations": {
|
|
"en": car.dealer_description_en,
|
|
"mn": car.dealer_description_mn,
|
|
"ru": car.dealer_description_ru
|
|
}
|
|
}
|
|
|
|
|
|
@router.post("/car/{car_id}/translations/regenerate")
|
|
async def regenerate_car_translations(
|
|
car_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user = Depends(get_current_admin_user),
|
|
):
|
|
"""차량의 딜러 설명 번역 재생성 (관리자용)"""
|
|
car = db.query(Car).filter(Car.id == car_id).first()
|
|
if not car:
|
|
raise HTTPException(status_code=404, detail="Car not found")
|
|
|
|
if not car.dealer_description:
|
|
raise HTTPException(status_code=400, detail="No dealer description to translate")
|
|
|
|
translation_service = get_translation_service()
|
|
if not translation_service.is_configured:
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="Translation service not configured. Please set PAPAGO_CLIENT_ID and PAPAGO_CLIENT_SECRET."
|
|
)
|
|
|
|
try:
|
|
translations = await translate_dealer_description(car.dealer_description)
|
|
car.dealer_description_en = translations.get('en')
|
|
car.dealer_description_mn = translations.get('mn')
|
|
car.dealer_description_ru = translations.get('ru')
|
|
db.commit()
|
|
|
|
return {
|
|
"message": "Translations regenerated successfully",
|
|
"car_id": car_id,
|
|
"translations": {
|
|
"en": car.dealer_description_en,
|
|
"mn": car.dealer_description_mn,
|
|
"ru": car.dealer_description_ru
|
|
}
|
|
}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Translation failed: {str(e)}")
|
|
|
|
|
|
@router.get("/admin/untranslated-cars")
|
|
async def get_untranslated_cars(
|
|
db: Session = Depends(get_db),
|
|
current_user = Depends(get_current_admin_user),
|
|
limit: int = Query(default=50, le=100),
|
|
):
|
|
"""번역이 없는 차량 목록 조회 (관리자용)"""
|
|
cars = db.query(Car).filter(
|
|
Car.dealer_description.isnot(None),
|
|
Car.dealer_description != '',
|
|
(
|
|
(Car.dealer_description_en.is_(None)) |
|
|
(Car.dealer_description_mn.is_(None)) |
|
|
(Car.dealer_description_ru.is_(None))
|
|
)
|
|
).limit(limit).all()
|
|
|
|
return {
|
|
"count": len(cars),
|
|
"cars": [
|
|
{
|
|
"id": car.id,
|
|
"car_name": car.car_name,
|
|
"dealer_description": car.dealer_description[:100] + "..." if car.dealer_description and len(car.dealer_description) > 100 else car.dealer_description,
|
|
"has_en": bool(car.dealer_description_en),
|
|
"has_mn": bool(car.dealer_description_mn),
|
|
"has_ru": bool(car.dealer_description_ru)
|
|
}
|
|
for car in cars
|
|
]
|
|
}
|
|
|
|
|
|
@router.post("/admin/translate-all-pending")
|
|
async def translate_all_pending(
|
|
db: Session = Depends(get_db),
|
|
current_user = Depends(get_current_admin_user),
|
|
):
|
|
"""번역이 없는 모든 차량에 대해 번역 수행 (관리자용)"""
|
|
translation_service = get_translation_service()
|
|
if not translation_service.is_configured:
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="Translation service not configured. Please set PAPAGO_CLIENT_ID and PAPAGO_CLIENT_SECRET."
|
|
)
|
|
|
|
cars = db.query(Car).filter(
|
|
Car.dealer_description.isnot(None),
|
|
Car.dealer_description != '',
|
|
(
|
|
(Car.dealer_description_en.is_(None)) &
|
|
(Car.dealer_description_mn.is_(None)) &
|
|
(Car.dealer_description_ru.is_(None))
|
|
)
|
|
).all()
|
|
|
|
results = []
|
|
for car in cars:
|
|
try:
|
|
translations = await translate_dealer_description(car.dealer_description)
|
|
car.dealer_description_en = translations.get('en')
|
|
car.dealer_description_mn = translations.get('mn')
|
|
car.dealer_description_ru = translations.get('ru')
|
|
results.append({
|
|
"car_id": car.id,
|
|
"status": "success"
|
|
})
|
|
except Exception as e:
|
|
results.append({
|
|
"car_id": car.id,
|
|
"status": "failed",
|
|
"error": str(e)
|
|
})
|
|
|
|
db.commit()
|
|
|
|
success_count = len([r for r in results if r["status"] == "success"])
|
|
return {
|
|
"message": f"Translated {success_count}/{len(results)} cars",
|
|
"total": len(results),
|
|
"success": success_count,
|
|
"failed": len(results) - success_count,
|
|
"results": results
|
|
}
|