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

4
app/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
"""
应用模块初始化
"""
__all__ = []

4
app/api/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
"""
API模块初始化
"""
__all__ = []

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

6
app/core/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
"""
核心模块初始化
"""
from app.core.config import settings
__all__ = ["settings"]

109
app/core/config.py Normal file
View File

@@ -0,0 +1,109 @@
"""
应用配置模块
"""
from typing import List, Optional
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""应用配置类"""
# 应用基本信息
APP_NAME: str = Field(default="资产管理系统", description="应用名称")
APP_VERSION: str = Field(default="1.0.0", description="应用版本")
APP_ENVIRONMENT: str = Field(default="development", description="运行环境")
DEBUG: bool = Field(default=False, description="调试模式")
API_V1_PREFIX: str = Field(default="/api/v1", description="API V1 前缀")
# 服务器配置
HOST: str = Field(default="0.0.0.0", description="服务器地址")
PORT: int = Field(default=8000, description="服务器端口")
# 数据库配置
DATABASE_URL: str = Field(
default="postgresql+asyncpg://postgres:postgres@localhost:5432/asset_management",
description="数据库连接URL"
)
DATABASE_ECHO: bool = Field(default=False, description="是否打印SQL语句")
# Redis配置
REDIS_URL: str = Field(default="redis://localhost:6379/0", description="Redis连接URL")
REDIS_MAX_CONNECTIONS: int = Field(default=50, description="Redis最大连接数")
# JWT配置
SECRET_KEY: str = Field(default="your-secret-key-change-in-production", description="JWT密钥")
ALGORITHM: str = Field(default="HS256", description="JWT算法")
ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=15, description="访问令牌过期时间(分钟)")
REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=7, description="刷新令牌过期时间(天)")
# CORS配置
CORS_ORIGINS: List[str] = Field(
default=["http://localhost:5173", "http://localhost:3000"],
description="允许的跨域来源"
)
CORS_ALLOW_CREDENTIALS: bool = Field(default=True, description="允许携带凭证")
CORS_ALLOW_METHODS: List[str] = Field(default=["*"], description="允许的HTTP方法")
CORS_ALLOW_HEADERS: List[str] = Field(default=["*"], description="允许的请求头")
# 文件上传配置
UPLOAD_DIR: str = Field(default="uploads", description="上传文件目录")
MAX_UPLOAD_SIZE: int = Field(default=10485760, description="最大上传大小(字节)")
ALLOWED_EXTENSIONS: List[str] = Field(
default=["png", "jpg", "jpeg", "gif", "pdf", "xlsx", "xls"],
description="允许的文件扩展名"
)
# 验证码配置
CAPTCHA_EXPIRE_SECONDS: int = Field(default=300, description="验证码过期时间(秒)")
CAPTCHA_LENGTH: int = Field(default=4, description="验证码长度")
# 日志配置
LOG_LEVEL: str = Field(default="INFO", description="日志级别")
LOG_FILE: str = Field(default="logs/app.log", description="日志文件路径")
LOG_ROTATION: str = Field(default="500 MB", description="日志轮转大小")
LOG_RETENTION: str = Field(default="10 days", description="日志保留时间")
# 分页配置
DEFAULT_PAGE_SIZE: int = Field(default=20, description="默认每页数量")
MAX_PAGE_SIZE: int = Field(default=100, description="最大每页数量")
# 二维码配置
QR_CODE_DIR: str = Field(default="uploads/qrcodes", description="二维码保存目录")
QR_CODE_SIZE: int = Field(default=300, description="二维码尺寸")
QR_CODE_BORDER: int = Field(default=2, description="二维码边框")
@field_validator("CORS_ORIGINS", mode="before")
@classmethod
def parse_cors_origins(cls, v: str) -> List[str]:
"""解析CORS来源"""
if isinstance(v, str):
return [origin.strip() for origin in v.split(",")]
return v
@field_validator("ALLOWED_EXTENSIONS", mode="before")
@classmethod
def parse_allowed_extensions(cls, v: str) -> List[str]:
"""解析允许的文件扩展名"""
if isinstance(v, str):
return [ext.strip() for ext in v.split(",")]
return v
@property
def is_development(self) -> bool:
"""是否为开发环境"""
return self.APP_ENVIRONMENT == "development"
@property
def is_production(self) -> bool:
"""是否为生产环境"""
return self.APP_ENVIRONMENT == "production"
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
case_sensitive = True
# 创建全局配置实例
settings = Settings()

208
app/core/deps.py Normal file
View File

@@ -0,0 +1,208 @@
"""
依赖注入模块
"""
from typing import Generator, Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.db.session import async_session_maker
from app.core.security import security_manager
from app.models.user import User, Role, Permission, UserRole, RolePermission
# HTTP Bearer认证
security = HTTPBearer()
async def get_db() -> Generator:
"""
获取数据库会话
Yields:
AsyncSession: 数据库会话
"""
async with async_session_maker() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db)
) -> User:
"""
获取当前登录用户
Args:
credentials: HTTP认证凭据
db: 数据库会话
Returns:
User: 当前用户对象
Raises:
HTTPException: 认证失败或用户不存在
"""
from app.utils.redis_client import redis_client
token = credentials.credentials
# 检查Token是否在黑名单中
is_blacklisted = await redis_client.get(f"blacklist:{token}")
if is_blacklisted:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token已失效请重新登录",
headers={"WWW-Authenticate": "Bearer"}
)
payload = security_manager.verify_token(token, token_type="access")
user_id: int = payload.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的认证凭据",
headers={"WWW-Authenticate": "Bearer"}
)
from app.crud.user import user_crud
user = await user_crud.get(db, id=user_id)
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="用户不存在"
)
if user.status != "active":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="用户已被禁用"
)
return user
async def get_current_active_user(
current_user: User = Depends(get_current_user)
) -> User:
"""
获取当前活跃用户
Args:
current_user: 当前用户
Returns:
User: 活跃用户对象
Raises:
HTTPException: 用户未激活
"""
if current_user.status != "active":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="用户账户未激活"
)
return current_user
async def get_current_admin_user(
current_user: User = Depends(get_current_user)
) -> User:
"""
获取当前管理员用户
Args:
current_user: 当前用户
Returns:
User: 管理员用户对象
Raises:
HTTPException: 用户不是管理员
"""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="权限不足,需要管理员权限"
)
return current_user
class PermissionChecker:
"""
权限检查器
"""
def __init__(self, required_permission: str):
self.required_permission = required_permission
async def __call__(
self,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
) -> User:
"""
检查用户是否有指定权限
Args:
current_user: 当前用户
db: 数据库会话
Returns:
用户对象
Raises:
HTTPException: 权限不足
"""
# 管理员拥有所有权限
if current_user.is_admin:
return current_user
# 查询用户的所有权限
# 获取用户的角色
result = await db.execute(
select(Role)
.join(UserRole, UserRole.role_id == Role.id)
.where(UserRole.user_id == current_user.id)
.where(Role.deleted_at.is_(None))
)
roles = result.scalars().all()
# 获取角色对应的所有权限编码
if not roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="权限不足"
)
role_ids = [role.id for role in roles]
result = await db.execute(
select(Permission.permission_code)
.join(RolePermission, RolePermission.permission_id == Permission.id)
.where(RolePermission.role_id.in_(role_ids))
.where(Permission.deleted_at.is_(None))
)
permissions = result.scalars().all()
# 检查是否有必需的权限
if self.required_permission not in permissions:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"需要权限: {self.required_permission}"
)
return current_user
# 常用权限检查器
require_asset_read = PermissionChecker("asset:asset:read")
require_asset_create = PermissionChecker("asset:asset:create")
require_asset_update = PermissionChecker("asset:asset:update")
require_asset_delete = PermissionChecker("asset:asset:delete")

155
app/core/exceptions.py Normal file
View File

@@ -0,0 +1,155 @@
"""
自定义异常类
"""
from typing import Any, Dict, Optional
from fastapi import HTTPException, status
class BusinessException(Exception):
"""业务逻辑异常基类"""
def __init__(
self,
message: str,
code: int = status.HTTP_400_BAD_REQUEST,
error_code: Optional[str] = None,
data: Optional[Dict[str, Any]] = None
):
"""
初始化业务异常
Args:
message: 错误消息
code: HTTP状态码
error_code: 业务错误码
data: 附加数据
"""
self.message = message
self.code = code
self.error_code = error_code
self.data = data
super().__init__(self.message)
class NotFoundException(BusinessException):
"""资源不存在异常"""
def __init__(self, resource: str = "资源"):
super().__init__(
message=f"{resource}不存在",
code=status.HTTP_404_NOT_FOUND,
error_code="RESOURCE_NOT_FOUND"
)
class AlreadyExistsException(BusinessException):
"""资源已存在异常"""
def __init__(self, resource: str = "资源"):
super().__init__(
message=f"{resource}已存在",
code=status.HTTP_409_CONFLICT,
error_code="RESOURCE_ALREADY_EXISTS"
)
class PermissionDeniedException(BusinessException):
"""权限不足异常"""
def __init__(self, message: str = "权限不足"):
super().__init__(
message=message,
code=status.HTTP_403_FORBIDDEN,
error_code="PERMISSION_DENIED"
)
class AuthenticationFailedException(BusinessException):
"""认证失败异常"""
def __init__(self, message: str = "认证失败"):
super().__init__(
message=message,
code=status.HTTP_401_UNAUTHORIZED,
error_code="AUTHENTICATION_FAILED"
)
class ValidationFailedException(BusinessException):
"""验证失败异常"""
def __init__(self, message: str = "数据验证失败", errors: Optional[Dict] = None):
super().__init__(
message=message,
code=status.HTTP_422_UNPROCESSABLE_ENTITY,
error_code="VALIDATION_FAILED",
data=errors
)
class InvalidCredentialsException(AuthenticationFailedException):
"""无效凭据异常"""
def __init__(self, message: str = "用户名或密码错误"):
super().__init__(message)
self.error_code = "INVALID_CREDENTIALS"
class TokenExpiredException(AuthenticationFailedException):
"""令牌过期异常"""
def __init__(self, message: str = "令牌已过期,请重新登录"):
super().__init__(message)
self.error_code = "TOKEN_EXPIRED"
class InvalidTokenException(AuthenticationFailedException):
"""无效令牌异常"""
def __init__(self, message: str = "无效的令牌"):
super().__init__(message)
self.error_code = "INVALID_TOKEN"
class CaptchaException(BusinessException):
"""验证码异常"""
def __init__(self, message: str = "验证码错误"):
super().__init__(
message=message,
code=status.HTTP_400_BAD_REQUEST,
error_code="CAPTCHA_ERROR"
)
class UserLockedException(BusinessException):
"""用户被锁定异常"""
def __init__(self, message: str = "用户已被锁定,请联系管理员"):
super().__init__(
message=message,
code=status.HTTP_403_FORBIDDEN,
error_code="USER_LOCKED"
)
class UserDisabledException(BusinessException):
"""用户被禁用异常"""
def __init__(self, message: str = "用户已被禁用"):
super().__init__(
message=message,
code=status.HTTP_403_FORBIDDEN,
error_code="USER_DISABLED"
)
class StateTransitionException(BusinessException):
"""状态转换异常"""
def __init__(self, current_state: str, target_state: str):
super().__init__(
message=f"无法从状态 '{current_state}' 转换到 '{target_state}'",
code=status.HTTP_400_BAD_REQUEST,
error_code="INVALID_STATE_TRANSITION"
)

152
app/core/response.py Normal file
View File

@@ -0,0 +1,152 @@
"""
统一响应封装模块
"""
from typing import Any, Generic, TypeVar, Optional, List
from pydantic import BaseModel, Field
from datetime import datetime
# 泛型类型变量
T = TypeVar("T")
class ResponseModel(BaseModel, Generic[T]):
"""统一响应模型"""
code: int = Field(default=200, description="响应状态码")
message: str = Field(default="success", description="响应消息")
data: Optional[T] = Field(default=None, description="响应数据")
timestamp: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="时间戳")
@classmethod
def success(cls, data: Optional[T] = None, message: str = "success") -> "ResponseModel[T]":
"""
成功响应
Args:
data: 响应数据
message: 响应消息
Returns:
ResponseModel: 响应对象
"""
return cls(code=200, message=message, data=data)
@classmethod
def error(
cls,
code: int,
message: str,
data: Optional[T] = None
) -> "ResponseModel[T]":
"""
错误响应
Args:
code: 错误码
message: 错误消息
data: 附加数据
Returns:
ResponseModel: 响应对象
"""
return cls(code=code, message=message, data=data)
class PaginationMeta(BaseModel):
"""分页元数据"""
total: int = Field(..., description="总记录数")
page: int = Field(..., ge=1, description="当前页码")
page_size: int = Field(..., ge=1, le=100, description="每页记录数")
total_pages: int = Field(..., ge=0, description="总页数")
class PaginatedResponse(BaseModel, Generic[T]):
"""分页响应模型"""
total: int = Field(..., description="总记录数")
page: int = Field(..., ge=1, description="当前页码")
page_size: int = Field(..., ge=1, description="每页记录数")
total_pages: int = Field(..., ge=0, description="总页数")
items: List[T] = Field(default_factory=list, description="数据列表")
class ValidationError(BaseModel):
"""验证错误详情"""
field: str = Field(..., description="字段名")
message: str = Field(..., description="错误消息")
class ErrorResponse(BaseModel):
"""错误响应模型"""
code: int = Field(..., description="错误码")
message: str = Field(..., description="错误消息")
errors: Optional[List[ValidationError]] = Field(default=None, description="错误详情列表")
timestamp: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="时间戳")
def success_response(data: Any = None, message: str = "success") -> dict:
"""
生成成功响应
Args:
data: 响应数据
message: 响应消息
Returns:
dict: 响应字典
"""
return ResponseModel.success(data=data, message=message).model_dump()
def error_response(code: int, message: str, errors: Optional[List[dict]] = None) -> dict:
"""
生成错误响应
Args:
code: 错误码
message: 错误消息
errors: 错误详情列表
Returns:
dict: 响应字典
"""
error_data = ErrorResponse(
code=code,
message=message,
errors=[ValidationError(**e) for e in errors] if errors else None
)
return error_data.model_dump()
def paginated_response(
items: List[Any],
total: int,
page: int,
page_size: int
) -> dict:
"""
生成分页响应
Args:
items: 数据列表
total: 总记录数
page: 当前页码
page_size: 每页记录数
Returns:
dict: 响应字典
"""
total_pages = (total + page_size - 1) // page_size if page_size > 0 else 0
response = PaginatedResponse(
total=total,
page=page,
page_size=page_size,
total_pages=total_pages,
items=items
)
return success_response(data=response.model_dump())

178
app/core/security.py Normal file
View File

@@ -0,0 +1,178 @@
"""
安全相关工具模块
"""
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import HTTPException, status
from app.core.config import settings
# 密码加密上下文
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
class SecurityManager:
"""安全管理器"""
def __init__(self):
self.secret_key = settings.SECRET_KEY
self.algorithm = settings.ALGORITHM
self.access_token_expire_minutes = settings.ACCESS_TOKEN_EXPIRE_MINUTES
self.refresh_token_expire_days = settings.REFRESH_TOKEN_EXPIRE_DAYS
def verify_password(self, plain_password: str, hashed_password: str) -> bool:
"""
验证密码
Args:
plain_password: 明文密码
hashed_password: 哈希密码
Returns:
bool: 密码是否匹配
"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(self, password: str) -> str:
"""
获取密码哈希值
Args:
password: 明文密码
Returns:
str: 哈希后的密码
"""
return pwd_context.hash(password)
def create_access_token(
self,
data: Dict[str, Any],
expires_delta: Optional[timedelta] = None
) -> str:
"""
创建访问令牌
Args:
data: 要编码的数据
expires_delta: 过期时间增量
Returns:
str: JWT令牌
"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=self.access_token_expire_minutes)
to_encode.update({
"exp": expire,
"type": "access"
})
encoded_jwt = jwt.encode(to_encode, self.secret_key, algorithm=self.algorithm)
return encoded_jwt
def create_refresh_token(
self,
data: Dict[str, Any],
expires_delta: Optional[timedelta] = None
) -> str:
"""
创建刷新令牌
Args:
data: 要编码的数据
expires_delta: 过期时间增量
Returns:
str: JWT令牌
"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(days=self.refresh_token_expire_days)
to_encode.update({
"exp": expire,
"type": "refresh"
})
encoded_jwt = jwt.encode(to_encode, self.secret_key, algorithm=self.algorithm)
return encoded_jwt
def decode_token(self, token: str) -> Dict[str, Any]:
"""
解码令牌
Args:
token: JWT令牌
Returns:
Dict: 解码后的数据
Raises:
HTTPException: 令牌无效或过期
"""
try:
payload = jwt.decode(token, self.secret_key, algorithms=[self.algorithm])
return payload
except JWTError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的认证凭据",
headers={"WWW-Authenticate": "Bearer"}
)
def verify_token(self, token: str, token_type: str = "access") -> Dict[str, Any]:
"""
验证令牌
Args:
token: JWT令牌
token_type: 令牌类型access/refresh
Returns:
Dict: 解码后的数据
Raises:
HTTPException: 令牌无效或类型不匹配
"""
payload = self.decode_token(token)
if payload.get("type") != token_type:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"令牌类型不匹配,期望{token_type}"
)
return payload
# 创建全局安全管理器实例
security_manager = SecurityManager()
def get_password_hash(password: str) -> str:
"""获取密码哈希值(便捷函数)"""
return security_manager.get_password_hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""验证密码(便捷函数)"""
return security_manager.verify_password(plain_password, hashed_password)
def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
"""创建访问令牌(便捷函数)"""
return security_manager.create_access_token(data, expires_delta)
def create_refresh_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
"""创建刷新令牌(便捷函数)"""
return security_manager.create_refresh_token(data, expires_delta)

0
app/crud/__init__.py Normal file
View File

332
app/crud/allocation.py Normal file
View File

@@ -0,0 +1,332 @@
"""
资产分配相关CRUD操作
"""
from typing import List, Optional, Tuple
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
from app.models.allocation import AssetAllocationOrder, AssetAllocationItem
from app.models.asset import Asset
from app.schemas.allocation import AllocationOrderCreate, AllocationOrderUpdate
class AllocationOrderCRUD:
"""分配单CRUD操作"""
def get(self, db: Session, id: int) -> Optional[AssetAllocationOrder]:
"""根据ID获取分配单"""
return db.query(AssetAllocationOrder).filter(
AssetAllocationOrder.id == id
).first()
def get_by_code(self, db: Session, order_code: str) -> Optional[AssetAllocationOrder]:
"""根据单号获取分配单"""
return db.query(AssetAllocationOrder).filter(
AssetAllocationOrder.order_code == order_code
).first()
def get_multi(
self,
db: Session,
skip: int = 0,
limit: int = 20,
order_type: Optional[str] = None,
approval_status: Optional[str] = None,
execute_status: Optional[str] = None,
applicant_id: Optional[int] = None,
target_organization_id: Optional[int] = None,
keyword: Optional[str] = None
) -> Tuple[List[AssetAllocationOrder], int]:
"""获取分配单列表"""
query = db.query(AssetAllocationOrder)
# 筛选条件
if order_type:
query = query.filter(AssetAllocationOrder.order_type == order_type)
if approval_status:
query = query.filter(AssetAllocationOrder.approval_status == approval_status)
if execute_status:
query = query.filter(AssetAllocationOrder.execute_status == execute_status)
if applicant_id:
query = query.filter(AssetAllocationOrder.applicant_id == applicant_id)
if target_organization_id:
query = query.filter(AssetAllocationOrder.target_organization_id == target_organization_id)
if keyword:
query = query.filter(
or_(
AssetAllocationOrder.order_code.like(f"%{keyword}%"),
AssetAllocationOrder.title.like(f"%{keyword}%")
)
)
# 排序
query = query.order_by(AssetAllocationOrder.created_at.desc())
# 总数
total = query.count()
# 分页
items = query.offset(skip).limit(limit).all()
return items, total
def create(
self,
db: Session,
obj_in: AllocationOrderCreate,
order_code: str,
applicant_id: int
) -> AssetAllocationOrder:
"""创建分配单"""
# 创建分配单
db_obj = AssetAllocationOrder(
order_code=order_code,
order_type=obj_in.order_type,
title=obj_in.title,
source_organization_id=obj_in.source_organization_id,
target_organization_id=obj_in.target_organization_id,
applicant_id=applicant_id,
expect_execute_date=obj_in.expect_execute_date,
remark=obj_in.remark,
created_by=applicant_id,
approval_status="pending",
execute_status="pending"
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
# 创建分配单明细
self._create_items(
db=db,
order_id=db_obj.id,
asset_ids=obj_in.asset_ids,
target_org_id=obj_in.target_organization_id
)
return db_obj
def update(
self,
db: Session,
db_obj: AssetAllocationOrder,
obj_in: AllocationOrderUpdate,
updater_id: int
) -> AssetAllocationOrder:
"""更新分配单"""
update_data = obj_in.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(db_obj, field, value)
db_obj.updated_by = updater_id
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def approve(
self,
db: Session,
db_obj: AssetAllocationOrder,
approval_status: str,
approver_id: int,
approval_remark: Optional[str] = None
) -> AssetAllocationOrder:
"""审批分配单"""
from datetime import datetime
db_obj.approval_status = approval_status
db_obj.approver_id = approver_id
db_obj.approval_time = datetime.utcnow()
db_obj.approval_remark = approval_remark
# 如果审批通过,自动设置为可执行状态
if approval_status == "approved":
db_obj.execute_status = "pending"
elif approval_status == "rejected":
db_obj.execute_status = "cancelled"
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def execute(
self,
db: Session,
db_obj: AssetAllocationOrder,
executor_id: int
) -> AssetAllocationOrder:
"""执行分配单"""
from datetime import datetime, date
db_obj.execute_status = "completed"
db_obj.actual_execute_date = date.today()
db_obj.executor_id = executor_id
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def cancel(self, db: Session, db_obj: AssetAllocationOrder) -> AssetAllocationOrder:
"""取消分配单"""
db_obj.approval_status = "cancelled"
db_obj.execute_status = "cancelled"
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def delete(self, db: Session, id: int) -> bool:
"""删除分配单"""
obj = self.get(db, id)
if obj:
db.delete(obj)
db.commit()
return True
return False
def get_statistics(
self,
db: Session,
applicant_id: Optional[int] = None
) -> dict:
"""获取分配单统计信息"""
query = db.query(AssetAllocationOrder)
if applicant_id:
query = query.filter(AssetAllocationOrder.applicant_id == applicant_id)
total = query.count()
pending = query.filter(AssetAllocationOrder.approval_status == "pending").count()
approved = query.filter(AssetAllocationOrder.approval_status == "approved").count()
rejected = query.filter(AssetAllocationOrder.approval_status == "rejected").count()
executing = query.filter(AssetAllocationOrder.execute_status == "executing").count()
completed = query.filter(AssetAllocationOrder.execute_status == "completed").count()
return {
"total": total,
"pending": pending,
"approved": approved,
"rejected": rejected,
"executing": executing,
"completed": completed
}
def _create_items(
self,
db: Session,
order_id: int,
asset_ids: List[int],
target_org_id: int
):
"""创建分配单明细"""
# 查询资产信息
assets = db.query(Asset).filter(Asset.id.in_(asset_ids)).all()
for asset in assets:
item = AssetAllocationItem(
order_id=order_id,
asset_id=asset.id,
asset_code=asset.asset_code,
asset_name=asset.asset_name,
from_organization_id=asset.organization_id,
to_organization_id=target_org_id,
from_status=asset.status,
to_status=self._get_target_status(asset.status),
execute_status="pending"
)
db.add(item)
db.commit()
def _get_target_status(self, current_status: str) -> str:
"""根据当前状态获取目标状态"""
status_map = {
"in_stock": "transferring",
"in_use": "transferring",
"maintenance": "in_stock"
}
return status_map.get(current_status, "transferring")
class AllocationItemCRUD:
"""分配单明细CRUD操作"""
def get_by_order(self, db: Session, order_id: int) -> List[AssetAllocationItem]:
"""根据分配单ID获取明细列表"""
return db.query(AssetAllocationItem).filter(
AssetAllocationItem.order_id == order_id
).order_by(AssetAllocationItem.id).all()
def get_multi(
self,
db: Session,
skip: int = 0,
limit: int = 20,
order_id: Optional[int] = None,
execute_status: Optional[str] = None
) -> Tuple[List[AssetAllocationItem], int]:
"""获取明细列表"""
query = db.query(AssetAllocationItem)
if order_id:
query = query.filter(AssetAllocationItem.order_id == order_id)
if execute_status:
query = query.filter(AssetAllocationItem.execute_status == execute_status)
total = query.count()
items = query.offset(skip).limit(limit).all()
return items, total
def update_execute_status(
self,
db: Session,
item_id: int,
execute_status: str,
failure_reason: Optional[str] = None
) -> AssetAllocationItem:
"""更新明细执行状态"""
from datetime import datetime
item = db.query(AssetAllocationItem).filter(
AssetAllocationItem.id == item_id
).first()
if item:
item.execute_status = execute_status
item.execute_time = datetime.utcnow()
item.failure_reason = failure_reason
db.add(item)
db.commit()
db.refresh(item)
return item
def batch_update_execute_status(
self,
db: Session,
order_id: int,
execute_status: str
):
"""批量更新明细执行状态"""
from datetime import datetime
items = db.query(AssetAllocationItem).filter(
and_(
AssetAllocationItem.order_id == order_id,
AssetAllocationItem.execute_status == "pending"
)
).all()
for item in items:
item.execute_status = execute_status
item.execute_time = datetime.utcnow()
db.add(item)
db.commit()
# 创建全局实例
allocation_order = AllocationOrderCRUD()
allocation_item = AllocationItemCRUD()

266
app/crud/asset.py Normal file
View File

@@ -0,0 +1,266 @@
"""
资产CRUD操作
"""
from typing import List, Optional, Tuple, Dict, Any
from sqlalchemy import and_, or_, func
from sqlalchemy.orm import Session
from app.models.asset import Asset, AssetStatusHistory
from app.schemas.asset import AssetCreate, AssetUpdate
class AssetCRUD:
"""资产CRUD操作类"""
def get(self, db: Session, id: int) -> Optional[Asset]:
"""根据ID获取资产"""
return db.query(Asset).filter(
and_(
Asset.id == id,
Asset.deleted_at.is_(None)
)
).first()
def get_by_code(self, db: Session, code: str) -> Optional[Asset]:
"""根据编码获取资产"""
return db.query(Asset).filter(
and_(
Asset.asset_code == code,
Asset.deleted_at.is_(None)
)
).first()
def get_by_serial_number(self, db: Session, serial_number: str) -> Optional[Asset]:
"""根据序列号获取资产"""
return db.query(Asset).filter(
and_(
Asset.serial_number == serial_number,
Asset.deleted_at.is_(None)
)
).first()
def get_multi(
self,
db: Session,
skip: int = 0,
limit: int = 20,
keyword: Optional[str] = None,
device_type_id: Optional[int] = None,
organization_id: Optional[int] = None,
status: Optional[str] = None,
purchase_date_start: Optional[Any] = None,
purchase_date_end: Optional[Any] = None
) -> Tuple[List[Asset], int]:
"""获取资产列表"""
query = db.query(Asset).filter(Asset.deleted_at.is_(None))
# 关键词搜索
if keyword:
query = query.filter(
or_(
Asset.asset_code.ilike(f"%{keyword}%"),
Asset.asset_name.ilike(f"%{keyword}%"),
Asset.model.ilike(f"%{keyword}%"),
Asset.serial_number.ilike(f"%{keyword}%")
)
)
# 筛选条件
if device_type_id:
query = query.filter(Asset.device_type_id == device_type_id)
if organization_id:
query = query.filter(Asset.organization_id == organization_id)
if status:
query = query.filter(Asset.status == status)
if purchase_date_start:
query = query.filter(Asset.purchase_date >= purchase_date_start)
if purchase_date_end:
query = query.filter(Asset.purchase_date <= purchase_date_end)
# 排序
query = query.order_by(Asset.id.desc())
# 总数
total = query.count()
# 分页
items = query.offset(skip).limit(limit).all()
return items, total
def create(
self,
db: Session,
obj_in: AssetCreate,
asset_code: str,
creator_id: Optional[int] = None
) -> Asset:
"""创建资产"""
# 计算保修到期日期
warranty_expire_date = None
if obj_in.purchase_date and obj_in.warranty_period:
from datetime import timedelta
warranty_expire_date = obj_in.purchase_date + timedelta(days=obj_in.warranty_period * 30)
db_obj = Asset(
**obj_in.model_dump(),
asset_code=asset_code,
status="pending",
warranty_expire_date=warranty_expire_date,
created_by=creator_id
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update(
self,
db: Session,
db_obj: Asset,
obj_in: AssetUpdate,
updater_id: Optional[int] = None
) -> Asset:
"""更新资产"""
obj_data = obj_in.model_dump(exclude_unset=True)
# 重新计算保修到期日期
if "purchase_date" in obj_data or "warranty_period" in obj_data:
purchase_date = obj_data.get("purchase_date", db_obj.purchase_date)
warranty_period = obj_data.get("warranty_period", db_obj.warranty_period)
if purchase_date and warranty_period:
from datetime import timedelta
obj_data["warranty_expire_date"] = purchase_date + timedelta(days=warranty_period * 30)
for field, value in obj_data.items():
setattr(db_obj, field, value)
db_obj.updated_by = updater_id
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def delete(self, db: Session, id: int, deleter_id: Optional[int] = None) -> bool:
"""删除资产(软删除)"""
obj = self.get(db, id)
if not obj:
return False
obj.deleted_at = func.now()
obj.deleted_by = deleter_id
db.add(obj)
db.commit()
return True
def get_by_ids(self, db: Session, ids: List[int]) -> List[Asset]:
"""根据ID列表获取资产"""
return db.query(Asset).filter(
and_(
Asset.id.in_(ids),
Asset.deleted_at.is_(None)
)
).all()
def update_status(
self,
db: Session,
asset_id: int,
new_status: str,
updater_id: Optional[int] = None
) -> Optional[Asset]:
"""更新资产状态"""
obj = self.get(db, asset_id)
if not obj:
return None
obj.status = new_status
obj.updated_by = updater_id
db.add(obj)
db.commit()
db.refresh(obj)
return obj
def search_by_dynamic_field(
self,
db: Session,
field_name: str,
field_value: Any,
skip: int = 0,
limit: int = 20
) -> Tuple[List[Asset], int]:
"""
根据动态字段搜索资产
使用JSONB的@>操作符进行高效查询
"""
query = db.query(Asset).filter(
and_(
Asset.deleted_at.is_(None),
Asset.dynamic_attributes.has_key(field_name)
)
)
# 根据值类型进行不同的查询
if isinstance(field_value, str):
query = query.filter(Asset.dynamic_attributes[field_name].astext == field_value)
else:
query = query.filter(Asset.dynamic_attributes[field_name] == field_value)
total = query.count()
items = query.offset(skip).limit(limit).all()
return items, total
class AssetStatusHistoryCRUD:
"""资产状态历史CRUD操作类"""
def create(
self,
db: Session,
asset_id: int,
old_status: Optional[str],
new_status: str,
operation_type: str,
operator_id: int,
operator_name: Optional[str] = None,
organization_id: Optional[int] = None,
remark: Optional[str] = None,
extra_data: Optional[Dict[str, Any]] = None
) -> AssetStatusHistory:
"""创建状态历史记录"""
db_obj = AssetStatusHistory(
asset_id=asset_id,
old_status=old_status,
new_status=new_status,
operation_type=operation_type,
operator_id=operator_id,
operator_name=operator_name,
organization_id=organization_id,
remark=remark,
extra_data=extra_data
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def get_by_asset(
self,
db: Session,
asset_id: int,
skip: int = 0,
limit: int = 50
) -> List[AssetStatusHistory]:
"""获取资产的状态历史"""
return db.query(AssetStatusHistory).filter(
AssetStatusHistory.asset_id == asset_id
).order_by(
AssetStatusHistory.created_at.desc()
).offset(skip).limit(limit).all()
# 创建全局实例
asset = AssetCRUD()
asset_status_history = AssetStatusHistoryCRUD()

198
app/crud/brand_supplier.py Normal file
View File

@@ -0,0 +1,198 @@
"""
品牌和供应商CRUD操作
"""
from typing import List, Optional, Tuple
from sqlalchemy import and_, or_, func
from sqlalchemy.orm import Session
from app.models.brand_supplier import Brand, Supplier
from app.schemas.brand_supplier import (
BrandCreate,
BrandUpdate,
SupplierCreate,
SupplierUpdate
)
class BrandCRUD:
"""品牌CRUD操作类"""
def get(self, db: Session, id: int) -> Optional[Brand]:
"""根据ID获取品牌"""
return db.query(Brand).filter(
and_(
Brand.id == id,
Brand.deleted_at.is_(None)
)
).first()
def get_by_code(self, db: Session, code: str) -> Optional[Brand]:
"""根据代码获取品牌"""
return db.query(Brand).filter(
and_(
Brand.brand_code == code,
Brand.deleted_at.is_(None)
)
).first()
def get_multi(
self,
db: Session,
skip: int = 0,
limit: int = 20,
status: Optional[str] = None,
keyword: Optional[str] = None
) -> Tuple[List[Brand], int]:
"""获取品牌列表"""
query = db.query(Brand).filter(Brand.deleted_at.is_(None))
if status:
query = query.filter(Brand.status == status)
if keyword:
query = query.filter(
or_(
Brand.brand_code.ilike(f"%{keyword}%"),
Brand.brand_name.ilike(f"%{keyword}%")
)
)
query = query.order_by(Brand.sort_order.asc(), Brand.id.desc())
total = query.count()
items = query.offset(skip).limit(limit).all()
return items, total
def create(self, db: Session, obj_in: BrandCreate, creator_id: Optional[int] = None) -> Brand:
"""创建品牌"""
if self.get_by_code(db, obj_in.brand_code):
raise ValueError(f"品牌代码 '{obj_in.brand_code}' 已存在")
db_obj = Brand(**obj_in.model_dump(), created_by=creator_id)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update(
self,
db: Session,
db_obj: Brand,
obj_in: BrandUpdate,
updater_id: Optional[int] = None
) -> Brand:
"""更新品牌"""
obj_data = obj_in.model_dump(exclude_unset=True)
for field, value in obj_data.items():
setattr(db_obj, field, value)
db_obj.updated_by = updater_id
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def delete(self, db: Session, id: int, deleter_id: Optional[int] = None) -> bool:
"""删除品牌(软删除)"""
obj = self.get(db, id)
if not obj:
return False
obj.deleted_at = func.now()
obj.deleted_by = deleter_id
db.add(obj)
db.commit()
return True
class SupplierCRUD:
"""供应商CRUD操作类"""
def get(self, db: Session, id: int) -> Optional[Supplier]:
"""根据ID获取供应商"""
return db.query(Supplier).filter(
and_(
Supplier.id == id,
Supplier.deleted_at.is_(None)
)
).first()
def get_by_code(self, db: Session, code: str) -> Optional[Supplier]:
"""根据代码获取供应商"""
return db.query(Supplier).filter(
and_(
Supplier.supplier_code == code,
Supplier.deleted_at.is_(None)
)
).first()
def get_multi(
self,
db: Session,
skip: int = 0,
limit: int = 20,
status: Optional[str] = None,
keyword: Optional[str] = None
) -> Tuple[List[Supplier], int]:
"""获取供应商列表"""
query = db.query(Supplier).filter(Supplier.deleted_at.is_(None))
if status:
query = query.filter(Supplier.status == status)
if keyword:
query = query.filter(
or_(
Supplier.supplier_code.ilike(f"%{keyword}%"),
Supplier.supplier_name.ilike(f"%{keyword}%")
)
)
query = query.order_by(Supplier.id.desc())
total = query.count()
items = query.offset(skip).limit(limit).all()
return items, total
def create(self, db: Session, obj_in: SupplierCreate, creator_id: Optional[int] = None) -> Supplier:
"""创建供应商"""
if self.get_by_code(db, obj_in.supplier_code):
raise ValueError(f"供应商代码 '{obj_in.supplier_code}' 已存在")
db_obj = Supplier(**obj_in.model_dump(), created_by=creator_id)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update(
self,
db: Session,
db_obj: Supplier,
obj_in: SupplierUpdate,
updater_id: Optional[int] = None
) -> Supplier:
"""更新供应商"""
obj_data = obj_in.model_dump(exclude_unset=True)
for field, value in obj_data.items():
setattr(db_obj, field, value)
db_obj.updated_by = updater_id
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def delete(self, db: Session, id: int, deleter_id: Optional[int] = None) -> bool:
"""删除供应商(软删除)"""
obj = self.get(db, id)
if not obj:
return False
obj.deleted_at = func.now()
obj.deleted_by = deleter_id
db.add(obj)
db.commit()
return True
# 创建全局实例
brand = BrandCRUD()
supplier = SupplierCRUD()

369
app/crud/device_type.py Normal file
View File

@@ -0,0 +1,369 @@
"""
设备类型CRUD操作
"""
from typing import List, Optional, Tuple
from sqlalchemy import select, and_, or_, func
from sqlalchemy.orm import Session
from app.models.device_type import DeviceType, DeviceTypeField
from app.schemas.device_type import DeviceTypeCreate, DeviceTypeUpdate, DeviceTypeFieldCreate, DeviceTypeFieldUpdate
class DeviceTypeCRUD:
"""设备类型CRUD操作类"""
def get(self, db: Session, id: int) -> Optional[DeviceType]:
"""
根据ID获取设备类型
Args:
db: 数据库会话
id: 设备类型ID
Returns:
DeviceType对象或None
"""
return db.query(DeviceType).filter(
and_(
DeviceType.id == id,
DeviceType.deleted_at.is_(None)
)
).first()
def get_by_code(self, db: Session, code: str) -> Optional[DeviceType]:
"""
根据代码获取设备类型
Args:
db: 数据库会话
code: 设备类型代码
Returns:
DeviceType对象或None
"""
return db.query(DeviceType).filter(
and_(
DeviceType.type_code == code,
DeviceType.deleted_at.is_(None)
)
).first()
def get_multi(
self,
db: Session,
skip: int = 0,
limit: int = 20,
category: Optional[str] = None,
status: Optional[str] = None,
keyword: Optional[str] = None
) -> Tuple[List[DeviceType], int]:
"""
获取设备类型列表
Args:
db: 数据库会话
skip: 跳过条数
limit: 返回条数
category: 设备分类筛选
status: 状态筛选
keyword: 搜索关键词
Returns:
(设备类型列表, 总数)
"""
query = db.query(DeviceType).filter(DeviceType.deleted_at.is_(None))
# 筛选条件
if category:
query = query.filter(DeviceType.category == category)
if status:
query = query.filter(DeviceType.status == status)
if keyword:
query = query.filter(
or_(
DeviceType.type_code.ilike(f"%{keyword}%"),
DeviceType.type_name.ilike(f"%{keyword}%")
)
)
# 排序
query = query.order_by(DeviceType.sort_order.asc(), DeviceType.id.desc())
# 总数
total = query.count()
# 分页
items = query.offset(skip).limit(limit).all()
return items, total
def create(self, db: Session, obj_in: DeviceTypeCreate, creator_id: Optional[int] = None) -> DeviceType:
"""
创建设备类型
Args:
db: 数据库会话
obj_in: 创建数据
creator_id: 创建人ID
Returns:
创建的DeviceType对象
"""
# 检查代码是否已存在
if self.get_by_code(db, obj_in.type_code):
raise ValueError(f"设备类型代码 '{obj_in.type_code}' 已存在")
db_obj = DeviceType(**obj_in.model_dump(), created_by=creator_id)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update(
self,
db: Session,
db_obj: DeviceType,
obj_in: DeviceTypeUpdate,
updater_id: Optional[int] = None
) -> DeviceType:
"""
更新设备类型
Args:
db: 数据库会话
db_obj: 数据库对象
obj_in: 更新数据
updater_id: 更新人ID
Returns:
更新后的DeviceType对象
"""
obj_data = obj_in.model_dump(exclude_unset=True)
for field, value in obj_data.items():
setattr(db_obj, field, value)
db_obj.updated_by = updater_id
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def delete(self, db: Session, id: int, deleter_id: Optional[int] = None) -> bool:
"""
删除设备类型(软删除)
Args:
db: 数据库会话
id: 设备类型ID
deleter_id: 删除人ID
Returns:
是否删除成功
"""
obj = self.get(db, id)
if not obj:
return False
obj.deleted_at = func.now()
obj.deleted_by = deleter_id
db.add(obj)
db.commit()
return True
def get_all_categories(self, db: Session) -> List[str]:
"""
获取所有设备分类
Args:
db: 数据库会话
Returns:
设备分类列表
"""
result = db.query(DeviceType.category).filter(
and_(
DeviceType.deleted_at.is_(None),
DeviceType.category.isnot(None)
)
).distinct().all()
return [r[0] for r in result if r[0]]
class DeviceTypeFieldCRUD:
"""设备类型字段CRUD操作类"""
def get(self, db: Session, id: int) -> Optional[DeviceTypeField]:
"""
根据ID获取字段
Args:
db: 数据库会话
id: 字段ID
Returns:
DeviceTypeField对象或None
"""
return db.query(DeviceTypeField).filter(
and_(
DeviceTypeField.id == id,
DeviceTypeField.deleted_at.is_(None)
)
).first()
def get_by_device_type(
self,
db: Session,
device_type_id: int,
status: Optional[str] = None
) -> List[DeviceTypeField]:
"""
获取设备类型的所有字段
Args:
db: 数据库会话
device_type_id: 设备类型ID
status: 状态筛选
Returns:
字段列表
"""
query = db.query(DeviceTypeField).filter(
and_(
DeviceTypeField.device_type_id == device_type_id,
DeviceTypeField.deleted_at.is_(None)
)
)
if status:
query = query.filter(DeviceTypeField.status == status)
return query.order_by(DeviceTypeField.sort_order.asc(), DeviceTypeField.id.asc()).all()
def create(
self,
db: Session,
obj_in: DeviceTypeFieldCreate,
device_type_id: int,
creator_id: Optional[int] = None
) -> DeviceTypeField:
"""
创建字段
Args:
db: 数据库会话
obj_in: 创建数据
device_type_id: 设备类型ID
creator_id: 创建人ID
Returns:
创建的DeviceTypeField对象
"""
# 检查字段代码是否已存在
existing = db.query(DeviceTypeField).filter(
and_(
DeviceTypeField.device_type_id == device_type_id,
DeviceTypeField.field_code == obj_in.field_code,
DeviceTypeField.deleted_at.is_(None)
)
).first()
if existing:
raise ValueError(f"字段代码 '{obj_in.field_code}' 已存在")
db_obj = DeviceTypeField(
**obj_in.model_dump(),
device_type_id=device_type_id,
created_by=creator_id
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update(
self,
db: Session,
db_obj: DeviceTypeField,
obj_in: DeviceTypeFieldUpdate,
updater_id: Optional[int] = None
) -> DeviceTypeField:
"""
更新字段
Args:
db: 数据库会话
db_obj: 数据库对象
obj_in: 更新数据
updater_id: 更新人ID
Returns:
更新后的DeviceTypeField对象
"""
obj_data = obj_in.model_dump(exclude_unset=True)
for field, value in obj_data.items():
setattr(db_obj, field, value)
db_obj.updated_by = updater_id
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def delete(self, db: Session, id: int, deleter_id: Optional[int] = None) -> bool:
"""
删除字段(软删除)
Args:
db: 数据库会话
id: 字段ID
deleter_id: 删除人ID
Returns:
是否删除成功
"""
obj = self.get(db, id)
if not obj:
return False
obj.deleted_at = func.now()
obj.deleted_by = deleter_id
db.add(obj)
db.commit()
return True
def batch_create(
self,
db: Session,
fields_in: List[DeviceTypeFieldCreate],
device_type_id: int,
creator_id: Optional[int] = None
) -> List[DeviceTypeField]:
"""
批量创建字段
Args:
db: 数据库会话
fields_in: 字段创建列表
device_type_id: 设备类型ID
creator_id: 创建人ID
Returns:
创建的字段列表
"""
db_objs = [
DeviceTypeField(
**field.model_dump(),
device_type_id=device_type_id,
created_by=creator_id
)
for field in fields_in
]
db.add_all(db_objs)
db.commit()
for obj in db_objs:
db.refresh(obj)
return db_objs
# 创建全局实例
device_type = DeviceTypeCRUD()
device_type_field = DeviceTypeFieldCRUD()

235
app/crud/file_management.py Normal file
View File

@@ -0,0 +1,235 @@
"""
文件管理CRUD操作
"""
from typing import List, Optional, Dict, Any, Tuple
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_, func, desc
from datetime import datetime, timedelta
from app.models.file_management import UploadedFile
class CRUDUploadedFile:
"""上传文件CRUD操作"""
def create(self, db: Session, *, obj_in: Dict[str, Any]) -> UploadedFile:
"""创建文件记录"""
db_obj = UploadedFile(**obj_in)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def get(self, db: Session, id: int) -> Optional[UploadedFile]:
"""根据ID获取文件"""
return db.query(UploadedFile).filter(
and_(
UploadedFile.id == id,
UploadedFile.is_deleted == 0
)
).first()
def get_by_share_code(self, db: Session, share_code: str) -> Optional[UploadedFile]:
"""根据分享码获取文件"""
now = datetime.utcnow()
return db.query(UploadedFile).filter(
and_(
UploadedFile.share_code == share_code,
UploadedFile.is_deleted == 0,
or_(
UploadedFile.share_expire_time.is_(None),
UploadedFile.share_expire_time > now
)
)
).first()
def get_multi(
self,
db: Session,
*,
skip: int = 0,
limit: int = 20,
keyword: Optional[str] = None,
file_type: Optional[str] = None,
uploader_id: Optional[int] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None
) -> Tuple[List[UploadedFile], int]:
"""获取文件列表"""
query = db.query(UploadedFile).filter(UploadedFile.is_deleted == 0)
# 关键词搜索
if keyword:
query = query.filter(
or_(
UploadedFile.original_name.like(f"%{keyword}%"),
UploadedFile.file_name.like(f"%{keyword}%")
)
)
# 文件类型筛选
if file_type:
query = query.filter(UploadedFile.file_type == file_type)
# 上传者筛选
if uploader_id:
query = query.filter(UploadedFile.uploader_id == uploader_id)
# 日期范围筛选
if start_date:
start = datetime.strptime(start_date, "%Y-%m-%d")
query = query.filter(UploadedFile.upload_time >= start)
if end_date:
end = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)
query = query.filter(UploadedFile.upload_time < end)
# 获取总数
total = query.count()
# 分页
items = query.order_by(desc(UploadedFile.upload_time)).offset(skip).limit(limit).all()
return items, total
def update(self, db: Session, *, db_obj: UploadedFile, obj_in: Dict[str, Any]) -> UploadedFile:
"""更新文件记录"""
for field, value in obj_in.items():
setattr(db_obj, field, value)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def delete(self, db: Session, *, db_obj: UploadedFile, deleter_id: int) -> UploadedFile:
"""软删除文件"""
db_obj.is_deleted = 1
db_obj.deleted_at = datetime.utcnow()
db_obj.deleted_by = deleter_id
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def delete_batch(self, db: Session, *, file_ids: List[int], deleter_id: int) -> int:
"""批量删除文件"""
now = datetime.utcnow()
count = db.query(UploadedFile).filter(
and_(
UploadedFile.id.in_(file_ids),
UploadedFile.is_deleted == 0
)
).update({
"is_deleted": 1,
"deleted_at": now,
"deleted_by": deleter_id
}, synchronize_session=False)
db.commit()
return count
def increment_download_count(self, db: Session, *, file_id: int) -> int:
"""增加下载次数"""
file_obj = self.get(db, file_id)
if file_obj:
file_obj.download_count = (file_obj.download_count or 0) + 1
db.add(file_obj)
db.commit()
return file_obj.download_count
return 0
def generate_share_code(self, db: Session, *, file_id: int, expire_days: int = 7) -> str:
"""生成分享码"""
import secrets
import string
file_obj = self.get(db, file_id)
if not file_obj:
return None
# 生成随机分享码
alphabet = string.ascii_uppercase + string.ascii_lowercase + string.digits
share_code = ''.join(secrets.choice(alphabet) for _ in range(16))
# 设置过期时间
expire_time = datetime.utcnow() + timedelta(days=expire_days)
# 更新文件记录
self.update(db, db_obj=file_obj, obj_in={
"share_code": share_code,
"share_expire_time": expire_time
})
return share_code
def get_statistics(
self,
db: Session,
*,
uploader_id: Optional[int] = None
) -> Dict[str, Any]:
"""获取文件统计信息"""
# 基础查询
query = db.query(UploadedFile).filter(UploadedFile.is_deleted == 0)
if uploader_id:
query = query.filter(UploadedFile.uploader_id == uploader_id)
# 总文件数和总大小
total_stats = query.with_entities(
func.count(UploadedFile.id).label('count'),
func.sum(UploadedFile.file_size).label('size')
).first()
# 文件类型分布
type_dist = query.with_entities(
UploadedFile.file_type,
func.count(UploadedFile.id).label('count')
).group_by(UploadedFile.file_type).all()
type_distribution = {file_type: count for file_type, count in type_dist}
# 今日上传数
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
upload_today = query.filter(UploadedFile.upload_time >= today_start).count()
# 本周上传数
week_start = today_start - timedelta(days=today_start.weekday())
upload_this_week = query.filter(UploadedFile.upload_time >= week_start).count()
# 本月上传数
month_start = today_start.replace(day=1)
upload_this_month = query.filter(UploadedFile.upload_time >= month_start).count()
# 上传排行
uploader_ranking = query.with_entities(
UploadedFile.uploader_id,
func.count(UploadedFile.id).label('count')
).group_by(UploadedFile.uploader_id).order_by(desc('count')).limit(10).all()
# 转换为人类可读的文件大小
total_size = total_stats.size or 0
total_size_human = self._format_size(total_size)
return {
"total_files": total_stats.count or 0,
"total_size": total_size,
"total_size_human": total_size_human,
"type_distribution": type_distribution,
"upload_today": upload_today,
"upload_this_week": upload_this_week,
"upload_this_month": upload_this_month,
"top_uploaders": [{"uploader_id": uid, "count": count} for uid, count in uploader_ranking]
}
@staticmethod
def _format_size(size_bytes: int) -> str:
"""格式化文件大小"""
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
if size_bytes < 1024.0:
return f"{size_bytes:.2f} {unit}"
size_bytes /= 1024.0
return f"{size_bytes:.2f} PB"
# 创建CRUD实例
uploaded_file = CRUDUploadedFile()

247
app/crud/maintenance.py Normal file
View File

@@ -0,0 +1,247 @@
"""
维修管理相关CRUD操作
"""
from typing import List, Optional, Tuple
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_, func
from app.models.maintenance import MaintenanceRecord
from app.schemas.maintenance import MaintenanceRecordCreate, MaintenanceRecordUpdate
class MaintenanceRecordCRUD:
"""维修记录CRUD操作"""
def get(self, db: Session, id: int) -> Optional[MaintenanceRecord]:
"""根据ID获取维修记录"""
return db.query(MaintenanceRecord).filter(
MaintenanceRecord.id == id
).first()
def get_by_code(self, db: Session, record_code: str) -> Optional[MaintenanceRecord]:
"""根据单号获取维修记录"""
return db.query(MaintenanceRecord).filter(
MaintenanceRecord.record_code == record_code
).first()
def get_multi(
self,
db: Session,
skip: int = 0,
limit: int = 20,
asset_id: Optional[int] = None,
status: Optional[str] = None,
fault_type: Optional[str] = None,
priority: Optional[str] = None,
maintenance_type: Optional[str] = None,
keyword: Optional[str] = None
) -> Tuple[List[MaintenanceRecord], int]:
"""获取维修记录列表"""
query = db.query(MaintenanceRecord)
# 筛选条件
if asset_id:
query = query.filter(MaintenanceRecord.asset_id == asset_id)
if status:
query = query.filter(MaintenanceRecord.status == status)
if fault_type:
query = query.filter(MaintenanceRecord.fault_type == fault_type)
if priority:
query = query.filter(MaintenanceRecord.priority == priority)
if maintenance_type:
query = query.filter(MaintenanceRecord.maintenance_type == maintenance_type)
if keyword:
query = query.filter(
or_(
MaintenanceRecord.record_code.like(f"%{keyword}%"),
MaintenanceRecord.asset_code.like(f"%{keyword}%"),
MaintenanceRecord.fault_description.like(f"%{keyword}%")
)
)
# 排序
query = query.order_by(MaintenanceRecord.report_time.desc())
# 总数
total = query.count()
# 分页
items = query.offset(skip).limit(limit).all()
return items, total
def create(
self,
db: Session,
obj_in: MaintenanceRecordCreate,
record_code: str,
asset_code: str,
report_user_id: int,
creator_id: int
) -> MaintenanceRecord:
"""创建维修记录"""
db_obj = MaintenanceRecord(
record_code=record_code,
asset_id=obj_in.asset_id,
asset_code=asset_code,
fault_description=obj_in.fault_description,
fault_type=obj_in.fault_type,
report_user_id=report_user_id,
priority=obj_in.priority,
maintenance_type=obj_in.maintenance_type,
vendor_id=obj_in.vendor_id,
maintenance_cost=obj_in.maintenance_cost,
maintenance_result=obj_in.maintenance_result,
replaced_parts=obj_in.replaced_parts,
images=obj_in.images,
remark=obj_in.remark,
status="pending",
created_by=creator_id
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update(
self,
db: Session,
db_obj: MaintenanceRecord,
obj_in: MaintenanceRecordUpdate,
updater_id: int
) -> MaintenanceRecord:
"""更新维修记录"""
update_data = obj_in.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(db_obj, field, value)
db_obj.updated_by = updater_id
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def start_maintenance(
self,
db: Session,
db_obj: MaintenanceRecord,
maintenance_type: str,
maintenance_user_id: int,
vendor_id: Optional[int] = None
) -> MaintenanceRecord:
"""开始维修"""
from datetime import datetime
db_obj.status = "in_progress"
db_obj.start_time = datetime.utcnow()
db_obj.maintenance_type = maintenance_type
db_obj.maintenance_user_id = maintenance_user_id
if vendor_id:
db_obj.vendor_id = vendor_id
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def complete_maintenance(
self,
db: Session,
db_obj: MaintenanceRecord,
maintenance_result: str,
maintenance_cost: Optional[float] = None,
replaced_parts: Optional[str] = None,
images: Optional[str] = None
) -> MaintenanceRecord:
"""完成维修"""
from datetime import datetime
db_obj.status = "completed"
db_obj.complete_time = datetime.utcnow()
db_obj.maintenance_result = maintenance_result
if maintenance_cost is not None:
db_obj.maintenance_cost = maintenance_cost
if replaced_parts:
db_obj.replaced_parts = replaced_parts
if images:
db_obj.images = images
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def cancel_maintenance(
self,
db: Session,
db_obj: MaintenanceRecord
) -> MaintenanceRecord:
"""取消维修"""
db_obj.status = "cancelled"
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def delete(self, db: Session, id: int) -> bool:
"""删除维修记录"""
obj = self.get(db, id)
if obj:
db.delete(obj)
db.commit()
return True
return False
def get_statistics(
self,
db: Session,
asset_id: Optional[int] = None
) -> dict:
"""获取维修统计信息"""
from decimal import Decimal
query = db.query(MaintenanceRecord)
if asset_id:
query = query.filter(MaintenanceRecord.asset_id == asset_id)
total = query.count()
pending = query.filter(MaintenanceRecord.status == "pending").count()
in_progress = query.filter(MaintenanceRecord.status == "in_progress").count()
completed = query.filter(MaintenanceRecord.status == "completed").count()
cancelled = query.filter(MaintenanceRecord.status == "cancelled").count()
# 总维修费用
total_cost_result = query.filter(
MaintenanceRecord.status == "completed",
MaintenanceRecord.maintenance_cost.isnot(None)
).with_entities(
func.sum(MaintenanceRecord.maintenance_cost)
).first()
total_cost = total_cost_result[0] if total_cost_result and total_cost_result[0] else Decimal("0.00")
return {
"total": total,
"pending": pending,
"in_progress": in_progress,
"completed": completed,
"cancelled": cancelled,
"total_cost": total_cost
}
def get_by_asset(
self,
db: Session,
asset_id: int,
skip: int = 0,
limit: int = 50
) -> List[MaintenanceRecord]:
"""根据资产ID获取维修记录"""
return db.query(MaintenanceRecord).filter(
MaintenanceRecord.asset_id == asset_id
).order_by(
MaintenanceRecord.report_time.desc()
).offset(skip).limit(limit).all()
# 创建全局实例
maintenance_record = MaintenanceRecordCRUD()

403
app/crud/notification.py Normal file
View File

@@ -0,0 +1,403 @@
"""
消息通知CRUD操作
"""
from typing import Optional, List, Dict, Any
from datetime import datetime
from sqlalchemy import select, and_, or_, func, desc, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.notification import Notification, NotificationTemplate
class NotificationCRUD:
"""消息通知CRUD类"""
async def get(self, db: AsyncSession, notification_id: int) -> Optional[Notification]:
"""
根据ID获取消息通知
Args:
db: 数据库会话
notification_id: 通知ID
Returns:
Notification对象或None
"""
result = await db.execute(
select(Notification).where(Notification.id == notification_id)
)
return result.scalar_one_or_none()
async def get_multi(
self,
db: AsyncSession,
*,
skip: int = 0,
limit: int = 100,
recipient_id: Optional[int] = None,
notification_type: Optional[str] = None,
priority: Optional[str] = None,
is_read: Optional[bool] = None,
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None,
keyword: Optional[str] = None
) -> tuple[List[Notification], int]:
"""
获取消息通知列表
Args:
db: 数据库会话
skip: 跳过条数
limit: 返回条数
recipient_id: 接收人ID
notification_type: 通知类型
priority: 优先级
is_read: 是否已读
start_time: 开始时间
end_time: 结束时间
keyword: 关键词
Returns:
(通知列表, 总数)
"""
# 构建查询条件
conditions = []
if recipient_id:
conditions.append(Notification.recipient_id == recipient_id)
if notification_type:
conditions.append(Notification.notification_type == notification_type)
if priority:
conditions.append(Notification.priority == priority)
if is_read is not None:
conditions.append(Notification.is_read == is_read)
if start_time:
conditions.append(Notification.created_at >= start_time)
if end_time:
conditions.append(Notification.created_at <= end_time)
if keyword:
conditions.append(
or_(
Notification.title.ilike(f"%{keyword}%"),
Notification.content.ilike(f"%{keyword}%")
)
)
# 查询总数
count_query = select(func.count(Notification.id))
if conditions:
count_query = count_query.where(and_(*conditions))
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# 查询数据
query = select(Notification)
if conditions:
query = query.where(and_(*conditions))
query = query.order_by(
Notification.is_read.asc(), # 未读优先
desc(Notification.created_at) # 按时间倒序
)
query = query.offset(skip).limit(limit)
result = await db.execute(query)
items = result.scalars().all()
return list(items), total
async def create(
self,
db: AsyncSession,
*,
obj_in: Dict[str, Any]
) -> Notification:
"""
创建消息通知
Args:
db: 数据库会话
obj_in: 创建数据
Returns:
Notification对象
"""
db_obj = Notification(**obj_in)
db.add(db_obj)
await db.flush()
await db.refresh(db_obj)
return db_obj
async def batch_create(
self,
db: AsyncSession,
*,
recipient_ids: List[int],
notification_data: Dict[str, Any]
) -> List[Notification]:
"""
批量创建消息通知
Args:
db: 数据库会话
recipient_ids: 接收人ID列表
notification_data: 通知数据
Returns:
Notification对象列表
"""
notifications = []
for recipient_id in recipient_ids:
obj_data = notification_data.copy()
obj_data["recipient_id"] = recipient_id
db_obj = Notification(**obj_data)
db.add(db_obj)
notifications.append(db_obj)
await db.flush()
return notifications
async def update(
self,
db: AsyncSession,
*,
db_obj: Notification,
obj_in: Dict[str, Any]
) -> Notification:
"""
更新消息通知
Args:
db: 数据库会话
db_obj: 数据库对象
obj_in: 更新数据
Returns:
Notification对象
"""
for field, value in obj_in.items():
if hasattr(db_obj, field):
setattr(db_obj, field, value)
await db.flush()
await db.refresh(db_obj)
return db_obj
async def mark_as_read(
self,
db: AsyncSession,
*,
notification_id: int,
read_at: Optional[datetime] = None
) -> Optional[Notification]:
"""
标记为已读
Args:
db: 数据库会话
notification_id: 通知ID
read_at: 已读时间
Returns:
Notification对象或None
"""
db_obj = await self.get(db, notification_id)
if not db_obj:
return None
if not db_obj.is_read:
db_obj.is_read = True
db_obj.read_at = read_at or datetime.utcnow()
await db.flush()
return db_obj
async def mark_all_as_read(
self,
db: AsyncSession,
*,
recipient_id: int,
read_at: Optional[datetime] = None
) -> int:
"""
标记所有未读为已读
Args:
db: 数据库会话
recipient_id: 接收人ID
read_at: 已读时间
Returns:
更新数量
"""
stmt = (
update(Notification)
.where(
and_(
Notification.recipient_id == recipient_id,
Notification.is_read == False
)
)
.values(
is_read=True,
read_at=read_at or datetime.utcnow()
)
)
result = await db.execute(stmt)
await db.flush()
return result.rowcount
async def delete(self, db: AsyncSession, *, notification_id: int) -> Optional[Notification]:
"""
删除消息通知
Args:
db: 数据库会话
notification_id: 通知ID
Returns:
删除的Notification对象或None
"""
obj = await self.get(db, notification_id)
if obj:
await db.delete(obj)
await db.flush()
return obj
async def batch_delete(
self,
db: AsyncSession,
*,
notification_ids: List[int]
) -> int:
"""
批量删除通知
Args:
db: 数据库会话
notification_ids: 通知ID列表
Returns:
删除数量
"""
from sqlalchemy import delete
stmt = delete(Notification).where(Notification.id.in_(notification_ids))
result = await db.execute(stmt)
await db.flush()
return result.rowcount
async def get_unread_count(
self,
db: AsyncSession,
recipient_id: int
) -> int:
"""
获取未读通知数量
Args:
db: 数据库会话
recipient_id: 接收人ID
Returns:
未读数量
"""
result = await db.execute(
select(func.count(Notification.id)).where(
and_(
Notification.recipient_id == recipient_id,
Notification.is_read == False
)
)
)
return result.scalar() or 0
async def get_statistics(
self,
db: AsyncSession,
recipient_id: int
) -> Dict[str, Any]:
"""
获取通知统计信息
Args:
db: 数据库会话
recipient_id: 接收人ID
Returns:
统计信息
"""
# 总数
total_result = await db.execute(
select(func.count(Notification.id)).where(Notification.recipient_id == recipient_id)
)
total_count = total_result.scalar() or 0
# 未读数
unread_result = await db.execute(
select(func.count(Notification.id)).where(
and_(
Notification.recipient_id == recipient_id,
Notification.is_read == False
)
)
)
unread_count = unread_result.scalar() or 0
# 已读数
read_count = total_count - unread_count
# 高优先级数
high_priority_result = await db.execute(
select(func.count(Notification.id)).where(
and_(
Notification.recipient_id == recipient_id,
Notification.priority.in_(["high", "urgent"]),
Notification.is_read == False
)
)
)
high_priority_count = high_priority_result.scalar() or 0
# 紧急通知数
urgent_result = await db.execute(
select(func.count(Notification.id)).where(
and_(
Notification.recipient_id == recipient_id,
Notification.priority == "urgent",
Notification.is_read == False
)
)
)
urgent_count = urgent_result.scalar() or 0
# 类型分布
type_result = await db.execute(
select(
Notification.notification_type,
func.count(Notification.id).label('count')
)
.where(Notification.recipient_id == recipient_id)
.group_by(Notification.notification_type)
)
type_distribution = [
{"type": row[0], "count": row[1]}
for row in type_result
]
return {
"total_count": total_count,
"unread_count": unread_count,
"read_count": read_count,
"high_priority_count": high_priority_count,
"urgent_count": urgent_count,
"type_distribution": type_distribution,
}
# 创建全局实例
notification_crud = NotificationCRUD()

311
app/crud/operation_log.py Normal file
View File

@@ -0,0 +1,311 @@
"""
操作日志CRUD操作
"""
from typing import Optional, List, Dict, Any
from datetime import datetime, timedelta
from sqlalchemy import select, and_, or_, func, desc
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.operation_log import OperationLog
class OperationLogCRUD:
"""操作日志CRUD类"""
async def get(self, db: AsyncSession, log_id: int) -> Optional[OperationLog]:
"""
根据ID获取操作日志
Args:
db: 数据库会话
log_id: 日志ID
Returns:
OperationLog对象或None
"""
result = await db.execute(
select(OperationLog).where(OperationLog.id == log_id)
)
return result.scalar_one_or_none()
async def get_multi(
self,
db: AsyncSession,
*,
skip: int = 0,
limit: int = 100,
operator_id: Optional[int] = None,
operator_name: Optional[str] = None,
module: Optional[str] = None,
operation_type: Optional[str] = None,
result: Optional[str] = None,
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None,
keyword: Optional[str] = None
) -> tuple[List[OperationLog], int]:
"""
获取操作日志列表
Args:
db: 数据库会话
skip: 跳过条数
limit: 返回条数
operator_id: 操作人ID
operator_name: 操作人姓名
module: 模块名称
operation_type: 操作类型
result: 操作结果
start_time: 开始时间
end_time: 结束时间
keyword: 关键词
Returns:
(日志列表, 总数)
"""
# 构建查询条件
conditions = []
if operator_id:
conditions.append(OperationLog.operator_id == operator_id)
if operator_name:
conditions.append(OperationLog.operator_name.ilike(f"%{operator_name}%"))
if module:
conditions.append(OperationLog.module == module)
if operation_type:
conditions.append(OperationLog.operation_type == operation_type)
if result:
conditions.append(OperationLog.result == result)
if start_time:
conditions.append(OperationLog.created_at >= start_time)
if end_time:
conditions.append(OperationLog.created_at <= end_time)
if keyword:
conditions.append(
or_(
OperationLog.url.ilike(f"%{keyword}%"),
OperationLog.params.ilike(f"%{keyword}%"),
OperationLog.error_msg.ilike(f"%{keyword}%")
)
)
# 查询总数
count_query = select(func.count(OperationLog.id))
if conditions:
count_query = count_query.where(and_(*conditions))
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# 查询数据
query = select(OperationLog)
if conditions:
query = query.where(and_(*conditions))
query = query.order_by(desc(OperationLog.created_at))
query = query.offset(skip).limit(limit)
result = await db.execute(query)
items = result.scalars().all()
return list(items), total
async def create(
self,
db: AsyncSession,
*,
obj_in: Dict[str, Any]
) -> OperationLog:
"""
创建操作日志
Args:
db: 数据库会话
obj_in: 创建数据
Returns:
OperationLog对象
"""
db_obj = OperationLog(**obj_in)
db.add(db_obj)
await db.flush()
await db.refresh(db_obj)
return db_obj
async def get_statistics(
self,
db: AsyncSession,
*,
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None
) -> Dict[str, Any]:
"""
获取操作日志统计信息
Args:
db: 数据库会话
start_time: 开始时间
end_time: 结束时间
Returns:
统计信息
"""
# 构建时间条件
conditions = []
if start_time:
conditions.append(OperationLog.created_at >= start_time)
if end_time:
conditions.append(OperationLog.created_at <= end_time)
where_clause = and_(*conditions) if conditions else None
# 总数
total_query = select(func.count(OperationLog.id))
if where_clause:
total_query = total_query.where(where_clause)
total_result = await db.execute(total_query)
total_count = total_result.scalar() or 0
# 成功数
success_query = select(func.count(OperationLog.id)).where(OperationLog.result == "success")
if where_clause:
success_query = success_query.where(where_clause)
success_result = await db.execute(success_query)
success_count = success_result.scalar() or 0
# 失败数
failed_count = total_count - success_count
# 今日操作数
today = datetime.utcnow().date()
today_start = datetime.combine(today, datetime.min.time())
today_query = select(func.count(OperationLog.id)).where(OperationLog.created_at >= today_start)
today_result = await db.execute(today_query)
today_count = today_result.scalar() or 0
# 模块分布
module_query = select(
OperationLog.module,
func.count(OperationLog.id).label('count')
).group_by(OperationLog.module)
if where_clause:
module_query = module_query.where(where_clause)
module_result = await db.execute(module_query)
module_distribution = [
{"module": row[0], "count": row[1]}
for row in module_result
]
# 操作类型分布
operation_query = select(
OperationLog.operation_type,
func.count(OperationLog.id).label('count')
).group_by(OperationLog.operation_type)
if where_clause:
operation_query = operation_query.where(where_clause)
operation_result = await db.execute(operation_query)
operation_distribution = [
{"operation_type": row[0], "count": row[1]}
for row in operation_result
]
return {
"total_count": total_count,
"success_count": success_count,
"failed_count": failed_count,
"today_count": today_count,
"module_distribution": module_distribution,
"operation_distribution": operation_distribution,
}
async def delete_old_logs(
self,
db: AsyncSession,
*,
days: int = 90
) -> int:
"""
删除旧日志
Args:
db: 数据库会话
days: 保留天数
Returns:
删除的日志数量
"""
cutoff_date = datetime.utcnow() - timedelta(days=days)
# 查询要删除的日志
result = await db.execute(
select(OperationLog.id).where(OperationLog.created_at < cutoff_date)
)
ids_to_delete = [row[0] for row in result]
if not ids_to_delete:
return 0
# 批量删除
from sqlalchemy import delete
delete_stmt = delete(OperationLog).where(OperationLog.id.in_(ids_to_delete))
await db.execute(delete_stmt)
return len(ids_to_delete)
async def get_operator_top(
self,
db: AsyncSession,
*,
limit: int = 10,
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None
) -> List[Dict[str, Any]]:
"""
获取操作排行榜
Args:
db: 数据库会话
limit: 返回条数
start_time: 开始时间
end_time: 结束时间
Returns:
操作排行列表
"""
# 构建时间条件
conditions = []
if start_time:
conditions.append(OperationLog.created_at >= start_time)
if end_time:
conditions.append(OperationLog.created_at <= end_time)
query = select(
OperationLog.operator_id,
OperationLog.operator_name,
func.count(OperationLog.id).label('count')
).group_by(
OperationLog.operator_id,
OperationLog.operator_name
).order_by(
desc('count')
).limit(limit)
if conditions:
query = query.where(and_(*conditions))
result = await db.execute(query)
return [
{
"operator_id": row[0],
"operator_name": row[1],
"count": row[2]
}
for row in result
]
# 创建全局实例
operation_log_crud = OperationLogCRUD()

351
app/crud/organization.py Normal file
View File

@@ -0,0 +1,351 @@
"""
机构网点CRUD操作
"""
from typing import List, Optional, Tuple
from sqlalchemy import select, and_, or_, func
from sqlalchemy.orm import Session
from app.models.organization import Organization
from app.schemas.organization import OrganizationCreate, OrganizationUpdate
class OrganizationCRUD:
"""机构网点CRUD操作类"""
def get(self, db: Session, id: int) -> Optional[Organization]:
"""
根据ID获取机构
Args:
db: 数据库会话
id: 机构ID
Returns:
Organization对象或None
"""
return db.query(Organization).filter(
and_(
Organization.id == id,
Organization.deleted_at.is_(None)
)
).first()
def get_by_code(self, db: Session, code: str) -> Optional[Organization]:
"""
根据代码获取机构
Args:
db: 数据库会话
code: 机构代码
Returns:
Organization对象或None
"""
return db.query(Organization).filter(
and_(
Organization.org_code == code,
Organization.deleted_at.is_(None)
)
).first()
def get_multi(
self,
db: Session,
skip: int = 0,
limit: int = 20,
org_type: Optional[str] = None,
status: Optional[str] = None,
keyword: Optional[str] = None
) -> Tuple[List[Organization], int]:
"""
获取机构列表
Args:
db: 数据库会话
skip: 跳过条数
limit: 返回条数
org_type: 机构类型筛选
status: 状态筛选
keyword: 搜索关键词
Returns:
(机构列表, 总数)
"""
query = db.query(Organization).filter(Organization.deleted_at.is_(None))
# 筛选条件
if org_type:
query = query.filter(Organization.org_type == org_type)
if status:
query = query.filter(Organization.status == status)
if keyword:
query = query.filter(
or_(
Organization.org_code.ilike(f"%{keyword}%"),
Organization.org_name.ilike(f"%{keyword}%")
)
)
# 排序
query = query.order_by(Organization.tree_level.asc(), Organization.sort_order.asc(), Organization.id.asc())
# 总数
total = query.count()
# 分页
items = query.offset(skip).limit(limit).all()
return items, total
def get_tree(self, db: Session, status: Optional[str] = None) -> List[Organization]:
"""
获取机构树
Args:
db: 数据库会话
status: 状态筛选
Returns:
机构树列表
"""
query = db.query(Organization).filter(Organization.deleted_at.is_(None))
if status:
query = query.filter(Organization.status == status)
# 获取所有机构
all_orgs = query.order_by(Organization.tree_level.asc(), Organization.sort_order.asc()).all()
# 构建树形结构
org_map = {org.id: org for org in all_orgs}
tree = []
for org in all_orgs:
# 清空children列表
org.children = []
if org.parent_id is None:
# 根节点
tree.append(org)
else:
# 添加到父节点的children
parent = org_map.get(org.parent_id)
if parent:
if not hasattr(parent, 'children'):
parent.children = []
parent.children.append(org)
return tree
def get_children(self, db: Session, parent_id: int) -> List[Organization]:
"""
获取子机构列表(直接子节点)
Args:
db: 数据库会话
parent_id: 父机构ID
Returns:
子机构列表
"""
return db.query(Organization).filter(
and_(
Organization.parent_id == parent_id,
Organization.deleted_at.is_(None)
)
).order_by(Organization.sort_order.asc(), Organization.id.asc()).all()
def get_all_children(self, db: Session, parent_id: int) -> List[Organization]:
"""
递归获取所有子机构(包括子节点的子节点)
Args:
db: 数据库会话
parent_id: 父机构ID
Returns:
所有子机构列表
"""
# 获取父节点的tree_path
parent = self.get(db, parent_id)
if not parent:
return []
# 构建查询路径
if parent.tree_path:
search_path = f"{parent.tree_path}{parent.id}/"
else:
search_path = f"/{parent.id}/"
# 查询所有以该路径开头的机构
return db.query(Organization).filter(
and_(
Organization.tree_path.like(f"{search_path}%"),
Organization.deleted_at.is_(None)
)
).order_by(Organization.tree_level.asc(), Organization.sort_order.asc()).all()
def get_parents(self, db: Session, child_id: int) -> List[Organization]:
"""
递归获取所有父机构(从根到直接父节点)
Args:
db: 数据库会话
child_id: 子机构ID
Returns:
所有父机构列表(从根到父)
"""
child = self.get(db, child_id)
if not child or not child.tree_path:
return []
# 解析tree_path提取所有ID
path_ids = [int(id) for id in child.tree_path.split("/") if id]
if not path_ids:
return []
# 查询所有父机构
return db.query(Organization).filter(
and_(
Organization.id.in_(path_ids),
Organization.deleted_at.is_(None)
)
).order_by(Organization.tree_level.asc()).all()
def create(
self,
db: Session,
obj_in: OrganizationCreate,
creator_id: Optional[int] = None
) -> Organization:
"""
创建机构
Args:
db: 数据库会话
obj_in: 创建数据
creator_id: 创建人ID
Returns:
创建的Organization对象
"""
# 检查代码是否已存在
if self.get_by_code(db, obj_in.org_code):
raise ValueError(f"机构代码 '{obj_in.org_code}' 已存在")
# 计算tree_path和tree_level
tree_path = None
tree_level = 0
if obj_in.parent_id:
parent = self.get(db, obj_in.parent_id)
if not parent:
raise ValueError(f"父机构ID {obj_in.parent_id} 不存在")
# 构建tree_path
if parent.tree_path:
tree_path = f"{parent.tree_path}{parent.id}/"
else:
tree_path = f"/{parent.id}/"
tree_level = parent.tree_level + 1
db_obj = Organization(
**obj_in.model_dump(),
tree_path=tree_path,
tree_level=tree_level,
created_by=creator_id
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update(
self,
db: Session,
db_obj: Organization,
obj_in: OrganizationUpdate,
updater_id: Optional[int] = None
) -> Organization:
"""
更新机构
Args:
db: 数据库会话
db_obj: 数据库对象
obj_in: 更新数据
updater_id: 更新人ID
Returns:
更新后的Organization对象
"""
obj_data = obj_in.model_dump(exclude_unset=True)
# 如果更新了parent_id需要重新计算tree_path和tree_level
if "parent_id" in obj_data:
new_parent_id = obj_data["parent_id"]
old_parent_id = db_obj.parent_id
if new_parent_id != old_parent_id:
# 重新计算当前节点的路径
if new_parent_id:
new_parent = self.get(db, new_parent_id)
if not new_parent:
raise ValueError(f"父机构ID {new_parent_id} 不存在")
if new_parent.tree_path:
db_obj.tree_path = f"{new_parent.tree_path}{new_parent.id}/"
else:
db_obj.tree_path = f"/{new_parent.id}/"
db_obj.tree_level = new_parent.tree_level + 1
else:
# 变为根节点
db_obj.tree_path = None
db_obj.tree_level = 0
# TODO: 需要递归更新所有子节点的tree_path和tree_level
# 这里需要批量更新,暂时跳过
for field, value in obj_data.items():
if field != "parent_id": # parent_id已经处理
setattr(db_obj, field, value)
db_obj.updated_by = updater_id
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def delete(self, db: Session, id: int, deleter_id: Optional[int] = None) -> bool:
"""
删除机构(软删除)
Args:
db: 数据库会话
id: 机构ID
deleter_id: 删除人ID
Returns:
是否删除成功
"""
obj = self.get(db, id)
if not obj:
return False
# 检查是否有子机构
children = self.get_children(db, id)
if children:
raise ValueError("该机构下存在子机构,无法删除")
obj.deleted_at = func.now()
obj.deleted_by = deleter_id
db.add(obj)
db.commit()
return True
# 创建全局实例
organization = OrganizationCRUD()

314
app/crud/recovery.py Normal file
View File

@@ -0,0 +1,314 @@
"""
资产回收相关CRUD操作
"""
from typing import List, Optional, Tuple
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
from app.models.recovery import AssetRecoveryOrder, AssetRecoveryItem
from app.models.asset import Asset
from app.schemas.recovery import AssetRecoveryOrderCreate, AssetRecoveryOrderUpdate
class AssetRecoveryOrderCRUD:
"""回收单CRUD操作"""
def get(self, db: Session, id: int) -> Optional[AssetRecoveryOrder]:
"""根据ID获取回收单"""
return db.query(AssetRecoveryOrder).filter(
AssetRecoveryOrder.id == id
).first()
def get_by_code(self, db: Session, order_code: str) -> Optional[AssetRecoveryOrder]:
"""根据单号获取回收单"""
return db.query(AssetRecoveryOrder).filter(
AssetRecoveryOrder.order_code == order_code
).first()
def get_multi(
self,
db: Session,
skip: int = 0,
limit: int = 20,
recovery_type: Optional[str] = None,
approval_status: Optional[str] = None,
execute_status: Optional[str] = None,
keyword: Optional[str] = None
) -> Tuple[List[AssetRecoveryOrder], int]:
"""获取回收单列表"""
query = db.query(AssetRecoveryOrder)
# 筛选条件
if recovery_type:
query = query.filter(AssetRecoveryOrder.recovery_type == recovery_type)
if approval_status:
query = query.filter(AssetRecoveryOrder.approval_status == approval_status)
if execute_status:
query = query.filter(AssetRecoveryOrder.execute_status == execute_status)
if keyword:
query = query.filter(
or_(
AssetRecoveryOrder.order_code.like(f"%{keyword}%"),
AssetRecoveryOrder.title.like(f"%{keyword}%")
)
)
# 排序
query = query.order_by(AssetRecoveryOrder.created_at.desc())
# 总数
total = query.count()
# 分页
items = query.offset(skip).limit(limit).all()
return items, total
def create(
self,
db: Session,
obj_in: AssetRecoveryOrderCreate,
order_code: str,
apply_user_id: int
) -> AssetRecoveryOrder:
"""创建回收单"""
from datetime import datetime
# 创建回收单
db_obj = AssetRecoveryOrder(
order_code=order_code,
recovery_type=obj_in.recovery_type,
title=obj_in.title,
asset_count=len(obj_in.asset_ids),
apply_user_id=apply_user_id,
apply_time=datetime.utcnow(),
remark=obj_in.remark,
approval_status="pending",
execute_status="pending"
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
# 创建回收单明细
self._create_items(
db=db,
order_id=db_obj.id,
asset_ids=obj_in.asset_ids
)
return db_obj
def update(
self,
db: Session,
db_obj: AssetRecoveryOrder,
obj_in: AssetRecoveryOrderUpdate
) -> AssetRecoveryOrder:
"""更新回收单"""
update_data = obj_in.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(db_obj, field, value)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def approve(
self,
db: Session,
db_obj: AssetRecoveryOrder,
approval_status: str,
approval_user_id: int,
approval_remark: Optional[str] = None
) -> AssetRecoveryOrder:
"""审批回收单"""
from datetime import datetime
db_obj.approval_status = approval_status
db_obj.approval_user_id = approval_user_id
db_obj.approval_time = datetime.utcnow()
db_obj.approval_remark = approval_remark
# 如果审批通过,自动设置为可执行状态
if approval_status == "approved":
db_obj.execute_status = "pending"
elif approval_status == "rejected":
db_obj.execute_status = "cancelled"
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def start(
self,
db: Session,
db_obj: AssetRecoveryOrder,
execute_user_id: int
) -> AssetRecoveryOrder:
"""开始回收"""
from datetime import datetime
db_obj.execute_status = "executing"
db_obj.execute_user_id = execute_user_id
db_obj.execute_time = datetime.utcnow()
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def complete(
self,
db: Session,
db_obj: AssetRecoveryOrder,
execute_user_id: int
) -> AssetRecoveryOrder:
"""完成回收"""
from datetime import datetime
db_obj.execute_status = "completed"
db_obj.execute_user_id = execute_user_id
db_obj.execute_time = datetime.utcnow()
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def cancel(self, db: Session, db_obj: AssetRecoveryOrder) -> AssetRecoveryOrder:
"""取消回收单"""
db_obj.approval_status = "cancelled"
db_obj.execute_status = "cancelled"
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def delete(self, db: Session, id: int) -> bool:
"""删除回收单"""
obj = self.get(db, id)
if obj:
db.delete(obj)
db.commit()
return True
return False
def get_statistics(
self,
db: Session
) -> dict:
"""获取回收单统计信息"""
query = db.query(AssetRecoveryOrder)
total = query.count()
pending = query.filter(AssetRecoveryOrder.approval_status == "pending").count()
approved = query.filter(AssetRecoveryOrder.approval_status == "approved").count()
rejected = query.filter(AssetRecoveryOrder.approval_status == "rejected").count()
executing = query.filter(AssetRecoveryOrder.execute_status == "executing").count()
completed = query.filter(AssetRecoveryOrder.execute_status == "completed").count()
return {
"total": total,
"pending": pending,
"approved": approved,
"rejected": rejected,
"executing": executing,
"completed": completed
}
def _create_items(
self,
db: Session,
order_id: int,
asset_ids: List[int]
):
"""创建回收单明细"""
# 查询资产信息
assets = db.query(Asset).filter(Asset.id.in_(asset_ids)).all()
for asset in assets:
item = AssetRecoveryItem(
order_id=order_id,
asset_id=asset.id,
asset_code=asset.asset_code,
recovery_status="pending"
)
db.add(item)
db.commit()
class AssetRecoveryItemCRUD:
"""回收单明细CRUD操作"""
def get_by_order(self, db: Session, order_id: int) -> List[AssetRecoveryItem]:
"""根据回收单ID获取明细列表"""
return db.query(AssetRecoveryItem).filter(
AssetRecoveryItem.order_id == order_id
).order_by(AssetRecoveryItem.id).all()
def get_multi(
self,
db: Session,
skip: int = 0,
limit: int = 20,
order_id: Optional[int] = None,
recovery_status: Optional[str] = None
) -> Tuple[List[AssetRecoveryItem], int]:
"""获取明细列表"""
query = db.query(AssetRecoveryItem)
if order_id:
query = query.filter(AssetRecoveryItem.order_id == order_id)
if recovery_status:
query = query.filter(AssetRecoveryItem.recovery_status == recovery_status)
total = query.count()
items = query.offset(skip).limit(limit).all()
return items, total
def update_recovery_status(
self,
db: Session,
item_id: int,
recovery_status: str
) -> AssetRecoveryItem:
"""更新明细回收状态"""
item = db.query(AssetRecoveryItem).filter(
AssetRecoveryItem.id == item_id
).first()
if item:
item.recovery_status = recovery_status
db.add(item)
db.commit()
db.refresh(item)
return item
def batch_update_recovery_status(
self,
db: Session,
order_id: int,
recovery_status: str
):
"""批量更新明细回收状态"""
items = db.query(AssetRecoveryItem).filter(
and_(
AssetRecoveryItem.order_id == order_id,
AssetRecoveryItem.recovery_status == "pending"
)
).all()
for item in items:
item.recovery_status = recovery_status
db.add(item)
db.commit()
# 创建全局实例
recovery_order = AssetRecoveryOrderCRUD()
recovery_item = AssetRecoveryItemCRUD()

324
app/crud/system_config.py Normal file
View File

@@ -0,0 +1,324 @@
"""
系统配置CRUD操作
"""
from typing import Optional, List, Dict, Any
from sqlalchemy import select, and_, or_, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.system_config import SystemConfig
import json
class SystemConfigCRUD:
"""系统配置CRUD类"""
async def get(self, db: AsyncSession, config_id: int) -> Optional[SystemConfig]:
"""
根据ID获取系统配置
Args:
db: 数据库会话
config_id: 配置ID
Returns:
SystemConfig对象或None
"""
result = await db.execute(
select(SystemConfig).where(SystemConfig.id == config_id)
)
return result.scalar_one_or_none()
async def get_by_key(self, db: AsyncSession, config_key: str) -> Optional[SystemConfig]:
"""
根据配置键获取系统配置
Args:
db: 数据库会话
config_key: 配置键
Returns:
SystemConfig对象或None
"""
result = await db.execute(
select(SystemConfig).where(SystemConfig.config_key == config_key)
)
return result.scalar_one_or_none()
async def get_multi(
self,
db: AsyncSession,
*,
skip: int = 0,
limit: int = 100,
keyword: Optional[str] = None,
category: Optional[str] = None,
is_active: Optional[bool] = None,
is_system: Optional[bool] = None
) -> tuple[List[SystemConfig], int]:
"""
获取系统配置列表
Args:
db: 数据库会话
skip: 跳过条数
limit: 返回条数
keyword: 搜索关键词
category: 配置分类
is_active: 是否启用
is_system: 是否系统配置
Returns:
(配置列表, 总数)
"""
# 构建查询条件
conditions = []
if keyword:
conditions.append(
or_(
SystemConfig.config_key.ilike(f"%{keyword}%"),
SystemConfig.config_name.ilike(f"%{keyword}%"),
SystemConfig.description.ilike(f"%{keyword}%")
)
)
if category:
conditions.append(SystemConfig.category == category)
if is_active is not None:
conditions.append(SystemConfig.is_active == is_active)
if is_system is not None:
conditions.append(SystemConfig.is_system == is_system)
# 查询总数
count_query = select(func.count(SystemConfig.id))
if conditions:
count_query = count_query.where(and_(*conditions))
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# 查询数据
query = select(SystemConfig)
if conditions:
query = query.where(and_(*conditions))
query = query.order_by(SystemConfig.category, SystemConfig.sort_order, SystemConfig.id)
query = query.offset(skip).limit(limit)
result = await db.execute(query)
items = result.scalars().all()
return list(items), total
async def get_by_category(
self,
db: AsyncSession,
category: str,
*,
is_active: bool = True
) -> List[SystemConfig]:
"""
根据分类获取配置列表
Args:
db: 数据库会话
category: 配置分类
is_active: 是否启用
Returns:
配置列表
"""
conditions = [SystemConfig.category == category]
if is_active:
conditions.append(SystemConfig.is_active == True)
result = await db.execute(
select(SystemConfig)
.where(and_(*conditions))
.order_by(SystemConfig.sort_order, SystemConfig.id)
)
return list(result.scalars().all())
async def get_categories(
self,
db: AsyncSession
) -> List[Dict[str, Any]]:
"""
获取所有配置分类及统计信息
Args:
db: 数据库会话
Returns:
分类列表
"""
result = await db.execute(
select(
SystemConfig.category,
func.count(SystemConfig.id).label('count')
)
.group_by(SystemConfig.category)
.order_by(SystemConfig.category)
)
categories = []
for row in result:
categories.append({
"category": row[0],
"count": row[1]
})
return categories
async def create(
self,
db: AsyncSession,
*,
obj_in: Dict[str, Any]
) -> SystemConfig:
"""
创建系统配置
Args:
db: 数据库会话
obj_in: 创建数据
Returns:
SystemConfig对象
"""
db_obj = SystemConfig(**obj_in)
db.add(db_obj)
await db.flush()
await db.refresh(db_obj)
return db_obj
async def update(
self,
db: AsyncSession,
*,
db_obj: SystemConfig,
obj_in: Dict[str, Any]
) -> SystemConfig:
"""
更新系统配置
Args:
db: 数据库会话
db_obj: 数据库对象
obj_in: 更新数据
Returns:
SystemConfig对象
"""
for field, value in obj_in.items():
if hasattr(db_obj, field):
setattr(db_obj, field, value)
await db.flush()
await db.refresh(db_obj)
return db_obj
async def batch_update(
self,
db: AsyncSession,
*,
configs: Dict[str, Any],
updater_id: Optional[int] = None
) -> List[SystemConfig]:
"""
批量更新配置
Args:
db: 数据库会话
configs: 配置键值对
updater_id: 更新人ID
Returns:
更新的配置列表
"""
updated_configs = []
for config_key, config_value in configs.items():
db_obj = await self.get_by_key(db, config_key)
if db_obj:
# 转换为字符串存储
if isinstance(config_value, (dict, list)):
config_value = json.dumps(config_value, ensure_ascii=False)
elif isinstance(config_value, bool):
config_value = str(config_value).lower()
else:
config_value = str(config_value)
db_obj.config_value = config_value
db_obj.updated_by = updater_id
updated_configs.append(db_obj)
await db.flush()
return updated_configs
async def delete(self, db: AsyncSession, *, config_id: int) -> Optional[SystemConfig]:
"""
删除系统配置
Args:
db: 数据库会话
config_id: 配置ID
Returns:
删除的SystemConfig对象或None
"""
obj = await self.get(db, config_id)
if obj:
# 系统配置不允许删除
if obj.is_system:
raise ValueError("系统配置不允许删除")
await db.delete(obj)
await db.flush()
return obj
async def get_value(
self,
db: AsyncSession,
config_key: str,
default: Any = None
) -> Any:
"""
获取配置值(自动转换类型)
Args:
db: 数据库会话
config_key: 配置键
default: 默认值
Returns:
配置值
"""
config = await self.get_by_key(db, config_key)
if not config or not config.is_active:
return default
value = config.config_value
# 根据类型转换
if config.value_type == "boolean":
return value.lower() in ("true", "1", "yes") if value else False
elif config.value_type == "number":
try:
return int(value) if value else 0
except ValueError:
try:
return float(value) if value else 0.0
except ValueError:
return 0
elif config.value_type == "json":
try:
return json.loads(value) if value else {}
except json.JSONDecodeError:
return {}
else:
return value
# 创建全局实例
system_config_crud = SystemConfigCRUD()

335
app/crud/transfer.py Normal file
View File

@@ -0,0 +1,335 @@
"""
资产调拨相关CRUD操作
"""
from typing import List, Optional, Tuple
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
from app.models.transfer import AssetTransferOrder, AssetTransferItem
from app.models.asset import Asset
from app.schemas.transfer import AssetTransferOrderCreate, AssetTransferOrderUpdate
class AssetTransferOrderCRUD:
"""调拨单CRUD操作"""
def get(self, db: Session, id: int) -> Optional[AssetTransferOrder]:
"""根据ID获取调拨单"""
return db.query(AssetTransferOrder).filter(
AssetTransferOrder.id == id
).first()
def get_by_code(self, db: Session, order_code: str) -> Optional[AssetTransferOrder]:
"""根据单号获取调拨单"""
return db.query(AssetTransferOrder).filter(
AssetTransferOrder.order_code == order_code
).first()
def get_multi(
self,
db: Session,
skip: int = 0,
limit: int = 20,
transfer_type: Optional[str] = None,
approval_status: Optional[str] = None,
execute_status: Optional[str] = None,
source_org_id: Optional[int] = None,
target_org_id: Optional[int] = None,
keyword: Optional[str] = None
) -> Tuple[List[AssetTransferOrder], int]:
"""获取调拨单列表"""
query = db.query(AssetTransferOrder)
# 筛选条件
if transfer_type:
query = query.filter(AssetTransferOrder.transfer_type == transfer_type)
if approval_status:
query = query.filter(AssetTransferOrder.approval_status == approval_status)
if execute_status:
query = query.filter(AssetTransferOrder.execute_status == execute_status)
if source_org_id:
query = query.filter(AssetTransferOrder.source_org_id == source_org_id)
if target_org_id:
query = query.filter(AssetTransferOrder.target_org_id == target_org_id)
if keyword:
query = query.filter(
or_(
AssetTransferOrder.order_code.like(f"%{keyword}%"),
AssetTransferOrder.title.like(f"%{keyword}%")
)
)
# 排序
query = query.order_by(AssetTransferOrder.created_at.desc())
# 总数
total = query.count()
# 分页
items = query.offset(skip).limit(limit).all()
return items, total
def create(
self,
db: Session,
obj_in: AssetTransferOrderCreate,
order_code: str,
apply_user_id: int
) -> AssetTransferOrder:
"""创建调拨单"""
from datetime import datetime
# 创建调拨单
db_obj = AssetTransferOrder(
order_code=order_code,
source_org_id=obj_in.source_org_id,
target_org_id=obj_in.target_org_id,
transfer_type=obj_in.transfer_type,
title=obj_in.title,
asset_count=len(obj_in.asset_ids),
apply_user_id=apply_user_id,
apply_time=datetime.utcnow(),
remark=obj_in.remark,
approval_status="pending",
execute_status="pending"
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
# 创建调拨单明细
self._create_items(
db=db,
order_id=db_obj.id,
asset_ids=obj_in.asset_ids,
source_org_id=obj_in.source_org_id,
target_org_id=obj_in.target_org_id
)
return db_obj
def update(
self,
db: Session,
db_obj: AssetTransferOrder,
obj_in: AssetTransferOrderUpdate
) -> AssetTransferOrder:
"""更新调拨单"""
update_data = obj_in.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(db_obj, field, value)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def approve(
self,
db: Session,
db_obj: AssetTransferOrder,
approval_status: str,
approval_user_id: int,
approval_remark: Optional[str] = None
) -> AssetTransferOrder:
"""审批调拨单"""
from datetime import datetime
db_obj.approval_status = approval_status
db_obj.approval_user_id = approval_user_id
db_obj.approval_time = datetime.utcnow()
db_obj.approval_remark = approval_remark
# 如果审批通过,自动设置为可执行状态
if approval_status == "approved":
db_obj.execute_status = "pending"
elif approval_status == "rejected":
db_obj.execute_status = "cancelled"
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def start(
self,
db: Session,
db_obj: AssetTransferOrder,
execute_user_id: int
) -> AssetTransferOrder:
"""开始调拨"""
from datetime import datetime
db_obj.execute_status = "executing"
db_obj.execute_user_id = execute_user_id
db_obj.execute_time = datetime.utcnow()
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def complete(
self,
db: Session,
db_obj: AssetTransferOrder,
execute_user_id: int
) -> AssetTransferOrder:
"""完成调拨"""
from datetime import datetime
db_obj.execute_status = "completed"
db_obj.execute_user_id = execute_user_id
db_obj.execute_time = datetime.utcnow()
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def cancel(self, db: Session, db_obj: AssetTransferOrder) -> AssetTransferOrder:
"""取消调拨单"""
db_obj.approval_status = "cancelled"
db_obj.execute_status = "cancelled"
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def delete(self, db: Session, id: int) -> bool:
"""删除调拨单"""
obj = self.get(db, id)
if obj:
db.delete(obj)
db.commit()
return True
return False
def get_statistics(
self,
db: Session,
source_org_id: Optional[int] = None,
target_org_id: Optional[int] = None
) -> dict:
"""获取调拨单统计信息"""
query = db.query(AssetTransferOrder)
if source_org_id:
query = query.filter(AssetTransferOrder.source_org_id == source_org_id)
if target_org_id:
query = query.filter(AssetTransferOrder.target_org_id == target_org_id)
total = query.count()
pending = query.filter(AssetTransferOrder.approval_status == "pending").count()
approved = query.filter(AssetTransferOrder.approval_status == "approved").count()
rejected = query.filter(AssetTransferOrder.approval_status == "rejected").count()
executing = query.filter(AssetTransferOrder.execute_status == "executing").count()
completed = query.filter(AssetTransferOrder.execute_status == "completed").count()
return {
"total": total,
"pending": pending,
"approved": approved,
"rejected": rejected,
"executing": executing,
"completed": completed
}
def _create_items(
self,
db: Session,
order_id: int,
asset_ids: List[int],
source_org_id: int,
target_org_id: int
):
"""创建调拨单明细"""
# 查询资产信息
assets = db.query(Asset).filter(Asset.id.in_(asset_ids)).all()
for asset in assets:
item = AssetTransferItem(
order_id=order_id,
asset_id=asset.id,
asset_code=asset.asset_code,
source_organization_id=source_org_id,
target_organization_id=target_org_id,
transfer_status="pending"
)
db.add(item)
db.commit()
class AssetTransferItemCRUD:
"""调拨单明细CRUD操作"""
def get_by_order(self, db: Session, order_id: int) -> List[AssetTransferItem]:
"""根据调拨单ID获取明细列表"""
return db.query(AssetTransferItem).filter(
AssetTransferItem.order_id == order_id
).order_by(AssetTransferItem.id).all()
def get_multi(
self,
db: Session,
skip: int = 0,
limit: int = 20,
order_id: Optional[int] = None,
transfer_status: Optional[str] = None
) -> Tuple[List[AssetTransferItem], int]:
"""获取明细列表"""
query = db.query(AssetTransferItem)
if order_id:
query = query.filter(AssetTransferItem.order_id == order_id)
if transfer_status:
query = query.filter(AssetTransferItem.transfer_status == transfer_status)
total = query.count()
items = query.offset(skip).limit(limit).all()
return items, total
def update_transfer_status(
self,
db: Session,
item_id: int,
transfer_status: str
) -> AssetTransferItem:
"""更新明细调拨状态"""
item = db.query(AssetTransferItem).filter(
AssetTransferItem.id == item_id
).first()
if item:
item.transfer_status = transfer_status
db.add(item)
db.commit()
db.refresh(item)
return item
def batch_update_transfer_status(
self,
db: Session,
order_id: int,
transfer_status: str
):
"""批量更新明细调拨状态"""
items = db.query(AssetTransferItem).filter(
and_(
AssetTransferItem.order_id == order_id,
AssetTransferItem.transfer_status == "pending"
)
).all()
for item in items:
item.transfer_status = transfer_status
db.add(item)
db.commit()
# 创建全局实例
transfer_order = AssetTransferOrderCRUD()
transfer_item = AssetTransferItemCRUD()

435
app/crud/user.py Normal file
View File

@@ -0,0 +1,435 @@
"""
用户CRUD操作
"""
from typing import Optional, List, Tuple
from sqlalchemy import select, and_, or_
from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User, Role, UserRole, Permission, RolePermission
from app.schemas.user import UserCreate, UserUpdate, RoleCreate, RoleUpdate
from app.core.security import get_password_hash
class UserCRUD:
"""用户CRUD类"""
async def get(self, db: AsyncSession, id: int) -> Optional[User]:
"""
根据ID获取用户
Args:
db: 数据库会话
id: 用户ID
Returns:
User: 用户对象或None
"""
result = await db.execute(
select(User)
.options(selectinload(User.roles).selectinload(Role.permissions))
.where(User.id == id, User.deleted_at.is_(None))
)
return result.scalar_one_or_none()
async def get_by_username(self, db: AsyncSession, username: str) -> Optional[User]:
"""
根据用户名获取用户
Args:
db: 数据库会话
username: 用户名
Returns:
User: 用户对象或None
"""
result = await db.execute(
select(User)
.options(selectinload(User.roles).selectinload(Role.permissions))
.where(User.username == username, User.deleted_at.is_(None))
)
return result.scalar_one_or_none()
async def get_by_email(self, db: AsyncSession, email: str) -> Optional[User]:
"""
根据邮箱获取用户
Args:
db: 数据库会话
email: 邮箱
Returns:
User: 用户对象或None
"""
result = await db.execute(
select(User)
.where(User.email == email, User.deleted_at.is_(None))
)
return result.scalar_one_or_none()
async def get_multi(
self,
db: AsyncSession,
skip: int = 0,
limit: int = 20,
keyword: Optional[str] = None,
status: Optional[str] = None,
role_id: Optional[int] = None
) -> Tuple[List[User], int]:
"""
获取用户列表
Args:
db: 数据库会话
skip: 跳过条数
limit: 返回条数
keyword: 搜索关键词
status: 状态筛选
role_id: 角色ID筛选
Returns:
Tuple[List[User], int]: 用户列表和总数
"""
# 构建查询条件
conditions = [User.deleted_at.is_(None)]
if keyword:
keyword_pattern = f"%{keyword}%"
conditions.append(
or_(
User.username.ilike(keyword_pattern),
User.real_name.ilike(keyword_pattern),
User.phone.ilike(keyword_pattern)
)
)
if status:
conditions.append(User.status == status)
# 构建基础查询
query = select(User).options(selectinload(User.roles)).where(*conditions)
# 如果需要按角色筛选
if role_id:
query = query.join(UserRole).where(UserRole.role_id == role_id)
# 按ID降序排序
query = query.order_by(User.id.desc())
# 获取总数
count_query = select(User.id).where(*conditions)
if role_id:
count_query = count_query.join(UserRole).where(UserRole.role_id == role_id)
result = await db.execute(select(User.id).where(*conditions))
total = len(result.all())
# 分页查询
result = await db.execute(query.offset(skip).limit(limit))
users = result.scalars().all()
return list(users), total
async def create(self, db: AsyncSession, obj_in: UserCreate, creator_id: int) -> User:
"""
创建用户
Args:
db: 数据库会话
obj_in: 创建数据
creator_id: 创建人ID
Returns:
User: 创建的用户对象
"""
# 检查用户名是否已存在
existing_user = await self.get_by_username(db, obj_in.username)
if existing_user:
raise ValueError("用户名已存在")
# 检查邮箱是否已存在
if obj_in.email:
existing_email = await self.get_by_email(db, obj_in.email)
if existing_email:
raise ValueError("邮箱已存在")
# 创建用户对象
db_obj = User(
username=obj_in.username,
password_hash=get_password_hash(obj_in.password),
real_name=obj_in.real_name,
email=obj_in.email,
phone=obj_in.phone,
created_by=creator_id
)
db.add(db_obj)
await db.flush()
await db.refresh(db_obj)
# 分配角色
for role_id in obj_in.role_ids:
user_role = UserRole(
user_id=db_obj.id,
role_id=role_id,
created_by=creator_id
)
db.add(user_role)
await db.commit()
await db.refresh(db_obj)
return await self.get(db, db_obj.id)
async def update(
self,
db: AsyncSession,
db_obj: User,
obj_in: UserUpdate,
updater_id: int
) -> User:
"""
更新用户
Args:
db: 数据库会话
db_obj: 数据库中的用户对象
obj_in: 更新数据
updater_id: 更新人ID
Returns:
User: 更新后的用户对象
"""
update_data = obj_in.model_dump(exclude_unset=True)
# 检查邮箱是否已被其他用户使用
if "email" in update_data and update_data["email"]:
existing_user = await db.execute(
select(User).where(
User.email == update_data["email"],
User.id != db_obj.id,
User.deleted_at.is_(None)
)
)
if existing_user.scalar_one_or_none():
raise ValueError("邮箱已被使用")
# 更新字段
for field, value in update_data.items():
if field == "role_ids":
continue
setattr(db_obj, field, value)
db_obj.updated_by = updater_id
# 更新角色
if "role_ids" in update_data:
# 删除旧角色
await db.execute(
select(UserRole).where(UserRole.user_id == db_obj.id)
)
old_roles = await db.execute(
select(UserRole).where(UserRole.user_id == db_obj.id)
)
for old_role in old_roles.scalars().all():
await db.delete(old_role)
# 添加新角色
for role_id in update_data["role_ids"]:
user_role = UserRole(
user_id=db_obj.id,
role_id=role_id,
created_by=updater_id
)
db.add(user_role)
await db.commit()
await db.refresh(db_obj)
return await self.get(db, db_obj.id)
async def delete(self, db: AsyncSession, id: int, deleter_id: int) -> bool:
"""
删除用户(软删除)
Args:
db: 数据库会话
id: 用户ID
deleter_id: 删除人ID
Returns:
bool: 是否删除成功
"""
db_obj = await self.get(db, id)
if not db_obj:
return False
db_obj.deleted_at = datetime.utcnow()
db_obj.deleted_by = deleter_id
await db.commit()
return True
async def update_password(
self,
db: AsyncSession,
user: User,
new_password: str
) -> bool:
"""
更新用户密码
Args:
db: 数据库会话
user: 用户对象
new_password: 新密码
Returns:
bool: 是否更新成功
"""
user.password_hash = get_password_hash(new_password)
user.login_fail_count = 0
user.locked_until = None
await db.commit()
return True
async def update_last_login(self, db: AsyncSession, user: User) -> bool:
"""
更新用户最后登录时间
Args:
db: 数据库会话
user: 用户对象
Returns:
bool: 是否更新成功
"""
from datetime import datetime
user.last_login_at = datetime.utcnow()
user.login_fail_count = 0
await db.commit()
return True
class RoleCRUD:
"""角色CRUD类"""
async def get(self, db: AsyncSession, id: int) -> Optional[Role]:
"""根据ID获取角色"""
result = await db.execute(
select(Role)
.options(selectinload(Role.permissions))
.where(Role.id == id, Role.deleted_at.is_(None))
)
return result.scalar_one_or_none()
async def get_by_code(self, db: AsyncSession, role_code: str) -> Optional[Role]:
"""根据代码获取角色"""
result = await db.execute(
select(Role).where(Role.role_code == role_code, Role.deleted_at.is_(None))
)
return result.scalar_one_or_none()
async def get_multi(
self,
db: AsyncSession,
status: Optional[str] = None
) -> List[Role]:
"""获取角色列表"""
conditions = [Role.deleted_at.is_(None)]
if status:
conditions.append(Role.status == status)
result = await db.execute(
select(Role)
.options(selectinload(Role.permissions))
.where(*conditions)
.order_by(Role.sort_order, Role.id)
)
return list(result.scalars().all())
async def create(self, db: AsyncSession, obj_in: RoleCreate, creator_id: int) -> Role:
"""创建角色"""
# 检查代码是否已存在
existing_role = await self.get_by_code(db, obj_in.role_code)
if existing_role:
raise ValueError("角色代码已存在")
db_obj = Role(
role_name=obj_in.role_name,
role_code=obj_in.role_code,
description=obj_in.description,
created_by=creator_id
)
db.add(db_obj)
await db.flush()
await db.refresh(db_obj)
# 分配权限
for permission_id in obj_in.permission_ids:
role_permission = RolePermission(
role_id=db_obj.id,
permission_id=permission_id,
created_by=creator_id
)
db.add(role_permission)
await db.commit()
return await self.get(db, db_obj.id)
async def update(
self,
db: AsyncSession,
db_obj: Role,
obj_in: RoleUpdate,
updater_id: int
) -> Role:
"""更新角色"""
update_data = obj_in.model_dump(exclude_unset=True)
for field, value in update_data.items():
if field == "permission_ids":
continue
setattr(db_obj, field, value)
db_obj.updated_by = updater_id
# 更新权限
if "permission_ids" in update_data:
# 删除旧权限
old_permissions = await db.execute(
select(RolePermission).where(RolePermission.role_id == db_obj.id)
)
for old_perm in old_permissions.scalars().all():
await db.delete(old_perm)
# 添加新权限
for permission_id in update_data["permission_ids"]:
role_permission = RolePermission(
role_id=db_obj.id,
permission_id=permission_id,
created_by=updater_id
)
db.add(role_permission)
await db.commit()
return await self.get(db, db_obj.id)
async def delete(self, db: AsyncSession, id: int) -> bool:
"""删除角色(软删除)"""
db_obj = await self.get(db, id)
if not db_obj:
return False
db_obj.deleted_at = datetime.utcnow()
await db.commit()
return True
# 创建CRUD实例
user_crud = UserCRUD()
role_crud = RoleCRUD()

12
app/db/__init__.py Normal file
View File

@@ -0,0 +1,12 @@
"""
数据库模块初始化
"""
from app.db.session import engine, async_session_maker, get_db, init_db, close_db
__all__ = [
"engine",
"async_session_maker",
"get_db",
"init_db",
"close_db",
]

12
app/db/base.py Normal file
View File

@@ -0,0 +1,12 @@
"""
数据库基类和配置
"""
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
"""数据库模型基类"""
pass
__all__ = ["Base"]

70
app/db/session.py Normal file
View File

@@ -0,0 +1,70 @@
"""
数据库会话管理
"""
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from app.core.config import settings
from app.db.base import Base
# 创建异步引擎
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DATABASE_ECHO,
pool_pre_ping=True,
pool_size=50, # 从20增加到50提高并发性能
max_overflow=10, # 从0增加到10允许峰值时的额外连接
)
# 创建异步会话工厂
async_session_maker = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autocommit=False,
autoflush=False,
)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""
获取数据库会话
Yields:
AsyncSession: 数据库会话
"""
async with async_session_maker() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def init_db() -> None:
"""
初始化数据库(创建所有表)
注意生产环境应使用Alembic迁移
"""
async with engine.begin() as conn:
# 导入所有模型以确保它们被注册
from app.models import user, asset, device_type, organization
# 创建所有表
await conn.run_sync(Base.metadata.create_all)
async def close_db() -> None:
"""关闭数据库连接"""
await engine.dispose()
__all__ = [
"engine",
"async_session_maker",
"get_db",
"init_db",
"close_db",
]

177
app/main.py Normal file
View File

@@ -0,0 +1,177 @@
"""
FastAPI应用主入口
"""
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException
from loguru import logger
import sys
from app.core.config import settings
from app.core.exceptions import BusinessException
from app.core.response import error_response
from app.api.v1 import api_router
from app.db.session import init_db, close_db
# 配置日志
logger.remove()
logger.add(
sys.stderr,
level=settings.LOG_LEVEL,
format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>",
colorize=True
)
logger.add(
settings.LOG_FILE,
rotation=settings.LOG_ROTATION,
retention=settings.LOG_RETENTION,
level=settings.LOG_LEVEL,
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}",
encoding="utf-8"
)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期管理"""
# 启动时执行
logger.info("🚀 应用启动中...")
logger.info(f"📦 环境: {settings.APP_ENVIRONMENT}")
logger.info(f"🔗 数据库: {settings.DATABASE_URL}")
# 初始化数据库生产环境使用Alembic迁移
if settings.is_development:
await init_db()
logger.info("✅ 数据库初始化完成")
yield
# 关闭时执行
logger.info("🛑 应用关闭中...")
await close_db()
logger.info("✅ 数据库连接已关闭")
# 创建FastAPI应用
app = FastAPI(
title=settings.APP_NAME,
version=settings.APP_VERSION,
description="企业级资产管理系统后端API",
docs_url="/docs" if settings.DEBUG else None,
redoc_url="/redoc" if settings.DEBUG else None,
openapi_url="/openapi.json" if settings.DEBUG else None,
lifespan=lifespan
)
# 配置CORS
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=settings.CORS_ALLOW_CREDENTIALS,
allow_methods=settings.CORS_ALLOW_METHODS,
allow_headers=settings.CORS_ALLOW_HEADERS,
)
# 自定义异常处理器
@app.exception_handler(BusinessException)
async def business_exception_handler(request: Request, exc: BusinessException):
"""业务异常处理"""
logger.warning(f"业务异常: {exc.message} - 错误码: {exc.error_code}")
return JSONResponse(
status_code=exc.code,
content=error_response(
code=exc.code,
message=exc.message,
errors=[{"field": k, "message": v} for k, v in exc.data.items()] if exc.data else None
)
)
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
"""HTTP异常处理"""
logger.warning(f"HTTP异常: {exc.status_code} - {exc.detail}")
return JSONResponse(
status_code=exc.status_code,
content=error_response(
code=exc.status_code,
message=str(exc.detail)
)
)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
"""请求验证异常处理"""
errors = []
for error in exc.errors():
errors.append({
"field": ".".join(str(loc) for loc in error["loc"]),
"message": error["msg"]
})
logger.warning(f"验证异常: {errors}")
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content=error_response(
code=status.HTTP_422_UNPROCESSABLE_ENTITY,
message="参数验证失败",
errors=errors
)
)
@app.exception_handler(Exception)
async def general_exception_handler(request: Request, exc: Exception):
"""通用异常处理"""
logger.error(f"未处理的异常: {type(exc).__name__} - {str(exc)}", exc_info=True)
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content=error_response(
code=status.HTTP_500_INTERNAL_SERVER_ERROR,
message="服务器内部错误" if not settings.DEBUG else str(exc)
)
)
# 注册路由
app.include_router(api_router, prefix=settings.API_V1_PREFIX)
# 健康检查
@app.get("/health", tags=["系统"])
async def health_check():
"""健康检查接口"""
return {
"status": "ok",
"app_name": settings.APP_NAME,
"version": settings.APP_VERSION,
"environment": settings.APP_ENVIRONMENT
}
# 根路径
@app.get("/", tags=["系统"])
async def root():
"""根路径"""
return {
"message": f"欢迎使用{settings.APP_NAME} API",
"version": settings.APP_VERSION,
"docs": "/docs" if settings.DEBUG else None
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"app.main:app",
host=settings.HOST,
port=settings.PORT,
reload=settings.DEBUG,
log_level=settings.LOG_LEVEL.lower()
)

View File

@@ -0,0 +1,6 @@
"""
中间件模块
"""
from app.middleware.operation_log import OperationLogMiddleware
__all__ = ["OperationLogMiddleware"]

View File

@@ -0,0 +1,194 @@
"""
操作日志中间件
"""
import time
import json
from typing import Callable
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import async_session_maker
from app.schemas.operation_log import OperationLogCreate, OperationModuleEnum, OperationTypeEnum, OperationResultEnum
from app.services.operation_log_service import operation_log_service
class OperationLogMiddleware(BaseHTTPMiddleware):
"""操作日志中间件"""
# 不需要记录的路径
EXCLUDE_PATHS = [
"/health",
"/docs",
"/openapi.json",
"/api/v1/auth/login",
"/api/v1/auth/captcha",
]
# 路径到模块的映射
PATH_MODULE_MAP = {
"/auth": OperationModuleEnum.AUTH,
"/device-types": OperationModuleEnum.DEVICE_TYPE,
"/organizations": OperationModuleEnum.ORGANIZATION,
"/assets": OperationModuleEnum.ASSET,
"/brands": OperationModuleEnum.BRAND_SUPPLIER,
"/suppliers": OperationModuleEnum.BRAND_SUPPLIER,
"/allocation-orders": OperationModuleEnum.ALLOCATION,
"/maintenance-records": OperationModuleEnum.MAINTENANCE,
"/system-config": OperationModuleEnum.SYSTEM_CONFIG,
"/users": OperationModuleEnum.USER,
"/statistics": OperationModuleEnum.STATISTICS,
"/operation-logs": OperationModuleEnum.SYSTEM_CONFIG,
"/notifications": OperationModuleEnum.SYSTEM_CONFIG,
}
# 方法到操作类型的映射
METHOD_OPERATION_MAP = {
"GET": OperationTypeEnum.QUERY,
"POST": OperationTypeEnum.CREATE,
"PUT": OperationTypeEnum.UPDATE,
"PATCH": OperationTypeEnum.UPDATE,
"DELETE": OperationTypeEnum.DELETE,
}
async def dispatch(self, request: Request, call_next: Callable) -> Response:
"""处理请求"""
# 检查是否需要记录
if self._should_log(request):
# 记录开始时间
start_time = time.time()
# 获取用户信息
user = getattr(request.state, "user", None)
# 处理请求
response = await call_next(request)
# 计算执行时长
duration = int((time.time() - start_time) * 1000)
# 异步记录日志
if user:
await self._log_operation(request, response, user, duration)
return response
return await call_next(request)
def _should_log(self, request: Request) -> bool:
"""判断是否需要记录日志"""
path = request.url.path
# 检查排除路径
for exclude_path in self.EXCLUDE_PATHS:
if path.startswith(exclude_path):
return False
# 只记录API请求
return path.startswith("/api/")
async def _log_operation(
self,
request: Request,
response: Response,
user,
duration: int
):
"""记录操作日志"""
try:
# 获取模块
module = self._get_module(request.url.path)
# 获取操作类型
operation_type = self.METHOD_OPERATION_MAP.get(request.method, OperationTypeEnum.QUERY)
# 特殊处理:如果是登录/登出
if "/auth/login" in request.url.path:
operation_type = OperationTypeEnum.LOGIN
elif "/auth/logout" in request.url.path:
operation_type = OperationTypeEnum.LOGOUT
# 获取请求参数
params = await self._get_request_params(request)
# 构建日志数据
log_data = OperationLogCreate(
operator_id=user.id,
operator_name=user.real_name or user.username,
operator_ip=request.client.host if request.client else None,
module=module,
operation_type=operation_type,
method=request.method,
url=request.url.path,
params=params,
result=OperationResultEnum.SUCCESS if response.status_code < 400 else OperationResultEnum.FAILED,
error_msg=None if response.status_code < 400 else f"HTTP {response.status_code}",
duration=duration,
user_agent=request.headers.get("user-agent"),
)
# 异步保存日志
async with async_session_maker() as db:
await operation_log_service.create_log(db, log_data)
except Exception as e:
# 记录日志失败不应影响业务
print(f"Failed to log operation: {e}")
def _get_module(self, path: str) -> OperationModuleEnum:
"""根据路径获取模块"""
for path_prefix, module in self.PATH_MODULE_MAP.items():
if path_prefix in path:
return module
return OperationModuleEnum.SYSTEM_CONFIG
async def _get_request_params(self, request: Request) -> str:
"""获取请求参数"""
try:
# GET请求
if request.method == "GET":
params = dict(request.query_params)
return json.dumps(params, ensure_ascii=False)
# POST/PUT/DELETE请求
if request.method in ["POST", "PUT", "DELETE", "PATCH"]:
try:
body = await request.body()
if body:
# 尝试解析JSON
try:
body_json = json.loads(body.decode())
# 过滤敏感字段
filtered_body = self._filter_sensitive_data(body_json)
return json.dumps(filtered_body, ensure_ascii=False)
except json.JSONDecodeError:
# 不是JSON返回原始数据
return body.decode()[:500] # 限制长度
except Exception:
pass
return ""
except Exception:
return ""
def _filter_sensitive_data(self, data: dict) -> dict:
"""过滤敏感数据"""
sensitive_fields = ["password", "old_password", "new_password", "token", "secret"]
if not isinstance(data, dict):
return data
filtered = {}
for key, value in data.items():
if key in sensitive_fields:
filtered[key] = "******"
elif isinstance(value, dict):
filtered[key] = self._filter_sensitive_data(value)
elif isinstance(value, list):
filtered[key] = [
self._filter_sensitive_data(item) if isinstance(item, dict) else item
for item in value
]
else:
filtered[key] = value
return filtered

43
app/models/__init__.py Normal file
View File

@@ -0,0 +1,43 @@
"""
数据模型模块初始化
"""
from app.models.user import User, Role, UserRole, Permission, RolePermission
from app.models.device_type import DeviceType, DeviceTypeField
from app.models.organization import Organization
from app.models.brand_supplier import Brand, Supplier
from app.models.asset import Asset, AssetStatusHistory
from app.models.allocation import AssetAllocationOrder, AssetAllocationItem
from app.models.maintenance import MaintenanceRecord
from app.models.transfer import AssetTransferOrder, AssetTransferItem
from app.models.recovery import AssetRecoveryOrder, AssetRecoveryItem
from app.models.system_config import SystemConfig
from app.models.operation_log import OperationLog
from app.models.notification import Notification, NotificationTemplate
from app.models.file_management import UploadedFile
__all__ = [
"User",
"Role",
"UserRole",
"Permission",
"RolePermission",
"DeviceType",
"DeviceTypeField",
"Organization",
"Brand",
"Supplier",
"Asset",
"AssetStatusHistory",
"AssetAllocationOrder",
"AssetAllocationItem",
"MaintenanceRecord",
"AssetTransferOrder",
"AssetTransferItem",
"AssetRecoveryOrder",
"AssetRecoveryItem",
"SystemConfig",
"OperationLog",
"Notification",
"NotificationTemplate",
"UploadedFile",
]

89
app/models/allocation.py Normal file
View File

@@ -0,0 +1,89 @@
"""
资产分配相关数据模型
"""
from datetime import datetime, date
from sqlalchemy import Column, BigInteger, String, Integer, Text, ForeignKey, DateTime, Date, Numeric, Index
from sqlalchemy.orm import relationship
from app.db.base import Base
class AssetAllocationOrder(Base):
"""资产分配单表"""
__tablename__ = "asset_allocation_orders"
id = Column(BigInteger, primary_key=True, index=True)
order_code = Column(String(50), unique=True, nullable=False, index=True, comment="分配单号")
order_type = Column(String(20), nullable=False, index=True, comment="单据类型")
title = Column(String(200), nullable=False, comment="标题")
source_organization_id = Column(BigInteger, ForeignKey("organizations.id"), nullable=True, comment="调出网点ID")
target_organization_id = Column(BigInteger, ForeignKey("organizations.id"), nullable=False, index=True, comment="调入网点ID")
applicant_id = Column(BigInteger, ForeignKey("users.id"), nullable=False, index=True, comment="申请人ID")
approver_id = Column(BigInteger, ForeignKey("users.id"), nullable=True, comment="审批人ID")
approval_status = Column(String(20), default="pending", nullable=False, index=True, comment="审批状态")
approval_time = Column(DateTime, nullable=True, comment="审批时间")
approval_remark = Column(Text, nullable=True, comment="审批备注")
expect_execute_date = Column(Date, nullable=True, comment="预计执行日期")
actual_execute_date = Column(Date, nullable=True, comment="实际执行日期")
executor_id = Column(BigInteger, ForeignKey("users.id"), nullable=True, comment="执行人ID")
execute_status = Column(String(20), default="pending", nullable=False, comment="执行状态")
remark = Column(Text, nullable=True, comment="备注")
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
created_by = Column(BigInteger, ForeignKey("users.id"), nullable=False)
updated_by = Column(BigInteger, ForeignKey("users.id"), nullable=True)
# 关系
source_organization = relationship("Organization", foreign_keys=[source_organization_id])
target_organization = relationship("Organization", foreign_keys=[target_organization_id])
applicant = relationship("User", foreign_keys=[applicant_id])
approver = relationship("User", foreign_keys=[approver_id])
executor = relationship("User", foreign_keys=[executor_id])
items = relationship("AssetAllocationItem", back_populates="order", cascade="all, delete-orphan")
# 索引
__table_args__ = (
Index("idx_allocation_orders_code", "order_code"),
Index("idx_allocation_orders_target_org", "target_organization_id"),
)
def __repr__(self):
return f"<AssetAllocationOrder(id={self.id}, order_code={self.order_code}, order_type={self.order_type})>"
class AssetAllocationItem(Base):
"""资产分配单明细表"""
__tablename__ = "asset_allocation_items"
id = Column(BigInteger, primary_key=True, index=True)
order_id = Column(BigInteger, ForeignKey("asset_allocation_orders.id", ondelete="CASCADE"), nullable=False, comment="分配单ID")
asset_id = Column(BigInteger, ForeignKey("assets.id"), nullable=False, comment="资产ID")
asset_code = Column(String(50), nullable=False, comment="资产编码")
asset_name = Column(String(200), nullable=False, comment="资产名称")
from_organization_id = Column(BigInteger, ForeignKey("organizations.id"), nullable=True, comment="原网点ID")
to_organization_id = Column(BigInteger, ForeignKey("organizations.id"), nullable=True, comment="目标网点ID")
from_status = Column(String(20), nullable=True, comment="原状态")
to_status = Column(String(20), nullable=True, comment="目标状态")
execute_status = Column(String(20), default="pending", nullable=False, index=True, comment="执行状态")
execute_time = Column(DateTime, nullable=True, comment="执行时间")
failure_reason = Column(Text, nullable=True, comment="失败原因")
remark = Column(Text, nullable=True, comment="备注")
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# 关系
order = relationship("AssetAllocationOrder", back_populates="items")
asset = relationship("Asset")
from_organization = relationship("Organization", foreign_keys=[from_organization_id])
to_organization = relationship("Organization", foreign_keys=[to_organization_id])
# 索引
__table_args__ = (
Index("idx_allocation_items_order", "order_id"),
Index("idx_allocation_items_asset", "asset_id"),
Index("idx_allocation_items_status", "execute_status"),
)
def __repr__(self):
return f"<AssetAllocationItem(id={self.id}, order_id={self.order_id}, asset_code={self.asset_code})>"

84
app/models/asset.py Normal file
View File

@@ -0,0 +1,84 @@
"""
资产相关数据模型
"""
from datetime import datetime, date
from sqlalchemy import Column, BigInteger, String, Integer, Text, ForeignKey, DateTime, Date, Numeric, Index
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship
from app.db.base import Base
class Asset(Base):
"""资产表"""
__tablename__ = "assets"
id = Column(BigInteger, primary_key=True, index=True)
asset_code = Column(String(50), unique=True, nullable=False, index=True, comment="资产编码")
asset_name = Column(String(200), nullable=False, comment="资产名称")
device_type_id = Column(BigInteger, ForeignKey("device_types.id"), nullable=False, comment="设备类型ID")
brand_id = Column(BigInteger, ForeignKey("brands.id"), nullable=True, comment="品牌ID")
model = Column(String(200), nullable=True, comment="规格型号")
serial_number = Column(String(200), nullable=True, index=True, comment="序列号(SN)")
supplier_id = Column(BigInteger, ForeignKey("suppliers.id"), nullable=True, comment="供应商ID")
purchase_date = Column(Date, nullable=True, index=True, comment="采购日期")
purchase_price = Column(Numeric(18, 2), nullable=True, comment="采购价格")
warranty_period = Column(Integer, nullable=True, comment="保修期(月)")
warranty_expire_date = Column(Date, nullable=True, comment="保修到期日期")
organization_id = Column(BigInteger, ForeignKey("organizations.id"), nullable=False, comment="所属网点ID")
location = Column(String(500), nullable=True, comment="存放位置")
status = Column(String(20), default="pending", nullable=False, index=True, comment="状态")
dynamic_attributes = Column(JSONB, default={}, comment="动态字段值")
qr_code_url = Column(String(500), nullable=True, comment="二维码图片URL")
remark = Column(Text, nullable=True, comment="备注")
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
created_by = Column(BigInteger, ForeignKey("users.id"), nullable=True)
updated_by = Column(BigInteger, ForeignKey("users.id"), nullable=True)
deleted_at = Column(DateTime, nullable=True)
deleted_by = Column(BigInteger, ForeignKey("users.id"), nullable=True)
# 关系
device_type = relationship("DeviceType", back_populates="assets")
brand = relationship("Brand", back_populates="assets")
supplier = relationship("Supplier", back_populates="assets")
organization = relationship("Organization")
created_user = relationship("User", foreign_keys=[created_by])
updated_user = relationship("User", foreign_keys=[updated_by])
deleted_user = relationship("User", foreign_keys=[deleted_by])
status_history = relationship("AssetStatusHistory", back_populates="asset", cascade="all, delete-orphan")
def __repr__(self):
return f"<Asset(id={self.id}, asset_code={self.asset_code}, asset_name={self.asset_name})>"
class AssetStatusHistory(Base):
"""资产状态历史表"""
__tablename__ = "asset_status_history"
id = Column(BigInteger, primary_key=True, index=True)
asset_id = Column(BigInteger, ForeignKey("assets.id", ondelete="CASCADE"), nullable=False, comment="资产ID")
old_status = Column(String(20), nullable=True, comment="原状态")
new_status = Column(String(20), nullable=False, index=True, comment="新状态")
operation_type = Column(String(50), nullable=False, comment="操作类型")
operator_id = Column(BigInteger, ForeignKey("users.id"), nullable=False, comment="操作人ID")
operator_name = Column(String(100), nullable=True, comment="操作人姓名(冗余)")
organization_id = Column(BigInteger, ForeignKey("organizations.id"), nullable=True, comment="相关网点ID")
remark = Column(Text, nullable=True, comment="备注")
extra_data = Column(JSONB, nullable=True, comment="额外数据")
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
# 关系
asset = relationship("Asset", back_populates="status_history")
operator = relationship("User", foreign_keys=[operator_id])
organization = relationship("Organization")
# 索引
__table_args__ = (
Index("idx_asset_status_history_asset", "asset_id"),
Index("idx_asset_status_history_time", "created_at"),
)
def __repr__(self):
return f"<AssetStatusHistory(id={self.id}, asset_id={self.asset_id}, old_status={self.old_status}, new_status={self.new_status})>"

View File

@@ -0,0 +1,70 @@
"""
品牌和供应商数据模型
"""
from datetime import datetime
from sqlalchemy import Column, BigInteger, String, Integer, Text, ForeignKey, DateTime
from sqlalchemy.orm import relationship
from app.db.base import Base
class Brand(Base):
"""品牌表"""
__tablename__ = "brands"
id = Column(BigInteger, primary_key=True, index=True)
brand_code = Column(String(50), unique=True, nullable=False, index=True, comment="品牌代码")
brand_name = Column(String(200), nullable=False, comment="品牌名称")
logo_url = Column(String(500), nullable=True, comment="Logo URL")
website = Column(String(500), nullable=True, comment="官网地址")
status = Column(String(20), default="active", nullable=False, comment="状态: active, inactive")
sort_order = Column(Integer, default=0, nullable=False, comment="排序")
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
created_by = Column(BigInteger, ForeignKey("users.id"), nullable=True)
updated_by = Column(BigInteger, ForeignKey("users.id"), nullable=True)
deleted_at = Column(DateTime, nullable=True)
deleted_by = Column(BigInteger, ForeignKey("users.id"), nullable=True)
# 关系
created_user = relationship("User", foreign_keys=[created_by])
updated_user = relationship("User", foreign_keys=[updated_by])
deleted_user = relationship("User", foreign_keys=[deleted_by])
assets = relationship("Asset", back_populates="brand")
def __repr__(self):
return f"<Brand(id={self.id}, brand_code={self.brand_code}, brand_name={self.brand_name})>"
class Supplier(Base):
"""供应商表"""
__tablename__ = "suppliers"
id = Column(BigInteger, primary_key=True, index=True)
supplier_code = Column(String(50), unique=True, nullable=False, index=True, comment="供应商代码")
supplier_name = Column(String(200), nullable=False, comment="供应商名称")
contact_person = Column(String(100), nullable=True, comment="联系人")
contact_phone = Column(String(20), nullable=True, comment="联系电话")
email = Column(String(255), nullable=True, comment="邮箱")
address = Column(String(500), nullable=True, comment="地址")
credit_code = Column(String(50), nullable=True, comment="统一社会信用代码")
bank_name = Column(String(200), nullable=True, comment="开户银行")
bank_account = Column(String(100), nullable=True, comment="银行账号")
status = Column(String(20), default="active", nullable=False, comment="状态: active, inactive")
remark = Column(Text, nullable=True, comment="备注")
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
created_by = Column(BigInteger, ForeignKey("users.id"), nullable=True)
updated_by = Column(BigInteger, ForeignKey("users.id"), nullable=True)
deleted_at = Column(DateTime, nullable=True)
deleted_by = Column(BigInteger, ForeignKey("users.id"), nullable=True)
# 关系
created_user = relationship("User", foreign_keys=[created_by])
updated_user = relationship("User", foreign_keys=[updated_by])
deleted_user = relationship("User", foreign_keys=[deleted_by])
assets = relationship("Asset", back_populates="supplier")
def __repr__(self):
return f"<Supplier(id={self.id}, supplier_code={self.supplier_code}, supplier_name={self.supplier_name})>"

80
app/models/device_type.py Normal file
View File

@@ -0,0 +1,80 @@
"""
设备类型相关数据模型
"""
from datetime import datetime
from sqlalchemy import Column, BigInteger, String, Integer, Text, ForeignKey, DateTime, Index
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship
from app.db.base import Base
class DeviceType(Base):
"""设备类型表"""
__tablename__ = "device_types"
id = Column(BigInteger, primary_key=True, index=True)
type_code = Column(String(50), unique=True, nullable=False, index=True, comment="设备类型代码")
type_name = Column(String(200), nullable=False, comment="设备类型名称")
category = Column(String(50), nullable=True, comment="设备分类: IT设备, 办公设备, 生产设备等")
description = Column(Text, nullable=True, comment="描述")
icon = Column(String(100), nullable=True, comment="图标名称")
status = Column(String(20), default="active", nullable=False, comment="状态: active, inactive")
sort_order = Column(Integer, default=0, nullable=False, comment="排序")
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
created_by = Column(BigInteger, ForeignKey("users.id"), nullable=True)
updated_by = Column(BigInteger, ForeignKey("users.id"), nullable=True)
deleted_at = Column(DateTime, nullable=True)
deleted_by = Column(BigInteger, ForeignKey("users.id"), nullable=True)
# 关系
created_user = relationship("User", foreign_keys=[created_by])
updated_user = relationship("User", foreign_keys=[updated_by])
deleted_user = relationship("User", foreign_keys=[deleted_by])
fields = relationship("DeviceTypeField", back_populates="device_type", cascade="all, delete-orphan")
assets = relationship("Asset", back_populates="device_type")
def __repr__(self):
return f"<DeviceType(id={self.id}, type_code={self.type_code}, type_name={self.type_name})>"
class DeviceTypeField(Base):
"""设备类型字段定义表(动态字段)"""
__tablename__ = "device_type_fields"
id = Column(BigInteger, primary_key=True, index=True)
device_type_id = Column(BigInteger, ForeignKey("device_types.id", ondelete="CASCADE"), nullable=False)
field_code = Column(String(50), nullable=False, comment="字段代码")
field_name = Column(String(100), nullable=False, comment="字段名称")
field_type = Column(String(20), nullable=False, comment="字段类型: text, number, date, select, multiselect, boolean, textarea")
is_required = Column(BigInteger, default=False, nullable=False, comment="是否必填")
default_value = Column(Text, nullable=True, comment="默认值")
options = Column(JSONB, nullable=True, comment="select类型的选项: [{'label': '选项1', 'value': '1'}]")
validation_rules = Column(JSONB, nullable=True, comment="验证规则: {'min': 0, 'max': 100, 'pattern': '^A-Z'}")
placeholder = Column(String(200), nullable=True, comment="占位符")
help_text = Column(Text, nullable=True, comment="帮助文本")
sort_order = Column(Integer, default=0, nullable=False, comment="排序")
status = Column(String(20), default="active", nullable=False, comment="状态: active, inactive")
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
created_by = Column(BigInteger, ForeignKey("users.id"), nullable=True)
updated_by = Column(BigInteger, ForeignKey("users.id"), nullable=True)
deleted_at = Column(DateTime, nullable=True)
deleted_by = Column(BigInteger, ForeignKey("users.id"), nullable=True)
# 关系
device_type = relationship("DeviceType", back_populates="fields")
created_user = relationship("User", foreign_keys=[created_by])
updated_user = relationship("User", foreign_keys=[updated_by])
deleted_user = relationship("User", foreign_keys=[deleted_by])
# 索引
__table_args__ = (
Index("idx_device_type_fields_type", "device_type_id"),
Index("idx_device_type_fields_code", "field_code"),
)
def __repr__(self):
return f"<DeviceTypeField(id={self.id}, field_code={self.field_code}, field_name={self.field_name})>"

View File

@@ -0,0 +1,46 @@
"""
文件管理数据模型
"""
from datetime import datetime
from sqlalchemy import Column, BigInteger, String, DateTime, Text, Index, Boolean, ForeignKey
from sqlalchemy.orm import relationship
from app.db.base import Base
class UploadedFile(Base):
"""上传文件表"""
__tablename__ = "uploaded_files"
id = Column(BigInteger, primary_key=True, index=True)
file_name = Column(String(255), nullable=False, comment="存储文件名(UUID)")
original_name = Column(String(255), nullable=False, index=True, comment="原始文件名")
file_path = Column(String(500), nullable=False, comment="文件存储路径")
file_size = Column(BigInteger, nullable=False, comment="文件大小(字节)")
file_type = Column(String(100), nullable=False, index=True, comment="文件类型(MIME)")
file_ext = Column(String(50), nullable=False, comment="文件扩展名")
uploader_id = Column(BigInteger, ForeignKey("users.id"), nullable=True, comment="上传人ID")
upload_time = Column(DateTime, default=datetime.utcnow, nullable=False, index=True, comment="上传时间")
thumbnail_path = Column(String(500), nullable=True, comment="缩略图路径")
share_code = Column(String(100), nullable=True, unique=True, index=True, comment="分享码")
share_expire_time = Column(DateTime, nullable=True, index=True, comment="分享过期时间")
download_count = Column(BigInteger, default=0, comment="下载次数")
is_deleted = Column(Boolean, default=False, nullable=False, comment="是否删除")
deleted_at = Column(DateTime, nullable=True, comment="删除时间")
deleted_by = Column(BigInteger, ForeignKey("users.id"), nullable=True, comment="删除人ID")
remark = Column(Text, nullable=True, comment="备注")
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# 关系
uploader = relationship("User", foreign_keys=[uploader_id])
deleter = relationship("User", foreign_keys=[deleted_by])
# 索引
__table_args__ = (
Index("idx_uploaded_files_uploader", "uploader_id"),
Index("idx_uploaded_files_deleted", "is_deleted"),
)
def __repr__(self):
return f"<UploadedFile(id={self.id}, original_name={self.original_name}, file_type={self.file_type})>"

57
app/models/maintenance.py Normal file
View File

@@ -0,0 +1,57 @@
"""
维修管理相关数据模型
"""
from datetime import datetime, date
from sqlalchemy import Column, BigInteger, String, Integer, Text, ForeignKey, DateTime, Date, Numeric, Index
from sqlalchemy.orm import relationship
from app.db.base import Base
class MaintenanceRecord(Base):
"""维修记录表"""
__tablename__ = "maintenance_records"
id = Column(BigInteger, primary_key=True, index=True)
record_code = Column(String(50), unique=True, nullable=False, index=True, comment="维修单号")
asset_id = Column(BigInteger, ForeignKey("assets.id"), nullable=False, index=True, comment="资产ID")
asset_code = Column(String(50), nullable=False, comment="资产编码")
fault_description = Column(Text, nullable=False, comment="故障描述")
fault_type = Column(String(50), nullable=True, comment="故障类型")
report_user_id = Column(BigInteger, ForeignKey("users.id"), nullable=True, comment="报修人ID")
report_time = Column(DateTime, default=datetime.utcnow, nullable=False, index=True, comment="报修时间")
priority = Column(String(20), default="normal", nullable=False, comment="优先级")
maintenance_type = Column(String(20), nullable=True, comment="维修类型")
vendor_id = Column(BigInteger, ForeignKey("suppliers.id"), nullable=True, comment="维修供应商ID")
maintenance_cost = Column(Numeric(18, 2), nullable=True, comment="维修费用")
start_time = Column(DateTime, nullable=True, comment="开始维修时间")
complete_time = Column(DateTime, nullable=True, comment="完成维修时间")
maintenance_user_id = Column(BigInteger, ForeignKey("users.id"), nullable=True, comment="维修人员ID")
maintenance_result = Column(Text, nullable=True, comment="维修结果描述")
replaced_parts = Column(Text, nullable=True, comment="更换的配件")
status = Column(String(20), default="pending", nullable=False, index=True, comment="状态")
images = Column(Text, nullable=True, comment="维修图片URL多个逗号分隔")
remark = Column(Text, nullable=True, comment="备注")
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
created_by = Column(BigInteger, ForeignKey("users.id"), nullable=True)
updated_by = Column(BigInteger, ForeignKey("users.id"), nullable=True)
# 关系
asset = relationship("Asset")
vendor = relationship("Supplier")
report_user = relationship("User", foreign_keys=[report_user_id])
maintenance_user = relationship("User", foreign_keys=[maintenance_user_id])
created_user = relationship("User", foreign_keys=[created_by])
updated_user = relationship("User", foreign_keys=[updated_by])
# 索引
__table_args__ = (
Index("idx_maintenance_records_code", "record_code"),
Index("idx_maintenance_records_asset", "asset_id"),
Index("idx_maintenance_records_status", "status"),
Index("idx_maintenance_records_time", "report_time"),
)
def __repr__(self):
return f"<MaintenanceRecord(id={self.id}, record_code={self.record_code}, asset_code={self.asset_code})>"

View File

@@ -0,0 +1,71 @@
"""
消息通知数据模型
"""
from datetime import datetime
from sqlalchemy import Column, BigInteger, String, Text, Integer, DateTime, Boolean, Index, ForeignKey
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship
from app.db.base import Base
class Notification(Base):
"""消息通知表"""
__tablename__ = "notifications"
id = Column(BigInteger, primary_key=True, index=True)
recipient_id = Column(BigInteger, ForeignKey("users.id"), nullable=False, index=True, comment="接收人ID")
recipient_name = Column(String(100), nullable=False, comment="接收人姓名(冗余)")
title = Column(String(200), nullable=False, comment="通知标题")
content = Column(Text, nullable=False, comment="通知内容")
notification_type = Column(String(20), nullable=False, index=True, comment="通知类型: system/approval/maintenance/allocation等")
priority = Column(String(20), default="normal", nullable=False, comment="优先级: low/normal/high/urgent")
is_read = Column(Boolean, default=False, nullable=False, index=True, comment="是否已读")
read_at = Column(DateTime, nullable=True, comment="已读时间")
related_entity_type = Column(String(50), nullable=True, comment="关联实体类型")
related_entity_id = Column(BigInteger, nullable=True, comment="关联实体ID")
action_url = Column(String(500), nullable=True, comment="操作链接")
extra_data = Column(JSONB, nullable=True, comment="额外数据")
sent_via_email = Column(Boolean, default=False, nullable=False, comment="是否已发送邮件")
sent_via_sms = Column(Boolean, default=False, nullable=False, comment="是否已发送短信")
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True, comment="创建时间")
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新时间")
expire_at = Column(DateTime, nullable=True, comment="过期时间")
# 关系
recipient = relationship("User", foreign_keys=[recipient_id])
# 索引
__table_args__ = (
Index("idx_notification_recipient", "recipient_id"),
Index("idx_notification_read", "is_read"),
Index("idx_notification_type", "notification_type"),
Index("idx_notification_time", "created_at"),
)
def __repr__(self):
return f"<Notification(id={self.id}, recipient={self.recipient_name}, title={self.title})>"
class NotificationTemplate(Base):
"""消息通知模板表"""
__tablename__ = "notification_templates"
id = Column(BigInteger, primary_key=True, index=True)
template_code = Column(String(50), unique=True, nullable=False, comment="模板编码")
template_name = Column(String(200), nullable=False, comment="模板名称")
notification_type = Column(String(20), nullable=False, comment="通知类型")
title_template = Column(String(200), nullable=False, comment="标题模板")
content_template = Column(Text, nullable=False, comment="内容模板")
variables = Column(JSONB, nullable=True, comment="变量说明")
priority = Column(String(20), default="normal", nullable=False, comment="默认优先级")
send_email = Column(Boolean, default=False, nullable=False, comment="是否发送邮件")
send_sms = Column(Boolean, default=False, nullable=False, comment="是否发送短信")
is_active = Column(Boolean, default=True, nullable=False, comment="是否启用")
description = Column(Text, nullable=True, comment="模板描述")
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
def __repr__(self):
return f"<NotificationTemplate(id={self.id}, code={self.template_code}, name={self.template_name})>"

View File

@@ -0,0 +1,40 @@
"""
操作日志数据模型
"""
from datetime import datetime
from sqlalchemy import Column, BigInteger, String, Text, Integer, DateTime, Index
from sqlalchemy.dialects.postgresql import JSONB
from app.db.base import Base
class OperationLog(Base):
"""操作日志表"""
__tablename__ = "operation_logs"
id = Column(BigInteger, primary_key=True, index=True)
operator_id = Column(BigInteger, nullable=False, index=True, comment="操作人ID")
operator_name = Column(String(100), nullable=False, comment="操作人姓名")
operator_ip = Column(String(50), nullable=True, comment="操作人IP")
module = Column(String(50), nullable=False, index=True, comment="模块名称")
operation_type = Column(String(50), nullable=False, index=True, comment="操作类型")
method = Column(String(10), nullable=False, comment="请求方法(GET/POST/PUT/DELETE等)")
url = Column(String(500), nullable=False, comment="请求URL")
params = Column(Text, nullable=True, comment="请求参数")
result = Column(String(20), default="success", nullable=False, comment="操作结果: success/failed")
error_msg = Column(Text, nullable=True, comment="错误信息")
duration = Column(Integer, nullable=True, comment="执行时长(毫秒)")
user_agent = Column(String(500), nullable=True, comment="用户代理")
extra_data = Column(JSONB, nullable=True, comment="额外数据")
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
# 索引
__table_args__ = (
Index("idx_operation_log_operator", "operator_id"),
Index("idx_operation_log_module", "module"),
Index("idx_operation_log_time", "created_at"),
Index("idx_operation_log_result", "result"),
)
def __repr__(self):
return f"<OperationLog(id={self.id}, operator={self.operator_name}, module={self.module}, operation={self.operation_type})>"

View File

@@ -0,0 +1,42 @@
"""
机构网点相关数据模型
"""
from datetime import datetime
from sqlalchemy import Column, BigInteger, String, Integer, Text, ForeignKey, DateTime, Index
from sqlalchemy.orm import relationship
from app.db.base import Base
class Organization(Base):
"""机构/网点表"""
__tablename__ = "organizations"
id = Column(BigInteger, primary_key=True, index=True)
org_code = Column(String(50), unique=True, nullable=False, index=True, comment="机构代码")
org_name = Column(String(200), nullable=False, comment="机构名称")
org_type = Column(String(20), nullable=False, comment="机构类型: province, city, outlet")
parent_id = Column(BigInteger, ForeignKey("organizations.id"), nullable=True, comment="父机构ID")
tree_path = Column(String(1000), nullable=True, comment="树形路径: /1/2/3/")
tree_level = Column(Integer, default=0, nullable=False, comment="层级")
address = Column(String(500), nullable=True, comment="地址")
contact_person = Column(String(100), nullable=True, comment="联系人")
contact_phone = Column(String(20), nullable=True, comment="联系电话")
status = Column(String(20), default="active", nullable=False, comment="状态: active, inactive")
sort_order = Column(Integer, default=0, nullable=False, comment="排序")
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
created_by = Column(BigInteger, ForeignKey("users.id"), nullable=True)
updated_by = Column(BigInteger, ForeignKey("users.id"), nullable=True)
deleted_at = Column(DateTime, nullable=True)
deleted_by = Column(BigInteger, ForeignKey("users.id"), nullable=True)
# 关系
parent = relationship("Organization", remote_side=[id], foreign_keys=[parent_id])
children = relationship("Organization", foreign_keys=[parent_id], backref="children_ref")
created_user = relationship("User", foreign_keys=[created_by])
updated_user = relationship("User", foreign_keys=[updated_by])
deleted_user = relationship("User", foreign_keys=[deleted_by])
def __repr__(self):
return f"<Organization(id={self.id}, org_code={self.org_code}, org_name={self.org_name})>"

73
app/models/recovery.py Normal file
View File

@@ -0,0 +1,73 @@
"""
资产回收相关数据模型
"""
from datetime import datetime, date
from sqlalchemy import Column, BigInteger, String, Integer, Text, ForeignKey, DateTime, Date, Index
from sqlalchemy.orm import relationship
from app.db.base import Base
class AssetRecoveryOrder(Base):
"""资产回收单表"""
__tablename__ = "asset_recovery_orders"
id = Column(BigInteger, primary_key=True, index=True)
order_code = Column(String(50), unique=True, nullable=False, index=True, comment="回收单号")
recovery_type = Column(String(20), nullable=False, index=True, comment="回收类型(user=使用人回收/org=机构回收/scrap=报废回收)")
title = Column(String(200), nullable=False, comment="标题")
asset_count = Column(Integer, default=0, nullable=False, comment="资产数量")
apply_user_id = Column(BigInteger, ForeignKey("users.id"), nullable=False, index=True, comment="申请人ID")
apply_time = Column(DateTime, nullable=False, comment="申请时间")
approval_status = Column(String(20), default="pending", nullable=False, index=True, comment="审批状态")
approval_user_id = Column(BigInteger, ForeignKey("users.id"), nullable=True, comment="审批人ID")
approval_time = Column(DateTime, nullable=True, comment="审批时间")
approval_remark = Column(Text, nullable=True, comment="审批备注")
execute_status = Column(String(20), default="pending", nullable=False, index=True, comment="执行状态")
execute_user_id = Column(BigInteger, ForeignKey("users.id"), nullable=True, comment="执行人ID")
execute_time = Column(DateTime, nullable=True, comment="执行时间")
remark = Column(Text, nullable=True, comment="备注")
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# 关系
apply_user = relationship("User", foreign_keys=[apply_user_id])
approval_user = relationship("User", foreign_keys=[approval_user_id])
execute_user = relationship("User", foreign_keys=[execute_user_id])
items = relationship("AssetRecoveryItem", back_populates="order", cascade="all, delete-orphan")
# 索引
__table_args__ = (
Index("idx_recovery_orders_code", "order_code"),
Index("idx_recovery_orders_type", "recovery_type"),
)
def __repr__(self):
return f"<AssetRecoveryOrder(id={self.id}, order_code={self.order_code}, recovery_type={self.recovery_type})>"
class AssetRecoveryItem(Base):
"""资产回收单明细表"""
__tablename__ = "asset_recovery_items"
id = Column(BigInteger, primary_key=True, index=True)
order_id = Column(BigInteger, ForeignKey("asset_recovery_orders.id", ondelete="CASCADE"), nullable=False, comment="回收单ID")
asset_id = Column(BigInteger, ForeignKey("assets.id"), nullable=False, comment="资产ID")
asset_code = Column(String(50), nullable=False, comment="资产编码")
recovery_status = Column(String(20), default="pending", nullable=False, index=True, comment="回收状态")
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
# 关系
order = relationship("AssetRecoveryOrder", back_populates="items")
asset = relationship("Asset")
# 索引
__table_args__ = (
Index("idx_recovery_items_order", "order_id"),
Index("idx_recovery_items_asset", "asset_id"),
Index("idx_recovery_items_status", "recovery_status"),
)
def __repr__(self):
return f"<AssetRecoveryItem(id={self.id}, order_id={self.order_id}, asset_code={self.asset_code})>"

View File

@@ -0,0 +1,40 @@
"""
系统配置数据模型
"""
from datetime import datetime
from sqlalchemy import Column, BigInteger, String, Text, Integer, DateTime, Boolean, Index
from sqlalchemy.dialects.postgresql import JSONB
from app.db.base import Base
class SystemConfig(Base):
"""系统配置表"""
__tablename__ = "system_configs"
id = Column(BigInteger, primary_key=True, index=True)
config_key = Column(String(100), unique=True, nullable=False, index=True, comment="配置键")
config_name = Column(String(200), nullable=False, comment="配置名称")
config_value = Column(Text, nullable=True, comment="配置值")
value_type = Column(String(20), default="string", nullable=False, comment="值类型: string/number/boolean/json")
category = Column(String(50), nullable=False, index=True, comment="配置分类")
description = Column(Text, nullable=True, comment="配置描述")
is_system = Column(Boolean, default=False, nullable=False, comment="是否系统配置")
is_encrypted = Column(Boolean, default=False, nullable=False, comment="是否加密存储")
validation_rule = Column(Text, nullable=True, comment="验证规则(JSON)")
options = Column(JSONB, nullable=True, comment="可选值配置")
default_value = Column(Text, nullable=True, comment="默认值")
sort_order = Column(Integer, default=0, nullable=False, comment="排序序号")
is_active = Column(Boolean, default=True, nullable=False, comment="是否启用")
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
updated_by = Column(BigInteger, nullable=True, comment="更新人ID")
# 索引
__table_args__ = (
Index("idx_system_config_category", "category"),
Index("idx_system_config_active", "is_active"),
)
def __repr__(self):
return f"<SystemConfig(id={self.id}, config_key={self.config_key}, config_name={self.config_name})>"

82
app/models/transfer.py Normal file
View File

@@ -0,0 +1,82 @@
"""
资产调拨相关数据模型
"""
from datetime import datetime, date
from sqlalchemy import Column, BigInteger, String, Integer, Text, ForeignKey, DateTime, Date, Index
from sqlalchemy.orm import relationship
from app.db.base import Base
class AssetTransferOrder(Base):
"""资产调拨单表"""
__tablename__ = "asset_transfer_orders"
id = Column(BigInteger, primary_key=True, index=True)
order_code = Column(String(50), unique=True, nullable=False, index=True, comment="调拨单号")
source_org_id = Column(BigInteger, ForeignKey("organizations.id"), nullable=False, comment="调出网点ID")
target_org_id = Column(BigInteger, ForeignKey("organizations.id"), nullable=False, index=True, comment="调入网点ID")
transfer_type = Column(String(20), nullable=False, index=True, comment="调拨类型(internal/external)")
title = Column(String(200), nullable=False, comment="标题")
asset_count = Column(Integer, default=0, nullable=False, comment="资产数量")
apply_user_id = Column(BigInteger, ForeignKey("users.id"), nullable=False, index=True, comment="申请人ID")
apply_time = Column(DateTime, nullable=False, comment="申请时间")
approval_status = Column(String(20), default="pending", nullable=False, index=True, comment="审批状态")
approval_user_id = Column(BigInteger, ForeignKey("users.id"), nullable=True, comment="审批人ID")
approval_time = Column(DateTime, nullable=True, comment="审批时间")
approval_remark = Column(Text, nullable=True, comment="审批备注")
execute_status = Column(String(20), default="pending", nullable=False, index=True, comment="执行状态")
execute_user_id = Column(BigInteger, ForeignKey("users.id"), nullable=True, comment="执行人ID")
execute_time = Column(DateTime, nullable=True, comment="执行时间")
remark = Column(Text, nullable=True, comment="备注")
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# 关系
source_organization = relationship("Organization", foreign_keys=[source_org_id])
target_organization = relationship("Organization", foreign_keys=[target_org_id])
apply_user = relationship("User", foreign_keys=[apply_user_id])
approval_user = relationship("User", foreign_keys=[approval_user_id])
execute_user = relationship("User", foreign_keys=[execute_user_id])
items = relationship("AssetTransferItem", back_populates="order", cascade="all, delete-orphan")
# 索引
__table_args__ = (
Index("idx_transfer_orders_code", "order_code"),
Index("idx_transfer_orders_source_org", "source_org_id"),
Index("idx_transfer_orders_target_org", "target_org_id"),
)
def __repr__(self):
return f"<AssetTransferOrder(id={self.id}, order_code={self.order_code}, transfer_type={self.transfer_type})>"
class AssetTransferItem(Base):
"""资产调拨单明细表"""
__tablename__ = "asset_transfer_items"
id = Column(BigInteger, primary_key=True, index=True)
order_id = Column(BigInteger, ForeignKey("asset_transfer_orders.id", ondelete="CASCADE"), nullable=False, comment="调拨单ID")
asset_id = Column(BigInteger, ForeignKey("assets.id"), nullable=False, comment="资产ID")
asset_code = Column(String(50), nullable=False, comment="资产编码")
source_organization_id = Column(BigInteger, ForeignKey("organizations.id"), nullable=False, comment="调出网点ID")
target_organization_id = Column(BigInteger, ForeignKey("organizations.id"), nullable=False, comment="调入网点ID")
transfer_status = Column(String(20), default="pending", nullable=False, index=True, comment="调拨状态")
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
# 关系
order = relationship("AssetTransferOrder", back_populates="items")
asset = relationship("Asset")
source_organization = relationship("Organization", foreign_keys=[source_organization_id])
target_organization = relationship("Organization", foreign_keys=[target_organization_id])
# 索引
__table_args__ = (
Index("idx_transfer_items_order", "order_id"),
Index("idx_transfer_items_asset", "asset_id"),
Index("idx_transfer_items_status", "transfer_status"),
)
def __repr__(self):
return f"<AssetTransferItem(id={self.id}, order_id={self.order_id}, asset_code={self.asset_code})>"

131
app/models/user.py Normal file
View File

@@ -0,0 +1,131 @@
"""
用户相关数据模型
"""
from datetime import datetime
from sqlalchemy import Column, BigInteger, String, Boolean, DateTime, Integer, ForeignKey, Text, Index
from sqlalchemy.orm import relationship
from app.db.base import Base
class User(Base):
"""用户表"""
__tablename__ = "users"
id = Column(BigInteger, primary_key=True, index=True)
username = Column(String(50), unique=True, nullable=False, index=True)
password_hash = Column(String(255), nullable=False, comment="bcrypt哈希")
real_name = Column(String(100), nullable=False)
email = Column(String(255), unique=True, nullable=True)
phone = Column(String(20), nullable=True)
avatar_url = Column(String(500), nullable=True)
status = Column(String(20), default="active", nullable=False, comment="active, disabled, locked")
is_admin = Column(Boolean, default=False, nullable=False)
last_login_at = Column(DateTime, nullable=True)
login_fail_count = Column(Integer, default=0, nullable=False)
locked_until = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
created_by = Column(BigInteger, ForeignKey("users.id"), nullable=True)
updated_by = Column(BigInteger, ForeignKey("users.id"), nullable=True)
deleted_at = Column(DateTime, nullable=True)
deleted_by = Column(BigInteger, ForeignKey("users.id"), nullable=True)
# 关系
created_by_user = relationship("User", remote_side=[id], foreign_keys=[created_by])
updated_by_user = relationship("User", remote_side=[id], foreign_keys=[updated_by])
deleted_by_user = relationship("User", remote_side=[id], foreign_keys=[deleted_by])
def __repr__(self):
return f"<User(id={self.id}, username={self.username}, real_name={self.real_name})>"
class Role(Base):
"""角色表"""
__tablename__ = "roles"
id = Column(BigInteger, primary_key=True, index=True)
role_name = Column(String(50), unique=True, nullable=False)
role_code = Column(String(50), unique=True, nullable=False)
description = Column(Text, nullable=True)
status = Column(String(20), default="active", nullable=False, comment="active, disabled")
sort_order = Column(Integer, default=0, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
created_by = Column(BigInteger, ForeignKey("users.id"), nullable=True)
updated_by = Column(BigInteger, ForeignKey("users.id"), nullable=True)
deleted_at = Column(DateTime, nullable=True)
deleted_by = Column(BigInteger, ForeignKey("users.id"), nullable=True)
# 关系
created_user = relationship("User", foreign_keys=[created_by])
updated_user = relationship("User", foreign_keys=[updated_by])
deleted_user = relationship("User", foreign_keys=[deleted_by])
def __repr__(self):
return f"<Role(id={self.id}, role_code={self.role_code}, role_name={self.role_name})>"
class UserRole(Base):
"""用户角色关联表"""
__tablename__ = "user_roles"
id = Column(BigInteger, primary_key=True, index=True)
user_id = Column(BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
role_id = Column(BigInteger, ForeignKey("roles.id", ondelete="CASCADE"), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
created_by = Column(BigInteger, ForeignKey("users.id"), nullable=True)
# 关系
user = relationship("User", foreign_keys=[user_id])
role = relationship("Role", foreign_keys=[role_id])
created_user = relationship("User", foreign_keys=[created_by])
# 索引
__table_args__ = (
Index("idx_user_roles_user", "user_id"),
Index("idx_user_roles_role", "role_id"),
)
class Permission(Base):
"""权限表"""
__tablename__ = "permissions"
id = Column(BigInteger, primary_key=True, index=True)
permission_name = Column(String(100), unique=True, nullable=False)
permission_code = Column(String(100), unique=True, nullable=False)
module = Column(String(50), nullable=False, comment="模块: asset, device_type, org, user, system")
resource = Column(String(50), nullable=True, comment="资源: asset, device_type, organization")
action = Column(String(50), nullable=True, comment="操作: create, read, update, delete, export, import")
description = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
def __repr__(self):
return f"<Permission(id={self.id}, permission_code={self.permission_code}, permission_name={self.permission_name})>"
class RolePermission(Base):
"""角色权限关联表"""
__tablename__ = "role_permissions"
id = Column(BigInteger, primary_key=True, index=True)
role_id = Column(BigInteger, ForeignKey("roles.id", ondelete="CASCADE"), nullable=False)
permission_id = Column(BigInteger, ForeignKey("permissions.id", ondelete="CASCADE"), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
created_by = Column(BigInteger, ForeignKey("users.id"), nullable=True)
# 关系
role = relationship("Role", foreign_keys=[role_id])
permission = relationship("Permission", foreign_keys=[permission_id])
created_user = relationship("User", foreign_keys=[created_by])
# 索引
__table_args__ = (
Index("idx_role_permissions_role", "role_id"),
Index("idx_role_permissions_permission", "permission_id"),
)

152
app/schemas/allocation.py Normal file
View File

@@ -0,0 +1,152 @@
"""
资产分配相关的Pydantic Schema
"""
from typing import Optional, List, Dict, Any
from datetime import datetime, date
from decimal import Decimal
from pydantic import BaseModel, Field
# ===== 分配单Schema =====
class AllocationOrderBase(BaseModel):
"""分配单基础Schema"""
order_type: str = Field(..., description="单据类型(allocation/transfer/recovery/maintenance/scrap)")
title: str = Field(..., min_length=1, max_length=200, description="标题")
source_organization_id: Optional[int] = Field(None, gt=0, description="调出网点ID")
target_organization_id: int = Field(..., gt=0, description="调入网点ID")
expect_execute_date: Optional[date] = Field(None, description="预计执行日期")
remark: Optional[str] = Field(None, description="备注")
class AllocationOrderCreate(AllocationOrderBase):
"""创建分配单Schema"""
asset_ids: List[int] = Field(..., min_items=1, description="资产ID列表")
class AllocationOrderUpdate(BaseModel):
"""更新分配单Schema"""
title: Optional[str] = Field(None, min_length=1, max_length=200)
expect_execute_date: Optional[date] = None
remark: Optional[str] = None
class AllocationOrderApproval(BaseModel):
"""分配单审批Schema"""
approval_status: str = Field(..., description="审批状态(approved/rejected)")
approval_remark: Optional[str] = Field(None, description="审批备注")
class AllocationOrderExecute(BaseModel):
"""分配单执行Schema"""
remark: Optional[str] = Field(None, description="执行备注")
class AllocationOrderInDB(BaseModel):
"""数据库中的分配单Schema"""
id: int
order_code: str
order_type: str
title: str
source_organization_id: Optional[int]
target_organization_id: int
applicant_id: int
approver_id: Optional[int]
approval_status: str
approval_time: Optional[datetime]
approval_remark: Optional[str]
expect_execute_date: Optional[date]
actual_execute_date: Optional[date]
executor_id: Optional[int]
execute_status: str
remark: Optional[str]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class AllocationOrderResponse(AllocationOrderInDB):
"""分配单响应Schema"""
pass
class AllocationOrderWithRelations(AllocationOrderResponse):
"""带关联信息的分配单响应Schema"""
source_organization: Optional[Dict[str, Any]] = None
target_organization: Optional[Dict[str, Any]] = None
applicant: Optional[Dict[str, Any]] = None
approver: Optional[Dict[str, Any]] = None
executor: Optional[Dict[str, Any]] = None
items: Optional[List[Dict[str, Any]]] = None
class AllocationOrderListResponse(BaseModel):
"""分配单列表响应Schema"""
total: int
page: int
page_size: int
total_pages: int
items: List[AllocationOrderWithRelations]
# ===== 分配单明细Schema =====
class AllocationItemBase(BaseModel):
"""分配单明细基础Schema"""
asset_id: int = Field(..., gt=0, description="资产ID")
remark: Optional[str] = Field(None, description="备注")
class AllocationItemInDB(BaseModel):
"""数据库中的分配单明细Schema"""
id: int
order_id: int
asset_id: int
asset_code: str
asset_name: str
from_organization_id: Optional[int]
to_organization_id: Optional[int]
from_status: Optional[str]
to_status: Optional[str]
execute_status: str
execute_time: Optional[datetime]
failure_reason: Optional[str]
remark: Optional[str]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class AllocationItemResponse(AllocationItemInDB):
"""分配单明细响应Schema"""
pass
# ===== 查询参数Schema =====
class AllocationOrderQueryParams(BaseModel):
"""分配单查询参数"""
order_type: Optional[str] = Field(None, description="单据类型")
approval_status: Optional[str] = Field(None, description="审批状态")
execute_status: Optional[str] = Field(None, description="执行状态")
applicant_id: Optional[int] = Field(None, gt=0, description="申请人ID")
target_organization_id: Optional[int] = Field(None, gt=0, description="目标网点ID")
keyword: Optional[str] = Field(None, description="搜索关键词")
page: int = Field(default=1, ge=1, description="页码")
page_size: int = Field(default=20, ge=1, le=100, description="每页数量")
# ===== 统计Schema =====
class AllocationOrderStatistics(BaseModel):
"""分配单统计Schema"""
total: int = Field(..., description="总数")
pending: int = Field(..., description="待审批数")
approved: int = Field(..., description="已审批数")
rejected: int = Field(..., description="已拒绝数")
executing: int = Field(..., description="执行中数")
completed: int = Field(..., description="已完成数")

163
app/schemas/asset.py Normal file
View File

@@ -0,0 +1,163 @@
"""
资产相关的Pydantic Schema
"""
from typing import Optional, List, Dict, Any
from datetime import datetime, date
from decimal import Decimal
from pydantic import BaseModel, Field
# ===== 资产Schema =====
class AssetBase(BaseModel):
"""资产基础Schema"""
asset_name: str = Field(..., min_length=1, max_length=200, description="资产名称")
device_type_id: int = Field(..., gt=0, description="设备类型ID")
brand_id: Optional[int] = Field(None, gt=0, description="品牌ID")
model: Optional[str] = Field(None, max_length=200, description="规格型号")
serial_number: Optional[str] = Field(None, max_length=200, description="序列号")
supplier_id: Optional[int] = Field(None, gt=0, description="供应商ID")
purchase_date: Optional[date] = Field(None, description="采购日期")
purchase_price: Optional[Decimal] = Field(None, ge=0, description="采购价格")
warranty_period: Optional[int] = Field(None, ge=0, description="保修期(月)")
organization_id: int = Field(..., gt=0, description="所属网点ID")
location: Optional[str] = Field(None, max_length=500, description="存放位置")
remark: Optional[str] = Field(None, description="备注")
class AssetCreate(AssetBase):
"""创建资产Schema"""
dynamic_attributes: Dict[str, Any] = Field(default_factory=dict, description="动态字段值")
class AssetUpdate(BaseModel):
"""更新资产Schema"""
asset_name: Optional[str] = Field(None, min_length=1, max_length=200)
brand_id: Optional[int] = Field(None, gt=0)
model: Optional[str] = Field(None, max_length=200)
serial_number: Optional[str] = Field(None, max_length=200)
supplier_id: Optional[int] = Field(None, gt=0)
purchase_date: Optional[date] = None
purchase_price: Optional[Decimal] = Field(None, ge=0)
warranty_period: Optional[int] = Field(None, ge=0)
warranty_expire_date: Optional[date] = None
organization_id: Optional[int] = Field(None, gt=0)
location: Optional[str] = Field(None, max_length=500)
dynamic_attributes: Optional[Dict[str, Any]] = None
remark: Optional[str] = None
class AssetInDB(BaseModel):
"""数据库中的资产Schema"""
id: int
asset_code: str
asset_name: str
device_type_id: int
brand_id: Optional[int]
model: Optional[str]
serial_number: Optional[str]
supplier_id: Optional[int]
purchase_date: Optional[date]
purchase_price: Optional[Decimal]
warranty_period: Optional[int]
warranty_expire_date: Optional[date]
organization_id: int
location: Optional[str]
status: str
dynamic_attributes: Dict[str, Any]
qr_code_url: Optional[str]
remark: Optional[str]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class AssetResponse(AssetInDB):
"""资产响应Schema"""
pass
class AssetWithRelations(AssetResponse):
"""带关联信息的资产响应Schema"""
device_type: Optional[Dict[str, Any]] = None
brand: Optional[Dict[str, Any]] = None
supplier: Optional[Dict[str, Any]] = None
organization: Optional[Dict[str, Any]] = None
# ===== 资产状态历史Schema =====
class AssetStatusHistoryBase(BaseModel):
"""资产状态历史基础Schema"""
old_status: Optional[str] = Field(None, description="原状态")
new_status: str = Field(..., description="新状态")
operation_type: str = Field(..., description="操作类型")
remark: Optional[str] = Field(None, description="备注")
class AssetStatusHistoryInDB(BaseModel):
"""数据库中的资产状态历史Schema"""
id: int
asset_id: int
old_status: Optional[str]
new_status: str
operation_type: str
operator_id: int
operator_name: Optional[str]
organization_id: Optional[int]
remark: Optional[str]
extra_data: Optional[Dict[str, Any]]
created_at: datetime
class Config:
from_attributes = True
class AssetStatusHistoryResponse(AssetStatusHistoryInDB):
"""资产状态历史响应Schema"""
pass
# ===== 批量操作Schema =====
class AssetBatchImport(BaseModel):
"""批量导入Schema"""
file_path: str = Field(..., description="Excel文件路径")
class AssetBatchImportResult(BaseModel):
"""批量导入结果Schema"""
total: int = Field(..., description="总数")
success: int = Field(..., description="成功数")
failed: int = Field(..., description="失败数")
errors: List[Dict[str, Any]] = Field(default_factory=list, description="错误列表")
class AssetBatchDelete(BaseModel):
"""批量删除Schema"""
asset_ids: List[int] = Field(..., min_items=1, description="资产ID列表")
# ===== 查询参数Schema =====
class AssetQueryParams(BaseModel):
"""资产查询参数"""
keyword: Optional[str] = Field(None, description="搜索关键词")
device_type_id: Optional[int] = Field(None, gt=0, description="设备类型ID")
organization_id: Optional[int] = Field(None, gt=0, description="网点ID")
status: Optional[str] = Field(None, description="状态")
purchase_date_start: Optional[date] = Field(None, description="采购日期开始")
purchase_date_end: Optional[date] = Field(None, description="采购日期结束")
page: int = Field(default=1, ge=1, description="页码")
page_size: int = Field(default=20, ge=1, le=100, description="每页数量")
# ===== 状态转换Schema =====
class AssetStatusTransition(BaseModel):
"""资产状态转换Schema"""
new_status: str = Field(..., description="目标状态")
remark: Optional[str] = Field(None, description="备注")
extra_data: Optional[Dict[str, Any]] = Field(None, description="额外数据")

View File

@@ -0,0 +1,113 @@
"""
品牌和供应商相关的Pydantic Schema
"""
from typing import Optional
from datetime import datetime
from pydantic import BaseModel, Field, EmailStr
# ===== 品牌Schema =====
class BrandBase(BaseModel):
"""品牌基础Schema"""
brand_code: str = Field(..., min_length=1, max_length=50, description="品牌代码")
brand_name: str = Field(..., min_length=1, max_length=200, description="品牌名称")
logo_url: Optional[str] = Field(None, max_length=500, description="Logo URL")
website: Optional[str] = Field(None, max_length=500, description="官网地址")
sort_order: int = Field(default=0, description="排序")
class BrandCreate(BrandBase):
"""创建品牌Schema"""
pass
class BrandUpdate(BaseModel):
"""更新品牌Schema"""
brand_name: Optional[str] = Field(None, min_length=1, max_length=200)
logo_url: Optional[str] = Field(None, max_length=500)
website: Optional[str] = Field(None, max_length=500)
status: Optional[str] = Field(None, pattern="^(active|inactive)$")
sort_order: Optional[int] = None
class BrandInDB(BaseModel):
"""数据库中的品牌Schema"""
id: int
brand_code: str
brand_name: str
logo_url: Optional[str]
website: Optional[str]
status: str
sort_order: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class BrandResponse(BrandInDB):
"""品牌响应Schema"""
pass
# ===== 供应商Schema =====
class SupplierBase(BaseModel):
"""供应商基础Schema"""
supplier_code: str = Field(..., min_length=1, max_length=50, description="供应商代码")
supplier_name: str = Field(..., min_length=1, max_length=200, description="供应商名称")
contact_person: Optional[str] = Field(None, max_length=100, description="联系人")
contact_phone: Optional[str] = Field(None, max_length=20, description="联系电话")
email: Optional[EmailStr] = Field(None, description="邮箱")
address: Optional[str] = Field(None, max_length=500, description="地址")
credit_code: Optional[str] = Field(None, max_length=50, description="统一社会信用代码")
bank_name: Optional[str] = Field(None, max_length=200, description="开户银行")
bank_account: Optional[str] = Field(None, max_length=100, description="银行账号")
remark: Optional[str] = Field(None, description="备注")
class SupplierCreate(SupplierBase):
"""创建供应商Schema"""
pass
class SupplierUpdate(BaseModel):
"""更新供应商Schema"""
supplier_name: Optional[str] = Field(None, min_length=1, max_length=200)
contact_person: Optional[str] = Field(None, max_length=100)
contact_phone: Optional[str] = Field(None, max_length=20)
email: Optional[EmailStr] = None
address: Optional[str] = Field(None, max_length=500)
credit_code: Optional[str] = Field(None, max_length=50)
bank_name: Optional[str] = Field(None, max_length=200)
bank_account: Optional[str] = Field(None, max_length=100)
status: Optional[str] = Field(None, pattern="^(active|inactive)$")
remark: Optional[str] = None
class SupplierInDB(BaseModel):
"""数据库中的供应商Schema"""
id: int
supplier_code: str
supplier_name: str
contact_person: Optional[str]
contact_phone: Optional[str]
email: Optional[str]
address: Optional[str]
credit_code: Optional[str]
bank_name: Optional[str]
bank_account: Optional[str]
status: str
remark: Optional[str]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class SupplierResponse(SupplierInDB):
"""供应商响应Schema"""
pass

152
app/schemas/device_type.py Normal file
View File

@@ -0,0 +1,152 @@
"""
设备类型相关的Pydantic Schema
"""
from typing import Optional, List, Dict, Any
from datetime import datetime
from pydantic import BaseModel, Field, field_validator
# ===== 设备类型Schema =====
class DeviceTypeBase(BaseModel):
"""设备类型基础Schema"""
type_code: str = Field(..., min_length=1, max_length=50, description="设备类型代码")
type_name: str = Field(..., min_length=1, max_length=200, description="设备类型名称")
category: Optional[str] = Field(None, max_length=50, description="设备分类")
description: Optional[str] = Field(None, description="描述")
icon: Optional[str] = Field(None, max_length=100, description="图标名称")
sort_order: int = Field(default=0, description="排序")
class DeviceTypeCreate(DeviceTypeBase):
"""创建设备类型Schema"""
pass
class DeviceTypeUpdate(BaseModel):
"""更新设备类型Schema"""
type_name: Optional[str] = Field(None, min_length=1, max_length=200)
category: Optional[str] = Field(None, max_length=50)
description: Optional[str] = None
icon: Optional[str] = Field(None, max_length=100)
status: Optional[str] = Field(None, pattern="^(active|inactive)$")
sort_order: Optional[int] = None
class DeviceTypeInDB(BaseModel):
"""数据库中的设备类型Schema"""
id: int
type_code: str
type_name: str
category: Optional[str]
description: Optional[str]
icon: Optional[str]
status: str
sort_order: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class DeviceTypeResponse(DeviceTypeInDB):
"""设备类型响应Schema"""
field_count: int = Field(default=0, description="字段数量")
class Config:
from_attributes = True
class DeviceTypeWithFields(DeviceTypeResponse):
"""带字段列表的设备类型响应Schema"""
fields: List["DeviceTypeFieldResponse"] = Field(default_factory=list, description="字段列表")
class Config:
from_attributes = True
# ===== 设备类型字段Schema =====
class DeviceTypeFieldBase(BaseModel):
"""设备类型字段基础Schema"""
field_code: str = Field(..., min_length=1, max_length=50, description="字段代码")
field_name: str = Field(..., min_length=1, max_length=100, description="字段名称")
field_type: str = Field(..., pattern="^(text|number|date|select|multiselect|boolean|textarea)$", description="字段类型")
is_required: bool = Field(default=False, description="是否必填")
default_value: Optional[str] = Field(None, description="默认值")
placeholder: Optional[str] = Field(None, max_length=200, description="占位符")
help_text: Optional[str] = Field(None, description="帮助文本")
sort_order: int = Field(default=0, description="排序")
class DeviceTypeFieldCreate(DeviceTypeFieldBase):
"""创建设备类型字段Schema"""
options: Optional[List[Dict[str, Any]]] = Field(None, description="选项列表用于select/multiselect类型")
validation_rules: Optional[Dict[str, Any]] = Field(None, description="验证规则")
@field_validator("field_type")
@classmethod
def validate_field_type(cls, v: str) -> str:
"""验证字段类型"""
valid_types = ["text", "number", "date", "select", "multiselect", "boolean", "textarea"]
if v not in valid_types:
raise ValueError(f"字段类型必须是以下之一: {', '.join(valid_types)}")
return v
class DeviceTypeFieldUpdate(BaseModel):
"""更新设备类型字段Schema"""
field_name: Optional[str] = Field(None, min_length=1, max_length=100)
field_type: Optional[str] = Field(None, pattern="^(text|number|date|select|multiselect|boolean|textarea)$")
is_required: Optional[bool] = None
default_value: Optional[str] = None
options: Optional[List[Dict[str, Any]]] = None
validation_rules: Optional[Dict[str, Any]] = None
placeholder: Optional[str] = Field(None, max_length=200)
help_text: Optional[str] = None
status: Optional[str] = Field(None, pattern="^(active|inactive)$")
sort_order: Optional[int] = None
class DeviceTypeFieldInDB(BaseModel):
"""数据库中的设备类型字段Schema"""
id: int
device_type_id: int
field_code: str
field_name: str
field_type: str
is_required: bool
default_value: Optional[str]
options: Optional[List[Dict[str, Any]]]
validation_rules: Optional[Dict[str, Any]]
placeholder: Optional[str]
help_text: Optional[str]
sort_order: int
status: str
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class DeviceTypeFieldResponse(DeviceTypeFieldInDB):
"""设备类型字段响应Schema"""
pass
# ===== 查询参数Schema =====
class DeviceTypeQueryParams(BaseModel):
"""设备类型查询参数"""
category: Optional[str] = Field(None, description="设备分类")
status: Optional[str] = Field(None, pattern="^(active|inactive)$", description="状态")
keyword: Optional[str] = Field(None, description="搜索关键词")
page: int = Field(default=1, ge=1, description="页码")
page_size: int = Field(default=20, ge=1, le=100, description="每页数量")
# 更新前向引用
DeviceTypeWithFields.model_rebuild()

View File

@@ -0,0 +1,159 @@
"""
文件管理相关的Pydantic Schema
"""
from typing import Optional, List, Dict, Any
from datetime import datetime
from pydantic import BaseModel, Field
# ===== 文件Schema =====
class UploadedFileBase(BaseModel):
"""上传文件基础Schema"""
original_name: str = Field(..., min_length=1, max_length=255, description="原始文件名")
file_size: int = Field(..., gt=0, description="文件大小(字节)")
file_type: str = Field(..., description="文件类型(MIME)")
remark: Optional[str] = Field(None, description="备注")
class UploadedFileCreate(UploadedFileBase):
"""创建文件记录Schema"""
file_name: str = Field(..., description="存储文件名")
file_path: str = Field(..., description="文件存储路径")
file_ext: str = Field(..., description="文件扩展名")
uploader_id: int = Field(..., gt=0, description="上传者ID")
class UploadedFileUpdate(BaseModel):
"""更新文件记录Schema"""
remark: Optional[str] = None
class UploadedFileInDB(BaseModel):
"""数据库中的文件Schema"""
id: int
file_name: str
original_name: str
file_path: str
file_size: int
file_type: str
file_ext: str
uploader_id: int
upload_time: datetime
thumbnail_path: Optional[str]
share_code: Optional[str]
share_expire_time: Optional[datetime]
download_count: int
is_deleted: int
deleted_at: Optional[datetime]
deleted_by: Optional[int]
remark: Optional[str]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class UploadedFileResponse(UploadedFileInDB):
"""文件响应Schema"""
uploader_name: Optional[str] = None
class UploadedFileWithUrl(UploadedFileResponse):
"""带访问URL的文件响应Schema"""
download_url: Optional[str] = None
preview_url: Optional[str] = None
share_url: Optional[str] = None
# ===== 文件上传Schema =====
class FileUploadResponse(BaseModel):
"""文件上传响应Schema"""
id: int
file_name: str
original_name: str
file_size: int
file_type: str
file_path: str
download_url: str
preview_url: Optional[str] = None
message: str = "上传成功"
# ===== 文件分享Schema =====
class FileShareCreate(BaseModel):
"""创建文件分享Schema"""
expire_days: int = Field(default=7, ge=1, le=30, description="有效期(天)")
class FileShareResponse(BaseModel):
"""文件分享响应Schema"""
share_code: str
share_url: str
expire_time: datetime
class FileShareVerify(BaseModel):
"""验证分享码Schema"""
share_code: str = Field(..., description="分享码")
# ===== 批量操作Schema =====
class FileBatchDelete(BaseModel):
"""批量删除文件Schema"""
file_ids: List[int] = Field(..., min_items=1, description="文件ID列表")
# ===== 查询参数Schema =====
class FileQueryParams(BaseModel):
"""文件查询参数"""
keyword: Optional[str] = Field(None, description="搜索关键词")
file_type: Optional[str] = Field(None, description="文件类型")
uploader_id: Optional[int] = Field(None, gt=0, description="上传者ID")
start_date: Optional[str] = Field(None, description="开始日期(YYYY-MM-DD)")
end_date: Optional[str] = Field(None, description="结束日期(YYYY-MM-DD)")
page: int = Field(default=1, ge=1, description="页码")
page_size: int = Field(default=20, ge=1, le=100, description="每页数量")
# ===== 统计Schema =====
class FileStatistics(BaseModel):
"""文件统计Schema"""
total_files: int = Field(..., description="总文件数")
total_size: int = Field(..., description="总大小(字节)")
total_size_human: str = Field(..., description="总大小(人类可读)")
type_distribution: Dict[str, int] = Field(default_factory=dict, description="文件类型分布")
upload_today: int = Field(..., description="今日上传数")
upload_this_week: int = Field(..., description="本周上传数")
upload_this_month: int = Field(..., description="本月上传数")
top_uploaders: List[Dict[str, Any]] = Field(default_factory=list, description="上传排行")
# ===== 分片上传Schema =====
class ChunkUploadInit(BaseModel):
"""初始化分片上传Schema"""
file_name: str = Field(..., description="文件名")
file_size: int = Field(..., gt=0, description="文件大小")
file_type: str = Field(..., description="文件类型")
total_chunks: int = Field(..., gt=0, description="总分片数")
file_hash: Optional[str] = Field(None, description="文件哈希(MD5/SHA256)")
class ChunkUploadInfo(BaseModel):
"""分片上传信息Schema"""
upload_id: str = Field(..., description="上传ID")
chunk_index: int = Field(..., ge=0, description="分片索引")
class ChunkUploadComplete(BaseModel):
"""完成分片上传Schema"""
upload_id: str = Field(..., description="上传ID")
file_name: str = Field(..., description="文件名")
file_hash: Optional[str] = Field(None, description="文件哈希")

127
app/schemas/maintenance.py Normal file
View File

@@ -0,0 +1,127 @@
"""
维修管理相关的Pydantic Schema
"""
from typing import Optional, List, Dict, Any
from datetime import datetime, date
from decimal import Decimal
from pydantic import BaseModel, Field
# ===== 维修记录Schema =====
class MaintenanceRecordBase(BaseModel):
"""维修记录基础Schema"""
asset_id: int = Field(..., gt=0, description="资产ID")
fault_description: str = Field(..., min_length=1, description="故障描述")
fault_type: Optional[str] = Field(None, description="故障类型(hardware/software/network/other)")
priority: str = Field(default="normal", description="优先级(low/normal/high/urgent)")
maintenance_type: Optional[str] = Field(None, description="维修类型(self_repair/vendor_repair/warranty)")
vendor_id: Optional[int] = Field(None, gt=0, description="维修供应商ID")
maintenance_cost: Optional[Decimal] = Field(None, ge=0, description="维修费用")
maintenance_result: Optional[str] = Field(None, description="维修结果描述")
replaced_parts: Optional[str] = Field(None, description="更换的配件")
images: Optional[str] = Field(None, description="维修图片URL多个逗号分隔")
remark: Optional[str] = Field(None, description="备注")
class MaintenanceRecordCreate(MaintenanceRecordBase):
"""创建维修记录Schema"""
pass
class MaintenanceRecordUpdate(BaseModel):
"""更新维修记录Schema"""
fault_description: Optional[str] = Field(None, min_length=1)
fault_type: Optional[str] = None
priority: Optional[str] = None
maintenance_type: Optional[str] = None
vendor_id: Optional[int] = Field(None, gt=0)
maintenance_cost: Optional[Decimal] = Field(None, ge=0)
maintenance_result: Optional[str] = None
replaced_parts: Optional[str] = None
images: Optional[str] = None
remark: Optional[str] = None
class MaintenanceRecordStart(BaseModel):
"""开始维修Schema"""
maintenance_type: str = Field(..., description="维修类型")
vendor_id: Optional[int] = Field(None, gt=0, description="维修供应商IDvendor_repair时必填")
remark: Optional[str] = Field(None, description="备注")
class MaintenanceRecordComplete(BaseModel):
"""完成维修Schema"""
maintenance_result: str = Field(..., description="维修结果描述")
maintenance_cost: Optional[Decimal] = Field(None, ge=0, description="维修费用")
replaced_parts: Optional[str] = Field(None, description="更换的配件")
images: Optional[str] = Field(None, description="维修图片URL")
asset_status: str = Field(default="in_stock", description="资产维修后状态(in_stock/in_use)")
class MaintenanceRecordInDB(BaseModel):
"""数据库中的维修记录Schema"""
id: int
record_code: str
asset_id: int
asset_code: str
fault_description: str
fault_type: Optional[str]
report_user_id: Optional[int]
report_time: datetime
priority: str
maintenance_type: Optional[str]
vendor_id: Optional[int]
maintenance_cost: Optional[Decimal]
start_time: Optional[datetime]
complete_time: Optional[datetime]
maintenance_user_id: Optional[int]
maintenance_result: Optional[str]
replaced_parts: Optional[str]
status: str
images: Optional[str]
remark: Optional[str]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class MaintenanceRecordResponse(MaintenanceRecordInDB):
"""维修记录响应Schema"""
pass
class MaintenanceRecordWithRelations(MaintenanceRecordResponse):
"""带关联信息的维修记录响应Schema"""
asset: Optional[Dict[str, Any]] = None
vendor: Optional[Dict[str, Any]] = None
report_user: Optional[Dict[str, Any]] = None
maintenance_user: Optional[Dict[str, Any]] = None
# ===== 查询参数Schema =====
class MaintenanceRecordQueryParams(BaseModel):
"""维修记录查询参数"""
asset_id: Optional[int] = Field(None, gt=0, description="资产ID")
status: Optional[str] = Field(None, description="状态")
fault_type: Optional[str] = Field(None, description="故障类型")
priority: Optional[str] = Field(None, description="优先级")
maintenance_type: Optional[str] = Field(None, description="维修类型")
keyword: Optional[str] = Field(None, description="搜索关键词")
page: int = Field(default=1, ge=1, description="页码")
page_size: int = Field(default=20, ge=1, le=100, description="每页数量")
# ===== 统计Schema =====
class MaintenanceStatistics(BaseModel):
"""维修统计Schema"""
total: int = Field(..., description="总数")
pending: int = Field(..., description="待处理数")
in_progress: int = Field(..., description="维修中数")
completed: int = Field(..., description="已完成数")
cancelled: int = Field(..., description="已取消数")
total_cost: Decimal = Field(..., description="总维修费用")

192
app/schemas/notification.py Normal file
View File

@@ -0,0 +1,192 @@
"""
消息通知相关的Pydantic Schema
"""
from typing import Optional, List, Dict, Any
from datetime import datetime
from pydantic import BaseModel, Field
from enum import Enum
class NotificationTypeEnum(str, Enum):
"""通知类型枚举"""
SYSTEM = "system" # 系统通知
APPROVAL = "approval" # 审批通知
MAINTENANCE = "maintenance" # 维修通知
ALLOCATION = "allocation" # 调拨通知
ASSET = "asset" # 资产通知
WARRANTY = "warranty" # 保修到期通知
REMINDER = "reminder" # 提醒通知
class PriorityEnum(str, Enum):
"""优先级枚举"""
LOW = "low"
NORMAL = "normal"
HIGH = "high"
URGENT = "urgent"
class NotificationBase(BaseModel):
"""消息通知基础Schema"""
recipient_id: int = Field(..., description="接收人ID")
title: str = Field(..., min_length=1, max_length=200, description="通知标题")
content: str = Field(..., min_length=1, description="通知内容")
notification_type: NotificationTypeEnum = Field(..., description="通知类型")
priority: PriorityEnum = Field(default=PriorityEnum.NORMAL, description="优先级")
related_entity_type: Optional[str] = Field(None, max_length=50, description="关联实体类型")
related_entity_id: Optional[int] = Field(None, description="关联实体ID")
action_url: Optional[str] = Field(None, max_length=500, description="操作链接")
extra_data: Optional[Dict[str, Any]] = Field(None, description="额外数据")
send_email: bool = Field(default=False, description="是否发送邮件")
send_sms: bool = Field(default=False, description="是否发送短信")
expire_at: Optional[datetime] = Field(None, description="过期时间")
class NotificationCreate(NotificationBase):
"""创建消息通知Schema"""
pass
class NotificationUpdate(BaseModel):
"""更新消息通知Schema"""
is_read: Optional[bool] = Field(None, description="是否已读")
class NotificationInDB(BaseModel):
"""数据库中的消息通知Schema"""
id: int
recipient_id: int
recipient_name: str
title: str
content: str
notification_type: str
priority: str
is_read: bool
read_at: Optional[datetime]
related_entity_type: Optional[str]
related_entity_id: Optional[int]
action_url: Optional[str]
extra_data: Optional[Dict[str, Any]]
sent_via_email: bool
sent_via_sms: bool
created_at: datetime
expire_at: Optional[datetime]
class Config:
from_attributes = True
class NotificationResponse(NotificationInDB):
"""消息通知响应Schema"""
pass
class NotificationQueryParams(BaseModel):
"""消息通知查询参数"""
recipient_id: Optional[int] = Field(None, description="接收人ID")
notification_type: Optional[NotificationTypeEnum] = Field(None, description="通知类型")
priority: Optional[PriorityEnum] = Field(None, description="优先级")
is_read: Optional[bool] = Field(None, description="是否已读")
start_time: Optional[datetime] = Field(None, description="开始时间")
end_time: Optional[datetime] = Field(None, description="结束时间")
keyword: Optional[str] = Field(None, description="关键词")
page: int = Field(default=1, ge=1, description="页码")
page_size: int = Field(default=20, ge=1, le=100, description="每页数量")
class NotificationBatchCreate(BaseModel):
"""批量创建通知Schema"""
recipient_ids: List[int] = Field(..., min_items=1, description="接收人ID列表")
title: str = Field(..., min_length=1, max_length=200, description="通知标题")
content: str = Field(..., min_length=1, description="通知内容")
notification_type: NotificationTypeEnum = Field(..., description="通知类型")
priority: PriorityEnum = Field(default=PriorityEnum.NORMAL, description="优先级")
action_url: Optional[str] = Field(None, max_length=500, description="操作链接")
extra_data: Optional[Dict[str, Any]] = Field(None, description="额外数据")
class NotificationBatchUpdate(BaseModel):
"""批量更新通知Schema"""
notification_ids: List[int] = Field(..., min_items=1, description="通知ID列表")
is_read: bool = Field(..., description="是否已读")
class NotificationStatistics(BaseModel):
"""通知统计Schema"""
total_count: int = Field(..., description="总通知数")
unread_count: int = Field(..., description="未读数")
read_count: int = Field(..., description="已读数")
high_priority_count: int = Field(..., description="高优先级数")
urgent_count: int = Field(..., description="紧急通知数")
type_distribution: List[Dict[str, Any]] = Field(default_factory=list, description="类型分布")
# ===== 通知模板Schema =====
class NotificationTemplateBase(BaseModel):
"""通知模板基础Schema"""
template_code: str = Field(..., min_length=1, max_length=50, description="模板编码")
template_name: str = Field(..., min_length=1, max_length=200, description="模板名称")
notification_type: NotificationTypeEnum = Field(..., description="通知类型")
title_template: str = Field(..., min_length=1, max_length=200, description="标题模板")
content_template: str = Field(..., min_length=1, description="内容模板")
variables: Optional[Dict[str, str]] = Field(None, description="变量说明")
priority: PriorityEnum = Field(default=PriorityEnum.NORMAL, description="默认优先级")
send_email: bool = Field(default=False, description="是否发送邮件")
send_sms: bool = Field(default=False, description="是否发送短信")
is_active: bool = Field(default=True, description="是否启用")
description: Optional[str] = Field(None, description="模板描述")
class NotificationTemplateCreate(NotificationTemplateBase):
"""创建通知模板Schema"""
pass
class NotificationTemplateUpdate(BaseModel):
"""更新通知模板Schema"""
template_name: Optional[str] = Field(None, min_length=1, max_length=200)
title_template: Optional[str] = Field(None, min_length=1, max_length=200)
content_template: Optional[str] = Field(None, min_length=1)
variables: Optional[Dict[str, str]] = None
priority: Optional[PriorityEnum] = None
send_email: Optional[bool] = None
send_sms: Optional[bool] = None
is_active: Optional[bool] = None
description: Optional[str] = None
class NotificationTemplateInDB(BaseModel):
"""数据库中的通知模板Schema"""
id: int
template_code: str
template_name: str
notification_type: str
title_template: str
content_template: str
variables: Optional[Dict[str, str]]
priority: str
send_email: bool
send_sms: bool
is_active: bool
description: Optional[str]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class NotificationTemplateResponse(NotificationTemplateInDB):
"""通知模板响应Schema"""
pass
class NotificationSendFromTemplate(BaseModel):
"""从模板发送通知Schema"""
template_code: str = Field(..., description="模板编码")
recipient_ids: List[int] = Field(..., min_items=1, description="接收人ID列表")
variables: Dict[str, Any] = Field(default_factory=dict, description="模板变量")
related_entity_type: Optional[str] = Field(None, description="关联实体类型")
related_entity_id: Optional[int] = Field(None, description="关联实体ID")
action_url: Optional[str] = Field(None, description="操作链接")

View File

@@ -0,0 +1,126 @@
"""
操作日志相关的Pydantic Schema
"""
from typing import Optional, List, Dict, Any
from datetime import datetime
from pydantic import BaseModel, Field
from enum import Enum
class OperationModuleEnum(str, Enum):
"""操作模块枚举"""
AUTH = "auth" # 认证模块
ASSET = "asset" # 资产模块
DEVICE_TYPE = "device_type" # 设备类型模块
ORGANIZATION = "organization" # 机构模块
BRAND_SUPPLIER = "brand_supplier" # 品牌供应商模块
ALLOCATION = "allocation" # 调拨模块
MAINTENANCE = "maintenance" # 维修模块
SYSTEM_CONFIG = "system_config" # 系统配置模块
USER = "user" # 用户模块
STATISTICS = "statistics" # 统计模块
class OperationTypeEnum(str, Enum):
"""操作类型枚举"""
CREATE = "create" # 创建
UPDATE = "update" # 更新
DELETE = "delete" # 删除
QUERY = "query" # 查询
EXPORT = "export" # 导出
IMPORT = "import" # 导入
LOGIN = "login" # 登录
LOGOUT = "logout" # 登出
APPROVE = "approve" # 审批
REJECT = "reject" # 拒绝
ASSIGN = "assign" # 分配
TRANSFER = "transfer" # 调拨
SCRAP = "scrap" # 报废
class OperationResultEnum(str, Enum):
"""操作结果枚举"""
SUCCESS = "success"
FAILED = "failed"
class OperationLogBase(BaseModel):
"""操作日志基础Schema"""
operator_id: int = Field(..., description="操作人ID")
operator_name: str = Field(..., min_length=1, max_length=100, description="操作人姓名")
operator_ip: Optional[str] = Field(None, max_length=50, description="操作人IP")
module: OperationModuleEnum = Field(..., description="模块名称")
operation_type: OperationTypeEnum = Field(..., description="操作类型")
method: str = Field(..., min_length=1, max_length=10, description="请求方法")
url: str = Field(..., min_length=1, max_length=500, description="请求URL")
params: Optional[str] = Field(None, description="请求参数")
result: OperationResultEnum = Field(default=OperationResultEnum.SUCCESS, description="操作结果")
error_msg: Optional[str] = Field(None, description="错误信息")
duration: Optional[int] = Field(None, ge=0, description="执行时长(毫秒)")
user_agent: Optional[str] = Field(None, max_length=500, description="用户代理")
extra_data: Optional[Dict[str, Any]] = Field(None, description="额外数据")
class OperationLogCreate(OperationLogBase):
"""创建操作日志Schema"""
pass
class OperationLogInDB(BaseModel):
"""数据库中的操作日志Schema"""
id: int
operator_id: int
operator_name: str
operator_ip: Optional[str]
module: str
operation_type: str
method: str
url: str
params: Optional[str]
result: str
error_msg: Optional[str]
duration: Optional[int]
user_agent: Optional[str]
extra_data: Optional[Dict[str, Any]]
created_at: datetime
class Config:
from_attributes = True
class OperationLogResponse(OperationLogInDB):
"""操作日志响应Schema"""
pass
class OperationLogQueryParams(BaseModel):
"""操作日志查询参数"""
operator_id: Optional[int] = Field(None, description="操作人ID")
operator_name: Optional[str] = Field(None, description="操作人姓名")
module: Optional[OperationModuleEnum] = Field(None, description="模块名称")
operation_type: Optional[OperationTypeEnum] = Field(None, description="操作类型")
result: Optional[OperationResultEnum] = Field(None, description="操作结果")
start_time: Optional[datetime] = Field(None, description="开始时间")
end_time: Optional[datetime] = Field(None, description="结束时间")
keyword: Optional[str] = Field(None, description="关键词")
page: int = Field(default=1, ge=1, description="页码")
page_size: int = Field(default=20, ge=1, le=100, description="每页数量")
class OperationLogStatistics(BaseModel):
"""操作日志统计Schema"""
total_count: int = Field(..., description="总操作次数")
success_count: int = Field(..., description="成功次数")
failed_count: int = Field(..., description="失败次数")
today_count: int = Field(..., description="今日操作次数")
module_distribution: List[Dict[str, Any]] = Field(default_factory=list, description="模块分布")
operation_distribution: List[Dict[str, Any]] = Field(default_factory=list, description="操作类型分布")
class OperationLogExport(BaseModel):
"""操作日志导出Schema"""
start_time: Optional[datetime] = Field(None, description="开始时间")
end_time: Optional[datetime] = Field(None, description="结束时间")
operator_id: Optional[int] = Field(None, description="操作人ID")
module: Optional[str] = Field(None, description="模块名称")
operation_type: Optional[str] = Field(None, description="操作类型")

View File

@@ -0,0 +1,80 @@
"""
机构网点相关的Pydantic Schema
"""
from typing import Optional, List
from datetime import datetime
from pydantic import BaseModel, Field
# ===== 机构网点Schema =====
class OrganizationBase(BaseModel):
"""机构基础Schema"""
org_code: str = Field(..., min_length=1, max_length=50, description="机构代码")
org_name: str = Field(..., min_length=1, max_length=200, description="机构名称")
org_type: str = Field(..., pattern="^(province|city|outlet)$", description="机构类型")
parent_id: Optional[int] = Field(None, description="父机构ID")
address: Optional[str] = Field(None, max_length=500, description="地址")
contact_person: Optional[str] = Field(None, max_length=100, description="联系人")
contact_phone: Optional[str] = Field(None, max_length=20, description="联系电话")
sort_order: int = Field(default=0, description="排序")
class OrganizationCreate(OrganizationBase):
"""创建机构Schema"""
pass
class OrganizationUpdate(BaseModel):
"""更新机构Schema"""
org_name: Optional[str] = Field(None, min_length=1, max_length=200)
org_type: Optional[str] = Field(None, pattern="^(province|city|outlet)$")
parent_id: Optional[int] = None
address: Optional[str] = Field(None, max_length=500)
contact_person: Optional[str] = Field(None, max_length=100)
contact_phone: Optional[str] = Field(None, max_length=20)
status: Optional[str] = Field(None, pattern="^(active|inactive)$")
sort_order: Optional[int] = None
class OrganizationInDB(BaseModel):
"""数据库中的机构Schema"""
id: int
org_code: str
org_name: str
org_type: str
parent_id: Optional[int]
tree_path: Optional[str]
tree_level: int
address: Optional[str]
contact_person: Optional[str]
contact_phone: Optional[str]
status: str
sort_order: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class OrganizationResponse(OrganizationInDB):
"""机构响应Schema"""
pass
class OrganizationTreeNode(OrganizationResponse):
"""机构树节点Schema"""
children: List["OrganizationTreeNode"] = []
class Config:
from_attributes = True
class OrganizationWithParent(OrganizationResponse):
"""带父机构信息的Schema"""
parent: Optional[OrganizationResponse] = None
# 更新前向引用
OrganizationTreeNode.model_rebuild()

118
app/schemas/recovery.py Normal file
View File

@@ -0,0 +1,118 @@
"""
资产回收相关的Pydantic Schema
"""
from typing import Optional, List, Dict, Any
from datetime import datetime
from pydantic import BaseModel, Field
# ===== 回收单Schema =====
class AssetRecoveryOrderBase(BaseModel):
"""回收单基础Schema"""
recovery_type: str = Field(..., description="回收类型(user=使用人回收/org=机构回收/scrap=报废回收)")
title: str = Field(..., min_length=1, max_length=200, description="标题")
remark: Optional[str] = Field(None, description="备注")
class AssetRecoveryOrderCreate(AssetRecoveryOrderBase):
"""创建回收单Schema"""
asset_ids: List[int] = Field(..., min_items=1, description="资产ID列表")
class AssetRecoveryOrderUpdate(BaseModel):
"""更新回收单Schema"""
title: Optional[str] = Field(None, min_length=1, max_length=200, description="标题")
remark: Optional[str] = Field(None, description="备注")
class AssetRecoveryOrderInDB(BaseModel):
"""数据库中的回收单Schema"""
id: int
order_code: str
recovery_type: str
title: str
asset_count: int
apply_user_id: int
apply_time: datetime
approval_status: str
approval_user_id: Optional[int]
approval_time: Optional[datetime]
approval_remark: Optional[str]
execute_status: str
execute_user_id: Optional[int]
execute_time: Optional[datetime]
remark: Optional[str]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class AssetRecoveryOrderResponse(AssetRecoveryOrderInDB):
"""回收单响应Schema"""
pass
class AssetRecoveryOrderWithRelations(AssetRecoveryOrderResponse):
"""带关联信息的回收单响应Schema"""
apply_user: Optional[Dict[str, Any]] = None
approval_user: Optional[Dict[str, Any]] = None
execute_user: Optional[Dict[str, Any]] = None
items: Optional[List[Dict[str, Any]]] = None
class AssetRecoveryOrderQueryParams(BaseModel):
"""回收单查询参数"""
recovery_type: Optional[str] = Field(None, description="回收类型")
approval_status: Optional[str] = Field(None, description="审批状态")
execute_status: Optional[str] = Field(None, description="执行状态")
keyword: Optional[str] = Field(None, description="搜索关键词")
page: int = Field(default=1, ge=1, description="页码")
page_size: int = Field(default=20, ge=1, le=100, description="每页数量")
class AssetRecoveryOrderListResponse(BaseModel):
"""回收单列表响应Schema"""
total: int
page: int
page_size: int
total_pages: int
items: List[AssetRecoveryOrderWithRelations]
class AssetRecoveryStatistics(BaseModel):
"""回收单统计Schema"""
total: int = Field(..., description="总数")
pending: int = Field(..., description="待审批数")
approved: int = Field(..., description="已审批数")
rejected: int = Field(..., description="已拒绝数")
executing: int = Field(..., description="执行中数")
completed: int = Field(..., description="已完成数")
# ===== 回收单明细Schema =====
class AssetRecoveryItemBase(BaseModel):
"""回收单明细基础Schema"""
asset_id: int = Field(..., gt=0, description="资产ID")
remark: Optional[str] = Field(None, description="备注")
class AssetRecoveryItemInDB(BaseModel):
"""数据库中的回收单明细Schema"""
id: int
order_id: int
asset_id: int
asset_code: str
recovery_status: str
created_at: datetime
class Config:
from_attributes = True
class AssetRecoveryItemResponse(AssetRecoveryItemInDB):
"""回收单明细响应Schema"""
pass

108
app/schemas/statistics.py Normal file
View File

@@ -0,0 +1,108 @@
"""
统计分析相关的Pydantic Schema
"""
from typing import Optional, List, Dict, Any
from datetime import datetime, date
from decimal import Decimal
from pydantic import BaseModel, Field
class StatisticsOverview(BaseModel):
"""总览统计Schema"""
total_assets: int = Field(..., description="资产总数")
total_value: Decimal = Field(..., description="资产总价值")
in_stock_count: int = Field(..., description="库存中数量")
in_use_count: int = Field(..., description="使用中数量")
maintenance_count: int = Field(..., description="维修中数量")
scrapped_count: int = Field(..., description="已报废数量")
today_purchase_count: int = Field(..., description="今日采购数量")
this_month_purchase_count: int = Field(..., description="本月采购数量")
organization_count: int = Field(..., description="机构网点数")
supplier_count: int = Field(..., description="供应商数")
class PurchaseStatistics(BaseModel):
"""采购统计Schema"""
total_purchase_count: int = Field(..., description="总采购数量")
total_purchase_value: Decimal = Field(..., description="总采购金额")
monthly_trend: List[Dict[str, Any]] = Field(default_factory=list, description="月度趋势")
supplier_distribution: List[Dict[str, Any]] = Field(default_factory=list, description="供应商分布")
category_distribution: List[Dict[str, Any]] = Field(default_factory=list, description="分类分布")
class DepreciationStatistics(BaseModel):
"""折旧统计Schema"""
total_depreciation_value: Decimal = Field(..., description="总折旧金额")
average_depreciation_rate: Decimal = Field(..., description="平均折旧率")
depreciation_by_category: List[Dict[str, Any]] = Field(default_factory=list, description="分类折旧")
assets_near_end_life: List[Dict[str, Any]] = Field(default_factory=list, description="接近使用年限的资产")
class ValueStatistics(BaseModel):
"""价值统计Schema"""
total_value: Decimal = Field(..., description="资产总价值")
net_value: Decimal = Field(..., description="资产净值")
depreciation_value: Decimal = Field(..., description="累计折旧")
value_by_category: List[Dict[str, Any]] = Field(default_factory=list, description="分类价值")
value_by_organization: List[Dict[str, Any]] = Field(default_factory=list, description="网点价值")
high_value_assets: List[Dict[str, Any]] = Field(default_factory=list, description="高价值资产")
class TrendAnalysis(BaseModel):
"""趋势分析Schema"""
asset_trend: List[Dict[str, Any]] = Field(default_factory=list, description="资产数量趋势")
value_trend: List[Dict[str, Any]] = Field(default_factory=list, description="资产价值趋势")
purchase_trend: List[Dict[str, Any]] = Field(default_factory=list, description="采购趋势")
maintenance_trend: List[Dict[str, Any]] = Field(default_factory=list, description="维修趋势")
allocation_trend: List[Dict[str, Any]] = Field(default_factory=list, description="调拨趋势")
class MaintenanceStatistics(BaseModel):
"""维修统计Schema"""
total_maintenance_count: int = Field(..., description="总维修次数")
total_maintenance_cost: Decimal = Field(..., description="总维修费用")
pending_count: int = Field(..., description="待维修数量")
in_progress_count: int = Field(..., description="维修中数量")
completed_count: int = Field(..., description="已完成数量")
monthly_trend: List[Dict[str, Any]] = Field(default_factory=list, description="月度趋势")
type_distribution: List[Dict[str, Any]] = Field(default_factory=list, description="维修类型分布")
cost_by_category: List[Dict[str, Any]] = Field(default_factory=list, description="分类维修费用")
class AllocationStatistics(BaseModel):
"""分配统计Schema"""
total_allocation_count: int = Field(..., description="总分配次数")
pending_count: int = Field(..., description="待审批数量")
approved_count: int = Field(..., description="已批准数量")
rejected_count: int = Field(..., description="已拒绝数量")
monthly_trend: List[Dict[str, Any]] = Field(default_factory=list, description="月度趋势")
by_organization: List[Dict[str, Any]] = Field(default_factory=list, description="网点分配统计")
transfer_statistics: List[Dict[str, Any]] = Field(default_factory=list, description="调拨统计")
class StatisticsQueryParams(BaseModel):
"""统计查询参数"""
start_date: Optional[date] = Field(None, description="开始日期")
end_date: Optional[date] = Field(None, description="结束日期")
organization_id: Optional[int] = Field(None, description="网点ID")
device_type_id: Optional[int] = Field(None, description="设备类型ID")
group_by: Optional[str] = Field(None, description="分组字段")
class ExportStatisticsRequest(BaseModel):
"""导出统计请求"""
report_type: str = Field(..., description="报表类型")
start_date: Optional[date] = Field(None, description="开始日期")
end_date: Optional[date] = Field(None, description="结束日期")
organization_id: Optional[int] = Field(None, description="网点ID")
device_type_id: Optional[int] = Field(None, description="设备类型ID")
format: str = Field(default="xlsx", description="导出格式")
include_charts: bool = Field(default=False, description="是否包含图表")
class ExportStatisticsResponse(BaseModel):
"""导出统计响应"""
file_url: str = Field(..., description="文件URL")
file_name: str = Field(..., description="文件名")
file_size: int = Field(..., description="文件大小(字节)")
record_count: int = Field(..., description="记录数量")

View File

@@ -0,0 +1,102 @@
"""
系统配置相关的Pydantic Schema
"""
from typing import Optional, List, Dict, Any
from datetime import datetime
from pydantic import BaseModel, Field
from enum import Enum
class ValueTypeEnum(str, Enum):
"""配置值类型枚举"""
STRING = "string"
NUMBER = "number"
BOOLEAN = "boolean"
JSON = "json"
class SystemConfigBase(BaseModel):
"""系统配置基础Schema"""
config_key: str = Field(..., min_length=1, max_length=100, description="配置键")
config_name: str = Field(..., min_length=1, max_length=200, description="配置名称")
config_value: Optional[str] = Field(None, description="配置值")
value_type: ValueTypeEnum = Field(default=ValueTypeEnum.STRING, description="值类型")
category: str = Field(..., min_length=1, max_length=50, description="配置分类")
description: Optional[str] = Field(None, description="配置描述")
is_system: bool = Field(default=False, description="是否系统配置")
is_encrypted: bool = Field(default=False, description="是否加密存储")
validation_rule: Optional[str] = Field(None, description="验证规则")
options: Optional[Dict[str, Any]] = Field(None, description="可选值配置")
default_value: Optional[str] = Field(None, description="默认值")
sort_order: int = Field(default=0, description="排序序号")
is_active: bool = Field(default=True, description="是否启用")
class SystemConfigCreate(SystemConfigBase):
"""创建系统配置Schema"""
pass
class SystemConfigUpdate(BaseModel):
"""更新系统配置Schema"""
config_name: Optional[str] = Field(None, min_length=1, max_length=200)
config_value: Optional[str] = None
value_type: Optional[ValueTypeEnum] = None
category: Optional[str] = Field(None, min_length=1, max_length=50)
description: Optional[str] = None
validation_rule: Optional[str] = None
options: Optional[Dict[str, Any]] = None
default_value: Optional[str] = None
sort_order: Optional[int] = None
is_active: Optional[bool] = None
class SystemConfigInDB(BaseModel):
"""数据库中的系统配置Schema"""
id: int
config_key: str
config_name: str
config_value: Optional[str]
value_type: str
category: str
description: Optional[str]
is_system: bool
is_encrypted: bool
validation_rule: Optional[str]
options: Optional[Dict[str, Any]]
default_value: Optional[str]
sort_order: int
is_active: bool
created_at: datetime
updated_at: datetime
updated_by: Optional[int]
class Config:
from_attributes = True
class SystemConfigResponse(SystemConfigInDB):
"""系统配置响应Schema"""
pass
class SystemConfigBatchUpdate(BaseModel):
"""批量更新配置Schema"""
configs: Dict[str, Any] = Field(..., description="配置键值对")
class SystemConfigQueryParams(BaseModel):
"""系统配置查询参数"""
keyword: Optional[str] = Field(None, description="搜索关键词")
category: Optional[str] = Field(None, description="配置分类")
is_active: Optional[bool] = Field(None, description="是否启用")
is_system: Optional[bool] = Field(None, description="是否系统配置")
page: int = Field(default=1, ge=1, description="页码")
page_size: int = Field(default=20, ge=1, le=100, description="每页数量")
class ConfigCategoryResponse(BaseModel):
"""配置分类响应Schema"""
category: str = Field(..., description="分类名称")
count: int = Field(..., description="配置数量")
description: Optional[str] = Field(None, description="分类描述")

138
app/schemas/transfer.py Normal file
View File

@@ -0,0 +1,138 @@
"""
资产调拨相关的Pydantic Schema
"""
from typing import Optional, List, Dict, Any
from datetime import datetime
from pydantic import BaseModel, Field
# ===== 调拨单Schema =====
class AssetTransferOrderBase(BaseModel):
"""调拨单基础Schema"""
source_org_id: int = Field(..., gt=0, description="调出网点ID")
target_org_id: int = Field(..., gt=0, description="调入网点ID")
transfer_type: str = Field(..., description="调拨类型(internal=内部调拨/external=跨机构调拨)")
title: str = Field(..., min_length=1, max_length=200, description="标题")
remark: Optional[str] = Field(None, description="备注")
class AssetTransferOrderCreate(AssetTransferOrderBase):
"""创建调拨单Schema"""
asset_ids: List[int] = Field(..., min_items=1, description="资产ID列表")
class AssetTransferOrderUpdate(BaseModel):
"""更新调拨单Schema"""
title: Optional[str] = Field(None, min_length=1, max_length=200, description="标题")
remark: Optional[str] = Field(None, description="备注")
class AssetTransferOrderStart(BaseModel):
"""开始调拨Schema"""
remark: Optional[str] = Field(None, description="开始备注")
class AssetTransferOrderComplete(BaseModel):
"""完成调拨Schema"""
remark: Optional[str] = Field(None, description="完成备注")
class AssetTransferOrderInDB(BaseModel):
"""数据库中的调拨单Schema"""
id: int
order_code: str
source_org_id: int
target_org_id: int
transfer_type: str
title: str
asset_count: int
apply_user_id: int
apply_time: datetime
approval_status: str
approval_user_id: Optional[int]
approval_time: Optional[datetime]
approval_remark: Optional[str]
execute_status: str
execute_user_id: Optional[int]
execute_time: Optional[datetime]
remark: Optional[str]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class AssetTransferOrderResponse(AssetTransferOrderInDB):
"""调拨单响应Schema"""
pass
class AssetTransferOrderWithRelations(AssetTransferOrderResponse):
"""带关联信息的调拨单响应Schema"""
source_organization: Optional[Dict[str, Any]] = None
target_organization: Optional[Dict[str, Any]] = None
apply_user: Optional[Dict[str, Any]] = None
approval_user: Optional[Dict[str, Any]] = None
execute_user: Optional[Dict[str, Any]] = None
items: Optional[List[Dict[str, Any]]] = None
class AssetTransferOrderQueryParams(BaseModel):
"""调拨单查询参数"""
transfer_type: Optional[str] = Field(None, description="调拨类型")
approval_status: Optional[str] = Field(None, description="审批状态")
execute_status: Optional[str] = Field(None, description="执行状态")
source_org_id: Optional[int] = Field(None, gt=0, description="调出网点ID")
target_org_id: Optional[int] = Field(None, gt=0, description="调入网点ID")
keyword: Optional[str] = Field(None, description="搜索关键词")
page: int = Field(default=1, ge=1, description="页码")
page_size: int = Field(default=20, ge=1, le=100, description="每页数量")
class AssetTransferOrderListResponse(BaseModel):
"""调拨单列表响应Schema"""
total: int
page: int
page_size: int
total_pages: int
items: List[AssetTransferOrderWithRelations]
class AssetTransferStatistics(BaseModel):
"""调拨单统计Schema"""
total: int = Field(..., description="总数")
pending: int = Field(..., description="待审批数")
approved: int = Field(..., description="已审批数")
rejected: int = Field(..., description="已拒绝数")
executing: int = Field(..., description="执行中数")
completed: int = Field(..., description="已完成数")
# ===== 调拨单明细Schema =====
class AssetTransferItemBase(BaseModel):
"""调拨单明细基础Schema"""
asset_id: int = Field(..., gt=0, description="资产ID")
remark: Optional[str] = Field(None, description="备注")
class AssetTransferItemInDB(BaseModel):
"""数据库中的调拨单明细Schema"""
id: int
order_id: int
asset_id: int
asset_code: str
source_organization_id: int
target_organization_id: int
transfer_status: str
created_at: datetime
class Config:
from_attributes = True
class AssetTransferItemResponse(AssetTransferItemInDB):
"""调拨单明细响应Schema"""
pass

231
app/schemas/user.py Normal file
View File

@@ -0,0 +1,231 @@
"""
用户相关的Pydantic Schema
"""
from typing import Optional, List
from datetime import datetime
from pydantic import BaseModel, Field, EmailStr, field_validator
# ===== 用户Schema =====
class UserBase(BaseModel):
"""用户基础Schema"""
real_name: str = Field(..., min_length=1, max_length=100, description="真实姓名")
email: Optional[EmailStr] = Field(None, description="邮箱")
phone: Optional[str] = Field(None, max_length=20, description="手机号")
class UserCreate(UserBase):
"""创建用户Schema"""
username: str = Field(..., min_length=4, max_length=50, description="用户名")
password: str = Field(..., min_length=8, max_length=100, description="密码")
role_ids: List[int] = Field(..., min_items=1, description="角色ID列表")
@field_validator("username")
@classmethod
def validate_username(cls, v: str) -> str:
"""验证用户名格式"""
if not v.replace("_", "").isalnum():
raise ValueError("用户名只能包含字母、数字和下划线")
return v
@field_validator("password")
@classmethod
def validate_password(cls, v: str) -> str:
"""验证密码强度"""
if not any(c.isupper() for c in v):
raise ValueError("密码必须包含至少一个大写字母")
if not any(c.islower() for c in v):
raise ValueError("密码必须包含至少一个小写字母")
if not any(c.isdigit() for c in v):
raise ValueError("密码必须包含至少一个数字")
return v
class UserUpdate(BaseModel):
"""更新用户Schema"""
real_name: Optional[str] = Field(None, min_length=1, max_length=100)
email: Optional[EmailStr] = None
phone: Optional[str] = Field(None, max_length=20)
status: Optional[str] = Field(None, pattern="^(active|disabled|locked)$")
role_ids: Optional[List[int]] = None
class UserInDB(BaseModel):
"""数据库中的用户Schema"""
id: int
username: str
real_name: str
email: Optional[str]
phone: Optional[str]
avatar_url: Optional[str]
status: str
is_admin: bool
last_login_at: Optional[datetime]
created_at: datetime
class Config:
from_attributes = True
class UserResponse(UserInDB):
"""用户响应Schema"""
roles: List["RoleResponse"] = []
class Config:
from_attributes = True
class UserInfo(BaseModel):
"""用户信息Schema不含敏感信息"""
id: int
username: str
real_name: str
email: Optional[str]
avatar_url: Optional[str]
is_admin: bool
status: str
class Config:
from_attributes = True
# ===== 登录认证Schema =====
class LoginRequest(BaseModel):
"""登录请求Schema"""
username: str = Field(..., min_length=1, description="用户名")
password: str = Field(..., min_length=1, description="密码")
captcha: str = Field(..., min_length=4, description="验证码")
captcha_key: str = Field(..., description="验证码UUID")
class LoginResponse(BaseModel):
"""登录响应Schema"""
access_token: str = Field(..., description="访问令牌")
refresh_token: str = Field(..., description="刷新令牌")
token_type: str = Field(default="Bearer", description="令牌类型")
expires_in: int = Field(..., description="过期时间(秒)")
user: UserInfo = Field(..., description="用户信息")
class RefreshTokenRequest(BaseModel):
"""刷新令牌请求Schema"""
refresh_token: str = Field(..., description="刷新令牌")
class RefreshTokenResponse(BaseModel):
"""刷新令牌响应Schema"""
access_token: str = Field(..., description="新的访问令牌")
expires_in: int = Field(..., description="过期时间(秒)")
class ChangePasswordRequest(BaseModel):
"""修改密码请求Schema"""
old_password: str = Field(..., min_length=1, description="旧密码")
new_password: str = Field(..., min_length=8, max_length=100, description="新密码")
confirm_password: str = Field(..., min_length=8, max_length=100, description="确认密码")
@field_validator("confirm_password")
@classmethod
def validate_passwords_match(cls, v: str, info) -> str:
"""验证两次密码是否一致"""
if "new_password" in info.data and v != info.data["new_password"]:
raise ValueError("两次输入的密码不一致")
return v
class ResetPasswordRequest(BaseModel):
"""重置密码请求Schema"""
new_password: str = Field(..., min_length=8, max_length=100, description="新密码")
# ===== 角色Schema =====
class RoleBase(BaseModel):
"""角色基础Schema"""
role_name: str = Field(..., min_length=1, max_length=50, description="角色名称")
role_code: str = Field(..., min_length=1, max_length=50, description="角色代码")
description: Optional[str] = Field(None, description="角色描述")
class RoleCreate(RoleBase):
"""创建角色Schema"""
permission_ids: List[int] = Field(default_factory=list, description="权限ID列表")
class RoleUpdate(BaseModel):
"""更新角色Schema"""
role_name: Optional[str] = Field(None, min_length=1, max_length=50)
description: Optional[str] = None
permission_ids: Optional[List[int]] = None
class RoleInDB(BaseModel):
"""数据库中的角色Schema"""
id: int
role_name: str
role_code: str
description: Optional[str]
status: str
sort_order: int
created_at: datetime
class Config:
from_attributes = True
class RoleResponse(RoleInDB):
"""角色响应Schema"""
permissions: List["PermissionResponse"] = []
class Config:
from_attributes = True
class RoleWithUserCount(RoleResponse):
"""带用户数量的角色响应Schema"""
user_count: int = Field(..., description="用户数量")
# ===== 权限Schema =====
class PermissionBase(BaseModel):
"""权限基础Schema"""
permission_name: str = Field(..., min_length=1, max_length=100)
permission_code: str = Field(..., min_length=1, max_length=100)
module: str = Field(..., min_length=1, max_length=50)
resource: Optional[str] = Field(None, max_length=50)
action: Optional[str] = Field(None, max_length=50)
description: Optional[str] = None
class PermissionCreate(PermissionBase):
"""创建权限Schema"""
pass
class PermissionUpdate(BaseModel):
"""更新权限Schema"""
permission_name: Optional[str] = Field(None, min_length=1, max_length=100)
description: Optional[str] = None
class PermissionResponse(PermissionBase):
"""权限响应Schema"""
id: int
created_at: datetime
class Config:
from_attributes = True
class PermissionTreeNode(PermissionResponse):
"""权限树节点Schema"""
children: List["PermissionTreeNode"] = []
# 更新前向引用
UserResponse.model_rebuild()
RoleResponse.model_rebuild()
PermissionTreeNode.model_rebuild()

0
app/services/__init__.py Normal file
View File

View File

@@ -0,0 +1,469 @@
"""
资产分配业务服务层
"""
from typing import List, Optional, Dict, Any
from sqlalchemy.orm import Session, selectinload
from app.crud.allocation import allocation_order, allocation_item
from app.crud.asset import asset
from app.schemas.allocation import (
AllocationOrderCreate,
AllocationOrderUpdate,
AllocationOrderApproval
)
from app.core.exceptions import NotFoundException, BusinessException
class AllocationService:
"""资产分配服务类"""
async def get_order(
self,
db: Session,
order_id: int
) -> Dict[str, Any]:
"""获取分配单详情"""
# 使用selectinload预加载关联数据避免N+1查询
from app.models.allocation import AllocationOrder
from app.models.organization import Organization
from app.models.user import User
from app.models.allocation import AllocationItem
obj = db.query(
AllocationOrder
).options(
selectinload(AllocationOrder.items),
selectinload(AllocationOrder.source_organization.of_type(Organization)),
selectinload(AllocationOrder.target_organization.of_type(Organization)),
selectinload(AllocationOrder.applicant.of_type(User)),
selectinload(AllocationOrder.approver.of_type(User)),
selectinload(AllocationOrder.executor.of_type(User))
).filter(
AllocationOrder.id == order_id
).first()
if not obj:
raise NotFoundException("分配单")
# 加载关联信息
return self._load_order_relations(db, obj)
def get_orders(
self,
db: Session,
skip: int = 0,
limit: int = 20,
order_type: Optional[str] = None,
approval_status: Optional[str] = None,
execute_status: Optional[str] = None,
applicant_id: Optional[int] = None,
target_organization_id: Optional[int] = None,
keyword: Optional[str] = None
) -> tuple:
"""获取分配单列表"""
items, total = allocation_order.get_multi(
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
)
# 加载关联信息
items_with_relations = [self._load_order_relations(db, item) for item in items]
return items_with_relations, total
async def create_order(
self,
db: Session,
obj_in: AllocationOrderCreate,
applicant_id: int
):
"""创建分配单"""
# 验证资产存在性和状态
assets = []
for asset_id in obj_in.asset_ids:
asset_obj = asset.get(db, asset_id)
if not asset_obj:
raise NotFoundException(f"资产ID {asset_id}")
assets.append(asset_obj)
# 验证资产状态是否允许分配
for asset_obj in assets:
if not self._can_allocate(asset_obj.status, obj_in.order_type):
raise BusinessException(
f"资产 {asset_obj.asset_code} 当前状态为 {asset_obj.status},不允许{self._get_order_type_name(obj_in.order_type)}操作"
)
# 生成分配单号
order_code = await self._generate_order_code(db, obj_in.order_type)
# 创建分配单
db_obj = allocation_order.create(
db=db,
obj_in=obj_in,
order_code=order_code,
applicant_id=applicant_id
)
return self._load_order_relations(db, db_obj)
def update_order(
self,
db: Session,
order_id: int,
obj_in: AllocationOrderUpdate,
updater_id: int
):
"""更新分配单"""
db_obj = allocation_order.get(db, order_id)
if not db_obj:
raise NotFoundException("分配单")
# 只有草稿或待审批状态可以更新
if db_obj.approval_status not in ["pending", "draft"]:
raise BusinessException("只有待审批状态的分配单可以更新")
return allocation_order.update(db, db_obj, obj_in, updater_id)
async def approve_order(
self,
db: Session,
order_id: int,
approval_in: AllocationOrderApproval,
approver_id: int
):
"""审批分配单"""
db_obj = allocation_order.get(db, order_id)
if not db_obj:
raise NotFoundException("分配单")
# 检查状态
if db_obj.approval_status != "pending":
raise BusinessException("该分配单已审批,无法重复审批")
# 审批
db_obj = allocation_order.approve(
db=db,
db_obj=db_obj,
approval_status=approval_in.approval_status,
approver_id=approver_id,
approval_remark=approval_in.approval_remark
)
# 如果审批通过,执行分配逻辑
if approval_in.approval_status == "approved":
await self._execute_allocation_logic(db, db_obj)
return self._load_order_relations(db, db_obj)
async def execute_order(
self,
db: Session,
order_id: int,
executor_id: int
):
"""执行分配单"""
db_obj = allocation_order.get(db, order_id)
if not db_obj:
raise NotFoundException("分配单")
# 检查状态
if db_obj.approval_status != "approved":
raise BusinessException("该分配单未审批通过,无法执行")
if db_obj.execute_status == "completed":
raise BusinessException("该分配单已执行完成")
# 执行分配单
db_obj = allocation_order.execute(db, db_obj, executor_id)
return self._load_order_relations(db, db_obj)
def cancel_order(
self,
db: Session,
order_id: int
) -> bool:
"""取消分配单"""
db_obj = allocation_order.get(db, order_id)
if not db_obj:
raise NotFoundException("分配单")
# 检查状态
if db_obj.execute_status == "completed":
raise BusinessException("已完成的分配单无法取消")
allocation_order.cancel(db, db_obj)
return True
def delete_order(
self,
db: Session,
order_id: int
) -> bool:
"""删除分配单"""
db_obj = allocation_order.get(db, order_id)
if not db_obj:
raise NotFoundException("分配单")
# 只有草稿或已取消的可以删除
if db_obj.approval_status not in ["draft", "rejected", "cancelled"]:
raise BusinessException("只能删除草稿、已拒绝或已取消的分配单")
return allocation_order.delete(db, order_id)
def get_order_items(
self,
db: Session,
order_id: int
) -> List:
"""获取分配单明细"""
# 验证分配单存在
if not allocation_order.get(db, order_id):
raise NotFoundException("分配单")
return allocation_item.get_by_order(db, order_id)
def get_statistics(
self,
db: Session,
applicant_id: Optional[int] = None
) -> Dict[str, int]:
"""获取分配单统计信息"""
return allocation_order.get_statistics(db, applicant_id)
async def _execute_allocation_logic(
self,
db: Session,
order_obj
):
"""执行分配逻辑(审批通过后自动执行)"""
# 根据单据类型执行不同的逻辑
if order_obj.order_type == "allocation":
await self._execute_allocation(db, order_obj)
elif order_obj.order_type == "transfer":
await self._execute_transfer(db, order_obj)
elif order_obj.order_type == "recovery":
await self._execute_recovery(db, order_obj)
elif order_obj.order_type == "maintenance":
await self._execute_maintenance_allocation(db, order_obj)
elif order_obj.order_type == "scrap":
await self._execute_scrap_allocation(db, order_obj)
async def _execute_allocation(self, db: Session, order_obj):
"""执行资产分配"""
# 更新明细状态为执行中
allocation_item.batch_update_execute_status(db, order_obj.id, "executing")
# 获取明细
items = allocation_item.get_by_order(db, order_obj.id)
# 更新资产状态
from app.services.asset_service import asset_service
from app.schemas.asset import AssetStatusTransition
for item in items:
try:
# 变更资产状态
await asset_service.change_asset_status(
db=db,
asset_id=item.asset_id,
status_transition=AssetStatusTransition(
new_status=item.to_status,
remark=f"分配单: {order_obj.order_code}"
),
operator_id=order_obj.applicant_id
)
# 更新明细状态为完成
allocation_item.update_execute_status(db, item.id, "completed")
except Exception as e:
# 更新明细状态为失败
allocation_item.update_execute_status(
db,
item.id,
"failed",
failure_reason=str(e)
)
async def _execute_transfer(self, db: Session, order_obj):
"""执行资产调拨"""
# 调拨逻辑与分配类似,但需要记录调出和调入网点
await self._execute_allocation(db, order_obj)
async def _execute_recovery(self, db: Session, order_obj):
"""执行资产回收"""
# 回收逻辑
await self._execute_allocation(db, order_obj)
async def _execute_maintenance_allocation(self, db: Session, order_obj):
"""执行维修分配"""
# 维修分配逻辑
await self._execute_allocation(db, order_obj)
async def _execute_scrap_allocation(self, db: Session, order_obj):
"""执行报废分配"""
# 报废分配逻辑
await self._execute_allocation(db, order_obj)
def _load_order_relations(
self,
db: Session,
obj
) -> Dict[str, Any]:
"""加载分配单关联信息"""
from app.models.user import User
from app.models.organization import Organization
result = {
"id": obj.id,
"order_code": obj.order_code,
"order_type": obj.order_type,
"title": obj.title,
"source_organization_id": obj.source_organization_id,
"target_organization_id": obj.target_organization_id,
"applicant_id": obj.applicant_id,
"approver_id": obj.approver_id,
"approval_status": obj.approval_status,
"approval_time": obj.approval_time,
"approval_remark": obj.approval_remark,
"expect_execute_date": obj.expect_execute_date,
"actual_execute_date": obj.actual_execute_date,
"executor_id": obj.executor_id,
"execute_status": obj.execute_status,
"remark": obj.remark,
"created_at": obj.created_at,
"updated_at": obj.updated_at
}
# 加载关联信息
if obj.source_organization_id:
source_org = db.query(Organization).filter(
Organization.id == obj.source_organization_id
).first()
if source_org:
result["source_organization"] = {
"id": source_org.id,
"org_name": source_org.org_name,
"org_type": source_org.org_type
}
if obj.target_organization_id:
target_org = db.query(Organization).filter(
Organization.id == obj.target_organization_id
).first()
if target_org:
result["target_organization"] = {
"id": target_org.id,
"org_name": target_org.org_name,
"org_type": target_org.org_type
}
if obj.applicant_id:
applicant = db.query(User).filter(User.id == obj.applicant_id).first()
if applicant:
result["applicant"] = {
"id": applicant.id,
"real_name": applicant.real_name,
"username": applicant.username
}
if obj.approver_id:
approver = db.query(User).filter(User.id == obj.approver_id).first()
if approver:
result["approver"] = {
"id": approver.id,
"real_name": approver.real_name,
"username": approver.username
}
if obj.executor_id:
executor = db.query(User).filter(User.id == obj.executor_id).first()
if executor:
result["executor"] = {
"id": executor.id,
"real_name": executor.real_name,
"username": executor.username
}
# 加载明细
items = allocation_item.get_by_order(db, obj.id)
result["items"] = [
{
"id": item.id,
"asset_id": item.asset_id,
"asset_code": item.asset_code,
"asset_name": item.asset_name,
"from_status": item.from_status,
"to_status": item.to_status,
"execute_status": item.execute_status,
"failure_reason": item.failure_reason
}
for item in items
]
return result
def _can_allocate(self, asset_status: str, order_type: str) -> bool:
"""判断资产是否可以分配"""
# 库存中或使用中的资产可以分配
if order_type in ["allocation", "transfer"]:
return asset_status in ["in_stock", "in_use"]
elif order_type == "recovery":
return asset_status == "in_use"
elif order_type == "maintenance":
return asset_status in ["in_stock", "in_use"]
elif order_type == "scrap":
return asset_status in ["in_stock", "in_use", "maintenance"]
return False
def _get_order_type_name(self, order_type: str) -> str:
"""获取单据类型中文名"""
type_names = {
"allocation": "分配",
"transfer": "调拨",
"recovery": "回收",
"maintenance": "维修",
"scrap": "报废"
}
return type_names.get(order_type, "操作")
async def _generate_order_code(self, db: Session, order_type: str) -> str:
"""生成分配单号"""
from datetime import datetime
import random
import string
# 单据类型前缀
prefix_map = {
"allocation": "AL",
"transfer": "TF",
"recovery": "RC",
"maintenance": "MT",
"scrap": "SC"
}
prefix = prefix_map.get(order_type, "AL")
# 日期部分
date_str = datetime.now().strftime("%Y%m%d")
# 序号部分4位随机数
sequence = "".join(random.choices(string.digits, k=4))
# 组合单号: AL202501240001
order_code = f"{prefix}{date_str}{sequence}"
# 检查是否重复,如果重复则重新生成
while allocation_order.get_by_code(db, order_code):
sequence = "".join(random.choices(string.digits, k=4))
order_code = f"{prefix}{date_str}{sequence}"
return order_code
# 创建全局实例
allocation_service = AllocationService()

View File

@@ -0,0 +1,296 @@
"""
资产管理业务服务层
"""
from typing import List, Optional, Tuple, Dict, Any
from sqlalchemy.orm import Session
from app.crud.asset import asset, asset_status_history
from app.schemas.asset import (
AssetCreate,
AssetUpdate,
AssetStatusTransition
)
from app.services.state_machine_service import state_machine_service
from app.utils.asset_code import generate_asset_code
from app.utils.qrcode import generate_qr_code, delete_qr_code
from app.core.exceptions import NotFoundException, AlreadyExistsException, StateTransitionException
class AssetService:
"""资产服务类"""
def __init__(self):
self.state_machine = state_machine_service
async def get_asset(self, db: Session, asset_id: int):
"""获取资产详情"""
obj = asset.get(db, asset_id)
if not obj:
raise NotFoundException("资产")
# 加载关联信息
return self._load_relations(db, obj)
def get_assets(
self,
db: Session,
skip: int = 0,
limit: int = 20,
keyword: Optional[str] = None,
device_type_id: Optional[int] = None,
organization_id: Optional[int] = None,
status: Optional[str] = None,
purchase_date_start: Optional[Any] = None,
purchase_date_end: Optional[Any] = None
) -> Tuple[List, int]:
"""获取资产列表"""
return asset.get_multi(
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
)
async def create_asset(
self,
db: Session,
obj_in: AssetCreate,
creator_id: int
):
"""创建资产"""
# 检查序列号是否已存在
if obj_in.serial_number:
existing = asset.get_by_serial_number(db, obj_in.serial_number)
if existing:
raise AlreadyExistsException("该序列号已被使用")
# 生成资产编码
asset_code = await generate_asset_code(db)
# 创建资产
db_obj = asset.create(db, obj_in, asset_code, creator_id)
# 生成二维码
try:
qr_code_url = generate_qr_code(asset_code)
db_obj.qr_code_url = qr_code_url
db.add(db_obj)
db.commit()
db.refresh(db_obj)
except Exception as e:
# 二维码生成失败不影响资产创建
pass
# 记录状态历史
await self._record_status_change(
db=db,
asset_id=db_obj.id,
old_status=None,
new_status="pending",
operation_type="create",
operator_id=creator_id,
operator_name=None, # 可以从用户表获取
remark="资产创建"
)
return db_obj
def update_asset(
self,
db: Session,
asset_id: int,
obj_in: AssetUpdate,
updater_id: int
):
"""更新资产"""
db_obj = asset.get(db, asset_id)
if not db_obj:
raise NotFoundException("资产")
# 如果更新序列号,检查是否重复
if obj_in.serial_number and obj_in.serial_number != db_obj.serial_number:
existing = asset.get_by_serial_number(db, obj_in.serial_number)
if existing:
raise AlreadyExistsException("该序列号已被使用")
return asset.update(db, db_obj, obj_in, updater_id)
def delete_asset(
self,
db: Session,
asset_id: int,
deleter_id: int
) -> bool:
"""删除资产"""
if not asset.get(db, asset_id):
raise NotFoundException("资产")
return asset.delete(db, asset_id, deleter_id)
async def change_asset_status(
self,
db: Session,
asset_id: int,
status_transition: AssetStatusTransition,
operator_id: int,
operator_name: Optional[str] = None
):
"""变更资产状态"""
db_obj = asset.get(db, asset_id)
if not db_obj:
raise NotFoundException("资产")
# 验证状态转换
error = self.state_machine.validate_transition(
db_obj.status,
status_transition.new_status
)
if error:
raise StateTransitionException(db_obj.status, status_transition.new_status)
# 更新状态
old_status = db_obj.status
asset.update_status(
db=db,
asset_id=asset_id,
new_status=status_transition.new_status,
updater_id=operator_id
)
# 记录状态历史
await self._record_status_change(
db=db,
asset_id=asset_id,
old_status=old_status,
new_status=status_transition.new_status,
operation_type=self._get_operation_type(old_status, status_transition.new_status),
operator_id=operator_id,
operator_name=operator_name,
remark=status_transition.remark,
extra_data=status_transition.extra_data
)
# 刷新对象
db.refresh(db_obj)
return db_obj
def get_asset_status_history(
self,
db: Session,
asset_id: int,
skip: int = 0,
limit: int = 50
) -> List:
"""获取资产状态历史"""
if not asset.get(db, asset_id):
raise NotFoundException("资产")
return asset_status_history.get_by_asset(db, asset_id, skip, limit)
def scan_asset_by_code(
self,
db: Session,
asset_code: str
):
"""扫码查询资产"""
obj = asset.get_by_code(db, asset_code)
if not obj:
raise NotFoundException("资产")
return self._load_relations(db, obj)
def get_statistics(
self,
db: Session,
organization_id: Optional[int] = None
) -> Dict[str, Any]:
"""获取资产统计信息"""
query = db.query(
func.count(Asset.id).label("total"),
func.sum(Asset.purchase_price).label("total_value")
).filter(Asset.deleted_at.is_(None))
if organization_id:
query = query.filter(Asset.organization_id == organization_id)
result = query.first()
# 按状态统计
status_query = db.query(
Asset.status,
func.count(Asset.id).label("count")
).filter(
Asset.deleted_at.is_(None)
)
if organization_id:
status_query = status_query.filter(Asset.organization_id == organization_id)
status_query = status_query.group_by(Asset.status)
status_distribution = {row.status: row.count for row in status_query.all()}
return {
"total": result.total or 0,
"total_value": float(result.total_value or 0),
"status_distribution": status_distribution
}
def _load_relations(self, db: Session, obj):
"""加载关联信息"""
# 这里可以预加载关联对象
# 例如: obj.device_type, obj.brand, obj.organization等
return obj
async def _record_status_change(
self,
db: Session,
asset_id: int,
old_status: Optional[str],
new_status: str,
operation_type: str,
operator_id: int,
operator_name: Optional[str] = None,
organization_id: Optional[int] = None,
remark: Optional[str] = None,
extra_data: Optional[Dict[str, Any]] = None
):
"""记录状态变更历史"""
asset_status_history.create(
db=db,
asset_id=asset_id,
old_status=old_status,
new_status=new_status,
operation_type=operation_type,
operator_id=operator_id,
operator_name=operator_name,
organization_id=organization_id,
remark=remark,
extra_data=extra_data
)
def _get_operation_type(self, old_status: str, new_status: str) -> str:
"""根据状态转换获取操作类型"""
operation_map = {
("pending", "in_stock"): "in_stock",
("in_stock", "in_use"): "allocate",
("in_use", "in_stock"): "recover",
("in_stock", "transferring"): "transfer",
("in_use", "transferring"): "transfer",
("transferring", "in_use"): "transfer_complete",
("in_stock", "maintenance"): "maintenance",
("in_use", "maintenance"): "maintenance",
("maintenance", "in_stock"): "maintenance_complete",
("maintenance", "in_use"): "maintenance_complete",
("in_stock", "pending_scrap"): "pending_scrap",
("in_use", "pending_scrap"): "pending_scrap",
("pending_scrap", "scrapped"): "scrap",
("pending_scrap", "in_stock"): "cancel_scrap",
}
return operation_map.get((old_status, new_status), "status_change")
# 创建全局实例
asset_service = AssetService()

View File

@@ -0,0 +1,356 @@
"""
认证服务
"""
from datetime import datetime, timedelta
from typing import Optional
from fastapi import status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import security_manager
from app.core.exceptions import (
InvalidCredentialsException,
UserLockedException,
UserDisabledException,
CaptchaException
)
from app.crud.user import user_crud
from app.models.user import User
from app.schemas.user import LoginResponse, UserInfo
from app.core.config import settings
import uuid
class AuthService:
"""认证服务类"""
def __init__(self):
self.max_login_failures = 5
self.lock_duration_minutes = 30
async def login(
self,
db: AsyncSession,
username: str,
password: str,
captcha: str,
captcha_key: str
) -> LoginResponse:
"""
用户登录
Args:
db: 数据库会话
username: 用户名
password: 密码
captcha: 验证码
captcha_key: 验证码UUID
Returns:
LoginResponse: 登录响应
Raises:
InvalidCredentialsException: 认证失败
UserLockedException: 用户被锁定
UserDisabledException: 用户被禁用
"""
# 验证验证码
import logging
logger = logging.getLogger(__name__)
logger.info(f"开始验证验证码 - captcha_key: {captcha_key}, captcha: {captcha}")
captcha_valid = await self._verify_captcha(captcha_key, captcha)
logger.info(f"验证码验证结果: {captcha_valid}")
if not captcha_valid:
logger.warning(f"验证码验证失败 - captcha_key: {captcha_key}")
raise CaptchaException()
# 获取用户
user = await user_crud.get_by_username(db, username)
if not user:
raise InvalidCredentialsException("用户名或密码错误")
# 检查用户状态
if user.status == "disabled":
raise UserDisabledException()
if user.status == "locked":
# 检查是否已过锁定时间
if user.locked_until and user.locked_until > datetime.utcnow():
raise UserLockedException(f"账户已被锁定,请在 {user.locked_until.strftime('%Y-%m-%d %H:%M:%S')} 后重试")
else:
# 解锁用户
user.status = "active"
user.locked_until = None
user.login_fail_count = 0
await db.commit()
# 验证密码
if not security_manager.verify_password(password, user.password_hash):
# 增加失败次数
user.login_fail_count += 1
# 检查是否需要锁定
if user.login_fail_count >= self.max_login_failures:
user.status = "locked"
user.locked_until = datetime.utcnow() + timedelta(minutes=self.lock_duration_minutes)
await db.commit()
if user.status == "locked":
raise UserLockedException(f"密码错误次数过多,账户已被锁定 {self.lock_duration_minutes} 分钟")
raise InvalidCredentialsException("用户名或密码错误")
# 登录成功,重置失败次数
await user_crud.update_last_login(db, user)
# 生成Token
access_token = security_manager.create_access_token(
data={"sub": str(user.id), "username": user.username}
)
refresh_token = security_manager.create_refresh_token(
data={"sub": str(user.id), "username": user.username}
)
# 获取用户角色和权限
user_info = await self._build_user_info(db, user)
return LoginResponse(
access_token=access_token,
refresh_token=refresh_token,
token_type="Bearer",
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
user=user_info
)
async def refresh_token(self, db: AsyncSession, refresh_token: str) -> dict:
"""
刷新访问令牌
Args:
db: 数据库会话
refresh_token: 刷新令牌
Returns:
dict: 包含新的访问令牌
"""
payload = security_manager.verify_token(refresh_token, token_type="refresh")
user_id = int(payload.get("sub"))
user = await user_crud.get(db, user_id)
if not user or user.status != "active":
raise InvalidCredentialsException("用户不存在或已被禁用")
# 生成新的访问令牌
access_token = security_manager.create_access_token(
data={"sub": str(user.id), "username": user.username}
)
return {
"access_token": access_token,
"expires_in": settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
}
async def change_password(
self,
db: AsyncSession,
user: User,
old_password: str,
new_password: str
) -> bool:
"""
修改密码
Args:
db: 数据库会话
user: 当前用户
old_password: 旧密码
new_password: 新密码
Returns:
bool: 是否修改成功
"""
# 验证旧密码
if not security_manager.verify_password(old_password, user.password_hash):
raise InvalidCredentialsException("旧密码错误")
# 更新密码
return await user_crud.update_password(db, user, new_password)
async def reset_password(
self,
db: AsyncSession,
user_id: int,
new_password: str
) -> bool:
"""
重置用户密码(管理员功能)
Args:
db: 数据库会话
user_id: 用户ID
new_password: 新密码
Returns:
bool: 是否重置成功
"""
user = await user_crud.get(db, user_id)
if not user:
return False
return await user_crud.update_password(db, user, new_password)
async def _generate_captcha(self) -> dict:
"""
生成验证码
Returns:
包含captcha_key和captcha_base64的字典
"""
import logging
logger = logging.getLogger(__name__)
from app.utils.redis_client import redis_client
import random
import string
import base64
from io import BytesIO
from PIL import Image, ImageDraw, ImageFont
try:
# 生成4位随机验证码使用更清晰的字符组合排除易混淆的字符
captcha_text = ''.join(random.choices('23456789', k=4)) # 排除0、1
logger.info(f"生成验证码: {captcha_text}")
# 生成验证码图片
width, height = 200, 80 # 增大图片尺寸
# 使用浅色背景而不是纯白
background_color = (245, 245, 250) # 浅蓝灰色
image = Image.new('RGB', (width, height), color=background_color)
draw = ImageDraw.Draw(image)
# 尝试使用更大的字体
try:
# 优先使用系统大字体
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 48)
except:
try:
font = ImageFont.truetype("arial.ttf", 48) # 增大到48
except:
font = ImageFont.load_default()
# 如果使用默认字体,尝试放大
font = font.font_variant(size=48)
# 绘制验证码
draw.text((10, 5), captcha_text, fill='black', font=font)
# 减少干扰线数量从5条减少到3条
for _ in range(3):
x1 = random.randint(0, width)
y1 = random.randint(0, height)
x2 = random.randint(0, width)
y2 = random.randint(0, height)
draw.line([(x1, y1), (x2, y2)], fill='gray', width=1)
# 添加噪点(可选)
for _ in range(50):
x = random.randint(0, width - 1)
y = random.randint(0, height - 1)
draw.point((x, y), fill='lightgray')
# 转换为base64
buffer = BytesIO()
image.save(buffer, format='PNG')
image_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
# 生成captcha_key
captcha_key = ''.join(random.choices(string.ascii_letters + string.digits, k=32))
logger.info(f"生成验证码Key: {captcha_key}")
# 存储到Redis5分钟过期
await redis_client.setex(
f"captcha:{captcha_key}",
300,
captcha_text
)
logger.info(f"验证码已存储到Redis: captcha:{captcha_key}, 值: {captcha_text}")
return {
"captcha_key": captcha_key,
"captcha_base64": f"data:image/png;base64,{image_base64}"
}
except Exception as e:
logger.error(f"生成验证码失败: {str(e)}", exc_info=True)
raise
async def _verify_captcha(self, captcha_key: str, captcha: str) -> bool:
"""
验证验证码
Args:
captcha_key: 验证码密钥
captcha: 用户输入的验证码
Returns:
验证是否成功
"""
import logging
logger = logging.getLogger(__name__)
from app.utils.redis_client import redis_client
try:
# 从Redis获取存储的验证码
stored_captcha = await redis_client.get(f"captcha:{captcha_key}")
logger.info(f"Redis中存储的验证码: {stored_captcha}, 用户输入: {captcha}")
if not stored_captcha:
logger.warning(f"验证码已过期或不存在 - captcha_key: {captcha_key}")
return False
# 验证码不区分大小写
is_valid = stored_captcha.lower() == captcha.lower()
logger.info(f"验证码匹配结果: {is_valid}")
return is_valid
except Exception as e:
logger.error(f"验证码验证异常: {str(e)}", exc_info=True)
return False
async def _build_user_info(self, db: AsyncSession, user: User) -> UserInfo:
"""
构建用户信息
Args:
db: 数据库会话
user: 用户对象
Returns:
UserInfo: 用户信息
"""
# 获取用户角色代码列表
role_codes = [role.role_code for role in user.roles]
# 获取用户权限代码列表
permissions = []
for role in user.roles:
for perm in role.permissions:
permissions.append(perm.permission_code)
# 如果是超级管理员,给予所有权限
if user.is_admin:
permissions = ["*:*:*"]
return UserInfo(
id=user.id,
username=user.username,
real_name=user.real_name,
email=user.email,
avatar_url=user.avatar_url,
is_admin=user.is_admin,
status=user.status
)
# 创建服务实例
auth_service = AuthService()

View File

@@ -0,0 +1,134 @@
"""
品牌和供应商业务服务层
"""
from typing import List, Optional, Tuple
from sqlalchemy.orm import Session
from app.crud.brand_supplier import brand, supplier
from app.schemas.brand_supplier import (
BrandCreate,
BrandUpdate,
SupplierCreate,
SupplierUpdate
)
from app.core.exceptions import NotFoundException, AlreadyExistsException
class BrandService:
"""品牌服务类"""
def get_brand(self, db: Session, brand_id: int):
"""获取品牌详情"""
obj = brand.get(db, brand_id)
if not obj:
raise NotFoundException("品牌")
return obj
def get_brands(
self,
db: Session,
skip: int = 0,
limit: int = 20,
status: Optional[str] = None,
keyword: Optional[str] = None
) -> Tuple[List, int]:
"""获取品牌列表"""
return brand.get_multi(db, skip, limit, status, keyword)
def create_brand(
self,
db: Session,
obj_in: BrandCreate,
creator_id: Optional[int] = None
):
"""创建品牌"""
try:
return brand.create(db, obj_in, creator_id)
except ValueError as e:
raise AlreadyExistsException("品牌") from e
def update_brand(
self,
db: Session,
brand_id: int,
obj_in: BrandUpdate,
updater_id: Optional[int] = None
):
"""更新品牌"""
db_obj = brand.get(db, brand_id)
if not db_obj:
raise NotFoundException("品牌")
return brand.update(db, db_obj, obj_in, updater_id)
def delete_brand(
self,
db: Session,
brand_id: int,
deleter_id: Optional[int] = None
) -> bool:
"""删除品牌"""
if not brand.get(db, brand_id):
raise NotFoundException("品牌")
return brand.delete(db, brand_id, deleter_id)
class SupplierService:
"""供应商服务类"""
def get_supplier(self, db: Session, supplier_id: int):
"""获取供应商详情"""
obj = supplier.get(db, supplier_id)
if not obj:
raise NotFoundException("供应商")
return obj
def get_suppliers(
self,
db: Session,
skip: int = 0,
limit: int = 20,
status: Optional[str] = None,
keyword: Optional[str] = None
) -> Tuple[List, int]:
"""获取供应商列表"""
return supplier.get_multi(db, skip, limit, status, keyword)
def create_supplier(
self,
db: Session,
obj_in: SupplierCreate,
creator_id: Optional[int] = None
):
"""创建供应商"""
try:
return supplier.create(db, obj_in, creator_id)
except ValueError as e:
raise AlreadyExistsException("供应商") from e
def update_supplier(
self,
db: Session,
supplier_id: int,
obj_in: SupplierUpdate,
updater_id: Optional[int] = None
):
"""更新供应商"""
db_obj = supplier.get(db, supplier_id)
if not db_obj:
raise NotFoundException("供应商")
return supplier.update(db, db_obj, obj_in, updater_id)
def delete_supplier(
self,
db: Session,
supplier_id: int,
deleter_id: Optional[int] = None
) -> bool:
"""删除供应商"""
if not supplier.get(db, supplier_id):
raise NotFoundException("供应商")
return supplier.delete(db, supplier_id, deleter_id)
# 创建全局实例
brand_service = BrandService()
supplier_service = SupplierService()

View File

@@ -0,0 +1,286 @@
"""
设备类型业务服务层
"""
from typing import List, Optional, Tuple
from sqlalchemy.orm import Session
from app.crud.device_type import device_type, device_type_field
from app.schemas.device_type import (
DeviceTypeCreate,
DeviceTypeUpdate,
DeviceTypeFieldCreate,
DeviceTypeFieldUpdate
)
from app.core.exceptions import NotFoundException, AlreadyExistsException
class DeviceTypeService:
"""设备类型服务类"""
def get_device_type(self, db: Session, device_type_id: int, include_fields: bool = False):
"""
获取设备类型详情
Args:
db: 数据库会话
device_type_id: 设备类型ID
include_fields: 是否包含字段列表
Returns:
设备类型对象
Raises:
NotFoundException: 设备类型不存在
"""
obj = device_type.get(db, device_type_id)
if not obj:
raise NotFoundException("设备类型")
# 计算字段数量
field_count = device_type_field.get_by_device_type(db, device_type_id)
obj.field_count = len(field_count)
return obj
def get_device_types(
self,
db: Session,
skip: int = 0,
limit: int = 20,
category: Optional[str] = None,
status: Optional[str] = None,
keyword: Optional[str] = None
) -> Tuple[List, int]:
"""
获取设备类型列表
Args:
db: 数据库会话
skip: 跳过条数
limit: 返回条数
category: 设备分类
status: 状态
keyword: 搜索关键词
Returns:
(设备类型列表, 总数)
"""
items, total = device_type.get_multi(
db=db,
skip=skip,
limit=limit,
category=category,
status=status,
keyword=keyword
)
# 为每个项目添加字段数量
for item in items:
fields = device_type_field.get_by_device_type(db, item.id)
item.field_count = len(fields)
return items, total
def create_device_type(
self,
db: Session,
obj_in: DeviceTypeCreate,
creator_id: Optional[int] = None
):
"""
创建设备类型
Args:
db: 数据库会话
obj_in: 创建数据
creator_id: 创建人ID
Returns:
创建的设备类型对象
Raises:
AlreadyExistsException: 设备类型代码已存在
"""
try:
return device_type.create(db, obj_in, creator_id)
except ValueError as e:
raise AlreadyExistsException("设备类型") from e
def update_device_type(
self,
db: Session,
device_type_id: int,
obj_in: DeviceTypeUpdate,
updater_id: Optional[int] = None
):
"""
更新设备类型
Args:
db: 数据库会话
device_type_id: 设备类型ID
obj_in: 更新数据
updater_id: 更新人ID
Returns:
更新后的设备类型对象
Raises:
NotFoundException: 设备类型不存在
"""
db_obj = device_type.get(db, device_type_id)
if not db_obj:
raise NotFoundException("设备类型")
return device_type.update(db, db_obj, obj_in, updater_id)
def delete_device_type(
self,
db: Session,
device_type_id: int,
deleter_id: Optional[int] = None
) -> bool:
"""
删除设备类型
Args:
db: 数据库会话
device_type_id: 设备类型ID
deleter_id: 删除人ID
Returns:
是否删除成功
Raises:
NotFoundException: 设备类型不存在
"""
if not device_type.get(db, device_type_id):
raise NotFoundException("设备类型")
return device_type.delete(db, device_type_id, deleter_id)
def get_device_type_fields(
self,
db: Session,
device_type_id: int,
status: Optional[str] = None
) -> List:
"""
获取设备类型的字段列表
Args:
db: 数据库会话
device_type_id: 设备类型ID
status: 状态筛选
Returns:
字段列表
Raises:
NotFoundException: 设备类型不存在
"""
# 验证设备类型存在
if not device_type.get(db, device_type_id):
raise NotFoundException("设备类型")
return device_type_field.get_by_device_type(db, device_type_id, status)
def create_device_type_field(
self,
db: Session,
device_type_id: int,
obj_in: DeviceTypeFieldCreate,
creator_id: Optional[int] = None
):
"""
创建设备类型字段
Args:
db: 数据库会话
device_type_id: 设备类型ID
obj_in: 创建数据
creator_id: 创建人ID
Returns:
创建的字段对象
Raises:
NotFoundException: 设备类型不存在
AlreadyExistsException: 字段代码已存在
"""
# 验证设备类型存在
if not device_type.get(db, device_type_id):
raise NotFoundException("设备类型")
try:
return device_type_field.create(db, obj_in, device_type_id, creator_id)
except ValueError as e:
raise AlreadyExistsException("字段") from e
def update_device_type_field(
self,
db: Session,
field_id: int,
obj_in: DeviceTypeFieldUpdate,
updater_id: Optional[int] = None
):
"""
更新设备类型字段
Args:
db: 数据库会话
field_id: 字段ID
obj_in: 更新数据
updater_id: 更新人ID
Returns:
更新后的字段对象
Raises:
NotFoundException: 字段不存在
"""
db_obj = device_type_field.get(db, field_id)
if not db_obj:
raise NotFoundException("字段")
return device_type_field.update(db, db_obj, obj_in, updater_id)
def delete_device_type_field(
self,
db: Session,
field_id: int,
deleter_id: Optional[int] = None
) -> bool:
"""
删除设备类型字段
Args:
db: 数据库会话
field_id: 字段ID
deleter_id: 删除人ID
Returns:
是否删除成功
Raises:
NotFoundException: 字段不存在
"""
if not device_type_field.get(db, field_id):
raise NotFoundException("字段")
return device_type_field.delete(db, field_id, deleter_id)
def get_all_categories(self, db: Session) -> List[str]:
"""
获取所有设备分类
Args:
db: 数据库会话
Returns:
设备分类列表
"""
return device_type.get_all_categories(db)
# 创建全局实例
device_type_service = DeviceTypeService()

View File

@@ -0,0 +1,508 @@
"""
文件存储服务
"""
import os
import uuid
import secrets
import mimetypes
from typing import Optional, Dict, Any, List, Tuple
from pathlib import Path
from datetime import datetime, timedelta
from fastapi import UploadFile, HTTPException, status
from sqlalchemy.orm import Session
from PIL import Image
import io
from app.models.file_management import UploadedFile
from app.schemas.file_management import (
UploadedFileCreate,
FileUploadResponse,
FileShareResponse,
FileStatistics
)
from app.crud.file_management import uploaded_file as crud_uploaded_file
class FileService:
"""文件存储服务"""
# 允许的文件类型白名单
ALLOWED_MIME_TYPES = {
# 图片
'image/jpeg', 'image/png', 'image/gif', 'image/bmp', 'image/webp', 'image/svg+xml',
# 文档
'application/pdf', 'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'text/plain', 'text/csv',
# 压缩包
'application/zip', 'application/x-rar-compressed', 'application/x-7z-compressed',
# 其他
'application/json', 'application/xml', 'text/xml'
}
# 文件大小限制(字节)- 默认100MB
MAX_FILE_SIZE = 100 * 1024 * 1024
# 图片文件大小限制 - 默认10MB
MAX_IMAGE_SIZE = 10 * 1024 * 1024
# Magic Numbers for file validation
MAGIC_NUMBERS = {
b'\xFF\xD8\xFF': 'image/jpeg',
b'\x89\x50\x4E\x47\x0D\x0A\x1A\x0A': 'image/png',
b'GIF87a': 'image/gif',
b'GIF89a': 'image/gif',
b'%PDF': 'application/pdf',
b'PK\x03\x04': 'application/zip',
}
def __init__(self, base_upload_dir: str = "uploads"):
self.base_upload_dir = Path(base_upload_dir)
self.ensure_upload_dirs()
def ensure_upload_dirs(self):
"""确保上传目录存在"""
directories = [
self.base_upload_dir,
self.base_upload_dir / "images",
self.base_upload_dir / "documents",
self.base_upload_dir / "thumbnails",
self.base_upload_dir / "temp",
]
for directory in directories:
directory.mkdir(parents=True, exist_ok=True)
def validate_file_type(self, file: UploadFile) -> bool:
"""验证文件类型"""
# 检查MIME类型
if file.content_type not in self.ALLOWED_MIME_TYPES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"不支持的文件类型: {file.content_type}"
)
return True
def validate_file_size(self, file: UploadFile) -> bool:
"""验证文件大小"""
# 先检查是否是图片
if file.content_type and file.content_type.startswith('image/'):
max_size = self.MAX_IMAGE_SIZE
else:
max_size = self.MAX_FILE_SIZE
# 读取文件内容检查大小
content = file.file.read()
file.file.seek(0) # 重置文件指针
if len(content) > max_size:
# 转换为MB
size_mb = max_size / (1024 * 1024)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"文件大小超过限制: {size_mb:.0f}MB"
)
return True
def validate_file_content(self, content: bytes) -> str:
"""验证文件内容Magic Number"""
for magic, mime_type in self.MAGIC_NUMBERS.items():
if content.startswith(magic):
return mime_type
return None
async def upload_file(
self,
db: Session,
file: UploadFile,
uploader_id: int,
remark: Optional[str] = None
) -> UploadedFile:
"""
上传文件
Args:
db: 数据库会话
file: 上传的文件
uploader_id: 上传者ID
remark: 备注
Returns:
UploadedFile: 创建的文件记录
"""
# 验证文件类型
self.validate_file_type(file)
# 验证文件大小
self.validate_file_size(file)
# 读取文件内容
content = await file.read()
# 验证文件内容
detected_mime = self.validate_file_content(content)
if detected_mime and detected_mime != file.content_type:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"文件内容与扩展名不匹配"
)
# 生成文件名
file_ext = self.get_file_extension(file.filename)
unique_filename = f"{uuid.uuid4()}{file_ext}"
# 确定存储路径
upload_date = datetime.utcnow()
date_dir = upload_date.strftime("%Y/%m/%d")
save_dir = self.base_upload_dir / date_dir
save_dir.mkdir(parents=True, exist_ok=True)
file_path = save_dir / unique_filename
# 保存文件
with open(file_path, "wb") as f:
f.write(content)
# 生成缩略图(如果是图片)
thumbnail_path = None
if file.content_type and file.content_type.startswith('image/'):
thumbnail_path = self.generate_thumbnail(content, unique_filename, date_dir)
# 创建数据库记录
file_create = UploadedFileCreate(
file_name=unique_filename,
original_name=file.filename,
file_path=str(file_path),
file_size=len(content),
file_type=file.content_type,
file_ext=file_ext.lstrip('.'),
uploader_id=uploader_id
)
db_obj = crud_uploaded_file.create(db, obj_in=file_create.dict())
# 更新缩略图路径
if thumbnail_path:
crud_uploaded_file.update(db, db_obj=db_obj, obj_in={"thumbnail_path": thumbnail_path})
# 模拟病毒扫描
self._scan_virus(file_path)
return db_obj
def generate_thumbnail(
self,
content: bytes,
filename: str,
date_dir: str
) -> Optional[str]:
"""生成缩略图"""
try:
# 打开图片
image = Image.open(io.BytesIO(content))
# 转换为RGB如果是RGBA
if image.mode in ('RGBA', 'P'):
image = image.convert('RGB')
# 创建缩略图
thumbnail_size = (200, 200)
image.thumbnail(thumbnail_size, Image.Resampling.LANCZOS)
# 保存缩略图
thumbnail_dir = self.base_upload_dir / "thumbnails" / date_dir
thumbnail_dir.mkdir(parents=True, exist_ok=True)
thumbnail_name = f"thumb_{filename}"
thumbnail_path = thumbnail_dir / thumbnail_name
image.save(thumbnail_path, 'JPEG', quality=85)
return str(thumbnail_path)
except Exception as e:
print(f"生成缩略图失败: {e}")
return None
def get_file_path(self, file_obj: UploadedFile) -> Path:
"""获取文件路径"""
return Path(file_obj.file_path)
def file_exists(self, file_obj: UploadedFile) -> bool:
"""检查文件是否存在"""
file_path = self.get_file_path(file_obj)
return file_path.exists() and file_path.is_file()
def delete_file_from_disk(self, file_obj: UploadedFile) -> bool:
"""从磁盘删除文件"""
try:
file_path = self.get_file_path(file_obj)
if file_path.exists():
file_path.unlink()
# 删除缩略图
if file_obj.thumbnail_path:
thumbnail_path = Path(file_obj.thumbnail_path)
if thumbnail_path.exists():
thumbnail_path.unlink()
return True
except Exception as e:
print(f"删除文件失败: {e}")
return False
def generate_share_link(
self,
db: Session,
file_id: int,
expire_days: int = 7,
base_url: str = "http://localhost:8000"
) -> FileShareResponse:
"""
生成分享链接
Args:
db: 数据库会话
file_id: 文件ID
expire_days: 有效期(天)
base_url: 基础URL
Returns:
FileShareResponse: 分享链接信息
"""
# 生成分享码
share_code = crud_uploaded_file.generate_share_code(
db,
file_id=file_id,
expire_days=expire_days
)
if not share_code:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="文件不存在"
)
# 获取文件信息
file_obj = crud_uploaded_file.get(db, file_id)
expire_time = file_obj.share_expire_time
# 生成分享URL
share_url = f"{base_url}/api/v1/files/share/{share_code}"
return FileShareResponse(
share_code=share_code,
share_url=share_url,
expire_time=expire_time
)
def get_shared_file(self, db: Session, share_code: str) -> Optional[UploadedFile]:
"""通过分享码获取文件"""
return crud_uploaded_file.get_by_share_code(db, share_code)
def get_statistics(
self,
db: Session,
uploader_id: Optional[int] = None
) -> FileStatistics:
"""获取文件统计信息"""
stats = crud_uploaded_file.get_statistics(db, uploader_id=uploader_id)
return FileStatistics(**stats)
@staticmethod
def get_file_extension(filename: str) -> str:
"""获取文件扩展名"""
return os.path.splitext(filename)[1]
@staticmethod
def get_mime_type(filename: str) -> str:
"""获取MIME类型"""
mime_type, _ = mimetypes.guess_type(filename)
return mime_type or 'application/octet-stream'
@staticmethod
def _scan_virus(file_path: Path) -> bool:
"""
模拟病毒扫描
实际生产环境应集成专业杀毒软件如:
- ClamAV
- VirusTotal API
- Windows Defender
"""
# 模拟扫描
import time
time.sleep(0.1) # 模拟扫描时间
return True # 假设文件安全
# 分片上传管理
class ChunkUploadManager:
"""分片上传管理器"""
def __init__(self):
self.uploads: Dict[str, Dict[str, Any]] = {}
def init_upload(
self,
file_name: str,
file_size: int,
file_type: str,
total_chunks: int,
file_hash: Optional[str] = None
) -> str:
"""初始化分片上传"""
upload_id = str(uuid.uuid4())
self.uploads[upload_id] = {
"file_name": file_name,
"file_size": file_size,
"file_type": file_type,
"total_chunks": total_chunks,
"file_hash": file_hash,
"uploaded_chunks": [],
"created_at": datetime.utcnow()
}
return upload_id
def save_chunk(
self,
upload_id: str,
chunk_index: int,
chunk_data: bytes
) -> bool:
"""保存分片"""
if upload_id not in self.uploads:
return False
upload_info = self.uploads[upload_id]
# 保存分片到临时文件
temp_dir = Path("uploads/temp")
temp_dir.mkdir(parents=True, exist_ok=True)
chunk_filename = f"{upload_id}_chunk_{chunk_index}"
chunk_path = temp_dir / chunk_filename
with open(chunk_path, "wb") as f:
f.write(chunk_data)
# 记录已上传的分片
if chunk_index not in upload_info["uploaded_chunks"]:
upload_info["uploaded_chunks"].append(chunk_index)
return True
def is_complete(self, upload_id: str) -> bool:
"""检查是否所有分片都已上传"""
if upload_id not in self.uploads:
return False
upload_info = self.uploads[upload_id]
return len(upload_info["uploaded_chunks"]) == upload_info["total_chunks"]
def merge_chunks(
self,
db: Session,
upload_id: str,
uploader_id: int,
file_service: FileService
) -> UploadedFile:
"""合并分片"""
if upload_id not in self.uploads:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="上传会话不存在"
)
if not self.is_complete(upload_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="分片未全部上传"
)
upload_info = self.uploads[upload_id]
# 合并分片
temp_dir = Path("uploads/temp")
merged_content = b""
for i in range(upload_info["total_chunks"]):
chunk_filename = f"{upload_id}_chunk_{i}"
chunk_path = temp_dir / chunk_filename
if not chunk_path.exists():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"分片 {i} 不存在"
)
with open(chunk_path, "rb") as f:
merged_content += f.read()
# 验证文件大小
if len(merged_content) != upload_info["file_size"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="文件大小不匹配"
)
# 验证文件哈希(如果提供)
if upload_info["file_hash"]:
import hashlib
file_hash = hashlib.md5(merged_content).hexdigest()
if file_hash != upload_info["file_hash"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="文件哈希不匹配"
)
# 保存文件
file_ext = Path(upload_info["file_name"]).suffix
unique_filename = f"{uuid.uuid4()}{file_ext}"
upload_date = datetime.utcnow()
date_dir = upload_date.strftime("%Y/%m/%d")
save_dir = Path("uploads") / date_dir
save_dir.mkdir(parents=True, exist_ok=True)
file_path = save_dir / unique_filename
with open(file_path, "wb") as f:
f.write(merged_content)
# 清理临时文件
self.cleanup_upload(upload_id)
# 创建数据库记录
from app.schemas.file_management import UploadedFileCreate
file_create = UploadedFileCreate(
file_name=unique_filename,
original_name=upload_info["file_name"],
file_path=str(file_path),
file_size=upload_info["file_size"],
file_type=upload_info["file_type"],
file_ext=file_ext.lstrip('.'),
uploader_id=uploader_id
)
db_obj = crud_uploaded_file.create(db, obj_in=file_create.dict())
return db_obj
def cleanup_upload(self, upload_id: str):
"""清理上传会话"""
if upload_id in self.uploads:
del self.uploads[upload_id]
# 清理临时分片文件
temp_dir = Path("uploads/temp")
for chunk_file in temp_dir.glob(f"{upload_id}_chunk_*"):
chunk_file.unlink()
# 创建服务实例
file_service = FileService()
chunk_upload_manager = ChunkUploadManager()

View File

@@ -0,0 +1,403 @@
"""
维修管理业务服务层
"""
from typing import List, Optional, Dict, Any
from sqlalchemy.orm import Session, selectinload
from app.crud.maintenance import maintenance_record
from app.crud.asset import asset
from app.schemas.maintenance import (
MaintenanceRecordCreate,
MaintenanceRecordUpdate,
MaintenanceRecordStart,
MaintenanceRecordComplete
)
from app.core.exceptions import NotFoundException, BusinessException
class MaintenanceService:
"""维修管理服务类"""
async def get_record(
self,
db: Session,
record_id: int
) -> Dict[str, Any]:
"""获取维修记录详情"""
# 使用selectinload预加载关联数据避免N+1查询
from app.models.maintenance import MaintenanceRecord
from app.models.asset import Asset
from app.models.user import User
from app.models.brand_supplier import Supplier
obj = db.query(
MaintenanceRecord
).options(
selectinload(MaintenanceRecord.asset.of_type(Asset)),
selectinload(MaintenanceRecord.report_user.of_type(User)),
selectinload(MaintenanceRecord.maintenance_user.of_type(User)),
selectinload(MaintenanceRecord.vendor.of_type(Supplier))
).filter(
MaintenanceRecord.id == record_id
).first()
if not obj:
raise NotFoundException("维修记录")
return self._load_relations(db, obj)
def get_records(
self,
db: Session,
skip: int = 0,
limit: int = 20,
asset_id: Optional[int] = None,
status: Optional[str] = None,
fault_type: Optional[str] = None,
priority: Optional[str] = None,
maintenance_type: Optional[str] = None,
keyword: Optional[str] = None
) -> tuple:
"""获取维修记录列表"""
items, total = maintenance_record.get_multi(
db=db,
skip=skip,
limit=limit,
asset_id=asset_id,
status=status,
fault_type=fault_type,
priority=priority,
maintenance_type=maintenance_type,
keyword=keyword
)
# 加载关联信息
items_with_relations = [self._load_relations(db, item) for item in items]
return items_with_relations, total
async def create_record(
self,
db: Session,
obj_in: MaintenanceRecordCreate,
report_user_id: int,
creator_id: int
):
"""创建维修记录"""
# 验证资产存在
asset_obj = asset.get(db, obj_in.asset_id)
if not asset_obj:
raise NotFoundException("资产")
# 生成维修单号
record_code = await self._generate_record_code(db)
# 创建维修记录
db_obj = maintenance_record.create(
db=db,
obj_in=obj_in,
record_code=record_code,
asset_code=asset_obj.asset_code,
report_user_id=report_user_id,
creator_id=creator_id
)
# 如果资产状态不是维修中,则更新状态
if asset_obj.status != "maintenance":
from app.services.asset_service import asset_service
from app.schemas.asset import AssetStatusTransition
try:
await asset_service.change_asset_status(
db=db,
asset_id=asset_obj.id,
status_transition=AssetStatusTransition(
new_status="maintenance",
remark=f"报修: {record_code}"
),
operator_id=report_user_id
)
except Exception as e:
# 状态更新失败不影响维修记录创建
pass
return self._load_relations(db, db_obj)
def update_record(
self,
db: Session,
record_id: int,
obj_in: MaintenanceRecordUpdate,
updater_id: int
):
"""更新维修记录"""
db_obj = maintenance_record.get(db, record_id)
if not db_obj:
raise NotFoundException("维修记录")
# 已完成的维修记录不能更新
if db_obj.status == "completed":
raise BusinessException("已完成的维修记录不能更新")
return maintenance_record.update(db, db_obj, obj_in, updater_id)
async def start_maintenance(
self,
db: Session,
record_id: int,
start_in: MaintenanceRecordStart,
maintenance_user_id: int
):
"""开始维修"""
db_obj = maintenance_record.get(db, record_id)
if not db_obj:
raise NotFoundException("维修记录")
# 检查状态
if db_obj.status != "pending":
raise BusinessException("只有待处理状态的维修记录可以开始维修")
# 验证维修类型
if start_in.maintenance_type == "vendor_repair" and not start_in.vendor_id:
raise BusinessException("外部维修必须指定维修供应商")
# 开始维修
db_obj = maintenance_record.start_maintenance(
db=db,
db_obj=db_obj,
maintenance_type=start_in.maintenance_type,
maintenance_user_id=maintenance_user_id,
vendor_id=start_in.vendor_id
)
return self._load_relations(db, db_obj)
async def complete_maintenance(
self,
db: Session,
record_id: int,
complete_in: MaintenanceRecordComplete,
maintenance_user_id: int
):
"""完成维修"""
db_obj = maintenance_record.get(db, record_id)
if not db_obj:
raise NotFoundException("维修记录")
# 检查状态
if db_obj.status != "in_progress":
raise BusinessException("只有维修中的记录可以完成")
# 完成维修
db_obj = maintenance_record.complete_maintenance(
db=db,
db_obj=db_obj,
maintenance_result=complete_in.maintenance_result,
maintenance_cost=complete_in.maintenance_cost,
replaced_parts=complete_in.replaced_parts,
images=complete_in.images
)
# 恢复资产状态
from app.services.asset_service import asset_service
from app.schemas.asset import AssetStatusTransition
try:
await asset_service.change_asset_status(
db=db,
asset_id=db_obj.asset_id,
status_transition=AssetStatusTransition(
new_status=complete_in.asset_status,
remark=f"维修完成: {db_obj.record_code}"
),
operator_id=maintenance_user_id
)
except Exception as e:
# 状态更新失败不影响维修记录完成
pass
return self._load_relations(db, db_obj)
def cancel_maintenance(
self,
db: Session,
record_id: int
):
"""取消维修"""
db_obj = maintenance_record.get(db, record_id)
if not db_obj:
raise NotFoundException("维修记录")
# 检查状态
if db_obj.status == "completed":
raise BusinessException("已完成的维修记录不能取消")
# 取消维修
db_obj = maintenance_record.cancel_maintenance(db, db_obj)
# 恢复资产状态
asset_obj = asset.get(db, db_obj.asset_id)
if asset_obj and asset_obj.status == "maintenance":
from app.services.asset_service import asset_service
from app.schemas.asset import AssetStatusTransition
try:
# 根据维修前的状态恢复
target_status = "in_stock" # 默认恢复为库存中
asset_service.change_asset_status(
db=db,
asset_id=asset_obj.id,
status_transition=AssetStatusTransition(
new_status=target_status,
remark=f"取消维修: {db_obj.record_code}"
),
operator_id=db_obj.report_user_id or 0
)
except Exception as e:
# 状态更新失败不影响维修记录取消
pass
return self._load_relations(db, db_obj)
def delete_record(
self,
db: Session,
record_id: int
) -> bool:
"""删除维修记录"""
db_obj = maintenance_record.get(db, record_id)
if not db_obj:
raise NotFoundException("维修记录")
# 只能删除待处理或已取消的记录
if db_obj.status not in ["pending", "cancelled"]:
raise BusinessException("只能删除待处理或已取消的维修记录")
return maintenance_record.delete(db, record_id)
def get_asset_records(
self,
db: Session,
asset_id: int,
skip: int = 0,
limit: int = 50
) -> List:
"""获取资产的维修记录"""
# 验证资产存在
if not asset.get(db, asset_id):
raise NotFoundException("资产")
records = maintenance_record.get_by_asset(db, asset_id, skip, limit)
return [self._load_relations(db, record) for record in records]
def get_statistics(
self,
db: Session,
asset_id: Optional[int] = None
) -> Dict[str, Any]:
"""获取维修统计信息"""
return maintenance_record.get_statistics(db, asset_id)
def _load_relations(
self,
db: Session,
obj
) -> Dict[str, Any]:
"""加载维修记录关联信息"""
from app.models.asset import Asset
from app.models.user import User
from app.models.brand_supplier import Supplier
result = {
"id": obj.id,
"record_code": obj.record_code,
"asset_id": obj.asset_id,
"asset_code": obj.asset_code,
"fault_description": obj.fault_description,
"fault_type": obj.fault_type,
"report_user_id": obj.report_user_id,
"report_time": obj.report_time,
"priority": obj.priority,
"maintenance_type": obj.maintenance_type,
"vendor_id": obj.vendor_id,
"maintenance_cost": float(obj.maintenance_cost) if obj.maintenance_cost else None,
"start_time": obj.start_time,
"complete_time": obj.complete_time,
"maintenance_user_id": obj.maintenance_user_id,
"maintenance_result": obj.maintenance_result,
"replaced_parts": obj.replaced_parts,
"status": obj.status,
"images": obj.images,
"remark": obj.remark,
"created_at": obj.created_at,
"updated_at": obj.updated_at
}
# 加载资产信息
if obj.asset_id:
asset_obj = db.query(Asset).filter(Asset.id == obj.asset_id).first()
if asset_obj:
result["asset"] = {
"id": asset_obj.id,
"asset_code": asset_obj.asset_code,
"asset_name": asset_obj.asset_name,
"status": asset_obj.status
}
# 加载报修人信息
if obj.report_user_id:
report_user = db.query(User).filter(User.id == obj.report_user_id).first()
if report_user:
result["report_user"] = {
"id": report_user.id,
"real_name": report_user.real_name,
"username": report_user.username
}
# 加载维修人员信息
if obj.maintenance_user_id:
maintenance_user = db.query(User).filter(User.id == obj.maintenance_user_id).first()
if maintenance_user:
result["maintenance_user"] = {
"id": maintenance_user.id,
"real_name": maintenance_user.real_name,
"username": maintenance_user.username
}
# 加载供应商信息
if obj.vendor_id:
vendor = db.query(Supplier).filter(Supplier.id == obj.vendor_id).first()
if vendor:
result["vendor"] = {
"id": vendor.id,
"supplier_name": vendor.supplier_name,
"contact_person": vendor.contact_person,
"contact_phone": vendor.contact_phone
}
return result
async def _generate_record_code(self, db: Session) -> str:
"""生成维修单号"""
from datetime import datetime
import random
import string
# 日期部分
date_str = datetime.now().strftime("%Y%m%d")
# 序号部分4位随机数
sequence = "".join(random.choices(string.digits, k=4))
# 组合单号: MT202501240001
record_code = f"MT{date_str}{sequence}"
# 检查是否重复,如果重复则重新生成
while maintenance_record.get_by_code(db, record_code):
sequence = "".join(random.choices(string.digits, k=4))
record_code = f"MT{date_str}{sequence}"
return record_code
# 创建全局实例
maintenance_service = MaintenanceService()

View File

@@ -0,0 +1,402 @@
"""
消息通知服务层
"""
from typing import Optional, List, Dict, Any
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.crud.notification import notification_crud
from app.models.notification import NotificationTemplate
from app.models.user import User
from app.schemas.notification import (
NotificationCreate,
NotificationBatchCreate,
NotificationSendFromTemplate
)
import json
class NotificationService:
"""消息通知服务类"""
async def get_notification(self, db: AsyncSession, notification_id: int) -> Optional[Dict[str, Any]]:
"""
获取消息通知详情
Args:
db: 数据库会话
notification_id: 通知ID
Returns:
通知信息
"""
notification = await notification_crud.get(db, notification_id)
if not notification:
return None
return {
"id": notification.id,
"recipient_id": notification.recipient_id,
"recipient_name": notification.recipient_name,
"title": notification.title,
"content": notification.content,
"notification_type": notification.notification_type,
"priority": notification.priority,
"is_read": notification.is_read,
"read_at": notification.read_at,
"related_entity_type": notification.related_entity_type,
"related_entity_id": notification.related_entity_id,
"action_url": notification.action_url,
"extra_data": notification.extra_data,
"sent_via_email": notification.sent_via_email,
"sent_via_sms": notification.sent_via_sms,
"created_at": notification.created_at,
"expire_at": notification.expire_at,
}
async def get_notifications(
self,
db: AsyncSession,
*,
skip: int = 0,
limit: int = 20,
recipient_id: Optional[int] = None,
notification_type: Optional[str] = None,
priority: Optional[str] = None,
is_read: Optional[bool] = None,
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None,
keyword: Optional[str] = None
) -> Dict[str, Any]:
"""
获取消息通知列表
Args:
db: 数据库会话
skip: 跳过条数
limit: 返回条数
recipient_id: 接收人ID
notification_type: 通知类型
priority: 优先级
is_read: 是否已读
start_time: 开始时间
end_time: 结束时间
keyword: 关键词
Returns:
通知列表和总数
"""
items, total = await notification_crud.get_multi(
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
)
return {
"items": [
{
"id": item.id,
"recipient_id": item.recipient_id,
"recipient_name": item.recipient_name,
"title": item.title,
"content": item.content,
"notification_type": item.notification_type,
"priority": item.priority,
"is_read": item.is_read,
"read_at": item.read_at,
"action_url": item.action_url,
"created_at": item.created_at,
}
for item in items
],
"total": total
}
async def create_notification(
self,
db: AsyncSession,
obj_in: NotificationCreate
) -> Dict[str, Any]:
"""
创建消息通知
Args:
db: 数据库会话
obj_in: 创建数据
Returns:
创建的通知信息
"""
# 获取接收人信息
user_result = await db.execute(
select(User).where(User.id == obj_in.recipient_id)
)
user = user_result.scalar_one_or_none()
if not user:
raise ValueError("接收人不存在")
# 转换为字典
obj_in_data = obj_in.model_dump()
obj_in_data["recipient_name"] = user.real_name
# 处理复杂类型
if obj_in_data.get("extra_data"):
obj_in_data["extra_data"] = json.loads(obj_in.extra_data.model_dump_json()) if isinstance(obj_in.extra_data, dict) else obj_in.extra_data
# 设置邮件和短信发送标记
obj_in_data["sent_via_email"] = obj_in_data.pop("send_email", False)
obj_in_data["sent_via_sms"] = obj_in_data.pop("send_sms", False)
notification = await notification_crud.create(db, obj_in=obj_in_data)
# TODO: 发送邮件和短信
# if notification.sent_via_email:
# await self._send_email(notification)
# if notification.sent_via_sms:
# await self._send_sms(notification)
return {
"id": notification.id,
"recipient_id": notification.recipient_id,
"title": notification.title,
}
async def batch_create_notifications(
self,
db: AsyncSession,
batch_in: NotificationBatchCreate
) -> Dict[str, Any]:
"""
批量创建消息通知
Args:
db: 数据库会话
batch_in: 批量创建数据
Returns:
创建结果
"""
# 获取接收人信息
user_results = await db.execute(
select(User).where(User.id.in_(batch_in.recipient_ids))
)
users = {user.id: user.real_name for user in user_results.scalars()}
# 准备通知数据
notification_data = {
"title": batch_in.title,
"content": batch_in.content,
"notification_type": batch_in.notification_type.value,
"priority": batch_in.priority.value,
"action_url": batch_in.action_url,
"extra_data": json.loads(batch_in.extra_data.model_dump_json()) if batch_in.extra_data else {},
}
# 批量创建
notifications = await notification_crud.batch_create(
db,
recipient_ids=batch_in.recipient_ids,
notification_data=notification_data
)
# 更新接收人姓名
for notification in notifications:
notification.recipient_name = users.get(notification.recipient_id, "")
await db.flush()
return {
"count": len(notifications),
"notification_ids": [n.id for n in notifications]
}
async def mark_as_read(
self,
db: AsyncSession,
notification_id: int
) -> Dict[str, Any]:
"""
标记为已读
Args:
db: 数据库会话
notification_id: 通知ID
Returns:
更新结果
"""
notification = await notification_crud.mark_as_read(
db,
notification_id=notification_id,
read_at=datetime.utcnow()
)
if not notification:
raise ValueError("通知不存在")
return {
"id": notification.id,
"is_read": notification.is_read,
"read_at": notification.read_at
}
async def mark_all_as_read(
self,
db: AsyncSession,
recipient_id: int
) -> Dict[str, Any]:
"""
标记所有未读为已读
Args:
db: 数据库会话
recipient_id: 接收人ID
Returns:
更新结果
"""
count = await notification_crud.mark_all_as_read(
db,
recipient_id=recipient_id,
read_at=datetime.utcnow()
)
return {
"count": count,
"message": f"已标记 {count} 条通知为已读"
}
async def delete_notification(self, db: AsyncSession, notification_id: int) -> None:
"""
删除消息通知
Args:
db: 数据库会话
notification_id: 通知ID
"""
await notification_crud.delete(db, notification_id=notification_id)
async def batch_delete_notifications(
self,
db: AsyncSession,
notification_ids: List[int]
) -> Dict[str, Any]:
"""
批量删除通知
Args:
db: 数据库会话
notification_ids: 通知ID列表
Returns:
删除结果
"""
count = await notification_crud.batch_delete(db, notification_ids=notification_ids)
return {
"count": count,
"message": f"已删除 {count} 条通知"
}
async def get_unread_count(self, db: AsyncSession, recipient_id: int) -> Dict[str, Any]:
"""
获取未读通知数量
Args:
db: 数据库会话
recipient_id: 接收人ID
Returns:
未读数量
"""
count = await notification_crud.get_unread_count(db, recipient_id)
return {"unread_count": count}
async def get_statistics(self, db: AsyncSession, recipient_id: int) -> Dict[str, Any]:
"""
获取通知统计信息
Args:
db: 数据库会话
recipient_id: 接收人ID
Returns:
统计信息
"""
return await notification_crud.get_statistics(db, recipient_id)
async def send_from_template(
self,
db: AsyncSession,
template_in: NotificationSendFromTemplate
) -> Dict[str, Any]:
"""
从模板发送通知
Args:
db: 数据库会话
template_in: 模板发送数据
Returns:
发送结果
"""
# 获取模板
result = await db.execute(
select(NotificationTemplate).where(
and_(
NotificationTemplate.template_code == template_in.template_code,
NotificationTemplate.is_active == True
)
)
)
template = result.scalar_one_or_none()
if not template:
raise ValueError(f"通知模板 {template_in.template_code} 不存在或未启用")
# 渲染标题和内容
title = self._render_template(template.title_template, template_in.variables)
content = self._render_template(template.content_template, template_in.variables)
# 创建批量通知数据
batch_data = NotificationBatchCreate(
recipient_ids=template_in.recipient_ids,
title=title,
content=content,
notification_type=template.notification_type,
priority=template.priority,
action_url=template_in.action_url,
extra_data={
"template_code": template.template_code,
"variables": template_in.variables
}
)
return await self.batch_create_notifications(db, batch_data)
def _render_template(self, template: str, variables: Dict[str, Any]) -> str:
"""
渲染模板
Args:
template: 模板字符串
variables: 变量字典
Returns:
渲染后的字符串
"""
try:
return template.format(**variables)
except KeyError as e:
raise ValueError(f"模板变量缺失: {e}")
# 创建全局实例
notification_service = NotificationService()

View File

@@ -0,0 +1,270 @@
"""
操作日志服务层
"""
from typing import Optional, List, Dict, Any
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from app.crud.operation_log import operation_log_crud
from app.schemas.operation_log import OperationLogCreate
class OperationLogService:
"""操作日志服务类"""
async def get_log(self, db: AsyncSession, log_id: int) -> Optional[Dict[str, Any]]:
"""
获取操作日志详情
Args:
db: 数据库会话
log_id: 日志ID
Returns:
日志信息
"""
log = await operation_log_crud.get(db, log_id)
if not log:
return None
return {
"id": log.id,
"operator_id": log.operator_id,
"operator_name": log.operator_name,
"operator_ip": log.operator_ip,
"module": log.module,
"operation_type": log.operation_type,
"method": log.method,
"url": log.url,
"params": log.params,
"result": log.result,
"error_msg": log.error_msg,
"duration": log.duration,
"user_agent": log.user_agent,
"extra_data": log.extra_data,
"created_at": log.created_at,
}
async def get_logs(
self,
db: AsyncSession,
*,
skip: int = 0,
limit: int = 20,
operator_id: Optional[int] = None,
operator_name: Optional[str] = None,
module: Optional[str] = None,
operation_type: Optional[str] = None,
result: Optional[str] = None,
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None,
keyword: Optional[str] = None
) -> Dict[str, Any]:
"""
获取操作日志列表
Args:
db: 数据库会话
skip: 跳过条数
limit: 返回条数
operator_id: 操作人ID
operator_name: 操作人姓名
module: 模块名称
operation_type: 操作类型
result: 操作结果
start_time: 开始时间
end_time: 结束时间
keyword: 关键词
Returns:
日志列表和总数
"""
items, total = await operation_log_crud.get_multi(
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
)
return {
"items": [
{
"id": item.id,
"operator_id": item.operator_id,
"operator_name": item.operator_name,
"operator_ip": item.operator_ip,
"module": item.module,
"operation_type": item.operation_type,
"method": item.method,
"url": item.url,
"result": item.result,
"error_msg": item.error_msg,
"duration": item.duration,
"created_at": item.created_at,
}
for item in items
],
"total": total
}
async def create_log(
self,
db: AsyncSession,
obj_in: OperationLogCreate
) -> Dict[str, Any]:
"""
创建操作日志
Args:
db: 数据库会话
obj_in: 创建数据
Returns:
创建的日志信息
"""
import json
# 转换为字典
obj_in_data = obj_in.model_dump()
# 处理复杂类型
if obj_in_data.get("extra_data"):
obj_in_data["extra_data"] = json.loads(obj_in.extra_data.model_dump_json()) if isinstance(obj_in.extra_data, dict) else obj_in.extra_data
log = await operation_log_crud.create(db, obj_in=obj_in_data)
return {
"id": log.id,
"operator_name": log.operator_name,
"module": log.module,
"operation_type": log.operation_type,
}
async def get_statistics(
self,
db: AsyncSession,
*,
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None
) -> Dict[str, Any]:
"""
获取操作日志统计信息
Args:
db: 数据库会话
start_time: 开始时间
end_time: 结束时间
Returns:
统计信息
"""
return await operation_log_crud.get_statistics(
db,
start_time=start_time,
end_time=end_time
)
async def get_operator_top(
self,
db: AsyncSession,
*,
limit: int = 10,
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None
) -> List[Dict[str, Any]]:
"""
获取操作排行榜
Args:
db: 数据库会话
limit: 返回条数
start_time: 开始时间
end_time: 结束时间
Returns:
操作排行列表
"""
return await operation_log_crud.get_operator_top(
db,
limit=limit,
start_time=start_time,
end_time=end_time
)
async def delete_old_logs(self, db: AsyncSession, *, days: int = 90) -> Dict[str, Any]:
"""
删除旧日志
Args:
db: 数据库会话
days: 保留天数
Returns:
删除结果
"""
count = await operation_log_crud.delete_old_logs(db, days=days)
return {
"deleted_count": count,
"message": f"已删除 {count}{days} 天前的日志"
}
async def export_logs(
self,
db: AsyncSession,
*,
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None,
operator_id: Optional[int] = None,
module: Optional[str] = None,
operation_type: Optional[str] = None
) -> List[Dict[str, Any]]:
"""
导出操作日志
Args:
db: 数据库会话
start_time: 开始时间
end_time: 结束时间
operator_id: 操作人ID
module: 模块名称
operation_type: 操作类型
Returns:
日志列表
"""
items, total = await operation_log_crud.get_multi(
db,
skip=0,
limit=10000, # 导出限制
operator_id=operator_id,
module=module,
operation_type=operation_type,
start_time=start_time,
end_time=end_time
)
return [
{
"操作人": item.operator_name,
"模块": item.module,
"操作类型": item.operation_type,
"请求方法": item.method,
"请求URL": item.url,
"操作结果": item.result,
"错误信息": item.error_msg or "",
"执行时长(毫秒)": item.duration or 0,
"操作时间": item.created_at.strftime("%Y-%m-%d %H:%M:%S"),
"操作IP": item.operator_ip or "",
}
for item in items
]
# 创建全局实例
operation_log_service = OperationLogService()

View File

@@ -0,0 +1,245 @@
"""
机构网点业务服务层
"""
from typing import List, Optional, Tuple
from sqlalchemy.orm import Session
from app.crud.organization import organization
from app.schemas.organization import OrganizationCreate, OrganizationUpdate
from app.core.exceptions import NotFoundException, AlreadyExistsException
class OrganizationService:
"""机构网点服务类"""
def get_organization(self, db: Session, org_id: int):
"""
获取机构详情
Args:
db: 数据库会话
org_id: 机构ID
Returns:
机构对象
Raises:
NotFoundException: 机构不存在
"""
obj = organization.get(db, org_id)
if not obj:
raise NotFoundException("机构")
return obj
def get_organizations(
self,
db: Session,
skip: int = 0,
limit: int = 20,
org_type: Optional[str] = None,
status: Optional[str] = None,
keyword: Optional[str] = None
) -> Tuple[List, int]:
"""
获取机构列表
Args:
db: 数据库会话
skip: 跳过条数
limit: 返回条数
org_type: 机构类型
status: 状态
keyword: 搜索关键词
Returns:
(机构列表, 总数)
"""
return organization.get_multi(
db=db,
skip=skip,
limit=limit,
org_type=org_type,
status=status,
keyword=keyword
)
def get_organization_tree(
self,
db: Session,
status: Optional[str] = None
) -> List:
"""
获取机构树
Args:
db: 数据库会话
status: 状态筛选
Returns:
机构树列表
"""
return organization.get_tree(db, status)
def get_organization_children(
self,
db: Session,
parent_id: int
) -> List:
"""
获取直接子机构
Args:
db: 数据库会话
parent_id: 父机构ID
Returns:
子机构列表
Raises:
NotFoundException: 父机构不存在
"""
if parent_id > 0 and not organization.get(db, parent_id):
raise NotFoundException("父机构")
return organization.get_children(db, parent_id)
def get_all_children(
self,
db: Session,
parent_id: int
) -> List:
"""
递归获取所有子机构
Args:
db: 数据库会话
parent_id: 父机构ID
Returns:
所有子机构列表
Raises:
NotFoundException: 父机构不存在
"""
if not organization.get(db, parent_id):
raise NotFoundException("机构")
return organization.get_all_children(db, parent_id)
def get_parents(
self,
db: Session,
child_id: int
) -> List:
"""
递归获取所有父机构
Args:
db: 数据库会话
child_id: 子机构ID
Returns:
所有父机构列表
Raises:
NotFoundException: 机构不存在
"""
if not organization.get(db, child_id):
raise NotFoundException("机构")
return organization.get_parents(db, child_id)
def create_organization(
self,
db: Session,
obj_in: OrganizationCreate,
creator_id: Optional[int] = None
):
"""
创建机构
Args:
db: 数据库会话
obj_in: 创建数据
creator_id: 创建人ID
Returns:
创建的机构对象
Raises:
AlreadyExistsException: 机构代码已存在
NotFoundException: 父机构不存在
"""
try:
return organization.create(db, obj_in, creator_id)
except ValueError as e:
if "不存在" in str(e):
raise NotFoundException("父机构") from e
raise AlreadyExistsException("机构") from e
def update_organization(
self,
db: Session,
org_id: int,
obj_in: OrganizationUpdate,
updater_id: Optional[int] = None
):
"""
更新机构
Args:
db: 数据库会话
org_id: 机构ID
obj_in: 更新数据
updater_id: 更新人ID
Returns:
更新后的机构对象
Raises:
NotFoundException: 机构不存在
"""
db_obj = organization.get(db, org_id)
if not db_obj:
raise NotFoundException("机构")
try:
return organization.update(db, db_obj, obj_in, updater_id)
except ValueError as e:
if "不存在" in str(e):
raise NotFoundException("父机构") from e
raise
def delete_organization(
self,
db: Session,
org_id: int,
deleter_id: Optional[int] = None
) -> bool:
"""
删除机构
Args:
db: 数据库会话
org_id: 机构ID
deleter_id: 删除人ID
Returns:
是否删除成功
Raises:
NotFoundException: 机构不存在
ValueError: 机构下存在子机构
"""
if not organization.get(db, org_id):
raise NotFoundException("机构")
try:
return organization.delete(db, org_id, deleter_id)
except ValueError as e:
if "子机构" in str(e):
raise ValueError("该机构下存在子机构,无法删除") from e
raise
# 创建全局实例
organization_service = OrganizationService()

View File

@@ -0,0 +1,409 @@
"""
资产回收业务服务层
"""
from typing import List, Optional, Dict, Any
from sqlalchemy.orm import Session, selectinload
from app.crud.recovery import recovery_order, recovery_item
from app.crud.asset import asset
from app.schemas.recovery import (
AssetRecoveryOrderCreate,
AssetRecoveryOrderUpdate
)
from app.core.exceptions import NotFoundException, BusinessException
class RecoveryService:
"""资产回收服务类"""
async def get_order(
self,
db: Session,
order_id: int
) -> Dict[str, Any]:
"""获取回收单详情"""
# 使用selectinload预加载关联数据避免N+1查询
from app.models.recovery import AssetRecoveryOrder
from app.models.user import User
from app.models.recovery import AssetRecoveryItem
obj = db.query(
AssetRecoveryOrder
).options(
selectinload(AssetRecoveryOrder.items),
selectinload(AssetRecoveryOrder.applicant.of_type(User)),
selectinload(AssetRecoveryOrder.approver.of_type(User)),
selectinload(AssetRecoveryOrder.executor.of_type(User))
).filter(
AssetRecoveryOrder.id == order_id
).first()
if not obj:
raise NotFoundException("回收单")
# 加载关联信息
return self._load_order_relations(db, obj)
def get_orders(
self,
db: Session,
skip: int = 0,
limit: int = 20,
recovery_type: Optional[str] = None,
approval_status: Optional[str] = None,
execute_status: Optional[str] = None,
keyword: Optional[str] = None
) -> tuple:
"""获取回收单列表"""
items, total = recovery_order.get_multi(
db=db,
skip=skip,
limit=limit,
recovery_type=recovery_type,
approval_status=approval_status,
execute_status=execute_status,
keyword=keyword
)
# 加载关联信息
items_with_relations = [self._load_order_relations(db, item) for item in items]
return items_with_relations, total
async def create_order(
self,
db: Session,
obj_in: AssetRecoveryOrderCreate,
apply_user_id: int
):
"""创建回收单"""
# 验证资产存在性和状态
assets = []
for asset_id in obj_in.asset_ids:
asset_obj = asset.get(db, asset_id)
if not asset_obj:
raise NotFoundException(f"资产ID {asset_id}")
assets.append(asset_obj)
# 验证资产状态是否允许回收
for asset_obj in assets:
if not self._can_recover(asset_obj.status, obj_in.recovery_type):
raise BusinessException(
f"资产 {asset_obj.asset_code} 当前状态为 {asset_obj.status},不允许{self._get_recovery_type_name(obj_in.recovery_type)}操作"
)
# 生成回收单号
order_code = await self._generate_order_code(db)
# 创建回收单
db_obj = recovery_order.create(
db=db,
obj_in=obj_in,
order_code=order_code,
apply_user_id=apply_user_id
)
return self._load_order_relations(db, db_obj)
def update_order(
self,
db: Session,
order_id: int,
obj_in: AssetRecoveryOrderUpdate
):
"""更新回收单"""
db_obj = recovery_order.get(db, order_id)
if not db_obj:
raise NotFoundException("回收单")
# 只有待审批状态可以更新
if db_obj.approval_status != "pending":
raise BusinessException("只有待审批状态的回收单可以更新")
return recovery_order.update(db, db_obj, obj_in)
def approve_order(
self,
db: Session,
order_id: int,
approval_status: str,
approval_user_id: int,
approval_remark: Optional[str] = None
):
"""审批回收单"""
db_obj = recovery_order.get(db, order_id)
if not db_obj:
raise NotFoundException("回收单")
# 检查状态
if db_obj.approval_status != "pending":
raise BusinessException("该回收单已审批,无法重复审批")
# 审批
db_obj = recovery_order.approve(
db=db,
db_obj=db_obj,
approval_status=approval_status,
approval_user_id=approval_user_id,
approval_remark=approval_remark
)
return self._load_order_relations(db, db_obj)
def start_order(
self,
db: Session,
order_id: int,
execute_user_id: int
):
"""开始回收"""
db_obj = recovery_order.get(db, order_id)
if not db_obj:
raise NotFoundException("回收单")
# 检查状态
if db_obj.approval_status != "approved":
raise BusinessException("该回收单未审批通过,无法开始执行")
if db_obj.execute_status != "pending":
raise BusinessException("该回收单已开始或已完成")
# 开始回收
db_obj = recovery_order.start(db, db_obj, execute_user_id)
# 更新明细状态为回收中
recovery_item.batch_update_recovery_status(db, order_id, "recovering")
return self._load_order_relations(db, db_obj)
async def complete_order(
self,
db: Session,
order_id: int,
execute_user_id: int
):
"""完成回收"""
db_obj = recovery_order.get(db, order_id)
if not db_obj:
raise NotFoundException("回收单")
# 检查状态
if db_obj.execute_status not in ["pending", "executing"]:
raise BusinessException("该回收单状态不允许完成操作")
# 完成回收单
db_obj = recovery_order.complete(db, db_obj, execute_user_id)
# 更新资产状态
await self._execute_recovery_logic(db, db_obj)
# 更新明细状态为完成
recovery_item.batch_update_recovery_status(db, order_id, "completed")
return self._load_order_relations(db, db_obj)
def cancel_order(
self,
db: Session,
order_id: int
) -> bool:
"""取消回收单"""
db_obj = recovery_order.get(db, order_id)
if not db_obj:
raise NotFoundException("回收单")
# 检查状态
if db_obj.execute_status == "completed":
raise BusinessException("已完成的回收单无法取消")
recovery_order.cancel(db, db_obj)
return True
def delete_order(
self,
db: Session,
order_id: int
) -> bool:
"""删除回收单"""
db_obj = recovery_order.get(db, order_id)
if not db_obj:
raise NotFoundException("回收单")
# 只有已取消或已拒绝的可以删除
if db_obj.approval_status not in ["rejected", "cancelled"]:
raise BusinessException("只能删除已拒绝或已取消的回收单")
return recovery_order.delete(db, order_id)
def get_order_items(
self,
db: Session,
order_id: int
) -> List:
"""获取回收单明细"""
# 验证回收单存在
if not recovery_order.get(db, order_id):
raise NotFoundException("回收单")
return recovery_item.get_by_order(db, order_id)
def get_statistics(
self,
db: Session
) -> Dict[str, int]:
"""获取回收单统计信息"""
return recovery_order.get_statistics(db)
async def _execute_recovery_logic(
self,
db: Session,
order_obj
):
"""执行回收逻辑(完成回收时自动执行)"""
# 获取明细
items = recovery_item.get_by_order(db, order_obj.id)
# 更新资产状态
from app.services.asset_service import asset_service
from app.schemas.asset import AssetStatusTransition
for item in items:
try:
# 根据回收类型确定目标状态
if order_obj.recovery_type == "scrap":
target_status = "scrapped"
remark = f"报废回收: {order_obj.order_code}"
else:
target_status = "in_stock"
remark = f"资产回收: {order_obj.order_code}"
# 变更资产状态
await asset_service.change_asset_status(
db=db,
asset_id=item.asset_id,
status_transition=AssetStatusTransition(
new_status=target_status,
remark=remark
),
operator_id=order_obj.execute_user_id
)
except Exception as e:
# 记录失败日志
print(f"回收资产 {item.asset_code} 失败: {str(e)}")
raise
def _load_order_relations(
self,
db: Session,
obj
) -> Dict[str, Any]:
"""加载回收单关联信息"""
from app.models.user import User
result = {
"id": obj.id,
"order_code": obj.order_code,
"recovery_type": obj.recovery_type,
"title": obj.title,
"asset_count": obj.asset_count,
"apply_user_id": obj.apply_user_id,
"apply_time": obj.apply_time,
"approval_status": obj.approval_status,
"approval_user_id": obj.approval_user_id,
"approval_time": obj.approval_time,
"approval_remark": obj.approval_remark,
"execute_status": obj.execute_status,
"execute_user_id": obj.execute_user_id,
"execute_time": obj.execute_time,
"remark": obj.remark,
"created_at": obj.created_at,
"updated_at": obj.updated_at
}
# 加载申请人
if obj.apply_user_id:
apply_user = db.query(User).filter(User.id == obj.apply_user_id).first()
if apply_user:
result["apply_user"] = {
"id": apply_user.id,
"real_name": apply_user.real_name,
"username": apply_user.username
}
# 加载审批人
if obj.approval_user_id:
approval_user = db.query(User).filter(User.id == obj.approval_user_id).first()
if approval_user:
result["approval_user"] = {
"id": approval_user.id,
"real_name": approval_user.real_name,
"username": approval_user.username
}
# 加载执行人
if obj.execute_user_id:
execute_user = db.query(User).filter(User.id == obj.execute_user_id).first()
if execute_user:
result["execute_user"] = {
"id": execute_user.id,
"real_name": execute_user.real_name,
"username": execute_user.username
}
# 加载明细
items = recovery_item.get_by_order(db, obj.id)
result["items"] = [
{
"id": item.id,
"asset_id": item.asset_id,
"asset_code": item.asset_code,
"recovery_status": item.recovery_status
}
for item in items
]
return result
def _can_recover(self, asset_status: str, recovery_type: str) -> bool:
"""判断资产是否可以回收"""
# 使用中的资产可以回收
if recovery_type in ["user", "org"]:
return asset_status == "in_use"
# 报废回收可以使用中或维修中的资产
elif recovery_type == "scrap":
return asset_status in ["in_use", "maintenance", "in_stock"]
return False
def _get_recovery_type_name(self, recovery_type: str) -> str:
"""获取回收类型中文名"""
type_names = {
"user": "使用人回收",
"org": "机构回收",
"scrap": "报废回收"
}
return type_names.get(recovery_type, "回收")
async def _generate_order_code(self, db: Session) -> str:
"""生成回收单号"""
from datetime import datetime
import random
import string
# 日期部分
date_str = datetime.now().strftime("%Y%m%d")
# 序号部分5位随机数
sequence = "".join(random.choices(string.digits, k=5))
# 组合单号: RO-20250124-00001
order_code = f"RO-{date_str}-{sequence}"
# 检查是否重复,如果重复则重新生成
while recovery_order.get_by_code(db, order_code):
sequence = "".join(random.choices(string.digits, k=5))
order_code = f"RO-{date_str}-{sequence}"
return order_code
# 创建全局实例
recovery_service = RecoveryService()

View File

@@ -0,0 +1,166 @@
"""
资产状态机服务
定义资产状态的转换规则和验证
"""
from typing import Dict, List, Optional
from enum import Enum
class AssetStatus(str, Enum):
"""资产状态枚举"""
PENDING = "pending" # 待入库
IN_STOCK = "in_stock" # 库存中
IN_USE = "in_use" # 使用中
TRANSFERRING = "transferring" # 调拨中
MAINTENANCE = "maintenance" # 维修中
PENDING_SCRAP = "pending_scrap" # 待报废
SCRAPPED = "scrapped" # 已报废
LOST = "lost" # 已丢失
class StateMachineService:
"""状态机服务类"""
# 状态转换规则
TRANSITIONS: Dict[str, List[str]] = {
AssetStatus.PENDING: [
AssetStatus.IN_STOCK,
AssetStatus.PENDING_SCRAP,
],
AssetStatus.IN_STOCK: [
AssetStatus.IN_USE,
AssetStatus.TRANSFERRING,
AssetStatus.MAINTENANCE,
AssetStatus.PENDING_SCRAP,
AssetStatus.LOST,
],
AssetStatus.IN_USE: [
AssetStatus.IN_STOCK,
AssetStatus.TRANSFERRING,
AssetStatus.MAINTENANCE,
AssetStatus.PENDING_SCRAP,
AssetStatus.LOST,
],
AssetStatus.TRANSFERRING: [
AssetStatus.IN_STOCK,
AssetStatus.IN_USE,
],
AssetStatus.MAINTENANCE: [
AssetStatus.IN_STOCK,
AssetStatus.IN_USE,
AssetStatus.PENDING_SCRAP,
],
AssetStatus.PENDING_SCRAP: [
AssetStatus.SCRAPPED,
AssetStatus.IN_STOCK, # 取消报废
],
AssetStatus.SCRAPPED: [], # 终态,不可转换
AssetStatus.LOST: [], # 终态,不可转换
}
# 状态显示名称
STATUS_NAMES: Dict[str, str] = {
AssetStatus.PENDING: "待入库",
AssetStatus.IN_STOCK: "库存中",
AssetStatus.IN_USE: "使用中",
AssetStatus.TRANSFERRING: "调拨中",
AssetStatus.MAINTENANCE: "维修中",
AssetStatus.PENDING_SCRAP: "待报废",
AssetStatus.SCRAPPED: "已报废",
AssetStatus.LOST: "已丢失",
}
def can_transition(self, current_status: str, target_status: str) -> bool:
"""
检查状态是否可以转换
Args:
current_status: 当前状态
target_status: 目标状态
Returns:
是否可以转换
"""
allowed_transitions = self.TRANSITIONS.get(current_status, [])
return target_status in allowed_transitions
def validate_transition(
self,
current_status: str,
target_status: str
) -> Optional[str]:
"""
验证状态转换并返回错误信息
Args:
current_status: 当前状态
target_status: 目标状态
Returns:
错误信息如果转换有效则返回None
"""
if current_status == target_status:
return "当前状态与目标状态相同"
if current_status not in self.TRANSITIONS:
return f"无效的当前状态: {current_status}"
if target_status not in self.TRANSITIONS:
return f"无效的目标状态: {target_status}"
if not self.can_transition(current_status, target_status):
return f"无法从状态 '{self.get_status_name(current_status)}' 转换到 '{self.get_status_name(target_status)}'"
return None
def get_status_name(self, status: str) -> str:
"""
获取状态的显示名称
Args:
status: 状态值
Returns:
状态显示名称
"""
return self.STATUS_NAMES.get(status, status)
def get_allowed_transitions(self, current_status: str) -> List[str]:
"""
获取允许的转换状态列表
Args:
current_status: 当前状态
Returns:
允许转换到的状态列表
"""
return self.TRANSITIONS.get(current_status, [])
def is_terminal_state(self, status: str) -> bool:
"""
判断是否为终态
Args:
status: 状态值
Returns:
是否为终态
"""
return len(self.TRANSITIONS.get(status, [])) == 0
def get_available_statuses(self) -> List[Dict[str, str]]:
"""
获取所有可用状态列表
Returns:
状态列表每个状态包含value和name
"""
return [
{"value": status, "name": name}
for status, name in self.STATUS_NAMES.items()
]
# 创建全局实例
state_machine_service = StateMachineService()

View File

@@ -0,0 +1,546 @@
"""
统计分析服务层
"""
from typing import Optional, List, Dict, Any
from datetime import datetime, date, timedelta
from decimal import Decimal
from sqlalchemy import select, func, and_, or_, case, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.asset import Asset
from app.models.allocation import AssetAllocationOrder
from app.models.maintenance import MaintenanceRecord
from app.models.organization import Organization
from app.models.brand_supplier import Supplier
from app.models.device_type import DeviceType
class StatisticsService:
"""统计分析服务类"""
async def get_overview(
self,
db: AsyncSession,
organization_id: Optional[int] = None
) -> Dict[str, Any]:
"""
获取总览统计
Args:
db: 数据库会话
organization_id: 网点ID
Returns:
总览统计数据
"""
# 构建查询条件
conditions = []
if organization_id:
conditions.append(Asset.organization_id == organization_id)
where_clause = and_(*conditions) if conditions else None
# 资产总数
total_query = select(func.count(Asset.id))
if where_clause:
total_query = total_query.where(where_clause)
total_result = await db.execute(total_query)
total_assets = total_result.scalar() or 0
# 资产总价值
value_query = select(func.coalesce(func.sum(Asset.purchase_price), 0))
if where_clause:
value_query = value_query.where(where_clause)
value_result = await db.execute(value_query)
total_value = value_result.scalar() or Decimal("0")
# 各状态数量
status_query = select(
Asset.status,
func.count(Asset.id).label('count')
).group_by(Asset.status)
if where_clause:
status_query = status_query.where(where_clause)
status_result = await db.execute(status_query)
status_counts = {row[0]: row[1] for row in status_result}
# 今日和本月采购数量
today = datetime.utcnow().date()
today_start = datetime.combine(today, datetime.min.time())
month_start = datetime(today.year, today.month, 1)
today_query = select(func.count(Asset.id)).where(Asset.created_at >= today_start)
if where_clause:
today_query = today_query.where(Asset.organization_id == organization_id)
today_result = await db.execute(today_query)
today_purchase_count = today_result.scalar() or 0
month_query = select(func.count(Asset.id)).where(Asset.created_at >= month_start)
if where_clause:
month_query = month_query.where(Asset.organization_id == organization_id)
month_result = await db.execute(month_query)
this_month_purchase_count = month_result.scalar() or 0
# 机构网点数
org_query = select(func.count(Organization.id))
org_result = await db.execute(org_query)
organization_count = org_result.scalar() or 0
# 供应商数
supplier_query = select(func.count(Supplier.id))
supplier_result = await db.execute(supplier_query)
supplier_count = supplier_result.scalar() or 0
return {
"total_assets": total_assets,
"total_value": float(total_value),
"in_stock_count": status_counts.get("in_stock", 0),
"in_use_count": status_counts.get("in_use", 0),
"maintenance_count": status_counts.get("maintenance", 0),
"scrapped_count": status_counts.get("scrapped", 0) + status_counts.get("pending_scrap", 0),
"today_purchase_count": today_purchase_count,
"this_month_purchase_count": this_month_purchase_count,
"organization_count": organization_count,
"supplier_count": supplier_count,
}
async def get_purchase_statistics(
self,
db: AsyncSession,
start_date: Optional[date] = None,
end_date: Optional[date] = None,
organization_id: Optional[int] = None
) -> Dict[str, Any]:
"""
获取采购统计
Args:
db: 数据库会话
start_date: 开始日期
end_date: 结束日期
organization_id: 网点ID
Returns:
采购统计数据
"""
# 构建查询条件
conditions = []
if start_date:
conditions.append(Asset.purchase_date >= start_date)
if end_date:
conditions.append(Asset.purchase_date <= end_date)
if organization_id:
conditions.append(Asset.organization_id == organization_id)
where_clause = and_(*conditions) if conditions else None
# 总采购数量和金额
count_query = select(func.count(Asset.id))
value_query = select(func.coalesce(func.sum(Asset.purchase_price), 0))
if where_clause:
count_query = count_query.where(where_clause)
value_query = value_query.where(where_clause)
count_result = await db.execute(count_query)
value_result = await db.execute(value_query)
total_purchase_count = count_result.scalar() or 0
total_purchase_value = value_result.scalar() or Decimal("0")
# 月度趋势
monthly_query = select(
func.to_char(Asset.purchase_date, 'YYYY-MM').label('month'),
func.count(Asset.id).label('count'),
func.coalesce(func.sum(Asset.purchase_price), 0).label('value')
).group_by('month').order_by('month')
if where_clause:
monthly_query = monthly_query.where(where_clause)
monthly_result = await db.execute(monthly_query)
monthly_trend = [
{
"month": row[0],
"count": row[1],
"value": float(row[2]) if row[2] else 0
}
for row in monthly_result
]
# 供应商分布
supplier_query = select(
Supplier.id.label('supplier_id'),
Supplier.name.label('supplier_name'),
func.count(Asset.id).label('count'),
func.coalesce(func.sum(Asset.purchase_price), 0).label('value')
).join(
Asset, Asset.supplier_id == Supplier.id
).group_by(
Supplier.id, Supplier.name
).order_by(func.count(Asset.id).desc())
if where_clause:
supplier_query = supplier_query.where(
and_(*[c for c in conditions if not any(x in str(c) for x in ['organization_id'])])
)
supplier_result = await db.execute(supplier_query)
supplier_distribution = [
{
"supplier_id": row[0],
"supplier_name": row[1],
"count": row[2],
"value": float(row[3]) if row[3] else 0
}
for row in supplier_result
]
return {
"total_purchase_count": total_purchase_count,
"total_purchase_value": float(total_purchase_value),
"monthly_trend": monthly_trend,
"supplier_distribution": supplier_distribution,
"category_distribution": [],
}
async def get_depreciation_statistics(
self,
db: AsyncSession,
organization_id: Optional[int] = None
) -> Dict[str, Any]:
"""
获取折旧统计
Args:
db: 数据库会话
organization_id: 网点ID
Returns:
折旧统计数据
"""
# 简化实现,实际需要根据折旧规则计算
return {
"total_depreciation_value": 0.0,
"average_depreciation_rate": 0.05,
"depreciation_by_category": [],
"assets_near_end_life": [],
}
async def get_value_statistics(
self,
db: AsyncSession,
organization_id: Optional[int] = None
) -> Dict[str, Any]:
"""
获取价值统计
Args:
db: 数据库会话
organization_id: 网点ID
Returns:
价值统计数据
"""
# 构建查询条件
conditions = []
if organization_id:
conditions.append(Asset.organization_id == organization_id)
where_clause = and_(*conditions) if conditions else None
# 总价值
total_query = select(func.coalesce(func.sum(Asset.purchase_price), 0))
if where_clause:
total_query = total_query.where(where_clause)
total_result = await db.execute(total_query)
total_value = total_result.scalar() or Decimal("0")
# 按分类统计
category_query = select(
DeviceType.id.label('device_type_id'),
DeviceType.name.label('device_type_name'),
func.count(Asset.id).label('count'),
func.coalesce(func.sum(Asset.purchase_price), 0).label('value')
).join(
Asset, Asset.device_type_id == DeviceType.id
).group_by(
DeviceType.id, DeviceType.name
).order_by(func.coalesce(func.sum(Asset.purchase_price), 0).desc())
if where_clause:
category_query = category_query.where(where_clause)
category_result = await db.execute(category_query)
value_by_category = [
{
"device_type_id": row[0],
"device_type_name": row[1],
"count": row[2],
"value": float(row[3]) if row[3] else 0
}
for row in category_result
]
# 按网点统计
org_query = select(
Organization.id.label('organization_id'),
Organization.name.label('organization_name'),
func.count(Asset.id).label('count'),
func.coalesce(func.sum(Asset.purchase_price), 0).label('value')
).join(
Asset, Asset.organization_id == Organization.id
).group_by(
Organization.id, Organization.name
).order_by(func.coalesce(func.sum(Asset.purchase_price), 0).desc())
if where_clause:
org_query = org_query.where(where_clause)
org_result = await db.execute(org_query)
value_by_organization = [
{
"organization_id": row[0],
"organization_name": row[1],
"count": row[2],
"value": float(row[3]) if row[3] else 0
}
for row in org_result
]
# 高价值资产价值前10
high_value_query = select(
Asset.id,
Asset.asset_code,
Asset.asset_name,
Asset.purchase_price,
DeviceType.name.label('device_type_name')
).join(
DeviceType, Asset.device_type_id == DeviceType.id
).order_by(
Asset.purchase_price.desc()
).limit(10)
if where_clause:
high_value_query = high_value_query.where(where_clause)
high_value_result = await db.execute(high_value_query)
high_value_assets = [
{
"asset_id": row[0],
"asset_code": row[1],
"asset_name": row[2],
"purchase_price": float(row[3]) if row[3] else 0,
"device_type_name": row[4]
}
for row in high_value_result
]
return {
"total_value": float(total_value),
"net_value": float(total_value * Decimal("0.8")), # 简化计算
"depreciation_value": float(total_value * Decimal("0.2")),
"value_by_category": value_by_category,
"value_by_organization": value_by_organization,
"high_value_assets": high_value_assets,
}
async def get_trend_analysis(
self,
db: AsyncSession,
start_date: Optional[date] = None,
end_date: Optional[date] = None,
organization_id: Optional[int] = None
) -> Dict[str, Any]:
"""
获取趋势分析
Args:
db: 数据库会话
start_date: 开始日期
end_date: 结束日期
organization_id: 网点ID
Returns:
趋势分析数据
"""
# 默认查询最近12个月
if not end_date:
end_date = datetime.utcnow().date()
if not start_date:
start_date = end_date - timedelta(days=365)
# 构建查询条件
conditions = [
Asset.created_at >= datetime.combine(start_date, datetime.min.time()),
Asset.created_at <= datetime.combine(end_date, datetime.max.time())
]
if organization_id:
conditions.append(Asset.organization_id == organization_id)
where_clause = and_(*conditions)
# 资产数量趋势(按月)
asset_trend_query = select(
func.to_char(Asset.created_at, 'YYYY-MM').label('month'),
func.count(Asset.id).label('count')
).group_by('month').order_by('month')
asset_trend_result = await db.execute(asset_trend_query.where(where_clause))
asset_trend = [
{"month": row[0], "count": row[1]}
for row in asset_trend_result
]
# 资产价值趋势
value_trend_query = select(
func.to_char(Asset.created_at, 'YYYY-MM').label('month'),
func.coalesce(func.sum(Asset.purchase_price), 0).label('value')
).group_by('month').order_by('month')
value_trend_result = await db.execute(value_trend_query.where(where_clause))
value_trend = [
{"month": row[0], "value": float(row[1]) if row[1] else 0}
for row in value_trend_result
]
return {
"asset_trend": asset_trend,
"value_trend": value_trend,
"purchase_trend": [],
"maintenance_trend": [],
"allocation_trend": [],
}
async def get_maintenance_statistics(
self,
db: AsyncSession,
start_date: Optional[date] = None,
end_date: Optional[date] = None,
organization_id: Optional[int] = None
) -> Dict[str, Any]:
"""
获取维修统计
Args:
db: 数据库会话
start_date: 开始日期
end_date: 结束日期
organization_id: 网点ID
Returns:
维修统计数据
"""
# 构建查询条件
conditions = []
if start_date:
conditions.append(MaintenanceRecord.created_at >= datetime.combine(start_date, datetime.min.time()))
if end_date:
conditions.append(MaintenanceRecord.created_at <= datetime.combine(end_date, datetime.max.time()))
if organization_id:
conditions.append(MaintenanceRecord.organization_id == organization_id)
where_clause = and_(*conditions) if conditions else None
# 总维修次数和费用
count_query = select(func.count(MaintenanceRecord.id))
cost_query = select(func.coalesce(func.sum(MaintenanceRecord.cost), 0))
if where_clause:
count_query = count_query.where(where_clause)
cost_query = cost_query.where(where_clause)
count_result = await db.execute(count_query)
cost_result = await db.execute(cost_query)
total_maintenance_count = count_result.scalar() or 0
total_maintenance_cost = cost_result.scalar() or Decimal("0")
# 按状态统计
status_query = select(
MaintenanceRecord.status,
func.count(MaintenanceRecord.id).label('count')
).group_by(MaintenanceRecord.status)
if where_clause:
status_query = status_query.where(where_clause)
status_result = await db.execute(status_query)
status_counts = {row[0]: row[1] for row in status_result}
return {
"total_maintenance_count": total_maintenance_count,
"total_maintenance_cost": float(total_maintenance_cost),
"pending_count": status_counts.get("pending", 0),
"in_progress_count": status_counts.get("in_progress", 0),
"completed_count": status_counts.get("completed", 0),
"monthly_trend": [],
"type_distribution": [],
"cost_by_category": [],
}
async def get_allocation_statistics(
self,
db: AsyncSession,
start_date: Optional[date] = None,
end_date: Optional[date] = None,
organization_id: Optional[int] = None
) -> Dict[str, Any]:
"""
获取分配统计
Args:
db: 数据库会话
start_date: 开始日期
end_date: 结束日期
organization_id: 网点ID
Returns:
分配统计数据
"""
# 构建查询条件
conditions = []
if start_date:
conditions.append(AssetAllocationOrder.created_at >= datetime.combine(start_date, datetime.min.time()))
if end_date:
conditions.append(AssetAllocationOrder.created_at <= datetime.combine(end_date, datetime.max.time()))
where_clause = and_(*conditions) if conditions else None
# 总分配次数
count_query = select(func.count(AssetAllocationOrder.id))
if where_clause:
count_query = count_query.where(where_clause)
count_result = await db.execute(count_query)
total_allocation_count = count_result.scalar() or 0
# 按状态统计
status_query = select(
AssetAllocationOrder.status,
func.count(AssetAllocationOrder.id).label('count')
).group_by(AssetAllocationOrder.status)
if where_clause:
status_query = status_query.where(where_clause)
status_result = await db.execute(status_query)
status_counts = {row[0]: row[1] for row in status_result}
return {
"total_allocation_count": total_allocation_count,
"pending_count": status_counts.get("pending", 0),
"approved_count": status_counts.get("approved", 0),
"rejected_count": status_counts.get("rejected", 0),
"monthly_trend": [],
"by_organization": [],
"transfer_statistics": [],
}
# 创建全局实例
statistics_service = StatisticsService()

View File

@@ -0,0 +1,298 @@
"""
系统配置服务层
"""
from typing import Optional, List, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.crud.system_config import system_config_crud
from app.schemas.system_config import SystemConfigCreate, SystemConfigUpdate
import json
class SystemConfigService:
"""系统配置服务类"""
async def get_config(self, db: AsyncSession, config_id: int) -> Optional[Dict[str, Any]]:
"""
获取配置详情
Args:
db: 数据库会话
config_id: 配置ID
Returns:
配置信息
"""
config = await system_config_crud.get(db, config_id)
if not config:
return None
return {
"id": config.id,
"config_key": config.config_key,
"config_name": config.config_name,
"config_value": config.config_value,
"value_type": config.value_type,
"category": config.category,
"description": config.description,
"is_system": config.is_system,
"is_encrypted": config.is_encrypted,
"validation_rule": config.validation_rule,
"options": config.options,
"default_value": config.default_value,
"sort_order": config.sort_order,
"is_active": config.is_active,
"created_at": config.created_at,
"updated_at": config.updated_at,
"updated_by": config.updated_by,
}
async def get_config_by_key(
self,
db: AsyncSession,
config_key: str,
default: Any = None
) -> Any:
"""
根据键获取配置值
Args:
db: 数据库会话
config_key: 配置键
default: 默认值
Returns:
配置值
"""
return await system_config_crud.get_value(db, config_key, default)
async def get_configs(
self,
db: AsyncSession,
*,
skip: int = 0,
limit: int = 20,
keyword: Optional[str] = None,
category: Optional[str] = None,
is_active: Optional[bool] = None,
is_system: Optional[bool] = None
) -> Dict[str, Any]:
"""
获取配置列表
Args:
db: 数据库会话
skip: 跳过条数
limit: 返回条数
keyword: 搜索关键词
category: 配置分类
is_active: 是否启用
is_system: 是否系统配置
Returns:
配置列表和总数
"""
items, total = await system_config_crud.get_multi(
db,
skip=skip,
limit=limit,
keyword=keyword,
category=category,
is_active=is_active,
is_system=is_system
)
return {
"items": [
{
"id": item.id,
"config_key": item.config_key,
"config_name": item.config_name,
"config_value": item.config_value,
"value_type": item.value_type,
"category": item.category,
"description": item.description,
"is_system": item.is_system,
"is_encrypted": item.is_encrypted,
"options": item.options,
"default_value": item.default_value,
"sort_order": item.sort_order,
"is_active": item.is_active,
"created_at": item.created_at,
"updated_at": item.updated_at,
}
for item in items
],
"total": total
}
async def get_configs_by_category(
self,
db: AsyncSession,
category: str,
is_active: bool = True
) -> List[Dict[str, Any]]:
"""
根据分类获取配置
Args:
db: 数据库会话
category: 配置分类
is_active: 是否启用
Returns:
配置列表
"""
items = await system_config_crud.get_by_category(db, category, is_active=is_active)
return [
{
"config_key": item.config_key,
"config_name": item.config_name,
"config_value": item.config_value,
"value_type": item.value_type,
"description": item.description,
}
for item in items
]
async def get_categories(self, db: AsyncSession) -> List[Dict[str, Any]]:
"""
获取所有配置分类
Args:
db: 数据库会话
Returns:
分类列表
"""
return await system_config_crud.get_categories(db)
async def create_config(
self,
db: AsyncSession,
obj_in: SystemConfigCreate,
creator_id: Optional[int] = None
) -> Dict[str, Any]:
"""
创建配置
Args:
db: 数据库会话
obj_in: 创建数据
creator_id: 创建人ID
Returns:
创建的配置信息
"""
# 检查键是否已存在
existing = await system_config_crud.get_by_key(db, obj_in.config_key)
if existing:
raise ValueError(f"配置键 {obj_in.config_key} 已存在")
# 转换为字典
obj_in_data = obj_in.model_dump()
# 处理复杂类型
if obj_in.options:
obj_in_data["options"] = json.loads(obj_in.options.model_dump_json()) if isinstance(obj_in.options, dict) else obj_in.options
config = await system_config_crud.create(db, obj_in=obj_in_data)
return {
"id": config.id,
"config_key": config.config_key,
"config_name": config.config_name,
"category": config.category,
}
async def update_config(
self,
db: AsyncSession,
config_id: int,
obj_in: SystemConfigUpdate,
updater_id: Optional[int] = None
) -> Dict[str, Any]:
"""
更新配置
Args:
db: 数据库会话
config_id: 配置ID
obj_in: 更新数据
updater_id: 更新人ID
Returns:
更新的配置信息
"""
config = await system_config_crud.get(db, config_id)
if not config:
raise ValueError("配置不存在")
# 系统配置不允许修改某些字段
if config.is_system:
if obj_in.config_key and obj_in.config_key != config.config_key:
raise ValueError("系统配置不允许修改配置键")
if obj_in.value_type and obj_in.value_type != config.value_type:
raise ValueError("系统配置不允许修改值类型")
if obj_in.category and obj_in.category != config.category:
raise ValueError("系统配置不允许修改分类")
# 转换为字典过滤None值
update_data = obj_in.model_dump(exclude_unset=True)
# 处理复杂类型
if update_data.get("options"):
update_data["options"] = json.loads(update_data["options"].model_dump_json()) if isinstance(update_data["options"], dict) else update_data["options"]
update_data["updated_by"] = updater_id
config = await system_config_crud.update(db, db_obj=config, obj_in=update_data)
return {
"id": config.id,
"config_key": config.config_key,
"config_name": config.config_name,
"config_value": config.config_value,
}
async def batch_update_configs(
self,
db: AsyncSession,
configs: Dict[str, Any],
updater_id: Optional[int] = None
) -> Dict[str, Any]:
"""
批量更新配置
Args:
db: 数据库会话
configs: 配置键值对
updater_id: 更新人ID
Returns:
更新结果
"""
updated = await system_config_crud.batch_update(
db,
configs=configs,
updater_id=updater_id
)
return {
"count": len(updated),
"configs": [item.config_key for item in updated]
}
async def delete_config(self, db: AsyncSession, config_id: int) -> None:
"""
删除配置
Args:
db: 数据库会话
config_id: 配置ID
"""
await system_config_crud.delete(db, config_id=config_id)
# 创建全局实例
system_config_service = SystemConfigService()

View File

@@ -0,0 +1,451 @@
"""
资产调拨业务服务层
"""
from typing import List, Optional, Dict, Any
from sqlalchemy.orm import Session, selectinload
from app.crud.transfer import transfer_order, transfer_item
from app.crud.asset import asset
from app.schemas.transfer import (
AssetTransferOrderCreate,
AssetTransferOrderUpdate
)
from app.core.exceptions import NotFoundException, BusinessException
class TransferService:
"""资产调拨服务类"""
async def get_order(
self,
db: Session,
order_id: int
) -> Dict[str, Any]:
"""获取调拨单详情"""
# 使用selectinload预加载关联数据避免N+1查询
from app.models.transfer import AssetTransferOrder
from app.models.organization import Organization
from app.models.user import User
from app.models.transfer import AssetTransferItem
obj = db.query(
AssetTransferOrder
).options(
selectinload(AssetTransferOrder.items),
selectinload(AssetTransferOrder.source_org.of_type(Organization)),
selectinload(AssetTransferOrder.target_org.of_type(Organization)),
selectinload(AssetTransferOrder.applicant.of_type(User)),
selectinload(AssetTransferOrder.approver.of_type(User)),
selectinload(AssetTransferOrder.executor.of_type(User))
).filter(
AssetTransferOrder.id == order_id
).first()
if not obj:
raise NotFoundException("调拨单")
# 加载关联信息
return self._load_order_relations(db, obj)
def get_orders(
self,
db: Session,
skip: int = 0,
limit: int = 20,
transfer_type: Optional[str] = None,
approval_status: Optional[str] = None,
execute_status: Optional[str] = None,
source_org_id: Optional[int] = None,
target_org_id: Optional[int] = None,
keyword: Optional[str] = None
) -> tuple:
"""获取调拨单列表"""
items, total = transfer_order.get_multi(
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
)
# 加载关联信息
items_with_relations = [self._load_order_relations(db, item) for item in items]
return items_with_relations, total
async def create_order(
self,
db: Session,
obj_in: AssetTransferOrderCreate,
apply_user_id: int
):
"""创建调拨单"""
# 验证资产存在性和状态
assets = []
for asset_id in obj_in.asset_ids:
asset_obj = asset.get(db, asset_id)
if not asset_obj:
raise NotFoundException(f"资产ID {asset_id}")
assets.append(asset_obj)
# 验证资产状态是否允许调拨
for asset_obj in assets:
if asset_obj.status not in ["in_stock", "in_use"]:
raise BusinessException(
f"资产 {asset_obj.asset_code} 当前状态为 {asset_obj.status},不允许调拨操作"
)
# 验证资产所属机构是否为调出机构
for asset_obj in assets:
if asset_obj.organization_id != obj_in.source_org_id:
raise BusinessException(
f"资产 {asset_obj.asset_code} 所属机构与调出机构不一致"
)
# 生成调拨单号
order_code = await self._generate_order_code(db)
# 创建调拨单
db_obj = transfer_order.create(
db=db,
obj_in=obj_in,
order_code=order_code,
apply_user_id=apply_user_id
)
return self._load_order_relations(db, db_obj)
def update_order(
self,
db: Session,
order_id: int,
obj_in: AssetTransferOrderUpdate
):
"""更新调拨单"""
db_obj = transfer_order.get(db, order_id)
if not db_obj:
raise NotFoundException("调拨单")
# 只有待审批状态可以更新
if db_obj.approval_status != "pending":
raise BusinessException("只有待审批状态的调拨单可以更新")
return transfer_order.update(db, db_obj, obj_in)
def approve_order(
self,
db: Session,
order_id: int,
approval_status: str,
approval_user_id: int,
approval_remark: Optional[str] = None
):
"""审批调拨单"""
db_obj = transfer_order.get(db, order_id)
if not db_obj:
raise NotFoundException("调拨单")
# 检查状态
if db_obj.approval_status != "pending":
raise BusinessException("该调拨单已审批,无法重复审批")
# 审批
db_obj = transfer_order.approve(
db=db,
db_obj=db_obj,
approval_status=approval_status,
approval_user_id=approval_user_id,
approval_remark=approval_remark
)
return self._load_order_relations(db, db_obj)
def start_order(
self,
db: Session,
order_id: int,
execute_user_id: int
):
"""开始调拨"""
db_obj = transfer_order.get(db, order_id)
if not db_obj:
raise NotFoundException("调拨单")
# 检查状态
if db_obj.approval_status != "approved":
raise BusinessException("该调拨单未审批通过,无法开始执行")
if db_obj.execute_status != "pending":
raise BusinessException("该调拨单已开始或已完成")
# 开始调拨
db_obj = transfer_order.start(db, db_obj, execute_user_id)
# 更新明细状态为调拨中
transfer_item.batch_update_transfer_status(db, order_id, "transferring")
return self._load_order_relations(db, db_obj)
async def complete_order(
self,
db: Session,
order_id: int,
execute_user_id: int
):
"""完成调拨"""
db_obj = transfer_order.get(db, order_id)
if not db_obj:
raise NotFoundException("调拨单")
# 检查状态
if db_obj.execute_status not in ["pending", "executing"]:
raise BusinessException("该调拨单状态不允许完成操作")
# 完成调拨单
db_obj = transfer_order.complete(db, db_obj, execute_user_id)
# 更新资产机构和状态
await self._execute_transfer_logic(db, db_obj)
# 更新明细状态为完成
transfer_item.batch_update_transfer_status(db, order_id, "completed")
return self._load_order_relations(db, db_obj)
def cancel_order(
self,
db: Session,
order_id: int
) -> bool:
"""取消调拨单"""
db_obj = transfer_order.get(db, order_id)
if not db_obj:
raise NotFoundException("调拨单")
# 检查状态
if db_obj.execute_status == "completed":
raise BusinessException("已完成的调拨单无法取消")
transfer_order.cancel(db, db_obj)
return True
def delete_order(
self,
db: Session,
order_id: int
) -> bool:
"""删除调拨单"""
db_obj = transfer_order.get(db, order_id)
if not db_obj:
raise NotFoundException("调拨单")
# 只有已取消或已拒绝的可以删除
if db_obj.approval_status not in ["rejected", "cancelled"]:
raise BusinessException("只能删除已拒绝或已取消的调拨单")
return transfer_order.delete(db, order_id)
def get_order_items(
self,
db: Session,
order_id: int
) -> List:
"""获取调拨单明细"""
# 验证调拨单存在
if not transfer_order.get(db, order_id):
raise NotFoundException("调拨单")
return transfer_item.get_by_order(db, order_id)
def get_statistics(
self,
db: Session,
source_org_id: Optional[int] = None,
target_org_id: Optional[int] = None
) -> Dict[str, int]:
"""获取调拨单统计信息"""
return transfer_order.get_statistics(db, source_org_id, target_org_id)
async def _execute_transfer_logic(
self,
db: Session,
order_obj
):
"""执行调拨逻辑(完成调拨时自动执行)"""
# 获取明细
items = transfer_item.get_by_order(db, order_obj.id)
# 更新资产机构和状态
from app.services.asset_service import asset_service
from app.schemas.asset import AssetStatusTransition, AssetUpdate
for item in items:
try:
# 变更资产状态
await asset_service.change_asset_status(
db=db,
asset_id=item.asset_id,
status_transition=AssetStatusTransition(
new_status="transferring",
remark=f"调拨单: {order_obj.order_code},从{item.source_organization_id}{item.target_organization_id}"
),
operator_id=order_obj.execute_user_id
)
# 更新资产所属机构
asset_obj = asset.get(db, item.asset_id)
if asset_obj:
asset.update(
db=db,
db_obj=asset_obj,
obj_in=AssetUpdate(
organization_id=item.target_organization_id
),
updater_id=order_obj.execute_user_id
)
# 最终状态变更
target_status = "in_stock"
await asset_service.change_asset_status(
db=db,
asset_id=item.asset_id,
status_transition=AssetStatusTransition(
new_status=target_status,
remark=f"调拨完成: {order_obj.order_code}"
),
operator_id=order_obj.execute_user_id
)
except Exception as e:
# 记录失败日志
print(f"调拨资产 {item.asset_code} 失败: {str(e)}")
raise
def _load_order_relations(
self,
db: Session,
obj
) -> Dict[str, Any]:
"""加载调拨单关联信息"""
from app.models.user import User
from app.models.organization import Organization
result = {
"id": obj.id,
"order_code": obj.order_code,
"source_org_id": obj.source_org_id,
"target_org_id": obj.target_org_id,
"transfer_type": obj.transfer_type,
"title": obj.title,
"asset_count": obj.asset_count,
"apply_user_id": obj.apply_user_id,
"apply_time": obj.apply_time,
"approval_status": obj.approval_status,
"approval_user_id": obj.approval_user_id,
"approval_time": obj.approval_time,
"approval_remark": obj.approval_remark,
"execute_status": obj.execute_status,
"execute_user_id": obj.execute_user_id,
"execute_time": obj.execute_time,
"remark": obj.remark,
"created_at": obj.created_at,
"updated_at": obj.updated_at
}
# 加载调出机构
if obj.source_org_id:
source_org = db.query(Organization).filter(
Organization.id == obj.source_org_id
).first()
if source_org:
result["source_organization"] = {
"id": source_org.id,
"org_name": source_org.org_name,
"org_type": source_org.org_type
}
# 加载调入机构
if obj.target_org_id:
target_org = db.query(Organization).filter(
Organization.id == obj.target_org_id
).first()
if target_org:
result["target_organization"] = {
"id": target_org.id,
"org_name": target_org.org_name,
"org_type": target_org.org_type
}
# 加载申请人
if obj.apply_user_id:
apply_user = db.query(User).filter(User.id == obj.apply_user_id).first()
if apply_user:
result["apply_user"] = {
"id": apply_user.id,
"real_name": apply_user.real_name,
"username": apply_user.username
}
# 加载审批人
if obj.approval_user_id:
approval_user = db.query(User).filter(User.id == obj.approval_user_id).first()
if approval_user:
result["approval_user"] = {
"id": approval_user.id,
"real_name": approval_user.real_name,
"username": approval_user.username
}
# 加载执行人
if obj.execute_user_id:
execute_user = db.query(User).filter(User.id == obj.execute_user_id).first()
if execute_user:
result["execute_user"] = {
"id": execute_user.id,
"real_name": execute_user.real_name,
"username": execute_user.username
}
# 加载明细
items = transfer_item.get_by_order(db, obj.id)
result["items"] = [
{
"id": item.id,
"asset_id": item.asset_id,
"asset_code": item.asset_code,
"source_organization_id": item.source_organization_id,
"target_organization_id": item.target_organization_id,
"transfer_status": item.transfer_status
}
for item in items
]
return result
async def _generate_order_code(self, db: Session) -> str:
"""生成调拨单号"""
from datetime import datetime
import random
import string
# 日期部分
date_str = datetime.now().strftime("%Y%m%d")
# 序号部分5位随机数
sequence = "".join(random.choices(string.digits, k=5))
# 组合单号: TO-20250124-00001
order_code = f"TO-{date_str}-{sequence}"
# 检查是否重复,如果重复则重新生成
while transfer_order.get_by_code(db, order_code):
sequence = "".join(random.choices(string.digits, k=5))
order_code = f"TO-{date_str}-{sequence}"
return order_code
# 创建全局实例
transfer_service = TransferService()

6
app/utils/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
"""
工具模块
"""
from app.utils.redis_client import redis_client, init_redis, close_redis, RedisClient
__all__ = ["redis_client", "init_redis", "close_redis", "RedisClient"]

97
app/utils/asset_code.py Normal file
View File

@@ -0,0 +1,97 @@
"""
资产编码生成工具
使用PostgreSQL Advisory Lock保证并发安全
"""
from datetime import datetime
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
async def generate_asset_code(db: AsyncSession) -> str:
"""
生成资产编码
格式: AS + YYYYMMDD + 流水号(4位)
示例: AS202501240001
使用PostgreSQL Advisory Lock保证并发安全
Args:
db: 数据库会话
Returns:
资产编码
"""
# 获取当前日期字符串
date_str = datetime.now().strftime("%Y%m%d")
prefix = f"AS{date_str}"
# 使用Advisory Lock保证并发安全
# 使用日期作为锁ID避免不同日期的锁冲突
lock_id = int(date_str)
try:
# 获取锁
await db.execute(text(f"SELECT pg_advisory_lock({lock_id})"))
# 查询今天最大的序号
result = await db.execute(
text("""
SELECT CAST(SUBSTRING(asset_code FROM 13 FOR 4) AS INTEGER) as max_seq
FROM assets
WHERE asset_code LIKE :prefix
AND deleted_at IS NULL
ORDER BY asset_code DESC
LIMIT 1
"""),
{"prefix": f"{prefix}%"}
)
row = result.fetchone()
max_seq = row[0] if row and row[0] else 0
# 生成新序号
new_seq = max_seq + 1
seq_str = f"{new_seq:04d}" # 补零到4位
# 组合编码
asset_code = f"{prefix}{seq_str}"
return asset_code
finally:
# 释放锁
await db.execute(text(f"SELECT pg_advisory_unlock({lock_id})"))
def validate_asset_code(asset_code: str) -> bool:
"""
验证资产编码格式
Args:
asset_code: 资产编码
Returns:
是否有效
"""
if not asset_code or len(asset_code) != 14:
return False
# 检查前缀
if not asset_code.startswith("AS"):
return False
# 检查日期部分
date_str = asset_code[2:10]
try:
datetime.strptime(date_str, "%Y%m%d")
except ValueError:
return False
# 检查序号部分
seq_str = asset_code[10:]
if not seq_str.isdigit():
return False
return True

86
app/utils/qrcode.py Normal file
View File

@@ -0,0 +1,86 @@
"""
二维码生成工具
"""
import os
import qrcode
from datetime import datetime
from pathlib import Path
from app.core.config import settings
def generate_qr_code(asset_code: str, save_path: str = None) -> str:
"""
生成资产二维码
Args:
asset_code: 资产编码
save_path: 保存路径(可选)
Returns:
二维码文件相对路径
"""
# 如果未指定保存路径,使用默认路径
if not save_path:
qr_dir = Path(settings.QR_CODE_DIR)
else:
qr_dir = Path(save_path)
# 确保目录存在
qr_dir.mkdir(parents=True, exist_ok=True)
# 生成文件名
filename = f"{asset_code}.png"
file_path = qr_dir / filename
# 创建二维码
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=10,
border=settings.QR_CODE_BORDER,
)
qr.add_data(asset_code)
qr.make(fit=True)
# 生成图片
img = qr.make_image(fill_color="black", back_color="white")
# 保存文件
img.save(str(file_path))
# 返回相对路径
return f"{settings.QR_CODE_DIR}/{filename}"
def get_qr_code_url(asset_code: str) -> str:
"""
获取二维码URL
Args:
asset_code: 资产编码
Returns:
二维码URL
"""
filename = f"{asset_code}.png"
return f"/static/{settings.QR_CODE_DIR}/{filename}"
def delete_qr_code(asset_code: str) -> bool:
"""
删除二维码文件
Args:
asset_code: 资产编码
Returns:
是否删除成功
"""
try:
file_path = Path(settings.QR_CODE_DIR) / f"{asset_code}.png"
if file_path.exists():
file_path.unlink()
return True
return False
except Exception:
return False

219
app/utils/redis_client.py Normal file
View File

@@ -0,0 +1,219 @@
"""
Redis客户端工具类
"""
import json
import asyncio
import hashlib
from functools import wraps
from typing import Optional, Any, List, Callable
from redis.asyncio import Redis, ConnectionPool
from app.core.config import settings
class RedisClient:
"""Redis客户端"""
def __init__(self):
"""初始化Redis客户端"""
self.pool: Optional[ConnectionPool] = None
self.redis: Optional[Redis] = None
async def connect(self):
"""连接Redis"""
if not self.pool:
self.pool = ConnectionPool.from_url(
settings.REDIS_URL,
max_connections=settings.REDIS_MAX_CONNECTIONS,
decode_responses=True
)
self.redis = Redis(connection_pool=self.pool)
async def close(self):
"""关闭连接"""
if self.redis:
await self.redis.close()
if self.pool:
await self.pool.disconnect()
async def get(self, key: str) -> Optional[str]:
"""获取缓存"""
if not self.redis:
await self.connect()
return await self.redis.get(key)
async def set(
self,
key: str,
value: str,
expire: Optional[int] = None
) -> bool:
"""设置缓存"""
if not self.redis:
await self.connect()
return await self.redis.set(key, value, ex=expire)
async def delete(self, key: str) -> int:
"""删除缓存"""
if not self.redis:
await self.connect()
return await self.redis.delete(key)
async def exists(self, key: str) -> bool:
"""检查键是否存在"""
if not self.redis:
await self.connect()
return await self.redis.exists(key) > 0
async def expire(self, key: str, seconds: int) -> bool:
"""设置过期时间"""
if not self.redis:
await self.connect()
return await self.redis.expire(key, seconds)
async def keys(self, pattern: str) -> List[str]:
"""获取匹配的键"""
if not self.redis:
await self.connect()
return await self.redis.keys(pattern)
async def delete_pattern(self, pattern: str) -> int:
"""删除匹配的键"""
keys = await self.keys(pattern)
if keys:
return await self.redis.delete(*keys)
return 0
async def setex(self, key: str, time: int, value: str) -> bool:
"""设置缓存并指定过期时间(秒)"""
if not self.redis:
await self.connect()
return await self.redis.setex(key, time, value)
# JSON操作辅助方法
async def get_json(self, key: str) -> Optional[Any]:
"""获取JSON数据"""
value = await self.get(key)
if value:
try:
return json.loads(value)
except json.JSONDecodeError:
return value
return None
async def set_json(
self,
key: str,
value: Any,
expire: Optional[int] = None
) -> bool:
"""设置JSON数据"""
json_str = json.dumps(value, ensure_ascii=False)
return await self.set(key, json_str, expire)
# 缓存装饰器
def cache(self, key_prefix: str, expire: int = 300):
"""
Redis缓存装饰器改进版
Args:
key_prefix: 缓存键前缀
expire: 过期时间默认300秒5分钟
Example:
@redis_client.cache("device_types", expire=1800)
async def get_device_types(...):
pass
"""
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
# 使用MD5生成更稳定的缓存键
key_data = f"{key_prefix}:{str(args)}:{str(kwargs)}"
cache_key = f"cache:{hashlib.md5(key_data.encode()).hexdigest()}"
# 尝试从缓存获取
cached = await self.get_json(cache_key)
if cached is not None:
return cached
# 执行函数
result = await func(*args, **kwargs)
# 存入缓存
await self.set_json(cache_key, result, expire)
return result
return wrapper
return decorator
# 统计缓存辅助方法
async def cache_statistics(
self,
key: str,
data: Any,
expire: int = 600
):
"""缓存统计数据"""
return await self.set_json(key, data, expire)
async def get_cached_statistics(self, key: str) -> Optional[Any]:
"""获取缓存的统计数据"""
return await self.get_json(key)
async def invalidate_statistics_cache(self, pattern: str = "statistics:*"):
"""清除统计数据缓存"""
return await self.delete_pattern(pattern)
# 同步函数的异步缓存包装器
def cached_async(self, key_prefix: str, expire: int = 300):
"""
为同步函数提供异步缓存包装的装饰器
Args:
key_prefix: 缓存键前缀
expire: 过期时间默认300秒5分钟
Example:
@redis_client.cached_async("device_types", expire=1800)
async def cached_get_device_types(db, skip, limit, ...):
return device_type_service.get_device_types(...)
"""
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
# 使用MD5生成更稳定的缓存键
key_data = f"{key_prefix}:{str(args)}:{str(kwargs)}"
cache_key = f"cache:{hashlib.md5(key_data.encode()).hexdigest()}"
# 尝试从缓存获取
cached = await self.get_json(cache_key)
if cached is not None:
return cached
# 执行函数
result = await func(*args, **kwargs)
# 存入缓存
await self.set_json(cache_key, result, expire)
return result
return wrapper
return decorator
# 创建全局实例
redis_client = RedisClient()
async def init_redis():
"""初始化Redis连接"""
await redis_client.connect()
async def close_redis():
"""关闭Redis连接"""
await redis_client.close()