feat: 日志管理 + 质保书生产过程数据图表

- 新增 PlanLog 模型与 /logs API;计划新增/移动/投入生产/生产完成/删除均记录
  (时间/计划号/卷号/操作/状态变化/位置/操作人/说明)
- 新增「日志管理」页面 + 路由 + 导航
- 质保书:把生产完成持久化的实时数据(process_data)按单位分组生成多组柱状图

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 16:31:41 +08:00
parent 1073379b09
commit 18d78d986c
12 changed files with 310 additions and 8 deletions

View File

@@ -1,6 +1,6 @@
from fastapi import APIRouter
from app.api import auth, material, production, plan, downtime, equipment, message, dashboard
from app.api import prediction, pdi, quality, inspection, cost
from app.api import prediction, pdi, quality, inspection, cost, logs
router = APIRouter()
router.include_router(auth.router, prefix="/auth", tags=["认证"])
@@ -16,3 +16,4 @@ router.include_router(pdi.router, prefix="/pdi", tags=["PDI管理
router.include_router(quality.router, prefix="/quality", tags=["质量管理"])
router.include_router(inspection.router, prefix="/inspection", tags=["设备巡检"])
router.include_router(cost.router, prefix="/cost", tags=["成本管理"])
router.include_router(logs.router, prefix="/logs", tags=["日志管理"])

54
backend/app/api/logs.py Normal file
View File

@@ -0,0 +1,54 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
from typing import Optional
from datetime import datetime
from app.database import get_db
from app.models.plan_log import PlanLog
from app.schemas.plan import PlanLogOut
from app.schemas.common import Response, PageResponse
from app.services.auth_service import get_current_user
router = APIRouter()
def _parse_dt(s):
if not s:
return None
try:
return datetime.fromisoformat(s.replace('Z', ''))
except Exception:
return None
@router.get("/", response_model=Response[PageResponse[PlanLogOut]])
async def list_logs(
page: int = 1,
page_size: int = 50,
plan_no: Optional[str] = None,
action: Optional[str] = None,
operator: Optional[str] = None,
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db),
_ = Depends(get_current_user),
):
query = select(PlanLog).order_by(desc(PlanLog.created_at), desc(PlanLog.id))
if plan_no:
query = query.where((PlanLog.plan_no.ilike(f"%{plan_no}%")) | (PlanLog.coil_no.ilike(f"%{plan_no}%")))
if action:
query = query.where(PlanLog.action == action)
if operator:
query = query.where(PlanLog.operator.ilike(f"%{operator}%"))
_sd = _parse_dt(start_date)
if _sd:
query = query.where(PlanLog.created_at >= _sd)
_ed = _parse_dt(end_date)
if _ed:
query = query.where(PlanLog.created_at <= _ed)
total = (await db.execute(select(func.count()).select_from(query.subquery()))).scalar()
result = await db.execute(query.offset((page - 1) * page_size).limit(page_size))
items = [PlanLogOut.model_validate(x) for x in result.scalars()]
return Response.ok(PageResponse(total=total, page=page, page_size=page_size, items=items))

View File

@@ -83,8 +83,8 @@ async def create_plan(
plan = ProductionPlan(**body.model_dump(), created_by=current_user.username)
db.add(plan)
await db.flush()
# 录入即准备好;若当前无在线计划,则队首自动上线
await line_service.ensure_online(db)
await line_service.add_plan_log(db, plan, "新增", current_user.username,
to_status=plan.status, detail="录入计划")
await db.refresh(plan)
return Response.ok(PlanOut.model_validate(plan))
@@ -116,13 +116,15 @@ async def update_plan(
@router.delete("/{plan_id}", response_model=Response[dict])
async def delete_plan(plan_id: int, db: AsyncSession = Depends(get_db), _ = Depends(get_current_user)):
async def delete_plan(plan_id: int, db: AsyncSession = Depends(get_db), current_user = Depends(get_current_user)):
result = await db.execute(select(ProductionPlan).where(ProductionPlan.id == plan_id))
plan = result.scalar_one_or_none()
if not plan:
raise HTTPException(status_code=404, detail="计划不存在")
if plan.status == "producing":
raise HTTPException(status_code=400, detail="生产中的计划不可删除")
await line_service.add_plan_log(db, plan, "删除", current_user.username,
from_status=plan.status, detail="删除计划")
await db.delete(plan)
return Response.ok({"deleted": plan_id})
@@ -139,16 +141,19 @@ async def confirm_plan(plan_id: int, db: AsyncSession = Depends(get_db), _ = Dep
@router.patch("/{plan_id}/start", response_model=Response[PlanOut])
async def move_to_saddle(plan_id: int, db: AsyncSession = Depends(get_db), _ = Depends(get_current_user)):
async def move_to_saddle(plan_id: int, db: AsyncSession = Depends(get_db), current_user = Depends(get_current_user)):
"""移动:把在线计划推到上卷鞍座(等待速度/投入生产)。"""
result = await db.execute(select(ProductionPlan).where(ProductionPlan.id == plan_id))
plan = result.scalar_one_or_none()
if not plan:
raise HTTPException(status_code=404, detail="计划不存在")
before = plan.status
try:
await line_service.move_to_saddle(db, plan)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
await line_service.add_plan_log(db, plan, "移动", current_user.username, from_status=before,
to_status=plan.status, position=plan.position, detail="移动到上卷鞍座")
await db.flush()
await db.refresh(plan)
return Response.ok(PlanOut.model_validate(plan))
@@ -161,32 +166,38 @@ async def list_positions(_ = Depends(get_current_user)):
@router.patch("/{plan_id}/move", response_model=Response[PlanOut])
async def move_plan(plan_id: int, position: str = Query(...), db: AsyncSession = Depends(get_db), _ = Depends(get_current_user)):
async def move_plan(plan_id: int, position: str = Query(...), db: AsyncSession = Depends(get_db), current_user = Depends(get_current_user)):
"""移动:把计划放到所选入口位置;放到上卷鞍座才触发生产联动。"""
result = await db.execute(select(ProductionPlan).where(ProductionPlan.id == plan_id))
plan = result.scalar_one_or_none()
if not plan:
raise HTTPException(status_code=404, detail="计划不存在")
before = plan.status
try:
await line_service.place_at_position(db, plan, position)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
await line_service.add_plan_log(db, plan, "移动", current_user.username, from_status=before,
to_status=plan.status, position=position, detail=f"移动到 {position}")
await db.flush()
await db.refresh(plan)
return Response.ok(PlanOut.model_validate(plan))
@router.patch("/{plan_id}/commit", response_model=Response[PlanOut])
async def commit_producing(plan_id: int, db: AsyncSession = Depends(get_db), _ = Depends(get_current_user)):
async def commit_producing(plan_id: int, db: AsyncSession = Depends(get_db), current_user = Depends(get_current_user)):
"""投入生产:把鞍座上的计划置为生产中(兜底未实时变化的数据)。"""
result = await db.execute(select(ProductionPlan).where(ProductionPlan.id == plan_id))
plan = result.scalar_one_or_none()
if not plan:
raise HTTPException(status_code=404, detail="计划不存在")
before = plan.status
try:
await line_service.commit_plan(db, plan)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
await line_service.add_plan_log(db, plan, "投入生产", current_user.username, from_status=before,
to_status="producing", position="上卷鞍座→产线", detail="手动投入生产")
await db.flush()
await db.refresh(plan)
return Response.ok(PlanOut.model_validate(plan))

View File

@@ -11,6 +11,7 @@ from app.models.energy import EnergyRecord
from app.models.inspection import EqpChecklist, EqpChecklistItem, EqpInspectionRecord, EqpInspectionDetail
from app.models.line_state import LineState
from app.models.cost import CostRecord
from app.models.plan_log import PlanLog
__all__ = [
"User",
@@ -24,5 +25,5 @@ __all__ = [
"QcTask", "QcTaskItem", "QcDefect",
"EnergyRecord",
"EqpChecklist", "EqpChecklistItem", "EqpInspectionRecord", "EqpInspectionDetail",
"LineState", "CostRecord",
"LineState", "CostRecord", "PlanLog",
]

View File

@@ -0,0 +1,19 @@
from sqlalchemy import Column, Integer, String, DateTime, Text, func
from app.database import Base
class PlanLog(Base):
"""计划操作/状态变更日志"""
__tablename__ = "plan_logs"
id = Column(Integer, primary_key=True, index=True)
plan_id = Column(Integer, index=True, comment="计划id")
plan_no = Column(String(30), index=True, comment="计划号")
coil_no = Column(String(30), comment="冷卷号")
action = Column(String(20), comment="操作: 新增/移动/投入生产/生产完成/删除")
from_status = Column(String(20), comment="原状态")
to_status = Column(String(20), comment="新状态")
position = Column(String(40), comment="变化位置")
operator = Column(String(50), comment="操作人")
detail = Column(Text, comment="说明")
created_at = Column(DateTime, server_default=func.now(), index=True)

View File

@@ -92,6 +92,22 @@ class PlanOut(BaseModel):
from_attributes = True
class PlanLogOut(BaseModel):
id: int
plan_no: Optional[str] = None
coil_no: Optional[str] = None
action: Optional[str] = None
from_status: Optional[str] = None
to_status: Optional[str] = None
position: Optional[str] = None
operator: Optional[str] = None
detail: Optional[str] = None
created_at: datetime
class Config:
from_attributes = True
class PlanTemplate(BaseModel):
"""新增计划时回填的"上次录入"模板(不含 plan_no/卷号/时间)"""
steel_grade: Optional[str] = None

View File

@@ -16,6 +16,17 @@ from app.models.plan import ProductionPlan
from app.models.production import ProductionRecord
from app.models.downtime import DowntimeRecord
from app.models.line_state import LineState
from app.models.plan_log import PlanLog
async def add_plan_log(db: AsyncSession, plan, action, operator="系统",
from_status=None, to_status=None, position=None, detail=None):
"""写入一条计划操作/状态变更日志。"""
db.add(PlanLog(
plan_id=plan.id, plan_no=plan.plan_no, coil_no=plan.cold_coil_no,
action=action, from_status=from_status, to_status=to_status,
position=position, operator=operator, detail=detail,
))
# ── 入口位置 ──
SADDLE_NAME = "上卷鞍座" # 唯一会触发生产联动的位置
@@ -151,6 +162,8 @@ async def _produce(db: AsyncSession, plan: ProductionPlan):
process_data=plan.run_data,
)
db.add(rec)
await add_plan_log(db, plan, "生产完成", "系统", from_status="producing", to_status="produced",
position="产线", detail=f"带头到达 {TARGET_LENGTH_M:.0f} m自动产出实绩")
logger.info(f"生产完成并产生实绩: {plan.cold_coil_no or plan.plan_no}")
@@ -222,6 +235,8 @@ async def auto_commit_saddle(db: AsyncSession):
if saddle is None:
return
await commit_plan(db, saddle)
await add_plan_log(db, saddle, "投入生产", "系统", from_status="online", to_status="producing",
position="上卷鞍座→产线", detail="产线空闲,自动投入生产")
async def tick(db: AsyncSession):