feat: Add banner toggle and soldout tracking to Cars page

- Add is_banner, soldout fields to Car model
- Add banner toggle API (POST /hero-banners/admin/toggle/{car_id})
- Add soldout APIs (POST/DELETE /cars/{car_id}/soldout)
- Add nightly soldout checker in agent (runs at 3:00 AM)
- Update Local Cars UI with banner checkbox and status column
- Remove hero-banners admin page (functionality moved to Cars page)
- Banner cars sorted to top with purple background
- Soldout cars displayed with gray overlay

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
AutonetSellCar Deploy
2025-12-31 12:50:40 +09:00
parent 9969554deb
commit c9fd7611a7
10 changed files with 579 additions and 40 deletions

View File

@@ -1,8 +1,9 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks
from sqlalchemy.orm import Session, joinedload
from typing import Optional, List
from ..database import get_db
from ..models import Car, CarMaker, CarModel, CarImage, CarOption
from ..services.soldout_service import SoldoutChecker
from ..schemas import (
CarCreate, CarUpdate, CarResponse, CarListResponse,
CarMakerCreate, CarMakerResponse,
@@ -29,6 +30,8 @@ def car_to_response(car: Car) -> dict:
"final_price_mn": (car.price_krw or 0) + (car.margin_mn or 0),
"price_usd": car.price_usd,
"is_displayed": car.is_displayed or False,
"is_banner": car.is_banner or False,
"soldout": car.soldout or False,
"fuel": car.fuel,
"transmission": car.transmission,
"color": car.color,
@@ -338,3 +341,78 @@ def create_model(model_data: CarModelCreate, db: Session = Depends(get_db)):
db.commit()
db.refresh(model)
return model
# ==================== Soldout APIs ====================
@router.post("/{car_id}/soldout")
def mark_car_soldout(car_id: int, db: Session = Depends(get_db)):
"""차량을 SOLD OUT 처리 (수동)"""
car = db.query(Car).filter(Car.id == car_id).first()
if not car:
raise HTTPException(status_code=404, detail="Car not found")
car.soldout = True
db.commit()
return {"car_id": car_id, "soldout": True, "message": "Car marked as sold out"}
@router.delete("/{car_id}/soldout")
def mark_car_available(car_id: int, db: Session = Depends(get_db)):
"""차량을 다시 판매 가능 상태로 변경 (수동)"""
car = db.query(Car).filter(Car.id == car_id).first()
if not car:
raise HTTPException(status_code=404, detail="Car not found")
car.soldout = False
db.commit()
return {"car_id": car_id, "soldout": False, "message": "Car marked as available"}
async def run_soldout_check(db: Session):
"""백그라운드에서 soldout 체크 실행"""
checker = SoldoutChecker(db)
await checker.check_all_cars()
@router.post("/admin/check-soldout")
async def trigger_soldout_check(
background_tasks: BackgroundTasks,
db: Session = Depends(get_db)
):
"""모든 차량의 SOLD OUT 상태 확인 (Admin, 백그라운드 실행)
Carmodoo에서 더 이상 존재하지 않는 차량을 soldout=True로 표시합니다.
이 작업은 백그라운드에서 실행되며, 시간이 오래 걸릴 수 있습니다.
"""
# 현재 active이고 soldout=False인 차량 수 확인
pending_count = db.query(Car).filter(
Car.status == "active",
Car.soldout == False,
Car.source == "carmodoo"
).count()
# 백그라운드로 실행 (주의: db session 문제가 있을 수 있음)
# 실제로는 agent에서 스케줄로 실행하는 것이 좋음
# background_tasks.add_task(run_soldout_check, db)
return {
"message": "Soldout check will be performed by nightly agent job",
"pending_cars": pending_count,
"note": "Use carmodoo-agent for scheduled soldout checks"
}
@router.get("/admin/soldout-stats")
def get_soldout_stats(db: Session = Depends(get_db)):
"""SOLD OUT 통계 조회 (Admin)"""
total = db.query(Car).filter(Car.status == "active").count()
soldout = db.query(Car).filter(Car.status == "active", Car.soldout == True).count()
available = db.query(Car).filter(Car.status == "active", Car.soldout == False).count()
return {
"total_active": total,
"soldout": soldout,
"available": available,
"soldout_percentage": round(soldout / total * 100, 1) if total > 0 else 0
}

View File

@@ -7,6 +7,7 @@ import aiofiles
from ..database import get_db
from ..models.hero_banner import HeroBanner, HeroBannerSettings
from ..models.car import Car
from ..schemas.hero_banner import (
HeroBannerCreate, HeroBannerUpdate, HeroBannerResponse,
HeroBannerListResponse, HeroBannerLocalizedResponse,
@@ -264,3 +265,97 @@ async def upload_banner_image(
"image_url": image_url,
"filename": filename,
}
# ==================== Banner Toggle & Ordering ====================
@router.post("/admin/toggle/{car_id}")
def toggle_banner(
car_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_admin_user)
):
"""차량의 배너 상태 토글 (Admin)
- is_banner=False → True: HeroBanner 생성
- is_banner=True → False: HeroBanner 삭제
"""
car = db.query(Car).filter(Car.id == car_id).first()
if not car:
raise HTTPException(status_code=404, detail="Car not found")
if car.is_banner:
# 배너에서 제거
banner = db.query(HeroBanner).filter(HeroBanner.car_id == car_id).first()
if banner:
db.delete(banner)
car.is_banner = False
db.commit()
return {"car_id": car_id, "is_banner": False, "message": "Removed from banner"}
else:
# 배너에 추가
# 현재 최대 display_order 찾기
max_order = db.query(HeroBanner).count()
# 차량 이미지 URL
image_url = f"/uploads/cars/{car_id}/image_0.jpg"
# 배너 생성
banner = HeroBanner(
title_ko=car.car_name or "",
title_en=car.car_name or "", # 프론트엔드에서 번역
title_mn=car.car_name or "",
title_ru=car.car_name or "",
subtitle_ko=f"{car.year or ''}년식 | {car.mileage:,}km" if car.mileage else f"{car.year or ''}년식",
subtitle_en=f"{car.year or ''} | {car.mileage:,}km" if car.mileage else f"{car.year or ''}",
subtitle_mn=f"{car.year or ''} | {car.mileage:,}km" if car.mileage else f"{car.year or ''}",
subtitle_ru=f"{car.year or ''} | {car.mileage:,}km" if car.mileage else f"{car.year or ''}",
image_url=image_url,
link_url=f"/cars/{car_id}",
car_id=car_id,
display_order=max_order,
is_active=True,
)
db.add(banner)
car.is_banner = True
db.commit()
db.refresh(banner)
return {"car_id": car_id, "is_banner": True, "banner_id": banner.id, "message": "Added to banner"}
@router.put("/admin/reorder")
def reorder_banners(
car_ids: List[int],
db: Session = Depends(get_db),
current_user: User = Depends(get_admin_user)
):
"""배너 순서 재정렬 (Admin)
car_ids: 배너 차량 ID 목록 (원하는 순서대로)
"""
for order, car_id in enumerate(car_ids):
banner = db.query(HeroBanner).filter(HeroBanner.car_id == car_id).first()
if banner:
banner.display_order = order
db.commit()
return {"message": "Banner order updated", "count": len(car_ids)}
@router.get("/admin/banner-cars")
def get_banner_cars(
db: Session = Depends(get_db),
current_user: User = Depends(get_admin_user)
):
"""배너 등록된 차량 목록 조회 (Admin)
display_order 순으로 정렬된 차량 ID 목록 반환
"""
banners = db.query(HeroBanner).filter(
HeroBanner.car_id.isnot(None)
).order_by(HeroBanner.display_order.asc()).all()
return {
"car_ids": [b.car_id for b in banners],
"count": len(banners)
}

View File

@@ -50,6 +50,8 @@ class Car(Base):
margin_mn = Column(BigInteger, default=0) # Mongolian margin amount in KRW
price_usd = Column(DECIMAL(12, 2))
is_displayed = Column(Boolean, default=False, index=True) # Show to users
is_banner = Column(Boolean, default=False, index=True) # Registered as hero banner
soldout = Column(Boolean, default=False, index=True) # Sold to another buyer (checked nightly vs Carmodoo)
fuel = Column(String(20))
transmission = Column(String(20))

View File

@@ -135,6 +135,8 @@ class CarUpdate(BaseModel):
color: Optional[str] = None
status: Optional[str] = None
is_displayed: Optional[bool] = None
is_banner: Optional[bool] = None
soldout: Optional[bool] = None
class CarResponse(BaseModel):
@@ -152,6 +154,8 @@ class CarResponse(BaseModel):
final_price_mn: Optional[int] = None # Computed: price_krw + margin_mn (for Mongolian users)
price_usd: Optional[Decimal] = None
is_displayed: bool = False
is_banner: bool = False
soldout: bool = False
fuel: Optional[str] = None
transmission: Optional[str] = None
color: Optional[str] = None

View File

@@ -0,0 +1,121 @@
"""
Soldout Check Service - Checks if cars are still available on Carmodoo
"""
import logging
from datetime import datetime
from typing import List, Tuple
from sqlalchemy.orm import Session
import httpx
from ..models.car import Car
logger = logging.getLogger(__name__)
class SoldoutChecker:
"""Carmodoo에서 차량 판매 여부를 확인하는 서비스"""
def __init__(self, db: Session, carmodoo_session_cookie: str = None):
self.db = db
self.carmodoo_session_cookie = carmodoo_session_cookie
self.carmodoo_base_url = "https://dealer.carmodoo.com"
async def check_car_availability(self, source_id: str) -> bool:
"""
Carmodoo에서 차량이 아직 판매 중인지 확인
Returns:
True if car is still available, False if sold/removed
"""
try:
async with httpx.AsyncClient(timeout=30.0) as client:
# Carmodoo 차량 상세 페이지 접근
url = f"{self.carmodoo_base_url}/car/carPopView.html"
params = {"carNo": source_id}
headers = {}
if self.carmodoo_session_cookie:
headers["Cookie"] = self.carmodoo_session_cookie
response = await client.get(url, params=params, headers=headers)
if response.status_code == 404:
return False
# 페이지 내용에서 "판매완료", "삭제", "없는 차량" 등 확인
content = response.text.lower()
sold_keywords = [
"판매완료", "판매 완료", "sold", "삭제된", "없는 차량",
"존재하지 않", "찾을 수 없", "not found"
]
for keyword in sold_keywords:
if keyword in content:
return False
return True
except Exception as e:
logger.error(f"Error checking car {source_id}: {e}")
# 에러 발생 시 available로 간주 (안전하게)
return True
async def check_all_cars(self) -> Tuple[int, int, List[int]]:
"""
모든 활성 차량의 판매 여부 확인
Returns:
(checked_count, soldout_count, soldout_car_ids)
"""
# soldout=False인 활성 차량만 조회
cars = self.db.query(Car).filter(
Car.status == "active",
Car.soldout == False,
Car.source == "carmodoo"
).all()
logger.info(f"Checking {len(cars)} cars for soldout status...")
checked = 0
soldout_count = 0
soldout_ids = []
for car in cars:
is_available = await self.check_car_availability(car.source_id)
checked += 1
if not is_available:
car.soldout = True
soldout_count += 1
soldout_ids.append(car.id)
logger.info(f"Car {car.id} ({car.car_name}) marked as SOLD OUT")
# Rate limiting
if checked % 10 == 0:
logger.info(f"Progress: {checked}/{len(cars)} checked, {soldout_count} sold out")
import asyncio
await asyncio.sleep(1)
self.db.commit()
logger.info(f"Soldout check completed: {checked} checked, {soldout_count} sold out")
return checked, soldout_count, soldout_ids
def mark_soldout(self, car_id: int) -> bool:
"""수동으로 차량을 soldout 처리"""
car = self.db.query(Car).filter(Car.id == car_id).first()
if car:
car.soldout = True
self.db.commit()
return True
return False
def mark_available(self, car_id: int) -> bool:
"""수동으로 차량을 available 처리"""
car = self.db.query(Car).filter(Car.id == car_id).first()
if car:
car.soldout = False
self.db.commit()
return True
return False