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):

View File

@@ -31,6 +31,9 @@ export const saveRuntime = (id, data) => request.patch(`/plan/${id}/runtime`, da
export const seedPlans = (count = 50) => request.post('/plan/seed', null, { params: { count } })
export const getLastPlanTemplate = () => request.get('/plan/last-template')
// 日志管理
export const getPlanLogs = params => request.get('/logs/', { params })
// 成本管理
export const getCostItems = () => request.get('/cost/items')
export const getCostRecords = params => request.get('/cost/', { params })

View File

@@ -76,6 +76,12 @@ const routes = [
component: () => import('@/views/CostManagement.vue'),
meta: { title: '成本管理', icon: 'el-icon-coin', requiresAuth: true }
},
{
path: 'logs',
name: 'LogManagement',
component: () => import('@/views/LogManagement.vue'),
meta: { title: '日志管理', icon: 'el-icon-document', requiresAuth: true }
},
]
},
{ path: '*', redirect: '/' }

View File

@@ -80,6 +80,7 @@ const MENU = [
{ path: '/inspection', title: '设备巡检', icon: IC.inspection },
{ path: '/quality', title: '质量管理', icon: IC.quality },
{ path: '/cost', title: '成本管理', icon: IC.capacity },
{ path: '/logs', title: '日志管理', icon: IC.message },
]
export default {

View File

@@ -0,0 +1,123 @@
<template>
<div>
<div class="card">
<div class="card-body" style="padding:10px 14px;">
<div class="flex-row" style="flex-wrap:wrap;gap:12px;">
<div class="flex-row">
<span class="kv-label">计划/卷号</span>
<input v-model="query.plan_no" class="kv-input" style="width:150px;" @keyup.enter="fetchData" />
</div>
<div class="flex-row">
<span class="kv-label">操作</span>
<select v-model="query.action" class="kv-input" style="width:120px;">
<option value="">全部</option>
<option v-for="a in actions" :key="a" :value="a">{{ a }}</option>
</select>
</div>
<div class="flex-row">
<span class="kv-label">操作人</span>
<input v-model="query.operator" class="kv-input" style="width:120px;" @keyup.enter="fetchData" />
</div>
<div class="flex-row">
<span class="kv-label">日期</span>
<input v-model="query.start_date" type="date" class="kv-input" style="width:130px;" />
<span class="kv-label">~</span>
<input v-model="query.end_date" type="date" class="kv-input" style="width:130px;" />
</div>
<div class="flex-row">
<button class="btn btn-primary" @click="fetchData">查询</button>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
计划日志
<span class="ch-badge"> {{ total }} </span>
</div>
<div class="table-scroll" v-loading="loading">
<table class="data-table">
<thead>
<tr>
<th style="width:150px;">时间</th>
<th>计划号</th>
<th>冷卷号</th>
<th style="width:90px;">操作</th>
<th style="width:160px;">状态变化</th>
<th>位置</th>
<th style="width:100px;">操作人</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr v-for="row in tableData" :key="row.id">
<td class="td-muted">{{ fmtTime(row.created_at) }}</td>
<td class="td-num">{{ row.plan_no || '—' }}</td>
<td class="td-num">{{ row.coil_no || '—' }}</td>
<td><span :class="['badge', actionBadge(row.action)]">{{ row.action || '—' }}</span></td>
<td>
<span v-if="row.from_status || row.to_status">
<span class="td-muted">{{ statusLabel(row.from_status) }}</span>
<b style="color:var(--sms-teal);">{{ statusLabel(row.to_status) }}</b>
</span>
<span v-else class="td-muted"></span>
</td>
<td>{{ row.position || '—' }}</td>
<td>{{ row.operator || '—' }}</td>
<td class="td-muted">{{ row.detail || '—' }}</td>
</tr>
<tr v-if="!tableData.length && !loading">
<td colspan="8" class="td-muted" style="text-align:center;padding:24px;">暂无数据</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script>
import { getPlanLogs } from '@/api'
const STATUS_LABEL = { ready: '准备好', online: '在线', producing: '生产中', produced: '生产完成' }
const ACTION_BADGE = { '新增': 'badge-gray', '移动': 'badge-blue', '投入生产': 'badge-yellow', '生产完成': 'badge-green', '删除': 'badge-red' }
export default {
name: 'LogManagement',
data() {
return {
loading: false,
tableData: [], total: 0,
actions: ['新增', '移动', '投入生产', '生产完成', '删除'],
query: { page: 1, page_size: 100, plan_no: '', action: '', operator: '', start_date: '', end_date: '' },
timer: null,
}
},
created() {
this.fetchData()
this.timer = setInterval(this.fetchData, 8000)
},
beforeDestroy() { clearInterval(this.timer) },
methods: {
async fetchData() {
this.loading = true
const p = { page: this.query.page, page_size: this.query.page_size }
if (this.query.plan_no) p.plan_no = this.query.plan_no
if (this.query.action) p.action = this.query.action
if (this.query.operator) p.operator = this.query.operator
if (this.query.start_date) p.start_date = this.query.start_date + 'T00:00:00'
if (this.query.end_date) p.end_date = this.query.end_date + 'T23:59:59'
try {
const res = await getPlanLogs(p)
this.tableData = res.data.items || []
this.total = res.data.total
} finally { this.loading = false }
},
fmtTime(t) { return t ? t.slice(0, 19).replace('T', ' ') : '—' },
statusLabel(s) { return STATUS_LABEL[s] || s || '' },
actionBadge(a) { return ACTION_BADGE[a] || 'badge-gray' },
},
}
</script>

View File

@@ -269,6 +269,17 @@
<tr><th>吨钢长度</th><td>{{ fmt(certRow.length_per_ton) }} m/t</td><th>下线时间</th><td>{{ fmtTime(certRow.offline_time) }}</td></tr>
<tr><th>备注</th><td colspan="3">{{ certRow.remark || '—' }}</td></tr>
</table>
<template v-if="certCharts.length">
<div style="margin-top:20px;font-size:14px;font-weight:600;color:#222;border-bottom:1px solid #ddd;padding-bottom:6px;">生产过程数据图表</div>
<div class="cert-charts">
<div v-for="(c, i) in certCharts" :key="i" class="cert-chart-box">
<div class="cc-title">{{ c.unit }}</div>
<v-chart class="cc-chart" :option="c.option" autoresize />
</div>
</div>
</template>
<div style="margin-top:18px;display:flex;justify-content:space-between;font-size:12px;color:#666;">
<div>检验员________________</div>
<div>签发日期{{ today }}</div>
@@ -284,15 +295,25 @@
</template>
<script>
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { BarChart } from 'echarts/charts'
import { GridComponent, TooltipComponent } from 'echarts/components'
import VChart from 'vue-echarts'
import { getProductionRecords, createProductionRecord, updateProductionRecord } from '@/api'
use([CanvasRenderer, BarChart, GridComponent, TooltipComponent])
const STATUS_MAP = {
UNWEIGH: { label: '未称重', badge: 'badge-yellow' },
PRODUCT: { label: '已产出', badge: 'badge-blue' },
}
const CHART_COLORS = ['#C03639', '#409EFF', '#67C23A', '#E6A23C', '#9B59B6', '#16A085']
export default {
name: 'Production',
components: { VChart },
data() {
return {
loading: false, saving: false,
@@ -308,6 +329,32 @@ export default {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
},
certCharts() {
const pd = this.certRow && this.certRow.process_data
if (!pd || !Array.isArray(pd.items) || !pd.items.length) return []
const groups = {}
pd.items.forEach(it => {
const v = parseFloat(it.value)
if (isNaN(v)) return
const u = it.unit || '其他'
;(groups[u] = groups[u] || []).push({ label: it.label, value: v })
})
return Object.entries(groups).map(([unit, items], gi) => ({
unit,
option: {
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: 44, right: 12, top: 16, bottom: 78 },
xAxis: {
type: 'category',
data: items.map(i => i.label.replace(/(开卷机|九辊矫直机|切头剪|酸洗槽|漂洗槽|三辊张力|平整机|静电涂油机|卷取机|夹送辊|挤干辊)\s?/g, '')),
axisLabel: { rotate: 40, fontSize: 9, color: '#666' },
axisLine: { lineStyle: { color: '#ddd' } },
},
yAxis: { type: 'value', name: unit, nameTextStyle: { color: '#999', fontSize: 10 }, axisLabel: { color: '#666', fontSize: 9 }, splitLine: { lineStyle: { color: '#eee' } } },
series: [{ type: 'bar', data: items.map(i => i.value), itemStyle: { color: CHART_COLORS[gi % CHART_COLORS.length] }, barMaxWidth: 18 }],
},
}))
},
},
created() { this.fetchData() },
methods: {
@@ -384,4 +431,9 @@ export default {
th, td { border: 1px solid #888; padding: 6px 10px; }
th { background: #eee; width: 110px; text-align: left; }
}
.cert-charts { display: grid; grid-template-columns: repeat(2, 1fr); gap: 14px; margin-top: 12px; }
.cert-chart-box { border: 1px solid #e4e7ed; border-radius: 4px; padding: 6px 8px 4px; }
.cc-title { font-size: 12px; font-weight: 600; color: #333; margin-bottom: 2px; }
.cc-chart { height: 200px; width: 100%; }
@media (max-width: 600px) { .cert-charts { grid-template-columns: 1fr; } }
</style>