feat(linkage): 鞍座改为预备生产位 + 在线改人工触发 + 计划删除

- 上卷鞍座=预备生产位(不生产);投入生产后离开鞍座、进入生产中并转入物料跟踪,鞍座随即空出
- 在线状态改为人工触发:移动到入口端才变在线,引擎不再自动上线(ensure_online 置空)
- 单卷在产:投入生产时若已有在产卷则拒绝
- 物料跟踪显示在产卷实时进度(客户端外推带头长度/进度条)
- 入口跟踪鞍座卡片改为预备生产展示(去掉进度)
- 计划管理新增删除按钮 + DELETE /plan/{id}(生产中不可删)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 15:20:16 +08:00
parent 1a6deea4bb
commit 62c484411e
6 changed files with 103 additions and 94 deletions

View File

@@ -115,6 +115,18 @@ async def update_plan(
return Response.ok(PlanOut.model_validate(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)):
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 db.delete(plan)
return Response.ok({"deleted": plan_id})
@router.patch("/{plan_id}/confirm", response_model=Response[PlanOut])
async def confirm_plan(plan_id: int, db: AsyncSession = Depends(get_db), _ = Depends(get_current_user)):
result = await db.execute(select(ProductionPlan).where(ProductionPlan.id == plan_id))
@@ -171,7 +183,10 @@ async def commit_producing(plan_id: int, db: AsyncSession = Depends(get_db), _ =
plan = result.scalar_one_or_none()
if not plan:
raise HTTPException(status_code=404, detail="计划不存在")
await line_service.commit_plan(db, plan)
try:
await line_service.commit_plan(db, plan)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
await db.flush()
await db.refresh(plan)
return Response.ok(PlanOut.model_validate(plan))

View File

@@ -51,9 +51,9 @@ async def place_at_position(db: AsyncSession, plan: ProductionPlan, position: st
plan.run_speed = 0
plan.run_length_m = 0
plan.position = position
if plan.status == "producing":
# 移动到入口端即变「在线」(人工触发,非自动)
if plan.status != "produced":
plan.status = "online"
await ensure_online(db)
def _shift_of(dt: datetime) -> str:
@@ -66,70 +66,40 @@ async def _saddle_plan(db: AsyncSession):
async def ensure_online(db: AsyncSession):
"""单卷在产:有卷在上卷鞍座/生产中时不保留在线队首;否则把最早 ready 置为唯一在线。"""
res = await db.execute(
select(ProductionPlan).where(ProductionPlan.status == "online", ProductionPlan.on_saddle != 1)
)
online = list(res.scalars())
# 是否已有在产/在鞍座的卷
res2 = await db.execute(
select(ProductionPlan).where(
(ProductionPlan.status == "producing") | (ProductionPlan.on_saddle == 1)
)
)
active = res2.scalars().first() is not None
if active:
# 正在生产时,不再保留「在线」队首,全部回退 ready等当前卷完成
for p in online:
p.status = "ready"
return
if len(online) > 1:
online.sort(key=lambda p: (p.plan_date or datetime.max, p.id))
for p in online[1:]:
p.status = "ready"
online = online[:1]
if not online:
res = await db.execute(
select(ProductionPlan)
.where(ProductionPlan.status == "ready")
.order_by(asc(ProductionPlan.plan_date), asc(ProductionPlan.id))
.limit(1)
)
head = res.scalar_one_or_none()
if head:
head.status = "online"
"""在线为人工触发:用户手动「移动到入口」才变在线,引擎不再自动上线。"""
return
async def move_to_saddle(db: AsyncSession, plan: ProductionPlan):
"""把计划移动到上卷鞍座:上卷即获得速度 → 生产中"""
"""把计划移动到上卷鞍座(预备生产位,尚未生产)"""
occupied = await _saddle_plan(db)
if occupied and occupied.id != plan.id:
raise ValueError("上卷鞍座已被占用,请等待当前钢卷生产完成")
now = datetime.now()
raise ValueError("上卷鞍座已有预备卷,请先投入生产或移走")
plan.on_saddle = 1
plan.position = SADDLE_NAME
plan.saddle_at = now
plan.saddle_at = datetime.now()
plan.run_started_at = None
plan.run_speed = 0
plan.run_length_m = 0
if plan.status != "produced":
plan.run_started_at = now
plan.run_speed = SIM_SPEED_M_MIN
plan.run_length_m = 0
plan.status = "producing"
plan.status = "online" # 在鞍座预备(待投入生产)
await ensure_online(db)
async def commit_plan(db: AsyncSession, plan: ProductionPlan):
"""投入生产(兜底):鞍座计划置为生产中"""
if plan.on_saddle != 1:
await move_to_saddle(db, plan)
return
if plan.run_started_at is None:
plan.run_started_at = datetime.now()
"""投入生产:鞍座预备卷进入生产中,离开鞍座进入产线(物料跟踪)"""
res = await db.execute(select(ProductionPlan).where(ProductionPlan.status == "producing"))
if res.scalars().first() is not None:
raise ValueError("产线已有钢卷在生产,请等待当前卷完成")
now = datetime.now()
plan.on_saddle = 0 # 离开上卷鞍座
plan.position = None
plan.saddle_at = None
plan.run_started_at = now
plan.run_speed = SIM_SPEED_M_MIN
plan.run_length_m = 0
plan.status = "producing"
await ensure_online(db)
await ensure_online(db) # 鞍座空出 → 队首自动上线
async def _produce(db: AsyncSession, plan: ProductionPlan):
@@ -183,17 +153,17 @@ async def _produce(db: AsyncSession, plan: ProductionPlan):
logger.info(f"生产完成并产生实绩: {plan.cold_coil_no or plan.plan_no}")
async def advance_saddle(db: AsyncSession):
"""推进鞍座计划:累计带头长度到 2000m → 生产完成。"""
plan = await _saddle_plan(db)
if not plan:
async def advance_production(db: AsyncSession):
"""推进产线在产卷(已离开鞍座):累计带头长度到 2000m → 生产完成。"""
res = await db.execute(select(ProductionPlan).where(ProductionPlan.status == "producing"))
plan = res.scalars().first()
if not plan or not plan.run_started_at:
return
now = datetime.now()
if plan.status == "producing" and plan.run_started_at:
elapsed = (now - plan.run_started_at).total_seconds()
plan.run_length_m = min(TARGET_LENGTH_M, (plan.run_speed or 0) / 60.0 * elapsed)
if plan.run_length_m >= TARGET_LENGTH_M:
await _produce(db, plan)
elapsed = (now - plan.run_started_at).total_seconds()
plan.run_length_m = min(TARGET_LENGTH_M, (plan.run_speed or 0) / 60.0 * elapsed)
if plan.run_length_m >= TARGET_LENGTH_M:
await _produce(db, plan)
async def _get_line_state(db: AsyncSession) -> LineState:
@@ -243,9 +213,8 @@ async def detect_downtime(db: AsyncSession):
async def tick(db: AsyncSession):
"""引擎单步:上线 + 推进鞍座 + 停机检测"""
await ensure_online(db)
await advance_saddle(db)
"""引擎单步:推进在产卷 + 停机检测(在线为人工触发,不自动上线)"""
await advance_production(db)
await detect_downtime(db)

View File

@@ -19,6 +19,7 @@ export const updateProductionRecord = (id, data) => request.put(`/production/${i
export const getPlans = params => request.get('/plan/', { params })
export const createPlan = data => request.post('/plan/', data)
export const updatePlan = (id, data) => request.put(`/plan/${id}`, data)
export const deletePlan = id => request.delete(`/plan/${id}`)
export const confirmPlan = id => request.patch(`/plan/${id}/confirm`)
export const startProducing = id => request.patch(`/plan/${id}/start`) // 移动到上卷鞍座(兼容)
export const moveToSaddle = id => request.patch(`/plan/${id}/start`)

View File

@@ -5,9 +5,9 @@
<div class="card">
<div class="card-header">
入口跟踪
<span class="ch-badge">{{ saddle ? '生产' + statusLabel(saddle.status) : '鞍座空闲' }}</span>
<span class="ch-badge">{{ saddle ? '上卷鞍座:预备生产' : '上卷鞍座空闲' }}</span>
<span style="margin-left:auto;display:flex;gap:8px;align-items:center;">
<button v-if="saddle && saddle.status !== 'producing'" class="btn btn-primary" @click="commit(saddle)">投入生产</button>
<button v-if="saddle" class="btn btn-primary" @click="commit(saddle)">投入生产</button>
<button class="btn btn-outline" @click="refreshAll">刷新</button>
</span>
</div>
@@ -38,28 +38,24 @@
</div>
</div>
<!-- 下方单个上卷鞍座唯一进入生产的工位 -->
<!-- 下方单个上卷鞍座预备生产位投入生产后离开鞍座进入物料跟踪 -->
<div :class="['pos-cell', 'saddle-cell', { filled: !!saddle }]">
<div class="pos-title saddle-title">上卷鞍座<span class="st-tag">生产</span></div>
<div class="pos-title saddle-title">上卷鞍座<span class="st-tag">预备生产位</span></div>
<div v-if="saddle" class="saddle-body">
<div class="sb-info">
<span><i>冷卷号</i>{{ saddle.cold_coil_no || saddle.plan_no }}</span>
<span><i>热卷号</i>{{ saddle.hot_coil_no || '—' }}</span>
<span><i>钢种</i>{{ saddle.steel_grade || '—' }}</span>
<span><i>规格</i>{{ fmt(saddle.product_thickness, 2) }}×{{ fmt(saddle.product_width, 0) }}</span>
<span><i>来料重[t]</i>{{ fmt(saddle.incoming_weight, 3) }}</span>
<span><i>轧制模式</i>{{ saddle.rolling_mode || '—' }}</span>
<span><i>状态</i><b :class="badgeOf(saddle.status)" style="padding:0 6px;border-radius:2px;">{{ statusLabel(saddle.status) }}</b></span>
</div>
<div class="sb-run">
<div class="sb-metric"><span class="m-v">{{ fmt(saddle.run_speed, 0) }}</span><span class="m-u">m/min</span></div>
<div class="sb-metric"><span class="m-v">{{ fmt(saddle.run_length_m, 0) }}</span><span class="m-u">/ {{ TARGET }} m</span></div>
<div class="sb-prog">
<div class="prog-bar-wrap" style="height:9px;"><div class="prog-bar-fill" :style="{ width: progPct(saddle) + '%', background: progColor(saddle) }"></div></div>
<span class="sb-pct">{{ progPct(saddle).toFixed(1) }}%</span>
</div>
<div class="sb-stage">
<span class="badge badge-yellow">预备生产</span>
<span class="sb-hint">点击右上投入生产 进入生产中并转入物料跟踪</span>
</div>
</div>
<div v-else class="pos-empty">空闲 移动计划上卷鞍座开始生产</div>
<div v-else class="pos-empty">空闲 把在线计划移动到上卷鞍座预备生产</div>
</div>
</div>
</div>
@@ -203,8 +199,8 @@ export default {
async commit(p) {
try {
await commitProducing(p.id)
this.$message.success('已投入生产')
this.fetchSaddle()
this.$message.success('已投入生产,转入物料跟踪')
this.refreshAll()
} catch (e) {
this.$message.error(e?.response?.data?.detail || '操作失败')
}
@@ -252,12 +248,9 @@ export default {
span { font-size: 12px; color: $text-primary; font-family: $font-mono; font-weight: 600; display: flex; gap: 6px;
i { color: $text-muted; font-style: normal; font-family: $font-main; font-weight: 400; min-width: 56px; } }
}
.sb-run { display: flex; align-items: center; gap: 16px; border-left: 1px solid $border; padding-left: 16px; }
.sb-metric { display: flex; flex-direction: column; line-height: 1.1;
.m-v { font-size: 22px; font-family: $font-mono; font-weight: 700; color: $sms-teal; }
.m-u { font-size: 10px; color: $text-muted; } }
.sb-prog { flex: 1; display: flex; flex-direction: column; gap: 5px;
.sb-pct { font-size: 11px; font-family: $font-mono; font-weight: 700; color: $sms-teal; text-align: right; } }
.sb-stage { display: flex; flex-direction: column; align-items: center; gap: 8px; justify-content: center;
border-left: 1px solid $border; padding-left: 16px;
.sb-hint { font-size: 11px; color: $text-muted; text-align: center; } }
.action-link { color: $accent-green; cursor: pointer; font-size: 12px; &:hover { text-decoration: underline; } }

View File

@@ -45,7 +45,12 @@
<span class="kv-label">冷卷号</span><span class="kv-value">{{ producingPlan.cold_coil_no || producingPlan.plan_no }}</span>
<span class="kv-label">钢种</span><span class="kv-value">{{ producingPlan.steel_grade || '—' }}</span>
<span class="kv-label">规格</span><span class="kv-value">{{ fmt(producingPlan.product_thickness) }}×{{ fmt(producingPlan.product_width, 0) }}</span>
<span class="kv-label">分卷</span><span class="kv-value">{{ producingPlan.split_count || 1 }}</span>
<span class="kv-label">线速度</span><span class="kv-value">{{ fmt(producingPlan.run_speed, 0) }}<span class="kv-unit"> m/min</span></span>
<span class="kv-label">带头</span><span class="kv-value">{{ fmt(prodLength, 0) }}<span class="kv-unit"> / 2000 m</span></span>
<div class="prog-bar-wrap" style="flex:1;min-width:120px;height:8px;">
<div class="prog-bar-fill" :style="{ width: prodPct + '%', background: prodPct >= 100 ? 'var(--accent-green)' : 'var(--sms-teal)' }"></div>
</div>
<span class="kv-value">{{ prodPct.toFixed(0) }}%</span>
</div>
<table class="data-table compact" v-if="onlinePlans.length">
<thead><tr><th>冷卷号</th><th>钢种</th><th>厚度</th><th>宽度</th><th>分卷</th><th>下达时间</th><th>操作</th></tr></thead>
@@ -380,6 +385,8 @@ export default {
data() {
return {
l1Online: false,
now: Date.now(),
prodBase: null, // { id, len, at, speed } 用于客户端平滑外推生产进度
current: { coil_no: '26053552', speed: 95.0 },
prev_coil_no: '26053551',
weld: { position: 0.08 },
@@ -428,8 +435,18 @@ export default {
}
},
computed: {
onlinePlans() { return this.plans.filter(p => p.status === 'online') },
onlinePlans() { return this.plans.filter(p => p.status === 'online' && p.on_saddle !== 1) },
producingPlan() { return this.plans.find(p => p.status === 'producing') || null },
prodLength() {
const p = this.producingPlan
if (!p) return 0
const b = this.prodBase
if (b && b.id === p.id) {
return Math.min(2000, b.len + b.speed / 60 * Math.max(0, (this.now - b.at) / 1000))
}
return p.run_length_m || 0
},
prodPct() { return this.producingPlan ? Math.max(0, Math.min(100, this.prodLength / 2000 * 100)) : 0 },
equipments() {
const n = EQUIPMENTS.length
const xStart = 50, xEnd = 1850
@@ -540,10 +557,10 @@ export default {
try {
const res = await getPlans({ page: 1, page_size: 50 })
this.plans = res.data.items || []
// 把生产中的卷号同步到产线显示
if (this.producingPlan && this.producingPlan.cold_coil_no) {
this.current.coil_no = this.producingPlan.cold_coil_no
}
// 把生产中的卷号同步到产线显示 + 记录进度外推基准
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
} catch (e) { /* ignore */ }
},
async movePlan(p) {
@@ -623,6 +640,7 @@ export default {
return { coil, gap, speed, aux }
},
tick() {
this.now = Date.now()
this.weld.position = (this.weld.position + 0.012) % 1
// 新一卷开始时滚动卷号
if (this.weld.position < 0.012) {

View File

@@ -78,8 +78,10 @@
<td><span :class="['badge', statusBadge(row.status)]">{{ statusLabel(row.status) }}</span></td>
<td @click.stop>
<span class="action-link" @click="openDialog(row)">编辑</span>
<span v-if="row.status === 'online' || row.status === 'ready'"
<span v-if="row.status === 'ready' || row.status === 'online'"
class="action-link" style="color:var(--accent-green)" @click="openMove(row)">移动</span>
<span v-if="row.status !== 'producing'"
class="action-link" style="color:var(--accent-red)" @click="removeRow(row)">删除</span>
</td>
</tr>
<tr v-if="!tableData.length && !loading">
@@ -268,7 +270,7 @@
</template>
<script>
import { getPlans, createPlan, updatePlan, confirmPlan as apiConfirm, movePlan, getLastPlanTemplate } from '@/api'
import { getPlans, createPlan, updatePlan, deletePlan, confirmPlan as apiConfirm, movePlan, getLastPlanTemplate } from '@/api'
const SADDLE = '上卷鞍座'
const POSITIONS = [
@@ -381,6 +383,17 @@ export default {
this.$message.success('已上线')
this.fetchData()
},
async removeRow(row) {
if (!confirm(`确认删除计划 ${row.cold_coil_no || row.plan_no}`)) return
try {
await deletePlan(row.id)
this.$message.success('已删除')
if (this.selectedRow && this.selectedRow.id === row.id) this.selectedRow = null
this.fetchData()
} catch (e) {
this.$message.error(e?.response?.data?.detail || '删除失败')
}
},
openMove(row) {
this.moveDialog = { visible: true, plan: row, target: row.position || '' }
},