Files
zpark-ems/core/backend/app/api/v1/charging.py

717 lines
27 KiB
Python
Raw Normal View History

from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_
from pydantic import BaseModel
from app.core.database import get_db
from app.core.deps import get_current_user, require_roles
from app.models.charging import (
ChargingStation, ChargingPile, ChargingPriceStrategy, ChargingPriceParam,
ChargingOrder, OccupancyOrder, ChargingBrand, ChargingMerchant,
)
from app.models.user import User
from app.services.audit import log_audit
router = APIRouter(prefix="/charging", tags=["充电管理"])
# ─── Pydantic Schemas ───────────────────────────────────────────────
class StationCreate(BaseModel):
name: str
merchant_id: int | None = None
type: str | None = None
address: str | None = None
latitude: float | None = None
longitude: float | None = None
price: float | None = None
activity: str | None = None
status: str = "active"
total_piles: int = 0
available_piles: int = 0
total_power_kw: float = 0
photo_url: str | None = None
operating_hours: str | None = None
class StationUpdate(BaseModel):
name: str | None = None
merchant_id: int | None = None
type: str | None = None
address: str | None = None
latitude: float | None = None
longitude: float | None = None
price: float | None = None
activity: str | None = None
status: str | None = None
total_piles: int | None = None
available_piles: int | None = None
total_power_kw: float | None = None
photo_url: str | None = None
operating_hours: str | None = None
class PileCreate(BaseModel):
station_id: int
encoding: str
name: str | None = None
type: str | None = None
brand: str | None = None
model: str | None = None
rated_power_kw: float | None = None
connector_type: str | None = None
status: str = "active"
work_status: str = "offline"
class PileUpdate(BaseModel):
station_id: int | None = None
encoding: str | None = None
name: str | None = None
type: str | None = None
brand: str | None = None
model: str | None = None
rated_power_kw: float | None = None
connector_type: str | None = None
status: str | None = None
work_status: str | None = None
class PriceParamCreate(BaseModel):
start_time: str
end_time: str
period_mark: str | None = None
elec_price: float
service_price: float = 0
class PriceStrategyCreate(BaseModel):
strategy_name: str
station_id: int | None = None
bill_model: str | None = None
description: str | None = None
status: str = "inactive"
params: list[PriceParamCreate] = []
class PriceStrategyUpdate(BaseModel):
strategy_name: str | None = None
station_id: int | None = None
bill_model: str | None = None
description: str | None = None
status: str | None = None
params: list[PriceParamCreate] | None = None
class MerchantCreate(BaseModel):
name: str
contact_person: str | None = None
phone: str | None = None
email: str | None = None
address: str | None = None
business_license: str | None = None
status: str = "active"
settlement_type: str | None = None
class BrandCreate(BaseModel):
brand_name: str
logo_url: str | None = None
country: str | None = None
description: str | None = None
# ─── Station Endpoints ───────────────────────────────────────────────
@router.get("/stations")
async def list_stations(
status: str | None = None,
type: str | None = None,
merchant_id: int | None = None,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
query = select(ChargingStation)
if status:
query = query.where(ChargingStation.status == status)
if type:
query = query.where(ChargingStation.type == type)
if merchant_id:
query = query.where(ChargingStation.merchant_id == merchant_id)
count_q = select(func.count()).select_from(query.subquery())
total = (await db.execute(count_q)).scalar()
query = query.order_by(ChargingStation.id.desc()).offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
stations = result.scalars().all()
return {"total": total, "items": [_station_to_dict(s) for s in stations]}
@router.post("/stations")
async def create_station(
data: StationCreate,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_roles("admin", "energy_manager")),
):
station = ChargingStation(**data.model_dump(), created_by=user.id)
db.add(station)
await db.flush()
await log_audit(db, user.id, "create", "charging", detail=f"创建充电站 {data.name}")
return _station_to_dict(station)
@router.put("/stations/{station_id}")
async def update_station(
station_id: int,
data: StationUpdate,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_roles("admin", "energy_manager")),
):
result = await db.execute(select(ChargingStation).where(ChargingStation.id == station_id))
station = result.scalar_one_or_none()
if not station:
raise HTTPException(status_code=404, detail="充电站不存在")
updates = data.model_dump(exclude_unset=True)
for k, v in updates.items():
setattr(station, k, v)
await log_audit(db, user.id, "update", "charging", detail=f"更新充电站 {station.name}")
return _station_to_dict(station)
@router.delete("/stations/{station_id}")
async def delete_station(
station_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_roles("admin", "energy_manager")),
):
result = await db.execute(select(ChargingStation).where(ChargingStation.id == station_id))
station = result.scalar_one_or_none()
if not station:
raise HTTPException(status_code=404, detail="充电站不存在")
station.status = "disabled"
await log_audit(db, user.id, "delete", "charging", detail=f"禁用充电站 {station.name}")
return {"message": "已禁用"}
@router.get("/stations/{station_id}/piles")
async def list_station_piles(
station_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
result = await db.execute(
select(ChargingPile).where(ChargingPile.station_id == station_id).order_by(ChargingPile.id)
)
return [_pile_to_dict(p) for p in result.scalars().all()]
# ─── Pile Endpoints ──────────────────────────────────────────────────
@router.get("/piles")
async def list_piles(
station_id: int | None = None,
status: str | None = None,
work_status: str | None = None,
type: str | None = None,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
query = select(ChargingPile)
if station_id:
query = query.where(ChargingPile.station_id == station_id)
if status:
query = query.where(ChargingPile.status == status)
if work_status:
query = query.where(ChargingPile.work_status == work_status)
if type:
query = query.where(ChargingPile.type == type)
count_q = select(func.count()).select_from(query.subquery())
total = (await db.execute(count_q)).scalar()
query = query.order_by(ChargingPile.id.desc()).offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
piles = result.scalars().all()
return {"total": total, "items": [_pile_to_dict(p) for p in piles]}
@router.post("/piles")
async def create_pile(
data: PileCreate,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_roles("admin", "energy_manager")),
):
pile = ChargingPile(**data.model_dump())
db.add(pile)
await db.flush()
await log_audit(db, user.id, "create", "charging", detail=f"创建充电桩 {data.encoding}")
return _pile_to_dict(pile)
@router.put("/piles/{pile_id}")
async def update_pile(
pile_id: int,
data: PileUpdate,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_roles("admin", "energy_manager")),
):
result = await db.execute(select(ChargingPile).where(ChargingPile.id == pile_id))
pile = result.scalar_one_or_none()
if not pile:
raise HTTPException(status_code=404, detail="充电桩不存在")
updates = data.model_dump(exclude_unset=True)
for k, v in updates.items():
setattr(pile, k, v)
await log_audit(db, user.id, "update", "charging", detail=f"更新充电桩 {pile.encoding}")
return _pile_to_dict(pile)
@router.delete("/piles/{pile_id}")
async def delete_pile(
pile_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_roles("admin", "energy_manager")),
):
result = await db.execute(select(ChargingPile).where(ChargingPile.id == pile_id))
pile = result.scalar_one_or_none()
if not pile:
raise HTTPException(status_code=404, detail="充电桩不存在")
pile.status = "disabled"
await log_audit(db, user.id, "delete", "charging", detail=f"禁用充电桩 {pile.encoding}")
return {"message": "已禁用"}
# ─── Pricing Endpoints ───────────────────────────────────────────────
@router.get("/pricing")
async def list_pricing(
station_id: int | None = None,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
query = select(ChargingPriceStrategy)
if station_id:
query = query.where(ChargingPriceStrategy.station_id == station_id)
result = await db.execute(query.order_by(ChargingPriceStrategy.id.desc()))
strategies = result.scalars().all()
items = []
for s in strategies:
params_q = await db.execute(
select(ChargingPriceParam).where(ChargingPriceParam.strategy_id == s.id).order_by(ChargingPriceParam.start_time)
)
params = [_param_to_dict(p) for p in params_q.scalars().all()]
d = _strategy_to_dict(s)
d["params"] = params
items.append(d)
return items
@router.post("/pricing")
async def create_pricing(
data: PriceStrategyCreate,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_roles("admin", "energy_manager")),
):
strategy = ChargingPriceStrategy(
strategy_name=data.strategy_name,
station_id=data.station_id,
bill_model=data.bill_model,
description=data.description,
status=data.status,
)
db.add(strategy)
await db.flush()
for p in data.params:
param = ChargingPriceParam(strategy_id=strategy.id, **p.model_dump())
db.add(param)
await db.flush()
await log_audit(db, user.id, "create", "charging", detail=f"创建计费策略 {data.strategy_name}")
return _strategy_to_dict(strategy)
@router.put("/pricing/{strategy_id}")
async def update_pricing(
strategy_id: int,
data: PriceStrategyUpdate,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_roles("admin", "energy_manager")),
):
result = await db.execute(select(ChargingPriceStrategy).where(ChargingPriceStrategy.id == strategy_id))
strategy = result.scalar_one_or_none()
if not strategy:
raise HTTPException(status_code=404, detail="计费策略不存在")
updates = data.model_dump(exclude_unset=True, exclude={"params"})
for k, v in updates.items():
setattr(strategy, k, v)
if data.params is not None:
# Delete old params and recreate
old_params = await db.execute(
select(ChargingPriceParam).where(ChargingPriceParam.strategy_id == strategy_id)
)
for old in old_params.scalars().all():
await db.delete(old)
await db.flush()
for p in data.params:
param = ChargingPriceParam(strategy_id=strategy_id, **p.model_dump())
db.add(param)
await db.flush()
await log_audit(db, user.id, "update", "charging", detail=f"更新计费策略 {strategy.strategy_name}")
return _strategy_to_dict(strategy)
@router.delete("/pricing/{strategy_id}")
async def delete_pricing(
strategy_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_roles("admin", "energy_manager")),
):
result = await db.execute(select(ChargingPriceStrategy).where(ChargingPriceStrategy.id == strategy_id))
strategy = result.scalar_one_or_none()
if not strategy:
raise HTTPException(status_code=404, detail="计费策略不存在")
strategy.status = "inactive"
await log_audit(db, user.id, "delete", "charging", detail=f"停用计费策略 {strategy.strategy_name}")
return {"message": "已停用"}
# ─── Order Endpoints ─────────────────────────────────────────────────
@router.get("/orders")
async def list_orders(
order_status: str | None = None,
station_id: int | None = None,
start_date: str | None = None,
end_date: str | None = None,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
query = select(ChargingOrder)
if order_status:
query = query.where(ChargingOrder.order_status == order_status)
if station_id:
query = query.where(ChargingOrder.station_id == station_id)
if start_date:
query = query.where(ChargingOrder.created_at >= start_date)
if end_date:
query = query.where(ChargingOrder.created_at <= end_date)
count_q = select(func.count()).select_from(query.subquery())
total = (await db.execute(count_q)).scalar()
query = query.order_by(ChargingOrder.created_at.desc()).offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
orders = result.scalars().all()
return {"total": total, "items": [_order_to_dict(o) for o in orders]}
@router.get("/orders/realtime")
async def realtime_orders(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
result = await db.execute(
select(ChargingOrder).where(ChargingOrder.order_status == "charging").order_by(ChargingOrder.start_time.desc())
)
return [_order_to_dict(o) for o in result.scalars().all()]
@router.get("/orders/abnormal")
async def abnormal_orders(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
query = select(ChargingOrder).where(ChargingOrder.order_status.in_(["failed", "refunded"]))
count_q = select(func.count()).select_from(query.subquery())
total = (await db.execute(count_q)).scalar()
query = query.order_by(ChargingOrder.created_at.desc()).offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
return {"total": total, "items": [_order_to_dict(o) for o in result.scalars().all()]}
@router.get("/orders/{order_id}")
async def get_order(
order_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
result = await db.execute(select(ChargingOrder).where(ChargingOrder.id == order_id))
order = result.scalar_one_or_none()
if not order:
raise HTTPException(status_code=404, detail="订单不存在")
return _order_to_dict(order)
@router.post("/orders/{order_id}/settle")
async def settle_order(
order_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_roles("admin", "energy_manager")),
):
result = await db.execute(select(ChargingOrder).where(ChargingOrder.id == order_id))
order = result.scalar_one_or_none()
if not order:
raise HTTPException(status_code=404, detail="订单不存在")
order.settle_type = "manual"
order.settle_time = datetime.now(timezone.utc)
order.order_status = "completed"
await log_audit(db, user.id, "update", "charging", detail=f"手动结算订单 {order.order_no}")
return {"message": "已结算"}
# ─── Dashboard ───────────────────────────────────────────────────────
@router.get("/dashboard")
async def charging_dashboard(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
now = datetime.now(timezone.utc)
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
# Total revenue (completed orders)
rev_q = await db.execute(
select(func.sum(ChargingOrder.paid_price)).where(ChargingOrder.order_status == "completed")
)
total_revenue = rev_q.scalar() or 0
# Total energy delivered
energy_q = await db.execute(
select(func.sum(ChargingOrder.energy)).where(ChargingOrder.order_status == "completed")
)
total_energy = energy_q.scalar() or 0
# Active sessions
active_q = await db.execute(
select(func.count(ChargingOrder.id)).where(ChargingOrder.order_status == "charging")
)
active_sessions = active_q.scalar() or 0
# Utilization rate: charging piles / total active piles
total_piles_q = await db.execute(
select(func.count(ChargingPile.id)).where(ChargingPile.status == "active")
)
total_piles = total_piles_q.scalar() or 0
charging_piles_q = await db.execute(
select(func.count(ChargingPile.id)).where(ChargingPile.work_status == "charging")
)
charging_piles = charging_piles_q.scalar() or 0
utilization_rate = round(charging_piles / total_piles * 100, 1) if total_piles > 0 else 0
# Revenue trend (last 30 days)
thirty_days_ago = now - timedelta(days=30)
trend_q = await db.execute(
select(
func.date(ChargingOrder.created_at).label("date"),
func.sum(ChargingOrder.paid_price).label("revenue"),
func.sum(ChargingOrder.energy).label("energy"),
).where(
and_(ChargingOrder.order_status == "completed", ChargingOrder.created_at >= thirty_days_ago)
).group_by(func.date(ChargingOrder.created_at)).order_by(func.date(ChargingOrder.created_at))
)
revenue_trend = [{"date": str(r[0]), "revenue": round(r[1] or 0, 2), "energy": round(r[2] or 0, 2)} for r in trend_q.all()]
# Station ranking by revenue
ranking_q = await db.execute(
select(
ChargingOrder.station_name,
func.sum(ChargingOrder.paid_price).label("revenue"),
func.count(ChargingOrder.id).label("orders"),
).where(ChargingOrder.order_status == "completed")
.group_by(ChargingOrder.station_name)
.order_by(func.sum(ChargingOrder.paid_price).desc())
.limit(10)
)
station_ranking = [{"station": r[0] or "未知", "revenue": round(r[1] or 0, 2), "orders": r[2]} for r in ranking_q.all()]
# Pile status distribution
pile_status_q = await db.execute(
select(ChargingPile.work_status, func.count(ChargingPile.id))
.where(ChargingPile.status == "active")
.group_by(ChargingPile.work_status)
)
pile_status = {row[0]: row[1] for row in pile_status_q.all()}
return {
"total_revenue": round(total_revenue, 2),
"total_energy": round(total_energy, 2),
"active_sessions": active_sessions,
"utilization_rate": utilization_rate,
"revenue_trend": revenue_trend,
"station_ranking": station_ranking,
"pile_status": {
"idle": pile_status.get("idle", 0),
"charging": pile_status.get("charging", 0),
"fault": pile_status.get("fault", 0),
"offline": pile_status.get("offline", 0),
},
}
# ─── Merchant CRUD ───────────────────────────────────────────────────
@router.get("/merchants")
async def list_merchants(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
result = await db.execute(select(ChargingMerchant).order_by(ChargingMerchant.id.desc()))
return [_merchant_to_dict(m) for m in result.scalars().all()]
@router.post("/merchants")
async def create_merchant(data: MerchantCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_roles("admin", "energy_manager"))):
merchant = ChargingMerchant(**data.model_dump())
db.add(merchant)
await db.flush()
return _merchant_to_dict(merchant)
@router.put("/merchants/{merchant_id}")
async def update_merchant(merchant_id: int, data: MerchantCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_roles("admin", "energy_manager"))):
result = await db.execute(select(ChargingMerchant).where(ChargingMerchant.id == merchant_id))
merchant = result.scalar_one_or_none()
if not merchant:
raise HTTPException(status_code=404, detail="运营商不存在")
for k, v in data.model_dump(exclude_unset=True).items():
setattr(merchant, k, v)
return _merchant_to_dict(merchant)
@router.delete("/merchants/{merchant_id}")
async def delete_merchant(merchant_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(require_roles("admin", "energy_manager"))):
result = await db.execute(select(ChargingMerchant).where(ChargingMerchant.id == merchant_id))
merchant = result.scalar_one_or_none()
if not merchant:
raise HTTPException(status_code=404, detail="运营商不存在")
merchant.status = "disabled"
return {"message": "已禁用"}
# ─── Brand CRUD ──────────────────────────────────────────────────────
@router.get("/brands")
async def list_brands(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
result = await db.execute(select(ChargingBrand).order_by(ChargingBrand.id.desc()))
return [_brand_to_dict(b) for b in result.scalars().all()]
@router.post("/brands")
async def create_brand(data: BrandCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_roles("admin", "energy_manager"))):
brand = ChargingBrand(**data.model_dump())
db.add(brand)
await db.flush()
return _brand_to_dict(brand)
@router.put("/brands/{brand_id}")
async def update_brand(brand_id: int, data: BrandCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_roles("admin", "energy_manager"))):
result = await db.execute(select(ChargingBrand).where(ChargingBrand.id == brand_id))
brand = result.scalar_one_or_none()
if not brand:
raise HTTPException(status_code=404, detail="品牌不存在")
for k, v in data.model_dump(exclude_unset=True).items():
setattr(brand, k, v)
return _brand_to_dict(brand)
@router.delete("/brands/{brand_id}")
async def delete_brand(brand_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(require_roles("admin", "energy_manager"))):
result = await db.execute(select(ChargingBrand).where(ChargingBrand.id == brand_id))
brand = result.scalar_one_or_none()
if not brand:
raise HTTPException(status_code=404, detail="品牌不存在")
await db.delete(brand)
return {"message": "已删除"}
# ─── Dict Helpers ────────────────────────────────────────────────────
def _station_to_dict(s: ChargingStation) -> dict:
return {
"id": s.id, "name": s.name, "merchant_id": s.merchant_id, "type": s.type,
"address": s.address, "latitude": s.latitude, "longitude": s.longitude,
"price": s.price, "activity": s.activity, "status": s.status,
"total_piles": s.total_piles, "available_piles": s.available_piles,
"total_power_kw": s.total_power_kw, "photo_url": s.photo_url,
"operating_hours": s.operating_hours, "created_by": s.created_by,
"created_at": str(s.created_at) if s.created_at else None,
}
def _pile_to_dict(p: ChargingPile) -> dict:
return {
"id": p.id, "station_id": p.station_id, "encoding": p.encoding,
"name": p.name, "type": p.type, "brand": p.brand, "model": p.model,
"rated_power_kw": p.rated_power_kw, "connector_type": p.connector_type,
"status": p.status, "work_status": p.work_status,
"created_at": str(p.created_at) if p.created_at else None,
}
def _strategy_to_dict(s: ChargingPriceStrategy) -> dict:
return {
"id": s.id, "strategy_name": s.strategy_name, "station_id": s.station_id,
"bill_model": s.bill_model, "description": s.description, "status": s.status,
"created_at": str(s.created_at) if s.created_at else None,
}
def _param_to_dict(p: ChargingPriceParam) -> dict:
return {
"id": p.id, "strategy_id": p.strategy_id, "start_time": p.start_time,
"end_time": p.end_time, "period_mark": p.period_mark,
"elec_price": p.elec_price, "service_price": p.service_price,
}
def _order_to_dict(o: ChargingOrder) -> dict:
return {
"id": o.id, "order_no": o.order_no, "user_id": o.user_id,
"user_name": o.user_name, "phone": o.phone,
"station_id": o.station_id, "station_name": o.station_name,
"pile_id": o.pile_id, "pile_name": o.pile_name,
"start_time": str(o.start_time) if o.start_time else None,
"end_time": str(o.end_time) if o.end_time else None,
"car_no": o.car_no, "car_vin": o.car_vin,
"charge_method": o.charge_method, "settle_type": o.settle_type,
"pay_type": o.pay_type,
"settle_time": str(o.settle_time) if o.settle_time else None,
"settle_price": o.settle_price, "paid_price": o.paid_price,
"discount_amt": o.discount_amt, "elec_amt": o.elec_amt,
"serve_amt": o.serve_amt, "order_status": o.order_status,
"charge_duration": o.charge_duration, "energy": o.energy,
"start_soc": o.start_soc, "end_soc": o.end_soc,
"abno_cause": o.abno_cause, "order_source": o.order_source,
"created_at": str(o.created_at) if o.created_at else None,
}
def _merchant_to_dict(m: ChargingMerchant) -> dict:
return {
"id": m.id, "name": m.name, "contact_person": m.contact_person,
"phone": m.phone, "email": m.email, "address": m.address,
"business_license": m.business_license, "status": m.status,
"settlement_type": m.settlement_type,
}
def _brand_to_dict(b: ChargingBrand) -> dict:
return {
"id": b.id, "brand_name": b.brand_name, "logo_url": b.logo_url,
"country": b.country, "description": b.description,
}