feat: v2.0 — maintenance module, AI analysis, station power fix
- Add full 检修维护中心 (6.4): 3-type work orders (消缺/巡检/抄表), asset management, warehouse, work plans, billing settlement - Add AI智能分析 tab with LLM-powered diagnostics (StepFun + ZhipuAI) - Add AI模型配置 settings page (provider, temperature, prompts) - Fix station power accuracy: use API station total (station_power) instead of inverter-level computation — eliminates timing gaps - Add 7 new DB models, 4 new API routers, 5 new frontend pages - Migrations: 009 (maintenance expansion) + 010 (AI analysis) - Version bump: 1.6.1 → 2.0.0 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
288
core/backend/app/api/v1/assets.py
Normal file
288
core/backend/app/api/v1/assets.py
Normal file
@@ -0,0 +1,288 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from pydantic import BaseModel
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user, require_roles
|
||||
from app.models.maintenance import Asset, AssetCategory, AssetChange
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/assets", tags=["资产管理"])
|
||||
|
||||
|
||||
# ── Pydantic Schemas ────────────────────────────────────────────────
|
||||
|
||||
class AssetCreate(BaseModel):
|
||||
name: str
|
||||
code: str | None = None
|
||||
category_id: int | None = None
|
||||
station_name: str | None = None
|
||||
location: str | None = None
|
||||
manufacturer: str | None = None
|
||||
model: str | None = None
|
||||
serial_number: str | None = None
|
||||
rated_power: float | None = None
|
||||
install_date: str | None = None
|
||||
warranty_until: str | None = None
|
||||
status: str = "active"
|
||||
specs: dict | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class AssetUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
code: str | None = None
|
||||
category_id: int | None = None
|
||||
station_name: str | None = None
|
||||
location: str | None = None
|
||||
manufacturer: str | None = None
|
||||
model: str | None = None
|
||||
serial_number: str | None = None
|
||||
rated_power: float | None = None
|
||||
install_date: str | None = None
|
||||
warranty_until: str | None = None
|
||||
status: str | None = None
|
||||
specs: dict | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class CategoryCreate(BaseModel):
|
||||
name: str
|
||||
description: str | None = None
|
||||
parent_id: int | None = None
|
||||
|
||||
|
||||
class ChangeCreate(BaseModel):
|
||||
asset_id: int
|
||||
change_type: str
|
||||
description: str | None = None
|
||||
changed_by: int | None = None
|
||||
change_date: str | None = None
|
||||
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
def _asset_to_dict(a: Asset) -> dict:
|
||||
return {
|
||||
"id": a.id, "name": a.name, "code": a.code,
|
||||
"category_id": a.category_id, "station_name": a.station_name,
|
||||
"location": a.location, "manufacturer": a.manufacturer,
|
||||
"model": a.model, "serial_number": a.serial_number,
|
||||
"rated_power": a.rated_power,
|
||||
"install_date": str(a.install_date) if a.install_date else None,
|
||||
"warranty_until": str(a.warranty_until) if a.warranty_until else None,
|
||||
"status": a.status, "specs": a.specs, "notes": a.notes,
|
||||
"created_at": str(a.created_at) if a.created_at else None,
|
||||
"updated_at": str(a.updated_at) if a.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _category_to_dict(c: AssetCategory) -> dict:
|
||||
return {
|
||||
"id": c.id, "name": c.name, "description": c.description,
|
||||
"parent_id": c.parent_id,
|
||||
"created_at": str(c.created_at) if c.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _change_to_dict(ch: AssetChange) -> dict:
|
||||
return {
|
||||
"id": ch.id, "asset_id": ch.asset_id, "change_type": ch.change_type,
|
||||
"description": ch.description, "changed_by": ch.changed_by,
|
||||
"change_date": str(ch.change_date) if ch.change_date else None,
|
||||
"created_at": str(ch.created_at) if ch.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
# ── Asset Categories ───────────────────────────────────────────────
|
||||
|
||||
@router.get("/categories")
|
||||
async def list_categories(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(select(AssetCategory).order_by(AssetCategory.id))
|
||||
return [_category_to_dict(c) for c in result.scalars().all()]
|
||||
|
||||
|
||||
@router.post("/categories")
|
||||
async def create_category(
|
||||
data: CategoryCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
cat = AssetCategory(**data.model_dump())
|
||||
db.add(cat)
|
||||
await db.flush()
|
||||
return _category_to_dict(cat)
|
||||
|
||||
|
||||
# ── Asset Statistics ───────────────────────────────────────────────
|
||||
|
||||
@router.get("/stats")
|
||||
async def asset_stats(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
# Count by status
|
||||
status_q = select(Asset.status, func.count()).group_by(Asset.status)
|
||||
status_result = await db.execute(status_q)
|
||||
by_status = {row[0]: row[1] for row in status_result.all()}
|
||||
|
||||
# Count by category
|
||||
cat_q = select(Asset.category_id, func.count()).group_by(Asset.category_id)
|
||||
cat_result = await db.execute(cat_q)
|
||||
by_category = {str(row[0]): row[1] for row in cat_result.all()}
|
||||
|
||||
total = sum(by_status.values())
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"by_status": by_status,
|
||||
"by_category": by_category,
|
||||
}
|
||||
|
||||
|
||||
# ── Asset Change Records ──────────────────────────────────────────
|
||||
|
||||
@router.get("/changes")
|
||||
async def list_changes(
|
||||
asset_id: int | None = None,
|
||||
change_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(AssetChange)
|
||||
if asset_id:
|
||||
query = query.where(AssetChange.asset_id == asset_id)
|
||||
if change_type:
|
||||
query = query.where(AssetChange.change_type == change_type)
|
||||
|
||||
count_q = select(func.count()).select_from(query.subquery())
|
||||
total = (await db.execute(count_q)).scalar()
|
||||
|
||||
query = query.order_by(AssetChange.id.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
return {
|
||||
"total": total,
|
||||
"items": [_change_to_dict(ch) for ch in result.scalars().all()],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/changes")
|
||||
async def create_change(
|
||||
data: ChangeCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
ch = AssetChange(**data.model_dump(exclude={"change_date"}))
|
||||
if data.change_date:
|
||||
ch.change_date = datetime.fromisoformat(data.change_date)
|
||||
else:
|
||||
ch.change_date = datetime.now(timezone.utc)
|
||||
if not ch.changed_by:
|
||||
ch.changed_by = user.id
|
||||
db.add(ch)
|
||||
await db.flush()
|
||||
return _change_to_dict(ch)
|
||||
|
||||
|
||||
# ── Assets CRUD ────────────────────────────────────────────────────
|
||||
|
||||
@router.get("")
|
||||
async def list_assets(
|
||||
station_name: str | None = None,
|
||||
category_id: int | None = None,
|
||||
status: str | None = None,
|
||||
search: 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(Asset)
|
||||
if station_name:
|
||||
query = query.where(Asset.station_name == station_name)
|
||||
if category_id:
|
||||
query = query.where(Asset.category_id == category_id)
|
||||
if status:
|
||||
query = query.where(Asset.status == status)
|
||||
if search:
|
||||
query = query.where(Asset.name.ilike(f"%{search}%"))
|
||||
|
||||
count_q = select(func.count()).select_from(query.subquery())
|
||||
total = (await db.execute(count_q)).scalar()
|
||||
|
||||
query = query.order_by(Asset.id.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
return {
|
||||
"total": total,
|
||||
"items": [_asset_to_dict(a) for a in result.scalars().all()],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{asset_id}")
|
||||
async def get_asset(
|
||||
asset_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(select(Asset).where(Asset.id == asset_id))
|
||||
asset = result.scalar_one_or_none()
|
||||
if not asset:
|
||||
raise HTTPException(status_code=404, detail="资产不存在")
|
||||
return _asset_to_dict(asset)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_asset(
|
||||
data: AssetCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
asset = Asset(**data.model_dump(exclude={"install_date", "warranty_until"}))
|
||||
if data.install_date:
|
||||
asset.install_date = datetime.fromisoformat(data.install_date)
|
||||
if data.warranty_until:
|
||||
asset.warranty_until = datetime.fromisoformat(data.warranty_until)
|
||||
db.add(asset)
|
||||
await db.flush()
|
||||
return _asset_to_dict(asset)
|
||||
|
||||
|
||||
@router.put("/{asset_id}")
|
||||
async def update_asset(
|
||||
asset_id: int,
|
||||
data: AssetUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
result = await db.execute(select(Asset).where(Asset.id == asset_id))
|
||||
asset = result.scalar_one_or_none()
|
||||
if not asset:
|
||||
raise HTTPException(status_code=404, detail="资产不存在")
|
||||
for k, v in data.model_dump(exclude_unset=True, exclude={"install_date", "warranty_until"}).items():
|
||||
setattr(asset, k, v)
|
||||
if data.install_date:
|
||||
asset.install_date = datetime.fromisoformat(data.install_date)
|
||||
if data.warranty_until:
|
||||
asset.warranty_until = datetime.fromisoformat(data.warranty_until)
|
||||
return _asset_to_dict(asset)
|
||||
|
||||
|
||||
@router.delete("/{asset_id}")
|
||||
async def delete_asset(
|
||||
asset_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
result = await db.execute(select(Asset).where(Asset.id == asset_id))
|
||||
asset = result.scalar_one_or_none()
|
||||
if not asset:
|
||||
raise HTTPException(status_code=404, detail="资产不存在")
|
||||
asset.status = "inactive"
|
||||
return {"message": "已删除"}
|
||||
Reference in New Issue
Block a user