Files
zpark-ems/core/backend/app/api/v1/warehouse.py

246 lines
8.4 KiB
Python
Raw Normal View History

from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from pydantic import BaseModel
from app.core.database import get_db
from app.core.deps import get_current_user, require_roles
from app.models.maintenance import SparePart, WarehouseTransaction
from app.models.user import User
router = APIRouter(prefix="/warehouse", tags=["仓库管理"])
# ── Pydantic Schemas ────────────────────────────────────────────────
class PartCreate(BaseModel):
name: str
code: str | None = None
category: str | None = None
unit: str | None = None
current_stock: int = 0
min_stock: int = 0
location: str | None = None
supplier: str | None = None
unit_price: float | None = None
specs: dict | None = None
notes: str | None = None
class PartUpdate(BaseModel):
name: str | None = None
code: str | None = None
category: str | None = None
unit: str | None = None
min_stock: int | None = None
location: str | None = None
supplier: str | None = None
unit_price: float | None = None
specs: dict | None = None
notes: str | None = None
class TransactionCreate(BaseModel):
spare_part_id: int
type: str # "in" or "out"
quantity: int
reason: str | None = None
related_order_id: int | None = None
operator_id: int | None = None
notes: str | None = None
# ── Helpers ─────────────────────────────────────────────────────────
def _part_to_dict(p: SparePart) -> dict:
return {
"id": p.id, "name": p.name, "code": p.code,
"category": p.category, "unit": p.unit,
"current_stock": p.current_stock, "min_stock": p.min_stock,
"location": p.location, "supplier": p.supplier,
"unit_price": p.unit_price, "specs": p.specs, "notes": p.notes,
"created_at": str(p.created_at) if p.created_at else None,
"updated_at": str(p.updated_at) if p.updated_at else None,
}
def _transaction_to_dict(t: WarehouseTransaction) -> dict:
return {
"id": t.id, "spare_part_id": t.spare_part_id,
"type": t.type, "quantity": t.quantity,
"reason": t.reason, "related_order_id": t.related_order_id,
"operator_id": t.operator_id, "notes": t.notes,
"created_at": str(t.created_at) if t.created_at else None,
}
# ── Spare Parts ────────────────────────────────────────────────────
@router.get("/parts")
async def list_parts(
category: str | None = None,
search: str | None = None,
low_stock_only: bool = False,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
query = select(SparePart)
if category:
query = query.where(SparePart.category == category)
if search:
query = query.where(SparePart.name.ilike(f"%{search}%"))
if low_stock_only:
query = query.where(SparePart.current_stock <= SparePart.min_stock)
count_q = select(func.count()).select_from(query.subquery())
total = (await db.execute(count_q)).scalar()
query = query.order_by(SparePart.id.desc()).offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
return {
"total": total,
"items": [_part_to_dict(p) for p in result.scalars().all()],
}
@router.get("/parts/{part_id}")
async def get_part(
part_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
result = await db.execute(select(SparePart).where(SparePart.id == part_id))
part = result.scalar_one_or_none()
if not part:
raise HTTPException(status_code=404, detail="备件不存在")
return _part_to_dict(part)
@router.post("/parts")
async def create_part(
data: PartCreate,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_roles("admin", "energy_manager")),
):
part = SparePart(**data.model_dump())
db.add(part)
await db.flush()
return _part_to_dict(part)
@router.put("/parts/{part_id}")
async def update_part(
part_id: int,
data: PartUpdate,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_roles("admin", "energy_manager")),
):
result = await db.execute(select(SparePart).where(SparePart.id == part_id))
part = result.scalar_one_or_none()
if not part:
raise HTTPException(status_code=404, detail="备件不存在")
for k, v in data.model_dump(exclude_unset=True).items():
setattr(part, k, v)
return _part_to_dict(part)
# ── Warehouse Transactions ─────────────────────────────────────────
@router.get("/transactions")
async def list_transactions(
spare_part_id: int | None = None,
type: str | None = None,
start_date: str | None = None,
end_date: str | None = None,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
query = select(WarehouseTransaction)
if spare_part_id:
query = query.where(WarehouseTransaction.spare_part_id == spare_part_id)
if type:
query = query.where(WarehouseTransaction.type == type)
if start_date:
query = query.where(WarehouseTransaction.created_at >= datetime.fromisoformat(start_date))
if end_date:
query = query.where(WarehouseTransaction.created_at <= datetime.fromisoformat(end_date))
count_q = select(func.count()).select_from(query.subquery())
total = (await db.execute(count_q)).scalar()
query = query.order_by(WarehouseTransaction.id.desc()).offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
return {
"total": total,
"items": [_transaction_to_dict(t) for t in result.scalars().all()],
}
@router.post("/transactions")
async def create_transaction(
data: TransactionCreate,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
# Fetch the spare part
result = await db.execute(select(SparePart).where(SparePart.id == data.spare_part_id))
part = result.scalar_one_or_none()
if not part:
raise HTTPException(status_code=404, detail="备件不存在")
# Validate stock for outbound
if data.type == "out" and part.current_stock < data.quantity:
raise HTTPException(status_code=400, detail=f"库存不足,当前库存: {part.current_stock}")
# Update stock
if data.type == "in":
part.current_stock += data.quantity
elif data.type == "out":
part.current_stock -= data.quantity
else:
raise HTTPException(status_code=400, detail="类型必须为 'in''out'")
txn = WarehouseTransaction(**data.model_dump())
if not txn.operator_id:
txn.operator_id = user.id
db.add(txn)
await db.flush()
return _transaction_to_dict(txn)
# ── Statistics ─────────────────────────────────────────────────────
@router.get("/stats")
async def warehouse_stats(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
# Total parts count
total_q = select(func.count()).select_from(SparePart)
total_parts = (await db.execute(total_q)).scalar() or 0
# Low stock alerts
low_q = select(func.count()).select_from(SparePart).where(
SparePart.current_stock <= SparePart.min_stock
)
low_stock_count = (await db.execute(low_q)).scalar() or 0
# Recent transactions (last 30 days)
from datetime import timedelta
cutoff = datetime.now(timezone.utc) - timedelta(days=30)
recent_q = select(func.count()).select_from(WarehouseTransaction).where(
WarehouseTransaction.created_at >= cutoff
)
recent_transactions = (await db.execute(recent_q)).scalar() or 0
return {
"total_parts": total_parts,
"low_stock_count": low_stock_count,
"recent_transactions": recent_transactions,
}