Files
H3ConuMS-v2/backend/app/services/inventory_service.py
T
v6ole f1f8518985 ```
feat(auth): 添加用户权限获取接口并完善JWT令牌角色信息

- 在JWT令牌中添加用户角色信息
- 新增get_my_permissions接口用于获取当前用户权限码列表
- 重构认证回调逻辑,增加错误日志记录
- 更新用户信息获取接口使用Authorization头验证
```
2026-04-06 00:40:08 +08:00

544 lines
20 KiB
Python

"""库存管理业务逻辑"""
import uuid
from datetime import date, datetime
from typing import Optional, List
from sqlalchemy.orm import Session
from sqlalchemy import func, text
from fastapi import HTTPException
from app.models.inventory import (
MaterialCategory, Material, InventoryBatch,
SerialDevice, InventoryTransaction, InventoryCheck
)
def _gen_no(prefix: str) -> str:
return f"{prefix}{datetime.now().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}"
# ── 物料分类 ──────────────────────────────────────────────────────────────────
def get_categories(db: Session) -> List[MaterialCategory]:
return db.query(MaterialCategory).order_by(MaterialCategory.id).all()
def create_category(db: Session, name: str, code: str, description: Optional[str]) -> MaterialCategory:
if db.query(MaterialCategory).filter(MaterialCategory.code == code).first():
raise HTTPException(status_code=400, detail="分类代码已存在")
cat = MaterialCategory(name=name, code=code, description=description)
db.add(cat)
db.commit()
db.refresh(cat)
return cat
# ── 物料 ──────────────────────────────────────────────────────────────────────
def get_materials(db: Session, skip: int, limit: int, category_id: Optional[int], keyword: Optional[str]):
q = db.query(Material)
if category_id:
q = q.filter(Material.category_id == category_id)
if keyword:
q = q.filter(Material.name.contains(keyword) | Material.model.contains(keyword))
total = q.count()
items = q.order_by(Material.id).offset(skip).limit(limit).all()
result = []
for m in items:
total_qty = db.query(func.sum(InventoryBatch.quantity)).filter(
InventoryBatch.material_id == m.id
).scalar() or 0
avail_qty = db.query(func.sum(InventoryBatch.available_quantity)).filter(
InventoryBatch.material_id == m.id
).scalar() or 0
item = {
"id": m.id,
"category_id": m.category_id,
"category_name": m.category.name if m.category else None,
"name": m.name,
"model": m.model,
"specification": m.specification,
"brand": m.brand,
"unit": m.unit,
"safe_quantity": m.safe_quantity,
"notes": m.notes,
"total_quantity": total_qty,
"available_quantity": avail_qty,
"created_at": m.created_at,
}
result.append(item)
return {"total": total, "items": result}
def create_material(db: Session, data: dict) -> Material:
m = Material(**data)
db.add(m)
db.commit()
db.refresh(m)
return m
def update_material(db: Session, material_id: int, data: dict) -> Material:
m = db.query(Material).filter(Material.id == material_id).first()
if not m:
raise HTTPException(status_code=404, detail="物料不存在")
for k, v in data.items():
if v is not None:
setattr(m, k, v)
db.commit()
db.refresh(m)
return m
def delete_material(db: Session, material_id: int):
m = db.query(Material).filter(Material.id == material_id).first()
if not m:
raise HTTPException(status_code=404, detail="物料不存在")
has_stock = db.query(InventoryBatch).filter(
InventoryBatch.material_id == material_id,
InventoryBatch.available_quantity > 0,
).first()
if has_stock:
raise HTTPException(status_code=400, detail="该物料仍有库存,请先出库后再删除")
db.delete(m)
db.commit()
# ── 采购入库 ──────────────────────────────────────────────────────────────────
def purchase_in(db: Session, data: dict, operator_id: int) -> dict:
material_id = data["material_id"]
m = db.query(Material).filter(Material.id == material_id).first()
if not m:
raise HTTPException(status_code=404, detail="物料不存在")
serial_nos = data.pop("serial_nos", None) or []
quantity = data["quantity"]
batch = InventoryBatch(
material_id=material_id,
batch_no=data["batch_no"],
quantity=quantity,
available_quantity=quantity,
supplier=data.get("supplier"),
purchase_date=data.get("purchase_date"),
purchase_price=data.get("purchase_price"),
location=data.get("location"),
status="in_stock",
)
db.add(batch)
db.flush()
# 高价值设备:逐个创建序列号记录
for sn in serial_nos:
sd = SerialDevice(
material_id=material_id,
batch_id=batch.id,
serial_no=sn,
status="in_stock",
)
db.add(sd)
txn = InventoryTransaction(
transaction_no=_gen_no("PI"),
transaction_type="purchase_in",
material_id=material_id,
batch_id=batch.id,
quantity=quantity,
from_status=None,
to_status="in_stock",
operator_id=operator_id,
notes=data.get("notes"),
)
db.add(txn)
db.commit()
return {"message": "入库成功", "batch_id": batch.id, "transaction_no": txn.transaction_no}
# ── 领用出库 ──────────────────────────────────────────────────────────────────
def allocate_out(db: Session, data: dict, operator_id: int) -> dict:
serial_device_id = data.get("serial_device_id")
batch_id = data.get("batch_id")
quantity = data.get("quantity", 1)
if serial_device_id:
sd = db.query(SerialDevice).filter(SerialDevice.id == serial_device_id).first()
if not sd:
raise HTTPException(status_code=404, detail="设备不存在")
if sd.status != "in_stock":
raise HTTPException(status_code=400, detail=f"设备当前状态为 {sd.status},无法领用")
batch = db.query(InventoryBatch).filter(InventoryBatch.id == sd.batch_id).first()
if batch:
batch.available_quantity = max(0, batch.available_quantity - 1)
sd.status = "allocated"
sd.installed_info = data.get("installation_info")
txn = InventoryTransaction(
transaction_no=_gen_no("AO"),
transaction_type="allocate_out",
material_id=sd.material_id,
batch_id=sd.batch_id,
serial_device_id=sd.id,
quantity=1,
from_status="in_stock",
to_status="allocated",
operator_id=operator_id,
project_name=data.get("project_name"),
installation_info=data.get("installation_info"),
notes=data.get("notes"),
)
db.add(txn)
db.commit()
return {"message": "领用成功", "transaction_no": txn.transaction_no}
elif batch_id:
batch = db.query(InventoryBatch).filter(InventoryBatch.id == batch_id).first()
if not batch:
raise HTTPException(status_code=404, detail="批次不存在")
if batch.available_quantity < quantity:
raise HTTPException(status_code=400, detail="库存不足")
batch.available_quantity -= quantity
# 方案三:领用时补录设备标识,存入 installation_info
device_info = data.get("installation_info") or {}
if isinstance(device_info, str):
device_info = {}
mac = data.get("mac_address", "").strip() if data.get("mac_address") else ""
sn = data.get("serial_no", "").strip() if data.get("serial_no") else ""
asset = data.get("asset_no", "").strip() if data.get("asset_no") else ""
if mac or sn or asset:
device_info = {k: v for k, v in {"mac_address": mac, "serial_no": sn, "asset_no": asset}.items() if v}
txn = InventoryTransaction(
transaction_no=_gen_no("AO"),
transaction_type="allocate_out",
material_id=batch.material_id,
batch_id=batch_id,
quantity=quantity,
from_status="in_stock",
to_status="allocated",
operator_id=operator_id,
project_name=data.get("project_name"),
installation_info=device_info if device_info else None,
notes=data.get("notes"),
)
db.add(txn)
db.commit()
return {"message": "领用成功", "transaction_no": txn.transaction_no}
raise HTTPException(status_code=400, detail="需要指定序列号设备或批次")
# ── 退库 ──────────────────────────────────────────────────────────────────────
def return_in(db: Session, serial_device_id: int, return_type: str, notes: Optional[str], operator_id: int) -> dict:
sd = db.query(SerialDevice).filter(SerialDevice.id == serial_device_id).first()
if not sd:
raise HTTPException(status_code=404, detail="设备不存在")
from_status = sd.status
if return_type == "scrap":
to_status = "scrapped"
elif return_type == "repair":
to_status = "repairing"
else:
to_status = "in_stock"
# 归还时恢复批次可用数量
if sd.batch_id:
batch = db.query(InventoryBatch).filter(InventoryBatch.id == sd.batch_id).first()
if batch:
batch.available_quantity += 1
sd.status = to_status
txn = InventoryTransaction(
transaction_no=_gen_no("RI"),
transaction_type="return_in" if return_type != "scrap" else "scrap_out",
material_id=sd.material_id,
batch_id=sd.batch_id,
serial_device_id=sd.id,
quantity=1,
from_status=from_status,
to_status=to_status,
operator_id=operator_id,
notes=notes,
)
db.add(txn)
db.commit()
return {"message": "退库成功", "transaction_no": txn.transaction_no}
# ── 批次查询 ──────────────────────────────────────────────────────────────────
def get_batches_by_material(db: Session, material_id: int) -> list:
batches = db.query(InventoryBatch).filter(
InventoryBatch.material_id == material_id,
InventoryBatch.available_quantity > 0,
).order_by(InventoryBatch.id.desc()).all()
return [
{
"id": b.id,
"batch_no": b.batch_no,
"quantity": b.quantity,
"available_quantity": b.available_quantity,
"supplier": b.supplier,
"location": b.location,
}
for b in batches
]
# ── 序列号设备查询 ────────────────────────────────────────────────────────────
def get_serial_devices(db: Session, skip: int, limit: int, material_id: Optional[int], status: Optional[str], keyword: Optional[str]):
q = db.query(SerialDevice)
if material_id:
q = q.filter(SerialDevice.material_id == material_id)
if status:
q = q.filter(SerialDevice.status == status)
if keyword:
q = q.filter(
SerialDevice.serial_no.contains(keyword) |
SerialDevice.mac_address.contains(keyword) |
SerialDevice.asset_no.contains(keyword)
)
total = q.count()
items = q.order_by(SerialDevice.id.desc()).offset(skip).limit(limit).all()
result = []
for sd in items:
result.append({
"id": sd.id,
"material_id": sd.material_id,
"material_name": sd.material.name if sd.material else None,
"batch_id": sd.batch_id,
"serial_no": sd.serial_no,
"mac_address": sd.mac_address,
"asset_no": sd.asset_no,
"status": sd.status,
"current_location": sd.current_location,
"installed_info": sd.installed_info,
"notes": sd.notes,
"created_at": sd.created_at,
"updated_at": sd.updated_at,
})
return {"total": total, "items": result}
def get_transaction_detail(db: Session, transaction_id: int) -> dict:
t = db.query(InventoryTransaction).filter(InventoryTransaction.id == transaction_id).first()
if not t:
raise HTTPException(status_code=404, detail="记录不存在")
m = db.query(Material).filter(Material.id == t.material_id).first()
batch = db.query(InventoryBatch).filter(InventoryBatch.id == t.batch_id).first() if t.batch_id else None
sd = db.query(SerialDevice).filter(SerialDevice.id == t.serial_device_id).first() if t.serial_device_id else None
return {
"id": t.id,
"transaction_no": t.transaction_no,
"transaction_type": t.transaction_type,
"material_id": t.material_id,
"material_name": m.name if m else None,
"material_model": m.model if m else None,
"material_brand": m.brand if m else None,
"quantity": t.quantity,
"from_status": t.from_status,
"to_status": t.to_status,
"operator_id": t.operator_id,
"project_name": t.project_name,
"installation_info": t.installation_info,
"notes": t.notes,
"created_at": t.created_at,
"batch": {
"id": batch.id,
"batch_no": batch.batch_no,
"supplier": batch.supplier,
"purchase_date": str(batch.purchase_date) if batch.purchase_date else None,
"purchase_price": str(batch.purchase_price) if batch.purchase_price else None,
"location": batch.location,
} if batch else None,
"serial_device": {
"id": sd.id,
"serial_no": sd.serial_no,
"mac_address": sd.mac_address,
"asset_no": sd.asset_no,
} if sd else None,
}
def get_transactions(db: Session, skip: int, limit: int, transaction_type: Optional[str], material_id: Optional[int]):
q = db.query(InventoryTransaction)
if transaction_type:
q = q.filter(InventoryTransaction.transaction_type == transaction_type)
if material_id:
q = q.filter(InventoryTransaction.material_id == material_id)
total = q.count()
items = q.order_by(InventoryTransaction.id.desc()).offset(skip).limit(limit).all()
result = []
for t in items:
m = db.query(Material).filter(Material.id == t.material_id).first()
result.append({
"id": t.id,
"transaction_no": t.transaction_no,
"transaction_type": t.transaction_type,
"material_id": t.material_id,
"material_name": m.name if m else None,
"quantity": t.quantity,
"from_status": t.from_status,
"to_status": t.to_status,
"operator_id": t.operator_id,
"project_name": t.project_name,
"installation_info": t.installation_info,
"notes": t.notes,
"created_at": t.created_at,
})
return {"total": total, "items": result}
# ── 库存总览 ──────────────────────────────────────────────────────────────────
def get_summary(db: Session) -> dict:
total_materials = db.query(Material).count()
total_qty = db.query(func.sum(InventoryBatch.quantity)).scalar() or 0
avail_qty = db.query(func.sum(InventoryBatch.available_quantity)).scalar() or 0
allocated_qty = total_qty - avail_qty
# 低库存物料数
low_stock = db.execute(text("""
SELECT COUNT(*) FROM (
SELECT m.id, m.safe_quantity, COALESCE(SUM(b.available_quantity), 0) AS avail
FROM materials m
LEFT JOIN inventory_batches b ON b.material_id = m.id
GROUP BY m.id, m.safe_quantity
HAVING COALESCE(SUM(b.available_quantity), 0) <= m.safe_quantity AND m.safe_quantity > 0
) t
""")).scalar() or 0
# 按分类统计
rows = db.execute(text("""
SELECT c.name, COUNT(m.id) AS material_count,
COALESCE(SUM(b.available_quantity), 0) AS available
FROM material_categories c
LEFT JOIN materials m ON m.category_id = c.id
LEFT JOIN inventory_batches b ON b.material_id = m.id
GROUP BY c.id, c.name
ORDER BY c.id
""")).fetchall()
category_stats = [{"name": r[0], "material_count": r[1], "available": r[2]} for r in rows]
return {
"total_materials": total_materials,
"total_quantity": total_qty,
"available_quantity": avail_qty,
"allocated_quantity": allocated_qty,
"low_stock_count": low_stock,
"category_stats": category_stats,
}
# ── 盘点 ──────────────────────────────────────────────────────────────────────
def create_check(db: Session, data: dict, checker_id: int) -> InventoryCheck:
material_id = data["material_id"]
batch_id = data.get("batch_id")
if batch_id:
batch = db.query(InventoryBatch).filter(InventoryBatch.id == batch_id).first()
book_qty = batch.available_quantity if batch else 0
else:
book_qty = db.query(func.sum(InventoryBatch.available_quantity)).filter(
InventoryBatch.material_id == material_id
).scalar() or 0
actual_qty = data["actual_quantity"]
diff = actual_qty - book_qty
check = InventoryCheck(
check_no=_gen_no("CK"),
check_date=data["check_date"],
checker_id=checker_id,
material_id=material_id,
batch_id=batch_id,
book_quantity=book_qty,
actual_quantity=actual_qty,
difference=diff,
reason=data.get("reason"),
adjusted=False,
)
db.add(check)
db.commit()
db.refresh(check)
return check
def adjust_check(db: Session, check_id: int, operator_id: int) -> dict:
check = db.query(InventoryCheck).filter(InventoryCheck.id == check_id).first()
if not check:
raise HTTPException(status_code=404, detail="盘点记录不存在")
if check.adjusted:
raise HTTPException(status_code=400, detail="已调整过")
if check.batch_id:
batch = db.query(InventoryBatch).filter(InventoryBatch.id == check.batch_id).first()
if batch:
batch.available_quantity = check.actual_quantity
else:
# 调整第一个批次(简化处理)
batch = db.query(InventoryBatch).filter(
InventoryBatch.material_id == check.material_id
).first()
if batch:
batch.available_quantity = check.actual_quantity
txn = InventoryTransaction(
transaction_no=_gen_no("ADJ"),
transaction_type="adjust",
material_id=check.material_id,
batch_id=check.batch_id,
quantity=abs(check.difference or 0),
from_status="in_stock",
to_status="in_stock",
operator_id=operator_id,
notes=f"盘点调整:{check.check_no},差异 {check.difference}",
)
db.add(txn)
check.adjusted = True
db.commit()
return {"message": "调整成功"}
def get_checks(db: Session, skip: int, limit: int, material_id: Optional[int]):
q = db.query(InventoryCheck)
if material_id:
q = q.filter(InventoryCheck.material_id == material_id)
total = q.count()
items = q.order_by(InventoryCheck.id.desc()).offset(skip).limit(limit).all()
result = []
for c in items:
m = db.query(Material).filter(Material.id == c.material_id).first()
result.append({
"id": c.id,
"check_no": c.check_no,
"check_date": c.check_date,
"material_id": c.material_id,
"material_name": m.name if m else None,
"batch_id": c.batch_id,
"book_quantity": c.book_quantity,
"actual_quantity": c.actual_quantity,
"difference": c.difference,
"reason": c.reason,
"adjusted": c.adjusted,
"created_at": c.created_at,
})
return {"total": total, "items": result}