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:
@@ -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)):
|
||||
"""上卷鞍座当前计划(含实时速度/已生产长度),并推进联动。"""
|
||||
|
||||
@@ -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 新字段
|
||||
|
||||
@@ -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="班次")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
# 兼容旧字段
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user