Files
AutonetSellCar/backend/app/api/carmodoo.py
AutonetSellCar Deploy bea89d0580 fix: Carmodoo search encoding issue - Korean car names garbled
lxml was re-encoding already decoded UTF-8 HTML based on charset="euc-kr"
meta tag. Fixed by removing charset meta tags and explicitly setting
UTF-8 encoding in HTMLParser.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 22:11:06 +09:00

2714 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:
# HTML 내부의 charset 선언 제거 (이미 UTF-8로 디코딩됨)
html = re.sub(r'<meta[^>]*charset[^>]*>', '', html, flags=re.IGNORECASE)
html = re.sub(r'charset\s*=\s*["\']?euc-kr["\']?', 'charset="utf-8"', html, flags=re.IGNORECASE)
# lxml에 UTF-8 인코딩임을 명시
from lxml.html import HTMLParser
parser = HTMLParser(encoding='utf-8')
tree = lxml_html.document_fromstring(html.encode('utf-8'), parser=parser)
# 각 차량 행 찾기 (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, purchased car view, or recommended vehicle)
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()
# Check 3: Car was recommended to user via vehicle request
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 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
}