fix: 修复多个关键问题

- 修复前端路由守卫:未登录时不显示提示,直接跳转登录页
- 修复API拦截器:401错误不显示提示,直接跳转
- 增强验证码显示:图片尺寸从120x40增加到200x80
- 增大验证码字体:从28号增加到48号
- 优化验证码字符:排除易混淆的0和1
- 减少干扰线:从5条减少到3条,添加背景色优化
- 增强登录API日志:添加详细的调试日志
- 增强验证码生成和验证日志
- 优化异常处理和错误追踪

影响文件:
- src/router/index.ts
- src/api/request.ts
- app/services/auth_service.py
- app/api/v1/auth.py
- app/schemas/user.py

测试状态:
- 前端构建通过
- 后端语法检查通过
- 验证码显示效果优化完成

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-01-25 00:26:21 +08:00
commit e71181f0a3
150 changed files with 39549 additions and 0 deletions

29
app/api/v1/__init__.py Normal file
View File

@@ -0,0 +1,29 @@
"""
API V1模块初始化
"""
from fastapi import APIRouter
from app.api.v1 import (
auth, device_types, organizations, assets, brands_suppliers,
allocations, maintenance, files, transfers, recoveries,
statistics, system_config, operation_logs, notifications
)
api_router = APIRouter()
# 注册路由模块
api_router.include_router(auth.router, prefix="/auth", tags=["认证"])
api_router.include_router(device_types.router, prefix="/device-types", tags=["设备类型管理"])
api_router.include_router(organizations.router, prefix="/organizations", tags=["机构网点管理"])
api_router.include_router(assets.router, prefix="/assets", tags=["资产管理"])
api_router.include_router(brands_suppliers.router, prefix="/brands-suppliers", tags=["品牌和供应商管理"])
api_router.include_router(allocations.router, prefix="/allocation-orders", tags=["资产分配管理"])
api_router.include_router(maintenance.router, prefix="/maintenance-records", tags=["维修管理"])
api_router.include_router(files.router, prefix="/files", tags=["文件管理"])
api_router.include_router(transfers.router, prefix="/transfers", tags=["资产调拨管理"])
api_router.include_router(recoveries.router, prefix="/recoveries", tags=["资产回收管理"])
api_router.include_router(statistics.router, prefix="/statistics", tags=["统计分析"])
api_router.include_router(system_config.router, prefix="/system-config", tags=["系统配置管理"])
api_router.include_router(operation_logs.router, prefix="/operation-logs", tags=["操作日志管理"])
api_router.include_router(notifications.router, prefix="/notifications", tags=["消息通知管理"])
__all__ = ["api_router"]

238
app/api/v1/allocations.py Normal file
View File

