597 lines
19 KiB
Python
597 lines
19 KiB
Python
"""
|
||
资产管理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)
|