f1f8518985
feat(auth): 添加用户权限获取接口并完善JWT令牌角色信息 - 在JWT令牌中添加用户角色信息 - 新增get_my_permissions接口用于获取当前用户权限码列表 - 重构认证回调逻辑,增加错误日志记录 - 更新用户信息获取接口使用Authorization头验证 ```
544 lines
20 KiB
Python
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}
|