diff --git a/backend/app/api/plan.py b/backend/app/api/plan.py index 2aa5a02..f9b8907 100644 --- a/backend/app/api/plan.py +++ b/backend/app/api/plan.py @@ -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)) diff --git a/backend/app/services/line_service.py b/backend/app/services/line_service.py index e9ad8e0..ac4e739 100644 --- a/backend/app/services/line_service.py +++ b/backend/app/services/line_service.py @@ -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) diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index a9cc5e1..287d8e7 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -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`) diff --git a/frontend/src/views/EntryTracking.vue b/frontend/src/views/EntryTracking.vue index f4cc164..5a6dea0 100644 --- a/frontend/src/views/EntryTracking.vue +++ b/frontend/src/views/EntryTracking.vue @@ -5,9 +5,9 @@
入口跟踪 - {{ saddle ? '生产:' + statusLabel(saddle.status) : '鞍座空闲' }} + {{ saddle ? '上卷鞍座:预备生产' : '上卷鞍座:空闲' }} - +
@@ -38,28 +38,24 @@
- +
-
上卷鞍座生产工位
+
上卷鞍座预备生产位
冷卷号{{ saddle.cold_coil_no || saddle.plan_no }} + 热卷号{{ saddle.hot_coil_no || '—' }} 钢种{{ saddle.steel_grade || '—' }} 规格{{ fmt(saddle.product_thickness, 2) }}×{{ fmt(saddle.product_width, 0) }} 来料重[t]{{ fmt(saddle.incoming_weight, 3) }} 轧制模式{{ saddle.rolling_mode || '—' }} - 状态{{ statusLabel(saddle.status) }}
-
-
{{ fmt(saddle.run_speed, 0) }}m/min
-
{{ fmt(saddle.run_length_m, 0) }}/ {{ TARGET }} m
-
-
- {{ progPct(saddle).toFixed(1) }}% -
+
+ 预备生产 + 点击右上「投入生产」→ 进入生产中并转入物料跟踪
-
空闲 — 移动计划到「上卷鞍座」开始生产
+
空闲 — 把在线计划移动到「上卷鞍座」预备生产
@@ -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; } } diff --git a/frontend/src/views/Material.vue b/frontend/src/views/Material.vue index f35c3a8..d3528d5 100644 --- a/frontend/src/views/Material.vue +++ b/frontend/src/views/Material.vue @@ -45,7 +45,12 @@ 冷卷号{{ producingPlan.cold_coil_no || producingPlan.plan_no }} 钢种{{ producingPlan.steel_grade || '—' }} 规格{{ fmt(producingPlan.product_thickness) }}×{{ fmt(producingPlan.product_width, 0) }} - 分卷{{ producingPlan.split_count || 1 }} + 线速度{{ fmt(producingPlan.run_speed, 0) }} m/min + 带头{{ fmt(prodLength, 0) }} / 2000 m +
+
+
+ {{ prodPct.toFixed(0) }}% @@ -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) { diff --git a/frontend/src/views/Plan.vue b/frontend/src/views/Plan.vue index 2d03c43..d3c1a19 100644 --- a/frontend/src/views/Plan.vue +++ b/frontend/src/views/Plan.vue @@ -78,8 +78,10 @@ @@ -268,7 +270,7 @@
冷卷号钢种厚度宽度分卷下达时间操作
{{ statusLabel(row.status) }} 编辑 - 移动 + 删除