273 lines
8.8 KiB
Python
273 lines
8.8 KiB
Python
"""耗材管理路由
|
|
|
|
实现耗材的 CRUD 操作和批量导入
|
|
"""
|
|
|
|
from typing import List, Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
|
|
from sqlalchemy import select, func, or_
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.database import get_db
|
|
from app.models.material import Material
|
|
from app.schemas.common import ResponseModel, PaginatedData, ErrorCode
|
|
from app.schemas.material import (
|
|
MaterialCreate,
|
|
MaterialUpdate,
|
|
MaterialResponse,
|
|
MaterialImportResult,
|
|
MaterialType,
|
|
)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
@router.get("", response_model=ResponseModel[PaginatedData[MaterialResponse]])
|
|
async def get_materials(
|
|
page: int = Query(1, ge=1, description="页码"),
|
|
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
|
keyword: Optional[str] = Query(None, description="关键词搜索"),
|
|
material_type: Optional[MaterialType] = Query(None, description="类型筛选"),
|
|
is_active: Optional[bool] = Query(None, description="是否启用筛选"),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""获取耗材列表"""
|
|
query = select(Material)
|
|
|
|
if keyword:
|
|
query = query.where(
|
|
or_(
|
|
Material.material_code.contains(keyword),
|
|
Material.material_name.contains(keyword),
|
|
)
|
|
)
|
|
if material_type:
|
|
query = query.where(Material.material_type == material_type.value)
|
|
if is_active is not None:
|
|
query = query.where(Material.is_active == is_active)
|
|
|
|
query = query.order_by(Material.id.desc())
|
|
|
|
# 分页
|
|
offset = (page - 1) * page_size
|
|
query = query.offset(offset).limit(page_size)
|
|
|
|
result = await db.execute(query)
|
|
materials = result.scalars().all()
|
|
|
|
# 统计总数
|
|
count_query = select(func.count(Material.id))
|
|
if keyword:
|
|
count_query = count_query.where(
|
|
or_(
|
|
Material.material_code.contains(keyword),
|
|
Material.material_name.contains(keyword),
|
|
)
|
|
)
|
|
if material_type:
|
|
count_query = count_query.where(Material.material_type == material_type.value)
|
|
if is_active is not None:
|
|
count_query = count_query.where(Material.is_active == is_active)
|
|
|
|
total_result = await db.execute(count_query)
|
|
total = total_result.scalar() or 0
|
|
|
|
return ResponseModel(
|
|
data=PaginatedData(
|
|
items=[MaterialResponse.model_validate(m) for m in materials],
|
|
total=total,
|
|
page=page,
|
|
page_size=page_size,
|
|
total_pages=(total + page_size - 1) // page_size,
|
|
)
|
|
)
|
|
|
|
|
|
@router.get("/{material_id}", response_model=ResponseModel[MaterialResponse])
|
|
async def get_material(
|
|
material_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""获取单个耗材详情"""
|
|
result = await db.execute(select(Material).where(Material.id == material_id))
|
|
material = result.scalar_one_or_none()
|
|
|
|
if not material:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail={"code": ErrorCode.NOT_FOUND, "message": "耗材不存在"}
|
|
)
|
|
|
|
return ResponseModel(data=MaterialResponse.model_validate(material))
|
|
|
|
|
|
@router.post("", response_model=ResponseModel[MaterialResponse])
|
|
async def create_material(
|
|
data: MaterialCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""创建耗材"""
|
|
# 检查编码是否已存在
|
|
existing = await db.execute(
|
|
select(Material).where(Material.material_code == data.material_code)
|
|
)
|
|
if existing.scalar_one_or_none():
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail={"code": ErrorCode.ALREADY_EXISTS, "message": "耗材编码已存在"}
|
|
)
|
|
|
|
material = Material(**data.model_dump())
|
|
db.add(material)
|
|
await db.flush()
|
|
await db.refresh(material)
|
|
|
|
return ResponseModel(message="创建成功", data=MaterialResponse.model_validate(material))
|
|
|
|
|
|
@router.put("/{material_id}", response_model=ResponseModel[MaterialResponse])
|
|
async def update_material(
|
|
material_id: int,
|
|
data: MaterialUpdate,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""更新耗材"""
|
|
result = await db.execute(select(Material).where(Material.id == material_id))
|
|
material = result.scalar_one_or_none()
|
|
|
|
if not material:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail={"code": ErrorCode.NOT_FOUND, "message": "耗材不存在"}
|
|
)
|
|
|
|
# 检查编码是否重复
|
|
if data.material_code and data.material_code != material.material_code:
|
|
existing = await db.execute(
|
|
select(Material).where(Material.material_code == data.material_code)
|
|
)
|
|
if existing.scalar_one_or_none():
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail={"code": ErrorCode.ALREADY_EXISTS, "message": "耗材编码已存在"}
|
|
)
|
|
|
|
update_data = data.model_dump(exclude_unset=True)
|
|
for field, value in update_data.items():
|
|
setattr(material, field, value)
|
|
|
|
await db.flush()
|
|
await db.refresh(material)
|
|
|
|
return ResponseModel(message="更新成功", data=MaterialResponse.model_validate(material))
|
|
|
|
|
|
@router.delete("/{material_id}", response_model=ResponseModel)
|
|
async def delete_material(
|
|
material_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""删除耗材"""
|
|
result = await db.execute(select(Material).where(Material.id == material_id))
|
|
material = result.scalar_one_or_none()
|
|
|
|
if not material:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail={"code": ErrorCode.NOT_FOUND, "message": "耗材不存在"}
|
|
)
|
|
|
|
await db.delete(material)
|
|
|
|
return ResponseModel(message="删除成功")
|
|
|
|
|
|
@router.post("/import", response_model=ResponseModel[MaterialImportResult])
|
|
async def import_materials(
|
|
file: UploadFile = File(..., description="Excel 文件"),
|
|
update_existing: bool = Query(False, description="是否更新已存在的数据"),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""批量导入耗材
|
|
|
|
Excel 格式:耗材编码 | 耗材名称 | 单位 | 单价 | 供应商 | 类型
|
|
"""
|
|
import openpyxl
|
|
from io import BytesIO
|
|
|
|
if not file.filename.endswith(('.xlsx', '.xls')):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail={"code": ErrorCode.PARAM_ERROR, "message": "请上传 Excel 文件"}
|
|
)
|
|
|
|
content = await file.read()
|
|
wb = openpyxl.load_workbook(BytesIO(content))
|
|
ws = wb.active
|
|
|
|
total = 0
|
|
success = 0
|
|
errors = []
|
|
|
|
for row_num, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2):
|
|
if not row[0]: # 跳过空行
|
|
continue
|
|
|
|
total += 1
|
|
|
|
try:
|
|
material_code = str(row[0]).strip()
|
|
material_name = str(row[1]).strip() if row[1] else ""
|
|
unit = str(row[2]).strip() if row[2] else ""
|
|
unit_price = float(row[3]) if row[3] else 0
|
|
supplier = str(row[4]).strip() if row[4] else None
|
|
material_type = str(row[5]).strip() if row[5] else "consumable"
|
|
|
|
if not material_name or not unit:
|
|
errors.append({"row": row_num, "error": "名称或单位不能为空"})
|
|
continue
|
|
|
|
# 检查是否已存在
|
|
existing = await db.execute(
|
|
select(Material).where(Material.material_code == material_code)
|
|
)
|
|
existing_material = existing.scalar_one_or_none()
|
|
|
|
if existing_material:
|
|
if update_existing:
|
|
existing_material.material_name = material_name
|
|
existing_material.unit = unit
|
|
existing_material.unit_price = unit_price
|
|
existing_material.supplier = supplier
|
|
existing_material.material_type = material_type
|
|
success += 1
|
|
else:
|
|
errors.append({"row": row_num, "error": f"耗材编码 {material_code} 已存在"})
|
|
else:
|
|
material = Material(
|
|
material_code=material_code,
|
|
material_name=material_name,
|
|
unit=unit,
|
|
unit_price=unit_price,
|
|
supplier=supplier,
|
|
material_type=material_type,
|
|
)
|
|
db.add(material)
|
|
success += 1
|
|
|
|
except Exception as e:
|
|
errors.append({"row": row_num, "error": str(e)})
|
|
|
|
await db.flush()
|
|
|
|
return ResponseModel(
|
|
message="导入完成",
|
|
data=MaterialImportResult(
|
|
total=total,
|
|
success=success,
|
|
failed=len(errors),
|
|
errors=errors,
|
|
)
|
|
)
|