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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
121
backend/app/services/soldout_service.py
Normal file
121
backend/app/services/soldout_service.py
Normal 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
|
||||
Reference in New Issue
Block a user