feat(linkage): 计划-鞍座-实绩-停机联动 + 成本管理页

后端:
- 计划录入即「准备好」,队首(最早)自动「在线」(唯一)
- 新增上卷鞍座联动引擎 line_service:移动→鞍座→(有速度/投入生产)→生产中
  →带头达2000m→生产完成并自动产生实绩、持久化运行数据
- 停机自动检测:线速度为0持续>10min 自动新增待补充停机记录,恢复后自动结束
- /plan/start=移动到鞍座, 新增 /plan/{id}/commit 投入生产, /plan/saddle/current,
  /plan/seed 批量插入(轧制力模式);后台引擎循环自动推进
- 新增成本管理:CostRecord 模型 + /cost CRUD + 9 类成本项(乳化液/盐酸/碱/电/水/蒸汽…)

前端:
- 入口跟踪重构为单个上卷鞍座工位(实时速度/带头长度进度/投入生产)+待上卷卡片+队列,
  计划列表/卡片/队列均可「移动」
- 新增成本管理页(成本项切换 + 柱+线图 + 明细表 + 时间筛选 + 新增),布局参考乳化液耗量统计

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 13:57:59 +08:00
parent 2144f13b88
commit 9fb3dcb785
18 changed files with 969 additions and 149 deletions

View File

@@ -1,6 +1,6 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api import auth, material, production, plan, downtime, equipment, message, dashboard from app.api import auth, material, production, plan, downtime, equipment, message, dashboard
from app.api import prediction, pdi, quality, inspection from app.api import prediction, pdi, quality, inspection, cost
router = APIRouter() router = APIRouter()
router.include_router(auth.router, prefix="/auth", tags=["认证"]) router.include_router(auth.router, prefix="/auth", tags=["认证"])
@@ -15,3 +15,4 @@ router.include_router(prediction.router, prefix="/prediction", tags=["工艺预
router.include_router(pdi.router, prefix="/pdi", tags=["PDI管理"]) router.include_router(pdi.router, prefix="/pdi", tags=["PDI管理"])
router.include_router(quality.router, prefix="/quality", tags=["质量管理"]) router.include_router(quality.router, prefix="/quality", tags=["质量管理"])
router.include_router(inspection.router, prefix="/inspection", tags=["设备巡检"]) router.include_router(inspection.router, prefix="/inspection", tags=["设备巡检"])
router.include_router(cost.router, prefix="/cost", tags=["成本管理"])

112
backend/app/api/cost.py Normal file
View File

@@ -0,0 +1,112 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, asc
from typing import Optional
from datetime import datetime
from app.database import get_db
from app.models.cost import CostRecord
from app.schemas.cost import CostItem, CostCreate, CostUpdate, CostOut
from app.schemas.common import Response
from app.services.auth_service import get_current_user
router = APIRouter()
# 预定义成本/消耗项
COST_ITEMS = [
{"item": "emulsion", "item_name": "乳化液", "unit": "L", "unit_cost_label": "L/t"},
{"item": "acid", "item_name": "盐酸", "unit": "L", "unit_cost_label": "L/t"},
{"item": "alkali", "item_name": "脱脂碱液", "unit": "kg", "unit_cost_label": "kg/t"},
{"item": "power", "item_name": "电耗", "unit": "kWh", "unit_cost_label": "kWh/t"},
{"item": "water", "item_name": "新水", "unit": "", "unit_cost_label": "m³/t"},
{"item": "steam", "item_name": "蒸汽", "unit": "t", "unit_cost_label": "kg/t"},
{"item": "antirust_oil","item_name": "防锈油", "unit": "L", "unit_cost_label": "L/t"},
{"item": "inhibitor", "item_name": "缓蚀剂", "unit": "kg", "unit_cost_label": "g/t"},
{"item": "roll", "item_name": "辊耗", "unit": "", "unit_cost_label": "支/kt"},
]
_ITEM_MAP = {x["item"]: x for x in COST_ITEMS}
def _parse_dt(s):
if not s:
return None
try:
return datetime.fromisoformat(s.replace('Z', ''))
except Exception:
return None
@router.get("/items", response_model=Response[list[CostItem]])
async def list_items(_ = Depends(get_current_user)):
return Response.ok([CostItem(**x) for x in COST_ITEMS])
@router.get("/", response_model=Response[list[CostOut]])
async def list_records(
item: 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(CostRecord).order_by(asc(CostRecord.record_date))
if item:
query = query.where(CostRecord.item == item)
_sd = _parse_dt(start_date)
if _sd:
query = query.where(CostRecord.record_date >= _sd)
_ed = _parse_dt(end_date)
if _ed:
query = query.where(CostRecord.record_date <= _ed)
result = await db.execute(query)
return Response.ok([CostOut.model_validate(r) for r in result.scalars()])
@router.post("/", response_model=Response[CostOut])
async def create_record(
body: CostCreate,
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user),
):
meta = _ITEM_MAP.get(body.item)
if not meta:
raise HTTPException(status_code=400, detail="未知成本项")
rec = CostRecord(
item=body.item,
item_name=meta["item_name"],
unit=meta["unit"],
record_date=body.record_date,
shift_a=body.shift_a or 0,
shift_b=body.shift_b or 0,
unit_cost=body.unit_cost or 0,
remark=body.remark,
created_by=current_user.username,
)
db.add(rec)
await db.flush()
return Response.ok(CostOut.model_validate(rec))
@router.put("/{rec_id}", response_model=Response[CostOut])
async def update_record(
rec_id: int,
body: CostUpdate,
db: AsyncSession = Depends(get_db),
_ = Depends(get_current_user),
):
rec = await db.get(CostRecord, rec_id)
if not rec:
raise HTTPException(status_code=404, detail="记录不存在")
for k, v in body.model_dump(exclude_none=True).items():
setattr(rec, k, v)
await db.flush()
return Response.ok(CostOut.model_validate(rec))
@router.delete("/{rec_id}", response_model=Response[dict])
async def delete_record(rec_id: int, db: AsyncSession = Depends(get_db), _ = Depends(get_current_user)):
rec = await db.get(CostRecord, rec_id)
if not rec:
raise HTTPException(status_code=404, detail="记录不存在")
await db.delete(rec)
return Response.ok({"deleted": rec_id})

View File

@@ -9,6 +9,7 @@ from app.models.plan import ProductionPlan
from app.schemas.plan import PlanCreate, PlanUpdate, PlanOut, PlanTemplate from app.schemas.plan import PlanCreate, PlanUpdate, PlanOut, PlanTemplate
from app.schemas.common import Response, PageResponse from app.schemas.common import Response, PageResponse
from app.services.auth_service import get_current_user from app.services.auth_service import get_current_user
from app.services import line_service
router = APIRouter() router = APIRouter()
@@ -42,6 +43,8 @@ async def list_plans(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_ = Depends(get_current_user), _ = Depends(get_current_user),
): ):
# 拉取前保证队首自动上线(鞍座推进/停机检测由 /saddle 与后台引擎负责)
await line_service.ensure_online(db)
query = select(ProductionPlan).order_by(desc(ProductionPlan.plan_date)) query = select(ProductionPlan).order_by(desc(ProductionPlan.plan_date))
if status: if status:
query = query.where(ProductionPlan.status == status) query = query.where(ProductionPlan.status == status)
@@ -80,6 +83,9 @@ async def create_plan(
plan = ProductionPlan(**body.model_dump(), created_by=current_user.username) plan = ProductionPlan(**body.model_dump(), created_by=current_user.username)
db.add(plan) db.add(plan)
await db.flush() await db.flush()
# 录入即准备好;若当前无在线计划,则队首自动上线
await line_service.ensure_online(db)
await db.refresh(plan)
return Response.ok(PlanOut.model_validate(plan)) return Response.ok(PlanOut.model_validate(plan))
@@ -121,21 +127,85 @@ async def confirm_plan(plan_id: int, db: AsyncSession = Depends(get_db), _ = Dep
@router.patch("/{plan_id}/start", response_model=Response[PlanOut]) @router.patch("/{plan_id}/start", response_model=Response[PlanOut])
async def start_producing(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), _ = Depends(get_current_user)):
"""移动到入口开始生产本条→producing其它 producing→online单卷在产)。""" """移动:把在线计划推到上卷鞍座(等待速度/投入生产)。"""
result = await db.execute(select(ProductionPlan).where(ProductionPlan.id == plan_id)) result = await db.execute(select(ProductionPlan).where(ProductionPlan.id == plan_id))
plan = result.scalar_one_or_none() plan = result.scalar_one_or_none()
if not plan: if not plan:
raise HTTPException(status_code=404, detail="计划不存在") raise HTTPException(status_code=404, detail="计划不存在")
# 其它正在生产的全部回退为在线(强制单卷在产) try:
others = await db.execute( await line_service.move_to_saddle(db, plan)
select(ProductionPlan).where( except ValueError as e:
ProductionPlan.status == "producing", raise HTTPException(status_code=400, detail=str(e))
ProductionPlan.id != plan_id,
)
)
for o in others.scalars():
o.status = "online"
plan.status = "producing"
await db.flush() await db.flush()
await db.refresh(plan)
return Response.ok(PlanOut.model_validate(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)):
"""投入生产:把鞍座上的计划置为生产中(兜底未实时变化的数据)。"""
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="计划不存在")
await line_service.commit_plan(db, plan)
await db.flush()
await db.refresh(plan)
return Response.ok(PlanOut.model_validate(plan))
@router.get("/saddle/current", response_model=Response[Optional[PlanOut]])
async def get_saddle(db: AsyncSession = Depends(get_db), _ = Depends(get_current_user)):
"""上卷鞍座当前计划(含实时速度/已生产长度),并推进联动。"""
await line_service.tick(db)
res = await db.execute(select(ProductionPlan).where(ProductionPlan.on_saddle == 1))
plan = res.scalars().first()
return Response.ok(PlanOut.model_validate(plan) if plan else None)
@router.post("/seed", response_model=Response[dict])
async def seed_plans(count: int = 50, db: AsyncSession = Depends(get_db), current_user = Depends(get_current_user)):
"""批量插入准备好的计划(轧制力模式),用于演示联动。"""
import random
from datetime import timedelta
res = await db.execute(select(func.count()).select_from(ProductionPlan))
base = (res.scalar() or 0)
grades = ["QStE340TM", "SPHC", "SAPH440", "B510L", "QTGLG-2019"]
now = datetime.now()
created = 0
for i in range(count):
seq = base + i + 1
it = round(random.uniform(2.0, 5.0), 2)
pt = round(it - random.uniform(0.0, 0.1), 2)
iw = random.choice([1000, 1050, 1100, 1150, 1200, 1250])
wt = round(random.uniform(15.0, 26.0), 3)
plan = ProductionPlan(
plan_no=f"PL{now:%Y%m%d}{seq:04d}",
plan_date=now + timedelta(minutes=i),
status="ready",
cold_coil_no=f"C{now:%y%m%d}{seq:04d}",
hot_coil_no=f"H{now:%y%m%d}{seq:04d}",
steel_grade=random.choice(grades),
incoming_thickness=it,
product_thickness=pt,
deviation_upper=0.05,
deviation_lower=-0.05,
incoming_width=iw,
product_width=iw - random.choice([0, 4, 6]),
packaging_req=random.choice(["裸包", "筒包"]),
trim_req=random.choice(["切边", "不切边"]),
rolling_mode="轧制力模式",
coil_diameter=random.choice([1450, 1500, 1550]),
split_count=1,
next_process="冷轧",
incoming_weight=wt,
incoming_od=random.choice([1400, 1450, 1500]),
split_weights=[wt],
created_by=current_user.username,
)
db.add(plan)
created += 1
await db.flush()
await line_service.ensure_online(db)
return Response.ok({"created": created})

View File

@@ -59,6 +59,13 @@ async def _run_migrations(conn):
"ALTER TABLE production_plans ADD COLUMN IF NOT EXISTS incoming_weight DOUBLE PRECISION", "ALTER TABLE production_plans ADD COLUMN IF NOT EXISTS incoming_weight DOUBLE PRECISION",
"ALTER TABLE production_plans ADD COLUMN IF NOT EXISTS incoming_od DOUBLE PRECISION", "ALTER TABLE production_plans ADD COLUMN IF NOT EXISTS incoming_od DOUBLE PRECISION",
"ALTER TABLE production_plans ADD COLUMN IF NOT EXISTS split_weights JSONB", "ALTER TABLE production_plans ADD COLUMN IF NOT EXISTS split_weights JSONB",
# 上卷鞍座 / 生产联动字段
"ALTER TABLE production_plans ADD COLUMN IF NOT EXISTS on_saddle INTEGER DEFAULT 0",
"ALTER TABLE production_plans ADD COLUMN IF NOT EXISTS saddle_at TIMESTAMP",
"ALTER TABLE production_plans ADD COLUMN IF NOT EXISTS run_started_at TIMESTAMP",
"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",
# 状态列改为 VARCHAR 以适配新值 # 状态列改为 VARCHAR 以适配新值
"ALTER TABLE production_plans ALTER COLUMN status TYPE VARCHAR(20) USING status::text", "ALTER TABLE production_plans ALTER COLUMN status TYPE VARCHAR(20) USING status::text",
# production_records 新字段 # production_records 新字段

View File

@@ -14,15 +14,21 @@ async def lifespan(app: FastAPI):
logger.info("启动推拉酸洗线二级系统...") logger.info("启动推拉酸洗线二级系统...")
await init_db() await init_db()
await _create_default_admin() await _create_default_admin()
await _ensure_line_state()
# 启动L1报文接收服务UDP # 启动L1报文接收服务UDP
from app.services.message_parser import l1_server from app.services.message_parser import l1_server
import app.services.material_service # noqa: 注册报文处理器 import app.services.material_service # noqa: 注册报文处理器
await l1_server.start() await l1_server.start()
# 启动产线联动引擎(计划上线/上卷鞍座/实绩/停机自动检测)
from app.services.line_service import run_engine_loop
engine_task = asyncio.create_task(run_engine_loop())
logger.info("系统启动完成") logger.info("系统启动完成")
yield yield
engine_task.cancel()
l1_server.stop() l1_server.stop()
logger.info("系统已停止") logger.info("系统已停止")
@@ -48,6 +54,17 @@ async def _create_default_admin():
logger.info("默认管理员已创建: admin / admin123") logger.info("默认管理员已创建: admin / admin123")
async def _ensure_line_state():
"""确保产线状态单例行存在(避免并发首建竞争)。"""
from app.database import AsyncSessionLocal
from app.models.line_state import LineState
async with AsyncSessionLocal() as db:
if not await db.get(LineState, 1):
db.add(LineState(id=1, speed=0))
await db.commit()
app = FastAPI( app = FastAPI(
title="推拉酸洗线二级系统", title="推拉酸洗线二级系统",
description="Pickling Line Level-2 MES System", description="Pickling Line Level-2 MES System",

View File

@@ -9,6 +9,8 @@ from app.models.pdi import PDIRecord
from app.models.quality import QcTask, QcTaskItem, QcDefect from app.models.quality import QcTask, QcTaskItem, QcDefect
from app.models.energy import EnergyRecord from app.models.energy import EnergyRecord
from app.models.inspection import EqpChecklist, EqpChecklistItem, EqpInspectionRecord, EqpInspectionDetail from app.models.inspection import EqpChecklist, EqpChecklistItem, EqpInspectionRecord, EqpInspectionDetail
from app.models.line_state import LineState
from app.models.cost import CostRecord
__all__ = [ __all__ = [
"User", "User",
@@ -22,4 +24,5 @@ __all__ = [
"QcTask", "QcTaskItem", "QcDefect", "QcTask", "QcTaskItem", "QcDefect",
"EnergyRecord", "EnergyRecord",
"EqpChecklist", "EqpChecklistItem", "EqpInspectionRecord", "EqpInspectionDetail", "EqpChecklist", "EqpChecklistItem", "EqpInspectionRecord", "EqpInspectionDetail",
"LineState", "CostRecord",
] ]

View File

@@ -0,0 +1,20 @@
from sqlalchemy import Column, Integer, String, Float, DateTime, Text, func
from app.database import Base
class CostRecord(Base):
"""成本/消耗记录(乳化液、盐酸、电、水、蒸汽等)"""
__tablename__ = "cost_records"
id = Column(Integer, primary_key=True, index=True)
item = Column(String(30), index=True, nullable=False, comment="成本项编码")
item_name = Column(String(50), comment="成本项名称")
unit = Column(String(20), comment="计量单位")
record_date = Column(DateTime, nullable=False, index=True, comment="记录时间")
shift_a = Column(Float, default=0, comment="A班量")
shift_b = Column(Float, default=0, comment="B班量")
unit_cost = Column(Float, default=0, comment="吨耗量")
remark = Column(Text, comment="备注")
created_by = Column(String(50))
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())

View File

@@ -0,0 +1,13 @@
from sqlalchemy import Column, Integer, Float, DateTime, func
from app.database import Base
class LineState(Base):
"""产线运行状态单例id=1用于停机自动检测。"""
__tablename__ = "line_state"
id = Column(Integer, primary_key=True, index=True)
speed = Column(Float, default=0, comment="当前线速度 m/min")
zero_since = Column(DateTime, comment="速度为0的起始时间")
open_downtime_id = Column(Integer, comment="当前未结束的自动停机记录id")
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())

View File

@@ -35,6 +35,14 @@ class ProductionPlan(Base):
incoming_od = Column(Float, comment="来料外径 mm") incoming_od = Column(Float, comment="来料外径 mm")
split_weights = Column(JSON, comment="分卷重量 [t,...]") split_weights = Column(JSON, comment="分卷重量 [t,...]")
# 上卷鞍座 / 生产联动
on_saddle = Column(Integer, default=0, comment="是否在上卷鞍座 0/1")
saddle_at = Column(DateTime, comment="移动到鞍座时间")
run_started_at = Column(DateTime, comment="投入生产(有速度)时间")
run_speed = Column(Float, default=0, comment="当前线速度 m/min")
run_length_m = Column(Float, default=0, comment="带头已生产长度 m")
produced_at = Column(DateTime, comment="生产完成时间")
# 兼容历史字段 # 兼容历史字段
shift = Column(String(10), comment="班次") shift = Column(String(10), comment="班次")
plan_quantity = Column(Integer, default=0) plan_quantity = Column(Integer, default=0)

View File

@@ -0,0 +1,43 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class CostItem(BaseModel):
item: str
item_name: str
unit: str
unit_cost_label: str # 吨耗单位
class CostCreate(BaseModel):
item: str
record_date: datetime
shift_a: Optional[float] = 0
shift_b: Optional[float] = 0
unit_cost: Optional[float] = 0
remark: Optional[str] = None
class CostUpdate(BaseModel):
record_date: Optional[datetime] = None
shift_a: Optional[float] = None
shift_b: Optional[float] = None
unit_cost: Optional[float] = None
remark: Optional[str] = None
class CostOut(BaseModel):
id: int
item: str
item_name: Optional[str] = None
unit: Optional[str] = None
record_date: datetime
shift_a: Optional[float] = 0
shift_b: Optional[float] = 0
unit_cost: Optional[float] = 0
remark: Optional[str] = None
created_at: datetime
class Config:
from_attributes = True

View File

@@ -24,7 +24,7 @@ class PlanCreate(BaseModel):
incoming_weight: Optional[float] = None incoming_weight: Optional[float] = None
incoming_od: Optional[float] = None incoming_od: Optional[float] = None
split_weights: Optional[List[Optional[float]]] = None split_weights: Optional[List[Optional[float]]] = None
status: Optional[str] = "online" status: Optional[str] = "ready"
remark: Optional[str] = None remark: Optional[str] = None
@@ -78,6 +78,13 @@ class PlanOut(BaseModel):
remark: Optional[str] = None remark: Optional[str] = None
created_by: Optional[str] = None created_by: Optional[str] = None
created_at: datetime created_at: datetime
# 上卷鞍座 / 生产联动
on_saddle: Optional[int] = 0
saddle_at: Optional[datetime] = None
run_started_at: Optional[datetime] = None
run_speed: Optional[float] = 0
run_length_m: Optional[float] = 0
produced_at: Optional[datetime] = None
class Config: class Config:
from_attributes = True from_attributes = True

View File

@@ -0,0 +1,216 @@
"""产线联动引擎:计划上线 / 上卷鞍座 / 生产实绩 / 停机自动检测。
状态流转:
ready(准备好) --自动--> online(在线, 队首唯一) --移动--> 上卷鞍座(staged)
--有速度/投入生产--> producing(生产中) --带头2000m--> produced(生产完成,产生实绩)
停机:当产线无生产(速度=0)持续超过 10min自动新增一条未结束停机记录
恢复速度后自动结束该记录,原因由用户后续补充录入。
"""
from datetime import datetime
from sqlalchemy import select, asc
from sqlalchemy.ext.asyncio import AsyncSession
from loguru import logger
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
# ── 仿真参数 ──
TARGET_LENGTH_M = 2000.0 # 带头目标长度(生产完成阈值)
SIM_SPEED_M_MIN = 600.0 # 仿真线速度 m/min2000m ≈ 200s
DOWNTIME_THRESHOLD_S = 600 # 速度为0持续超过该秒数判定停机10min
def _shift_of(dt: datetime) -> str:
return "" if 8 <= dt.hour < 20 else ""
async def _saddle_plan(db: AsyncSession):
res = await db.execute(select(ProductionPlan).where(ProductionPlan.on_saddle == 1))
return res.scalars().first()
async def ensure_online(db: AsyncSession):
"""保证恰好一条 online队首最早录入的 ready"""
res = await db.execute(select(ProductionPlan).where(ProductionPlan.status == "online"))
online = list(res.scalars())
if len(online) > 1:
# 仅保留最早的一条为 online其余回退 ready
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"
async def move_to_saddle(db: AsyncSession, plan: ProductionPlan):
"""把在线计划移动到上卷鞍座staged等待速度/投入生产)。"""
occupied = await _saddle_plan(db)
if occupied and occupied.id != plan.id:
raise ValueError("上卷鞍座已被占用,请等待当前钢卷生产完成")
plan.on_saddle = 1
plan.saddle_at = datetime.now()
plan.run_started_at = None
plan.run_speed = 0
plan.run_length_m = 0
if plan.status not in ("producing", "produced"):
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)
if plan.run_started_at is None:
plan.run_started_at = datetime.now()
plan.run_speed = SIM_SPEED_M_MIN
plan.status = "producing"
await ensure_online(db)
async def _produce(db: AsyncSession, plan: ProductionPlan):
"""带头到达目标长度 → 生产完成,并生成实绩记录。"""
now = datetime.now()
plan.status = "produced"
plan.produced_at = now
plan.on_saddle = 0
plan.run_speed = 0
plan.run_length_m = TARGET_LENGTH_M
weight = plan.incoming_weight or plan.plan_weight or 0
length = TARGET_LENGTH_M
rec = ProductionRecord(
coil_no=plan.cold_coil_no or plan.plan_no,
sub_coil_no=plan.cold_coil_no,
hot_coil_no=plan.hot_coil_no,
plan_id=plan.id,
shift=_shift_of(now),
steel_grade=plan.steel_grade,
incoming_thickness=plan.incoming_thickness,
outlet_thickness=plan.product_thickness,
deviation_upper=plan.deviation_upper,
deviation_lower=plan.deviation_lower,
incoming_width=plan.incoming_width,
outlet_width=plan.product_width,
incoming_weight=weight,
weighed_weight=round(weight * 0.985, 4) if weight else None,
packaging_req=plan.packaging_req,
trim_req=plan.trim_req,
surface_quality="合格",
product_quality=99.0,
product_length=length,
length_per_ton=round(length / weight, 2) if weight else None,
offline_time=now,
status="PRODUCT",
shift_date=now,
start_time=plan.run_started_at or now,
end_time=now,
process_length=length,
process_weight=weight,
avg_speed=SIM_SPEED_M_MIN,
max_speed=round(SIM_SPEED_M_MIN * 1.05, 1),
inlet_thickness=plan.incoming_thickness,
inlet_width=plan.incoming_width,
quality_grade="A",
operator="系统",
)
db.add(rec)
logger.info(f"生产完成并产生实绩: {plan.cold_coil_no or plan.plan_no}")
async def advance_saddle(db: AsyncSession):
"""推进鞍座计划staged 自动获得速度 → 生产中;累计长度到 2000m → 完成。"""
plan = await _saddle_plan(db)
if not plan:
return
now = datetime.now()
# staged在线且在鞍座自动获得速度模拟 PLC 速度信号
if plan.status == "online" and plan.run_started_at is None:
plan.run_started_at = now
plan.run_speed = SIM_SPEED_M_MIN
plan.status = "producing"
await ensure_online(db)
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)
async def _get_line_state(db: AsyncSession) -> LineState:
st = await db.get(LineState, 1)
if not st:
st = LineState(id=1, speed=0)
db.add(st)
await db.flush()
return st
async def detect_downtime(db: AsyncSession):
"""速度为0持续超过阈值 → 自动新增停机;恢复速度 → 自动结束。"""
res = await db.execute(select(ProductionPlan).where(ProductionPlan.status == "producing"))
running = res.scalars().first() is not None
now = datetime.now()
st = await _get_line_state(db)
if running:
st.speed = SIM_SPEED_M_MIN
st.zero_since = None
if st.open_downtime_id:
rec = await db.get(DowntimeRecord, st.open_downtime_id)
if rec and rec.end_time is None:
rec.end_time = now
rec.duration = round((now - rec.start_time).total_seconds() / 60.0, 1)
st.open_downtime_id = None
else:
st.speed = 0
if st.zero_since is None:
st.zero_since = now
elif st.open_downtime_id is None and (now - st.zero_since).total_seconds() >= DOWNTIME_THRESHOLD_S:
rec = DowntimeRecord(
category_code="AUTO",
category_name="待定",
shift=_shift_of(now),
shift_date=now,
start_time=st.zero_since,
fault_desc="线速度为0持续超过10分钟系统自动检测原因待补充",
reporter="系统",
is_planned=0,
)
db.add(rec)
await db.flush()
st.open_downtime_id = rec.id
logger.info("自动检测到停机,已新增待补充停机记录")
async def tick(db: AsyncSession):
"""引擎单步:上线 + 推进鞍座 + 停机检测。"""
await ensure_online(db)
await advance_saddle(db)
await detect_downtime(db)
async def run_engine_loop(interval_s: int = 15):
"""后台循环,使联动在无人查看时也能自动推进。"""
import asyncio
from app.database import AsyncSessionLocal
while True:
try:
async with AsyncSessionLocal() as db:
await tick(db)
await db.commit()
except Exception as e: # noqa
logger.warning(f"line engine tick 失败: {e}")
await asyncio.sleep(interval_s)

