Fix API compatibility and add user/role/permission and asset import/export

This commit is contained in:
2026-01-25 23:36:23 +08:00
commit 501d11e14e
371 changed files with 68853 additions and 0 deletions

View File

@@ -0,0 +1,596 @@
"""
资产管理API路由
"""
from typing import List, Optional, Dict, Any
from datetime import datetime, date
from decimal import Decimal
from io import BytesIO, StringIO
import csv
import zipfile
from fastapi import APIRouter, Depends, HTTPException, status, Query, UploadFile, File
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db, get_current_user
from app.core.response import success_response, paginated_response
from app.schemas.asset import (
AssetCreate,
AssetUpdate,
AssetResponse,
AssetWithRelations,
AssetStatusHistoryResponse,
AssetStatusTransition,
AssetQueryParams
)
from app.services.asset_service import asset_service
from app.models.asset import Asset
from app.models.device_type import DeviceType
from app.models.organization import Organization
from app.models.brand_supplier import Brand, Supplier
from app.utils.case import convert_keys_to_snake
router = APIRouter()
def _parse_date(value: Optional[str]) -> Optional[date]:
if not value:
return None
if isinstance(value, date):
return value
value_str = str(value).strip()
if not value_str:
return None
for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y.%m.%d"):
try:
return datetime.strptime(value_str, fmt).date()
except ValueError:
continue
return None
def _parse_decimal(value: Optional[str]) -> Optional[Decimal]:
if value is None:
return None
value_str = str(value).strip()
if not value_str:
return None
value_str = value_str.replace(",", "")
try:
return Decimal(value_str)
except Exception:
return None
def _parse_int(value: Optional[str]) -> Optional[int]:
if value is None:
return None
value_str = str(value).strip()
if not value_str:
return None
try:
return int(float(value_str))
except Exception:
return None
def _column_to_index(cell_ref: str) -> int:
letters = "".join(ch for ch in cell_ref if ch.isalpha())
index = 0
for ch in letters:
index = index * 26 + (ord(ch.upper()) - ord("A") + 1)
return max(index - 1, 0)
def _read_xlsx_rows(content: bytes) -> List[List[str]]:
import xml.etree.ElementTree as ET
with zipfile.ZipFile(BytesIO(content)) as zf:
shared_strings: List[str] = []
if "xl/sharedStrings.xml" in zf.namelist():
shared_xml = zf.read("xl/sharedStrings.xml")
root = ET.fromstring(shared_xml)
ns = {"a": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
for si in root.findall(".//a:si", ns):
text_parts = [t.text or "" for t in si.findall(".//a:t", ns)]
shared_strings.append("".join(text_parts))
sheet_name = None
for name in zf.namelist():
if name.startswith("xl/worksheets/") and name.endswith(".xml"):
sheet_name = name
break
if not sheet_name:
return []
sheet_xml = zf.read(sheet_name)
root = ET.fromstring(sheet_xml)
ns = {"a": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
rows: List[List[str]] = []
for row in root.findall(".//a:sheetData/a:row", ns):
row_values: List[str] = []
for cell in row.findall("a:c", ns):
cell_ref = cell.get("r", "")
col_index = _column_to_index(cell_ref)
while len(row_values) <= col_index:
row_values.append("")
cell_type = cell.get("t")
value = ""
if cell_type == "s":
v = cell.find("a:v", ns)
if v is not None and v.text is not None:
try:
value = shared_strings[int(v.text)]
except Exception:
value = v.text
elif cell_type == "inlineStr":
text_parts = [t.text or "" for t in cell.findall(".//a:t", ns)]
value = "".join(text_parts)
else:
v = cell.find("a:v", ns)
if v is not None and v.text is not None:
value = v.text
row_values[col_index] = value
rows.append(row_values)
return rows
def _rows_to_dicts(headers: List[str], rows: List[List[str]]) -> List[Dict[str, Any]]:
header_keys = [convert_keys_to_snake(h.strip()) if isinstance(h, str) else "" for h in headers]
items: List[Dict[str, Any]] = []
for row in rows:
row_dict: Dict[str, Any] = {}
for idx, key in enumerate(header_keys):
if not key:
continue
value = row[idx] if idx < len(row) else ""
if isinstance(value, str):
value = value.strip()
row_dict[key] = value
if any(value not in ("", None) for value in row_dict.values()):
items.append(row_dict)
return items
@router.get("/", response_model=List[AssetResponse])
async def get_assets(
page: int = Query(1, ge=1, description="页码"),
page_size: 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: AsyncSession = 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**: 采购日期结束
"""
skip = (page - 1) * page_size
items, total = await asset_service.get_assets(
db=db,
skip=skip,
limit=page_size,
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 paginated_response(items, total, page, page_size)
@router.get("/statistics")
async def get_asset_statistics(
organization_id: Optional[int] = Query(None, description="网点ID筛选"),
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取资产统计信息
- **organization_id**: 网点ID筛选
返回资产总数、总价值、状态分布等统计信息
"""
data = await asset_service.get_statistics(db, organization_id)
return success_response(data=data)
@router.get("/{asset_id}", response_model=AssetWithRelations)
async def get_asset(
asset_id: int,
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取资产详情
- **asset_id**: 资产ID
返回资产详情及其关联信息
"""
data = await asset_service.get_asset(db, asset_id)
return success_response(data=data)
@router.get("/scan/{asset_code}", response_model=AssetWithRelations)
async def scan_asset(
asset_code: str,
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
扫码查询资产
- **asset_code**: 资产编码
通过扫描二维码查询资产详情
"""
data = await asset_service.scan_asset_by_code(db, asset_code)
return success_response(data=data)
@router.post("/", response_model=AssetResponse, status_code=status.HTTP_201_CREATED)
async def create_asset(
obj_in: AssetCreate,
db: AsyncSession = 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**: 备注
"""
data = await asset_service.create_asset(
db=db,
obj_in=obj_in,
creator_id=current_user.id
)
return success_response(data=data)
@router.put("/{asset_id}", response_model=AssetResponse)
async def update_asset(
asset_id: int,
obj_in: AssetUpdate,
db: AsyncSession = 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**: 备注
"""
data = await asset_service.update_asset(
db=db,
asset_id=asset_id,
obj_in=obj_in,
updater_id=current_user.id
)
return success_response(data=data)
@router.delete("/{asset_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_asset(
asset_id: int,
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
删除资产
- **asset_id**: 资产ID
软删除资产
"""
await asset_service.delete_asset(
db=db,
asset_id=asset_id,
deleter_id=current_user.id
)
return success_response(message="删除成功")
@router.post("/import")
async def import_assets(
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
current_user=Depends(get_current_user)
):
content = await file.read()
rows: List[List[str]] = []
if zipfile.is_zipfile(BytesIO(content)):
try:
rows = _read_xlsx_rows(content)
except Exception as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
else:
try:
text = content.decode("utf-8-sig")
except Exception:
text = content.decode(errors="ignore")
reader = csv.reader(StringIO(text))
rows = [row for row in reader]
if not rows:
return success_response(data={"total": 0, "success": 0, "failed": 0, "errors": []})
headers = rows[0]
data_rows = rows[1:]
records = _rows_to_dicts(headers, data_rows)
if not records:
return success_response(data={"total": 0, "success": 0, "failed": 0, "errors": []})
device_type_result = await db.execute(
select(DeviceType).where(DeviceType.deleted_at.is_(None))
)
org_result = await db.execute(
select(Organization).where(Organization.deleted_at.is_(None))
)
brand_result = await db.execute(select(Brand).where(Brand.deleted_at.is_(None)))
supplier_result = await db.execute(select(Supplier).where(Supplier.deleted_at.is_(None)))
device_type_map = {dt.type_name.strip().lower(): dt.id for dt in device_type_result.scalars().all()}
org_map = {org.org_name.strip().lower(): org.id for org in org_result.scalars().all()}
brand_map = {b.brand_name.strip().lower(): b.id for b in brand_result.scalars().all()}
supplier_map = {s.supplier_name.strip().lower(): s.id for s in supplier_result.scalars().all()}
total = len(records)
success_count = 0
errors: List[Dict[str, Any]] = []
for idx, row in enumerate(records, start=2):
try:
asset_name = row.get("asset_name")
if not asset_name:
raise ValueError("asset_name is required")
device_type_id = _parse_int(row.get("device_type_id"))
if not device_type_id:
name = row.get("device_type_name") or row.get("device_type") or row.get("type_name")
if name:
device_type_id = device_type_map.get(str(name).strip().lower())
if not device_type_id:
raise ValueError("device_type_id is required")
organization_id = _parse_int(row.get("organization_id"))
if not organization_id:
name = row.get("organization_name") or row.get("org_name")
if name:
organization_id = org_map.get(str(name).strip().lower())
if not organization_id:
raise ValueError("organization_id is required")
brand_id = _parse_int(row.get("brand_id"))
if not brand_id:
name = row.get("brand_name")
if name:
brand_id = brand_map.get(str(name).strip().lower())
supplier_id = _parse_int(row.get("supplier_id"))
if not supplier_id:
name = row.get("supplier_name")
if name:
supplier_id = supplier_map.get(str(name).strip().lower())
purchase_date = _parse_date(row.get("purchase_date"))
purchase_price = _parse_decimal(row.get("purchase_price"))
warranty_period = _parse_int(row.get("warranty_period"))
known_keys = {
"asset_name",
"device_type_id",
"device_type_name",
"device_type",
"type_name",
"brand_id",
"brand_name",
"model",
"model_name",
"serial_number",
"supplier_id",
"supplier_name",
"purchase_date",
"purchase_price",
"warranty_period",
"organization_id",
"organization_name",
"org_name",
"location",
"remark",
}
dynamic_attributes = {
key: value
for key, value in row.items()
if key not in known_keys and value not in ("", None)
}
asset_payload = AssetCreate(
asset_name=asset_name,
device_type_id=device_type_id,
organization_id=organization_id,
brand_id=brand_id,
model=row.get("model") or row.get("model_name"),
serial_number=row.get("serial_number"),
supplier_id=supplier_id,
purchase_date=purchase_date,
purchase_price=purchase_price,
warranty_period=warranty_period,
location=row.get("location"),
remark=row.get("remark"),
dynamic_attributes=dynamic_attributes,
)
await asset_service.create_asset(db=db, obj_in=asset_payload, creator_id=current_user.id)
success_count += 1
except Exception as exc:
errors.append({"row": idx, "message": str(exc)})
failed_count = total - success_count
return success_response(
data={
"total": total,
"success": success_count,
"failed": failed_count,
"errors": errors,
}
)
@router.get("/export")
async def export_assets(
db: AsyncSession = Depends(get_db),
current_user=Depends(get_current_user)
):
result = await db.execute(
select(Asset)
.where(Asset.deleted_at.is_(None))
.options(
selectinload(Asset.device_type),
selectinload(Asset.brand),
selectinload(Asset.supplier),
selectinload(Asset.organization),
)
.order_by(Asset.id.asc())
)
assets = list(result.scalars().all())
output = StringIO()
writer = csv.writer(output)
writer.writerow(
[
"assetCode",
"assetName",
"deviceTypeName",
"brandName",
"modelName",
"serialNumber",
"orgName",
"location",
"status",
"purchaseDate",
"purchasePrice",
"warrantyExpireDate",
]
)
for asset in assets:
writer.writerow(
[
asset.asset_code,
asset.asset_name,
asset.device_type.type_name if asset.device_type else "",
asset.brand.brand_name if asset.brand else "",
asset.model or "",
asset.serial_number or "",
asset.organization.org_name if asset.organization else "",
asset.location or "",
asset.status,
asset.purchase_date.isoformat() if asset.purchase_date else "",
str(asset.purchase_price) if asset.purchase_price is not None else "",
asset.warranty_expire_date.isoformat() if asset.warranty_expire_date else "",
]
)
csv_bytes = output.getvalue().encode("utf-8-sig")
headers = {"Content-Disposition": "attachment; filename=assets.csv"}
return StreamingResponse(BytesIO(csv_bytes), media_type="text/csv", headers=headers)
# ===== 状态管理 =====
@router.post("/{asset_id}/status", response_model=AssetResponse)
async def change_asset_status(
asset_id: int,
status_transition: AssetStatusTransition,
db: AsyncSession = 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: 已丢失
"""
data = await 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
)
return success_response(data=data)
@router.get("/{asset_id}/history", response_model=List[AssetStatusHistoryResponse])
async 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: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
获取资产状态历史
- **asset_id**: 资产ID
- **skip**: 跳过条数
- **limit**: 返回条数
返回资产的所有状态变更记录
"""
data = await asset_service.get_asset_status_history(db, asset_id, skip, limit)
return success_response(data=data)