Files
pickling-mes/backend/app/api/plan.py
wangyu 9fb3dcb785 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>
2026-06-29 13:57:59 +08:00

212 lines
8.6 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
from typing import Optional
from datetime import datetime
from app.database import get_db
from app.models.plan import ProductionPlan
from app.schemas.plan import PlanCreate, PlanUpdate, PlanOut, PlanTemplate
from app.schemas.common import Response, PageResponse
from app.services.auth_service import get_current_user
from app.services import line_service
router = APIRouter()
def _parse_dt(s):
if not s:
return None
try:
return datetime.fromisoformat(s.replace('Z', ''))
except Exception:
return None
TEMPLATE_FIELDS = (
"steel_grade", "incoming_thickness", "product_thickness",
"deviation_upper", "deviation_lower",
"incoming_width", "product_width",
"packaging_req", "trim_req", "rolling_mode",
"coil_diameter", "split_count", "next_process",
"incoming_weight", "incoming_od", "split_weights",
)
@router.get("/", response_model=Response[PageResponse[PlanOut]])
async def list_plans(
page: int = 1,
page_size: int = 20,
status: Optional[str] = None,
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db),
_ = Depends(get_current_user),
):
# 拉取前保证队首自动上线(鞍座推进/停机检测由 /saddle 与后台引擎负责)
await line_service.ensure_online(db)
query = select(ProductionPlan).order_by(desc(ProductionPlan.plan_date))
if status:
query = query.where(ProductionPlan.status == status)
_sd = _parse_dt(start_date)
if _sd:
query = query.where(ProductionPlan.plan_date >= _sd)
_ed = _parse_dt(end_date)
if _ed:
query = query.where(ProductionPlan.plan_date <= _ed)
total = (await db.execute(select(func.count()).select_from(query.subquery()))).scalar()
result = await db.execute(query.offset((page - 1) * page_size).limit(page_size))
items = [PlanOut.model_validate(p) for p in result.scalars()]
return Response.ok(PageResponse(total=total, page=page, page_size=page_size, items=items))
@router.get("/last-template", response_model=Response[PlanTemplate])
async def get_last_template(db: AsyncSession = Depends(get_db), _ = Depends(get_current_user)):
"""最近一条计划的工艺字段(不含计划号/卷号/时间),用于新增时回填。"""
result = await db.execute(select(ProductionPlan).order_by(desc(ProductionPlan.created_at)).limit(1))
p = result.scalar_one_or_none()
if not p:
return Response.ok(PlanTemplate())
return Response.ok(PlanTemplate(**{k: getattr(p, k, None) for k in TEMPLATE_FIELDS}))
@router.post("/", response_model=Response[PlanOut])
async def create_plan(
body: PlanCreate,
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user),
):
existing = await db.execute(select(ProductionPlan).where(ProductionPlan.plan_no == body.plan_no))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="计划号已存在")
plan = ProductionPlan(**body.model_dump(), created_by=current_user.username)
db.add(plan)
await db.flush()
# 录入即准备好;若当前无在线计划,则队首自动上线
await line_service.ensure_online(db)
await db.refresh(plan)
return Response.ok(PlanOut.model_validate(plan))
@router.get("/{plan_id}", response_model=Response[PlanOut])
async def get_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="计划不存在")
return Response.ok(PlanOut.model_validate(plan))
@router.put("/{plan_id}", response_model=Response[PlanOut])
async def update_plan(
plan_id: int,
body: PlanUpdate,
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="计划不存在")
for k, v in body.model_dump(exclude_none=True).items():
setattr(plan, k, v)
await db.flush()
return Response.ok(PlanOut.model_validate(plan))
@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))
plan = result.scalar_one_or_none()
if not plan:
raise HTTPException(status_code=404, detail="计划不存在")
plan.status = "online"
await db.flush()
return Response.ok(PlanOut.model_validate(plan))
@router.patch("/{plan_id}/start", response_model=Response[PlanOut])
async def move_to_saddle(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="计划不存在")
try:
await line_service.move_to_saddle(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))
@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})