View File

@@ -20,9 +20,20 @@ export const getPlans = params => request.get('/plan/', { params })
export const createPlan = data => request.post('/plan/', data) export const createPlan = data => request.post('/plan/', data)
export const updatePlan = (id, data) => request.put(`/plan/${id}`, data) export const updatePlan = (id, data) => request.put(`/plan/${id}`, data)
export const confirmPlan = id => request.patch(`/plan/${id}/confirm`) export const confirmPlan = id => request.patch(`/plan/${id}/confirm`)
export const startProducing = id => request.patch(`/plan/${id}/start`) export const startProducing = id => request.patch(`/plan/${id}/start`) // 移动到上卷鞍座
export const moveToSaddle = id => request.patch(`/plan/${id}/start`)
export const commitProducing = id => request.patch(`/plan/${id}/commit`) // 投入生产
export const getSaddle = () => request.get('/plan/saddle/current')
export const seedPlans = (count = 50) => request.post('/plan/seed', null, { params: { count } })
export const getLastPlanTemplate = () => request.get('/plan/last-template') export const getLastPlanTemplate = () => request.get('/plan/last-template')
// 成本管理
export const getCostItems = () => request.get('/cost/items')
export const getCostRecords = params => request.get('/cost/', { params })
export const createCostRecord = data => request.post('/cost/', data)
export const updateCostRecord = (id, data) => request.put(`/cost/${id}`, data)
export const deleteCostRecord = id => request.delete(`/cost/${id}`)
// 停机管理 // 停机管理
export const getDowntimeCategories = () => request.get('/downtime/categories') export const getDowntimeCategories = () => request.get('/downtime/categories')
export const getDowntimeRecords = params => request.get('/downtime/', { params }) export const getDowntimeRecords = params => request.get('/downtime/', { params })

