feat: 实时数据持久化到计划/实绩 + 质量管理改版(钢卷信息+异常记录+继承)

- 在产计划持久化实时数据快照(run_data),物料跟踪每次轮询上报;生产完成写入实绩 process_data
- 实绩页点击行展开生产阶段数据 + 实时数据快照(测点列表)
- 质量管理默认进入异常管理,改为全宽:顶部选卷 + 钢卷信息(5列) + 异常记录表
- 异常记录新增「继承来源」列与「继承」按钮(从来源卷复制缺陷),进入即预置6行
- qc_defect 增加 inherit_source 字段

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 16:01:34 +08:00
parent 4567b87314
commit 649e667ad0
13 changed files with 139 additions and 40 deletions

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Body
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
from typing import Optional
@@ -192,6 +192,18 @@ async def commit_producing(plan_id: int, db: AsyncSession = Depends(get_db), _ =
return Response.ok(PlanOut.model_validate(plan))
@router.patch("/{plan_id}/runtime", response_model=Response[dict])
async def save_runtime(plan_id: int, body: dict = Body(...), db: AsyncSession = Depends(get_db), _ = 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":
plan.run_data = body
return Response.ok({"ok": True})
@router.get("/saddle/current", response_model=Response[Optional[PlanOut]])
async def get_saddle(db: AsyncSession = Depends(get_db), _ = Depends(get_current_user)):
"""上卷鞍座当前计划(含实时速度/已生产长度),并推进联动。"""

View File

@@ -67,6 +67,9 @@ async def _run_migrations(conn):
"ALTER TABLE production_plans ADD COLUMN IF NOT EXISTS run_speed DOUBLE PRECISION DEFAULT 0",
"ALTER TABLE production_plans ADD COLUMN IF NOT EXISTS run_length_m DOUBLE PRECISION DEFAULT 0",
"ALTER TABLE production_plans ADD COLUMN IF NOT EXISTS produced_at TIMESTAMP",
"ALTER TABLE production_plans ADD COLUMN IF NOT EXISTS run_data JSONB",
"ALTER TABLE production_records ADD COLUMN IF NOT EXISTS process_data JSONB",
"ALTER TABLE qc_defect ADD COLUMN IF NOT EXISTS inherit_source VARCHAR(30)",
# 状态列改为 VARCHAR 以适配新值
"ALTER TABLE production_plans ALTER COLUMN status TYPE VARCHAR(20) USING status::text",
# production_records 新字段

View File

@@ -43,6 +43,7 @@ class ProductionPlan(Base):
run_speed = Column(Float, default=0, comment="当前线速度 m/min")
run_length_m = Column(Float, default=0, comment="带头已生产长度 m")
produced_at = Column(DateTime, comment="生产完成时间")
run_data = Column(JSON, comment="生产期间实时数据快照")
# 兼容历史字段
shift = Column(String(10), comment="班次")

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, Float, DateTime, Text, ForeignKey, func
from sqlalchemy import Column, Integer, String, Float, DateTime, Text, ForeignKey, JSON, func
from app.database import Base
@@ -30,6 +30,7 @@ class ProductionRecord(Base):
length_per_ton = Column(Float, comment="吨钢长度 m/t")
offline_time = Column(DateTime, comment="下线时间")
status = Column(String(20), default="UNWEIGH", comment="状态: UNWEIGH/PRODUCT")
process_data = Column(JSON, comment="生产阶段实时数据快照")
# 兼容历史字段
shift_date = Column(DateTime)

View File

@@ -57,6 +57,7 @@ class QcDefect(Base):
side_middle = Column(Boolean, default=False, comment="中间")
side_drive = Column(Boolean, default=False, comment="驱动侧")
is_main = Column(Boolean, default=False, comment="主缺陷")
inherit_source = Column(String(30), nullable=True, comment="继承来源卷号")
image_url = Column(String(255), nullable=True, comment="缺陷图片URL")
# 兼容旧字段

View File

@@ -86,6 +86,7 @@ class PlanOut(BaseModel):
run_speed: Optional[float] = 0
run_length_m: Optional[float] = 0
produced_at: Optional[datetime] = None
run_data: Optional[dict] = None
class Config:
from_attributes = True

View File

@@ -94,6 +94,7 @@ class ProductionRecordOut(BaseModel):
inlet_width: Optional[float] = None
quality_grade: Optional[str] = None
operator: Optional[str] = None
process_data: Optional[dict] = None
created_at: datetime
class Config:

View File

@@ -98,6 +98,7 @@ class QcDefectBase(BaseModel):
side_middle: Optional[bool] = False
side_drive: Optional[bool] = False
is_main: Optional[bool] = False
inherit_source: Optional[str] = None
image_url: Optional[str] = None
defect_code: Optional[str] = None
defect_type: Optional[str] = None
@@ -130,6 +131,7 @@ class QcDefectUpdate(BaseModel):
side_middle: Optional[bool] = None
side_drive: Optional[bool] = None
is_main: Optional[bool] = None
inherit_source: Optional[str] = None
image_url: Optional[str] = None
defect_code: Optional[str] = None
defect_type: Optional[str] = None
@@ -156,6 +158,7 @@ class QcDefectOut(BaseModel):
side_middle: Optional[bool] = None
side_drive: Optional[bool] = None
is_main: Optional[bool] = None
inherit_source: Optional[str] = None
image_url: Optional[str] = None
production_line: Optional[str] = None
position: Optional[str] = None

View File

@@ -148,6 +148,7 @@ async def _produce(db: AsyncSession, plan: ProductionPlan):
inlet_width=plan.incoming_width,
quality_grade="A",
operator="系统",
process_data=plan.run_data,
)
db.add(rec)
logger.info(f"生产完成并产生实绩: {plan.cold_coil_no or plan.plan_no}")

View File

@@ -27,6 +27,7 @@ export const movePlan = (id, position) => request.patch(`/plan/${id}/move`, null
export const getPositions = () => request.get('/plan/positions/all')
export const commitProducing = id => request.patch(`/plan/${id}/commit`) // 投入生产
export const getSaddle = () => request.get('/plan/saddle/current')
export const saveRuntime = (id, data) => request.patch(`/plan/${id}/runtime`, data)
export const seedPlans = (count = 50) => request.post('/plan/seed', null, { params: { count } })
export const getLastPlanTemplate = () => request.get('/plan/last-template')

View File

@@ -337,7 +337,7 @@
</template>
<script>
import { getPlans, startProducing } from '@/api'
import { getPlans, startProducing, saveRuntime } from '@/api'
function rnd(base, amp) { return base + (Math.random() - 0.5) * amp }
function fix(v, n = 1) { return Number(v).toFixed(n) }
@@ -560,6 +560,20 @@ export default {
const pp = this.producingPlan
if (pp && pp.cold_coil_no) this.current.coil_no = pp.cold_coil_no
this.prodBase = pp ? { id: pp.id, len: pp.run_length_m || 0, at: Date.now(), speed: pp.run_speed || 0 } : null
// 持久化实时数据快照到在产计划
if (pp) this.persistRuntime(pp)
} catch (e) { /* ignore */ }
},
async persistRuntime(pp) {
try {
const snapshot = {
time: new Date().toISOString(),
coil_no: pp.cold_coil_no || pp.plan_no,
line_speed: Number(this.current.speed.toFixed(1)),
weld_position: Number((this.weld.position * 100).toFixed(1)),
items: this.rtItems.map(it => ({ label: it.label, value: it.val, unit: it.unit })),
}
await saveRuntime(pp.id, snapshot)
} catch (e) { /* ignore */ }
},
async movePlan(p) {

View File

@@ -117,6 +117,20 @@
<div class="sg-row"><span class="sg-k">成品质量</span><span class="sg-v">{{ fmt(selectedRow.product_quality) }} <i>%</i></span></div>
<div class="sg-row"><span class="sg-k">操作员</span><span class="sg-v">{{ selectedRow.operator || '—' }}</span></div>
</div>
<template v-if="selectedRow.process_data && selectedRow.process_data.items && selectedRow.process_data.items.length">
<div class="card-header" style="border-top:1px solid var(--border);">
实时数据快照
<span class="ch-badge">{{ selectedRow.process_data.items.length }} 测点</span>
<span class="td-muted" style="margin-left:auto;font-size:11px;">采集 {{ fmtTime(selectedRow.process_data.time) }}</span>
</div>
<div class="rt-grid">
<div v-for="(it, i) in selectedRow.process_data.items" :key="i" class="rt-cell">
<span class="rt-l">{{ it.label }}</span>
<span class="rt-v">{{ it.value }}<i>{{ it.unit }}</i></span>
</div>
</div>
</template>
</div>
<!-- 新增/编辑弹窗 -->
@@ -354,6 +368,10 @@ export default {
.sg-row { display: flex; align-items: center; gap: 10px; font-size: 12px; min-height: 22px; }
.sg-k { color: $text-muted; min-width: 64px; }
.sg-v { color: $text-primary; font-family: $font-mono; font-weight: 600; i { color: $text-muted; font-style: normal; font-family: $font-main; font-weight: 400; font-size: 11px; margin-left: 2px; } }
.rt-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px 24px; padding: 10px 18px 16px; }
.rt-cell { display: flex; justify-content: space-between; gap: 10px; font-size: 11.5px; border-bottom: 1px dashed $border; padding: 2px 0; }
.rt-l { color: $text-secondary; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rt-v { color: $sms-teal; font-family: $font-mono; font-weight: 600; white-space: nowrap; i { color: $text-muted; font-style: normal; font-family: $font-main; font-weight: 400; margin-left: 3px; } }
.form-field { display: flex; flex-direction: column; gap: 5px; }
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; }
.data-table.compact th, .data-table.compact td { padding: 4px 6px; font-size: 11.5px; }

View File

@@ -90,38 +90,24 @@
<!-- 异常管理 Tab -->
<template v-if="activeTab === 'abnormal'">
<div class="abn-layout">
<!-- 左侧钢卷列表 -->
<div class="abn-sidebar">
<div class="sidebar-header">
钢卷列表
<span class="add-btn" title="刷新" @click="fetchCoils"></span>
</div>
<div class="sidebar-search">
<input v-model="coilQuery.coil_no" class="kv-input" placeholder="搜索卷号..." style="width:100%;" @keyup.enter="fetchCoils" />
</div>
<div class="cl-list">
<div
v-for="c in coils"
:key="c.id"
:class="['cl-item', { active: selectedCoil && selectedCoil.id === c.id }]"
@click="selectCoil(c)"
>
<div class="cl-name">{{ c.coil_no }}</div>
<div class="cl-meta">
<span class="td-muted" style="font-size:10px;">{{ c.steel_grade || '—' }}</span>
<span class="td-muted" style="font-size:10px;">{{ c.spec_thickness ? c.spec_thickness + '×' + (c.spec_width || '?') : '' }}</span>
</div>
</div>
<div v-if="!coils.length" class="cl-empty">暂无钢卷</div>
<!-- 顶部选择钢卷 -->
<div class="card">
<div class="card-body" style="padding:10px 14px;">
<div class="flex-row" style="gap:12px;flex-wrap:wrap;">
<span class="kv-label">钢卷</span>
<select v-model="selectedCoilNo" class="kv-input" style="width:240px;" @change="onCoilChange">
<option value="">请选择钢卷</option>
<option v-for="c in coils" :key="c.id" :value="c.coil_no">{{ c.coil_no }} · {{ c.steel_grade || '' }}</option>
</select>
<input v-model="coilQuery.coil_no" class="kv-input" style="width:160px;" placeholder="搜索卷号" @keyup.enter="fetchCoils" />
<button class="btn btn-outline" @click="fetchCoils">刷新列表</button>
</div>
</div>
</div>
<!-- 右侧异常管理面板 -->
<div class="abn-main">
<div v-if="!selectedCoil" class="empty-tip">请从左侧选择钢卷</div>
<div v-if="!selectedCoil" class="card"><div class="card-body"><div class="empty-tip">请选择钢卷</div></div></div>
<template v-else>
<template v-else>
<!-- 钢卷信息 -->
<div class="card">
<div class="card-header" style="display:flex;align-items:center;justify-content:space-between;">
@@ -167,9 +153,10 @@
<div class="card-header" style="display:flex;align-items:center;justify-content:space-between;">
<span>异常记录 <span class="ch-badge">{{ defects.length }} </span></span>
<div class="flex-row" style="gap:8px;">
<button class="btn btn-primary" style="padding:2px 14px;font-size:11px;" :disabled="saving" @click="saveDefects">{{ saving ? '保存中...' : '保存' }}</button>
<button class="btn btn-outline" style="padding:2px 12px;font-size:11px;" @click="openInherit">继承</button>
<button class="btn btn-outline" style="padding:2px 10px;font-size:11px;" @click="addDefectRow"> 新增行</button>
<button class="btn btn-outline" style="padding:2px 10px;font-size:11px;" @click="loadDefects"> 刷新</button>
<button class="btn btn-primary" style="padding:2px 14px;font-size:11px;" :disabled="saving" @click="saveDefects">{{ saving ? '保存中...' : '保存' }}</button>
</div>
</div>
<div class="table-scroll">
@@ -185,6 +172,7 @@
<th style="width:180px;">断面位置</th>
<th style="width:160px;">缺陷代码</th>
<th style="width:110px;">程度</th>
<th style="width:90px;">继承来源</th>
<th style="width:60px;">主缺陷</th>
<th style="min-width:120px;">缺陷图片</th>
<th style="width:60px;">操作</th>
@@ -218,6 +206,7 @@
<input type="radio" :value="opt.value" v-model="d.degree" />{{ opt.label }}
</label>
</td>
<td class="td-muted" style="text-align:center;">{{ d.inherit_source || '—' }}</td>
<td style="text-align:center;"><input type="checkbox" v-model="d.is_main" /></td>
<td><input v-model="d.image_url" class="kv-input" style="width:100%;" placeholder="图片URL" /></td>
<td>
@@ -226,16 +215,34 @@
</td>
</tr>
<tr v-if="!defects.length">
<td colspan="12" class="td-muted" style="text-align:center;padding:18px;">暂无异常点击 新增行开始录入</td>
<td colspan="13" class="td-muted" style="text-align:center;padding:18px;">暂无数据</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
</template>
</template>
<!-- 继承来源选择弹窗 -->
<div v-if="inheritDialog.visible" class="modal-mask" @click.self="inheritDialog.visible=false">
<div class="modal-box" style="width:420px;">
<div class="modal-header">继承缺陷 选择来源钢卷<span class="modal-close" @click="inheritDialog.visible=false"></span></div>
<div class="modal-body">
<div class="form-field">
<div class="kv-label">来源钢卷</div>
<select v-model="inheritDialog.source" class="kv-input">
<option value="">请选择</option>
<option v-for="c in coils" :key="c.id" :value="c.coil_no" v-show="c.coil_no !== selectedCoilNo">{{ c.coil_no }} · {{ c.steel_grade || '' }}</option>
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" @click="inheritDialog.visible=false">取消</button>
<button class="btn btn-primary" @click="doInherit">确定继承</button>
</div>
</div>
</template>
</div>
<!-- 新增/编辑任务弹窗 -->
<div v-if="taskDialogVisible" class="modal-mask" @click.self="taskDialogVisible = false">
@@ -344,6 +351,7 @@ function blankDefect() {
defect_code: '',
degree: '',
is_main: false,
inherit_source: '',
image_url: '',
}
}
@@ -352,8 +360,10 @@ export default {
name: 'Quality',
data() {
return {
activeTab: 'tasks',
activeTab: 'abnormal',
saving: false,
selectedCoilNo: '',
inheritDialog: { visible: false, source: '' },
// 任务
taskData: [], taskTotal: 0,
@@ -377,7 +387,7 @@ export default {
return '—'
},
},
created() { this.fetchTasks() },
created() { this.fetchCoils(); this.fetchTasks() },
methods: {
// ── 任务 ──────────────────────────────────────
async fetchTasks() {
@@ -435,8 +445,38 @@ export default {
},
async selectCoil(c) {
this.selectedCoil = c
this.selectedCoilNo = c ? c.coil_no : ''
await this.loadDefects()
},
async onCoilChange() {
const c = this.coils.find(x => x.coil_no === this.selectedCoilNo)
if (c) await this.selectCoil(c)
else { this.selectedCoil = null; this.defects = [] }
},
openInherit() {
if (!this.selectedCoil) return
this.inheritDialog = { visible: true, source: '' }
},
async doInherit() {
const src = this.inheritDialog.source
if (!src) { this.$message.error('请选择继承来源钢卷'); return }
try {
const res = await getQcDefectsByCoil(src)
const rows = (res.data || []).map(d => ({
defect_desc: d.defect_desc || '',
start_position: d.start_position || 0,
end_position: d.end_position || 0,
upper_surface: !!d.upper_surface, lower_surface: !!d.lower_surface,
side_op: !!d.side_op, side_middle: !!d.side_middle, side_drive: !!d.side_drive,
defect_code: d.defect_code || '', degree: d.degree || '',
is_main: !!d.is_main, inherit_source: src, image_url: d.image_url || '',
}))
if (!rows.length) { this.$message.warning('来源钢卷无缺陷记录'); return }
this.defects = rows
this.inheritDialog.visible = false
this.$message.success(`已继承 ${rows.length} 条缺陷`)
} catch (e) { this.$message.error('继承失败') }
},
async reloadCoil() {
if (!this.selectedCoil) return
try {
@@ -460,10 +500,11 @@ export default {
defect_code: d.defect_code || '',
degree: d.degree || '',
is_main: !!d.is_main,
inherit_source: d.inherit_source || '',
image_url: d.image_url || '',
}))
if (!this.defects.length) this.addDefectRow()
} catch (e) { this.defects = [blankDefect()] }
while (this.defects.length < 6) this.defects.push(blankDefect())
} catch (e) { this.defects = Array.from({ length: 6 }, () => blankDefect()) }
},
addDefectRow() { this.defects.push(blankDefect()) },
removeRow(i) { this.defects.splice(i, 1) },
@@ -496,8 +537,9 @@ export default {
defect_code: d.defect_code || null,
degree: d.degree || null,
is_main: !!d.is_main,
inherit_source: d.inherit_source || null,
image_url: d.image_url || null,
}))
})).filter(d => d.defect_desc || d.defect_code || d.start_position || d.end_position)
await bulkSaveQcDefects({ coil_no: this.selectedCoil.coil_no, defects: list })
this.$message.success('保存成功')
await this.loadDefects()