@@ -0,0 +1,238 @@
"""
资产分配管理API路由
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from app.core.deps import get_db, get_current_user
from app.schemas.allocation import (
AllocationOrderCreate,
AllocationOrderUpdate,
AllocationOrderApproval,
AllocationOrderWithRelations,
AllocationItemResponse,
AllocationOrderQueryParams,
AllocationOrderStatistics
)
from app.services.allocation_service import allocation_service
router = APIRouter()
@router.get("/", response_model=list)
def get_allocation_orders(
skip: int = Query(0, ge=0, description="跳过条数"),
limit: int = Query(20, ge=1, le=100, description="返回条数"),
order_type: Optional[str] = Query(None, description="单据类型"),
approval_status: Optional[str] = Query(None, description="审批状态"),
execute_status: Optional[str] = Query(None, description="执行状态"),
applicant_id: Optional[int] = Query(None, description="申请人ID"),
target_organization_id: Optional[int] = Query(None, description="目标网点ID"),
keyword: Optional[str] = Query(None, description="搜索关键词"),
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取分配单列表
- **skip**: 跳过条数
- **limit**: 返回条数最大100
- **order_type**: 单据类型allocation/transfer/recovery/maintenance/scrap
- **approval_status**: 审批状态pending/approved/rejected/cancelled
- **execute_status**: 执行状态pending/executing/completed/cancelled
- **applicant_id**: 申请人ID
- **target_organization_id**: 目标网点ID
- **keyword**: 搜索关键词(单号/标题)
"""
items, total = allocation_service.get_orders(
db=db,
skip=skip,
limit=limit,
order_type=order_type,
approval_status=approval_status,
execute_status=execute_status,
applicant_id=applicant_id,
target_organization_id=target_organization_id,
keyword=keyword
)
return items
@router.get("/statistics", response_model=AllocationOrderStatistics)
def get_allocation_statistics(
applicant_id: Optional[int] = Query(None, description="申请人ID"),
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取分配单统计信息
- **applicant_id**: 申请人ID可选
返回分配单总数、待审批数、已审批数等统计信息
"""
return allocation_service.get_statistics(db, applicant_id)
@router.get("/{order_id}", response_model=dict)
def get_allocation_order(
order_id: int,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取分配单详情
- **order_id**: 分配单ID
返回分配单详情及其关联信息(包含明细列表)
"""
return allocation_service.get_order(db, order_id)
@router.get("/{order_id}/items", response_model=list)
def get_allocation_order_items(
order_id: int,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取分配单明细列表
- **order_id**: 分配单ID
返回该分配单的所有资产明细
"""
return allocation_service.get_order_items(db, order_id)
@router.post("/", response_model=dict, status_code=status.HTTP_201_CREATED)
def create_allocation_order(
obj_in: AllocationOrderCreate,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
创建分配单
- **order_type**: 单据类型
- allocation: 资产分配(从仓库分配给网点)
- transfer: 资产调拨(网点间调拨)
- recovery: 资产回收(从使用中回收)
- maintenance: 维修分配
- scrap: 报废分配
- **title**: 标题
- **source_organization_id**: 调出网点ID可选调拨时必填
- **target_organization_id**: 调入网点ID
- **asset_ids**: 资产ID列表
- **expect_execute_date**: 预计执行日期
- **remark**: 备注
"""
return allocation_service.create_order(
db=db,
obj_in=obj_in,
applicant_id=current_user.id
)
@router.put("/{order_id}", response_model=dict)
def update_allocation_order(
order_id: int,
obj_in: AllocationOrderUpdate,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
更新分配单
- **order_id**: 分配单ID
- **title**: 标题
- **expect_execute_date**: 预计执行日期
- **remark**: 备注
只有待审批状态的分配单可以更新
"""
return allocation_service.update_order(
db=db,
order_id=order_id,
obj_in=obj_in,
updater_id=current_user.id
)
@router.post("/{order_id}/approve", response_model=dict)
def approve_allocation_order(
order_id: int,
approval_in: AllocationOrderApproval,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
审批分配单
- **order_id**: 分配单ID
- **approval_status**: 审批状态approved/rejected
- **approval_remark**: 审批备注
审批通过后会自动执行资产分配逻辑
"""
return allocation_service.approve_order(
db=db,
order_id=order_id,
approval_in=approval_in,
approver_id=current_user.id
)
@router.post("/{order_id}/execute", response_model=dict)
def execute_allocation_order(
order_id: int,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
执行分配单
- **order_id**: 分配单ID
手动执行已审批通过的分配单
"""
return allocation_service.execute_order(
db=db,
order_id=order_id,
executor_id=current_user.id
)
@router.post("/{order_id}/cancel", status_code=status.HTTP_204_NO_CONTENT)
def cancel_allocation_order(
order_id: int,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
取消分配单
- **order_id**: 分配单ID
取消分配单(已完成的无法取消)
"""
allocation_service.cancel_order(db, order_id)
return None
@router.delete("/{order_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_allocation_order(
order_id: int,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
删除分配单
- **order_id**: 分配单ID
只能删除草稿、已拒绝或已取消的分配单
"""
allocation_service.delete_order(db, order_id)
return None

245
app/api/v1/assets.py Normal file
View File

@@ -0,0 +1,245 @@
"""
资产管理API路由
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from app.core.deps import get_db, get_current_user
from app.schemas.asset import (
AssetCreate,
AssetUpdate,
AssetResponse,
AssetWithRelations,
AssetStatusHistoryResponse,
AssetStatusTransition,
AssetQueryParams
)
from app.services.asset_service import asset_service
router = APIRouter()
@router.get("/", response_model=List[AssetResponse])
def get_assets(
skip: int = Query(0, ge=0, description="跳过条数"),
limit: int = Query(20, ge=1, le=100, description="返回条数"),
keyword: Optional[str] = Query(None, description="搜索关键词"),
device_type_id: Optional[int] = Query(None, description="设备类型ID"),
organization_id: Optional[int] = Query(None, description="网点ID"),
status: Optional[str] = Query(None, description="状态"),
purchase_date_start: Optional[str] = Query(None, description="采购日期开始(YYYY-MM-DD)"),
purchase_date_end: Optional[str] = Query(None, description="采购日期结束(YYYY-MM-DD)"),
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取资产列表
- **skip**: 跳过条数
- **limit**: 返回条数最大100
- **keyword**: 搜索关键词(编码/名称/型号/序列号)
- **device_type_id**: 设备类型ID筛选
- **organization_id**: 网点ID筛选
- **status**: 状态筛选
- **purchase_date_start**: 采购日期开始
- **purchase_date_end**: 采购日期结束
"""
items, total = asset_service.get_assets(
db=db,
skip=skip,
limit=limit,
keyword=keyword,
device_type_id=device_type_id,
organization_id=organization_id,
status=status,
purchase_date_start=purchase_date_start,
purchase_date_end=purchase_date_end
)
return items
@router.get("/statistics")
def get_asset_statistics(
organization_id: Optional[int] = Query(None, description="网点ID筛选"),
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取资产统计信息
- **organization_id**: 网点ID筛选
返回资产总数、总价值、状态分布等统计信息
"""
return asset_service.get_statistics(db, organization_id)
@router.get("/{asset_id}", response_model=AssetWithRelations)
def get_asset(
asset_id: int,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取资产详情
- **asset_id**: 资产ID
返回资产详情及其关联信息
"""
return asset_service.get_asset(db, asset_id)
@router.get("/scan/{asset_code}", response_model=AssetWithRelations)
def scan_asset(
asset_code: str,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
扫码查询资产
- **asset_code**: 资产编码
通过扫描二维码查询资产详情
"""
return asset_service.scan_asset_by_code(db, asset_code)
@router.post("/", response_model=AssetResponse, status_code=status.HTTP_201_CREATED)
def create_asset(
obj_in: AssetCreate,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
创建资产
- **asset_name**: 资产名称
- **device_type_id**: 设备类型ID
- **brand_id**: 品牌ID可选
- **model**: 规格型号
- **serial_number**: 序列号
- **supplier_id**: 供应商ID
- **purchase_date**: 采购日期
- **purchase_price**: 采购价格
- **warranty_period**: 保修期(月)
- **organization_id**: 所属网点ID
- **location**: 存放位置
- **dynamic_attributes**: 动态字段值
- **remark**: 备注
"""
return asset_service.create_asset(
db=db,
obj_in=obj_in,
creator_id=current_user.id
)
@router.put("/{asset_id}", response_model=AssetResponse)
def update_asset(
asset_id: int,
obj_in: AssetUpdate,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
更新资产
- **asset_id**: 资产ID
- **asset_name**: 资产名称
- **brand_id**: 品牌ID
- **model**: 规格型号
- **serial_number**: 序列号
- **supplier_id**: 供应商ID
- **purchase_date**: 采购日期
- **purchase_price**: 采购价格
- **warranty_period**: 保修期
- **organization_id**: 所属网点ID
- **location**: 存放位置
- **dynamic_attributes**: 动态字段值
- **remark**: 备注
"""
return asset_service.update_asset(
db=db,
asset_id=asset_id,
obj_in=obj_in,
updater_id=current_user.id
)
@router.delete("/{asset_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_asset(
asset_id: int,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
删除资产
- **asset_id**: 资产ID
软删除资产
"""
asset_service.delete_asset(
db=db,
asset_id=asset_id,
deleter_id=current_user.id
)
return None
# ===== 状态管理 =====
@router.post("/{asset_id}/status", response_model=AssetResponse)
def change_asset_status(
asset_id: int,
status_transition: AssetStatusTransition,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
变更资产状态
- **asset_id**: 资产ID
- **new_status**: 目标状态
- **remark**: 备注
- **extra_data**: 额外数据
状态说明:
- pending: 待入库
- in_stock: 库存中
- in_use: 使用中
- transferring: 调拨中
- maintenance: 维修中
- pending_scrap: 待报废
- scrapped: 已报废
- lost: 已丢失
"""
return asset_service.change_asset_status(
db=db,
asset_id=asset_id,
status_transition=status_transition,
operator_id=current_user.id,
operator_name=current_user.real_name
)
@router.get("/{asset_id}/history", response_model=List[AssetStatusHistoryResponse])
def get_asset_status_history(
asset_id: int,
skip: int = Query(0, ge=0, description="跳过条数"),
limit: int = Query(50, ge=1, le=100, description="返回条数"),
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取资产状态历史
- **asset_id**: 资产ID
- **skip**: 跳过条数
- **limit**: 返回条数
返回资产的所有状态变更记录
"""
return asset_service.get_asset_status_history(db, asset_id, skip, limit)

147
app/api/v1/auth.py Normal file
View File

@@ -0,0 +1,147 @@
"""
认证相关API路由
"""
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status, Header
from fastapi.security import HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db, get_current_user
from app.schemas.user import (
LoginRequest,
LoginResponse,
RefreshTokenRequest,
RefreshTokenResponse,
ChangePasswordRequest,
)
from app.services.auth_service import auth_service
from app.models.user import User
from app.core.response import success_response
from app.core.config import settings
from jose import jwt
import logging
logger = logging.getLogger(__name__)
router = APIRouter()
security = HTTPBearer()
@router.post("/login", response_model=LoginResponse)
async def login(
credentials: LoginRequest,
db: AsyncSession = Depends(get_db)
):
"""
用户登录
- **username**: 用户名
- **password**: 密码
- **captcha**: 验证码
- **captcha_key**: 验证码UUID
"""
logger.info(f"登录请求 - 用户名: {credentials.username}, "
f"验证码: {credentials.captcha}, 验证码Key: {credentials.captcha_key}")
try:
result = await auth_service.login(
db=db,
username=credentials.username,
password=credentials.password,
captcha=credentials.captcha,
captcha_key=credentials.captcha_key
)
logger.info(f"登录成功 - 用户名: {credentials.username}")
return success_response(data=result)
except Exception as e:
logger.error(f"登录失败 - 用户名: {credentials.username}, 错误: {str(e)}", exc_info=True)
raise
@router.post("/refresh", response_model=RefreshTokenResponse)
async def refresh_token(
token_request: RefreshTokenRequest,
db: AsyncSession = Depends(get_db)
):
"""
刷新访问令牌
- **refresh_token**: 刷新令牌
"""
result = await auth_service.refresh_token(
db=db,
refresh_token=token_request.refresh_token
)
return success_response(data=result)
@router.post("/logout")
async def logout(
current_user: User = Depends(get_current_user),
authorization: str = Header(...),
db: AsyncSession = Depends(get_db)
):
"""
用户登出
"""
from app.utils.redis_client import redis_client
# 提取Token
token = authorization.replace("Bearer ", "")
# 获取Token剩余有效期
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
exp = payload.get("exp")
if exp:
# 计算剩余秒数
remaining_time = int(exp) - int(datetime.utcnow().timestamp())
if remaining_time > 0:
# 将Token加入黑名单
await redis_client.setex(
f"blacklist:{token}",
remaining_time,
"1"
)
except Exception as e:
logger.error(f"Token黑名单添加失败: {str(e)}")
return success_response(message="登出成功")
@router.put("/change-password")
async def change_password(
password_data: ChangePasswordRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""
修改密码
- **old_password**: 旧密码
- **new_password**: 新密码
- **confirm_password**: 确认密码
"""
await auth_service.change_password(
db=db,
user=current_user,
old_password=password_data.old_password,
new_password=password_data.new_password
)
return success_response(message="密码修改成功")
@router.get("/captcha")
async def get_captcha():
"""
获取验证码
返回验证码图片和captcha_key
"""
captcha_data = await auth_service._generate_captcha()
return success_response(data={
"captcha_key": captcha_data["captcha_key"],
"captcha_image": captcha_data["captcha_base64"]
})

View File

@@ -0,0 +1,134 @@
"""
品牌和供应商API路由
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from app.core.deps import get_db, get_current_user
from app.schemas.brand_supplier import (
BrandCreate,
BrandUpdate,
BrandResponse,
SupplierCreate,
SupplierUpdate,
SupplierResponse
)
from app.services.brand_supplier_service import brand_service, supplier_service
router = APIRouter()
# ===== 品牌管理 =====
@router.get("/brands", response_model=List[BrandResponse])
def get_brands(
skip: int = Query(0, ge=0, description="跳过条数"),
limit: int = Query(20, ge=1, le=100, description="返回条数"),
status: Optional[str] = Query(None, description="状态筛选"),
keyword: Optional[str] = Query(None, description="搜索关键词"),
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""获取品牌列表"""
items, total = brand_service.get_brands(db, skip, limit, status, keyword)
return items
@router.get("/brands/{brand_id}", response_model=BrandResponse)
def get_brand(
brand_id: int,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""获取品牌详情"""
return brand_service.get_brand(db, brand_id)
@router.post("/brands", response_model=BrandResponse, status_code=status.HTTP_201_CREATED)
def create_brand(
obj_in: BrandCreate,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""创建品牌"""
return brand_service.create_brand(db, obj_in, current_user.id)
@router.put("/brands/{brand_id}", response_model=BrandResponse)
def update_brand(
brand_id: int,
obj_in: BrandUpdate,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""更新品牌"""
return brand_service.update_brand(db, brand_id, obj_in, current_user.id)
@router.delete("/brands/{brand_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_brand(
brand_id: int,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""删除品牌"""
brand_service.delete_brand(db, brand_id, current_user.id)
return None
# ===== 供应商管理 =====
@router.get("/suppliers", response_model=List[SupplierResponse])
def get_suppliers(
skip: int = Query(0, ge=0, description="跳过条数"),
limit: int = Query(20, ge=1, le=100, description="返回条数"),
status: Optional[str] = Query(None, description="状态筛选"),
keyword: Optional[str] = Query(None, description="搜索关键词"),
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""获取供应商列表"""
items, total = supplier_service.get_suppliers(db, skip, limit, status, keyword)
return items
@router.get("/suppliers/{supplier_id}", response_model=SupplierResponse)
def get_supplier(
supplier_id: int,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""获取供应商详情"""
return supplier_service.get_supplier(db, supplier_id)
@router.post("/suppliers", response_model=SupplierResponse, status_code=status.HTTP_201_CREATED)
def create_supplier(
obj_in: SupplierCreate,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""创建供应商"""
return supplier_service.create_supplier(db, obj_in, current_user.id)
@router.put("/suppliers/{supplier_id}", response_model=SupplierResponse)
def update_supplier(
supplier_id: int,
obj_in: SupplierUpdate,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""更新供应商"""
return supplier_service.update_supplier(db, supplier_id, obj_in, current_user.id)
@router.delete("/suppliers/{supplier_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_supplier(
supplier_id: int,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""删除供应商"""
supplier_service.delete_supplier(db, supplier_id, current_user.id)
return None

277
app/api/v1/device_types.py Normal file
View File

@@ -0,0 +1,277 @@
"""
设备类型API路由
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from app.core.deps import get_db, get_current_user
from app.schemas.device_type import (
DeviceTypeCreate,
DeviceTypeUpdate,
DeviceTypeResponse,
DeviceTypeWithFields,
DeviceTypeFieldCreate,
DeviceTypeFieldUpdate,
DeviceTypeFieldResponse
)
from app.services.device_type_service import device_type_service
from app.utils.redis_client import redis_client
router = APIRouter()
# 异步缓存包装器
@redis_client.cached_async("device_types:list", expire=1800) # 缓存30分钟
async def _cached_get_device_types(
skip: int,
limit: int,
category: Optional[str],
status: Optional[str],
keyword: Optional[str],
db: Session
):
"""获取设备类型列表的缓存包装器"""
items, total = device_type_service.get_device_types(
db=db,
skip=skip,
limit=limit,
category=category,
status=status,
keyword=keyword
)
return items
@redis_client.cached_async("device_types:categories", expire=1800) # 缓存30分钟
async def _cached_get_device_type_categories(db: Session):
"""获取所有设备分类的缓存包装器"""
return device_type_service.get_all_categories(db)
@router.get("/", response_model=List[DeviceTypeResponse])
async def get_device_types(
skip: int = Query(0, ge=0, description="跳过条数"),
limit: int = Query(20, ge=1, le=100, description="返回条数"),
category: Optional[str] = Query(None, description="设备分类"),
status: Optional[str] = Query(None, description="状态"),
keyword: Optional[str] = Query(None, description="搜索关键词"),
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取设备类型列表已启用缓存30分钟
- **skip**: 跳过条数
- **limit**: 返回条数最大100
- **category**: 设备分类筛选
- **status**: 状态筛选active/inactive
- **keyword**: 搜索关键词(代码或名称)
"""
return await _cached_get_device_types(
skip=skip,
limit=limit,
category=category,
status=status,
keyword=keyword,
db=db
)
@router.get("/categories", response_model=List[str])
async def get_device_type_categories(
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取所有设备分类已启用缓存30分钟
返回所有使用中的设备分类列表
"""
return await _cached_get_device_type_categories(db)
@router.get("/{device_type_id}", response_model=DeviceTypeWithFields)
def get_device_type(
device_type_id: int,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取设备类型详情
- **device_type_id**: 设备类型ID
返回设备类型详情及其字段列表
"""
return device_type_service.get_device_type(db, device_type_id, include_fields=True)
@router.post("/", response_model=DeviceTypeResponse, status_code=status.HTTP_201_CREATED)
def create_device_type(
obj_in: DeviceTypeCreate,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
创建设备类型
- **type_code**: 设备类型代码(唯一)
- **type_name**: 设备类型名称
- **category**: 设备分类
- **description**: 描述
- **icon**: 图标名称
- **sort_order**: 排序
"""
return device_type_service.create_device_type(
db=db,
obj_in=obj_in,
creator_id=current_user.id
)
@router.put("/{device_type_id}", response_model=DeviceTypeResponse)
def update_device_type(
device_type_id: int,
obj_in: DeviceTypeUpdate,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
更新设备类型
- **device_type_id**: 设备类型ID
- **type_name**: 设备类型名称
- **category**: 设备分类
- **description**: 描述
- **icon**: 图标名称
- **status**: 状态
- **sort_order**: 排序
"""
return device_type_service.update_device_type(
db=db,
device_type_id=device_type_id,
obj_in=obj_in,
updater_id=current_user.id
)
@router.delete("/{device_type_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_device_type(
device_type_id: int,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
删除设备类型
- **device_type_id**: 设备类型ID
软删除设备类型及其所有字段
"""
device_type_service.delete_device_type(
db=db,
device_type_id=device_type_id,
deleter_id=current_user.id
)
return None
# ===== 字段管理 =====
@router.get("/{device_type_id}/fields", response_model=List[DeviceTypeFieldResponse])
def get_device_type_fields(
device_type_id: int,
status: Optional[str] = Query(None, description="状态筛选"),
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取设备类型的字段列表
- **device_type_id**: 设备类型ID
- **status**: 状态筛选active/inactive
返回指定设备类型的所有字段定义
"""
return device_type_service.get_device_type_fields(db, device_type_id, status)
@router.post("/{device_type_id}/fields", response_model=DeviceTypeFieldResponse, status_code=status.HTTP_201_CREATED)
def create_device_type_field(
device_type_id: int,
obj_in: DeviceTypeFieldCreate,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
创建设备类型字段
- **device_type_id**: 设备类型ID
- **field_code**: 字段代码(在同一设备类型下唯一)
- **field_name**: 字段名称
- **field_type**: 字段类型text/number/date/select/multiselect/boolean/textarea
- **is_required**: 是否必填
- **default_value**: 默认值
- **options**: 选项列表用于select/multiselect类型
- **validation_rules**: 验证规则
- **placeholder**: 占位符
- **help_text**: 帮助文本
- **sort_order**: 排序
"""
return device_type_service.create_device_type_field(
db=db,
device_type_id=device_type_id,
obj_in=obj_in,
creator_id=current_user.id
)
@router.put("/fields/{field_id}", response_model=DeviceTypeFieldResponse)
def update_device_type_field(
field_id: int,
obj_in: DeviceTypeFieldUpdate,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
更新设备类型字段
- **field_id**: 字段ID
- **field_name**: 字段名称
- **field_type**: 字段类型
- **is_required**: 是否必填
- **default_value**: 默认值
- **options**: 选项列表
- **validation_rules**: 验证规则
- **placeholder**: 占位符
- **help_text**: 帮助文本
- **status**: 状态
- **sort_order**: 排序
"""
return device_type_service.update_device_type_field(
db=db,
field_id=field_id,
obj_in=obj_in,
updater_id=current_user.id
)
@router.delete("/fields/{field_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_device_type_field(
field_id: int,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
删除设备类型字段
- **field_id**: 字段ID
软删除字段
"""
device_type_service.delete_device_type_field(
db=db,
field_id=field_id,
deleter_id=current_user.id
)
return None

547
app/api/v1/files.py Normal file
View File

@@ -0,0 +1,547 @@
"""
文件管理API路由
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query, UploadFile, File, Form
from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy.orm import Session
from app.core.deps import get_db, get_current_user
from app.schemas.file_management import (
UploadedFileCreate,
UploadedFileUpdate,
UploadedFileResponse,
UploadedFileWithUrl,
FileUploadResponse,
FileShareCreate,
FileShareResponse,
FileBatchDelete,
FileStatistics,
ChunkUploadInit,
ChunkUploadInfo,
ChunkUploadComplete
)
from app.crud.file_management import uploaded_file
from app.services.file_service import file_service, chunk_upload_manager
router = APIRouter()
@router.post("/upload", response_model=FileUploadResponse)
async def upload_file(
file: UploadFile = File(..., description="上传的文件"),
remark: Optional[str] = Form(None, description="备注"),
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
上传文件
- **file**: 上传的文件
- **remark**: 备注
支持的文件类型:
- 图片: JPEG, PNG, GIF, BMP, WebP, SVG
- 文档: PDF, Word, Excel, PowerPoint, TXT, CSV
- 压缩包: ZIP, RAR, 7Z
文件大小限制:
- 图片: 最大10MB
- 其他: 最大100MB
"""
# 上传文件
file_obj = await file_service.upload_file(
db=db,
file=file,
uploader_id=current_user.id,
remark=remark
)
# 生成访问URL
base_url = "http://localhost:8000" # TODO: 从配置读取
download_url = f"{base_url}/api/v1/files/{file_obj.id}/download"
preview_url = None
if file_obj.file_type and file_obj.file_type.startswith('image/'):
preview_url = f"{base_url}/api/v1/files/{file_obj.id}/preview"
return FileUploadResponse(
id=file_obj.id,
file_name=file_obj.file_name,
original_name=file_obj.original_name,
file_size=file_obj.file_size,
file_type=file_obj.file_type,
file_path=file_obj.file_path,
download_url=download_url,
preview_url=preview_url,
message="上传成功"
)
@router.get("/", response_model=List[UploadedFileResponse])
def get_files(
skip: int = Query(0, ge=0, description="跳过条数"),
limit: int = Query(20, ge=1, le=100, description="返回条数"),
keyword: Optional[str] = Query(None, description="搜索关键词"),
file_type: Optional[str] = Query(None, description="文件类型"),
uploader_id: Optional[int] = Query(None, description="上传者ID"),
start_date: Optional[str] = Query(None, description="开始日期(YYYY-MM-DD)"),
end_date: Optional[str] = Query(None, description="结束日期(YYYY-MM-DD)"),
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取文件列表
- **skip**: 跳过条数
- **limit**: 返回条数最大100
- **keyword**: 搜索关键词(文件名)
- **file_type**: 文件类型筛选
- **uploader_id**: 上传者ID筛选
- **start_date**: 开始日期
- **end_date**: 结束日期
"""
items, total = uploaded_file.get_multi(
db,
skip=skip,
limit=limit,
keyword=keyword,
file_type=file_type,
uploader_id=uploader_id,
start_date=start_date,
end_date=end_date
)
# 添加上传者姓名
result = []
for item in items:
item_dict = UploadedFileResponse.from_orm(item).dict()
if item.uploader:
item_dict['uploader_name'] = item.uploader.real_name
result.append(UploadedFileResponse(**item_dict))
return result
@router.get("/statistics", response_model=FileStatistics)
def get_file_statistics(
uploader_id: Optional[int] = Query(None, description="上传者ID筛选"),
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取文件统计信息
- **uploader_id**: 上传者ID筛选
返回文件总数、总大小、类型分布等统计信息
"""
return file_service.get_statistics(db, uploader_id=uploader_id)
@router.get("/{file_id}", response_model=UploadedFileWithUrl)
def get_file(
file_id: int,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取文件详情
- **file_id**: 文件ID
返回文件详情及访问URL
"""
file_obj = uploaded_file.get(db, file_id)
if not file_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="文件不存在"
)
# 生成访问URL
base_url = "http://localhost:8000"
file_dict = UploadedFileWithUrl.from_orm(file_obj).dict()
file_dict['download_url'] = f"{base_url}/api/v1/files/{file_id}/download"
if file_obj.file_type and file_obj.file_type.startswith('image/'):
file_dict['preview_url'] = f"{base_url}/api/v1/files/{file_id}/preview"
if file_obj.share_code:
file_dict['share_url'] = f"{base_url}/api/v1/files/share/{file_obj.share_code}"
return UploadedFileWithUrl(**file_dict)
@router.get("/{file_id}/download")
def download_file(
file_id: int,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
下载文件
- **file_id**: 文件ID
返回文件流
"""
file_obj = uploaded_file.get(db, file_id)
if not file_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="文件不存在"
)
# 检查文件是否存在
if not file_service.file_exists(file_obj):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="文件已被删除或移动"
)
# 增加下载次数
uploaded_file.increment_download_count(db, file_id=file_id)
# 返回文件
file_path = file_service.get_file_path(file_obj)
return FileResponse(
path=str(file_path),
filename=file_obj.original_name,
media_type=file_obj.file_type
)
@router.get("/{file_id}/preview")
def preview_file(
file_id: int,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
预览文件
- **file_id**: 文件ID
支持图片直接预览,其他文件类型可能需要转换为预览格式
"""
file_obj = uploaded_file.get(db, file_id)
if not file_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="文件不存在"
)
# 检查文件是否存在
if not file_service.file_exists(file_obj):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="文件已被删除或移动"
)
# 检查文件类型是否支持预览
if not file_obj.file_type or not file_obj.file_type.startswith('image/'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="该文件类型不支持在线预览"
)
# 返回缩略图(如果存在)
if file_obj.thumbnail_path:
thumbnail_path = file_obj.thumbnail_path
if Path(thumbnail_path).exists():
return FileResponse(
path=thumbnail_path,
media_type="image/jpeg"
)
# 返回原图
file_path = file_service.get_file_path(file_obj)
return FileResponse(
path=str(file_path),
media_type=file_obj.file_type
)
@router.put("/{file_id}", response_model=UploadedFileResponse)
def update_file(
file_id: int,
obj_in: UploadedFileUpdate,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
更新文件信息
- **file_id**: 文件ID
- **remark**: 备注
"""
file_obj = uploaded_file.get(db, file_id)
if not file_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="文件不存在"
)
# 检查权限:只有上传者可以更新
if file_obj.uploader_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="无权限修改此文件"
)
# 更新文件
file_obj = uploaded_file.update(
db,
db_obj=file_obj,
obj_in=obj_in.dict(exclude_unset=True)
)
return UploadedFileResponse.from_orm(file_obj)
@router.delete("/{file_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_file(
file_id: int,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
删除文件
- **file_id**: 文件ID
软删除文件记录和物理删除文件
"""
file_obj = uploaded_file.get(db, file_id)
if not file_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="文件不存在"
)
# 检查权限:只有上传者可以删除
if file_obj.uploader_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="无权限删除此文件"
)
# 软删除数据库记录
uploaded_file.delete(db, db_obj=file_obj, deleter_id=current_user.id)
# 从磁盘删除文件
file_service.delete_file_from_disk(file_obj)
return None
@router.delete("/batch", status_code=status.HTTP_204_NO_CONTENT)
def delete_files_batch(
obj_in: FileBatchDelete,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
批量删除文件
- **file_ids**: 文件ID列表
批量软删除文件记录和物理删除文件
"""
# 软删除数据库记录
count = uploaded_file.delete_batch(
db,
file_ids=obj_in.file_ids,
deleter_id=current_user.id
)
# 从磁盘删除文件
for file_id in obj_in.file_ids:
file_obj = uploaded_file.get(db, file_id)
if file_obj and file_obj.uploader_id == current_user.id:
file_service.delete_file_from_disk(file_obj)
return None
@router.post("/{file_id}/share", response_model=FileShareResponse)
def create_share_link(
file_id: int,
share_in: FileShareCreate = FileShareCreate(),
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
生成分享链接
- **file_id**: 文件ID
- **expire_days**: 有效期默认7天最大30天
生成用于文件分享的临时链接
"""
file_obj = uploaded_file.get(db, file_id)
if not file_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="文件不存在"
)
# 检查权限:只有上传者可以分享
if file_obj.uploader_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="无权限分享此文件"
)
# 生成分享链接
base_url = "http://localhost:8000"
return file_service.generate_share_link(
db,
file_id=file_id,
expire_days=share_in.expire_days,
base_url=base_url
)
@router.get("/share/{share_code}")
def access_shared_file(
share_code: str,
db: Session = Depends(get_db)
):
"""
访问分享的文件
- **share_code**: 分享码
通过分享码访问文件(无需登录)
"""
file_obj = file_service.get_shared_file(db, share_code)
if not file_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="分享链接不存在或已过期"
)
# 检查文件是否存在
if not file_service.file_exists(file_obj):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="文件已被删除或移动"
)
# 增加下载次数
uploaded_file.increment_download_count(db, file_id=file_obj.id)
# 返回文件
file_path = file_service.get_file_path(file_obj)
return FileResponse(
path=str(file_path),
filename=file_obj.original_name,
media_type=file_obj.file_type
)
# ===== 分片上传 =====
@router.post("/chunks/init")
def init_chunk_upload(
obj_in: ChunkUploadInit,
current_user = Depends(get_current_user)
):
"""
初始化分片上传
- **file_name**: 文件名
- **file_size**: 文件大小(字节)
- **file_type**: 文件类型
- **total_chunks**: 总分片数
- **file_hash**: 文件哈希(可选)
返回上传ID用于后续上传分片
"""
upload_id = chunk_upload_manager.init_upload(
file_name=obj_in.file_name,
file_size=obj_in.file_size,
file_type=obj_in.file_type,
total_chunks=obj_in.total_chunks,
file_hash=obj_in.file_hash
)
return {"upload_id": upload_id, "message": "初始化成功"}
@router.post("/chunks/upload")
async def upload_chunk(
upload_id: str,
chunk_index: int,
chunk: UploadFile = File(..., description="分片文件"),
current_user = Depends(get_current_user)
):
"""
上传分片
- **upload_id**: 上传ID
- **chunk_index**: 分片索引从0开始
- **chunk**: 分片文件
"""
content = await chunk.read()
success = chunk_upload_manager.save_chunk(upload_id, chunk_index, content)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="上传会话不存在"
)
return {"message": f"分片 {chunk_index} 上传成功"}
@router.post("/chunks/complete", response_model=FileUploadResponse)
def complete_chunk_upload(
obj_in: ChunkUploadComplete,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
完成分片上传
- **upload_id**: 上传ID
- **file_name**: 文件名
- **file_hash**: 文件哈希(可选)
合并所有分片并创建文件记录
"""
# 合并分片
try:
file_obj = chunk_upload_manager.merge_chunks(
db=db,
upload_id=obj_in.upload_id,
uploader_id=current_user.id,
file_service=file_service
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"合并分片失败: {str(e)}"
)
# 生成访问URL
base_url = "http://localhost:8000"
download_url = f"{base_url}/api/v1/files/{file_obj.id}/download"
preview_url = None
if file_obj.file_type and file_obj.file_type.startswith('image/'):
preview_url = f"{base_url}/api/v1/files/{file_obj.id}/preview"
return FileUploadResponse(
id=file_obj.id,
file_name=file_obj.file_name,
original_name=file_obj.original_name,
file_size=file_obj.file_size,
file_type=file_obj.file_type,
file_path=file_obj.file_path,
download_url=download_url,
preview_url=preview_url,
message="上传成功"
)

257
app/api/v1/maintenance.py Normal file
View File

@@ -0,0 +1,257 @@
"""
维修管理API路由
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from app.core.deps import get_db, get_current_user
from app.schemas.maintenance import (
MaintenanceRecordCreate,
MaintenanceRecordUpdate,
MaintenanceRecordStart,
MaintenanceRecordComplete,
MaintenanceRecordWithRelations,
MaintenanceRecordQueryParams,
MaintenanceStatistics
)
from app.services.maintenance_service import maintenance_service
router = APIRouter()
@router.get("/", response_model=list)
def get_maintenance_records(
skip: int = Query(0, ge=0, description="跳过条数"),
limit: int = Query(20, ge=1, le=100, description="返回条数"),
asset_id: Optional[int] = Query(None, description="资产ID"),
status: Optional[str] = Query(None, description="状态"),
fault_type: Optional[str] = Query(None, description="故障类型"),
priority: Optional[str] = Query(None, description="优先级"),
maintenance_type: Optional[str] = Query(None, description="维修类型"),
keyword: Optional[str] = Query(None, description="搜索关键词"),
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取维修记录列表
- **skip**: 跳过条数
- **limit**: 返回条数最大100
- **asset_id**: 资产ID筛选
- **status**: 状态筛选pending/in_progress/completed/cancelled
- **fault_type**: 故障类型筛选hardware/software/network/other
- **priority**: 优先级筛选low/normal/high/urgent
- **maintenance_type**: 维修类型筛选self_repair/vendor_repair/warranty
- **keyword**: 搜索关键词(单号/资产编码/故障描述)
"""
items, total = maintenance_service.get_records(
db=db,
skip=skip,
limit=limit,
asset_id=asset_id,
status=status,
fault_type=fault_type,
priority=priority,
maintenance_type=maintenance_type,
keyword=keyword
)
return items
@router.get("/statistics", response_model=MaintenanceStatistics)
def get_maintenance_statistics(
asset_id: Optional[int] = Query(None, description="资产ID"),
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取维修统计信息
- **asset_id**: 资产ID可选
返回维修记录总数、待处理数、维修中数、已完成数等统计信息
"""
return maintenance_service.get_statistics(db, asset_id)
@router.get("/{record_id}", response_model=dict)
def get_maintenance_record(
record_id: int,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取维修记录详情
- **record_id**: 维修记录ID
返回维修记录详情及其关联信息
"""
return maintenance_service.get_record(db, record_id)
@router.post("/", response_model=dict, status_code=status.HTTP_201_CREATED)
def create_maintenance_record(
obj_in: MaintenanceRecordCreate,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
创建维修记录
- **asset_id**: 资产ID
- **fault_description**: 故障描述
- **fault_type**: 故障类型hardware/software/network/other
- **priority**: 优先级low/normal/high/urgent
- **maintenance_type**: 维修类型self_repair/vendor_repair/warranty
- **vendor_id**: 维修供应商ID外部维修时必填
- **maintenance_cost**: 维修费用
- **maintenance_result**: 维修结果描述
- **replaced_parts**: 更换的配件
- **images**: 维修图片URL多个逗号分隔
- **remark**: 备注
"""
return maintenance_service.create_record(
db=db,
obj_in=obj_in,
report_user_id=current_user.id,
creator_id=current_user.id
)
@router.put("/{record_id}", response_model=dict)
def update_maintenance_record(
record_id: int,
obj_in: MaintenanceRecordUpdate,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
更新维修记录
- **record_id**: 维修记录ID
- **fault_description**: 故障描述
- **fault_type**: 故障类型
- **priority**: 优先级
- **maintenance_type**: 维修类型
- **vendor_id**: 维修供应商ID
- **maintenance_cost**: 维修费用
- **maintenance_result**: 维修结果描述
- **replaced_parts**: 更换的配件
- **images**: 维修图片URL
- **remark**: 备注
已完成的维修记录不能更新
"""
return maintenance_service.update_record(
db=db,
record_id=record_id,
obj_in=obj_in,
updater_id=current_user.id
)
@router.post("/{record_id}/start", response_model=dict)
def start_maintenance(
record_id: int,
start_in: MaintenanceRecordStart,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
开始维修
- **record_id**: 维修记录ID
- **maintenance_type**: 维修类型
- self_repair: 自行维修
- vendor_repair: 外部维修需指定vendor_id
- warranty: 保修维修
- **vendor_id**: 维修供应商ID外部维修时必填
- **remark**: 备注
只有待处理状态的维修记录可以开始维修
"""
return maintenance_service.start_maintenance(
db=db,
record_id=record_id,
start_in=start_in,
maintenance_user_id=current_user.id
)
@router.post("/{record_id}/complete", response_model=dict)
def complete_maintenance(
record_id: int,
complete_in: MaintenanceRecordComplete,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
完成维修
- **record_id**: 维修记录ID
- **maintenance_result**: 维修结果描述
- **maintenance_cost**: 维修费用
- **replaced_parts**: 更换的配件
- **images**: 维修图片URL
- **asset_status**: 资产维修后状态in_stock/in_use
只有维修中的记录可以完成
"""
return maintenance_service.complete_maintenance(
db=db,
record_id=record_id,
complete_in=complete_in,
maintenance_user_id=current_user.id
)
@router.post("/{record_id}/cancel", response_model=dict)
def cancel_maintenance(
record_id: int,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
取消维修
- **record_id**: 维修记录ID
已完成的维修记录不能取消
"""
return maintenance_service.cancel_maintenance(db, record_id)
@router.delete("/{record_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_maintenance_record(
record_id: int,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
删除维修记录
- **record_id**: 维修记录ID
只能删除待处理或已取消的维修记录
"""
maintenance_service.delete_record(db, record_id)
return None
@router.get("/asset/{asset_id}", response_model=list)
def get_asset_maintenance_records(
asset_id: int,
skip: int = Query(0, ge=0, description="跳过条数"),
limit: int = Query(50, ge=1, le=100, description="返回条数"),
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取资产的维修记录
- **asset_id**: 资产ID
- **skip**: 跳过条数
- **limit**: 返回条数
"""
return maintenance_service.get_asset_records(db, asset_id, skip, limit)

309
app/api/v1/notifications.py Normal file
View File

@@ -0,0 +1,309 @@
"""
消息通知管理API路由
"""
from typing import Optional, List, Dict, Any
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import and_
from app.core.deps import get_db, get_current_user
from app.schemas.notification import (
NotificationCreate,
NotificationUpdate,
NotificationResponse,
NotificationQueryParams,
NotificationBatchCreate,
NotificationBatchUpdate,
NotificationStatistics,
NotificationSendFromTemplate
)
from app.services.notification_service import notification_service
router = APIRouter()
@router.get("/", response_model=Dict[str, Any])
async def get_notifications(
skip: int = Query(0, ge=0, description="跳过条数"),
limit: int = Query(20, ge=1, le=100, description="返回条数"),
notification_type: Optional[str] = Query(None, description="通知类型"),
priority: Optional[str] = Query(None, description="优先级"),
is_read: Optional[bool] = Query(None, description="是否已读"),
start_time: Optional[datetime] = Query(None, description="开始时间"),
end_time: Optional[datetime] = Query(None, description="结束时间"),
keyword: Optional[str] = Query(None, description="关键词"),
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取消息通知列表
- **skip**: 跳过条数
- **limit**: 返回条数最大100
- **notification_type**: 通知类型筛选
- **priority**: 优先级筛选
- **is_read**: 是否已读筛选
- **start_time**: 开始时间筛选
- **end_time**: 结束时间筛选
- **keyword**: 关键词搜索
注意:普通用户只能查看自己的通知,管理员可以查看所有通知
"""
recipient_id = None if current_user.is_superuser else current_user.id
return await notification_service.get_notifications(
db,
skip=skip,
limit=limit,
recipient_id=recipient_id,
notification_type=notification_type,
priority=priority,
is_read=is_read,
start_time=start_time,
end_time=end_time,
keyword=keyword
)
@router.get("/unread-count", response_model=Dict[str, Any])
async def get_unread_count(
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取当前用户未读通知数量
返回未读通知数量
"""
return await notification_service.get_unread_count(db, current_user.id)
@router.get("/statistics", response_model=Dict[str, Any])
async def get_notification_statistics(
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取当前用户通知统计信息
返回通知总数、未读数、已读数、高优先级数、紧急通知数、类型分布等统计信息
"""
return await notification_service.get_statistics(db, current_user.id)
@router.get("/{notification_id}", response_model=Dict[str, Any])
async def get_notification(
notification_id: int,
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取消息通知详情
- **notification_id**: 通知ID
注意:只能查看自己的通知,管理员可以查看所有通知
"""
notification = await notification_service.get_notification(db, notification_id)
if not notification:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="通知不存在"
)
# 检查权限
if not current_user.is_superuser and notification["recipient_id"] != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="无权查看此通知"
)
return notification
@router.post("/", response_model=Dict[str, Any], status_code=status.HTTP_201_CREATED)
async def create_notification(
obj_in: NotificationCreate,
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
创建消息通知
- **recipient_id**: 接收人ID
- **title**: 通知标题
- **content**: 通知内容
- **notification_type**: 通知类型
- **priority**: 优先级low/normal/high/urgent
- **related_entity_type**: 关联实体类型
- **related_entity_id**: 关联实体ID
- **action_url**: 操作链接
- **extra_data**: 额外数据
- **send_email**: 是否发送邮件
- **send_sms**: 是否发送短信
- **expire_at**: 过期时间
"""
try:
return await notification_service.create_notification(db, obj_in=obj_in)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.post("/batch", response_model=Dict[str, Any])
async def batch_create_notifications(
batch_in: NotificationBatchCreate,
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
批量创建消息通知
- **recipient_ids**: 接收人ID列表
- **title**: 通知标题
- **content**: 通知内容
- **notification_type**: 通知类型
- **priority**: 优先级
- **action_url**: 操作链接
- **extra_data**: 额外数据
"""
return await notification_service.batch_create_notifications(db, batch_in=batch_in)
@router.post("/from-template", response_model=Dict[str, Any])
async def send_from_template(
template_in: NotificationSendFromTemplate,
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
从模板发送通知
- **template_code**: 模板编码
- **recipient_ids**: 接收人ID列表
- **variables**: 模板变量
- **related_entity_type**: 关联实体类型
- **related_entity_id**: 关联实体ID
- **action_url**: 操作链接
"""
try:
return await notification_service.send_from_template(db, template_in=template_in)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.put("/{notification_id}/read", response_model=Dict[str, Any])
async def mark_notification_as_read(
notification_id: int,
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
标记通知为已读
- **notification_id**: 通知ID
"""
try:
notification = await notification_service.get_notification(db, notification_id)
if not notification:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="通知不存在"
)
# 检查权限
if not current_user.is_superuser and notification["recipient_id"] != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="无权操作此通知"
)
return await notification_service.mark_as_read(db, notification_id)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.put("/read-all", response_model=Dict[str, Any])
async def mark_all_as_read(
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
标记所有未读通知为已读
将当前用户的所有未读通知标记为已读
"""
return await notification_service.mark_all_as_read(db, current_user.id)
@router.delete("/{notification_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_notification(
notification_id: int,
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
删除消息通知
- **notification_id**: 通知ID
"""
notification = await notification_service.get_notification(db, notification_id)
if not notification:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="通知不存在"
)
# 检查权限
if not current_user.is_superuser and notification["recipient_id"] != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="无权删除此通知"
)
await notification_service.delete_notification(db, notification_id)
return None
@router.post("/batch-delete", response_model=Dict[str, Any])
async def batch_delete_notifications(
notification_ids: List[int],
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
批量删除消息通知
- **notification_ids**: 通知ID列表
"""
# 检查权限
if not current_user.is_superuser:
# 普通用户只能删除自己的通知
notifications = await notification_service.get_notifications(
db,
skip=0,
limit=len(notification_ids) * 2
)
valid_ids = [
n["id"] for n in notifications["items"]
if n["recipient_id"] == current_user.id and n["id"] in notification_ids
]
if not valid_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="没有有效的通知ID"
)
notification_ids = valid_ids
return await notification_service.batch_delete_notifications(db, notification_ids)

View File

@@ -0,0 +1,205 @@
"""
操作日志管理API路由
"""
from typing import Optional, List, Dict, Any
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db, get_current_user
from app.schemas.operation_log import (
OperationLogCreate,
OperationLogResponse,
OperationLogQueryParams,
OperationLogStatistics,
OperationLogExport
)
from app.services.operation_log_service import operation_log_service
router = APIRouter()
@router.get("/", response_model=Dict[str, Any])
async def get_operation_logs(
skip: int = Query(0, ge=0, description="跳过条数"),
limit: int = Query(20, ge=1, le=100, description="返回条数"),
operator_id: Optional[int] = Query(None, description="操作人ID"),
operator_name: Optional[str] = Query(None, description="操作人姓名"),
module: Optional[str] = Query(None, description="模块名称"),
operation_type: Optional[str] = Query(None, description="操作类型"),
result: Optional[str] = Query(None, description="操作结果"),
start_time: Optional[datetime] = Query(None, description="开始时间"),
end_time: Optional[datetime] = Query(None, description="结束时间"),
keyword: Optional[str] = Query(None, description="关键词"),
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取操作日志列表
- **skip**: 跳过条数
- **limit**: 返回条数最大100
- **operator_id**: 操作人ID筛选
- **operator_name**: 操作人姓名筛选
- **module**: 模块名称筛选
- **operation_type**: 操作类型筛选
- **result**: 操作结果筛选
- **start_time**: 开始时间筛选
- **end_time**: 结束时间筛选
- **keyword**: 关键词搜索
"""
return await operation_log_service.get_logs(
db,
skip=skip,
limit=limit,
operator_id=operator_id,
operator_name=operator_name,
module=module,
operation_type=operation_type,
result=result,
start_time=start_time,
end_time=end_time,
keyword=keyword
)
@router.get("/statistics", response_model=Dict[str, Any])
async def get_operation_statistics(
start_time: Optional[datetime] = Query(None, description="开始时间"),
end_time: Optional[datetime] = Query(None, description="结束时间"),
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取操作日志统计信息
- **start_time**: 开始时间
- **end_time**: 结束时间
返回操作总数、成功数、失败数、今日操作数、模块分布、操作类型分布等统计信息
"""
return await operation_log_service.get_statistics(
db,
start_time=start_time,
end_time=end_time
)
@router.get("/top-operators", response_model=List[Dict[str, Any]])
async def get_top_operators(
limit: int = Query(10, ge=1, le=50, description="返回条数"),
start_time: Optional[datetime] = Query(None, description="开始时间"),
end_time: Optional[datetime] = Query(None, description="结束时间"),
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取操作排行榜
- **limit**: 返回条数
- **start_time**: 开始时间
- **end_time**: 结束时间
返回操作次数最多的用户列表
"""
return await operation_log_service.get_operator_top(
db,
limit=limit,
start_time=start_time,
end_time=end_time
)
@router.get("/{log_id}", response_model=Dict[str, Any])
async def get_operation_log(
log_id: int,
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取操作日志详情
- **log_id**: 日志ID
"""
log = await operation_log_service.get_log(db, log_id)
if not log:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="操作日志不存在"
)
return log
@router.post("/", response_model=Dict[str, Any], status_code=status.HTTP_201_CREATED)
async def create_operation_log(
obj_in: OperationLogCreate,
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
创建操作日志(通常由系统自动记录)
- **operator_id**: 操作人ID
- **operator_name**: 操作人姓名
- **operator_ip**: 操作人IP
- **module**: 模块名称
- **operation_type**: 操作类型
- **method**: 请求方法
- **url**: 请求URL
- **params**: 请求参数
- **result**: 操作结果
- **error_msg**: 错误信息
- **duration**: 执行时长(毫秒)
- **user_agent**: 用户代理
- **extra_data**: 额外数据
"""
return await operation_log_service.create_log(db, obj_in=obj_in)
@router.post("/export", response_model=List[Dict[str, Any]])
async def export_operation_logs(
export_config: OperationLogExport,
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
导出操作日志
- **start_time**: 开始时间
- **end_time**: 结束时间
- **operator_id**: 操作人ID
- **module**: 模块名称
- **operation_type**: 操作类型
返回可导出的日志列表
"""
return await operation_log_service.export_logs(
db,
start_time=export_config.start_time,
end_time=export_config.end_time,
operator_id=export_config.operator_id,
module=export_config.module,
operation_type=export_config.operation_type
)
@router.delete("/old-logs", response_model=Dict[str, Any])
async def delete_old_logs(
days: int = Query(90, ge=1, le=365, description="保留天数"),
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
删除旧操作日志
- **days**: 保留天数默认90天
删除指定天数之前的操作日志
"""
# 检查权限
if not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="只有超级管理员可以删除日志"
)
return await operation_log_service.delete_old_logs(db, days=days)

240
app/api/v1/organizations.py Normal file
View File

@@ -0,0 +1,240 @@
"""
机构网点API路由
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from app.core.deps import get_db, get_current_user
from app.schemas.organization import (
OrganizationCreate,
OrganizationUpdate,
OrganizationResponse,
OrganizationTreeNode,
OrganizationWithParent
)
from app.services.organization_service import organization_service
from app.utils.redis_client import redis_client
router = APIRouter()
# 异步缓存包装器
@redis_client.cached_async("organizations:list", expire=1800) # 缓存30分钟
async def _cached_get_organizations(
skip: int,
limit: int,
org_type: Optional[str],
status: Optional[str],
keyword: Optional[str],
db: Session
):
"""获取机构列表的缓存包装器"""
items, total = organization_service.get_organizations(
db=db,
skip=skip,
limit=limit,
org_type=org_type,
status=status,
keyword=keyword
)
return items
@redis_client.cached_async("organizations:tree", expire=1800) # 缓存30分钟
async def _cached_get_organization_tree(
status: Optional[str],
db: Session
):
"""获取机构树的缓存包装器"""
return organization_service.get_organization_tree(db, status)
@router.get("/", response_model=List[OrganizationResponse])
async def get_organizations(
skip: int = Query(0, ge=0, description="跳过条数"),
limit: int = Query(20, ge=1, le=100, description="返回条数"),
org_type: Optional[str] = Query(None, description="机构类型"),
status: Optional[str] = Query(None, description="状态"),
keyword: Optional[str] = Query(None, description="搜索关键词"),
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取机构列表已启用缓存30分钟
- **skip**: 跳过条数
- **limit**: 返回条数最大100
- **org_type**: 机构类型筛选province/city/outlet
- **status**: 状态筛选active/inactive
- **keyword**: 搜索关键词(代码或名称)
"""
return await _cached_get_organizations(
skip=skip,
limit=limit,
org_type=org_type,
status=status,
keyword=keyword,
db=db
)
@router.get("/tree", response_model=List[OrganizationTreeNode])
async def get_organization_tree(
status: Optional[str] = Query(None, description="状态筛选"),
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取机构树已启用缓存30分钟
- **status**: 状态筛选active/inactive
返回树形结构的机构列表
"""
return await _cached_get_organization_tree(status, db)
@router.get("/{org_id}", response_model=OrganizationWithParent)
def get_organization(
org_id: int,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取机构详情
- **org_id**: 机构ID
返回机构详情及其父机构信息
"""
org = organization_service.get_organization(db, org_id)
# 加载父机构信息
if org.parent_id:
from app.crud.organization import organization as organization_crud
parent = organization_crud.get(db, org.parent_id)
org.parent = parent
return org
@router.get("/{org_id}/children", response_model=List[OrganizationResponse])
def get_organization_children(
org_id: int,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取直接子机构
- **org_id**: 父机构ID0表示根节点
返回指定机构的直接子机构列表
"""
return organization_service.get_organization_children(db, org_id)
@router.get("/{org_id}/all-children", response_model=List[OrganizationResponse])
def get_all_organization_children(
org_id: int,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
递归获取所有子机构
- **org_id**: 父机构ID
返回指定机构的所有子机构(包括子节点的子节点)
"""
return organization_service.get_all_children(db, org_id)
@router.get("/{org_id}/parents", response_model=List[OrganizationResponse])
def get_organization_parents(
org_id: int,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
递归获取所有父机构
- **org_id**: 子机构ID
返回从根到直接父节点的所有父机构列表
"""
return organization_service.get_parents(db, org_id)
@router.post("/", response_model=OrganizationResponse, status_code=status.HTTP_201_CREATED)
def create_organization(
obj_in: OrganizationCreate,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
创建机构
- **org_code**: 机构代码(唯一)
- **org_name**: 机构名称
- **org_type**: 机构类型province/city/outlet
- **parent_id**: 父机构ID可选
- **address**: 地址
- **contact_person**: 联系人
- **contact_phone**: 联系电话
- **sort_order**: 排序
"""
return organization_service.create_organization(
db=db,
obj_in=obj_in,
creator_id=current_user.id
)
@router.put("/{org_id}", response_model=OrganizationResponse)
def update_organization(
org_id: int,
obj_in: OrganizationUpdate,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
更新机构
- **org_id**: 机构ID
- **org_name**: 机构名称
- **org_type**: 机构类型
- **parent_id**: 父机构ID
- **address**: 地址
- **contact_person**: 联系人
- **contact_phone**: 联系电话
- **status**: 状态
- **sort_order**: 排序
"""
return organization_service.update_organization(
db=db,
org_id=org_id,
obj_in=obj_in,
updater_id=current_user.id
)
@router.delete("/{org_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_organization(
org_id: int,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
删除机构
- **org_id**: 机构ID
软删除机构(如果机构下存在子机构则无法删除)
"""
organization_service.delete_organization(
db=db,
org_id=org_id,
deleter_id=current_user.id
)
return None

244
app/api/v1/recoveries.py Normal file
View File

@@ -0,0 +1,244 @@
"""
资产回收管理API路由
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from app.core.deps import get_db, get_current_user
from app.schemas.recovery import (
AssetRecoveryOrderCreate,
AssetRecoveryOrderUpdate,
AssetRecoveryOrderWithRelations,
AssetRecoveryOrderQueryParams,
AssetRecoveryStatistics
)
from app.services.recovery_service import recovery_service
router = APIRouter()
@router.get("/", response_model=list)
def get_recovery_orders(
skip: int = Query(0, ge=0, description="跳过条数"),
limit: int = Query(20, ge=1, le=100, description="返回条数"),
recovery_type: Optional[str] = Query(None, description="回收类型"),
approval_status: Optional[str] = Query(None, description="审批状态"),
execute_status: Optional[str] = Query(None, description="执行状态"),
keyword: Optional[str] = Query(None, description="搜索关键词"),
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取回收单列表
- **skip**: 跳过条数
- **limit**: 返回条数最大100
- **recovery_type**: 回收类型user=使用人回收/org=机构回收/scrap=报废回收)
- **approval_status**: 审批状态pending/approved/rejected/cancelled
- **execute_status**: 执行状态pending/executing/completed/cancelled
- **keyword**: 搜索关键词(单号/标题)
"""
items, total = recovery_service.get_orders(
db=db,
skip=skip,
limit=limit,
recovery_type=recovery_type,
approval_status=approval_status,
execute_status=execute_status,
keyword=keyword
)
return items
@router.get("/statistics", response_model=AssetRecoveryStatistics)
def get_recovery_statistics(
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取回收单统计信息
返回回收单总数、待审批数、已审批数等统计信息
"""
return recovery_service.get_statistics(db)
@router.get("/{order_id}", response_model=dict)
def get_recovery_order(
order_id: int,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取回收单详情
- **order_id**: 回收单ID
返回回收单详情及其关联信息(包含明细列表)
"""
return recovery_service.get_order(db, order_id)
@router.get("/{order_id}/items", response_model=list)
def get_recovery_order_items(
order_id: int,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取回收单明细列表
- **order_id**: 回收单ID
返回该回收单的所有资产明细
"""
return recovery_service.get_order_items(db, order_id)
@router.post("/", response_model=dict, status_code=status.HTTP_201_CREATED)
def create_recovery_order(
obj_in: AssetRecoveryOrderCreate,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
创建回收单
- **recovery_type**: 回收类型
- user: 使用人回收(从使用人处回收)
- org: 机构回收(从机构回收)
- scrap: 报废回收(报废资产回收)
- **title**: 标题
- **asset_ids**: 资产ID列表
- **remark**: 备注
创建后状态为待审批,需要审批后才能执行
"""
return recovery_service.create_order(
db=db,
obj_in=obj_in,
apply_user_id=current_user.id
)
@router.put("/{order_id}", response_model=dict)
def update_recovery_order(
order_id: int,
obj_in: AssetRecoveryOrderUpdate,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
更新回收单
- **order_id**: 回收单ID
- **title**: 标题
- **remark**: 备注
只有待审批状态的回收单可以更新
"""
return recovery_service.update_order(
db=db,
order_id=order_id,
obj_in=obj_in
)
@router.post("/{order_id}/approve", response_model=dict)
def approve_recovery_order(
order_id: int,
approval_status: str = Query(..., description="审批状态(approved/rejected)"),
approval_remark: Optional[str] = Query(None, description="审批备注"),
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
审批回收单
- **order_id**: 回收单ID
- **approval_status**: 审批状态approved/rejected
- **approval_remark**: 审批备注
审批通过后可以开始执行回收
"""
return recovery_service.approve_order(
db=db,
order_id=order_id,
approval_status=approval_status,
approval_user_id=current_user.id,
approval_remark=approval_remark
)
@router.post("/{order_id}/start", response_model=dict)
def start_recovery_order(
order_id: int,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
开始回收
- **order_id**: 回收单ID
开始执行已审批通过的回收单
"""
return recovery_service.start_order(
db=db,
order_id=order_id,
execute_user_id=current_user.id
)
@router.post("/{order_id}/complete", response_model=dict)
def complete_recovery_order(
order_id: int,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
完成回收
- **order_id**: 回收单ID
完成回收单,自动更新资产状态为库存中或报废
"""
return recovery_service.complete_order(
db=db,
order_id=order_id,
execute_user_id=current_user.id
)
@router.post("/{order_id}/cancel", status_code=status.HTTP_204_NO_CONTENT)
def cancel_recovery_order(
order_id: int,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
取消回收单
- **order_id**: 回收单ID
取消回收单(已完成的无法取消)
"""
recovery_service.cancel_order(db, order_id)
return None
@router.delete("/{order_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_recovery_order(
order_id: int,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
删除回收单
- **order_id**: 回收单ID
只能删除已拒绝或已取消的回收单
"""
recovery_service.delete_order(db, order_id)
return None

211
app/api/v1/statistics.py Normal file
View File

@@ -0,0 +1,211 @@
"""
统计分析API路由
"""
from typing import Optional, Dict, Any
from datetime import date
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db, get_current_user
from app.services.statistics_service import statistics_service
router = APIRouter()
@router.get("/overview", response_model=Dict[str, Any])
async def get_statistics_overview(
organization_id: Optional[int] = Query(None, description="网点ID筛选"),
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取总览统计
- **organization_id**: 网点ID筛选
返回资产总数、总价值、各状态数量、采购统计、网点数等概览信息
"""
return await statistics_service.get_overview(db, organization_id=organization_id)
@router.get("/assets/purchase", response_model=Dict[str, Any])
async def get_purchase_statistics(
start_date: Optional[date] = Query(None, description="开始日期"),
end_date: Optional[date] = Query(None, description="结束日期"),
organization_id: Optional[int] = Query(None, description="网点ID筛选"),
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取采购统计
- **start_date**: 开始日期
- **end_date**: 结束日期
- **organization_id**: 网点ID筛选
返回采购数量、采购金额、月度趋势、供应商分布等统计信息
"""
return await statistics_service.get_purchase_statistics(
db,
start_date=start_date,
end_date=end_date,
organization_id=organization_id
)
@router.get("/assets/depreciation", response_model=Dict[str, Any])
async def get_depreciation_statistics(
organization_id: Optional[int] = Query(None, description="网点ID筛选"),
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取折旧统计
- **organization_id**: 网点ID筛选
返回折旧金额、折旧率、分类折旧等统计信息
"""
return await statistics_service.get_depreciation_statistics(db, organization_id=organization_id)
@router.get("/assets/value", response_model=Dict[str, Any])
async def get_value_statistics(
organization_id: Optional[int] = Query(None, description="网点ID筛选"),
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取价值统计
- **organization_id**: 网点ID筛选
返回资产总价值、净值、折旧、分类价值、网点价值、高价值资产等统计信息
"""
return await statistics_service.get_value_statistics(db, organization_id=organization_id)
@router.get("/assets/trend", response_model=Dict[str, Any])
async def get_trend_analysis(
start_date: Optional[date] = Query(None, description="开始日期"),
end_date: Optional[date] = Query(None, description="结束日期"),
organization_id: Optional[int] = Query(None, description="网点ID筛选"),
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取趋势分析
- **start_date**: 开始日期
- **end_date**: 结束日期
- **organization_id**: 网点ID筛选
返回资产数量趋势、价值趋势、采购趋势、维修趋势、调拨趋势等分析数据
"""
return await statistics_service.get_trend_analysis(
db,
start_date=start_date,
end_date=end_date,
organization_id=organization_id
)
@router.get("/maintenance/summary", response_model=Dict[str, Any])
async def get_maintenance_summary(
start_date: Optional[date] = Query(None, description="开始日期"),
end_date: Optional[date] = Query(None, description="结束日期"),
organization_id: Optional[int] = Query(None, description="网点ID筛选"),
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取维修汇总
- **start_date**: 开始日期
- **end_date**: 结束日期
- **organization_id**: 网点ID筛选
返回维修次数、维修费用、状态分布等统计信息
"""
return await statistics_service.get_maintenance_statistics(
db,
start_date=start_date,
end_date=end_date,
organization_id=organization_id
)
@router.get("/allocation/summary", response_model=Dict[str, Any])
async def get_allocation_summary(
start_date: Optional[date] = Query(None, description="开始日期"),
end_date: Optional[date] = Query(None, description="结束日期"),
organization_id: Optional[int] = Query(None, description="网点ID筛选"),
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取分配汇总
- **start_date**: 开始日期
- **end_date**: 结束日期
- **organization_id**: 网点ID筛选
返回分配次数、状态分布、网点分配统计等信息
"""
return await statistics_service.get_allocation_statistics(
db,
start_date=start_date,
end_date=end_date,
organization_id=organization_id
)
@router.post("/export")
async def export_statistics(
report_type: str = Query(..., description="报表类型"),
start_date: Optional[date] = Query(None, description="开始日期"),
end_date: Optional[date] = Query(None, description="结束日期"),
organization_id: Optional[int] = Query(None, description="网点ID"),
format: str = Query("xlsx", description="导出格式"),
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
导出统计报表
- **report_type**: 报表类型overview/purchase/depreciation/value/trend/maintenance/allocation
- **start_date**: 开始日期
- **end_date**: 结束日期
- **organization_id**: 网点ID
- **format**: 导出格式xlsx/csv/pdf
返回导出文件信息
"""
# 根据报表类型获取数据
if report_type == "overview":
data = await statistics_service.get_overview(db, organization_id)
elif report_type == "purchase":
data = await statistics_service.get_purchase_statistics(db, start_date, end_date, organization_id)
elif report_type == "depreciation":
data = await statistics_service.get_depreciation_statistics(db, organization_id)
elif report_type == "value":
data = await statistics_service.get_value_statistics(db, organization_id)
elif report_type == "trend":
data = await statistics_service.get_trend_analysis(db, start_date, end_date, organization_id)
elif report_type == "maintenance":
data = await statistics_service.get_maintenance_statistics(db, start_date, end_date, organization_id)
elif report_type == "allocation":
data = await statistics_service.get_allocation_statistics(db, start_date, end_date, organization_id)
else:
raise ValueError(f"不支持的报表类型: {report_type}")
# TODO: 实现导出逻辑
# 1. 生成Excel/CSV/PDF文件
# 2. 保存到文件系统
# 3. 返回文件URL
return {
"message": "导出功能待实现",
"data": data,
"report_type": report_type,
"format": format
}

244
app/api/v1/system_config.py Normal file
View File

@@ -0,0 +1,244 @@
"""
系统配置管理API路由
"""
from typing import Optional, List, Dict, Any
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db, get_current_user
from app.schemas.system_config import (
SystemConfigCreate,
SystemConfigUpdate,
SystemConfigResponse,
SystemConfigBatchUpdate,
SystemConfigQueryParams,
ConfigCategoryResponse
)
from app.services.system_config_service import system_config_service
router = APIRouter()
@router.get("/", response_model=Dict[str, Any])
async def get_configs(
skip: int = Query(0, ge=0, description="跳过条数"),
limit: int = Query(20, ge=1, le=100, description="返回条数"),
keyword: Optional[str] = Query(None, description="搜索关键词"),
category: Optional[str] = Query(None, description="配置分类"),
is_active: Optional[bool] = Query(None, description="是否启用"),
is_system: Optional[bool] = Query(None, description="是否系统配置"),
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取系统配置列表
- **skip**: 跳过条数
- **limit**: 返回条数最大100
- **keyword**: 搜索关键词(配置键/配置名称/描述)
- **category**: 配置分类筛选
- **is_active**: 是否启用筛选
- **is_system**: 是否系统配置筛选
"""
return await system_config_service.get_configs(
db,
skip=skip,
limit=limit,
keyword=keyword,
category=category,
is_active=is_active,
is_system=is_system
)
@router.get("/categories", response_model=List[Dict[str, Any]])
async def get_config_categories(
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取所有配置分类
返回配置分类及每个分类的配置数量
"""
return await system_config_service.get_categories(db)
@router.get("/category/{category}", response_model=List[Dict[str, Any]])
async def get_configs_by_category(
category: str,
is_active: bool = Query(True, description="是否启用"),
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
根据分类获取配置
- **category**: 配置分类
- **is_active**: 是否启用
"""
return await system_config_service.get_configs_by_category(
db,
category=category,
is_active=is_active
)
@router.get("/key/{config_key}", response_model=Any)
async def get_config_by_key(
config_key: str,
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
根据配置键获取配置值
- **config_key**: 配置键
返回配置的实际值(已根据类型转换)
"""
value = await system_config_service.get_config_by_key(db, config_key)
if value is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"配置键 {config_key} 不存在或未启用"
)
return {"config_key": config_key, "value": value}
@router.get("/{config_id}", response_model=Dict[str, Any])
async def get_config(
config_id: int,
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取配置详情
- **config_id**: 配置ID
"""
config = await system_config_service.get_config(db, config_id)
if not config:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="配置不存在"
)
return config
@router.post("/", response_model=Dict[str, Any], status_code=status.HTTP_201_CREATED)
async def create_config(
obj_in: SystemConfigCreate,
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
创建系统配置
- **config_key**: 配置键(唯一)
- **config_name**: 配置名称
- **config_value**: 配置值
- **value_type**: 值类型string/number/boolean/json
- **category**: 配置分类
- **description**: 配置描述
- **is_system**: 是否系统配置(系统配置不允许删除和修改部分字段)
- **is_encrypted**: 是否加密存储
- **options**: 可选值配置
- **default_value**: 默认值
- **sort_order**: 排序序号
- **is_active**: 是否启用
"""
try:
return await system_config_service.create_config(
db,
obj_in=obj_in,
creator_id=current_user.id
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.put("/{config_id}", response_model=Dict[str, Any])
async def update_config(
config_id: int,
obj_in: SystemConfigUpdate,
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
更新系统配置
- **config_id**: 配置ID
- **config_name**: 配置名称
- **config_value**: 配置值
- **description**: 配置描述
- **options**: 可选值配置
- **default_value**: 默认值
- **sort_order**: 排序序号
- **is_active**: 是否启用
"""
try:
return await system_config_service.update_config(
db,
config_id=config_id,
obj_in=obj_in,
updater_id=current_user.id
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.post("/batch", response_model=Dict[str, Any])
async def batch_update_configs(
batch_update: SystemConfigBatchUpdate,
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
批量更新配置
- **configs**: 配置键值对字典
示例:
```json
{
"configs": {
"system.title": "资产管理系统",
"system.max_upload_size": 10485760
}
}
```
"""
return await system_config_service.batch_update_configs(
db,
configs=batch_update.configs,
updater_id=current_user.id
)
@router.delete("/{config_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_config(
config_id: int,
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
删除系统配置
- **config_id**: 配置ID
注意:系统配置不允许删除
"""
try:
await system_config_service.delete_config(db, config_id)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
return None

254
app/api/v1/transfers.py Normal file
View File

@@ -0,0 +1,254 @@
"""
资产调拨管理API路由
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from app.core.deps import get_db, get_current_user
from app.schemas.transfer import (
AssetTransferOrderCreate,
AssetTransferOrderUpdate,
AssetTransferOrderWithRelations,
AssetTransferOrderQueryParams,
AssetTransferStatistics
)
from app.services.transfer_service import transfer_service
router = APIRouter()
@router.get("/", response_model=list)
def get_transfer_orders(
skip: int = Query(0, ge=0, description="跳过条数"),
limit: int = Query(20, ge=1, le=100, description="返回条数"),
transfer_type: Optional[str] = Query(None, description="调拨类型"),
approval_status: Optional[str] = Query(None, description="审批状态"),
execute_status: Optional[str] = Query(None, description="执行状态"),
source_org_id: Optional[int] = Query(None, description="调出网点ID"),
target_org_id: Optional[int] = Query(None, description="调入网点ID"),
keyword: Optional[str] = Query(None, description="搜索关键词"),
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取调拨单列表
- **skip**: 跳过条数
- **limit**: 返回条数最大100
- **transfer_type**: 调拨类型internal=内部调拨/external=跨机构调拨)
- **approval_status**: 审批状态pending/approved/rejected/cancelled
- **execute_status**: 执行状态pending/executing/completed/cancelled
- **source_org_id**: 调出网点ID
- **target_org_id**: 调入网点ID
- **keyword**: 搜索关键词(单号/标题)
"""
items, total = transfer_service.get_orders(
db=db,
skip=skip,
limit=limit,
transfer_type=transfer_type,
approval_status=approval_status,
execute_status=execute_status,
source_org_id=source_org_id,
target_org_id=target_org_id,
keyword=keyword
)
return items
@router.get("/statistics", response_model=AssetTransferStatistics)
def get_transfer_statistics(
source_org_id: Optional[int] = Query(None, description="调出网点ID"),
target_org_id: Optional[int] = Query(None, description="调入网点ID"),
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取调拨单统计信息
- **source_org_id**: 调出网点ID可选
- **target_org_id**: 调入网点ID可选
返回调拨单总数、待审批数、已审批数等统计信息
"""
return transfer_service.get_statistics(db, source_org_id, target_org_id)
@router.get("/{order_id}", response_model=dict)
def get_transfer_order(
order_id: int,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取调拨单详情
- **order_id**: 调拨单ID
返回调拨单详情及其关联信息(包含明细列表)
"""
return transfer_service.get_order(db, order_id)
@router.get("/{order_id}/items", response_model=list)
def get_transfer_order_items(
order_id: int,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取调拨单明细列表
- **order_id**: 调拨单ID
返回该调拨单的所有资产明细
"""
return transfer_service.get_order_items(db, order_id)
@router.post("/", response_model=dict, status_code=status.HTTP_201_CREATED)
def create_transfer_order(
obj_in: AssetTransferOrderCreate,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
创建调拨单
- **source_org_id**: 调出网点ID
- **target_org_id**: 调入网点ID
- **transfer_type**: 调拨类型internal=内部调拨/external=跨机构调拨)
- **title**: 标题
- **asset_ids**: 资产ID列表
- **remark**: 备注
创建后状态为待审批,需要审批后才能执行
"""
return transfer_service.create_order(
db=db,
obj_in=obj_in,
apply_user_id=current_user.id
)
@router.put("/{order_id}", response_model=dict)
def update_transfer_order(
order_id: int,
obj_in: AssetTransferOrderUpdate,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
更新调拨单
- **order_id**: 调拨单ID
- **title**: 标题
- **remark**: 备注
只有待审批状态的调拨单可以更新
"""
return transfer_service.update_order(
db=db,
order_id=order_id,
obj_in=obj_in
)
@router.post("/{order_id}/approve", response_model=dict)
def approve_transfer_order(
order_id: int,
approval_status: str = Query(..., description="审批状态(approved/rejected)"),
approval_remark: Optional[str] = Query(None, description="审批备注"),
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
审批调拨单
- **order_id**: 调拨单ID
- **approval_status**: 审批状态approved/rejected
- **approval_remark**: 审批备注
审批通过后可以开始执行调拨
"""
return transfer_service.approve_order(
db=db,
order_id=order_id,
approval_status=approval_status,
approval_user_id=current_user.id,
approval_remark=approval_remark
)
@router.post("/{order_id}/start", response_model=dict)
def start_transfer_order(
order_id: int,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
开始调拨
- **order_id**: 调拨单ID
开始执行已审批通过的调拨单
"""
return transfer_service.start_order(
db=db,
order_id=order_id,
execute_user_id=current_user.id
)
@router.post("/{order_id}/complete", response_model=dict)
def complete_transfer_order(
order_id: int,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
完成调拨
- **order_id**: 调拨单ID
完成调拨单,自动更新资产机构和状态
"""
return transfer_service.complete_order(
db=db,
order_id=order_id,
execute_user_id=current_user.id
)
@router.post("/{order_id}/cancel", status_code=status.HTTP_204_NO_CONTENT)
def cancel_transfer_order(
order_id: int,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
取消调拨单
- **order_id**: 调拨单ID
取消调拨单(已完成的无法取消)
"""
transfer_service.cancel_order(db, order_id)
return None
@router.delete("/{order_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_transfer_order(
order_id: int,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
删除调拨单
- **order_id**: 调拨单ID
只能删除已拒绝或已取消的调拨单
"""
transfer_service.delete_order(db, order_id)
return None