View File

@@ -70,6 +70,12 @@ const routes = [
component: () => import('@/views/Quality.vue'), component: () => import('@/views/Quality.vue'),
meta: { title: '质量管理', icon: 'el-icon-medal', requiresAuth: true } meta: { title: '质量管理', icon: 'el-icon-medal', requiresAuth: true }
}, },
{
path: 'cost',
name: 'CostManagement',
component: () => import('@/views/CostManagement.vue'),
meta: { title: '成本管理', icon: 'el-icon-coin', requiresAuth: true }
},
] ]
}, },
{ path: '*', redirect: '/' } { path: '*', redirect: '/' }

View File

@@ -0,0 +1,254 @@
<template>
<div class="cost-page">
<!-- 成本项切换 -->
<div class="card">
<div class="card-body" style="padding:8px 12px;">
<div class="item-tabs">
<span
v-for="it in items"
:key="it.item"
:class="['item-tab', { active: it.item === curItem }]"
@click="selectItem(it.item)"
>{{ it.item_name }}</span>
</div>
</div>
</div>
<!-- 图表 + 记录表 -->
<div class="cost-main">
<div class="card chart-card">
<div class="card-header">{{ curMeta.item_name }}耗量统计</div>
<div class="card-body">
<v-chart v-if="records.length" class="cost-chart" :option="chartOption" autoresize />
<div v-else class="empty-tip">暂无数据请调整时间范围或点击新增录入</div>
</div>
</div>
<div class="card table-card">
<div class="card-header">记录明细 <span class="ch-badge">{{ records.length }} </span></div>
<div class="table-scroll" style="max-height:420px;">
<table class="data-table compact">
<thead>
<tr>
<th>A班量</th><th>B班量</th><th>吨耗量</th><th>记录时间</th><th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="r in recordsDesc" :key="r.id">
<td class="td-num td-ok">{{ fmt(r.shift_a) }}</td>
<td class="td-num td-warn">{{ fmt(r.shift_b) }}</td>
<td class="td-num">{{ fmt(r.unit_cost) }}</td>
<td class="td-muted">{{ fmtTime(r.record_date) }}</td>
<td>
<span class="action-link" @click="openDialog(r)">编辑</span>
<span class="action-link del" @click="remove(r)">删除</span>
</td>
</tr>
<tr v-if="!records.length">
<td colspan="5" class="td-muted" style="text-align:center;padding:14px;">暂无数据</td>
</tr>
</tbody>
</table>
</div>
</div>
</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.start_date" type="date" class="kv-input" style="width:150px;" />
</div>
<div class="flex-row">
<span class="kv-label">结束时间</span>
<input v-model="query.end_date" type="date" class="kv-input" style="width:150px;" />
</div>
<div class="flex-row" style="margin-left:auto;gap:8px;">
<button class="btn btn-primary" @click="fetchData">查找</button>
<button class="btn btn-outline" @click="openDialog()"> 新增</button>
</div>
</div>
</div>
</div>
<!-- 新增/编辑 -->
<div v-if="dialogVisible" class="modal-mask" @click.self="dialogVisible=false">
<div class="modal-box" style="width:420px;">
<div class="modal-header">
{{ editRow ? '编辑' : '新增' }}{{ curMeta.item_name }}记录
<span class="modal-close" @click="dialogVisible=false"></span>
</div>
<div class="modal-body">
<div class="form-field">
<div class="kv-label">记录时间 *</div>
<input v-model="form.record_date" type="datetime-local" class="kv-input" />
</div>
<div class="grid-2" style="gap:12px;margin-top:10px;">
<div class="form-field">
<div class="kv-label">A班量 ({{ curMeta.unit }})</div>
<input v-model.number="form.shift_a" type="number" step="0.01" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">B班量 ({{ curMeta.unit }})</div>
<input v-model.number="form.shift_b" type="number" step="0.01" class="kv-input" />
</div>
</div>
<div class="form-field" style="margin-top:10px;">
<div class="kv-label">吨耗量 ({{ curMeta.unit_cost_label }})</div>
<input v-model.number="form.unit_cost" type="number" step="0.001" class="kv-input" />
</div>
<div class="form-field" style="margin-top:10px;">
<div class="kv-label">备注</div>
<input v-model="form.remark" class="kv-input" />
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" @click="dialogVisible=false">取消</button>
<button class="btn btn-primary" :disabled="saving" @click="save">{{ saving ? '保存中...' : '保存' }}</button>
</div>
</div>
</div>
</div>
</template>
<script>
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { BarChart, LineChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'
import VChart from 'vue-echarts'
import { getCostItems, getCostRecords, createCostRecord, updateCostRecord, deleteCostRecord } from '@/api'
use([CanvasRenderer, BarChart, LineChart, GridComponent, TooltipComponent, LegendComponent])
export default {
name: 'CostManagement',
components: { VChart },
data() {
return {
items: [], curItem: '',
records: [],
query: { start_date: '', end_date: '' },
dialogVisible: false, editRow: null, saving: false,
form: this.emptyForm(),
}
},
computed: {
curMeta() { return this.items.find(x => x.item === this.curItem) || { item_name: '', unit: '', unit_cost_label: '' } },
recordsDesc() { return [...this.records].slice().reverse() },
chartOption() {
const dates = this.records.map(r => this.fmtDay(r.record_date))
const meta = this.curMeta
return {
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
legend: { data: ['A班耗量', 'B班耗量', '吨耗量'], top: 4, textStyle: { color: '#606266' } },
grid: { left: 50, right: 56, top: 40, bottom: 40 },
xAxis: [{ type: 'category', data: dates, axisLabel: { color: '#909399' }, axisLine: { lineStyle: { color: '#dcdfe6' } } }],
yAxis: [
{ type: 'value', name: `耗量 ${meta.unit}`, nameTextStyle: { color: '#909399' }, axisLabel: { color: '#909399' }, splitLine: { lineStyle: { color: '#ebeef5' } } },
{ type: 'value', name: `吨耗 ${meta.unit_cost_label}`, nameTextStyle: { color: '#909399' }, axisLabel: { color: '#909399' }, splitLine: { show: false } },
],
series: [
{ name: 'A班耗量', type: 'bar', data: this.records.map(r => r.shift_a), itemStyle: { color: '#67C23A' }, barMaxWidth: 22 },
{ name: 'B班耗量', type: 'bar', data: this.records.map(r => r.shift_b), itemStyle: { color: '#E6A23C' }, barMaxWidth: 22 },
{ name: '吨耗量', type: 'line', yAxisIndex: 1, data: this.records.map(r => r.unit_cost), itemStyle: { color: '#C03639' }, lineStyle: { color: '#C03639', width: 2 }, symbol: 'circle', symbolSize: 7 },
],
}
},
},
async created() {
await this.fetchItems()
const today = new Date()
const past = new Date(today.getTime() - 7 * 86400000)
this.query.start_date = this.toDay(past)
this.query.end_date = this.toDay(today)
this.fetchData()
},
methods: {
emptyForm() { return { record_date: '', shift_a: null, shift_b: null, unit_cost: null, remark: '' } },
async fetchItems() {
try {
const res = await getCostItems()
this.items = res.data || []
if (this.items.length && !this.curItem) this.curItem = this.items[0].item
} catch (e) { /* ignore */ }
},
selectItem(item) { this.curItem = item; this.fetchData() },
async fetchData() {
const params = { item: this.curItem }
if (this.query.start_date) params.start_date = this.query.start_date + 'T00:00:00'
if (this.query.end_date) params.end_date = this.query.end_date + 'T23:59:59'
try {
const res = await getCostRecords(params)
this.records = res.data || []
} catch (e) { /* ignore */ }
},
fmt(v, n = 2) { return v != null && v !== '' ? Number(v).toFixed(n) : '—' },
fmtTime(t) { return t ? t.slice(0, 16).replace('T', ' ') : '—' },
fmtDay(t) { return t ? t.slice(5, 10) : '' },
toDay(d) { const p = n => String(n).padStart(2, '0'); return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}` },
nowDT() { const d = new Date(); const p = n => String(n).padStart(2, '0'); return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}` },
openDialog(row = null) {
this.editRow = row
if (row) {
this.form = { record_date: (row.record_date || '').slice(0, 16), shift_a: row.shift_a, shift_b: row.shift_b, unit_cost: row.unit_cost, remark: row.remark || '' }
} else {
this.form = this.emptyForm(); this.form.record_date = this.nowDT()
}
this.dialogVisible = true
},
async save() {
if (!this.form.record_date) { this.$message.error('记录时间不能为空'); return }
this.saving = true
try {
const d = { ...this.form }
if (d.record_date && d.record_date.length === 16) d.record_date += ':00'
if (this.editRow) await updateCostRecord(this.editRow.id, d)
else await createCostRecord({ ...d, item: this.curItem })
this.$message.success('保存成功')
this.dialogVisible = false; this.fetchData()
} catch (e) {
this.$message.error(e?.response?.data?.detail || '保存失败')
} finally { this.saving = false }
},
async remove(row) {
if (!confirm('确认删除该记录?')) return
try { await deleteCostRecord(row.id); this.$message.success('已删除'); this.fetchData() }
catch (e) { this.$message.error('删除失败') }
},
},
}
</script>
<style lang="scss" scoped>
@import '@/assets/styles/variables';
.cost-page { display: flex; flex-direction: column; gap: 12px; }
.item-tabs { display: flex; flex-wrap: wrap; gap: 6px; }
.item-tab {
padding: 5px 14px; font-size: 12px; border-radius: 4px; cursor: pointer;
color: $text-secondary; border: 1px solid $border; background: $bg-card;
&:hover { color: $sms-teal; border-color: $sms-teal; }
&.active { color: #fff; background: $sms-teal; border-color: $sms-teal; }
}
.cost-main { display: grid; grid-template-columns: 3fr 2fr; gap: 12px; align-items: stretch; }
.chart-card, .table-card { display: flex; flex-direction: column; }
.cost-chart { height: 420px; width: 100%; }
.empty-tip { text-align: center; padding: 60px 20px; color: $text-muted; font-size: 13px; }
.form-field { display: flex; flex-direction: column; gap: 5px; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; }
.action-link { color: $sms-highlight; cursor: pointer; font-size: 12px; margin-right: 10px; &:hover { text-decoration: underline; } &.del { color: $accent-red; } }
.modal-mask { position: fixed; inset: 0; background: rgba(0,0,0,.6); display: flex; align-items: center; justify-content: center; z-index: 9999; }
.modal-box { background: $bg-card; border: 1px solid $border; border-radius: 6px; max-width: 95vw; max-height: 90vh; display: flex; flex-direction: column; }
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: $bg-panel; border-bottom: 1px solid $border; font-size: 13px; font-weight: 600; color: $sms-highlight; .modal-close { cursor: pointer; color: $text-muted; &:hover { color: $text-primary; } } }
.modal-body { padding: 16px; overflow-y: auto; }
.modal-footer { padding: 10px 16px; background: $bg-panel; border-top: 1px solid $border; display: flex; justify-content: flex-end; gap: 10px; }
@media (max-width: 1100px) { .cost-main { grid-template-columns: 1fr; } }
</style>

