feat(linkage): 移动改为任意入口位置选择,仅上卷鞍座触发生产

- 计划新增 position 字段;新增 /plan/{id}/move?position=… 与 /plan/positions/all
- line_service.place_at_position:放到任意位置(位置唯一占用),上卷鞍座单独触发生产联动
- 入口跟踪:新增入口位置图(单一鞍座)显示占位;移动按钮弹出位置选择框
- 计划管理:移动按钮同样弹出位置选择框

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 14:10:16 +08:00
parent 9fb3dcb785
commit 03709da1ca
8 changed files with 233 additions and 68 deletions

View File

@@ -142,6 +142,28 @@ async def move_to_saddle(plan_id: int, db: AsyncSession = Depends(get_db), _ = D
return Response.ok(PlanOut.model_validate(plan))
@router.get("/positions/all", response_model=Response[list[str]])
async def list_positions(_ = Depends(get_current_user)):
"""可移动到的入口位置列表(含唯一上卷鞍座)。"""
return Response.ok(line_service.ENTRY_POSITIONS)
@router.patch("/{plan_id}/move", response_model=Response[PlanOut])
async def move_plan(plan_id: int, position: str = Query(...), 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="计划不存在")
try:
await line_service.place_at_position(db, plan, position)
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))
@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)):
"""投入生产:把鞍座上的计划置为生产中(兜底未实时变化的数据)。"""

View File

@@ -59,7 +59,8 @@ 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_od DOUBLE PRECISION",
"ALTER TABLE production_plans ADD COLUMN IF NOT EXISTS split_weights JSONB",
# 上卷鞍座 / 生产联动字段
# 入口位置 / 上卷鞍座 / 生产联动字段
"ALTER TABLE production_plans ADD COLUMN IF NOT EXISTS position VARCHAR(30)",
"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",

View File

@@ -35,7 +35,8 @@ class ProductionPlan(Base):
incoming_od = Column(Float, comment="来料外径 mm")
split_weights = Column(JSON, comment="分卷重量 [t,...]")
# 上卷鞍座 / 生产联动
# 入口位置 / 上卷鞍座 / 生产联动
position = Column(String(30), comment="当前入口位置(上卷小车/称重位/地辊/倒卷小车/上卷鞍座)")
on_saddle = Column(Integer, default=0, comment="是否在上卷鞍座 0/1")
saddle_at = Column(DateTime, comment="移动到鞍座时间")
run_started_at = Column(DateTime, comment="投入生产(有速度)时间")

View File

@@ -78,7 +78,8 @@ class PlanOut(BaseModel):
remark: Optional[str] = None
created_by: Optional[str] = None
created_at: datetime
# 上卷鞍座 / 生产联动
# 入口位置 / 上卷鞍座 / 生产联动
position: Optional[str] = None
on_saddle: Optional[int] = 0
saddle_at: Optional[datetime] = None
run_started_at: Optional[datetime] = None

View File

@@ -17,12 +17,45 @@ from app.models.production import ProductionRecord
from app.models.downtime import DowntimeRecord
from app.models.line_state import LineState
# ── 入口位置 ──
SADDLE_NAME = "上卷鞍座" # 唯一会触发生产联动的位置
ENTRY_POSITIONS = [
"1#上卷小车", "2#上卷小车",
"1#称重位", "2#称重位",
"1#地辊", "2#地辊",
"1#倒卷小车", "2#倒卷小车",
SADDLE_NAME,
]
# ── 仿真参数 ──
TARGET_LENGTH_M = 2000.0 # 带头目标长度(生产完成阈值)
SIM_SPEED_M_MIN = 600.0 # 仿真线速度 m/min2000m ≈ 200s
DOWNTIME_THRESHOLD_S = 600 # 速度为0持续超过该秒数判定停机10min
async def place_at_position(db: AsyncSession, plan: ProductionPlan, position: str):
"""把计划放到任意入口位置;位置唯一占用。上卷鞍座单独触发生产联动。"""
if position not in ENTRY_POSITIONS:
raise ValueError("未知入口位置")
if position == SADDLE_NAME:
await move_to_saddle(db, plan)
return
# 普通位置:清除同位置上的其它计划,移出鞍座(若在)
res = await db.execute(
select(ProductionPlan).where(ProductionPlan.position == position, ProductionPlan.id != plan.id)
)
for o in res.scalars():
o.position = None
plan.on_saddle = 0
plan.run_started_at = None
plan.run_speed = 0
plan.run_length_m = 0
plan.position = position
if plan.status == "producing":
plan.status = "online"
await ensure_online(db)
def _shift_of(dt: datetime) -> str:
return "" if 8 <= dt.hour < 20 else ""
@@ -60,6 +93,7 @@ async def move_to_saddle(db: AsyncSession, plan: ProductionPlan):
if occupied and occupied.id != plan.id:
raise ValueError("上卷鞍座已被占用,请等待当前钢卷生产完成")
plan.on_saddle = 1
plan.position = SADDLE_NAME
plan.saddle_at = datetime.now()
plan.run_started_at = None
plan.run_speed = 0
@@ -86,6 +120,7 @@ async def _produce(db: AsyncSession, plan: ProductionPlan):
plan.status = "produced"
plan.produced_at = now
plan.on_saddle = 0
plan.position = None
plan.run_speed = 0
plan.run_length_m = TARGET_LENGTH_M