feat(auth): 添加用户权限获取接口并完善JWT令牌角色信息

- 在JWT令牌中添加用户角色信息
- 新增get_my_permissions接口用于获取当前用户权限码列表
- 重构认证回调逻辑,增加错误日志记录
- 更新用户信息获取接口使用Authorization头验证
```
This commit is contained in:
2026-04-06 00:40:08 +08:00
parent dfa8fa62a8
commit f1f8518985
71 changed files with 9402 additions and 191 deletions
+183
View File
@@ -0,0 +1,183 @@
"""审计日志服务"""
import os
from datetime import datetime, timedelta
from typing import Optional
from sqlalchemy.orm import Session
from sqlalchemy import and_
from app.models.audit_log import AuditLog
AUDIT_LOG_DIR = os.environ.get("AUDIT_LOG_DIR", "/app/logs/audit")
AUDIT_LOG_RETENTION_DAYS = int(os.environ.get("AUDIT_LOG_RETENTION_DAYS", "90"))
# 路由分类规则:(method, path_pattern, exact, action_type, action_subtype, resource_type, description)
# exact=True 精确匹配路径;exact=False 前缀匹配。
# 精确匹配规则排在前,前缀匹配按从长到短排列,避免短前缀误匹配。
_ROUTE_MAP = [
# ── 认证 ──────────────────────────────────────────────────────
("POST", "/api/auth/callback", True, "auth", "login", "user", "用户登录"),
("GET", "/api/auth/profile", True, "auth", "profile", "user", "查看个人信息"),
# ── OLT:精确路径(不含 ID 段)────────────────────────────────
("GET", "/api/olt/devices", True, "olt", "list", "olt", "查询 OLT 设备列表"),
("POST", "/api/olt/devices", True, "olt", "create", "olt", "新建 OLT 设备"),
("POST", "/api/olt/import", True, "olt", "import", "olt", "批量导入 OLT 设备"),
("POST", "/api/olt/quick-scan", True, "olt", "quick_scan", "olt", "触发快速扫描"),
("POST", "/api/olt/loopback-detection", True, "olt", "loopback", "olt", "触发环路检测"),
# OLT 端口操作(路径含 ID,前缀匹配;/ports/toggle 比 /devices/ 更具体,先列)
("POST", "/api/olt/devices/", False, "olt", "port_toggle", "olt", "切换 OLT 端口状态"),
("GET", "/api/olt/devices/", False, "olt", "port_list", "olt", "查询 OLT 端口列表"),
("PUT", "/api/olt/devices/", False, "olt", "update", "olt", "更新 OLT 设备信息"),
("DELETE", "/api/olt/devices/", False, "olt", "delete", "olt", "删除 OLT 设备"),
# 重复 MAC
("POST", "/api/olt/duplicate-macs/", False, "olt", "port_clear", "olt", "清除重复 MAC 端口占用"),
("DELETE", "/api/olt/duplicate-macs/", False, "olt", "mac_delete", "olt", "删除重复 MAC 记录"),
# 新发现设备
("PUT", "/api/olt/new-devices/", False, "olt", "device_fill", "olt", "补全新发现设备信息"),
("DELETE", "/api/olt/new-devices/", False, "olt", "device_ignore", "olt", "忽略新发现设备"),
# ── ONU 设备 ──────────────────────────────────────────────────
("POST", "/api/check/status", True, "system", "scan_trigger", "device", "手动触发全量状态扫描"),
("POST", "/api/import/upload", True, "device", "import", "device", "批量导入 ONU 设备"),
("DELETE", "/api/devices/status/all", True, "device", "status_clear", "device", "清除所有设备状态"),
("POST", "/api/devices/", False, "device", "refresh", "device", "刷新单台设备在线状态"),
("PUT", "/api/devices/", False, "device", "update", "device", "更新 ONU 设备信息"),
("DELETE", "/api/devices/", False, "device", "delete", "device", "删除 ONU 设备"),
# 设备更换(路径含 /replace,需在 refresh 前匹配,通过 _classify 特殊处理)
("POST", "/replace", False, "device", "replace", "device", "更换设备 MAC 地址"),
# ── 用户管理 ──────────────────────────────────────────────────
("POST", "/api/users", True, "user", "create", "user", "创建用户"),
("PUT", "/api/users/", False, "user", "update", "user", "更新用户信息"),
("DELETE", "/api/users/", False, "user", "delete", "user", "删除用户"),
# ── 角色权限 ──────────────────────────────────────────────────
("PUT", "/api/roles/", False, "user", "role_update", "role", "更新角色权限配置"),
# ── 系统设置 ──────────────────────────────────────────────────
("PUT", "/api/settings/check_interval", True, "system", "config_update", "system", "更新定时扫描间隔"),
("PUT", "/api/settings/", False, "system", "config_update", "system", "更新系统配置"),
# ── 库存管理(精确路径优先)───────────────────────────────────
("POST", "/api/inventory/transactions/purchase", True, "inventory", "purchase", "inventory", "物料采购入库"),
("POST", "/api/inventory/transactions/allocate", True, "inventory", "allocate", "inventory", "物料分配出库"),
("POST", "/api/inventory/transactions/return", True, "inventory", "return", "inventory", "物料退库"),
("POST", "/api/inventory/categories", True, "inventory", "cat_create", "inventory", "新建库存分类"),
("POST", "/api/inventory/materials", True, "inventory", "mat_create", "inventory", "新建物料"),
("PUT", "/api/inventory/materials/", False, "inventory", "mat_update", "inventory", "更新物料信息"),
("DELETE", "/api/inventory/materials/", False, "inventory", "mat_delete", "inventory", "删除物料"),
("POST", "/api/inventory/checks", True, "inventory", "check_create", "inventory", "发起库存盘点"),
("POST", "/api/inventory/checks/", False, "inventory", "check_adjust", "inventory", "盘点差异调整"),
("PUT", "/api/inventory/checks/", False, "inventory", "check_update", "inventory", "更新盘点记录"),
]
def _classify(method: str, path: str):
"""根据请求方法和路径推断操作分类,精确匹配优先于前缀匹配"""
# 第一轮:精确匹配
for m, p, exact, atype, subtype, rtype, desc in _ROUTE_MAP:
if exact and method == m and path == p:
return atype, subtype, rtype, desc
# 第二轮:后缀匹配(path_pattern 以 "/" 开头但不含 "/api",视为后缀)
for m, p, exact, atype, subtype, rtype, desc in _ROUTE_MAP:
if not exact and not p.startswith('/api') and method == m and path.endswith(p):
return atype, subtype, rtype, desc
# 第三轮:前缀匹配(规则列表已按从具体到宽泛排列)
for m, p, exact, atype, subtype, rtype, desc in _ROUTE_MAP:
if not exact and p.startswith('/api') and method == m and path.startswith(p):
return atype, subtype, rtype, desc
return "system", "request", "unknown", f"{method} {path}"
def _get_ip(request) -> str:
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
if request.client:
return request.client.host
return ""
def write_audit_log(
db: Session,
*,
user_id: str,
username: str,
user_role: str,
method: str,
path: str,
ip_address: str = "",
user_agent: str = "",
status_code: int,
request_params: Optional[dict] = None,
response_data: Optional[dict] = None,
error_message: Optional[str] = None,
description: Optional[str] = None,
resource_id: Optional[str] = None,
resource_name: Optional[str] = None,
):
action_type, action_subtype, resource_type, default_desc = _classify(method, path)
status = "success" if status_code < 400 else ("failed" if status_code < 500 else "error")
log = AuditLog(
user_id=user_id,
username=username,
user_role=user_role,
action_type=action_type,
action_subtype=action_subtype,
ip_address=ip_address,
user_agent=user_agent[:500] if user_agent else "",
request_method=method,
request_path=path,
status=status,
status_code=status_code,
resource_type=resource_type,
resource_id=resource_id,
resource_name=resource_name,
description=description or default_desc,
request_params=request_params,
response_data=response_data if status_code < 400 else None,
error_message=error_message,
)
db.add(log)
db.commit()
return log.id
def query_logs(
db: Session,
*,
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None,
user_id: Optional[str] = None,
username: Optional[str] = None,
action_type: Optional[str] = None,
resource_type: Optional[str] = None,
status: Optional[str] = None,
page: int = 1,
page_size: int = 50,
):
q = db.query(AuditLog)
filters = []
if start_time:
filters.append(AuditLog.action_time >= start_time)
if end_time:
filters.append(AuditLog.action_time <= end_time)
if user_id:
filters.append(AuditLog.user_id == user_id)
if username:
filters.append(AuditLog.username.ilike(f"%{username}%"))
if action_type:
filters.append(AuditLog.action_type == action_type)
if resource_type:
filters.append(AuditLog.resource_type == resource_type)
if status:
filters.append(AuditLog.status == status)
if filters:
q = q.filter(and_(*filters))
total = q.count()
items = q.order_by(AuditLog.action_time.desc()).offset((page - 1) * page_size).limit(page_size).all()
return total, items
def cleanup_old_logs(db: Session, retention_days: int = AUDIT_LOG_RETENTION_DAYS):
"""清理超过保留期的日志"""
cutoff = datetime.utcnow() - timedelta(days=retention_days)
deleted = db.query(AuditLog).filter(AuditLog.created_at < cutoff).delete()
db.commit()
return deleted
+543
View File
@@ -0,0 +1,543 @@
"""库存管理业务逻辑"""
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}
+17 -5
View File
@@ -165,14 +165,23 @@ class SSHService:
mac_match = re.search(r'([0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4})', line, re.IGNORECASE)
if mac_match:
mac = mac_match.group(1).lower()
# 提取状态字段
if 'Up' in line:
if re.search(r'\b(up|online)\b', line.lower()):
devices[mac] = 'online'
elif 'Offline' in line:
elif re.search(r'\b(offline|down)\b', line.lower()):
devices[mac] = 'offline'
return devices
def _clean_output(self, output: str) -> str:
"""清理终端控制字符和 More 分页标记,避免污染解析"""
# 移除 ANSI 转义序列
output = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', output)
# 移除 ---- More ---- 行(含前后控制字符)
output = re.sub(r'---- More ----[^\n]*', '', output)
# 将独立的 \r(不跟 \n)替换为空,避免覆盖行内容
output = re.sub(r'\r(?!\n)', '', output)
return output
def parse_onu_info(self, output: str) -> Tuple[Dict[str, ONUInfo], Dict[str, List[ONUInfo]]]:
"""增强解析:提取完整 ONU 信息
返回: (unique_devices, duplicate_devices)
@@ -180,6 +189,7 @@ class SSHService:
- duplicate_devices: MAC -> [ONUInfo, ...] (出现在多个端口的 MAC)
"""
all_records: Dict[str, List[ONUInfo]] = {}
output = self._clean_output(output)
lines = output.split('\n')
current_slot = None
@@ -227,9 +237,11 @@ class SSHService:
mac = mac_match.group(1).lower()
# 提取状态
# 提取状态:H3C OLT 不同固件版本可能输出 Up/UP/up/Online/online
status = 'offline'
if 'Up' in line:
line_lower = line.lower()
# 检查行末状态字段(避免误匹配 "Onu" 中的字母)
if re.search(r'\b(up|online)\b', line_lower):
status = 'online'
# 提取端口信息: Onu1/0/2:1 -> slot=2, port=1, port_id="1/0/2:1"