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,11 +1,13 @@
|
|||||||
"""
|
"""
|
||||||
Carmodoo Sync Agent - Syncs makers/models from Carmodoo to AutonetSellCar Backend
|
Carmodoo Sync Agent - Syncs makers/models from Carmodoo to AutonetSellCar Backend
|
||||||
|
Also performs nightly soldout checks.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
import httpx
|
import httpx
|
||||||
|
from datetime import datetime, time
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from .carmodoo_client import CarmodooClient, CarmodooConfig
|
from .carmodoo_client import CarmodooClient, CarmodooConfig
|
||||||
|
|
||||||
@@ -149,10 +151,139 @@ class SyncAgent:
|
|||||||
finally:
|
finally:
|
||||||
await self.stop()
|
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():
|
async def main():
|
||||||
agent = SyncAgent()
|
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__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@@ -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 sqlalchemy.orm import Session, joinedload
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..models import Car, CarMaker, CarModel, CarImage, CarOption
|
from ..models import Car, CarMaker, CarModel, CarImage, CarOption
|
||||||
|
from ..services.soldout_service import SoldoutChecker
|
||||||
from ..schemas import (
|
from ..schemas import (
|
||||||
CarCreate, CarUpdate, CarResponse, CarListResponse,
|
CarCreate, CarUpdate, CarResponse, CarListResponse,
|
||||||
CarMakerCreate, CarMakerResponse,
|
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),
|
"final_price_mn": (car.price_krw or 0) + (car.margin_mn or 0),
|
||||||
"price_usd": car.price_usd,
|
"price_usd": car.price_usd,
|
||||||
"is_displayed": car.is_displayed or False,
|
"is_displayed": car.is_displayed or False,
|
||||||
|
"is_banner": car.is_banner or False,
|
||||||
|
"soldout": car.soldout or False,
|
||||||
"fuel": car.fuel,
|
"fuel": car.fuel,
|
||||||
"transmission": car.transmission,
|
"transmission": car.transmission,
|
||||||
"color": car.color,
|
"color": car.color,
|
||||||
@@ -338,3 +341,78 @@ def create_model(model_data: CarModelCreate, db: Session = Depends(get_db)):
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(model)
|
db.refresh(model)
|
||||||
return 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 ..database import get_db
|
||||||
from ..models.hero_banner import HeroBanner, HeroBannerSettings
|
from ..models.hero_banner import HeroBanner, HeroBannerSettings
|
||||||
|
from ..models.car import Car
|
||||||
from ..schemas.hero_banner import (
|
from ..schemas.hero_banner import (
|
||||||
HeroBannerCreate, HeroBannerUpdate, HeroBannerResponse,
|
HeroBannerCreate, HeroBannerUpdate, HeroBannerResponse,
|
||||||
HeroBannerListResponse, HeroBannerLocalizedResponse,
|
HeroBannerListResponse, HeroBannerLocalizedResponse,
|
||||||
@@ -264,3 +265,97 @@ async def upload_banner_image(
|
|||||||
"image_url": image_url,
|
"image_url": image_url,
|
||||||
"filename": filename,
|
"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
|
margin_mn = Column(BigInteger, default=0) # Mongolian margin amount in KRW
|
||||||
price_usd = Column(DECIMAL(12, 2))
|
price_usd = Column(DECIMAL(12, 2))
|
||||||
is_displayed = Column(Boolean, default=False, index=True) # Show to users
|
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))
|
fuel = Column(String(20))
|
||||||
transmission = Column(String(20))
|
transmission = Column(String(20))
|
||||||
|
|||||||
@@ -135,6 +135,8 @@ class CarUpdate(BaseModel):
|
|||||||
color: Optional[str] = None
|
color: Optional[str] = None
|
||||||
status: Optional[str] = None
|
status: Optional[str] = None
|
||||||
is_displayed: Optional[bool] = None
|
is_displayed: Optional[bool] = None
|
||||||
|
is_banner: Optional[bool] = None
|
||||||
|
soldout: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
class CarResponse(BaseModel):
|
class CarResponse(BaseModel):
|
||||||
@@ -152,6 +154,8 @@ class CarResponse(BaseModel):
|
|||||||
final_price_mn: Optional[int] = None # Computed: price_krw + margin_mn (for Mongolian users)
|
final_price_mn: Optional[int] = None # Computed: price_krw + margin_mn (for Mongolian users)
|
||||||
price_usd: Optional[Decimal] = None
|
price_usd: Optional[Decimal] = None
|
||||||
is_displayed: bool = False
|
is_displayed: bool = False
|
||||||
|
is_banner: bool = False
|
||||||
|
soldout: bool = False
|
||||||
fuel: Optional[str] = None
|
fuel: Optional[str] = None
|
||||||
transmission: Optional[str] = None
|
transmission: Optional[str] = None
|
||||||
color: 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
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useSearchParams, useRouter } from 'next/navigation';
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
import Image from 'next/image';
|
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';
|
import { translateCarName } from '@/lib/i18n';
|
||||||
|
|
||||||
interface CarmodooMaker {
|
interface CarmodooMaker {
|
||||||
@@ -67,6 +67,8 @@ interface LocalCar {
|
|||||||
final_price_krw?: number;
|
final_price_krw?: number;
|
||||||
final_price_mn?: number;
|
final_price_mn?: number;
|
||||||
is_displayed?: boolean;
|
is_displayed?: boolean;
|
||||||
|
is_banner?: boolean;
|
||||||
|
soldout?: boolean;
|
||||||
fuel?: string;
|
fuel?: string;
|
||||||
transmission?: string;
|
transmission?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
@@ -133,6 +135,8 @@ export default function CarsAdminPage() {
|
|||||||
const [currentImageIndex, setCurrentImageIndex] = useState(0);
|
const [currentImageIndex, setCurrentImageIndex] = useState(0);
|
||||||
const [selectedLocalCars, setSelectedLocalCars] = useState<Set<number>>(new Set());
|
const [selectedLocalCars, setSelectedLocalCars] = useState<Set<number>>(new Set());
|
||||||
const [registeringLocalBanner, setRegisteringLocalBanner] = useState(false);
|
const [registeringLocalBanner, setRegisteringLocalBanner] = useState(false);
|
||||||
|
const [togglingBanner, setTogglingBanner] = useState<number | null>(null);
|
||||||
|
const [bannerCarIds, setBannerCarIds] = useState<number[]>([]); // 배너 순서대로 정렬된 차량 ID
|
||||||
|
|
||||||
// All Cars (public view) state
|
// All Cars (public view) state
|
||||||
const [allCars, setAllCars] = useState<LocalCar[]>([]);
|
const [allCars, setAllCars] = useState<LocalCar[]>([]);
|
||||||
@@ -238,6 +242,7 @@ export default function CarsAdminPage() {
|
|||||||
loadLocalCars();
|
loadLocalCars();
|
||||||
loadAllCars();
|
loadAllCars();
|
||||||
loadInitialData();
|
loadInitialData();
|
||||||
|
loadBannerCars();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 제조사 변경 시 모델 목록 로드
|
// 제조사 변경 시 모델 목록 로드
|
||||||
@@ -290,11 +295,67 @@ export default function CarsAdminPage() {
|
|||||||
}
|
}
|
||||||
}, [requestId]);
|
}, [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) => {
|
const loadLocalCars = async (page = 1) => {
|
||||||
setLocalLoading(true);
|
setLocalLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get('/cars', { params: { page, page_size: 20, admin: true } });
|
const { data } = await api.get('/cars', { params: { page, page_size: 100, admin: true } });
|
||||||
setLocalCars(data.cars || []);
|
|
||||||
|
// 배너 차량을 맨 위로 정렬
|
||||||
|
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);
|
setLocalTotal(data.total || 0);
|
||||||
setLocalPage(page);
|
setLocalPage(page);
|
||||||
|
|
||||||
@@ -1150,12 +1211,7 @@ export default function CarsAdminPage() {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-gray-200">
|
<tr className="border-b border-gray-200">
|
||||||
<th className="py-3 px-2 text-center text-sm font-medium text-gray-600">
|
<th className="py-3 px-2 text-center text-sm font-medium text-gray-600">
|
||||||
<input
|
Banner
|
||||||
type="checkbox"
|
|
||||||
checked={selectedLocalCars.size === localCars.length && localCars.length > 0}
|
|
||||||
onChange={handleSelectAllLocalCars}
|
|
||||||
className="w-4 h-4 text-purple-600 rounded border-gray-300 focus:ring-purple-500"
|
|
||||||
/>
|
|
||||||
</th>
|
</th>
|
||||||
<th className="py-3 px-2 text-center text-sm font-medium text-gray-600">Display</th>
|
<th className="py-3 px-2 text-center text-sm font-medium text-gray-600">Display</th>
|
||||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Image</th>
|
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Image</th>
|
||||||
@@ -1167,36 +1223,48 @@ export default function CarsAdminPage() {
|
|||||||
<th className="py-3 px-4 text-right text-sm font-medium text-gray-600">Final Price</th>
|
<th className="py-3 px-4 text-right text-sm font-medium text-gray-600">Final Price</th>
|
||||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Fuel</th>
|
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Fuel</th>
|
||||||
<th className="py-3 px-2 text-center text-sm font-medium text-gray-600">PDF</th>
|
<th className="py-3 px-2 text-center text-sm font-medium text-gray-600">PDF</th>
|
||||||
|
<th className="py-3 px-2 text-center text-sm font-medium text-gray-600">Status</th>
|
||||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Actions</th>
|
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{localCars.map((car) => {
|
{localCars.map((car) => {
|
||||||
const mainImage = getImageUrl(car.images?.find((img) => img.is_main)?.url || car.images?.[0]?.url);
|
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 (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={car.id}
|
key={car.id}
|
||||||
className={`border-b border-gray-100 hover:bg-gray-50 cursor-pointer ${!car.is_displayed ? 'opacity-60' : ''}`}
|
className={`border-b border-gray-100 hover:bg-gray-50 cursor-pointer transition-all ${
|
||||||
|
isSoldout ? 'bg-gray-100 opacity-50' : ''
|
||||||
|
} ${isBanner ? 'bg-purple-50' : ''}`}
|
||||||
onClick={() => handleCarClick(car)}
|
onClick={() => handleCarClick(car)}
|
||||||
>
|
>
|
||||||
|
{/* Banner 체크박스 */}
|
||||||
<td className="py-3 px-2 text-center" onClick={(e) => e.stopPropagation()}>
|
<td className="py-3 px-2 text-center" onClick={(e) => e.stopPropagation()}>
|
||||||
<input
|
<button
|
||||||
type="checkbox"
|
onClick={(e) => handleToggleBanner(car.id, e)}
|
||||||
checked={selectedLocalCars.has(car.id)}
|
disabled={togglingBanner === car.id}
|
||||||
onChange={() => {
|
className={`w-6 h-6 rounded border-2 flex items-center justify-center transition-all ${
|
||||||
setSelectedLocalCars(prev => {
|
isBanner
|
||||||
const newSet = new Set(prev);
|
? 'bg-purple-600 border-purple-600 text-white'
|
||||||
if (newSet.has(car.id)) {
|
: 'border-gray-300 hover:border-purple-400'
|
||||||
newSet.delete(car.id);
|
} ${togglingBanner === car.id ? 'opacity-50' : ''}`}
|
||||||
} else {
|
title={isBanner ? 'Remove from banner' : 'Add to banner'}
|
||||||
newSet.add(car.id);
|
>
|
||||||
}
|
{togglingBanner === car.id ? (
|
||||||
return newSet;
|
<svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
});
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
}}
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
className="w-4 h-4 text-purple-600 rounded border-gray-300 focus:ring-purple-500"
|
</svg>
|
||||||
/>
|
) : isBanner ? (
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
|
{/* Display 토글 */}
|
||||||
<td className="py-3 px-2 text-center">
|
<td className="py-3 px-2 text-center">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => handleToggleDisplay(car, e)}
|
onClick={(e) => handleToggleDisplay(car, e)}
|
||||||
@@ -1276,6 +1344,26 @@ export default function CarsAdminPage() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
{/* Status (Soldout) */}
|
||||||
|
<td className="py-3 px-2 text-center" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{isSoldout ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleToggleSoldout(car.id, true, e)}
|
||||||
|
className="px-2 py-1 text-xs rounded-full bg-gray-600 text-white hover:bg-gray-700"
|
||||||
|
title="Click to mark as available"
|
||||||
|
>
|
||||||
|
SOLD
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleToggleSoldout(car.id, false, e)}
|
||||||
|
className="px-2 py-1 text-xs rounded-full bg-green-100 text-green-700 hover:bg-green-200"
|
||||||
|
title="Click to mark as sold out"
|
||||||
|
>
|
||||||
|
Available
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className="py-3 px-4">
|
<td className="py-3 px-4">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { useAuthStore } from '@/lib/store';
|
|||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ href: '/admin', label: 'Dashboard', icon: '📊' },
|
{ href: '/admin', label: 'Dashboard', icon: '📊' },
|
||||||
{ href: '/admin/visitor-stats', label: 'Visitor Stats', icon: '👁️' },
|
{ href: '/admin/visitor-stats', label: 'Visitor Stats', icon: '👁️' },
|
||||||
{ href: '/admin/hero-banners', label: 'Hero Banners', icon: '🖼️' },
|
|
||||||
{ href: '/admin/cars', label: 'Cars', icon: '🚗' },
|
{ href: '/admin/cars', label: 'Cars', icon: '🚗' },
|
||||||
{ href: '/admin/vehicle-requests', label: 'Vehicle Requests', icon: '📋' },
|
{ href: '/admin/vehicle-requests', label: 'Vehicle Requests', icon: '📋' },
|
||||||
{ href: '/admin/purchased', label: 'Purchased Vehicles', icon: '📦' },
|
{ href: '/admin/purchased', label: 'Purchased Vehicles', icon: '📦' },
|
||||||
|
|||||||
@@ -358,17 +358,6 @@ export default function AdminDashboard() {
|
|||||||
<div className="bg-white rounded-xl shadow-sm p-5">
|
<div className="bg-white rounded-xl shadow-sm p-5">
|
||||||
<h3 className="font-semibold text-gray-800 mb-4">Quick Actions</h3>
|
<h3 className="font-semibold text-gray-800 mb-4">Quick Actions</h3>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<Link
|
|
||||||
href="/admin/hero-banners"
|
|
||||||
className="flex items-center gap-3 p-4 border border-gray-200 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-colors"
|
|
||||||
>
|
|
||||||
<span className="text-2xl">🖼️</span>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-gray-800">Hero Banners</p>
|
|
||||||
<p className="text-xs text-gray-500">Manage slider</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/admin/cars"
|
href="/admin/cars"
|
||||||
className="flex items-center gap-3 p-4 border border-gray-200 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-colors"
|
className="flex items-center gap-3 p-4 border border-gray-200 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-colors"
|
||||||
|
|||||||
@@ -70,6 +70,22 @@ export const carsApi = {
|
|||||||
const { data } = await api.get('/cars/models/', { params });
|
const { data } = await api.get('/cars/models/', { params });
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Soldout APIs
|
||||||
|
markSoldout: async (carId: number): Promise<{ car_id: number; soldout: boolean; message: string }> => {
|
||||||
|
const { data } = await api.post(`/cars/${carId}/soldout`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
markAvailable: async (carId: number): Promise<{ car_id: number; soldout: boolean; message: string }> => {
|
||||||
|
const { data } = await api.delete(`/cars/${carId}/soldout`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getSoldoutStats: async (): Promise<{ total_active: number; soldout: number; available: number; soldout_percentage: number }> => {
|
||||||
|
const { data } = await api.get('/cars/admin/soldout-stats');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auth API
|
// Auth API
|
||||||
@@ -187,6 +203,22 @@ export const heroBannersApi = {
|
|||||||
const { data } = await api.put('/hero-banners/admin/settings', settings);
|
const { data } = await api.put('/hero-banners/admin/settings', settings);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Banner Toggle & Ordering
|
||||||
|
adminToggleBanner: async (carId: number): Promise<{ car_id: number; is_banner: boolean; banner_id?: number; message: string }> => {
|
||||||
|
const { data } = await api.post(`/hero-banners/admin/toggle/${carId}`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
adminReorderBanners: async (carIds: number[]): Promise<{ message: string; count: number }> => {
|
||||||
|
const { data } = await api.put('/hero-banners/admin/reorder', carIds);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
adminGetBannerCars: async (): Promise<{ car_ids: number[]; count: number }> => {
|
||||||
|
const { data } = await api.get('/hero-banners/admin/banner-cars');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Translations API
|
// Translations API
|
||||||
|
|||||||
Reference in New Issue
Block a user