View File

@@ -1,67 +1,115 @@
<template> <template>
<div class="entry-page"> <div class="entry-page">
<!-- 上卷鞍座单个生产工位 -->
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
入口跟踪 上卷鞍座
<span class="ch-badge">在线 {{ onlinePlans.length }} / 生产中 {{ producingPlan ? 1 : 0 }}</span> <span class="ch-badge">{{ saddle ? statusLabel(saddle.status) : '空闲' }}</span>
<span style="margin-left:auto;display:flex;gap:8px;align-items:center;"> <span style="margin-left:auto;display:flex;gap:8px;align-items:center;">
<button class="btn btn-outline" @click="fetchPlans">刷新</button> <button v-if="saddle && saddle.status !== 'producing'" class="btn btn-primary" @click="commit(saddle)">投入生产</button>
<button class="btn btn-outline" @click="refreshAll">刷新</button>
</span> </span>
</div> </div>
<div class="entry-grid"> <div class="card-body">
<div v-for="row in rows" :key="row.key" class="entry-row"> <div v-if="saddle" class="saddle-station">
<div <div class="saddle-info">
v-for="pos in row.positions" <div class="si-row"><span class="si-k">冷卷号</span><span class="si-v hl">{{ saddle.cold_coil_no || saddle.plan_no }}</span></div>
:key="pos.name" <div class="si-row"><span class="si-k">热卷号</span><span class="si-v">{{ saddle.hot_coil_no || '—' }}</span></div>
:class="['pos-cell', { filled: !!pos.plan, highlight: pos.highlight }]" <div class="si-row"><span class="si-k">钢种</span><span class="si-v">{{ saddle.steel_grade || '—' }}</span></div>
> <div class="si-row"><span class="si-k">规格(×)</span><span class="si-v">{{ fmt(saddle.product_thickness, 2) }} × {{ fmt(saddle.product_width, 0) }}</span></div>
<div class="pos-title">{{ pos.name }}</div> <div class="si-row"><span class="si-k">来料重量[t]</span><span class="si-v">{{ fmt(saddle.incoming_weight, 3) }}</span></div>
<table class="pos-table"> <div class="si-row"><span class="si-k">轧制模式</span><span class="si-v">{{ saddle.rolling_mode || '—' }}</span></div>
<tbody> </div>
<tr><td class="k">冷卷号</td><td class="v">{{ pos.plan ? (pos.plan.cold_coil_no || '—') : '' }}</td></tr>
<tr><td class="k">热卷号</td><td class="v">{{ pos.plan ? (pos.plan.hot_coil_no || '—') : '' }}</td></tr> <div class="saddle-run">
<tr><td class="k">钢种</td><td class="v">{{ pos.plan ? (pos.plan.steel_grade || '—') : '' }}</td></tr> <div class="metric-box">
<tr><td class="k">来料厚度[mm]</td><td class="v">{{ pos.plan ? fmt(pos.plan.incoming_thickness, 2) : '' }}</td></tr> <span class="mb-label">线速度</span>
<tr><td class="k">成品厚度[mm]</td><td class="v">{{ pos.plan ? fmt(pos.plan.product_thickness, 2) : '' }}</td></tr> <span class="mb-value">{{ fmt(saddle.run_speed, 0) }}</span>
<tr><td class="k">厚差范围[mm]</td><td class="v">{{ pos.plan ? devRange(pos.plan) : '' }}</td></tr> <span class="mb-unit">m/min</span>
<tr><td class="k">来料宽度[mm]</td><td class="v">{{ pos.plan ? fmt(pos.plan.incoming_width, 0) : '' }}</td></tr> </div>
<tr><td class="k">成品宽度[mm]</td><td class="v">{{ pos.plan ? fmt(pos.plan.product_width, 0) : '' }}</td></tr> <div class="metric-box">
<tr><td class="k">来料重量[t]</td><td class="v">{{ pos.plan ? fmt(pos.plan.incoming_weight, 4) : '' }}</td></tr> <span class="mb-label">带头长度 / 目标</span>
<tr><td class="k">轧制模式</td><td class="v">{{ pos.plan ? (pos.plan.rolling_mode || '—') : '' }}</td></tr> <span class="mb-value">{{ fmt(saddle.run_length_m, 0) }}</span>
</tbody> <span class="mb-unit">/ {{ TARGET }} m</span>
</table> </div>
<div class="run-prog">
<div class="rp-head">
<span>生产进度</span>
<span class="rp-pct">{{ progPct(saddle).toFixed(1) }}%</span>
</div>
<div class="prog-bar-wrap" style="height:10px;">
<div class="prog-bar-fill" :style="{ width: progPct(saddle) + '%', background: progColor(saddle) }"></div>
</div>
<div class="rp-tip">
<template v-if="saddle.status === 'producing'">带头到达 {{ TARGET }} m 后自动产出实绩并完成</template>
<template v-else>已在鞍座等待速度信号可点投入生产手动开始</template>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div v-else class="saddle-empty">
上卷鞍座空闲 从下方队列点击移动把在线计划推到鞍座
</div>
</div>
</div>
<!-- 待上卷计划卡片 -->
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
入口队列点击移动可将计划推到入口并开始生产 待上卷计划
<span class="ch-badge">{{ onlinePlans.length }} </span> <span class="ch-badge">在线/准备 {{ queuePlans.length }} </span>
</div>
<div class="card-body">
<div v-if="queueCards.length" class="card-grid">
<div v-for="p in queueCards" :key="p.id" :class="['plan-card', { online: p.status === 'online' }]">
<div class="pc-head">
<span class="pc-coil">{{ p.cold_coil_no || p.plan_no }}</span>
<span :class="['badge', p.status === 'online' ? 'badge-green' : 'badge-gray']">{{ statusLabel(p.status) }}</span>
</div>
<div class="pc-body">
<div class="pc-row"><span>钢种</span><b>{{ p.steel_grade || '—' }}</b></div>
<div class="pc-row"><span>规格</span><b>{{ fmt(p.product_thickness, 2) }}×{{ fmt(p.product_width, 0) }}</b></div>
<div class="pc-row"><span>重量[t]</span><b>{{ fmt(p.incoming_weight, 3) }}</b></div>
<div class="pc-row"><span>轧制模式</span><b>{{ p.rolling_mode || '—' }}</b></div>
</div>
<button class="btn btn-primary fw" :disabled="saddleOccupied" @click="move(p)">移动到鞍座</button>
</div>
</div>
<div v-else class="td-muted" style="text-align:center;padding:14px;">暂无在线/准备好的计划</div>
</div>
</div>
<!-- 入口队列表 -->
<div class="card">
<div class="card-header">
入口队列
<span class="ch-badge">{{ queuePlans.length }} </span>
</div> </div>
<div class="table-scroll"> <div class="table-scroll">
<table class="data-table compact"> <table class="data-table compact">
<thead> <thead>
<tr> <tr>
<th>冷卷号</th><th>热卷号</th><th>钢种</th> <th>序号</th><th>冷卷号</th><th>热卷号</th><th>钢种</th>
<th>规格(×)</th><th>来料重量</th><th>轧制模式</th><th>状态</th><th>操作</th> <th>规格(×)</th><th>来料重量</th><th>轧制模式</th><th>状态</th><th>操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="p in onlinePlans" :key="p.id"> <tr v-for="(p, i) in queuePlans" :key="p.id">
<td class="td-num">{{ i + 1 }}</td>
<td class="td-num">{{ p.cold_coil_no || p.plan_no }}</td> <td class="td-num">{{ p.cold_coil_no || p.plan_no }}</td>
<td class="td-num">{{ p.hot_coil_no || '—' }}</td> <td class="td-num">{{ p.hot_coil_no || '—' }}</td>
<td>{{ p.steel_grade || '—' }}</td> <td>{{ p.steel_grade || '—' }}</td>
<td class="td-num">{{ fmt(p.product_thickness, 2) }} × {{ fmt(p.product_width, 0) }}</td> <td class="td-num">{{ fmt(p.product_thickness, 2) }} × {{ fmt(p.product_width, 0) }}</td>
<td class="td-num">{{ fmt(p.incoming_weight, 2) }}</td> <td class="td-num">{{ fmt(p.incoming_weight, 2) }}</td>
<td>{{ p.rolling_mode || '—' }}</td> <td>{{ p.rolling_mode || '—' }}</td>
<td><span :class="['badge', p.status === 'online' ? 'badge-green' : 'badge-gray']">{{ p.status === 'online' ? '在线' : '准备好' }}</span></td> <td><span :class="['badge', p.status === 'online' ? 'badge-green' : 'badge-gray']">{{ statusLabel(p.status) }}</span></td>
<td><span class="action-link" @click="moveToProducing(p)">移动</span></td> <td><span :class="['action-link', { disabled: saddleOccupied }]" @click="!saddleOccupied && move(p)">移动</span></td>
</tr> </tr>
<tr v-if="!onlinePlans.length"> <tr v-if="!queuePlans.length">
<td colspan="8" class="td-muted" style="text-align:center;padding:14px;">暂无在线/准备好的计划</td> <td colspan="9" class="td-muted" style="text-align:center;padding:14px;">暂无在线/准备好的计划</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -71,59 +119,68 @@
</template> </template>
<script> <script>
import { getPlans, startProducing } from '@/api' import { getPlans, getSaddle, moveToSaddle, commitProducing } from '@/api'
const ROW1 = ['1#上卷小车','1#称重位','1#地辊','13#上卷鞍座','11#上卷鞍座','9#上卷鞍座','7#上卷鞍座','1#倒卷小车','5#上卷鞍座','3#上卷鞍座','1#上卷鞍座'] const STATUS_LABEL = { ready: '准备好', online: '在线', producing: '生产中', produced: '生产完成' }
const ROW2 = ['2#上卷小车','2#称重位','2#地辊','14#上卷鞍座','12#上卷鞍座','10#上卷鞍座','8#上卷鞍座','2#倒卷小车','6#上卷鞍座','4#上卷鞍座','2#上卷鞍座'] const TARGET = 2000
export default { export default {
name: 'EntryTracking', name: 'EntryTracking',
data() { data() {
return { plans: [], timer: null } return { plans: [], saddle: null, TARGET, timer: null, fastTimer: null }
}, },
computed: { computed: {
onlinePlans() { return this.plans.filter(p => p.status === 'online' || p.status === 'ready') }, queuePlans() {
producingPlan() { return this.plans.find(p => p.status === 'producing') || null }, // 在线 + 准备好,且不在鞍座上;在线排前
rows() { return this.plans
// 主辊位填充1#地辊 = 生产中卷; 2#地辊 = 在线队首 .filter(p => (p.status === 'online' || p.status === 'ready') && p.on_saddle !== 1)
const onl = this.onlinePlans .sort((a, b) => (a.status === 'online' ? -1 : 1) - (b.status === 'online' ? -1 : 1))
const map1 = { '1#地辊': this.producingPlan }
const map2 = { '2#地辊': onl[0] || null }
const build = (names, map) => names.map(n => ({
name: n,
plan: map[n] || null,
highlight: n === '1#地辊' || n === '2#地辊',
}))
return [
{ key: 'r1', positions: build(ROW1, map1) },
{ key: 'r2', positions: build(ROW2, map2) },
]
}, },
queueCards() { return this.queuePlans.slice(0, 8) },
saddleOccupied() { return !!this.saddle && this.saddle.status !== 'produced' },
}, },
created() { created() {
this.fetchPlans() this.refreshAll()
this.timer = setInterval(this.fetchPlans, 5000) this.fastTimer = setInterval(this.fetchSaddle, 2000) // 鞍座实时进度
this.timer = setInterval(this.fetchPlans, 5000) // 队列
}, },
beforeDestroy() { clearInterval(this.timer) }, beforeDestroy() { clearInterval(this.timer); clearInterval(this.fastTimer) },
methods: { methods: {
refreshAll() { this.fetchPlans(); this.fetchSaddle() },
async fetchPlans() { async fetchPlans() {
try { try {
const res = await getPlans({ page: 1, page_size: 50 }) const res = await getPlans({ page: 1, page_size: 100 })
this.plans = res.data.items || [] this.plans = res.data.items || []
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
}, },
fmt(v, n = 2) { return v != null && v !== '' ? Number(v).toFixed(n) : '—' }, async fetchSaddle() {
devRange(p) {
const u = p.deviation_upper, l = p.deviation_lower
if (u == null && l == null) return '—'
return `${l != null ? Number(l).toFixed(3) : '—'} / ${u != null ? Number(u).toFixed(3) : '—'}`
},
async moveToProducing(p) {
if (!confirm(`将计划 ${p.cold_coil_no || p.plan_no} 移动到入口并开始生产?`)) return
try { try {
await startProducing(p.id) const res = await getSaddle()
this.$message.success('已开始生产') this.saddle = res.data || null
this.fetchPlans() } catch (e) { /* ignore */ }
},
fmt(v, n = 2) { return v != null && v !== '' ? Number(v).toFixed(n) : '—' },
statusLabel(s) { return STATUS_LABEL[s] || s || '—' },
progPct(p) { return Math.max(0, Math.min(100, (p.run_length_m || 0) / TARGET * 100)) },
progColor(p) {
const pct = this.progPct(p)
return pct >= 100 ? 'var(--accent-green)' : 'var(--sms-teal)'
},
async move(p) {
if (this.saddleOccupied) { this.$message.warning('鞍座已被占用,请等待当前钢卷生产完成'); return }
try {
await moveToSaddle(p.id)
this.$message.success('已移动到上卷鞍座')
this.refreshAll()
} catch (e) {
this.$message.error(e?.response?.data?.detail || '操作失败')
}
},
async commit(p) {
try {
await commitProducing(p.id)
this.$message.success('已投入生产')
this.fetchSaddle()
} catch (e) { } catch (e) {
this.$message.error(e?.response?.data?.detail || '操作失败') this.$message.error(e?.response?.data?.detail || '操作失败')
} }
@@ -137,71 +194,41 @@ export default {
.entry-page { display: flex; flex-direction: column; gap: 12px; } .entry-page { display: flex; flex-direction: column; gap: 12px; }
.entry-grid { // ── 鞍座工位 ──
padding: 10px; .saddle-station { display: grid; grid-template-columns: 1fr 2fr; gap: 16px; }
display: flex; .saddle-info {
flex-direction: column; display: grid; grid-template-columns: 1fr 1fr; gap: 6px 18px;
gap: 8px; align-content: start;
overflow-x: auto; border-right: 1px solid $border; padding-right: 16px;
} }
.si-row { display: flex; justify-content: space-between; gap: 10px; font-size: 12px; padding: 3px 0; }
.si-k { color: $text-muted; }
.si-v { color: $text-primary; font-family: $font-mono; font-weight: 600; &.hl { color: $sms-teal; } }
.entry-row { .saddle-run { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; align-content: start; }
display: grid; .saddle-run .metric-box { min-width: 0; }
grid-template-columns: repeat(11, minmax(118px, 1fr)); .run-prog { grid-column: 1 / -1; }
gap: 6px; .rp-head { display: flex; justify-content: space-between; font-size: 12px; color: $text-secondary; margin-bottom: 5px; }
} .rp-pct { font-family: $font-mono; font-weight: 700; color: $sms-teal; }
.rp-tip { font-size: 11px; color: $text-muted; margin-top: 6px; }
.pos-cell {
background: $bg-panel; .saddle-empty { text-align: center; padding: 30px; color: $text-muted; font-size: 13px; }
border: 1px solid $border;
border-radius: 3px; // ── 待上卷卡片 ──
padding: 4px 5px 6px; .card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; }
min-height: 220px; .plan-card {
background: $bg-panel; border: 1px solid $border; border-radius: 6px;
&.filled { border-color: $sms-highlight; background: rgba($sms-highlight, .04); } padding: 10px; display: flex; flex-direction: column; gap: 8px;
&.highlight { box-shadow: 0 0 0 1px rgba($sms-highlight, .35) inset; } &.online { border-color: $accent-green; box-shadow: 0 0 0 1px rgba($accent-green, .25) inset; }
}
.pos-title {
text-align: center;
font-size: 11px;
font-weight: 700;
color: $text-primary;
padding: 3px 0 5px;
border-bottom: 1px dashed $border;
margin-bottom: 4px;
letter-spacing: .3px;
}
.pos-table {
width: 100%;
border-collapse: collapse;
font-size: 10.5px;
line-height: 1.45;
td {
padding: 1px 2px;
vertical-align: top;
white-space: nowrap;
}
td.k {
color: $text-muted;
text-align: right;
width: 56%;
font-size: 10px;
}
td.v {
color: $sms-highlight;
text-align: right;
font-family: $font-mono;
font-weight: 600;
}
} }
.pc-head { display: flex; align-items: center; justify-content: space-between; }
.pc-coil { font-family: $font-mono; font-weight: 700; color: $sms-teal; font-size: 13px; }
.pc-body { display: flex; flex-direction: column; gap: 3px; }
.pc-row { display: flex; justify-content: space-between; font-size: 11.5px; span { color: $text-muted; } b { color: $text-primary; font-family: $font-mono; } }
.action-link { .action-link {
color: $accent-green; color: $accent-green; cursor: pointer; font-size: 12px;
cursor: pointer;
font-size: 12px;
&:hover { text-decoration: underline; } &:hover { text-decoration: underline; }
&.disabled { color: $text-muted; cursor: not-allowed; text-decoration: none; }
} }
</style> </style>

View File

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

View File

@@ -78,7 +78,7 @@
<td><span :class="['badge', statusBadge(row.status)]">{{ statusLabel(row.status) }}</span></td> <td><span :class="['badge', statusBadge(row.status)]">{{ statusLabel(row.status) }}</span></td>
<td @click.stop> <td @click.stop>
<span class="action-link" @click="openDialog(row)">编辑</span> <span class="action-link" @click="openDialog(row)">编辑</span>
<span v-if="row.status === 'ready' || row.status === 'online'" <span v-if="row.status === 'online'"
class="action-link" style="color:var(--accent-green)" @click="moveToProducing(row)">移动</span> class="action-link" style="color:var(--accent-green)" @click="moveToProducing(row)">移动</span>
</td> </td>
</tr> </tr>
@@ -274,7 +274,7 @@ export default {
created() { this.fetchData() }, created() { this.fetchData() },
methods: { methods: {
emptyForm() { emptyForm() {
return { plan_no: '', split_count: 1, status: 'online', split_weights: [null,null,null,null,null,null] } return { plan_no: '', split_count: 1, status: 'ready', split_weights: [null,null,null,null,null,null] }
}, },
async fetchData() { async fetchData() {
this.loading = true this.loading = true
@@ -349,10 +349,14 @@ export default {
this.fetchData() this.fetchData()
}, },
async moveToProducing(row) { async moveToProducing(row) {
if (!confirm(`将计划 ${row.cold_coil_no || row.plan_no} 移动到入口并开始生产`)) return if (!confirm(`将计划 ${row.cold_coil_no || row.plan_no} 移动到上卷鞍座`)) return
try {
await startProducing(row.id) await startProducing(row.id)
this.$message.success('已开始生产') this.$message.success('已移动到上卷鞍座')
this.fetchData() this.fetchData()
} catch (e) {
this.$message.error(e?.response?.data?.detail || '操作失败')
}
}, },
async save() { async save() {
if (!this.form.plan_no) { this.$message.error('计划号不能为空'); return } if (!this.form.plan_no) { this.$message.error('计划号不能为空'); return }