diff --git a/agent/src/sync_agent.py b/agent/src/sync_agent.py index 318ad1f..0caee7d 100644 --- a/agent/src/sync_agent.py +++ b/agent/src/sync_agent.py @@ -1,11 +1,13 @@ """ Carmodoo Sync Agent - Syncs makers/models from Carmodoo to AutonetSellCar Backend +Also performs nightly soldout checks. """ import asyncio import os import logging import httpx +from datetime import datetime, time from dotenv import load_dotenv from .carmodoo_client import CarmodooClient, CarmodooConfig @@ -149,10 +151,139 @@ class SyncAgent: finally: await self.stop() + async def check_soldout(self): + """Check all cars for soldout status""" + logger.info("Starting soldout check...") + + if not await self.start(): + return + + try: + # Get all active cars from backend + response = await self.http_client.get( + f"{self.api_url}/cars", + params={"admin": True, "page_size": 1000, "status": "active"} + ) + + if response.status_code != 200: + logger.error(f"Failed to get cars: {response.status_code}") + return + + data = response.json() + cars = data.get("cars", []) + logger.info(f"Checking {len(cars)} cars for soldout status...") + + soldout_count = 0 + checked = 0 + + for car in cars: + if car.get("soldout"): + continue # Already soldout + + source_id = car.get("source_id") + car_id = car.get("id") + + if not source_id: + continue + + # Check if car exists on Carmodoo + is_available = await self._check_car_on_carmodoo(source_id) + checked += 1 + + if not is_available: + # Mark as soldout via API + try: + resp = await self.http_client.post( + f"{self.api_url}/cars/{car_id}/soldout" + ) + if resp.status_code == 200: + soldout_count += 1 + logger.info(f"Car {car_id} ({car.get('car_name')}) marked as SOLD OUT") + except Exception as e: + logger.error(f"Failed to mark car {car_id} as soldout: {e}") + + # Rate limiting + if checked % 10 == 0: + logger.info(f"Progress: {checked}/{len(cars)} checked, {soldout_count} sold out") + await asyncio.sleep(1) + + logger.info(f"Soldout check completed: {checked} checked, {soldout_count} sold out") + + except Exception as e: + logger.error(f"Soldout check error: {e}") + finally: + await self.stop() + + async def _check_car_on_carmodoo(self, source_id: str) -> bool: + """Check if car exists on Carmodoo""" + try: + # Try to get car info from Carmodoo + url = f"https://dealer.carmodoo.com/car/carPopView.html" + params = {"carNo": source_id} + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url, params=params) + + if response.status_code == 404: + return False + + content = response.text.lower() + sold_keywords = ["판매완료", "판매 완료", "삭제된", "없는 차량", "존재하지 않"] + + 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}") + return True # Assume available on error + + async def run_scheduled(self): + """Run agent with scheduled tasks""" + logger.info("Starting scheduled agent...") + + # Run initial sync + await self.run_sync() + + # Schedule nightly soldout check at 3:00 AM + while True: + now = datetime.now() + target_time = time(3, 0) # 3:00 AM + + # Calculate seconds until next 3:00 AM + target_datetime = datetime.combine(now.date(), target_time) + if now.time() >= target_time: + # Already past 3 AM today, schedule for tomorrow + from datetime import timedelta + target_datetime += timedelta(days=1) + + seconds_until = (target_datetime - now).total_seconds() + logger.info(f"Next soldout check in {seconds_until / 3600:.1f} hours at {target_datetime}") + + await asyncio.sleep(seconds_until) + + # Run soldout check + logger.info("Running scheduled soldout check...") + await self.check_soldout() + async def main(): agent = SyncAgent() - await agent.run_sync() + + # Check command line args + import sys + if len(sys.argv) > 1: + if sys.argv[1] == "sync": + await agent.run_sync() + elif sys.argv[1] == "soldout": + await agent.check_soldout() + elif sys.argv[1] == "scheduled": + await agent.run_scheduled() + else: + # Default: run scheduled + await agent.run_scheduled() if __name__ == '__main__': diff --git a/backend/app/api/cars.py b/backend/app/api/cars.py index 36d75ac..8570fd7 100644 --- a/backend/app/api/cars.py +++ b/backend/app/api/cars.py @@ -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 + } diff --git a/backend/app/api/hero_banners.py b/backend/app/api/hero_banners.py index 604b5e7..af75fc8 100644 --- a/backend/app/api/hero_banners.py +++ b/backend/app/api/hero_banners.py @@ -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) + } diff --git a/backend/app/models/car.py b/backend/app/models/car.py index f583b8d..ca48c1b 100644 --- a/backend/app/models/car.py +++ b/backend/app/models/car.py @@ -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)) diff --git a/backend/app/schemas/car.py b/backend/app/schemas/car.py index 9aa8d22..be67abb 100644 --- a/backend/app/schemas/car.py +++ b/backend/app/schemas/car.py @@ -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 diff --git a/backend/app/services/soldout_service.py b/backend/app/services/soldout_service.py new file mode 100644 index 0000000..8346f4e --- /dev/null +++ b/backend/app/services/soldout_service.py @@ -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 diff --git a/frontend/src/app/admin/cars/page.tsx b/frontend/src/app/admin/cars/page.tsx index 58c1b47..2ed1591 100644 --- a/frontend/src/app/admin/cars/page.tsx +++ b/frontend/src/app/admin/cars/page.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from 'react'; import { useSearchParams, useRouter } from 'next/navigation'; import Image from 'next/image'; -import api, { heroBannersApi, carmodooApi, vehicleRequestsApi } from '@/lib/api'; +import api, { heroBannersApi, carmodooApi, vehicleRequestsApi, carsApi } from '@/lib/api'; import { translateCarName } from '@/lib/i18n'; interface CarmodooMaker { @@ -67,6 +67,8 @@ interface LocalCar { final_price_krw?: number; final_price_mn?: number; is_displayed?: boolean; + is_banner?: boolean; + soldout?: boolean; fuel?: string; transmission?: string; color?: string; @@ -133,6 +135,8 @@ export default function CarsAdminPage() { const [currentImageIndex, setCurrentImageIndex] = useState(0); const [selectedLocalCars, setSelectedLocalCars] = useState>(new Set()); const [registeringLocalBanner, setRegisteringLocalBanner] = useState(false); + const [togglingBanner, setTogglingBanner] = useState(null); + const [bannerCarIds, setBannerCarIds] = useState([]); // 배너 순서대로 정렬된 차량 ID // All Cars (public view) state const [allCars, setAllCars] = useState([]); @@ -238,6 +242,7 @@ export default function CarsAdminPage() { loadLocalCars(); loadAllCars(); loadInitialData(); + loadBannerCars(); }, []); // 제조사 변경 시 모델 목록 로드 @@ -290,11 +295,67 @@ export default function CarsAdminPage() { } }, [requestId]); + // 배너 차량 목록 로드 + const loadBannerCars = async () => { + try { + const result = await heroBannersApi.adminGetBannerCars(); + setBannerCarIds(result.car_ids); + } catch (err) { + console.error('Failed to load banner cars:', err); + } + }; + + // 배너 토글 핸들러 + const handleToggleBanner = async (carId: number, e: React.MouseEvent) => { + e.stopPropagation(); + setTogglingBanner(carId); + try { + const result = await heroBannersApi.adminToggleBanner(carId); + // 로컬 상태 업데이트 + setLocalCars(prev => prev.map(car => + car.id === carId ? { ...car, is_banner: result.is_banner } : car + )); + // 배너 목록 갱신 + await loadBannerCars(); + } catch (err) { + console.error('Failed to toggle banner:', err); + alert('배너 상태 변경에 실패했습니다.'); + } finally { + setTogglingBanner(null); + } + }; + + // Soldout 토글 핸들러 + const handleToggleSoldout = async (carId: number, currentSoldout: boolean, e: React.MouseEvent) => { + e.stopPropagation(); + try { + if (currentSoldout) { + await carsApi.markAvailable(carId); + } else { + await carsApi.markSoldout(carId); + } + // 로컬 상태 업데이트 + setLocalCars(prev => prev.map(car => + car.id === carId ? { ...car, soldout: !currentSoldout } : car + )); + } catch (err) { + console.error('Failed to toggle soldout:', err); + alert('상태 변경에 실패했습니다.'); + } + }; + const loadLocalCars = async (page = 1) => { setLocalLoading(true); try { - const { data } = await api.get('/cars', { params: { page, page_size: 20, admin: true } }); - setLocalCars(data.cars || []); + const { data } = await api.get('/cars', { params: { page, page_size: 100, admin: true } }); + + // 배너 차량을 맨 위로 정렬 + const cars = data.cars || []; + const bannerCars = cars.filter((c: LocalCar) => c.is_banner); + const nonBannerCars = cars.filter((c: LocalCar) => !c.is_banner); + const sortedCars = [...bannerCars, ...nonBannerCars]; + + setLocalCars(sortedCars); setLocalTotal(data.total || 0); setLocalPage(page); @@ -1150,12 +1211,7 @@ export default function CarsAdminPage() { - 0} - onChange={handleSelectAllLocalCars} - className="w-4 h-4 text-purple-600 rounded border-gray-300 focus:ring-purple-500" - /> + Banner Display Image @@ -1167,36 +1223,48 @@ export default function CarsAdminPage() { Final Price Fuel PDF + Status Actions {localCars.map((car) => { const mainImage = getImageUrl(car.images?.find((img) => img.is_main)?.url || car.images?.[0]?.url); + const isSoldout = car.soldout || false; + const isBanner = car.is_banner || false; return ( handleCarClick(car)} > + {/* Banner 체크박스 */} e.stopPropagation()}> - { - setSelectedLocalCars(prev => { - const newSet = new Set(prev); - if (newSet.has(car.id)) { - newSet.delete(car.id); - } else { - newSet.add(car.id); - } - return newSet; - }); - }} - className="w-4 h-4 text-purple-600 rounded border-gray-300 focus:ring-purple-500" - /> + + {/* Display 토글 */} )} + {/* Status (Soldout) */} + e.stopPropagation()}> + {isSoldout ? ( + + ) : ( + + )} +