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 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.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(quality.router, prefix="/quality", 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.common import Response, PageResponse
from app.services.auth_service import get_current_user
from app.services import line_service
router = APIRouter()
@@ -42,6 +43,8 @@ async def list_plans(
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)
@@ -80,6 +83,9 @@ async def create_plan(
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))
@@ -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])
async def start_producing(plan_id: int, db: AsyncSession = Depends(get_db), _ = Depends(get_current_user)):
"""移动到入口开始生产本条→producing其它 producing→online单卷在产)。"""
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="计划不存在")
# 其它正在生产的全部回退为在线(强制单卷在产)
others = await db.execute(
select(ProductionPlan).where(
ProductionPlan.status == "producing",
ProductionPlan.id != plan_id,
)
)
for o in others.scalars():
o.status = "online"
plan.status = "producing"
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})