[feat]:[FL-206][新增维度管理功能]

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
chengkai3
2026-06-28 11:01:16 +08:00
parent 31ee65f745
commit 6d52f24ef3
10 changed files with 1161 additions and 1 deletions
+2
View File
@@ -5,6 +5,7 @@ from .v1.admin_files import router as admin_files_router
from .v1.ai_chat import router as ai_chat_router
from .v1.atp_assets import router as atp_assets_router
from .v1.auth import router as auth_router
from .v1.dimensions import router as dimensions_router
from .v1.documents import router as documents_router
from .v1.elevation import router as elevation_router
from .v1.fault_recurrence import router as fault_recurrence_router
@@ -29,6 +30,7 @@ v1_router.include_router(admin_router)
v1_router.include_router(admin_files_router)
v1_router.include_router(ai_chat_router)
v1_router.include_router(atp_assets_router)
v1_router.include_router(dimensions_router)
v1_router.include_router(documents_router)
v1_router.include_router(task_monitor_router)
v1_router.include_router(scheduled_tasks_router)
+103
View File
@@ -0,0 +1,103 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from ...core.database import get_db
from ...core.dependencies import CurrentUser, require_any_permission, require_enabled_menu_route, require_permission
from ...schemas.dimension_item import (
DimensionItemCreateRequest,
DimensionItemListResponse,
DimensionItemSummary,
DimensionItemTreeNode,
DimensionItemUpdateRequest,
)
from ...services.dimension_item_service import (
create_dimension_item,
delete_dimension_item,
get_dimension_item_by_id,
get_dimension_tree,
list_dimension_items,
serialize_dimension_item,
update_dimension_item,
)
router = APIRouter(prefix="/dimensions", tags=["dimensions"], dependencies=[Depends(require_enabled_menu_route)])
@router.get("", response_model=DimensionItemListResponse)
def get_dimension_item_list(
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
keyword: str | None = Query(default=None),
dimension_type: str | None = Query(default=None),
enabled: bool | None = Query(default=None),
_: CurrentUser = Depends(require_any_permission("dimension.read", "dimension.manage")),
db: Session = Depends(get_db),
) -> DimensionItemListResponse:
return list_dimension_items(
db,
limit=limit,
offset=offset,
keyword=keyword,
dimension_type=dimension_type,
enabled=enabled,
)
@router.get("/tree", response_model=list[DimensionItemTreeNode])
def get_dimension_item_tree(
dimension_type: str | None = Query(default=None),
_: CurrentUser = Depends(require_any_permission("dimension.read", "dimension.manage")),
db: Session = Depends(get_db),
) -> list[DimensionItemTreeNode]:
return get_dimension_tree(db, dimension_type=dimension_type)
@router.post("", response_model=DimensionItemSummary)
def create_dimension_item_endpoint(
payload: DimensionItemCreateRequest,
current_user: CurrentUser = Depends(require_permission("dimension.manage")),
db: Session = Depends(get_db),
) -> DimensionItemSummary:
created = create_dimension_item(db, payload, actor=current_user.user)
if not created:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="维度项编码已存在或父节点不存在")
return created
@router.patch("/{item_id}", response_model=DimensionItemSummary)
def update_dimension_item_endpoint(
item_id: str,
payload: DimensionItemUpdateRequest,
current_user: CurrentUser = Depends(require_permission("dimension.manage")),
db: Session = Depends(get_db),
) -> DimensionItemSummary:
updated = update_dimension_item(db, item_id, payload, actor=current_user.user)
if not updated:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="维度项不存在或更新失败")
return updated
@router.delete("/{item_id}")
def delete_dimension_item_endpoint(
item_id: str,
_: CurrentUser = Depends(require_permission("dimension.manage")),
db: Session = Depends(get_db),
) -> dict[str, bool]:
deleted = delete_dimension_item(db, item_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="维度项不存在或存在子节点")
return {"success": True}
@router.get("/{item_id}", response_model=DimensionItemSummary)
def get_dimension_item_detail(
item_id: str,
_: CurrentUser = Depends(require_any_permission("dimension.read", "dimension.manage")),
db: Session = Depends(get_db),
) -> DimensionItemSummary:
item = get_dimension_item_by_id(db, item_id)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="维度项不存在")
return serialize_dimension_item(item)
+1
View File
@@ -560,6 +560,7 @@ def init_db() -> None:
atp_asset,
audit_log,
auth_session,
dimension_item,
elevation,
file_storage,
fl_analysis,
+2 -1
View File
@@ -4,13 +4,14 @@ Import all model modules during package initialization so SQLAlchemy can
resolve string-based relationships regardless of route/service import order.
"""
from . import ai_chat, atp_asset, audit_log, auth_session, document, elevation, file_storage, fl_analysis, lightning_event, lightning_sample, line, line_tower, menu, object_group, rbac, scheduled_task, system_message, system_param, tower_model, tower_profile, user, wine, worker_registry
from . import ai_chat, atp_asset, audit_log, auth_session, dimension_item, document, elevation, file_storage, fl_analysis, lightning_event, lightning_sample, line, line_tower, menu, object_group, rbac, scheduled_task, system_message, system_param, tower_model, tower_profile, user, wine, worker_registry
__all__ = [
"ai_chat",
"atp_asset",
"audit_log",
"auth_session",
"dimension_item",
"document",
"elevation",
"file_storage",
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
from datetime import datetime
from uuid import uuid4
from sqlalchemy import Boolean, DateTime, Index, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from ..core.database import Base
from .base import utcnow
class DimensionItem(Base):
__tablename__ = "dimension_item"
__table_args__ = (
Index("idx_dimension_item_type", "dimension_type"),
Index("idx_dimension_item_parent", "parent_id"),
Index("idx_dimension_item_code", "code"),
Index("idx_dimension_item_enabled", "is_enabled"),
Index("idx_dimension_item_sort", "sort_order"),
)
id: Mapped[str] = mapped_column(
String(32),
primary_key=True,
default=lambda: uuid4().hex,
)
dimension_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
code: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
parent_id: Mapped[str | None] = mapped_column(String(32), index=True)
description: Mapped[str | None] = mapped_column(Text)
is_enabled: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0, index=True)
create_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
create_user: Mapped[str | None] = mapped_column(String(64), index=True)
update_date: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=utcnow,
onupdate=utcnow,
)
update_user: Mapped[str | None] = mapped_column(String(64), index=True)
+60
View File
@@ -0,0 +1,60 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
class DimensionItemSummary(BaseModel):
id: str
dimension_type: str
code: str
name: str
parent_id: str | None = None
description: str | None = None
is_enabled: bool = True
sort_order: int = 0
create_date: datetime
create_user: str | None = None
update_date: datetime
update_user: str | None = None
class DimensionItemTreeNode(BaseModel):
id: str
dimension_type: str
code: str
name: str
parent_id: str | None = None
description: str | None = None
is_enabled: bool = True
sort_order: int = 0
create_date: datetime
create_user: str | None = None
update_date: datetime
update_user: str | None = None
children: list[DimensionItemTreeNode] = Field(default_factory=list)
class DimensionItemListResponse(BaseModel):
items: list[DimensionItemSummary]
total: int
class DimensionItemCreateRequest(BaseModel):
dimension_type: str = Field(min_length=1, max_length=64)
code: str = Field(min_length=1, max_length=128)
name: str = Field(min_length=1, max_length=255)
parent_id: str | None = None
description: str | None = Field(default=None, max_length=2000)
is_enabled: bool = True
sort_order: int = Field(default=0, ge=0, le=1_000_000)
class DimensionItemUpdateRequest(BaseModel):
code: str | None = Field(default=None, min_length=1, max_length=128)
name: str | None = Field(default=None, min_length=1, max_length=255)
parent_id: str | None = None
description: str | None = Field(default=None, max_length=2000)
is_enabled: bool | None = None
sort_order: int | None = Field(default=None, ge=0, le=1_000_000)
+225
View File
@@ -0,0 +1,225 @@
from __future__ import annotations
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from ..models.base import utcnow
from ..models.dimension_item import DimensionItem
from ..models.user import User
from ..schemas.dimension_item import (
DimensionItemCreateRequest,
DimensionItemListResponse,
DimensionItemSummary,
DimensionItemTreeNode,
DimensionItemUpdateRequest,
)
from .push_service import publish_topic
DIMENSION_ITEM_TOPIC = "admin.dimension-items"
def serialize_dimension_item(item: DimensionItem) -> DimensionItemSummary:
return DimensionItemSummary(
id=item.id,
dimension_type=item.dimension_type,
code=item.code,
name=item.name,
parent_id=item.parent_id,
description=item.description,
is_enabled=item.is_enabled,
sort_order=item.sort_order,
create_date=item.create_date,
create_user=item.create_user,
update_date=item.update_date,
update_user=item.update_user,
)
def list_dimension_items(
db: Session,
*,
limit: int,
offset: int,
keyword: str | None,
dimension_type: str | None,
enabled: bool | None,
) -> DimensionItemListResponse:
stmt = select(DimensionItem)
total_stmt = select(func.count()).select_from(DimensionItem)
if dimension_type:
stmt = stmt.where(DimensionItem.dimension_type == dimension_type)
total_stmt = total_stmt.where(DimensionItem.dimension_type == dimension_type)
normalized_keyword = (keyword or "").strip()
if normalized_keyword:
like = f"%{normalized_keyword}%"
predicate = or_(
DimensionItem.code.ilike(like),
DimensionItem.name.ilike(like),
)
stmt = stmt.where(predicate)
total_stmt = total_stmt.where(predicate)
if enabled is not None:
stmt = stmt.where(DimensionItem.is_enabled == enabled)
total_stmt = total_stmt.where(DimensionItem.is_enabled == enabled)
total = int(db.scalar(total_stmt) or 0)
stmt = stmt.order_by(DimensionItem.sort_order, DimensionItem.create_date.desc()).limit(limit).offset(offset)
items = list(db.scalars(stmt).all())
return DimensionItemListResponse(
items=[serialize_dimension_item(item) for item in items],
total=total,
)
def get_dimension_tree(
db: Session,
dimension_type: str | None = None,
) -> list[DimensionItemTreeNode]:
stmt = select(DimensionItem).order_by(DimensionItem.sort_order, DimensionItem.create_date)
if dimension_type:
stmt = stmt.where(DimensionItem.dimension_type == dimension_type)
items = list(db.scalars(stmt).all())
item_map: dict[str, DimensionItemTreeNode] = {}
for item in items:
item_map[item.id] = DimensionItemTreeNode(
id=item.id,
dimension_type=item.dimension_type,
code=item.code,
name=item.name,
parent_id=item.parent_id,
description=item.description,
is_enabled=item.is_enabled,
sort_order=item.sort_order,
create_date=item.create_date,
create_user=item.create_user,
update_date=item.update_date,
update_user=item.update_user,
children=[],
)
root_nodes: list[DimensionItemTreeNode] = []
for node in item_map.values():
if node.parent_id and node.parent_id in item_map:
item_map[node.parent_id].children.append(node)
else:
root_nodes.append(node)
return root_nodes
def get_dimension_item_by_id(db: Session, item_id: str) -> DimensionItem | None:
return db.scalar(select(DimensionItem).where(DimensionItem.id == item_id))
def create_dimension_item(
db: Session,
payload: DimensionItemCreateRequest,
actor: User,
) -> DimensionItemSummary | None:
existing = db.scalar(
select(DimensionItem).where(
DimensionItem.dimension_type == payload.dimension_type,
DimensionItem.code == payload.code,
)
)
if existing:
return None
if payload.parent_id:
parent = get_dimension_item_by_id(db, payload.parent_id)
if not parent:
return None
item = DimensionItem(
dimension_type=payload.dimension_type,
code=payload.code,
name=payload.name,
parent_id=payload.parent_id,
description=payload.description,
is_enabled=payload.is_enabled,
sort_order=payload.sort_order,
create_user=actor.id,
update_user=actor.id,
)
db.add(item)
db.commit()
db.refresh(item)
publish_topic(DIMENSION_ITEM_TOPIC)
return serialize_dimension_item(item)
def update_dimension_item(
db: Session,
item_id: str,
payload: DimensionItemUpdateRequest,
actor: User,
) -> DimensionItemSummary | None:
item = get_dimension_item_by_id(db, item_id)
if not item:
return None
if payload.code is not None:
existing = db.scalar(
select(DimensionItem).where(
DimensionItem.dimension_type == item.dimension_type,
DimensionItem.code == payload.code,
DimensionItem.id != item_id,
)
)
if existing:
return None
item.code = payload.code
if payload.name is not None:
item.name = payload.name
if payload.parent_id is not None:
if payload.parent_id:
parent = get_dimension_item_by_id(db, payload.parent_id)
if not parent:
return None
if payload.parent_id == item_id:
return None
item.parent_id = payload.parent_id
if payload.description is not None:
item.description = payload.description
if payload.is_enabled is not None:
item.is_enabled = payload.is_enabled
if payload.sort_order is not None:
item.sort_order = payload.sort_order
item.update_date = utcnow()
item.update_user = actor.id
db.commit()
db.refresh(item)
publish_topic(DIMENSION_ITEM_TOPIC)
return serialize_dimension_item(item)
def delete_dimension_item(db: Session, item_id: str) -> bool:
item = get_dimension_item_by_id(db, item_id)
if not item:
return False
children = db.scalars(select(DimensionItem).where(DimensionItem.parent_id == item_id)).all()
if children:
return False
db.delete(item)
db.commit()
publish_topic(DIMENSION_ITEM_TOPIC)
return True