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

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})