246 lines
8.4 KiB
Python
246 lines
8.4 KiB
Python
|
|
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,
|
||
|
|
}
|