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:
@@ -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
112
backend/app/api/cost.py
Normal 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": "m³", "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})
|
||||||
@@ -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})
|
||||||
|
|||||||
@@ -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 新字段
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
20
backend/app/models/cost.py
Normal file
20
backend/app/models/cost.py
Normal 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())
|
||||||
13
backend/app/models/line_state.py
Normal file
13
backend/app/models/line_state.py
Normal 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())
|
||||||
@@ -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)
|
||||||
|
|||||||
43
backend/app/schemas/cost.py
Normal file
43
backend/app/schemas/cost.py
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
216
backend/app/services/line_service.py
Normal file
216
backend/app/services/line_service.py
Normal 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/min(2000m ≈ 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)
|
||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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: '/' }
|
||||||
|
|||||||
254
frontend/src/views/CostManagement.vue
Normal file
254
frontend/src/views/CostManagement.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
Reference in New Issue
Block a user