Add SOLD OUT badge and improve deployment docs
- Add SOLD OUT overlay on car detail page image - Add SOLD OUT badge next to car name - Add Sold Out status in Admin Cars detail view - Add soldout field to Car TypeScript interface - Create PRODUCTION_VALUES.md for deployment reference - Update CLAUDE.md with CRITICAL deployment section - Update TROUBLESHOOTING.md with recurring errors 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
53
CLAUDE.md
53
CLAUDE.md
@@ -8,6 +8,59 @@
|
||||
|
||||
---
|
||||
|
||||
# ⛔ CRITICAL: 배포 전 필수 확인사항 (DO NOT SKIP!)
|
||||
|
||||
## 🚨 Production 환경 고정값 (절대 변경 금지!)
|
||||
|
||||
| 항목 | 올바른 값 | 잘못된 예 |
|
||||
|------|----------|----------|
|
||||
| **DB_NAME** | `autonet` | ~~mongolcar~~, ~~autonet_db~~ |
|
||||
| **DB_PASSWORD** | `roskfl@1122` (@ 포함, URL 인코딩 필요) | - |
|
||||
| **API_URL (Frontend)** | `https://autonetsellcar.com` | ~~http://192.168.0.202:8000~~ |
|
||||
| **Uploads 경로** | `/opt/autonet/production/backend/uploads` | ~~`/home/damon/mongolcar/data/uploads`~~ |
|
||||
| **배포 디렉토리** | `/opt/autonet/production` | ~~`/home/damon/mongolcar`~~ |
|
||||
|
||||
## 🚫 절대 하지 말 것
|
||||
|
||||
1. **수동 `docker run`에서 환경변수 직접 타이핑 금지** - [PRODUCTION_VALUES.md](./PRODUCTION_VALUES.md)에서 복사만 허용
|
||||
2. **DB_NAME 직접 타이핑 금지** - 반드시 `autonet` 복사/붙여넣기
|
||||
3. **`.env` 파일 덮어쓰기 금지** - 서버의 기존 `.env` 보존
|
||||
4. **`/home/damon/mongolcar/data/uploads` 사용 금지** - 비어있는 잘못된 경로
|
||||
|
||||
## ✅ 배포 전 필수 검증 명령어
|
||||
|
||||
```bash
|
||||
# 1. DB 이름 확인 (반드시 autonet이어야 함!)
|
||||
ssh server1 "docker exec postgres-primary psql -U admin -d autonet -c 'SELECT COUNT(*) FROM cars;'"
|
||||
|
||||
# 2. Uploads 경로에 파일 있는지 확인
|
||||
ssh server2 "ls /opt/autonet/production/backend/uploads/cars/ | head -5"
|
||||
|
||||
# 3. 배포 후 API 확인
|
||||
curl -s https://autonetsellcar.com/api/hero-banners/ | head -c 100
|
||||
```
|
||||
|
||||
## 📋 표준 컨테이너 실행 명령어 (복사해서 사용!)
|
||||
|
||||
**⚠️ 아래 명령어를 그대로 복사해서 사용하세요. 절대 직접 타이핑하지 마세요!**
|
||||
|
||||
### Backend 실행
|
||||
```bash
|
||||
ssh server2 "docker stop autonet-backend 2>/dev/null; docker rm autonet-backend 2>/dev/null; docker run -d --name autonet-backend --restart unless-stopped -p 8000:8000 -e USE_SQLITE=False -e DB_HOST=192.168.0.201 -e DB_PORT=5432 -e DB_NAME=autonet -e DB_USER=admin -e 'DB_PASSWORD=roskfl@1122' -e REDIS_HOST=192.168.0.201 -e REDIS_PORT=6379 -e 'REDIS_PASSWORD=roskfl@1122' -e SECRET_KEY=YourSuperSecretKeyForJWT123! -e AGENT_API_KEY=AgentApiKey123! -v /opt/autonet/production/backend/uploads:/app/uploads --network mongolcar-network production-backend"
|
||||
```
|
||||
|
||||
### Frontend 빌드 전 (.env.production 설정)
|
||||
```bash
|
||||
ssh server2 "echo 'NEXT_PUBLIC_API_URL=https://autonetsellcar.com' > /home/damon/mongolcar/frontend/.env.production"
|
||||
```
|
||||
|
||||
### Frontend 실행
|
||||
```bash
|
||||
ssh server2 "docker stop autonet-frontend 2>/dev/null; docker rm autonet-frontend 2>/dev/null; docker run -d --name autonet-frontend --restart unless-stopped -p 3000:3000 --network mongolcar-network production-frontend"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 프로젝트 구조
|
||||
|
||||
```
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
이 문서는 시스템 아키텍처와 코드 수정부터 운영 배포까지의 전체 과정을 설명합니다.
|
||||
|
||||
> **중요**: 배포 전 반드시 [PRODUCTION_VALUES.md](./PRODUCTION_VALUES.md)의 값을 확인하세요!
|
||||
> 반복되는 오류 해결은 [TROUBLESHOOTING.md](./TROUBLESHOOTING.md)를 참고하세요.
|
||||
|
||||
---
|
||||
|
||||
## 1. 시스템 아키텍처 개요
|
||||
@@ -150,7 +153,7 @@ docker exec postgres-primary env | grep POSTGRES
|
||||
# 출력:
|
||||
# POSTGRES_USER=admin
|
||||
# POSTGRES_PASSWORD=roskfl@1122
|
||||
# POSTGRES_DB=mongolcar
|
||||
# POSTGRES_DB=mongolcar (컨테이너 기본 DB, AutonetSellCar는 autonet DB 사용!)
|
||||
```
|
||||
|
||||
### 4.2 데이터베이스 생성
|
||||
|
||||
179
PRODUCTION_VALUES.md
Normal file
179
PRODUCTION_VALUES.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# AutonetSellCar Production Values (Single Source of Truth)
|
||||
|
||||
**이 파일의 값들은 절대 변경하지 마세요!**
|
||||
**DO NOT MODIFY THESE VALUES!**
|
||||
|
||||
---
|
||||
|
||||
## Server Information
|
||||
|
||||
| 서버 | IP | 역할 |
|
||||
|------|-----|------|
|
||||
| Server1 | 192.168.0.201 | PostgreSQL Primary |
|
||||
| Server2 | 192.168.0.202 | Production (Frontend + Backend) |
|
||||
| Server3 | 192.168.0.203 | Grantech.kr |
|
||||
| Server4 | 192.168.0.204 | Development |
|
||||
|
||||
---
|
||||
|
||||
## Database Configuration
|
||||
|
||||
| 항목 | 값 | 비고 |
|
||||
|------|-----|------|
|
||||
| **DB_NAME** | `autonet` | ~~mongolcar~~ 절대 아님! |
|
||||
| **DB_USER** | `admin` | |
|
||||
| **DB_PASSWORD** | `roskfl@1122` | `@` 포함, URL 인코딩 필요 |
|
||||
| **DB_HOST** | `192.168.0.201` | Server1 |
|
||||
| **DB_PORT** | `5432` | PostgreSQL 기본 포트 |
|
||||
|
||||
### Database URL Format
|
||||
|
||||
```
|
||||
postgresql://admin:roskfl%401122@192.168.0.201:5432/autonet
|
||||
^^^^^^^^
|
||||
@가 %40으로 인코딩됨
|
||||
```
|
||||
|
||||
### config.py에서 quote_plus() 필수
|
||||
|
||||
```python
|
||||
from urllib.parse import quote_plus
|
||||
encoded_password = quote_plus(self.DB_PASSWORD) # roskfl@1122 → roskfl%401122
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Docker Container Configuration
|
||||
|
||||
### Backend Container
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name autonet-backend \
|
||||
--restart unless-stopped \
|
||||
-p 8000:8000 \
|
||||
-e USE_SQLITE=False \
|
||||
-e DB_HOST=192.168.0.201 \
|
||||
-e DB_PORT=5432 \
|
||||
-e DB_NAME=autonet \
|
||||
-e DB_USER=admin \
|
||||
-e "DB_PASSWORD=roskfl@1122" \
|
||||
-v /opt/autonet/production/backend/uploads:/app/uploads \
|
||||
production-backend
|
||||
```
|
||||
|
||||
**주의사항:**
|
||||
- `DB_NAME=autonet` (절대 mongolcar 아님!)
|
||||
- `-v /opt/autonet/production/backend/uploads:/app/uploads` (절대 `/home/damon/mongolcar/...` 아님!)
|
||||
- `DB_PASSWORD`에 `@` 포함됨 - 따옴표로 감싸기
|
||||
|
||||
### Frontend Container
|
||||
|
||||
```bash
|
||||
# 빌드 전 .env.production 확인!
|
||||
echo 'NEXT_PUBLIC_API_URL=https://autonetsellcar.com' > /opt/autonet/production/frontend/.env.production
|
||||
|
||||
# 빌드 (반드시 --no-cache)
|
||||
docker build --no-cache -t production-frontend .
|
||||
|
||||
# 실행
|
||||
docker run -d \
|
||||
--name autonet-frontend \
|
||||
--restart unless-stopped \
|
||||
-p 3000:3000 \
|
||||
production-frontend
|
||||
```
|
||||
|
||||
**주의사항:**
|
||||
- `NEXT_PUBLIC_API_URL=https://autonetsellcar.com` (절대 `http://192.168.0.202:8000` 아님!)
|
||||
- HTTPS 필수 (Mixed Content 방지)
|
||||
|
||||
---
|
||||
|
||||
## File Paths
|
||||
|
||||
| 용도 | 올바른 경로 | 잘못된 경로 |
|
||||
|------|-------------|-------------|
|
||||
| **Uploads** | `/opt/autonet/production/backend/uploads` | `/home/damon/mongolcar/data/uploads` |
|
||||
| **Backend 소스** | `/opt/autonet/production/backend` | - |
|
||||
| **Frontend 소스** | `/opt/autonet/production/frontend` | - |
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Backend (.env)
|
||||
|
||||
```env
|
||||
USE_SQLITE=False
|
||||
DB_HOST=192.168.0.201
|
||||
DB_PORT=5432
|
||||
DB_NAME=autonet
|
||||
DB_USER=admin
|
||||
DB_PASSWORD=roskfl@1122
|
||||
```
|
||||
|
||||
### Frontend (.env.production)
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_API_URL=https://autonetsellcar.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification Commands
|
||||
|
||||
### 1. DB 연결 확인
|
||||
|
||||
```bash
|
||||
ssh server2 "docker exec autonet-backend python -c \"from app.config import get_settings; print('DB_NAME:', get_settings().DB_NAME)\""
|
||||
# 예상 출력: DB_NAME: autonet
|
||||
```
|
||||
|
||||
### 2. API 데이터 확인
|
||||
|
||||
```bash
|
||||
curl -s https://autonetsellcar.com/api/hero-banners/ | jq 'length'
|
||||
# 예상 출력: 8 이상 (0이면 문제!)
|
||||
```
|
||||
|
||||
### 3. 이미지 확인
|
||||
|
||||
```bash
|
||||
curl -I https://autonetsellcar.com/uploads/cars/1/image_0.jpg
|
||||
# 예상 출력: HTTP/2 200
|
||||
```
|
||||
|
||||
### 4. Frontend API URL 확인
|
||||
|
||||
```bash
|
||||
ssh server2 "docker exec autonet-frontend grep -o 'autonetsellcar.com' /app/.next/static/chunks/app/**/*.js | head -1"
|
||||
# 예상 출력: autonetsellcar.com (localhost나 192.168.0.202가 아님!)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference Card
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ PRODUCTION VALUES │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ DB_NAME: autonet (NOT mongolcar!) │
|
||||
│ DB_PASSWORD: roskfl@1122 (@ needs URL encoding) │
|
||||
│ API_URL: https://autonetsellcar.com (NOT http://...) │
|
||||
│ UPLOADS: /opt/autonet/production/backend/uploads │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Change History
|
||||
|
||||
| 날짜 | 변경 내용 |
|
||||
|------|----------|
|
||||
| 2026-01-02 | 문서 최초 생성 (반복 배포 오류 방지 목적) |
|
||||
|
||||
---
|
||||
|
||||
**이 문서의 값과 다르면 무조건 틀린 것입니다!**
|
||||
@@ -233,9 +233,162 @@ docker exec autonet-frontend sh -c "grep -r 'localhost:8000' /app/.next/static/c
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 2026-01-02: 반복되는 배포 오류 종합 (CRITICAL)
|
||||
|
||||
### 문제 1: DB_NAME 오류 (`mongolcar` vs `autonet`)
|
||||
|
||||
**증상**:
|
||||
- 사이트에 차량/배너 없음
|
||||
- API가 빈 배열 `[]` 반환
|
||||
- 로그인은 되지만 데이터 없음
|
||||
|
||||
**원인**:
|
||||
- 수동 `docker run` 시 DB_NAME을 `mongolcar`로 잘못 입력
|
||||
- PostgreSQL에 `mongolcar`와 `autonet` 두 DB가 존재
|
||||
- `mongolcar`는 비어있고, 실제 데이터는 `autonet`에 있음
|
||||
|
||||
**확인 방법**:
|
||||
```bash
|
||||
# 현재 연결된 DB 확인
|
||||
ssh server2 "docker exec autonet-backend python -c \"from app.config import get_settings; print('DB_NAME:', get_settings().DB_NAME)\""
|
||||
|
||||
# DB별 데이터 확인
|
||||
ssh server1 "docker exec postgres-primary psql -U admin -d autonet -c 'SELECT COUNT(*) FROM cars;'"
|
||||
ssh server1 "docker exec postgres-primary psql -U admin -d mongolcar -c 'SELECT COUNT(*) FROM cars;'"
|
||||
```
|
||||
|
||||
**해결**:
|
||||
- `DB_NAME=autonet` 확인 후 컨테이너 재시작
|
||||
- CLAUDE.md의 표준 명령어 복사해서 사용
|
||||
|
||||
---
|
||||
|
||||
### 문제 2: 비밀번호 `@` 파싱 오류
|
||||
|
||||
**증상**:
|
||||
- `could not translate host name "1122@192.168.0.201"`
|
||||
- 백엔드 컨테이너 계속 재시작
|
||||
|
||||
**원인**:
|
||||
- `DB_PASSWORD=roskfl@1122`의 `@`가 URL에서 호스트 구분자로 인식됨
|
||||
- `config.py`에 `quote_plus()` URL 인코딩이 없거나, Docker 이미지가 오래됨
|
||||
|
||||
**확인 방법**:
|
||||
```bash
|
||||
# config.py에 quote_plus 있는지 확인
|
||||
ssh server2 "docker exec autonet-backend cat /app/app/config.py | grep quote_plus"
|
||||
```
|
||||
|
||||
**해결**:
|
||||
1. `config.py`에 `quote_plus()` 적용 확인
|
||||
2. Docker 이미지 재빌드: `docker build --no-cache -t production-backend ./backend`
|
||||
|
||||
---
|
||||
|
||||
### 문제 3: 이미지 404 (잘못된 uploads 경로)
|
||||
|
||||
**증상**:
|
||||
- `/uploads/cars/40/image_0.jpg` → 404
|
||||
- Hero 배너에 기본 이미지만 표시
|
||||
|
||||
**원인**:
|
||||
- Production 컨테이너가 잘못된 경로 마운트: `/home/damon/mongolcar/data/uploads` (비어있음!)
|
||||
- 실제 이미지는 `/opt/autonet/production/backend/uploads`에 있음
|
||||
|
||||
**확인 방법**:
|
||||
```bash
|
||||
# 올바른 경로 (파일 있어야 함)
|
||||
ssh server2 "ls /opt/autonet/production/backend/uploads/cars/ | head -5"
|
||||
|
||||
# 잘못된 경로 (비어있음!)
|
||||
ssh server2 "ls /home/damon/mongolcar/data/uploads/cars/ 2>/dev/null || echo 'Empty or not exists'"
|
||||
```
|
||||
|
||||
**해결**:
|
||||
- 볼륨 마운트를 `-v /opt/autonet/production/backend/uploads:/app/uploads`로 변경
|
||||
- CLAUDE.md의 표준 명령어 사용
|
||||
|
||||
---
|
||||
|
||||
### 문제 4: Mixed Content (HTTPS/HTTP)
|
||||
|
||||
**증상**:
|
||||
- 브라우저 콘솔에 Mixed Content 오류
|
||||
- API 요청 실패
|
||||
- 사이트에 기본 이미지만 표시
|
||||
|
||||
**원인**:
|
||||
- Frontend가 `NEXT_PUBLIC_API_URL=http://192.168.0.202:8000`으로 빌드됨
|
||||
- HTTPS 페이지에서 HTTP API 호출 시 브라우저가 차단
|
||||
|
||||
**확인 방법**:
|
||||
```bash
|
||||
# 빌드된 JS에서 API URL 확인
|
||||
ssh server2 "docker exec autonet-frontend grep -r '192.168.0.202:8000' /app/.next/static/chunks/ | head -1"
|
||||
```
|
||||
|
||||
**해결**:
|
||||
```bash
|
||||
# Frontend 빌드 전 필수!
|
||||
ssh server2 "echo 'NEXT_PUBLIC_API_URL=https://autonetsellcar.com' > /home/damon/mongolcar/frontend/.env.production"
|
||||
ssh server2 "cd /home/damon/mongolcar/frontend && docker build --no-cache -t production-frontend ."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 문제 5: .env 파일이 디렉토리로 생성됨
|
||||
|
||||
**증상**:
|
||||
- 로그인 실패
|
||||
- `(sqlite3.OperationalError) no such column` 에러
|
||||
- 환경변수 로드 실패
|
||||
|
||||
**원인**:
|
||||
- docker-compose 볼륨 마운트 시 `.env` 파일이 없으면 디렉토리로 생성됨
|
||||
|
||||
**확인 방법**:
|
||||
```bash
|
||||
ssh server2 "ls -la /opt/autonet/production/backend/.env"
|
||||
# 'd'로 시작하면 디렉토리 (문제!)
|
||||
```
|
||||
|
||||
**해결**:
|
||||
```bash
|
||||
# 디렉토리면 삭제 후 파일로 재생성
|
||||
ssh server2 "rm -rf /opt/autonet/production/backend/.env"
|
||||
ssh server2 "cat > /opt/autonet/production/backend/.env << 'EOF'
|
||||
USE_SQLITE=False
|
||||
DB_HOST=192.168.0.201
|
||||
DB_PORT=5432
|
||||
DB_NAME=autonet
|
||||
DB_USER=admin
|
||||
DB_PASSWORD=roskfl@1122
|
||||
EOF"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 재발 방지 체크리스트
|
||||
|
||||
배포 시 반드시 확인:
|
||||
|
||||
- [ ] DB_NAME이 `autonet`인가? (절대 `mongolcar` 아님!)
|
||||
- [ ] Uploads 경로가 `/opt/autonet/production/backend/uploads`인가?
|
||||
- [ ] Frontend `.env.production`에 `https://autonetsellcar.com`이 설정되어 있는가?
|
||||
- [ ] `--no-cache`로 이미지를 빌드했는가?
|
||||
- [ ] 배포 후 `curl https://autonetsellcar.com/api/hero-banners/`로 데이터 확인했는가?
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
|
||||
| 날짜 | 문제 | 해결 |
|
||||
|------|------|------|
|
||||
| 2026-01-02 | DB_NAME 오류 (mongolcar vs autonet) | DB_NAME=autonet 확인, 표준 명령어 사용 |
|
||||
| 2026-01-02 | 비밀번호 @ 파싱 오류 | quote_plus() URL 인코딩 적용 |
|
||||
| 2026-01-02 | 이미지 404 (잘못된 uploads 경로) | /opt/autonet/production/backend/uploads 사용 |
|
||||
| 2026-01-02 | Mixed Content 오류 | NEXT_PUBLIC_API_URL=https://autonetsellcar.com |
|
||||
| 2024-12-30 | 운영서버 이미지 미표시 (배너) | 소스 코드 동기화 + Docker 재빌드 |
|
||||
| 2024-12-30 | 차량 상세 이미지 미표시 | `getImageUrl()` 함수 수정 |
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import List
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import VehicleRequest, RequestVehicle, PurchasedVehicle, User, DealerInfo, SystemSettings
|
||||
from ..models import VehicleRequest, RequestVehicle, PurchasedVehicle, User, DealerInfo, SystemSettings, Car
|
||||
from ..schemas import (
|
||||
VehicleRequestCreate, VehicleRequestResponse,
|
||||
RequestVehicleCreate, RequestVehicleResponse, RequestVehicleApprove,
|
||||
@@ -100,9 +100,21 @@ def get_my_requests(
|
||||
else:
|
||||
approved_vehicles = []
|
||||
|
||||
# Enrich approved vehicles with latest soldout status from cars table
|
||||
enriched_vehicles = []
|
||||
for v in approved_vehicles:
|
||||
vehicle_response = RequestVehicleResponse.model_validate(v)
|
||||
# Get latest soldout status from cars table
|
||||
if v.car_id:
|
||||
car = db.query(Car).filter(Car.id == v.car_id).first()
|
||||
if car:
|
||||
# Add soldout status to car_data
|
||||
vehicle_response.car_data = {**vehicle_response.car_data, "soldout": car.soldout}
|
||||
enriched_vehicles.append(vehicle_response)
|
||||
|
||||
result.append(VehicleRequestWithVehicles(
|
||||
request=VehicleRequestResponse.model_validate(req),
|
||||
approved_vehicles=[RequestVehicleResponse.model_validate(v) for v in approved_vehicles]
|
||||
approved_vehicles=enriched_vehicles
|
||||
))
|
||||
|
||||
return result
|
||||
@@ -189,9 +201,30 @@ def admin_get_request_detail(
|
||||
if not request:
|
||||
raise HTTPException(status_code=404, detail="Request not found")
|
||||
|
||||
# Import CarPerformanceCheck for PDF status
|
||||
from ..models import CarPerformanceCheck
|
||||
|
||||
# Enrich with PDF status and soldout
|
||||
enriched_vehicles = []
|
||||
for v in request.recommended_vehicles:
|
||||
vehicle_response = RequestVehicleResponse.model_validate(v)
|
||||
|
||||
# Get PDF status and soldout from car
|
||||
if v.car_id:
|
||||
car = db.query(Car).filter(Car.id == v.car_id).first()
|
||||
perf_check = db.query(CarPerformanceCheck).filter(CarPerformanceCheck.car_id == v.car_id).first()
|
||||
|
||||
vehicle_response.car_data = {
|
||||
**vehicle_response.car_data,
|
||||
"soldout": car.soldout if car else False,
|
||||
"has_pdf": bool(perf_check and perf_check.pdf_path),
|
||||
"check_num": perf_check.check_number if perf_check else None,
|
||||
}
|
||||
enriched_vehicles.append(vehicle_response)
|
||||
|
||||
return VehicleRequestWithVehicles(
|
||||
request=VehicleRequestResponse.model_validate(request),
|
||||
approved_vehicles=[RequestVehicleResponse.model_validate(v) for v in request.recommended_vehicles]
|
||||
approved_vehicles=enriched_vehicles
|
||||
)
|
||||
|
||||
|
||||
@@ -202,7 +235,7 @@ def admin_add_vehicle(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Admin: Add a vehicle to a request"""
|
||||
"""Admin: Add a vehicle to a request (also imports to cars table)"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
@@ -210,9 +243,51 @@ def admin_add_vehicle(
|
||||
if not request:
|
||||
raise HTTPException(status_code=404, detail="Request not found")
|
||||
|
||||
# Extract car data
|
||||
car_data = vehicle_data.car_data
|
||||
source_id = str(car_data.get("id", ""))
|
||||
|
||||
# Check if car already exists in cars table
|
||||
existing_car = None
|
||||
if source_id:
|
||||
existing_car = db.query(Car).filter(
|
||||
Car.source == "carmodoo",
|
||||
Car.source_id == source_id
|
||||
).first()
|
||||
|
||||
car_id = None
|
||||
if existing_car:
|
||||
car_id = existing_car.id
|
||||
elif source_id:
|
||||
# Create new car record from car_data
|
||||
new_car = Car(
|
||||
source="carmodoo",
|
||||
source_id=source_id,
|
||||
car_name=car_data.get("car_name", ""),
|
||||
year=car_data.get("year"),
|
||||
mileage=car_data.get("mileage"),
|
||||
price_krw=car_data.get("original_price"),
|
||||
fuel=car_data.get("fuel"),
|
||||
transmission=car_data.get("transmission"),
|
||||
color=car_data.get("color"),
|
||||
displacement=car_data.get("displacement"),
|
||||
margin_krw=car_data.get("korea_margin"),
|
||||
margin_mn=car_data.get("mongolia_margin"),
|
||||
check_num=car_data.get("check_num"),
|
||||
is_displayed=True, # Displayed so user can view recommended car
|
||||
status="active"
|
||||
)
|
||||
db.add(new_car)
|
||||
db.flush()
|
||||
car_id = new_car.id
|
||||
|
||||
# Update car_data with local car_id for frontend
|
||||
car_data["local_car_id"] = car_id
|
||||
|
||||
vehicle = RequestVehicle(
|
||||
request_id=request_id,
|
||||
car_data=vehicle_data.car_data,
|
||||
car_id=car_id,
|
||||
car_data=car_data,
|
||||
is_approved=vehicle_data.is_approved,
|
||||
approved_at=datetime.utcnow() if vehicle_data.is_approved else None
|
||||
)
|
||||
|
||||
@@ -218,6 +218,17 @@ export default function CarsAdminPage() {
|
||||
added: number;
|
||||
errors: number;
|
||||
} | null>(null);
|
||||
const [requestInfo, setRequestInfo] = useState<{
|
||||
user_email?: string;
|
||||
user_name?: string;
|
||||
maker_name?: string;
|
||||
model_name?: string;
|
||||
grade_name?: string;
|
||||
year_from?: number;
|
||||
year_to?: number;
|
||||
mileage_max?: number;
|
||||
fuel?: string;
|
||||
} | null>(null);
|
||||
|
||||
// Dealer description editing state
|
||||
const [showDescEditModal, setShowDescEditModal] = useState(false);
|
||||
@@ -299,6 +310,21 @@ export default function CarsAdminPage() {
|
||||
|
||||
// Switch to Carmodoo tab
|
||||
setActiveTab('carmodoo');
|
||||
|
||||
// Fetch request details for display
|
||||
vehicleRequestsApi.adminGetRequestDetail(parseInt(requestId)).then(data => {
|
||||
setRequestInfo({
|
||||
user_email: data.request.user_email,
|
||||
user_name: data.request.user_name,
|
||||
maker_name: data.request.maker_name,
|
||||
model_name: data.request.model_name,
|
||||
grade_name: data.request.grade_name,
|
||||
year_from: data.request.year_from,
|
||||
year_to: data.request.year_to,
|
||||
mileage_max: data.request.mileage_max,
|
||||
fuel: data.request.fuel,
|
||||
});
|
||||
}).catch(err => console.error('Failed to load request info:', err));
|
||||
}
|
||||
}, [requestId]);
|
||||
|
||||
@@ -1195,24 +1221,76 @@ export default function CarsAdminPage() {
|
||||
|
||||
{/* Request Mode Banner */}
|
||||
{requestId && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-blue-100 rounded-full p-2">
|
||||
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-blue-800">Adding vehicles to Request #{requestId}</p>
|
||||
<p className="text-sm text-blue-600">Search and select vehicles, then click "Add to Request" button.</p>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-blue-100 rounded-full p-2">
|
||||
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-blue-800">
|
||||
Adding vehicles to Request #{requestId}
|
||||
{requestInfo?.user_email && (
|
||||
<span className="ml-2 text-sm font-normal text-blue-600">
|
||||
({requestInfo.user_email})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push('/admin/vehicle-requests')}
|
||||
className="text-blue-600 hover:text-blue-800 text-sm font-medium flex items-center gap-1"
|
||||
>
|
||||
← Back to Requests
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push('/admin/vehicle-requests')}
|
||||
className="text-blue-600 hover:text-blue-800 text-sm font-medium flex items-center gap-1"
|
||||
>
|
||||
← Back to Requests
|
||||
</button>
|
||||
{requestInfo && (
|
||||
<div className="bg-white/50 rounded-lg p-3 grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
|
||||
{requestInfo.maker_name && (
|
||||
<div>
|
||||
<span className="text-blue-500">Maker:</span>
|
||||
<span className="ml-1 font-medium text-blue-800">{requestInfo.maker_name}</span>
|
||||
</div>
|
||||
)}
|
||||
{requestInfo.model_name && (
|
||||
<div>
|
||||
<span className="text-blue-500">Model:</span>
|
||||
<span className="ml-1 font-medium text-blue-800">{requestInfo.model_name}</span>
|
||||
</div>
|
||||
)}
|
||||
{requestInfo.grade_name && (
|
||||
<div>
|
||||
<span className="text-blue-500">Grade:</span>
|
||||
<span className="ml-1 font-medium text-blue-800">{requestInfo.grade_name}</span>
|
||||
</div>
|
||||
)}
|
||||
{(requestInfo.year_from || requestInfo.year_to) && (
|
||||
<div>
|
||||
<span className="text-blue-500">Year:</span>
|
||||
<span className="ml-1 font-medium text-blue-800">
|
||||
{requestInfo.year_from || '-'} ~ {requestInfo.year_to || '-'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{requestInfo.mileage_max && (
|
||||
<div>
|
||||
<span className="text-blue-500">Mileage:</span>
|
||||
<span className="ml-1 font-medium text-blue-800">
|
||||
~{Math.round(requestInfo.mileage_max / 10000)}만km
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{requestInfo.fuel && (
|
||||
<div>
|
||||
<span className="text-blue-500">Fuel:</span>
|
||||
<span className="ml-1 font-medium text-blue-800">{requestInfo.fuel}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2568,6 +2646,16 @@ export default function CarsAdminPage() {
|
||||
{selectedCar.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Sold Out</span>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
selectedCar.soldout
|
||||
? 'bg-red-100 text-red-700'
|
||||
: 'bg-green-100 text-green-700'
|
||||
}`}>
|
||||
{selectedCar.soldout ? 'SOLD OUT' : 'Available'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Seize Count</span>
|
||||
<span className={`font-medium ${(selectedCar.seize_count || 0) > 0 ? 'text-red-600' : ''}`}>
|
||||
|
||||
@@ -410,6 +410,15 @@ export default function CarDetailPage() {
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Soldout overlay */}
|
||||
{car.soldout && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40">
|
||||
<span className="bg-red-600 text-white px-6 py-3 rounded-lg font-bold text-2xl transform -rotate-12 shadow-lg">
|
||||
{language === 'ko' ? '판매완료' : 'SOLD OUT'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Thumbnails */}
|
||||
@@ -527,9 +536,16 @@ export default function CarDetailPage() {
|
||||
|
||||
{/* Details */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-2">
|
||||
{translate(car.car_name) || `${translate(car.maker?.name) || ''} ${translate(car.model?.name) || ''}`.trim() || '-'}
|
||||
</h1>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h1 className="text-3xl font-bold text-gray-800">
|
||||
{translate(car.car_name) || `${translate(car.maker?.name) || ''} ${translate(car.model?.name) || ''}`.trim() || '-'}
|
||||
</h1>
|
||||
{car.soldout && (
|
||||
<span className="px-3 py-1 bg-red-600 text-white text-sm font-bold rounded-full">
|
||||
{language === 'ko' ? '판매완료' : 'SOLD OUT'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const price = formatPrice(car.final_price_krw || car.price_krw);
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface Car {
|
||||
dealer_description_mn?: string;
|
||||
dealer_description_ru?: string;
|
||||
status: string;
|
||||
soldout?: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
maker?: CarMaker;
|
||||
|
||||
Reference in New Issue
Block a user