Files
smart-project-pricing/后端服务/app/routers/market.py
2026-01-31 21:33:06 +08:00

734 lines
24 KiB
Python

"""市场行情管理路由
实现竞品机构、竞品价格、标杆价格和市场分析
"""
from typing import Optional
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, func, or_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models import (
Project,
Competitor,
CompetitorPrice,
BenchmarkPrice,
MarketAnalysisResult,
Category,
)
from app.schemas.common import ResponseModel, PaginatedData, ErrorCode
from app.schemas.competitor import (
CompetitorCreate,
CompetitorUpdate,
CompetitorResponse,
CompetitorPriceCreate,
CompetitorPriceUpdate,
CompetitorPriceResponse,
Positioning,
)
from app.schemas.market import (
BenchmarkPriceCreate,
BenchmarkPriceUpdate,
BenchmarkPriceResponse,
MarketAnalysisRequest,
MarketAnalysisResult as MarketAnalysisResultSchema,
MarketAnalysisResponse,
)
from app.services.market_service import MarketService
router = APIRouter()
# ============ 竞品机构 CRUD ============
@router.get("/competitors", response_model=ResponseModel[PaginatedData[CompetitorResponse]])
async def get_competitors(
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
positioning: Optional[Positioning] = Query(None, description="定位筛选"),
is_key_competitor: Optional[bool] = Query(None, description="是否重点关注"),
keyword: Optional[str] = Query(None, description="关键词搜索"),
db: AsyncSession = Depends(get_db),
):
"""获取竞品机构列表"""
query = select(Competitor).options(selectinload(Competitor.prices))
if positioning:
query = query.where(Competitor.positioning == positioning.value)
if is_key_competitor is not None:
query = query.where(Competitor.is_key_competitor == is_key_competitor)
if keyword:
query = query.where(
or_(
Competitor.competitor_name.contains(keyword),
Competitor.address.contains(keyword),
)
)
query = query.order_by(Competitor.is_key_competitor.desc(), Competitor.id.desc())
# 分页
offset = (page - 1) * page_size
query = query.offset(offset).limit(page_size)
result = await db.execute(query)
competitors = result.scalars().all()
# 统计总数
count_query = select(func.count(Competitor.id))
if positioning:
count_query = count_query.where(Competitor.positioning == positioning.value)
if is_key_competitor is not None:
count_query = count_query.where(Competitor.is_key_competitor == is_key_competitor)
if keyword:
count_query = count_query.where(
or_(
Competitor.competitor_name.contains(keyword),
Competitor.address.contains(keyword),
)
)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# 构建响应
items = []
for c in competitors:
# 获取最新价格更新日期
last_price_update = None
if c.prices:
last_price_update = max(p.collected_at for p in c.prices)
items.append(CompetitorResponse(
id=c.id,
competitor_name=c.competitor_name,
address=c.address,
distance_km=float(c.distance_km) if c.distance_km else None,
positioning=Positioning(c.positioning),
contact=c.contact,
is_key_competitor=c.is_key_competitor,
is_active=c.is_active,
price_count=len(c.prices),
last_price_update=last_price_update,
created_at=c.created_at,
updated_at=c.updated_at,
))
return ResponseModel(
data=PaginatedData(
items=items,
total=total,
page=page,
page_size=page_size,
total_pages=(total + page_size - 1) // page_size,
)
)
@router.get("/competitors/{competitor_id}", response_model=ResponseModel[CompetitorResponse])
async def get_competitor(
competitor_id: int,
db: AsyncSession = Depends(get_db),
):
"""获取竞品机构详情"""
result = await db.execute(
select(Competitor).options(
selectinload(Competitor.prices)
).where(Competitor.id == competitor_id)
)
competitor = result.scalar_one_or_none()
if not competitor:
raise HTTPException(
status_code=404,
detail={"code": ErrorCode.NOT_FOUND, "message": "竞品机构不存在"}
)
last_price_update = None
if competitor.prices:
last_price_update = max(p.collected_at for p in competitor.prices)
return ResponseModel(
data=CompetitorResponse(
id=competitor.id,
competitor_name=competitor.competitor_name,
address=competitor.address,
distance_km=float(competitor.distance_km) if competitor.distance_km else None,
positioning=Positioning(competitor.positioning),
contact=competitor.contact,
is_key_competitor=competitor.is_key_competitor,
is_active=competitor.is_active,
price_count=len(competitor.prices),
last_price_update=last_price_update,
created_at=competitor.created_at,
updated_at=competitor.updated_at,
)
)
@router.post("/competitors", response_model=ResponseModel[CompetitorResponse])
async def create_competitor(
data: CompetitorCreate,
db: AsyncSession = Depends(get_db),
):
"""创建竞品机构"""
competitor = Competitor(
competitor_name=data.competitor_name,
address=data.address,
distance_km=data.distance_km,
positioning=data.positioning.value,
contact=data.contact,
is_key_competitor=data.is_key_competitor,
is_active=data.is_active,
)
db.add(competitor)
await db.flush()
await db.refresh(competitor)
return ResponseModel(
message="创建成功",
data=CompetitorResponse(
id=competitor.id,
competitor_name=competitor.competitor_name,
address=competitor.address,
distance_km=float(competitor.distance_km) if competitor.distance_km else None,
positioning=Positioning(competitor.positioning),
contact=competitor.contact,
is_key_competitor=competitor.is_key_competitor,
is_active=competitor.is_active,
price_count=0,
last_price_update=None,
created_at=competitor.created_at,
updated_at=competitor.updated_at,
)
)
@router.put("/competitors/{competitor_id}", response_model=ResponseModel[CompetitorResponse])
async def update_competitor(
competitor_id: int,
data: CompetitorUpdate,
db: AsyncSession = Depends(get_db),
):
"""更新竞品机构"""
result = await db.execute(
select(Competitor).options(
selectinload(Competitor.prices)
).where(Competitor.id == competitor_id)
)
competitor = result.scalar_one_or_none()
if not competitor:
raise HTTPException(
status_code=404,
detail={"code": ErrorCode.NOT_FOUND, "message": "竞品机构不存在"}
)
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
if field == "positioning" and value:
value = value.value
setattr(competitor, field, value)
await db.flush()
await db.refresh(competitor)
last_price_update = None
if competitor.prices:
last_price_update = max(p.collected_at for p in competitor.prices)
return ResponseModel(
message="更新成功",
data=CompetitorResponse(
id=competitor.id,
competitor_name=competitor.competitor_name,
address=competitor.address,
distance_km=float(competitor.distance_km) if competitor.distance_km else None,
positioning=Positioning(competitor.positioning),
contact=competitor.contact,
is_key_competitor=competitor.is_key_competitor,
is_active=competitor.is_active,
price_count=len(competitor.prices),
last_price_update=last_price_update,
created_at=competitor.created_at,
updated_at=competitor.updated_at,
)
)
@router.delete("/competitors/{competitor_id}", response_model=ResponseModel)
async def delete_competitor(
competitor_id: int,
db: AsyncSession = Depends(get_db),
):
"""删除竞品机构"""
result = await db.execute(
select(Competitor).where(Competitor.id == competitor_id)
)
competitor = result.scalar_one_or_none()
if not competitor:
raise HTTPException(
status_code=404,
detail={"code": ErrorCode.NOT_FOUND, "message": "竞品机构不存在"}
)
await db.delete(competitor)
return ResponseModel(message="删除成功")
# ============ 竞品价格管理 ============
@router.get("/competitors/{competitor_id}/prices", response_model=ResponseModel[list[CompetitorPriceResponse]])
async def get_competitor_prices(
competitor_id: int,
project_id: Optional[int] = Query(None, description="项目筛选"),
db: AsyncSession = Depends(get_db),
):
"""获取竞品价格列表"""
# 检查竞品机构是否存在
competitor_result = await db.execute(
select(Competitor).where(Competitor.id == competitor_id)
)
competitor = competitor_result.scalar_one_or_none()
if not competitor:
raise HTTPException(
status_code=404,
detail={"code": ErrorCode.NOT_FOUND, "message": "竞品机构不存在"}
)
query = select(CompetitorPrice).where(
CompetitorPrice.competitor_id == competitor_id
)
if project_id:
query = query.where(CompetitorPrice.project_id == project_id)
query = query.order_by(CompetitorPrice.collected_at.desc())
result = await db.execute(query)
prices = result.scalars().all()
response_items = []
for p in prices:
response_items.append(CompetitorPriceResponse(
id=p.id,
competitor_id=p.competitor_id,
competitor_name=competitor.competitor_name,
project_id=p.project_id,
project_name=p.project_name,
original_price=float(p.original_price),
promo_price=float(p.promo_price) if p.promo_price else None,
member_price=float(p.member_price) if p.member_price else None,
price_source=p.price_source,
collected_at=p.collected_at,
remark=p.remark,
created_at=p.created_at,
updated_at=p.updated_at,
))
return ResponseModel(data=response_items)
@router.post("/competitors/{competitor_id}/prices", response_model=ResponseModel[CompetitorPriceResponse])
async def create_competitor_price(
competitor_id: int,
data: CompetitorPriceCreate,
db: AsyncSession = Depends(get_db),
):
"""添加竞品价格"""
# 检查竞品机构是否存在
competitor_result = await db.execute(
select(Competitor).where(Competitor.id == competitor_id)
)
competitor = competitor_result.scalar_one_or_none()
if not competitor:
raise HTTPException(
status_code=404,
detail={"code": ErrorCode.NOT_FOUND, "message": "竞品机构不存在"}
)
# 检查关联项目是否存在
if data.project_id:
project_result = await db.execute(
select(Project).where(Project.id == data.project_id)
)
if not project_result.scalar_one_or_none():
raise HTTPException(
status_code=400,
detail={"code": ErrorCode.NOT_FOUND, "message": "关联项目不存在"}
)
price = CompetitorPrice(
competitor_id=competitor_id,
project_id=data.project_id,
project_name=data.project_name,
original_price=data.original_price,
promo_price=data.promo_price,
member_price=data.member_price,
price_source=data.price_source.value,
collected_at=data.collected_at,
remark=data.remark,
)
db.add(price)
await db.flush()
await db.refresh(price)
return ResponseModel(
message="添加成功",
data=CompetitorPriceResponse(
id=price.id,
competitor_id=price.competitor_id,
competitor_name=competitor.competitor_name,
project_id=price.project_id,
project_name=price.project_name,
original_price=float(price.original_price),
promo_price=float(price.promo_price) if price.promo_price else None,
member_price=float(price.member_price) if price.member_price else None,
price_source=price.price_source,
collected_at=price.collected_at,
remark=price.remark,
created_at=price.created_at,
updated_at=price.updated_at,
)
)
@router.put("/competitor-prices/{price_id}", response_model=ResponseModel[CompetitorPriceResponse])
async def update_competitor_price(
price_id: int,
data: CompetitorPriceUpdate,
db: AsyncSession = Depends(get_db),
):
"""更新竞品价格"""
result = await db.execute(
select(CompetitorPrice).options(
selectinload(CompetitorPrice.competitor)
).where(CompetitorPrice.id == price_id)
)
price = result.scalar_one_or_none()
if not price:
raise HTTPException(
status_code=404,
detail={"code": ErrorCode.NOT_FOUND, "message": "竞品价格不存在"}
)
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
if field == "price_source" and value:
value = value.value
setattr(price, field, value)
await db.flush()
await db.refresh(price)
return ResponseModel(
message="更新成功",
data=CompetitorPriceResponse(
id=price.id,
competitor_id=price.competitor_id,
competitor_name=price.competitor.competitor_name if price.competitor else None,
project_id=price.project_id,
project_name=price.project_name,
original_price=float(price.original_price),
promo_price=float(price.promo_price) if price.promo_price else None,
member_price=float(price.member_price) if price.member_price else None,
price_source=price.price_source,
collected_at=price.collected_at,
remark=price.remark,
created_at=price.created_at,
updated_at=price.updated_at,
)
)
@router.delete("/competitor-prices/{price_id}", response_model=ResponseModel)
async def delete_competitor_price(
price_id: int,
db: AsyncSession = Depends(get_db),
):
"""删除竞品价格"""
result = await db.execute(
select(CompetitorPrice).where(CompetitorPrice.id == price_id)
)
price = result.scalar_one_or_none()
if not price:
raise HTTPException(
status_code=404,
detail={"code": ErrorCode.NOT_FOUND, "message": "竞品价格不存在"}
)
await db.delete(price)
return ResponseModel(message="删除成功")
# ============ 标杆价格管理 ============
@router.get("/benchmark-prices", response_model=ResponseModel[PaginatedData[BenchmarkPriceResponse]])
async def get_benchmark_prices(
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
category_id: Optional[int] = Query(None, description="分类筛选"),
db: AsyncSession = Depends(get_db),
):
"""获取标杆价格列表"""
query = select(BenchmarkPrice).options(selectinload(BenchmarkPrice.category))
if category_id:
query = query.where(BenchmarkPrice.category_id == category_id)
query = query.order_by(BenchmarkPrice.effective_date.desc())
# 分页
offset = (page - 1) * page_size
query = query.offset(offset).limit(page_size)
result = await db.execute(query)
benchmarks = result.scalars().all()
# 统计总数
count_query = select(func.count(BenchmarkPrice.id))
if category_id:
count_query = count_query.where(BenchmarkPrice.category_id == category_id)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
items = []
for b in benchmarks:
items.append(BenchmarkPriceResponse(
id=b.id,
benchmark_name=b.benchmark_name,
category_id=b.category_id,
category_name=b.category.category_name if b.category else None,
min_price=float(b.min_price),
max_price=float(b.max_price),
avg_price=float(b.avg_price),
price_tier=b.price_tier,
effective_date=b.effective_date,
remark=b.remark,
created_at=b.created_at,
updated_at=b.updated_at,
))
return ResponseModel(
data=PaginatedData(
items=items,
total=total,
page=page,
page_size=page_size,
total_pages=(total + page_size - 1) // page_size,
)
)
@router.post("/benchmark-prices", response_model=ResponseModel[BenchmarkPriceResponse])
async def create_benchmark_price(
data: BenchmarkPriceCreate,
db: AsyncSession = Depends(get_db),
):
"""创建标杆价格"""
# 检查分类是否存在
category_name = None
if data.category_id:
category_result = await db.execute(
select(Category).where(Category.id == data.category_id)
)
category = category_result.scalar_one_or_none()
if not category:
raise HTTPException(
status_code=400,
detail={"code": ErrorCode.NOT_FOUND, "message": "分类不存在"}
)
category_name = category.category_name
benchmark = BenchmarkPrice(
benchmark_name=data.benchmark_name,
category_id=data.category_id,
min_price=data.min_price,
max_price=data.max_price,
avg_price=data.avg_price,
price_tier=data.price_tier.value,
effective_date=data.effective_date,
remark=data.remark,
)
db.add(benchmark)
await db.flush()
await db.refresh(benchmark)
return ResponseModel(
message="创建成功",
data=BenchmarkPriceResponse(
id=benchmark.id,
benchmark_name=benchmark.benchmark_name,
category_id=benchmark.category_id,
category_name=category_name,
min_price=float(benchmark.min_price),
max_price=float(benchmark.max_price),
avg_price=float(benchmark.avg_price),
price_tier=benchmark.price_tier,
effective_date=benchmark.effective_date,
remark=benchmark.remark,
created_at=benchmark.created_at,
updated_at=benchmark.updated_at,
)
)
@router.put("/benchmark-prices/{benchmark_id}", response_model=ResponseModel[BenchmarkPriceResponse])
async def update_benchmark_price(
benchmark_id: int,
data: BenchmarkPriceUpdate,
db: AsyncSession = Depends(get_db),
):
"""更新标杆价格"""
result = await db.execute(
select(BenchmarkPrice).options(
selectinload(BenchmarkPrice.category)
).where(BenchmarkPrice.id == benchmark_id)
)
benchmark = result.scalar_one_or_none()
if not benchmark:
raise HTTPException(
status_code=404,
detail={"code": ErrorCode.NOT_FOUND, "message": "标杆价格不存在"}
)
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
if field == "price_tier" and value:
value = value.value
setattr(benchmark, field, value)
await db.flush()
await db.refresh(benchmark)
# 获取分类名称
category_name = None
if benchmark.category_id:
cat_result = await db.execute(
select(Category).where(Category.id == benchmark.category_id)
)
category = cat_result.scalar_one_or_none()
if category:
category_name = category.category_name
return ResponseModel(
message="更新成功",
data=BenchmarkPriceResponse(
id=benchmark.id,
benchmark_name=benchmark.benchmark_name,
category_id=benchmark.category_id,
category_name=category_name,
min_price=float(benchmark.min_price),
max_price=float(benchmark.max_price),
avg_price=float(benchmark.avg_price),
price_tier=benchmark.price_tier,
effective_date=benchmark.effective_date,
remark=benchmark.remark,
created_at=benchmark.created_at,
updated_at=benchmark.updated_at,
)
)
@router.delete("/benchmark-prices/{benchmark_id}", response_model=ResponseModel)
async def delete_benchmark_price(
benchmark_id: int,
db: AsyncSession = Depends(get_db),
):
"""删除标杆价格"""
result = await db.execute(
select(BenchmarkPrice).where(BenchmarkPrice.id == benchmark_id)
)
benchmark = result.scalar_one_or_none()
if not benchmark:
raise HTTPException(
status_code=404,
detail={"code": ErrorCode.NOT_FOUND, "message": "标杆价格不存在"}
)
await db.delete(benchmark)
return ResponseModel(message="删除成功")
# ============ 市场分析 ============
@router.post("/projects/{project_id}/market-analysis", response_model=ResponseModel[MarketAnalysisResultSchema])
async def analyze_market(
project_id: int,
data: MarketAnalysisRequest = MarketAnalysisRequest(),
db: AsyncSession = Depends(get_db),
):
"""执行市场价格分析"""
market_service = MarketService(db)
try:
result = await market_service.analyze_market(
project_id=project_id,
competitor_ids=data.competitor_ids,
include_benchmark=data.include_benchmark,
)
except ValueError as e:
raise HTTPException(
status_code=404,
detail={"code": ErrorCode.NOT_FOUND, "message": str(e)}
)
return ResponseModel(message="分析完成", data=result)
@router.get("/projects/{project_id}/market-analysis", response_model=ResponseModel[MarketAnalysisResponse])
async def get_market_analysis(
project_id: int,
db: AsyncSession = Depends(get_db),
):
"""获取最新市场分析结果"""
# 检查项目是否存在
project_result = await db.execute(
select(Project).where(Project.id == project_id)
)
if not project_result.scalar_one_or_none():
raise HTTPException(
status_code=404,
detail={"code": ErrorCode.NOT_FOUND, "message": "项目不存在"}
)
market_service = MarketService(db)
result = await market_service.get_latest_analysis(project_id)
if not result:
raise HTTPException(
status_code=404,
detail={"code": ErrorCode.NOT_FOUND, "message": "暂无分析结果,请先执行市场分析"}
)
return ResponseModel(
data=MarketAnalysisResponse(
id=result.id,
project_id=result.project_id,
analysis_date=result.analysis_date,
competitor_count=result.competitor_count,
market_min_price=float(result.market_min_price),
market_max_price=float(result.market_max_price),
market_avg_price=float(result.market_avg_price),
market_median_price=float(result.market_median_price),
suggested_range_min=float(result.suggested_range_min),
suggested_range_max=float(result.suggested_range_max),
created_at=result.created_at,
)
)