commit 193da0018fc136c43f512ddcfeaa4034b7be7ef5
Author: wangyu <823267011@qq.com>
Date: Wed May 27 16:38:40 2026 +0800
feat: 移除PDI和订单号字段,新增设备巡检模块
- 从物料跟踪页面移除订单号列和表单字段
- 从导航菜单移除PDI管理,添加设备巡检
- 新增InspectionLocation和InspectionRecord后端模型和API
- 新增设备巡检前端页面(左侧点位列表,右侧设备和历史记录)
diff --git a/.DS_Store b/.DS_Store
new file mode 100644
index 0000000..828a82f
Binary files /dev/null and b/.DS_Store differ
diff --git a/PPLZJHF2501.00-01 酸洗机组布置总图.pdf b/PPLZJHF2501.00-01 酸洗机组布置总图.pdf
new file mode 100644
index 0000000..856bb2c
Binary files /dev/null and b/PPLZJHF2501.00-01 酸洗机组布置总图.pdf differ
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..4396938
--- /dev/null
+++ b/README.md
@@ -0,0 +1,188 @@
+# 推拉酸洗线 L2 过程控制系统
+
+基于 FastAPI + Vue 2 的推拉酸洗线二级过程控制系统,实现物料跟踪、实绩管理、计划管理、停机管理、设备管理、工艺预测等功能。
+
+---
+
+## 技术栈
+
+| 层 | 技术 |
+|----|------|
+| 后端 | Python 3.11+,FastAPI,SQLAlchemy 2(async),asyncpg |
+| 数据库 | PostgreSQL 16 |
+| 缓存 | Redis 7 |
+| 前端 | Vue 2.7,Element UI,Axios |
+| L1通信 | UDP(asyncio DatagramProtocol),监听 9000 端口 |
+
+---
+
+## 目录结构
+
+```
+pickling-mes/
+├── backend/
+│ ├── app/
+│ │ ├── api/ # 路由(各模块接口)
+│ │ ├── models/ # SQLAlchemy 数据模型
+│ │ ├── services/ # 业务逻辑(UDP解析、预测模型)
+│ │ ├── config.py # 配置(环境变量)
+│ │ ├── database.py # 数据库连接
+│ │ └── main.py # 入口
+│ ├── tests/
+│ │ └── test_udp_sender.py # UDP报文模拟测试工具
+│ └── requirements.txt
+├── frontend/
+│ ├── src/
+│ │ ├── views/ # 页面组件
+│ │ ├── api/ # 接口调用
+│ │ ├── store/ # Vuex(认证)
+│ │ └── router/ # 路由
+│ └── package.json
+├── docs/ # 接口文档
+└── docker-compose.yml
+```
+
+---
+
+## 启动方式
+
+### 方式一:Docker Compose(推荐)
+
+**前置条件**:已安装 Docker Desktop
+
+```bash
+cd pickling-mes
+docker-compose up -d
+```
+
+启动后访问:
+- 前端:http://localhost:8080
+- 后端 API 文档:http://localhost:8000/docs
+- 默认账号:`admin` / `admin123`
+
+停止:
+```bash
+docker-compose down
+```
+
+---
+
+### 方式二:本地开发启动
+
+**前置条件**:Python 3.11+,Node.js 18+,PostgreSQL 16,Redis 7
+
+#### 1. 准备数据库
+
+```bash
+# 启动 PostgreSQL 和 Redis(也可用 Docker 只起这两个服务)
+docker-compose up -d postgres redis
+```
+
+#### 2. 启动后端
+
+```bash
+cd backend
+
+# 安装依赖
+pip install -r requirements.txt
+
+# 配置环境变量(可选,默认值已可用于本地开发)
+cp .env.example .env # 如有需要修改数据库连接
+
+# 启动
+uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
+```
+
+后端启动时会自动:
+- 建表(init_db)
+- 创建默认 admin 账号(admin / admin123)
+- 启动 UDP 监听服务(0.0.0.0:9000)
+
+#### 3. 启动前端
+
+```bash
+cd frontend
+
+# 安装依赖
+npm install
+
+# 开发模式启动
+npm run serve
+```
+
+访问 http://localhost:8080
+
+---
+
+## 环境变量说明
+
+在 `backend/.env` 中配置(不存在则使用下列默认值):
+
+| 变量 | 默认值 | 说明 |
+|------|--------|------|
+| `DATABASE_URL` | `postgresql+asyncpg://postgres:password@localhost:5432/pickling_mes` | 数据库连接(async) |
+| `DATABASE_SYNC_URL` | `postgresql://postgres:password@localhost:5432/pickling_mes` | 数据库连接(同步,Alembic用)|
+| `REDIS_URL` | `redis://localhost:6379/0` | Redis 连接 |
+| `SECRET_KEY` | `dev-secret-key` | JWT 签名密钥,**生产环境必须修改** |
+| `L1_HOST` | `0.0.0.0` | UDP 监听地址 |
+| `L1_PORT` | `9000` | UDP 监听端口(L1 PLC 向此端口发送报文)|
+| `ACCESS_TOKEN_EXPIRE_MINUTES` | `480` | Token 有效期(分钟)|
+
+---
+
+## L1 通信(UDP)
+
+系统启动后自动监听 UDP `0.0.0.0:9000`,接收 L1 PLC 推送的报文。
+
+**已实现的报文类型**(Body 格式待 PLC 方协议文档确认后适配):
+
+| 报文 ID | 含义 | 触发时机 |
+|---------|------|---------|
+| PC01 | 卷材入口 | 带钢上线时 |
+| PC02 | 卷材出口 | 带钢下线时 |
+| PC03 | 过程数据 | 周期推送(2s)|
+| PC04 | 质量缺陷 | 缺陷检出时 |
+| PC05 | 设备状态 | 状态变化时 |
+| PC20 | 心跳 | 每 10s |
+
+**模拟测试**(无 PLC 时验证 UDP 通信):
+
+```bash
+cd backend
+python tests/test_udp_sender.py
+# 默认向 127.0.0.1:9000 发送 PC20/PC01/PC03/PC02 测试帧
+```
+
+---
+
+## 预测模型
+
+后端内置 4 个基于物理公式的工艺预测模型,无需训练数据即可运行:
+
+| 模型 | 接口 | 说明 |
+|------|------|------|
+| 酸洗速度 | `POST /prediction/acid-speed` | 基于 Arrhenius 动力学,输出最大允许速度 |
+| 张力设定 | `POST /prediction/tension` | 基于截面积×屈服强度,输出各区张力 |
+| 质量预测 | `POST /prediction/quality` | 输出质量等级(A1~C)及改进建议 |
+| 消耗预测 | `POST /prediction/consumption` | 输出单卷酸、蒸汽、电、水消耗量 |
+
+接口参数详见:http://localhost:8000/docs
+
+---
+
+## 主要功能模块
+
+| 路径 | 模块 |
+|------|------|
+| `/dashboard` | 生产看板(实时指标、趋势图)|
+| `/material` | 物料跟踪(卷材全流程跟踪)|
+| `/production` | 实绩管理 |
+| `/plan` | 计划管理 |
+| `/downtime` | 停机管理 |
+| `/equipment` | 设备管理 |
+| `/message` | 报文监控(UDP收发日志)|
+| `/process-model` | 工艺段模型(酸洗速度预测)|
+| `/tension-model` | 张力设定 |
+| `/pdi` | PDI 管理(L3 下发→L2 确认)|
+| `/quality` | 质量管理 |
+| `/capacity` | 产能分析 |
diff --git a/backend/.env.example b/backend/.env.example
new file mode 100644
index 0000000..a6f5524
--- /dev/null
+++ b/backend/.env.example
@@ -0,0 +1,23 @@
+# 数据库配置
+DATABASE_URL=postgresql+asyncpg://postgres:password@localhost:5432/pickling_mes
+DATABASE_SYNC_URL=postgresql://postgres:password@localhost:5432/pickling_mes
+
+# Redis配置
+REDIS_URL=redis://localhost:6379/0
+
+# JWT配置
+SECRET_KEY=your-secret-key-change-in-production
+ALGORITHM=HS256
+ACCESS_TOKEN_EXPIRE_MINUTES=480
+
+# 服务配置
+APP_HOST=0.0.0.0
+APP_PORT=8000
+DEBUG=true
+
+# L1报文接收配置(UDP,本机监听)
+L1_HOST=0.0.0.0
+L1_PORT=9000
+
+# 跨域配置
+CORS_ORIGINS=["http://localhost:8080","http://127.0.0.1:8080"]
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..c57f001
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,6 @@
+FROM python:3.12-slim
+WORKDIR /app
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+COPY . .
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/backend/app/__init__.py b/backend/app/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py
new file mode 100644
index 0000000..8c3f8b3
--- /dev/null
+++ b/backend/app/api/__init__.py
@@ -0,0 +1,17 @@
+from fastapi import APIRouter
+from app.api import auth, material, production, plan, downtime, equipment, message, dashboard
+from app.api import prediction, pdi, quality, inspection
+
+router = APIRouter()
+router.include_router(auth.router, prefix="/auth", tags=["认证"])
+router.include_router(dashboard.router, prefix="/dashboard", tags=["看板"])
+router.include_router(material.router, prefix="/material", tags=["物料跟踪"])
+router.include_router(production.router, prefix="/production", tags=["实绩管理"])
+router.include_router(plan.router, prefix="/plan", tags=["计划管理"])
+router.include_router(downtime.router, prefix="/downtime", tags=["停机管理"])
+router.include_router(equipment.router, prefix="/equipment", tags=["设备管理"])
+router.include_router(message.router, prefix="/message", tags=["报文管理"])
+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=["设备巡检"])
diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py
new file mode 100644
index 0000000..9e1e1b2
--- /dev/null
+++ b/backend/app/api/auth.py
@@ -0,0 +1,48 @@
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+
+from app.database import get_db
+from app.models.user import User
+from app.schemas.user import LoginRequest, Token, UserCreate, UserOut
+from app.schemas.common import Response
+from app.services.auth_service import (
+ authenticate_user, create_access_token, hash_password,
+ get_current_user, require_roles
+)
+
+router = APIRouter()
+
+
+@router.post("/login", response_model=Response[Token])
+async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
+ user = await authenticate_user(db, body.username, body.password)
+ if not user:
+ raise HTTPException(status_code=401, detail="用户名或密码错误")
+ token = create_access_token({"sub": user.username})
+ return Response.ok(Token(access_token=token, username=user.username, role=user.role))
+
+
+@router.get("/me", response_model=Response[UserOut])
+async def get_me(current_user: User = Depends(get_current_user)):
+ return Response.ok(UserOut.model_validate(current_user))
+
+
+@router.post("/users", response_model=Response[UserOut])
+async def create_user(
+ body: UserCreate,
+ db: AsyncSession = Depends(get_db),
+ _: User = Depends(require_roles("admin")),
+):
+ result = await db.execute(select(User).where(User.username == body.username))
+ if result.scalar_one_or_none():
+ raise HTTPException(status_code=400, detail="用户名已存在")
+ user = User(
+ username=body.username,
+ full_name=body.full_name,
+ hashed_password=hash_password(body.password),
+ role=body.role,
+ )
+ db.add(user)
+ await db.flush()
+ return Response.ok(UserOut.model_validate(user))
diff --git a/backend/app/api/dashboard.py b/backend/app/api/dashboard.py
new file mode 100644
index 0000000..677b70c
--- /dev/null
+++ b/backend/app/api/dashboard.py
@@ -0,0 +1,55 @@
+from fastapi import APIRouter, Depends
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, func
+from datetime import datetime, date
+
+from app.database import get_db
+from app.models.material import Coil, CoilStatus
+from app.models.production import ProductionRecord
+from app.models.downtime import DowntimeRecord
+from app.models.equipment import Equipment, EquipmentStatus
+from app.schemas.common import Response
+from app.services.auth_service import get_current_user
+
+router = APIRouter()
+
+
+@router.get("/summary", response_model=Response[dict])
+async def get_summary(db: AsyncSession = Depends(get_db), _ = Depends(get_current_user)):
+ today = datetime.combine(date.today(), datetime.min.time())
+
+ # 今日产量
+ prod_result = await db.execute(
+ select(func.count(), func.sum(ProductionRecord.process_weight))
+ .where(ProductionRecord.start_time >= today)
+ )
+ prod_count, prod_weight = prod_result.one()
+
+ # 在线钢卷数
+ online_result = await db.execute(
+ select(func.count()).where(Coil.status == CoilStatus.ON_LINE)
+ )
+ online_coils = online_result.scalar()
+
+ # 今日停机时长
+ downtime_result = await db.execute(
+ select(func.sum(DowntimeRecord.duration))
+ .where(DowntimeRecord.start_time >= today)
+ )
+ total_downtime = downtime_result.scalar() or 0
+
+ # 设备状态统计
+ equip_result = await db.execute(
+ select(Equipment.status, func.count()).group_by(Equipment.status)
+ )
+ equip_stats = {str(row[0]): row[1] for row in equip_result}
+
+ return Response.ok({
+ "today_production": {
+ "coil_count": prod_count or 0,
+ "weight_kg": float(prod_weight or 0),
+ },
+ "online_coils": online_coils or 0,
+ "today_downtime_min": float(total_downtime),
+ "equipment_status": equip_stats,
+ })
diff --git a/backend/app/api/downtime.py b/backend/app/api/downtime.py
new file mode 100644
index 0000000..4abd080
--- /dev/null
+++ b/backend/app/api/downtime.py
@@ -0,0 +1,108 @@
+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.downtime import DowntimeRecord, DowntimeCategory
+from app.schemas.downtime import (
+ DowntimeCreate, DowntimeUpdate, DowntimeOut,
+ CategoryCreate, CategoryOut
+)
+from app.schemas.common import Response, PageResponse
+from app.services.auth_service import get_current_user
+
+router = APIRouter()
+
+
+def _parse_dt(s):
+ if not s:
+ return None
+ try:
+ return datetime.fromisoformat(s.replace('Z', ''))
+ except Exception:
+ return None
+
+
+@router.get("/categories", response_model=Response[list[CategoryOut]])
+async def list_categories(db: AsyncSession = Depends(get_db), _ = Depends(get_current_user)):
+ result = await db.execute(select(DowntimeCategory).where(DowntimeCategory.is_active == 1))
+ return Response.ok([CategoryOut.model_validate(c) for c in result.scalars()])
+
+
+@router.post("/categories", response_model=Response[CategoryOut])
+async def create_category(
+ body: CategoryCreate,
+ db: AsyncSession = Depends(get_db),
+ _ = Depends(get_current_user),
+):
+ cat = DowntimeCategory(**body.model_dump())
+ db.add(cat)
+ await db.flush()
+ return Response.ok(CategoryOut.model_validate(cat))
+
+
+@router.get("/", response_model=Response[PageResponse[DowntimeOut]])
+async def list_downtime(
+ page: int = 1,
+ page_size: int = 20,
+ category_code: Optional[str] = None,
+ shift: Optional[str] = None,
+ start_date: Optional[str] = Query(None),
+ end_date: Optional[str] = Query(None),
+ is_planned: Optional[int] = None,
+ db: AsyncSession = Depends(get_db),
+ _ = Depends(get_current_user),
+):
+ query = select(DowntimeRecord).order_by(desc(DowntimeRecord.start_time))
+ if category_code:
+ query = query.where(DowntimeRecord.category_code == category_code)
+ if shift:
+ query = query.where(DowntimeRecord.shift == shift)
+ _sd = _parse_dt(start_date)
+ if _sd:
+ query = query.where(DowntimeRecord.start_time >= _sd)
+ _ed = _parse_dt(end_date)
+ if _ed:
+ query = query.where(DowntimeRecord.start_time <= _ed)
+ if is_planned is not None:
+ query = query.where(DowntimeRecord.is_planned == is_planned)
+
+ 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 = [DowntimeOut.model_validate(r) for r in result.scalars()]
+ return Response.ok(PageResponse(total=total, page=page, page_size=page_size, items=items))
+
+
+@router.post("/", response_model=Response[DowntimeOut])
+async def create_downtime(
+ body: DowntimeCreate,
+ db: AsyncSession = Depends(get_db),
+ _ = Depends(get_current_user),
+):
+ record = DowntimeRecord(**body.model_dump())
+ db.add(record)
+ await db.flush()
+ return Response.ok(DowntimeOut.model_validate(record))
+
+
+@router.put("/{record_id}", response_model=Response[DowntimeOut])
+async def update_downtime(
+ record_id: int,
+ body: DowntimeUpdate,
+ db: AsyncSession = Depends(get_db),
+ _ = Depends(get_current_user),
+):
+ result = await db.execute(select(DowntimeRecord).where(DowntimeRecord.id == record_id))
+ record = result.scalar_one_or_none()
+ if not record:
+ raise HTTPException(status_code=404, detail="停机记录不存在")
+ update_data = body.model_dump(exclude_none=True)
+ for k, v in update_data.items():
+ setattr(record, k, v)
+ if record.end_time and record.start_time:
+ delta = (record.end_time - record.start_time).total_seconds() / 60
+ record.duration = round(delta, 2)
+ await db.flush()
+ return Response.ok(DowntimeOut.model_validate(record))
diff --git a/backend/app/api/equipment.py b/backend/app/api/equipment.py
new file mode 100644
index 0000000..b880d73
--- /dev/null
+++ b/backend/app/api/equipment.py
@@ -0,0 +1,112 @@
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, func, desc
+from typing import Optional
+
+from app.database import get_db
+from app.models.equipment import Equipment, EquipmentMaintenance, EquipmentStatus
+from app.schemas.equipment import (
+ EquipmentCreate, EquipmentUpdate, EquipmentOut,
+ MaintenanceCreate, MaintenanceOut
+)
+from app.schemas.common import Response, PageResponse
+from app.services.auth_service import get_current_user
+
+router = APIRouter()
+
+
+@router.get("/", response_model=Response[PageResponse[EquipmentOut]])
+async def list_equipment(
+ page: int = 1,
+ page_size: int = 20,
+ name: Optional[str] = None,
+ status: Optional[str] = None,
+ category: Optional[str] = None,
+ db: AsyncSession = Depends(get_db),
+ _ = Depends(get_current_user),
+):
+ query = select(Equipment).order_by(Equipment.code)
+ if name:
+ query = query.where(Equipment.name.ilike(f"%{name}%"))
+ if status:
+ try:
+ query = query.where(Equipment.status == EquipmentStatus(status))
+ except ValueError:
+ pass
+ if category:
+ query = query.where(Equipment.category == category)
+
+ 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 = [EquipmentOut.model_validate(e) for e in result.scalars()]
+ return Response.ok(PageResponse(total=total, page=page, page_size=page_size, items=items))
+
+
+@router.post("/", response_model=Response[EquipmentOut])
+async def create_equipment(
+ body: EquipmentCreate,
+ db: AsyncSession = Depends(get_db),
+ _ = Depends(get_current_user),
+):
+ existing = await db.execute(select(Equipment).where(Equipment.code == body.code))
+ if existing.scalar_one_or_none():
+ raise HTTPException(status_code=400, detail="设备编号已存在")
+ equip = Equipment(**body.model_dump())
+ db.add(equip)
+ await db.flush()
+ return Response.ok(EquipmentOut.model_validate(equip))
+
+
+@router.get("/{equip_id}", response_model=Response[EquipmentOut])
+async def get_equipment(equip_id: int, db: AsyncSession = Depends(get_db), _ = Depends(get_current_user)):
+ result = await db.execute(select(Equipment).where(Equipment.id == equip_id))
+ equip = result.scalar_one_or_none()
+ if not equip:
+ raise HTTPException(status_code=404, detail="设备不存在")
+ return Response.ok(EquipmentOut.model_validate(equip))
+
+
+@router.put("/{equip_id}", response_model=Response[EquipmentOut])
+async def update_equipment(
+ equip_id: int,
+ body: EquipmentUpdate,
+ db: AsyncSession = Depends(get_db),
+ _ = Depends(get_current_user),
+):
+ result = await db.execute(select(Equipment).where(Equipment.id == equip_id))
+ equip = result.scalar_one_or_none()
+ if not equip:
+ raise HTTPException(status_code=404, detail="设备不存在")
+ for k, v in body.model_dump(exclude_none=True).items():
+ setattr(equip, k, v)
+ await db.flush()
+ return Response.ok(EquipmentOut.model_validate(equip))
+
+
+@router.get("/{equip_id}/maintenance", response_model=Response[PageResponse[MaintenanceOut]])
+async def list_maintenance(
+ equip_id: int,
+ page: int = 1,
+ page_size: int = 20,
+ db: AsyncSession = Depends(get_db),
+ _ = Depends(get_current_user),
+):
+ query = select(EquipmentMaintenance).where(
+ EquipmentMaintenance.equipment_id == equip_id
+ ).order_by(desc(EquipmentMaintenance.start_time))
+ 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 = [MaintenanceOut.model_validate(m) for m in result.scalars()]
+ return Response.ok(PageResponse(total=total, page=page, page_size=page_size, items=items))
+
+
+@router.post("/maintenance", response_model=Response[MaintenanceOut])
+async def create_maintenance(
+ body: MaintenanceCreate,
+ db: AsyncSession = Depends(get_db),
+ _ = Depends(get_current_user),
+):
+ record = EquipmentMaintenance(**body.model_dump())
+ db.add(record)
+ await db.flush()
+ return Response.ok(MaintenanceOut.model_validate(record))
diff --git a/backend/app/api/inspection.py b/backend/app/api/inspection.py
new file mode 100644
index 0000000..21a157a
--- /dev/null
+++ b/backend/app/api/inspection.py
@@ -0,0 +1,75 @@
+from fastapi import APIRouter, Depends
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, func, desc
+from typing import Optional
+
+from app.database import get_db
+from app.models.inspection import InspectionLocation, InspectionRecord
+from app.schemas.inspection import (
+ InspectionLocationCreate, InspectionLocationOut,
+ InspectionRecordCreate, InspectionRecordOut,
+)
+from app.schemas.common import Response, PageResponse
+from app.services.auth_service import get_current_user
+
+router = APIRouter()
+
+
+@router.get("/locations", response_model=Response[list[InspectionLocationOut]])
+async def list_locations(
+ db: AsyncSession = Depends(get_db),
+ _ = Depends(get_current_user),
+):
+ result = await db.execute(
+ select(InspectionLocation).order_by(InspectionLocation.sort_order, InspectionLocation.id)
+ )
+ items = [InspectionLocationOut.model_validate(r) for r in result.scalars()]
+ return Response.ok(items)
+
+
+@router.post("/locations", response_model=Response[InspectionLocationOut])
+async def create_location(
+ body: InspectionLocationCreate,
+ db: AsyncSession = Depends(get_db),
+ _ = Depends(get_current_user),
+):
+ loc = InspectionLocation(**body.model_dump())
+ db.add(loc)
+ await db.flush()
+ return Response.ok(InspectionLocationOut.model_validate(loc))
+
+
+@router.get("/records", response_model=Response[PageResponse[InspectionRecordOut]])
+async def list_records(
+ page: int = 1,
+ page_size: int = 30,
+ location_id: Optional[int] = None,
+ db: AsyncSession = Depends(get_db),
+ _ = Depends(get_current_user),
+):
+ query = select(InspectionRecord).order_by(desc(InspectionRecord.created_at))
+ if location_id:
+ query = query.where(InspectionRecord.location_id == location_id)
+ 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 = [InspectionRecordOut.model_validate(r) for r in result.scalars()]
+ return Response.ok(PageResponse(total=total, page=page, page_size=page_size, items=items))
+
+
+@router.post("/records", response_model=Response[InspectionRecordOut])
+async def create_record(
+ body: InspectionRecordCreate,
+ db: AsyncSession = Depends(get_db),
+ _ = Depends(get_current_user),
+):
+ loc_result = await db.execute(
+ select(InspectionLocation).where(InspectionLocation.id == body.location_id)
+ )
+ loc = loc_result.scalar_one_or_none()
+ record = InspectionRecord(
+ **body.model_dump(),
+ location_name=loc.name if loc else None,
+ )
+ db.add(record)
+ await db.flush()
+ return Response.ok(InspectionRecordOut.model_validate(record))
diff --git a/backend/app/api/material.py b/backend/app/api/material.py
new file mode 100644
index 0000000..2c1cc21
--- /dev/null
+++ b/backend/app/api/material.py
@@ -0,0 +1,102 @@
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, func, desc
+from typing import Optional
+
+from app.database import get_db
+from app.models.material import Coil, MaterialTracking, CoilStatus
+from app.schemas.material import CoilCreate, CoilUpdate, CoilOut, TrackingCreate, TrackingOut
+from app.schemas.common import Response, PageResponse
+from app.services.auth_service import get_current_user
+
+router = APIRouter()
+
+
+@router.get("/coils", response_model=Response[PageResponse[CoilOut]])
+async def list_coils(
+ page: int = 1,
+ page_size: int = 20,
+ coil_no: Optional[str] = None,
+ status: Optional[str] = None,
+ steel_grade: Optional[str] = None,
+ db: AsyncSession = Depends(get_db),
+ _ = Depends(get_current_user),
+):
+ query = select(Coil).order_by(desc(Coil.created_at))
+ if coil_no:
+ query = query.where(Coil.coil_no.ilike(f"%{coil_no}%"))
+ if status:
+ try:
+ query = query.where(Coil.status == CoilStatus(status))
+ except ValueError:
+ pass
+ if steel_grade:
+ query = query.where(Coil.steel_grade == steel_grade)
+
+ total_result = await db.execute(select(func.count()).select_from(query.subquery()))
+ total = total_result.scalar()
+
+ result = await db.execute(query.offset((page - 1) * page_size).limit(page_size))
+ items = [CoilOut.model_validate(c) for c in result.scalars()]
+
+ return Response.ok(PageResponse(total=total, page=page, page_size=page_size, items=items))
+
+
+@router.post("/coils", response_model=Response[CoilOut])
+async def create_coil(
+ body: CoilCreate,
+ db: AsyncSession = Depends(get_db),
+ _ = Depends(get_current_user),
+):
+ existing = await db.execute(select(Coil).where(Coil.coil_no == body.coil_no))
+ if existing.scalar_one_or_none():
+ raise HTTPException(status_code=400, detail="卷号已存在")
+ coil = Coil(**body.model_dump())
+ db.add(coil)
+ await db.flush()
+ return Response.ok(CoilOut.model_validate(coil))
+
+
+@router.get("/coils/{coil_no}", response_model=Response[CoilOut])
+async def get_coil(coil_no: str, db: AsyncSession = Depends(get_db), _ = Depends(get_current_user)):
+ result = await db.execute(select(Coil).where(Coil.coil_no == coil_no))
+ coil = result.scalar_one_or_none()
+ if not coil:
+ raise HTTPException(status_code=404, detail="钢卷不存在")
+ return Response.ok(CoilOut.model_validate(coil))
+
+
+@router.put("/coils/{coil_no}", response_model=Response[CoilOut])
+async def update_coil(
+ coil_no: str,
+ body: CoilUpdate,
+ db: AsyncSession = Depends(get_db),
+ _ = Depends(get_current_user),
+):
+ result = await db.execute(select(Coil).where(Coil.coil_no == coil_no))
+ coil = result.scalar_one_or_none()
+ if not coil:
+ raise HTTPException(status_code=404, detail="钢卷不存在")
+ for k, v in body.model_dump(exclude_none=True).items():
+ setattr(coil, k, v)
+ await db.flush()
+ return Response.ok(CoilOut.model_validate(coil))
+
+
+@router.get("/tracking", response_model=Response[PageResponse[TrackingOut]])
+async def list_tracking(
+ coil_no: Optional[str] = None,
+ page: int = 1,
+ page_size: int = 50,
+ db: AsyncSession = Depends(get_db),
+ _ = Depends(get_current_user),
+):
+ query = select(MaterialTracking).order_by(desc(MaterialTracking.event_time))
+ if coil_no:
+ query = query.where(MaterialTracking.coil_no == coil_no)
+
+ total_result = await db.execute(select(func.count()).select_from(query.subquery()))
+ total = total_result.scalar()
+ result = await db.execute(query.offset((page - 1) * page_size).limit(page_size))
+ items = [TrackingOut.model_validate(t) for t in result.scalars()]
+ return Response.ok(PageResponse(total=total, page=page, page_size=page_size, items=items))
diff --git a/backend/app/api/message.py b/backend/app/api/message.py
new file mode 100644
index 0000000..82614d9
--- /dev/null
+++ b/backend/app/api/message.py
@@ -0,0 +1,82 @@
+from fastapi import APIRouter, Depends, 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.message import MessageLog
+from app.schemas.common import Response, PageResponse
+from app.services.auth_service import get_current_user
+
+router = APIRouter()
+
+
+def _parse_dt(s):
+ if not s:
+ return None
+ try:
+ return datetime.fromisoformat(s.replace('Z', ''))
+ except Exception:
+ return None
+
+
+@router.get("/logs", response_model=Response[PageResponse[dict]])
+async def list_message_logs(
+ page: int = 1,
+ page_size: int = 50,
+ msg_type: Optional[str] = None,
+ direction: Optional[str] = None,
+ status: Optional[str] = None,
+ start_time: Optional[str] = Query(None),
+ end_time: Optional[str] = Query(None),
+ db: AsyncSession = Depends(get_db),
+ _ = Depends(get_current_user),
+):
+ query = select(MessageLog).order_by(desc(MessageLog.received_at))
+ if msg_type:
+ query = query.where(MessageLog.msg_type == msg_type)
+ if direction:
+ query = query.where(MessageLog.direction == direction)
+ if status:
+ query = query.where(MessageLog.status == status)
+ _sd = _parse_dt(start_time)
+ if _sd:
+ query = query.where(MessageLog.received_at >= _sd)
+ _ed = _parse_dt(end_time)
+ if _ed:
+ query = query.where(MessageLog.received_at <= _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 = []
+ for log in result.scalars():
+ items.append({
+ "id": log.id,
+ "msg_id": log.msg_id,
+ "msg_type": log.msg_type,
+ "direction": log.direction,
+ "source": log.source,
+ "status": log.status,
+ "error_msg": log.error_msg,
+ "process_time": log.process_time,
+ "received_at": log.received_at,
+ })
+ return Response.ok(PageResponse(total=total, page=page, page_size=page_size, items=items))
+
+
+@router.get("/logs/{log_id}", response_model=Response[dict])
+async def get_message_log(log_id: int, db: AsyncSession = Depends(get_db), _ = Depends(get_current_user)):
+ result = await db.execute(select(MessageLog).where(MessageLog.id == log_id))
+ log = result.scalar_one_or_none()
+ if not log:
+ return Response.error("报文记录不存在", code=404)
+ return Response.ok({
+ "id": log.id,
+ "msg_type": log.msg_type,
+ "direction": log.direction,
+ "raw_data": log.raw_data,
+ "parsed_data": log.parsed_data,
+ "status": log.status,
+ "received_at": log.received_at,
+ })
diff --git a/backend/app/api/pdi.py b/backend/app/api/pdi.py
new file mode 100644
index 0000000..4efaa7f
--- /dev/null
+++ b/backend/app/api/pdi.py
@@ -0,0 +1,112 @@
+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.pdi import PDIRecord, L3Status, L2Status
+from app.schemas.pdi import PDIRecordCreate, PDIRecordUpdate, PDIRecordOut
+from app.schemas.common import Response, PageResponse
+from app.services.auth_service import get_current_user
+
+router = APIRouter()
+
+
+@router.get("/stats", response_model=Response[dict])
+async def get_pdi_stats(
+ db: AsyncSession = Depends(get_db),
+ _=Depends(get_current_user),
+):
+ """各状态PDI数量统计"""
+ total_q = await db.execute(select(func.count()).select_from(PDIRecord))
+ pending_q = await db.execute(
+ select(func.count()).select_from(PDIRecord).where(PDIRecord.l2_status == L2Status.pending)
+ )
+ proc_q = await db.execute(
+ select(func.count()).select_from(PDIRecord).where(PDIRecord.l2_status == L2Status.processing)
+ )
+ done_q = await db.execute(
+ select(func.count()).select_from(PDIRecord).where(PDIRecord.l2_status == L2Status.done)
+ )
+ confirmed_q = await db.execute(
+ select(func.count()).select_from(PDIRecord).where(PDIRecord.l3_status == L3Status.confirmed)
+ )
+
+ return Response.ok({
+ "total": total_q.scalar(),
+ "pending": pending_q.scalar(),
+ "processing": proc_q.scalar(),
+ "done": done_q.scalar(),
+ "confirmed": confirmed_q.scalar(),
+ })
+
+
+@router.get("/", response_model=Response[PageResponse[PDIRecordOut]])
+async def list_pdi(
+ page: int = 1,
+ page_size: int = 20,
+ coil_no: Optional[str] = None,
+ l3_status: Optional[str] = None,
+ l2_status: Optional[str] = None,
+ db: AsyncSession = Depends(get_db),
+ _=Depends(get_current_user),
+):
+ query = select(PDIRecord).order_by(desc(PDIRecord.created_at))
+ if coil_no:
+ query = query.where(PDIRecord.coil_no.ilike(f"%{coil_no}%"))
+ if l3_status:
+ query = query.where(PDIRecord.l3_status == l3_status)
+ if l2_status:
+ query = query.where(PDIRecord.l2_status == l2_status)
+
+ 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 = [PDIRecordOut.model_validate(r) for r in result.scalars()]
+ return Response.ok(PageResponse(total=total, page=page, page_size=page_size, items=items))
+
+
+@router.post("/", response_model=Response[PDIRecordOut])
+async def create_pdi(
+ body: PDIRecordCreate,
+ db: AsyncSession = Depends(get_db),
+ _=Depends(get_current_user),
+):
+ record = PDIRecord(**body.model_dump())
+ db.add(record)
+ await db.flush()
+ return Response.ok(PDIRecordOut.model_validate(record))
+
+
+@router.put("/{pdi_id}", response_model=Response[PDIRecordOut])
+async def update_pdi(
+ pdi_id: int,
+ body: PDIRecordUpdate,
+ db: AsyncSession = Depends(get_db),
+ _=Depends(get_current_user),
+):
+ result = await db.execute(select(PDIRecord).where(PDIRecord.id == pdi_id))
+ record = result.scalar_one_or_none()
+ if not record:
+ raise HTTPException(status_code=404, detail="PDI记录不存在")
+ for k, v in body.model_dump(exclude_none=True).items():
+ setattr(record, k, v)
+ await db.flush()
+ return Response.ok(PDIRecordOut.model_validate(record))
+
+
+@router.patch("/{pdi_id}/confirm", response_model=Response[PDIRecordOut])
+async def confirm_pdi(
+ pdi_id: int,
+ db: AsyncSession = Depends(get_db),
+ _=Depends(get_current_user),
+):
+ """L2确认PDI,将状态设置为processing"""
+ result = await db.execute(select(PDIRecord).where(PDIRecord.id == pdi_id))
+ record = result.scalar_one_or_none()
+ if not record:
+ raise HTTPException(status_code=404, detail="PDI记录不存在")
+ record.l2_status = L2Status.processing
+ record.confirm_time = datetime.utcnow()
+ await db.flush()
+ return Response.ok(PDIRecordOut.model_validate(record))
diff --git a/backend/app/api/plan.py b/backend/app/api/plan.py
new file mode 100644
index 0000000..56da118
--- /dev/null
+++ b/backend/app/api/plan.py
@@ -0,0 +1,103 @@
+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, PlanStatus
+from app.schemas.plan import PlanCreate, PlanUpdate, PlanOut
+from app.schemas.common import Response, PageResponse
+from app.services.auth_service import get_current_user
+
+router = APIRouter()
+
+
+def _parse_dt(s):
+ if not s:
+ return None
+ try:
+ return datetime.fromisoformat(s.replace('Z', ''))
+ except Exception:
+ return None
+
+
+@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),
+):
+ query = select(ProductionPlan).order_by(desc(ProductionPlan.plan_date))
+ if status:
+ try:
+ query = query.where(ProductionPlan.status == PlanStatus(status))
+ except ValueError:
+ pass
+ _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.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()
+ 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 = PlanStatus.CONFIRMED
+ await db.flush()
+ return Response.ok(PlanOut.model_validate(plan))
diff --git a/backend/app/api/prediction.py b/backend/app/api/prediction.py
new file mode 100644
index 0000000..47e6bb9
--- /dev/null
+++ b/backend/app/api/prediction.py
@@ -0,0 +1,294 @@
+from fastapi import APIRouter, Depends, HTTPException
+from pydantic import BaseModel, Field
+from typing import List, Optional
+from datetime import datetime
+
+from app.schemas.common import Response
+from app.services.auth_service import get_current_user
+from app.services.prediction import (
+ AcidSpeedModel,
+ TensionModel,
+ QualityPredictionModel,
+ AcidConsumptionModel,
+ _load_cal,
+ _save_cal,
+)
+
+router = APIRouter()
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Prediction request schemas
+# ─────────────────────────────────────────────────────────────────────────────
+
+class AcidSpeedRequest(BaseModel):
+ thickness: float = Field(..., gt=0)
+ width: float = Field(..., gt=0)
+ steel_grade: str
+ acid_conc_list: List[float]
+ acid_temp_list: List[float]
+ scale_weight: Optional[float] = 8.5
+ target_pi: Optional[float] = 95.0
+
+
+class TensionRequest(BaseModel):
+ thickness: float = Field(..., gt=0)
+ width: float = Field(..., gt=0)
+ yield_strength: float = Field(..., gt=0)
+ tension_coef: Optional[float] = 0.25
+
+
+class QualityRequest(BaseModel):
+ thickness: float = Field(..., gt=0)
+ avg_speed: float = Field(..., gt=0)
+ acid_conc_avg: float = Field(..., gt=0)
+ acid_temp_avg: float = Field(..., gt=0)
+ scale_weight: Optional[float] = 8.5
+
+
+class ConsumptionRequest(BaseModel):
+ thickness: float = Field(..., gt=0)
+ width: float = Field(..., gt=0)
+ coil_weight_kg: float = Field(..., gt=0)
+ has_regen_station: Optional[bool] = True
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Calibration request schemas
+# ─────────────────────────────────────────────────────────────────────────────
+
+class AcidCalibRequest(BaseModel):
+ # 需要重建模型实例的上下文参数
+ thickness: float = Field(..., gt=0)
+ width: float = Field(..., gt=0)
+ steel_grade: str
+ acid_conc_list: List[float]
+ acid_temp_list: List[float]
+ scale_weight: Optional[float] = 8.5
+ # 校准输入
+ actual_max_speed: float = Field(..., gt=0, description="实测质量合格时的最高速度 m/min")
+ actual_quality_ok: bool = Field(..., description="该速度下质量是否合格")
+ note: Optional[str] = None
+
+
+class TensionCalibRequest(BaseModel):
+ thickness: float = Field(..., gt=0)
+ width: float = Field(..., gt=0)
+ yield_strength: float = Field(..., gt=0)
+ tension_coef: Optional[float] = 0.25
+ zone: str = Field(..., description="测量位置,如 s1_roller")
+ measured_kn: float = Field(..., gt=0, description="实测张力 kN")
+ note: Optional[str] = None
+
+
+class QualityCalibRequest(BaseModel):
+ thickness: float = Field(..., gt=0)
+ avg_speed: float = Field(..., gt=0)
+ acid_conc_avg: float = Field(..., gt=0)
+ acid_temp_avg: float = Field(..., gt=0)
+ scale_weight: Optional[float] = 8.5
+ actual_grade: str = Field(..., description="实际质检等级 A1/A2/B1/B2/C")
+ note: Optional[str] = None
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Helper: append calibration history
+# ─────────────────────────────────────────────────────────────────────────────
+
+def _append_history(model_key: str, k_before: float, k_after: float,
+ input_data: dict, note: str = ""):
+ cal = _load_cal()
+ history = cal.get("history", [])
+ history.insert(0, {
+ "ts": datetime.now().isoformat(timespec="seconds"),
+ "model": model_key,
+ "k_before": k_before,
+ "k_after": k_after,
+ "input": input_data,
+ "note": note or "",
+ })
+ cal["history"] = history[:100]
+ _save_cal(cal)
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Prediction endpoints
+# ─────────────────────────────────────────────────────────────────────────────
+
+@router.post("/acid-speed", response_model=Response[dict])
+async def predict_acid_speed(body: AcidSpeedRequest, _=Depends(get_current_user)):
+ try:
+ model = AcidSpeedModel(
+ thickness=body.thickness, width=body.width,
+ steel_grade=body.steel_grade,
+ acid_conc_list=body.acid_conc_list,
+ acid_temp_list=body.acid_temp_list,
+ scale_weight=body.scale_weight, target_pi=body.target_pi,
+ )
+ result = model.calculate()
+ except ValueError as e:
+ raise HTTPException(status_code=422, detail=str(e))
+ return Response.ok(result)
+
+
+@router.post("/tension", response_model=Response[dict])
+async def predict_tension(body: TensionRequest, _=Depends(get_current_user)):
+ model = TensionModel(
+ thickness=body.thickness, width=body.width,
+ yield_strength=body.yield_strength, tension_coef=body.tension_coef,
+ )
+ return Response.ok(model.calculate())
+
+
+@router.post("/quality", response_model=Response[dict])
+async def predict_quality(body: QualityRequest, _=Depends(get_current_user)):
+ model = QualityPredictionModel(
+ thickness=body.thickness, avg_speed=body.avg_speed,
+ acid_conc_avg=body.acid_conc_avg, acid_temp_avg=body.acid_temp_avg,
+ scale_weight=body.scale_weight,
+ )
+ return Response.ok(model.calculate())
+
+
+@router.post("/consumption", response_model=Response[dict])
+async def predict_consumption(body: ConsumptionRequest, _=Depends(get_current_user)):
+ model = AcidConsumptionModel(
+ thickness=body.thickness, width=body.width,
+ coil_weight_kg=body.coil_weight_kg,
+ has_regen_station=body.has_regen_station,
+ )
+ return Response.ok(model.calculate())
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Calibration endpoints
+# ─────────────────────────────────────────────────────────────────────────────
+
+TENSION_ZONES = ["inlet","s1_roller","acid_entry","acid1","acid2","acid3","rinse","leveler","s2_roller","outlet"]
+
+
+@router.get("/calibration", response_model=Response[dict])
+async def get_calibration(_=Depends(get_current_user)):
+ """返回各模型当前校准系数和历史记录"""
+ cal = _load_cal()
+ tension_zone_kcal = {
+ z: cal.get(f"tension_zone_{z}", 1.0) for z in TENSION_ZONES
+ }
+ return Response.ok({
+ "acid_speed_kcal": cal.get("acid_speed_kcal", 1.0),
+ "tension_zone_kcal": tension_zone_kcal,
+ "quality_kcal": cal.get("quality_kcal", 1.0),
+ "history": cal.get("history", []),
+ })
+
+
+@router.post("/calibration/acid-speed", response_model=Response[dict])
+async def calibrate_acid_speed(body: AcidCalibRequest, _=Depends(get_current_user)):
+ """录入实测数据,更新酸洗速度模型校准系数"""
+ try:
+ model = AcidSpeedModel(
+ thickness=body.thickness, width=body.width,
+ steel_grade=body.steel_grade,
+ acid_conc_list=body.acid_conc_list,
+ acid_temp_list=body.acid_temp_list,
+ scale_weight=body.scale_weight,
+ )
+ except ValueError as e:
+ raise HTTPException(status_code=422, detail=str(e))
+
+ k_before = model.K_cal
+ predicted_speed = model.calculate()["max_speed"]
+ k_after = model.calibrate(
+ actual_max_speed=body.actual_max_speed,
+ actual_quality_ok=body.actual_quality_ok,
+ )
+ _append_history(
+ "acid_speed", k_before, k_after,
+ {"actual_speed": body.actual_max_speed,
+ "quality_ok": body.actual_quality_ok,
+ "predicted_speed": predicted_speed},
+ body.note or "",
+ )
+ return Response.ok({
+ "k_before": k_before,
+ "k_after": k_after,
+ "predicted_speed": predicted_speed,
+ "adjustment": round((k_after / k_before - 1) * 100, 2),
+ })
+
+
+@router.post("/calibration/tension", response_model=Response[dict])
+async def calibrate_tension(body: TensionCalibRequest, _=Depends(get_current_user)):
+ """录入实测张力,仅更新指定区段的校准系数"""
+ model = TensionModel(
+ thickness=body.thickness, width=body.width,
+ yield_strength=body.yield_strength, tension_coef=body.tension_coef,
+ )
+ calc = model.calculate()
+ predicted_kn = calc["zones"].get(body.zone, {}).get("tension_kN", 0)
+ k_before = model.zone_kcal.get(body.zone, 1.0)
+ new_zone_kcal = model.calibrate(zone=body.zone, measured_kn=body.measured_kn)
+ k_after = new_zone_kcal.get(body.zone, 1.0)
+ _append_history(
+ "tension", k_before, k_after,
+ {"zone": body.zone,
+ "measured_kn": body.measured_kn,
+ "predicted_kn": predicted_kn},
+ body.note or "",
+ )
+ return Response.ok({
+ "zone": body.zone,
+ "k_before": k_before,
+ "k_after": k_after,
+ "predicted_kn": predicted_kn,
+ "measured_kn": body.measured_kn,
+ "adjustment": round((k_after / k_before - 1) * 100, 2),
+ "zone_kcal": new_zone_kcal,
+ })
+
+
+@router.post("/calibration/quality", response_model=Response[dict])
+async def calibrate_quality(body: QualityCalibRequest, _=Depends(get_current_user)):
+ """录入实际质检等级,更新质量模型校准系数"""
+ model = QualityPredictionModel(
+ thickness=body.thickness, avg_speed=body.avg_speed,
+ acid_conc_avg=body.acid_conc_avg, acid_temp_avg=body.acid_temp_avg,
+ scale_weight=body.scale_weight,
+ )
+ k_before = model.K_cal
+ calc = model.calculate()
+ predicted_grade = calc["overall_grade"]
+ k_after = model.calibrate(actual_grade=body.actual_grade)
+ _append_history(
+ "quality", k_before, k_after,
+ {"actual_grade": body.actual_grade,
+ "predicted_grade": predicted_grade},
+ body.note or "",
+ )
+ return Response.ok({
+ "k_before": k_before,
+ "k_after": k_after,
+ "predicted_grade": predicted_grade,
+ "actual_grade": body.actual_grade,
+ "adjustment": round((k_after / k_before - 1) * 100, 2),
+ })
+
+
+@router.post("/calibration/reset/{model_key}", response_model=Response[dict])
+async def reset_calibration(model_key: str, _=Depends(get_current_user)):
+ """将指定模型的校准系数全部重置为 1.0"""
+ cal = _load_cal()
+ if model_key == "tension":
+ # 重置所有区段
+ for z in TENSION_ZONES:
+ cal[f"tension_zone_{z}"] = 1.0
+ _append_history("tension", None, 1.0, {"action": "reset_all_zones"})
+ elif model_key in ("acid_speed", "quality"):
+ key = f"{model_key}_kcal"
+ k_before = cal.get(key, 1.0)
+ cal[key] = 1.0
+ _append_history(model_key, k_before, 1.0, {"action": "reset"})
+ else:
+ raise HTTPException(status_code=404, detail="未知模型")
+ _save_cal(cal)
+ return Response.ok({"model": model_key, "reset": True})
diff --git a/backend/app/api/production.py b/backend/app/api/production.py
new file mode 100644
index 0000000..59acd06
--- /dev/null
+++ b/backend/app/api/production.py
@@ -0,0 +1,89 @@
+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.production import ProductionRecord
+from app.schemas.production import ProductionRecordCreate, ProductionRecordUpdate, ProductionRecordOut
+from app.schemas.common import Response, PageResponse
+from app.services.auth_service import get_current_user
+
+router = APIRouter()
+
+
+def _parse_dt(s):
+ if not s:
+ return None
+ try:
+ return datetime.fromisoformat(s.replace('Z', ''))
+ except Exception:
+ return None
+
+
+@router.get("/", response_model=Response[PageResponse[ProductionRecordOut]])
+async def list_records(
+ page: int = 1,
+ page_size: int = 20,
+ coil_no: Optional[str] = None,
+ shift: 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(ProductionRecord).order_by(desc(ProductionRecord.start_time))
+ if coil_no:
+ query = query.where(ProductionRecord.coil_no.ilike(f"%{coil_no}%"))
+ if shift:
+ query = query.where(ProductionRecord.shift == shift)
+ _sd = _parse_dt(start_date)
+ if _sd:
+ query = query.where(ProductionRecord.start_time >= _sd)
+ _ed = _parse_dt(end_date)
+ if _ed:
+ query = query.where(ProductionRecord.start_time <= _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 = [ProductionRecordOut.model_validate(r) for r in result.scalars()]
+ return Response.ok(PageResponse(total=total, page=page, page_size=page_size, items=items))
+
+
+@router.post("/", response_model=Response[ProductionRecordOut])
+async def create_record(
+ body: ProductionRecordCreate,
+ db: AsyncSession = Depends(get_db),
+ _ = Depends(get_current_user),
+):
+ record = ProductionRecord(**body.model_dump())
+ db.add(record)
+ await db.flush()
+ return Response.ok(ProductionRecordOut.model_validate(record))
+
+
+@router.get("/{record_id}", response_model=Response[ProductionRecordOut])
+async def get_record(record_id: int, db: AsyncSession = Depends(get_db), _ = Depends(get_current_user)):
+ result = await db.execute(select(ProductionRecord).where(ProductionRecord.id == record_id))
+ record = result.scalar_one_or_none()
+ if not record:
+ raise HTTPException(status_code=404, detail="实绩记录不存在")
+ return Response.ok(ProductionRecordOut.model_validate(record))
+
+
+@router.put("/{record_id}", response_model=Response[ProductionRecordOut])
+async def update_record(
+ record_id: int,
+ body: ProductionRecordUpdate,
+ db: AsyncSession = Depends(get_db),
+ _ = Depends(get_current_user),
+):
+ result = await db.execute(select(ProductionRecord).where(ProductionRecord.id == record_id))
+ record = result.scalar_one_or_none()
+ if not record:
+ raise HTTPException(status_code=404, detail="实绩记录不存在")
+ for k, v in body.model_dump(exclude_none=True).items():
+ setattr(record, k, v)
+ await db.flush()
+ return Response.ok(ProductionRecordOut.model_validate(record))
diff --git a/backend/app/api/quality.py b/backend/app/api/quality.py
new file mode 100644
index 0000000..d7102b1
--- /dev/null
+++ b/backend/app/api/quality.py
@@ -0,0 +1,134 @@
+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.quality import QualityRecord
+from app.schemas.quality import QualityRecordCreate, QualityRecordUpdate, QualityRecordOut
+from app.schemas.common import Response, PageResponse
+from app.services.auth_service import get_current_user
+from app.services.prediction import QualityPredictionModel
+
+router = APIRouter()
+
+
+@router.get("/summary", response_model=Response[dict])
+async def quality_summary(
+ db: AsyncSession = Depends(get_db),
+ _=Depends(get_current_user),
+):
+ """合格率、平均评分、班次分组统计"""
+ total_q = await db.execute(select(func.count()).select_from(QualityRecord))
+ passed_q = await db.execute(
+ select(func.count()).select_from(QualityRecord).where(QualityRecord.is_passed == True)
+ )
+ avg_pi_q = await db.execute(select(func.avg(QualityRecord.pi_score)).select_from(QualityRecord))
+ avg_suf_q = await db.execute(select(func.avg(QualityRecord.surface_score)).select_from(QualityRecord))
+
+ # Grade distribution
+ grade_counts: dict = {}
+ for grade in ["A1", "A2", "B1", "B2", "C"]:
+ cnt_q = await db.execute(
+ select(func.count()).select_from(QualityRecord).where(QualityRecord.overall_grade == grade)
+ )
+ grade_counts[grade] = cnt_q.scalar() or 0
+
+ total = total_q.scalar() or 0
+ passed = passed_q.scalar() or 0
+ pass_rate = round(passed / total * 100, 1) if total > 0 else 0.0
+
+ return Response.ok({
+ "total": total,
+ "passed": passed,
+ "pass_rate": pass_rate,
+ "avg_pi_score": round(avg_pi_q.scalar() or 0, 1),
+ "avg_surface_score": round(avg_suf_q.scalar() or 0, 1),
+ "grade_distribution": grade_counts,
+ })
+
+
+@router.get("/", response_model=Response[PageResponse[QualityRecordOut]])
+async def list_quality(
+ page: int = 1,
+ page_size: int = 20,
+ coil_no: Optional[str] = None,
+ overall_grade: Optional[str] = None,
+ start_date: Optional[datetime] = None,
+ end_date: Optional[datetime] = None,
+ db: AsyncSession = Depends(get_db),
+ _=Depends(get_current_user),
+):
+ query = select(QualityRecord).order_by(desc(QualityRecord.created_at))
+ if coil_no:
+ query = query.where(QualityRecord.coil_no.ilike(f"%{coil_no}%"))
+ if overall_grade:
+ query = query.where(QualityRecord.overall_grade == overall_grade)
+ _sd = _parse_dt(start_date)
+ if _sd:
+ query = query.where(QualityRecord.created_at >= _sd)
+ _ed = _parse_dt(end_date)
+ if _ed:
+ query = query.where(QualityRecord.created_at <= _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 = [QualityRecordOut.model_validate(r) for r in result.scalars()]
+ return Response.ok(PageResponse(total=total, page=page, page_size=page_size, items=items))
+
+
+@router.post("/", response_model=Response[QualityRecordOut])
+async def create_quality(
+ body: QualityRecordCreate,
+ db: AsyncSession = Depends(get_db),
+ _=Depends(get_current_user),
+):
+ """
+ 创建质量记录。若未提供 pi_score / surface_score / overall_grade,
+ 则尝试用 QualityPredictionModel 自动填充(需要thickness_actual和相关参数)。
+ """
+ data = body.model_dump()
+
+ if (
+ data.get("pi_score") is None
+ and data.get("thickness_actual") is not None
+ ):
+ # Use default avg values for auto-prediction when detailed params are absent
+ avg_speed = 100.0 # m/min default
+ acid_conc_avg = 160.0 # g/L default
+ acid_temp_avg = 75.0 # °C default
+ try:
+ pred = QualityPredictionModel(
+ thickness=data["thickness_actual"],
+ avg_speed=avg_speed,
+ acid_conc_avg=acid_conc_avg,
+ acid_temp_avg=acid_temp_avg,
+ ).calculate()
+ data["pi_score"] = pred["pi_score"]
+ data["surface_score"] = pred["surface_score"]
+ data["overall_grade"] = pred["overall_grade"]
+ except Exception:
+ pass # best-effort, do not block creation
+
+ record = QualityRecord(**data)
+ db.add(record)
+ await db.flush()
+ return Response.ok(QualityRecordOut.model_validate(record))
+
+
+@router.put("/{quality_id}", response_model=Response[QualityRecordOut])
+async def update_quality(
+ quality_id: int,
+ body: QualityRecordUpdate,
+ db: AsyncSession = Depends(get_db),
+ _=Depends(get_current_user),
+):
+ result = await db.execute(select(QualityRecord).where(QualityRecord.id == quality_id))
+ record = result.scalar_one_or_none()
+ if not record:
+ raise HTTPException(status_code=404, detail="质量记录不存在")
+ for k, v in body.model_dump(exclude_none=True).items():
+ setattr(record, k, v)
+ await db.flush()
+ return Response.ok(QualityRecordOut.model_validate(record))
diff --git a/backend/app/config.py b/backend/app/config.py
new file mode 100644
index 0000000..dc648b9
--- /dev/null
+++ b/backend/app/config.py
@@ -0,0 +1,29 @@
+from pydantic_settings import BaseSettings
+from typing import List
+import json
+
+
+class Settings(BaseSettings):
+ DATABASE_URL: str = "postgresql+asyncpg://postgres:password@localhost:5432/pickling_mes"
+ DATABASE_SYNC_URL: str = "postgresql://postgres:password@localhost:5432/pickling_mes"
+ REDIS_URL: str = "redis://localhost:6379/0"
+
+ SECRET_KEY: str = "dev-secret-key"
+ ALGORITHM: str = "HS256"
+ ACCESS_TOKEN_EXPIRE_MINUTES: int = 480
+
+ APP_HOST: str = "0.0.0.0"
+ APP_PORT: int = 8000
+ DEBUG: bool = True
+
+ L1_HOST: str = "0.0.0.0"
+ L1_PORT: int = 9000
+
+ CORS_ORIGINS: List[str] = ["http://localhost:8080", "http://127.0.0.1:8080"]
+
+ class Config:
+ env_file = ".env"
+ env_file_encoding = "utf-8"
+
+
+settings = Settings()
diff --git a/backend/app/database.py b/backend/app/database.py
new file mode 100644
index 0000000..14e2dd9
--- /dev/null
+++ b/backend/app/database.py
@@ -0,0 +1,33 @@
+from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
+from sqlalchemy.orm import DeclarativeBase
+from app.config import settings
+
+engine = create_async_engine(
+ settings.DATABASE_URL,
+ echo=settings.DEBUG,
+ pool_size=10,
+ max_overflow=20,
+)
+
+AsyncSessionLocal = async_sessionmaker(
+ engine, class_=AsyncSession, expire_on_commit=False
+)
+
+
+class Base(DeclarativeBase):
+ pass
+
+
+async def get_db():
+ async with AsyncSessionLocal() as session:
+ try:
+ yield session
+ await session.commit()
+ except Exception:
+ await session.rollback()
+ raise
+
+
+async def init_db():
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.create_all)
diff --git a/backend/app/main.py b/backend/app/main.py
new file mode 100644
index 0000000..e3b4c73
--- /dev/null
+++ b/backend/app/main.py
@@ -0,0 +1,76 @@
+import asyncio
+from contextlib import asynccontextmanager
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+from loguru import logger
+
+from app.config import settings
+from app.database import init_db
+from app.api import router
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ logger.info("启动推拉酸洗线二级系统...")
+ await init_db()
+ await _create_default_admin()
+
+ # 启动L1报文接收服务(UDP)
+ from app.services.message_parser import l1_server
+ import app.services.material_service # noqa: 注册报文处理器
+ await l1_server.start()
+
+ logger.info("系统启动完成")
+ yield
+
+ l1_server.stop()
+ logger.info("系统已停止")
+
+
+async def _create_default_admin():
+ """首次启动时创建默认管理员"""
+ from app.database import AsyncSessionLocal
+ from sqlalchemy import select
+ from app.models.user import User
+ from app.services.auth_service import hash_password
+
+ async with AsyncSessionLocal() as db:
+ result = await db.execute(select(User).where(User.username == "admin"))
+ if not result.scalar_one_or_none():
+ admin = User(
+ username="admin",
+ full_name="系统管理员",
+ hashed_password=hash_password("admin123"),
+ role="admin",
+ )
+ db.add(admin)
+ await db.commit()
+ logger.info("默认管理员已创建: admin / admin123")
+
+
+app = FastAPI(
+ title="推拉酸洗线二级系统",
+ description="Pickling Line Level-2 MES System",
+ version="1.0.0",
+ lifespan=lifespan,
+)
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=settings.CORS_ORIGINS,
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+app.include_router(router, prefix="/api")
+
+
+@app.get("/health")
+async def health():
+ return {"status": "ok", "service": "pickling-mes"}
+
+
+if __name__ == "__main__":
+ import uvicorn
+ uvicorn.run("app.main:app", host=settings.APP_HOST, port=settings.APP_PORT, reload=settings.DEBUG)
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
new file mode 100644
index 0000000..504fa3c
--- /dev/null
+++ b/backend/app/models/__init__.py
@@ -0,0 +1,25 @@
+from app.models.user import User
+from app.models.material import Coil, MaterialTracking
+from app.models.production import ProductionRecord
+from app.models.plan import ProductionPlan
+from app.models.downtime import DowntimeRecord, DowntimeCategory
+from app.models.equipment import Equipment, EquipmentMaintenance
+from app.models.message import MessageLog
+from app.models.pdi import PDIRecord
+from app.models.quality import QualityRecord
+from app.models.energy import EnergyRecord
+from app.models.inspection import InspectionLocation, InspectionRecord
+
+__all__ = [
+ "User",
+ "Coil", "MaterialTracking",
+ "ProductionRecord",
+ "ProductionPlan",
+ "DowntimeRecord", "DowntimeCategory",
+ "Equipment", "EquipmentMaintenance",
+ "MessageLog",
+ "PDIRecord",
+ "QualityRecord",
+ "EnergyRecord",
+ "InspectionLocation", "InspectionRecord",
+]
diff --git a/backend/app/models/downtime.py b/backend/app/models/downtime.py
new file mode 100644
index 0000000..6c2ab71
--- /dev/null
+++ b/backend/app/models/downtime.py
@@ -0,0 +1,38 @@
+from sqlalchemy import Column, Integer, String, Float, DateTime, Text, ForeignKey, func
+from app.database import Base
+
+
+class DowntimeCategory(Base):
+ """停机类别"""
+ __tablename__ = "downtime_categories"
+
+ id = Column(Integer, primary_key=True, index=True)
+ code = Column(String(20), unique=True, nullable=False)
+ name = Column(String(50), nullable=False)
+ category = Column(String(20), comment="大类: equipment/process/material/other")
+ description = Column(Text)
+ is_active = Column(Integer, default=1)
+
+
+class DowntimeRecord(Base):
+ """停机记录"""
+ __tablename__ = "downtime_records"
+
+ id = Column(Integer, primary_key=True, index=True)
+ category_id = Column(Integer, ForeignKey("downtime_categories.id"))
+ category_code = Column(String(20), index=True)
+ category_name = Column(String(50))
+ shift = Column(String(10), comment="班次")
+ shift_date = Column(DateTime, comment="班期")
+ start_time = Column(DateTime, nullable=False, comment="停机开始时间")
+ end_time = Column(DateTime, comment="停机结束时间")
+ duration = Column(Float, comment="停机时长min(自动计算)")
+ equipment_code = Column(String(30), comment="停机设备编号")
+ fault_desc = Column(Text, comment="故障描述")
+ action_taken = Column(Text, comment="处理措施")
+ root_cause = Column(Text, comment="根本原因")
+ reporter = Column(String(50), comment="报告人")
+ handler = Column(String(50), comment="处理人")
+ is_planned = Column(Integer, default=0, comment="是否计划停机")
+ created_at = Column(DateTime, server_default=func.now())
+ updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
diff --git a/backend/app/models/energy.py b/backend/app/models/energy.py
new file mode 100644
index 0000000..a8b4146
--- /dev/null
+++ b/backend/app/models/energy.py
@@ -0,0 +1,36 @@
+from sqlalchemy import Column, Integer, String, Float, DateTime, Text, func
+from app.database import Base
+
+
+class EnergyRecord(Base):
+ """班次能耗记录"""
+ __tablename__ = "energy_records"
+
+ id = Column(Integer, primary_key=True, index=True)
+ shift = Column(String(10), nullable=True, comment="班次 甲/乙/丙/丁")
+ shift_date = Column(DateTime, nullable=True, comment="班期")
+
+ # 产量
+ production_weight_kg = Column(Float, nullable=True, comment="本班产量 kg")
+
+ # 电力
+ power_consumption_kwh = Column(Float, nullable=True, comment="电耗 kWh")
+ power_unit = Column(Float, nullable=True, comment="电耗单耗 kWh/t")
+
+ # 蒸汽
+ steam_consumption_kg = Column(Float, nullable=True, comment="蒸汽消耗 kg")
+ steam_unit = Column(Float, nullable=True, comment="蒸汽单耗 kg/t")
+
+ # 盐酸
+ acid_consumption_kg = Column(Float, nullable=True, comment="盐酸消耗 kg")
+ acid_unit = Column(Float, nullable=True, comment="酸耗单耗 kg/t")
+
+ # 冷却水
+ cooling_water_m3 = Column(Float, nullable=True, comment="冷却水 m³")
+ water_unit = Column(Float, nullable=True, comment="冷却水单耗 m³/t")
+
+ # 各槽HCl平均浓度 (JSON字符串, e.g. '[180,175,170,165,160,155]')
+ acid_conc_avg = Column(Text, nullable=True, comment="各槽HCl平均浓度 JSON g/L")
+
+ created_at = Column(DateTime, server_default=func.now())
+ updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
diff --git a/backend/app/models/equipment.py b/backend/app/models/equipment.py
new file mode 100644
index 0000000..97eb64e
--- /dev/null
+++ b/backend/app/models/equipment.py
@@ -0,0 +1,53 @@
+from sqlalchemy import Column, Integer, String, Float, DateTime, Enum, Text, ForeignKey, func
+from app.database import Base
+import enum
+
+
+class EquipmentStatus(str, enum.Enum):
+ NORMAL = "normal" # 正常
+ FAULT = "fault" # 故障
+ MAINTENANCE = "maintenance" # 检修
+ STANDBY = "standby" # 备用
+
+
+class Equipment(Base):
+ """设备台账"""
+ __tablename__ = "equipments"
+
+ id = Column(Integer, primary_key=True, index=True)
+ code = Column(String(30), unique=True, nullable=False, index=True, comment="设备编号")
+ name = Column(String(100), nullable=False, comment="设备名称")
+ category = Column(String(30), comment="设备类别")
+ model = Column(String(50), comment="型号规格")
+ manufacturer = Column(String(100), comment="制造厂商")
+ install_date = Column(DateTime, comment="投用日期")
+ location = Column(String(50), comment="安装位置")
+ status = Column(Enum(EquipmentStatus), default=EquipmentStatus.NORMAL)
+ rated_power = Column(Float, comment="额定功率kW")
+ remark = Column(Text)
+ created_at = Column(DateTime, server_default=func.now())
+ updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
+
+
+class EquipmentMaintenance(Base):
+ """设备维保记录"""
+ __tablename__ = "equipment_maintenance"
+
+ id = Column(Integer, primary_key=True, index=True)
+ equipment_id = Column(Integer, ForeignKey("equipments.id"), nullable=False)
+ equipment_code = Column(String(30), index=True)
+ maintenance_type = Column(String(20), comment="类型: repair/planned/inspection")
+ title = Column(String(200), nullable=False)
+ description = Column(Text, comment="维保内容")
+ start_time = Column(DateTime, nullable=False)
+ end_time = Column(DateTime)
+ duration = Column(Float, comment="工时h")
+ cost = Column(Float, comment="费用元")
+ spare_parts = Column(Text, comment="更换备件(JSON格式)")
+ technician = Column(String(50), comment="执行人")
+ approver = Column(String(50), comment="审核人")
+ next_maintenance = Column(DateTime, comment="下次维保时间")
+ result = Column(String(20), comment="结果: pass/fail/pending")
+ remark = Column(Text)
+ created_at = Column(DateTime, server_default=func.now())
+ updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
diff --git a/backend/app/models/inspection.py b/backend/app/models/inspection.py
new file mode 100644
index 0000000..5f59f0f
--- /dev/null
+++ b/backend/app/models/inspection.py
@@ -0,0 +1,28 @@
+from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, func
+from app.database import Base
+
+
+class InspectionLocation(Base):
+ __tablename__ = "inspection_locations"
+
+ id = Column(Integer, primary_key=True, index=True)
+ code = Column(String(30), unique=True, nullable=False, index=True)
+ name = Column(String(100), nullable=False)
+ description = Column(Text)
+ sort_order = Column(Integer, default=0)
+ created_at = Column(DateTime, server_default=func.now())
+
+
+class InspectionRecord(Base):
+ __tablename__ = "inspection_records"
+
+ id = Column(Integer, primary_key=True, index=True)
+ location_id = Column(Integer, ForeignKey("inspection_locations.id"), nullable=False)
+ location_name = Column(String(100))
+ equipment_code = Column(String(30), index=True)
+ equipment_name = Column(String(100))
+ scan_code = Column(String(200))
+ inspector = Column(String(50), nullable=False)
+ result = Column(String(20), default="normal")
+ notes = Column(Text)
+ created_at = Column(DateTime, server_default=func.now())
diff --git a/backend/app/models/material.py b/backend/app/models/material.py
new file mode 100644
index 0000000..3d7605e
--- /dev/null
+++ b/backend/app/models/material.py
@@ -0,0 +1,56 @@
+from sqlalchemy import Column, Integer, String, Float, DateTime, Enum, Text, ForeignKey, func
+from sqlalchemy.orm import relationship
+from app.database import Base
+import enum
+
+
+class CoilStatus(str, enum.Enum):
+ WAITING = "waiting" # 等待入线
+ ON_LINE = "on_line" # 在线处理
+ FINISHED = "finished" # 处理完成
+ ABNORMAL = "abnormal" # 异常
+
+
+class Coil(Base):
+ """钢卷主档"""
+ __tablename__ = "coils"
+
+ id = Column(Integer, primary_key=True, index=True)
+ coil_no = Column(String(30), unique=True, nullable=False, index=True, comment="卷号")
+ order_no = Column(String(30), index=True, comment="订单号")
+ steel_grade = Column(String(30), comment="钢种")
+ spec_thickness = Column(Float, comment="规格厚度mm")
+ spec_width = Column(Float, comment="规格宽度mm")
+ target_thickness = Column(Float, comment="目标厚度mm")
+ target_width = Column(Float, comment="目标宽度mm")
+ gross_weight = Column(Float, comment="毛重kg")
+ net_weight = Column(Float, comment="净重kg")
+ inner_diameter = Column(Float, comment="内径mm")
+ status = Column(Enum(CoilStatus), default=CoilStatus.WAITING)
+ plan_id = Column(Integer, ForeignKey("production_plans.id"), nullable=True)
+ remark = Column(Text)
+ created_at = Column(DateTime, server_default=func.now())
+ updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
+
+ tracking = relationship("MaterialTracking", back_populates="coil")
+
+
+class MaterialTracking(Base):
+ """物料跟踪记录"""
+ __tablename__ = "material_tracking"
+
+ id = Column(Integer, primary_key=True, index=True)
+ coil_id = Column(Integer, ForeignKey("coils.id"), nullable=False, index=True)
+ coil_no = Column(String(30), nullable=False, index=True)
+ position = Column(String(50), comment="当前位置/工位")
+ event_type = Column(String(30), comment="事件类型: entry/exit/process/inspect")
+ event_desc = Column(String(200), comment="事件描述")
+ actual_thickness = Column(Float, comment="实测厚度")
+ actual_width = Column(Float, comment="实测宽度")
+ speed = Column(Float, comment="线速度m/min")
+ tension = Column(Float, comment="张力N")
+ operator = Column(String(50))
+ event_time = Column(DateTime, nullable=False, index=True)
+ created_at = Column(DateTime, server_default=func.now())
+
+ coil = relationship("Coil", back_populates="tracking")
diff --git a/backend/app/models/message.py b/backend/app/models/message.py
new file mode 100644
index 0000000..5c6e985
--- /dev/null
+++ b/backend/app/models/message.py
@@ -0,0 +1,20 @@
+from sqlalchemy import Column, Integer, String, DateTime, Text, Float, func
+from app.database import Base
+
+
+class MessageLog(Base):
+ """L1报文日志"""
+ __tablename__ = "message_logs"
+
+ id = Column(Integer, primary_key=True, index=True)
+ msg_id = Column(String(50), index=True, comment="报文ID")
+ msg_type = Column(String(30), index=True, comment="报文类型")
+ direction = Column(String(10), comment="方向: recv/send")
+ source = Column(String(50), comment="来源系统")
+ raw_data = Column(Text, comment="原始报文")
+ parsed_data = Column(Text, comment="解析结果JSON")
+ status = Column(String(20), default="success", comment="处理状态: success/error")
+ error_msg = Column(Text, comment="错误信息")
+ process_time = Column(Float, comment="处理耗时ms")
+ received_at = Column(DateTime, nullable=False, index=True)
+ created_at = Column(DateTime, server_default=func.now())
diff --git a/backend/app/models/pdi.py b/backend/app/models/pdi.py
new file mode 100644
index 0000000..b8ef156
--- /dev/null
+++ b/backend/app/models/pdi.py
@@ -0,0 +1,59 @@
+import enum
+from sqlalchemy import Column, Integer, String, Float, DateTime, Text, Enum, func
+from app.database import Base
+
+
+class L3Status(str, enum.Enum):
+ pending = "pending"
+ sent = "sent"
+ confirmed = "confirmed"
+ cancelled = "cancelled"
+
+
+class L2Status(str, enum.Enum):
+ pending = "pending"
+ processing = "processing"
+ done = "done"
+
+
+class PDIRecord(Base):
+ """PDI (Process Data Input) – per-coil process order from L3."""
+ __tablename__ = "pdi_records"
+
+ id = Column(Integer, primary_key=True, index=True)
+ coil_no = Column(String(30), nullable=False, index=True, comment="卷号")
+ order_no = Column(String(30), nullable=True, comment="订单号")
+ customer = Column(String(100), nullable=True, comment="客户名称")
+ steel_grade = Column(String(30), nullable=True, comment="钢种")
+
+ # 几何规格
+ thickness = Column(Float, nullable=True, comment="来料厚度 mm")
+ width = Column(Float, nullable=True, comment="来料宽度 mm")
+ target_thickness = Column(Float, nullable=True, comment="目标厚度 mm")
+ target_width = Column(Float, nullable=True, comment="目标宽度 mm")
+
+ # 力学性能
+ yield_strength = Column(Float, nullable=True, comment="屈服强度 MPa")
+ tensile_strength = Column(Float, nullable=True, comment="抗拉强度 MPa")
+ elongation = Column(Float, nullable=True, comment="延伸率 %")
+
+ # 卷重/尺寸
+ coil_weight = Column(Float, nullable=True, comment="卷重 kg")
+ inner_diameter = Column(Float, nullable=True, comment="内径 mm")
+ outer_diameter = Column(Float, nullable=True, comment="外径 mm")
+
+ # 工艺
+ process_route = Column(String(100), nullable=True, comment="工艺路径 e.g. P1+P2+P3+P4+P5+P6")
+ priority = Column(Integer, default=3, comment="优先级 1-5")
+
+ # 状态
+ l3_status = Column(Enum(L3Status), default=L3Status.pending, comment="L3状态")
+ l2_status = Column(Enum(L2Status), default=L2Status.pending, comment="L2状态")
+
+ # 时间戳
+ send_time = Column(DateTime, nullable=True, comment="下发时间")
+ confirm_time = Column(DateTime, nullable=True, comment="确认时间")
+
+ remark = Column(Text, nullable=True, comment="备注")
+ created_at = Column(DateTime, server_default=func.now())
+ updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
diff --git a/backend/app/models/plan.py b/backend/app/models/plan.py
new file mode 100644
index 0000000..5975a1d
--- /dev/null
+++ b/backend/app/models/plan.py
@@ -0,0 +1,33 @@
+from sqlalchemy import Column, Integer, String, Float, DateTime, Enum, Text, func
+from app.database import Base
+import enum
+
+
+class PlanStatus(str, enum.Enum):
+ DRAFT = "draft" # 草稿
+ CONFIRMED = "confirmed" # 已确认
+ IN_PROGRESS = "in_progress" # 执行中
+ COMPLETED = "completed" # 完成
+ CANCELLED = "cancelled" # 取消
+
+
+class ProductionPlan(Base):
+ """生产计划"""
+ __tablename__ = "production_plans"
+
+ id = Column(Integer, primary_key=True, index=True)
+ plan_no = Column(String(30), unique=True, nullable=False, index=True, comment="计划号")
+ plan_date = Column(DateTime, nullable=False, comment="计划日期")
+ shift = Column(String(10), comment="班次")
+ plan_quantity = Column(Integer, default=0, comment="计划数量(卷)")
+ plan_weight = Column(Float, default=0, comment="计划重量kg")
+ actual_quantity = Column(Integer, default=0, comment="实际数量(卷)")
+ actual_weight = Column(Float, default=0, comment="实际重量kg")
+ status = Column(Enum(PlanStatus), default=PlanStatus.DRAFT)
+ steel_grade = Column(String(30), comment="主要钢种")
+ spec_range = Column(String(50), comment="规格范围")
+ priority = Column(Integer, default=5, comment="优先级1-10")
+ remark = Column(Text)
+ created_by = Column(String(50))
+ created_at = Column(DateTime, server_default=func.now())
+ updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
diff --git a/backend/app/models/production.py b/backend/app/models/production.py
new file mode 100644
index 0000000..686a556
--- /dev/null
+++ b/backend/app/models/production.py
@@ -0,0 +1,28 @@
+from sqlalchemy import Column, Integer, String, Float, DateTime, Text, ForeignKey, func
+from app.database import Base
+
+
+class ProductionRecord(Base):
+ """生产实绩"""
+ __tablename__ = "production_records"
+
+ id = Column(Integer, primary_key=True, index=True)
+ coil_no = Column(String(30), nullable=False, index=True)
+ plan_id = Column(Integer, ForeignKey("production_plans.id"), nullable=True)
+ shift = Column(String(10), comment="班次: 甲/乙/丙/丁")
+ shift_date = Column(DateTime, comment="班期")
+ start_time = Column(DateTime, comment="开始时间")
+ end_time = Column(DateTime, comment="结束时间")
+ process_length = Column(Float, comment="处理长度m")
+ process_weight = Column(Float, comment="处理重量kg")
+ avg_speed = Column(Float, comment="平均速度m/min")
+ max_speed = Column(Float, comment="最大速度m/min")
+ acid_consumption = Column(Float, comment="酸耗量L")
+ inlet_thickness = Column(Float, comment="入口厚度mm")
+ outlet_thickness = Column(Float, comment="出口厚度mm")
+ inlet_width = Column(Float, comment="入口宽度mm")
+ quality_grade = Column(String(10), comment="质量等级")
+ operator = Column(String(50))
+ remark = Column(Text)
+ created_at = Column(DateTime, server_default=func.now())
+ updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
diff --git a/backend/app/models/quality.py b/backend/app/models/quality.py
new file mode 100644
index 0000000..56aeeda
--- /dev/null
+++ b/backend/app/models/quality.py
@@ -0,0 +1,38 @@
+from sqlalchemy import Column, Integer, String, Float, DateTime, Text, Boolean, ForeignKey, func
+from app.database import Base
+
+
+class QualityRecord(Base):
+ """质量检验记录"""
+ __tablename__ = "quality_records"
+
+ id = Column(Integer, primary_key=True, index=True)
+ coil_no = Column(String(30), nullable=False, index=True, comment="卷号")
+ production_record_id = Column(Integer, ForeignKey("production_records.id"), nullable=True, comment="关联生产实绩")
+
+ # 实测规格
+ thickness_actual = Column(Float, nullable=True, comment="实测厚度 mm")
+ width_actual = Column(Float, nullable=True, comment="实测宽度 mm")
+ flatness = Column(Float, nullable=True, comment="平直度 IU")
+ crown = Column(Float, nullable=True, comment="凸度 μm")
+
+ # 表面缺陷
+ surface_defect_type = Column(String(50), nullable=True, comment="表面缺陷类型")
+ defect_length_m = Column(Float, nullable=True, comment="缺陷长度 m")
+ defect_position = Column(String(50), nullable=True, comment="缺陷位置")
+
+ # 质量模型评分
+ pi_score = Column(Float, nullable=True, comment="酸洗指数评分 0-100")
+ surface_score = Column(Float, nullable=True, comment="表面质量评分 0-100")
+ overall_grade = Column(String(5), nullable=True, comment="综合等级 A1/A2/B1/B2/C")
+
+ # 残酸 / 粗糙度
+ acid_residual = Column(Float, nullable=True, comment="残酸量 g/m²")
+ roughness_ra = Column(Float, nullable=True, comment="粗糙度 Ra μm")
+
+ # 检验信息
+ inspector = Column(String(50), nullable=True, comment="检验员")
+ inspect_time = Column(DateTime, nullable=True, comment="检验时间")
+ is_passed = Column(Boolean, default=True, comment="是否合格")
+
+ created_at = Column(DateTime, server_default=func.now())
diff --git a/backend/app/models/user.py b/backend/app/models/user.py
new file mode 100644
index 0000000..3f7b359
--- /dev/null
+++ b/backend/app/models/user.py
@@ -0,0 +1,15 @@
+from sqlalchemy import Column, Integer, String, Boolean, DateTime, func
+from app.database import Base
+
+
+class User(Base):
+ __tablename__ = "users"
+
+ id = Column(Integer, primary_key=True, index=True)
+ username = Column(String(50), unique=True, nullable=False, index=True)
+ full_name = Column(String(100))
+ hashed_password = Column(String(255), nullable=False)
+ role = Column(String(20), default="operator") # admin/engineer/operator/viewer
+ is_active = Column(Boolean, default=True)
+ created_at = Column(DateTime, server_default=func.now())
+ updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py
new file mode 100644
index 0000000..cab15a1
--- /dev/null
+++ b/backend/app/schemas/__init__.py
@@ -0,0 +1,7 @@
+from app.schemas.common import Response, PageResponse, PageParams
+from app.schemas.user import UserCreate, UserUpdate, UserOut, Token, LoginRequest
+from app.schemas.material import CoilCreate, CoilUpdate, CoilOut, TrackingCreate, TrackingOut
+from app.schemas.production import ProductionRecordCreate, ProductionRecordUpdate, ProductionRecordOut
+from app.schemas.plan import PlanCreate, PlanUpdate, PlanOut
+from app.schemas.downtime import DowntimeCreate, DowntimeUpdate, DowntimeOut, CategoryCreate, CategoryOut
+from app.schemas.equipment import EquipmentCreate, EquipmentUpdate, EquipmentOut, MaintenanceCreate, MaintenanceOut
diff --git a/backend/app/schemas/common.py b/backend/app/schemas/common.py
new file mode 100644
index 0000000..6db08aa
--- /dev/null
+++ b/backend/app/schemas/common.py
@@ -0,0 +1,30 @@
+from pydantic import BaseModel
+from typing import Generic, TypeVar, Optional, List
+
+T = TypeVar("T")
+
+
+class Response(BaseModel, Generic[T]):
+ code: int = 200
+ msg: str = "success"
+ data: Optional[T] = None
+
+ @classmethod
+ def ok(cls, data=None, msg="success"):
+ return cls(code=200, msg=msg, data=data)
+
+ @classmethod
+ def error(cls, msg="error", code=500):
+ return cls(code=code, msg=msg, data=None)
+
+
+class PageParams(BaseModel):
+ page: int = 1
+ page_size: int = 20
+
+
+class PageResponse(BaseModel, Generic[T]):
+ total: int
+ page: int
+ page_size: int
+ items: List[T]
diff --git a/backend/app/schemas/downtime.py b/backend/app/schemas/downtime.py
new file mode 100644
index 0000000..e906d42
--- /dev/null
+++ b/backend/app/schemas/downtime.py
@@ -0,0 +1,67 @@
+from pydantic import BaseModel
+from typing import Optional
+from datetime import datetime
+
+
+class CategoryCreate(BaseModel):
+ code: str
+ name: str
+ category: str
+ description: Optional[str] = None
+
+
+class CategoryOut(BaseModel):
+ id: int
+ code: str
+ name: str
+ category: str
+ description: Optional[str]
+ is_active: int
+
+ class Config:
+ from_attributes = True
+
+
+class DowntimeCreate(BaseModel):
+ category_id: Optional[int] = None
+ category_code: Optional[str] = None
+ shift: Optional[str] = None
+ shift_date: Optional[datetime] = None
+ start_time: datetime
+ end_time: Optional[datetime] = None
+ equipment_code: Optional[str] = None
+ fault_desc: Optional[str] = None
+ action_taken: Optional[str] = None
+ root_cause: Optional[str] = None
+ reporter: Optional[str] = None
+ handler: Optional[str] = None
+ is_planned: int = 0
+
+
+class DowntimeUpdate(BaseModel):
+ end_time: Optional[datetime] = None
+ action_taken: Optional[str] = None
+ root_cause: Optional[str] = None
+ handler: Optional[str] = None
+ fault_desc: Optional[str] = None
+
+
+class DowntimeOut(BaseModel):
+ id: int
+ category_code: Optional[str]
+ category_name: Optional[str]
+ shift: Optional[str]
+ shift_date: Optional[datetime]
+ start_time: datetime
+ end_time: Optional[datetime]
+ duration: Optional[float]
+ equipment_code: Optional[str]
+ fault_desc: Optional[str]
+ action_taken: Optional[str]
+ reporter: Optional[str]
+ handler: Optional[str]
+ is_planned: int
+ created_at: datetime
+
+ class Config:
+ from_attributes = True
diff --git a/backend/app/schemas/equipment.py b/backend/app/schemas/equipment.py
new file mode 100644
index 0000000..d57bdc0
--- /dev/null
+++ b/backend/app/schemas/equipment.py
@@ -0,0 +1,79 @@
+from pydantic import BaseModel
+from typing import Optional
+from datetime import datetime
+from app.models.equipment import EquipmentStatus
+
+
+class EquipmentCreate(BaseModel):
+ code: str
+ name: str
+ category: Optional[str] = None
+ model: Optional[str] = None
+ manufacturer: Optional[str] = None
+ install_date: Optional[datetime] = None
+ location: Optional[str] = None
+ rated_power: Optional[float] = None
+ remark: Optional[str] = None
+
+
+class EquipmentUpdate(BaseModel):
+ name: Optional[str] = None
+ category: Optional[str] = None
+ model: Optional[str] = None
+ location: Optional[str] = None
+ status: Optional[EquipmentStatus] = None
+ remark: Optional[str] = None
+
+
+class EquipmentOut(BaseModel):
+ id: int
+ code: str
+ name: str
+ category: Optional[str]
+ model: Optional[str]
+ manufacturer: Optional[str]
+ install_date: Optional[datetime]
+ location: Optional[str]
+ status: EquipmentStatus
+ rated_power: Optional[float]
+ created_at: datetime
+
+ class Config:
+ from_attributes = True
+
+
+class MaintenanceCreate(BaseModel):
+ equipment_id: int
+ equipment_code: Optional[str] = None
+ maintenance_type: str
+ title: str
+ description: Optional[str] = None
+ start_time: datetime
+ end_time: Optional[datetime] = None
+ duration: Optional[float] = None
+ cost: Optional[float] = None
+ spare_parts: Optional[str] = None
+ technician: Optional[str] = None
+ approver: Optional[str] = None
+ next_maintenance: Optional[datetime] = None
+ result: Optional[str] = None
+ remark: Optional[str] = None
+
+
+class MaintenanceOut(BaseModel):
+ id: int
+ equipment_id: int
+ equipment_code: Optional[str]
+ maintenance_type: str
+ title: str
+ description: Optional[str]
+ start_time: datetime
+ end_time: Optional[datetime]
+ duration: Optional[float]
+ cost: Optional[float]
+ technician: Optional[str]
+ result: Optional[str]
+ created_at: datetime
+
+ class Config:
+ from_attributes = True
diff --git a/backend/app/schemas/inspection.py b/backend/app/schemas/inspection.py
new file mode 100644
index 0000000..815d250
--- /dev/null
+++ b/backend/app/schemas/inspection.py
@@ -0,0 +1,37 @@
+from pydantic import BaseModel
+from typing import Optional
+from datetime import datetime
+
+
+class InspectionLocationCreate(BaseModel):
+ code: str
+ name: str
+ description: Optional[str] = None
+ sort_order: int = 0
+
+
+class InspectionLocationOut(InspectionLocationCreate):
+ id: int
+ created_at: datetime
+
+ class Config:
+ from_attributes = True
+
+
+class InspectionRecordCreate(BaseModel):
+ location_id: int
+ equipment_code: Optional[str] = None
+ equipment_name: Optional[str] = None
+ scan_code: Optional[str] = None
+ inspector: str
+ result: str = "normal"
+ notes: Optional[str] = None
+
+
+class InspectionRecordOut(InspectionRecordCreate):
+ id: int
+ location_name: Optional[str] = None
+ created_at: datetime
+
+ class Config:
+ from_attributes = True
diff --git a/backend/app/schemas/material.py b/backend/app/schemas/material.py
new file mode 100644
index 0000000..824b89a
--- /dev/null
+++ b/backend/app/schemas/material.py
@@ -0,0 +1,82 @@
+from pydantic import BaseModel
+from typing import Optional
+from datetime import datetime
+from app.models.material import CoilStatus
+
+
+class CoilCreate(BaseModel):
+ coil_no: str
+ order_no: Optional[str] = None
+ steel_grade: Optional[str] = None
+ spec_thickness: Optional[float] = None
+ spec_width: Optional[float] = None
+ target_thickness: Optional[float] = None
+ target_width: Optional[float] = None
+ gross_weight: Optional[float] = None
+ net_weight: Optional[float] = None
+ inner_diameter: Optional[float] = None
+ plan_id: Optional[int] = None
+ remark: Optional[str] = None
+
+
+class CoilUpdate(BaseModel):
+ order_no: Optional[str] = None
+ steel_grade: Optional[str] = None
+ spec_thickness: Optional[float] = None
+ spec_width: Optional[float] = None
+ target_thickness: Optional[float] = None
+ target_width: Optional[float] = None
+ gross_weight: Optional[float] = None
+ net_weight: Optional[float] = None
+ status: Optional[CoilStatus] = None
+ plan_id: Optional[int] = None
+ remark: Optional[str] = None
+
+
+class CoilOut(BaseModel):
+ id: int
+ coil_no: str
+ order_no: Optional[str]
+ steel_grade: Optional[str]
+ spec_thickness: Optional[float]
+ spec_width: Optional[float]
+ target_thickness: Optional[float]
+ target_width: Optional[float]
+ gross_weight: Optional[float]
+ net_weight: Optional[float]
+ status: CoilStatus
+ plan_id: Optional[int]
+ created_at: datetime
+
+ class Config:
+ from_attributes = True
+
+
+class TrackingCreate(BaseModel):
+ coil_no: str
+ position: Optional[str] = None
+ event_type: str
+ event_desc: Optional[str] = None
+ actual_thickness: Optional[float] = None
+ actual_width: Optional[float] = None
+ speed: Optional[float] = None
+ tension: Optional[float] = None
+ operator: Optional[str] = None
+ event_time: datetime
+
+
+class TrackingOut(BaseModel):
+ id: int
+ coil_id: int
+ coil_no: str
+ position: Optional[str]
+ event_type: str
+ event_desc: Optional[str]
+ actual_thickness: Optional[float]
+ actual_width: Optional[float]
+ speed: Optional[float]
+ operator: Optional[str]
+ event_time: datetime
+
+ class Config:
+ from_attributes = True
diff --git a/backend/app/schemas/pdi.py b/backend/app/schemas/pdi.py
new file mode 100644
index 0000000..91c33fe
--- /dev/null
+++ b/backend/app/schemas/pdi.py
@@ -0,0 +1,81 @@
+from pydantic import BaseModel
+from typing import Optional
+from datetime import datetime
+from app.models.pdi import L3Status, L2Status
+
+
+class PDIRecordCreate(BaseModel):
+ coil_no: str
+ order_no: Optional[str] = None
+ customer: Optional[str] = None
+ steel_grade: Optional[str] = None
+ thickness: Optional[float] = None
+ width: Optional[float] = None
+ target_thickness: Optional[float] = None
+ target_width: Optional[float] = None
+ yield_strength: Optional[float] = None
+ tensile_strength: Optional[float] = None
+ elongation: Optional[float] = None
+ coil_weight: Optional[float] = None
+ inner_diameter: Optional[float] = None
+ outer_diameter: Optional[float] = None
+ process_route: Optional[str] = None
+ priority: Optional[int] = 3
+ l3_status: Optional[L3Status] = L3Status.pending
+ l2_status: Optional[L2Status] = L2Status.pending
+ send_time: Optional[datetime] = None
+ confirm_time: Optional[datetime] = None
+ remark: Optional[str] = None
+
+
+class PDIRecordUpdate(BaseModel):
+ order_no: Optional[str] = None
+ customer: Optional[str] = None
+ steel_grade: Optional[str] = None
+ thickness: Optional[float] = None
+ width: Optional[float] = None
+ target_thickness: Optional[float] = None
+ target_width: Optional[float] = None
+ yield_strength: Optional[float] = None
+ tensile_strength: Optional[float] = None
+ elongation: Optional[float] = None
+ coil_weight: Optional[float] = None
+ inner_diameter: Optional[float] = None
+ outer_diameter: Optional[float] = None
+ process_route: Optional[str] = None
+ priority: Optional[int] = None
+ l3_status: Optional[L3Status] = None
+ l2_status: Optional[L2Status] = None
+ send_time: Optional[datetime] = None
+ confirm_time: Optional[datetime] = None
+ remark: Optional[str] = None
+
+
+class PDIRecordOut(BaseModel):
+ id: int
+ coil_no: str
+ order_no: Optional[str]
+ customer: Optional[str]
+ steel_grade: Optional[str]
+ thickness: Optional[float]
+ width: Optional[float]
+ target_thickness: Optional[float]
+ target_width: Optional[float]
+ yield_strength: Optional[float]
+ tensile_strength: Optional[float]
+ elongation: Optional[float]
+ coil_weight: Optional[float]
+ inner_diameter: Optional[float]
+ outer_diameter: Optional[float]
+ process_route: Optional[str]
+ priority: Optional[int]
+ l3_status: Optional[L3Status]
+ l2_status: Optional[L2Status]
+ send_time: Optional[datetime]
+ confirm_time: Optional[datetime]
+ remark: Optional[str]
+ created_at: datetime
+ updated_at: Optional[datetime]
+
+ class Config:
+ from_attributes = True
diff --git a/backend/app/schemas/plan.py b/backend/app/schemas/plan.py
new file mode 100644
index 0000000..1cfced9
--- /dev/null
+++ b/backend/app/schemas/plan.py
@@ -0,0 +1,48 @@
+from pydantic import BaseModel
+from typing import Optional
+from datetime import datetime
+from app.models.plan import PlanStatus
+
+
+class PlanCreate(BaseModel):
+ plan_no: str
+ plan_date: datetime
+ shift: Optional[str] = None
+ plan_quantity: int = 0
+ plan_weight: float = 0
+ steel_grade: Optional[str] = None
+ spec_range: Optional[str] = None
+ priority: int = 5
+ remark: Optional[str] = None
+
+
+class PlanUpdate(BaseModel):
+ plan_date: Optional[datetime] = None
+ shift: Optional[str] = None
+ plan_quantity: Optional[int] = None
+ plan_weight: Optional[float] = None
+ actual_quantity: Optional[int] = None
+ actual_weight: Optional[float] = None
+ status: Optional[PlanStatus] = None
+ priority: Optional[int] = None
+ remark: Optional[str] = None
+
+
+class PlanOut(BaseModel):
+ id: int
+ plan_no: str
+ plan_date: datetime
+ shift: Optional[str]
+ plan_quantity: int
+ plan_weight: float
+ actual_quantity: int
+ actual_weight: float
+ status: PlanStatus
+ steel_grade: Optional[str]
+ spec_range: Optional[str]
+ priority: int
+ created_by: Optional[str]
+ created_at: datetime
+
+ class Config:
+ from_attributes = True
diff --git a/backend/app/schemas/production.py b/backend/app/schemas/production.py
new file mode 100644
index 0000000..1ff2c81
--- /dev/null
+++ b/backend/app/schemas/production.py
@@ -0,0 +1,57 @@
+from pydantic import BaseModel
+from typing import Optional
+from datetime import datetime
+
+
+class ProductionRecordCreate(BaseModel):
+ coil_no: str
+ plan_id: Optional[int] = None
+ shift: Optional[str] = None
+ shift_date: Optional[datetime] = None
+ start_time: Optional[datetime] = None
+ end_time: Optional[datetime] = None
+ process_length: Optional[float] = None
+ process_weight: Optional[float] = None
+ avg_speed: Optional[float] = None
+ max_speed: Optional[float] = None
+ acid_consumption: Optional[float] = None
+ inlet_thickness: Optional[float] = None
+ outlet_thickness: Optional[float] = None
+ inlet_width: Optional[float] = None
+ quality_grade: Optional[str] = None
+ operator: Optional[str] = None
+ remark: Optional[str] = None
+
+
+class ProductionRecordUpdate(BaseModel):
+ shift: Optional[str] = None
+ end_time: Optional[datetime] = None
+ process_length: Optional[float] = None
+ process_weight: Optional[float] = None
+ avg_speed: Optional[float] = None
+ acid_consumption: Optional[float] = None
+ quality_grade: Optional[str] = None
+ remark: Optional[str] = None
+
+
+class ProductionRecordOut(BaseModel):
+ id: int
+ coil_no: str
+ plan_id: Optional[int]
+ shift: Optional[str]
+ shift_date: Optional[datetime]
+ start_time: Optional[datetime]
+ end_time: Optional[datetime]
+ process_length: Optional[float]
+ process_weight: Optional[float]
+ avg_speed: Optional[float]
+ max_speed: Optional[float]
+ acid_consumption: Optional[float]
+ inlet_thickness: Optional[float]
+ outlet_thickness: Optional[float]
+ quality_grade: Optional[str]
+ operator: Optional[str]
+ created_at: datetime
+
+ class Config:
+ from_attributes = True
diff --git a/backend/app/schemas/quality.py b/backend/app/schemas/quality.py
new file mode 100644
index 0000000..040932d
--- /dev/null
+++ b/backend/app/schemas/quality.py
@@ -0,0 +1,66 @@
+from pydantic import BaseModel
+from typing import Optional
+from datetime import datetime
+
+
+class QualityRecordCreate(BaseModel):
+ coil_no: str
+ production_record_id: Optional[int] = None
+ thickness_actual: Optional[float] = None
+ width_actual: Optional[float] = None
+ flatness: Optional[float] = None
+ crown: Optional[float] = None
+ surface_defect_type: Optional[str] = None
+ defect_length_m: Optional[float] = None
+ defect_position: Optional[str] = None
+ pi_score: Optional[float] = None
+ surface_score: Optional[float] = None
+ overall_grade: Optional[str] = None
+ acid_residual: Optional[float] = None
+ roughness_ra: Optional[float] = None
+ inspector: Optional[str] = None
+ inspect_time: Optional[datetime] = None
+ is_passed: Optional[bool] = True
+
+
+class QualityRecordUpdate(BaseModel):
+ thickness_actual: Optional[float] = None
+ width_actual: Optional[float] = None
+ flatness: Optional[float] = None
+ crown: Optional[float] = None
+ surface_defect_type: Optional[str] = None
+ defect_length_m: Optional[float] = None
+ defect_position: Optional[str] = None
+ pi_score: Optional[float] = None
+ surface_score: Optional[float] = None
+ overall_grade: Optional[str] = None
+ acid_residual: Optional[float] = None
+ roughness_ra: Optional[float] = None
+ inspector: Optional[str] = None
+ inspect_time: Optional[datetime] = None
+ is_passed: Optional[bool] = None
+
+
+class QualityRecordOut(BaseModel):
+ id: int
+ coil_no: str
+ production_record_id: Optional[int]
+ thickness_actual: Optional[float]
+ width_actual: Optional[float]
+ flatness: Optional[float]
+ crown: Optional[float]
+ surface_defect_type: Optional[str]
+ defect_length_m: Optional[float]
+ defect_position: Optional[str]
+ pi_score: Optional[float]
+ surface_score: Optional[float]
+ overall_grade: Optional[str]
+ acid_residual: Optional[float]
+ roughness_ra: Optional[float]
+ inspector: Optional[str]
+ inspect_time: Optional[datetime]
+ is_passed: Optional[bool]
+ created_at: datetime
+
+ class Config:
+ from_attributes = True
diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py
new file mode 100644
index 0000000..db5a80a
--- /dev/null
+++ b/backend/app/schemas/user.py
@@ -0,0 +1,41 @@
+from pydantic import BaseModel
+from typing import Optional
+from datetime import datetime
+
+
+class LoginRequest(BaseModel):
+ username: str
+ password: str
+
+
+class Token(BaseModel):
+ access_token: str
+ token_type: str = "bearer"
+ username: str
+ role: str
+
+
+class UserCreate(BaseModel):
+ username: str
+ full_name: Optional[str] = None
+ password: str
+ role: str = "operator"
+
+
+class UserUpdate(BaseModel):
+ full_name: Optional[str] = None
+ role: Optional[str] = None
+ is_active: Optional[bool] = None
+ password: Optional[str] = None
+
+
+class UserOut(BaseModel):
+ id: int
+ username: str
+ full_name: Optional[str]
+ role: str
+ is_active: bool
+ created_at: datetime
+
+ class Config:
+ from_attributes = True
diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py
new file mode 100644
index 0000000..9688999
--- /dev/null
+++ b/backend/app/services/auth_service.py
@@ -0,0 +1,70 @@
+from datetime import datetime, timedelta
+from typing import Optional
+from jose import JWTError, jwt
+from passlib.context import CryptContext
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+from fastapi import Depends, HTTPException, status
+from fastapi.security import OAuth2PasswordBearer
+
+from app.config import settings
+from app.models.user import User
+from app.database import get_db
+
+pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
+
+
+def verify_password(plain: str, hashed: str) -> bool:
+ return pwd_context.verify(plain, hashed)
+
+
+def hash_password(password: str) -> str:
+ return pwd_context.hash(password)
+
+
+def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
+ to_encode = data.copy()
+ expire = datetime.utcnow() + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
+ to_encode["exp"] = expire
+ return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
+
+
+async def authenticate_user(db: AsyncSession, username: str, password: str) -> Optional[User]:
+ result = await db.execute(select(User).where(User.username == username))
+ user = result.scalar_one_or_none()
+ if not user or not verify_password(password, user.hashed_password):
+ return None
+ return user
+
+
+async def get_current_user(
+ token: str = Depends(oauth2_scheme),
+ db: AsyncSession = Depends(get_db)
+) -> User:
+ credentials_exception = HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="无效的认证凭据",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+ try:
+ payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
+ username: str = payload.get("sub")
+ if not username:
+ raise credentials_exception
+ except JWTError:
+ raise credentials_exception
+
+ result = await db.execute(select(User).where(User.username == username))
+ user = result.scalar_one_or_none()
+ if not user or not user.is_active:
+ raise credentials_exception
+ return user
+
+
+def require_roles(*roles: str):
+ async def checker(current_user: User = Depends(get_current_user)):
+ if current_user.role not in roles:
+ raise HTTPException(status_code=403, detail="权限不足")
+ return current_user
+ return checker
diff --git a/backend/app/services/material_service.py b/backend/app/services/material_service.py
new file mode 100644
index 0000000..1fb4ed1
--- /dev/null
+++ b/backend/app/services/material_service.py
@@ -0,0 +1,70 @@
+from datetime import datetime
+from typing import Optional
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, func
+from loguru import logger
+
+from app.models.material import Coil, MaterialTracking, CoilStatus
+from app.services.message_parser import dispatcher
+
+
+class MaterialService:
+
+ @staticmethod
+ async def get_coil(db: AsyncSession, coil_no: str) -> Optional[Coil]:
+ result = await db.execute(select(Coil).where(Coil.coil_no == coil_no))
+ return result.scalar_one_or_none()
+
+ @staticmethod
+ async def create_tracking(db: AsyncSession, coil: Coil, event_type: str,
+ position: str = None, **kwargs) -> MaterialTracking:
+ tracking = MaterialTracking(
+ coil_id=coil.id,
+ coil_no=coil.coil_no,
+ position=position,
+ event_type=event_type,
+ event_time=kwargs.get("event_time", datetime.now()),
+ **{k: v for k, v in kwargs.items() if k != "event_time"},
+ )
+ db.add(tracking)
+ await db.flush()
+ return tracking
+
+ @staticmethod
+ async def update_coil_status(db: AsyncSession, coil: Coil, status: CoilStatus):
+ coil.status = status
+ await db.flush()
+
+
+material_service = MaterialService()
+
+
+# 注册L1报文处理器
+@dispatcher.register("PC01")
+async def handle_coil_entry(data: dict):
+ """处理卷材入口报文"""
+ from app.database import AsyncSessionLocal
+ async with AsyncSessionLocal() as db:
+ coil = await material_service.get_coil(db, data["coil_no"])
+ if coil:
+ await material_service.update_coil_status(db, coil, CoilStatus.ON_LINE)
+ await material_service.create_tracking(
+ db, coil, "entry", position="入口", **data
+ )
+ await db.commit()
+ logger.info(f"卷材入线: {data['coil_no']}")
+
+
+@dispatcher.register("PC02")
+async def handle_coil_exit(data: dict):
+ """处理卷材出口报文"""
+ from app.database import AsyncSessionLocal
+ async with AsyncSessionLocal() as db:
+ coil = await material_service.get_coil(db, data["coil_no"])
+ if coil:
+ await material_service.update_coil_status(db, coil, CoilStatus.FINISHED)
+ await material_service.create_tracking(
+ db, coil, "exit", position="出口", **data
+ )
+ await db.commit()
+ logger.info(f"卷材出线: {data['coil_no']}")
diff --git a/backend/app/services/message_parser.py b/backend/app/services/message_parser.py
new file mode 100644
index 0000000..344c78c
--- /dev/null
+++ b/backend/app/services/message_parser.py
@@ -0,0 +1,348 @@
+"""
+L1报文解析服务 — UDP协议
+
+假设报文格式(固定帧结构,收到实际协议文档后对应调整):
+┌─────────────────────────────────────────────────────┐
+│ Offset Size 说明 │
+│ 0 2B 魔数 0xAA 0xBB │
+│ 2 4B 报文类型 ASCII,如 "PC01" │
+│ 6 2B 序列号 uint16 大端 │
+│ 8 2B Body长度 uint16 大端 │
+│ 10 2B 校验和 所有Body字节累加低16位 │
+│ 12 N B Body GBK编码固定列宽文本 │
+└─────────────────────────────────────────────────────┘
+UDP最大包:65507字节,单帧不分片。
+回执帧:Header相同结构,Body = "ACK" + 原序列号(2B)
+"""
+import asyncio
+import struct
+import time
+import uuid
+import json
+from datetime import datetime
+from typing import Optional, Dict, Any, Tuple
+from loguru import logger
+
+from app.config import settings
+
+
+# ─────────────────────────── 报文类型注册表 ───────────────────────────
+MSG_TYPES: Dict[str, str] = {
+ "PC01": "卷材入口报文",
+ "PC02": "卷材出口报文",
+ "PC03": "过程数据报文",
+ "PC04": "质量数据报文",
+ "PC05": "设备状态报文",
+ "PC10": "计划下发报文",
+ "PC11": "计划确认报文",
+ "PC20": "心跳报文",
+}
+
+HEADER_SIZE = 12 # 报文头固定长度
+MAGIC = b'\xAA\xBB'
+
+
+# ─────────────────────────── 校验和 ───────────────────────────
+def _checksum(body: bytes) -> int:
+ return sum(body) & 0xFFFF
+
+
+# ─────────────────────────── 报文头解析 ───────────────────────────
+def parse_header(raw: bytes) -> Optional[Dict[str, Any]]:
+ if len(raw) < HEADER_SIZE:
+ logger.warning(f"报文过短: {len(raw)}B,丢弃")
+ return None
+ magic = raw[0:2]
+ if magic != MAGIC:
+ logger.warning(f"魔数错误: {magic.hex()},丢弃")
+ return None
+ msg_type = raw[2:6].decode("ascii", errors="replace").strip()
+ seq = struct.unpack(">H", raw[6:8])[0]
+ body_len = struct.unpack(">H", raw[8:10])[0]
+ checksum = struct.unpack(">H", raw[10:12])[0]
+ body = raw[HEADER_SIZE: HEADER_SIZE + body_len]
+ if len(body) < body_len:
+ logger.warning(f"Body不完整: 期望{body_len}B 实际{len(body)}B")
+ return None
+ if _checksum(body) != checksum:
+ logger.warning(f"校验和错误 [{msg_type}] seq={seq},丢弃")
+ return None
+ return {"msg_type": msg_type, "seq": seq, "body": body,
+ "body_str": body.decode("gbk", errors="replace")}
+
+
+# ─────────────────────────── 构建回执帧 ───────────────────────────
+def build_ack(seq: int) -> bytes:
+ body = b"ACK" + struct.pack(">H", seq)
+ hdr = MAGIC
+ hdr += b"ACK ".ljust(4)[:4]
+ hdr += struct.pack(">H", seq)
+ hdr += struct.pack(">H", len(body))
+ hdr += struct.pack(">H", _checksum(body))
+ return hdr + body
+
+
+# ─────────────────────────── 构建发送帧 ───────────────────────────
+def build_frame(msg_type: str, body: bytes, seq: int = 0) -> bytes:
+ hdr = MAGIC
+ hdr += msg_type.encode("ascii").ljust(4)[:4]
+ hdr += struct.pack(">H", seq)
+ hdr += struct.pack(">H", len(body))
+ hdr += struct.pack(">H", _checksum(body))
+ return hdr + body
+
+
+# ─────────────────────────── Body解析器 ───────────────────────────
+class PC01Parser:
+ """卷材入口报文
+ Body固定列宽(GBK): 卷号(20) 钢种(10) 厚度(6) 宽度(6) 重量(8) 班次(2)
+ """
+ def parse(self, body: str) -> Dict[str, Any]:
+ return {
+ "coil_no": body[0:20].strip(),
+ "steel_grade": body[20:30].strip(),
+ "thickness": _safe_float(body[30:36]),
+ "width": _safe_float(body[36:42]),
+ "weight": _safe_float(body[42:50]),
+ "shift": body[50:52].strip(),
+ "event_time": datetime.now(),
+ }
+
+
+class PC02Parser:
+ """卷材出口报文
+ Body: 卷号(20) 实测厚度(6) 实测宽度(6) 处理长度(8) 平均速度(6) 质量等级(2)
+ """
+ def parse(self, body: str) -> Dict[str, Any]:
+ return {
+ "coil_no": body[0:20].strip(),
+ "actual_thickness": _safe_float(body[20:26]),
+ "actual_width": _safe_float(body[26:32]),
+ "process_length": _safe_float(body[32:40]),
+ "avg_speed": _safe_float(body[40:46]),
+ "quality_grade": body[46:48].strip(),
+ "event_time": datetime.now(),
+ }
+
+
+class PC03Parser:
+ """过程数据报文(周期推送)
+ Body: 卷号(20) 位置(10) 速度(6) 入口张力(8) 出口张力(8) 酸液温度(6)
+ """
+ def parse(self, body: str) -> Dict[str, Any]:
+ return {
+ "coil_no": body[0:20].strip(),
+ "position": body[20:30].strip(),
+ "speed": _safe_float(body[30:36]),
+ "tension_inlet": _safe_float(body[36:44]),
+ "tension_outlet": _safe_float(body[44:52]),
+ "acid_temp": _safe_float(body[52:58]),
+ "event_time": datetime.now(),
+ }
+
+
+class PC04Parser:
+ """质量数据报文
+ Body: 卷号(20) 缺陷类型(10) 缺陷位置(8) 严重程度(2)
+ """
+ def parse(self, body: str) -> Dict[str, Any]:
+ return {
+ "coil_no": body[0:20].strip(),
+ "defect_type": body[20:30].strip(),
+ "defect_pos": _safe_float(body[30:38]),
+ "severity": body[38:40].strip(),
+ "event_time": datetime.now(),
+ }
+
+
+class PC05Parser:
+ """设备状态报文
+ Body: 设备编号(10) 状态码(4) 故障码(6) 时间戳(14 yyyyMMddHHmmss)
+ """
+ def parse(self, body: str) -> Dict[str, Any]:
+ ts_str = body[20:34].strip()
+ try:
+ ts = datetime.strptime(ts_str, "%Y%m%d%H%M%S")
+ except Exception:
+ ts = datetime.now()
+ return {
+ "equipment_code": body[0:10].strip(),
+ "status_code": body[10:14].strip(),
+ "fault_code": body[14:20].strip(),
+ "event_time": ts,
+ }
+
+
+class PC20Parser:
+ """心跳报文 Body: 时间戳(14)"""
+ def parse(self, body: str) -> Dict[str, Any]:
+ return {"event_time": datetime.now(), "raw_ts": body[0:14].strip()}
+
+
+def _safe_float(s: str) -> float:
+ try:
+ return float(s.strip())
+ except (ValueError, AttributeError):
+ return 0.0
+
+
+BODY_PARSERS: Dict[str, Any] = {
+ "PC01": PC01Parser(),
+ "PC02": PC02Parser(),
+ "PC03": PC03Parser(),
+ "PC04": PC04Parser(),
+ "PC05": PC05Parser(),
+ "PC20": PC20Parser(),
+}
+
+
+# ─────────────────────────── 分发器 ───────────────────────────
+class MessageDispatcher:
+ def __init__(self):
+ self._handlers: Dict[str, list] = {}
+
+ def register(self, msg_type: str):
+ def decorator(func):
+ self._handlers.setdefault(msg_type, []).append(func)
+ return func
+ return decorator
+
+ async def dispatch(self, msg_type: str, data: Dict[str, Any]):
+ for handler in self._handlers.get(msg_type, []):
+ try:
+ await handler(data)
+ except Exception as e:
+ logger.error(f"报文处理器异常 [{msg_type}]: {e}")
+
+
+dispatcher = MessageDispatcher()
+
+
+# ─────────────────────────── UDP 服务端 ───────────────────────────
+class L1UdpProtocol(asyncio.DatagramProtocol):
+ """asyncio UDP DatagramProtocol 实现"""
+
+ def __init__(self, server: "L1UdpServer"):
+ self._server = server
+ self.transport: Optional[asyncio.DatagramTransport] = None
+
+ def connection_made(self, transport: asyncio.DatagramTransport):
+ self.transport = transport
+ host, port = transport.get_extra_info("sockname")
+ logger.info(f"UDP监听启动: {host}:{port}")
+
+ def datagram_received(self, data: bytes, addr: Tuple[str, int]):
+ asyncio.create_task(self._server.handle(data, addr, self.transport))
+
+ def error_received(self, exc: Exception):
+ logger.error(f"UDP错误: {exc}")
+
+ def connection_lost(self, exc):
+ logger.warning(f"UDP连接丢失: {exc}")
+
+
+class L1UdpServer:
+ """UDP报文接收服务"""
+
+ def __init__(self):
+ self._transport: Optional[asyncio.DatagramTransport] = None
+ self._running = False
+ # 统计
+ self.recv_count = 0
+ self.error_count = 0
+
+ async def start(self):
+ self._running = True
+ loop = asyncio.get_running_loop()
+ self._transport, _ = await loop.create_datagram_endpoint(
+ lambda: L1UdpProtocol(self),
+ local_addr=(settings.L1_HOST, settings.L1_PORT),
+ )
+ logger.info(f"L1 UDP服务已启动,监听 {settings.L1_HOST}:{settings.L1_PORT}")
+
+ async def handle(self, raw: bytes, addr: Tuple[str, int],
+ transport: asyncio.DatagramTransport):
+ t0 = time.time()
+ self.recv_count += 1
+ logger.debug(f"收到UDP包 from {addr[0]}:{addr[1]} {len(raw)}B")
+
+ header = parse_header(raw)
+ if not header:
+ self.error_count += 1
+ await self._save_log(raw, addr, None, "error", "报文头解析失败", t0)
+ return
+
+ msg_type = header["msg_type"]
+ seq = header["seq"]
+
+ # 发送ACK回执
+ if msg_type != "ACK":
+ ack = build_ack(seq)
+ transport.sendto(ack, addr)
+
+ # 心跳不做业务处理
+ if msg_type == "PC20":
+ logger.debug(f"心跳 seq={seq}")
+ return
+
+ # Body解析
+ body_parser = BODY_PARSERS.get(msg_type)
+ data: Dict[str, Any] = {}
+ if body_parser:
+ try:
+ data = body_parser.parse(header["body_str"])
+ except Exception as e:
+ logger.error(f"Body解析异常 [{msg_type}]: {e}")
+ self.error_count += 1
+ await self._save_log(raw, addr, header, "error", str(e), t0)
+ return
+ else:
+ logger.warning(f"未知报文类型: {msg_type}")
+
+ elapsed_ms = (time.time() - t0) * 1000
+ logger.info(f"[{msg_type}] seq={seq} from {addr[0]} 耗时{elapsed_ms:.1f}ms")
+
+ await self._save_log(raw, addr, header, "success", None, t0, data)
+ await dispatcher.dispatch(msg_type, data)
+
+ async def _save_log(self, raw: bytes, addr: Tuple[str, int],
+ header: Optional[Dict], status: str,
+ error_msg: Optional[str], t0: float,
+ parsed_data: Optional[Dict] = None):
+ try:
+ from app.database import AsyncSessionLocal
+ from app.models.message import MessageLog
+ elapsed_ms = (time.time() - t0) * 1000
+ async with AsyncSessionLocal() as db:
+ log = MessageLog(
+ msg_id=str(uuid.uuid4())[:16],
+ msg_type=header["msg_type"] if header else "UNKNOWN",
+ direction="recv",
+ source=f"{addr[0]}:{addr[1]}",
+ raw_data=raw.hex(),
+ parsed_data=json.dumps(parsed_data, default=str) if parsed_data else None,
+ status=status,
+ error_msg=error_msg,
+ process_time=round(elapsed_ms, 2),
+ received_at=datetime.now(),
+ )
+ db.add(log)
+ await db.commit()
+ except Exception as e:
+ logger.error(f"保存报文日志失败: {e}")
+
+ def send(self, data: bytes, addr: Tuple[str, int]):
+ """主动向L1发送报文"""
+ if self._transport:
+ self._transport.sendto(data, addr)
+ else:
+ raise RuntimeError("UDP服务未启动")
+
+ def stop(self):
+ self._running = False
+ if self._transport:
+ self._transport.close()
+
+
+# 全局单例
+l1_server = L1UdpServer()
diff --git a/backend/app/services/prediction.py b/backend/app/services/prediction.py
new file mode 100644
index 0000000..84432f9
--- /dev/null
+++ b/backend/app/services/prediction.py
@@ -0,0 +1,482 @@
+"""
+工艺预测模型 — 灰箱(Gray-box)架构
+
+设计思路:
+ 物理结构来自 Arrhenius 酸洗动力学,参数取自公开文献实验值,
+ 而非理论推导。每个模型内置校准系数 K_cal(初始=1.0),
+ 投产后可通过 calibrate() 方法用实测结果回归更新,
+ 使模型随数据积累逐步收敛到真实工况。
+
+关键文献依据:
+ [1] 碳钢 HCl 酸洗活化能:Ea ≈ 40~50 kJ/mol(实验测定均值取 45 kJ/mol)
+ 来源:Hydrochloric Acid Pickling Process Optimization in Metal Wire,
+ IJSSST Vol-16 No-5; IspatGuru Pickling of Hot Rolled Strip
+ [2] H⁺ 浓度动力学阶次:1.0~2.0阶(取保守值 1.2)
+ 来源:Optimizing pickling process for 30Cr13 steel,
+ ScienceDirect 2025; neural network MPC studies
+ [3] 温度效应校验:速率每升温 6~8°C 翻倍(Ea≈45 kJ/mol 时对应约 7°C)
+ [4] 欠酸洗风险判别特征:strip thickness, speed, conc, temp
+ 来源:Prediction of under pickling defects on steel strip surface,
+ arXiv:1207.0911
+ [5] 速度优化:Nelder-Mead simplex 已在实际 1450mm 酸洗线验证
+ 来源:Zhu et al., Advances in Mechanical Engineering, 2016
+"""
+import math
+import json
+import os
+from typing import List, Dict, Any, Optional, Tuple
+
+# ── 校准系数持久化路径 ────────────────────────────────────────────────────────
+_CAL_FILE = os.path.join(os.path.dirname(__file__), "cal_coeffs.json")
+
+def _load_cal() -> Dict[str, float]:
+ try:
+ with open(_CAL_FILE) as f:
+ return json.load(f)
+ except Exception:
+ return {}
+
+def _save_cal(d: Dict[str, float]):
+ with open(_CAL_FILE, "w") as f:
+ json.dump(d, f, indent=2)
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# 1. 酸洗速度模型(Gray-box)
+# ─────────────────────────────────────────────────────────────────────────────
+class AcidSpeedModel:
+ """
+ 基于文献实测参数的 Arrhenius 灰箱模型。
+
+ 与上一版本的关键差异:
+ - Ea/R: 3000 K → 5413 K(45 kJ/mol 实验值,文献[1])
+ - 浓度指数: 0.6 → 1.2(H⁺ 二阶动力学,文献[2])
+ - 增加氧化铁皮结构修正(FeO/Fe₃O₄双层模型,文献[4])
+ - 内置 K_cal 校准系数,支持投产后在线标定
+ """
+
+ # 文献实验值(碳钢 HCl 连续酸洗)
+ TANK_LENGTH = 18.0 # m,单槽有效长度(按设备规格书)
+ NUM_TANKS = 6
+ # K0 由实际工况反推:
+ # 目标:75°C、180 g/L 正常酸液条件下,最大速度约 120~130 m/min(PI≥95%)
+ # 推导:t_total = 6×18/(125/60)=51.8s,k=ln(20)/51.8=0.058 s⁻¹
+ # k=K0×SCALE_RATE_FACTOR → K0=0.058/0.765≈0.075 s⁻¹
+ K0 = 0.075 # 指前因子 s⁻¹,由设备规格反推标定
+ EA_R = 5413.0 # Ea/R (K),Ea=45 kJ/mol / R=8.314(文献实验值[1])
+ T_REF = 348.15 # 参考温度 75°C (K)
+ C_REF = 180.0 # 参考游离酸浓度 g/L
+ N_CONC = 1.2 # 浓度动力学阶次(文献[2] 取保守值)
+ V_MIN = 20.0
+ V_MAX = 180.0
+ CAL_KEY = "acid_speed_kcal"
+
+ # 氧化铁皮结构系数(FeO 快速溶解 + Fe₃O₄ 慢速溶解,文献[4])
+ # 热轧碳钢铁皮组成约:FeO 70%,Fe₃O₄ 20%,Fe₂O₃ 10%
+ # FeO 溶速约为 Fe₃O₄ 的 4 倍;有效速率取加权平均
+ SCALE_RATE_FACTOR = 0.70 * 1.0 + 0.20 * 0.25 + 0.10 * 0.15 # ≈ 0.765
+
+ def __init__(
+ self,
+ thickness: float, # mm
+ width: float, # mm
+ steel_grade: str,
+ acid_conc_list: List[float], # 各槽游离酸 g/L
+ acid_temp_list: List[float], # 各槽温度 °C
+ scale_weight: float = 8.5, # g/m²,氧化铁皮重量
+ target_pi: float = 95.0,
+ ):
+ if len(acid_conc_list) != self.NUM_TANKS:
+ raise ValueError(f"acid_conc_list 需要 {self.NUM_TANKS} 个元素")
+ if len(acid_temp_list) != self.NUM_TANKS:
+ raise ValueError(f"acid_temp_list 需要 {self.NUM_TANKS} 个元素")
+
+ self.thickness = thickness
+ self.width = width
+ self.steel_grade = steel_grade
+ self.acid_conc_list = acid_conc_list
+ self.acid_temp_list = acid_temp_list
+ self.scale_weight = scale_weight
+ self.target_pi = target_pi
+ self.K_cal = _load_cal().get(self.CAL_KEY, 1.0)
+
+ def _k_i(self, conc: float, temp_c: float) -> float:
+ """单槽有效酸洗速率常数(含文献参数 + 铁皮结构修正)"""
+ T_k = temp_c + 273.15
+ arrhenius = math.exp(-self.EA_R * (1.0 / T_k - 1.0 / self.T_REF))
+ conc_factor = max(conc / self.C_REF, 0.01) ** self.N_CONC
+ # 铁皮越厚,有效接触面积越低(正比于 1/scale_weight^0.3 经验修正)
+ scale_corr = (8.5 / max(self.scale_weight, 1.0)) ** 0.3
+ return self.K0 * arrhenius * conc_factor * self.SCALE_RATE_FACTOR * scale_corr * self.K_cal
+
+ def _compute_pi(self, v_mpm: float) -> Tuple[float, List[float], List[float]]:
+ v_mps = v_mpm / 60.0
+ pi_prev = 0.0
+ pi_per_tank, rt_per_tank = [], []
+ for i in range(self.NUM_TANKS):
+ t_i = self.TANK_LENGTH / v_mps
+ k_i = self._k_i(self.acid_conc_list[i], self.acid_temp_list[i])
+ # 精确解析解:dPI/dt = k*(1-PI/100) → PI_new = 100-(100-PI_old)*exp(-k*t)
+ # 避免 Euler 一阶近似在 k*t 较大时的严重失真
+ pi_prev = 100.0 - (100.0 - pi_prev) * math.exp(-k_i * t_i)
+ pi_per_tank.append(round(pi_prev, 2))
+ rt_per_tank.append(round(t_i, 1))
+ return pi_prev, pi_per_tank, rt_per_tank
+
+ def calculate(self) -> Dict[str, Any]:
+ # Nelder-Mead 单维退化为二分搜索(文献[5]验证有效)
+ pi_at_min, _, _ = self._compute_pi(self.V_MIN)
+ if pi_at_min < self.target_pi:
+ pi, pp, rt = self._compute_pi(self.V_MIN)
+ return {
+ "max_speed": self.V_MIN,
+ "pi_per_tank": pp,
+ "residence_time_per_tank": rt,
+ "total_pi": round(pi, 2),
+ "under_pickling_risk": self._risk_level(self.V_MIN, pi),
+ "warning": "酸液条件不足,即使最低速下酸洗指数仍低于目标,请检查酸浓度和温度",
+ "K_cal": self.K_cal,
+ }
+
+ lo, hi, best_v = self.V_MIN, self.V_MAX, self.V_MIN
+ while hi - lo >= 0.5:
+ mid = (lo + hi) / 2.0
+ pi_mid, _, _ = self._compute_pi(mid)
+ if pi_mid >= self.target_pi:
+ best_v = mid; lo = mid + 0.5
+ else:
+ hi = mid - 0.5
+
+ best_v = math.floor(best_v)
+ total_pi, pi_per_tank, rt_per_tank = self._compute_pi(best_v)
+
+ return {
+ "max_speed": best_v,
+ "pi_per_tank": pi_per_tank,
+ "residence_time_per_tank": rt_per_tank,
+ "total_pi": round(total_pi, 2),
+ "under_pickling_risk": self._risk_level(best_v, total_pi),
+ "warning": None,
+ "K_cal": self.K_cal,
+ }
+
+ def _risk_level(self, speed: float, pi: float) -> str:
+ """
+ 欠酸洗风险评估(文献[4] decision-tree 特征阈值)
+ 输入:speed(m/min), pi(%),结合厚度、浓度综合判断
+ """
+ avg_conc = sum(self.acid_conc_list) / len(self.acid_conc_list)
+ avg_temp = sum(self.acid_temp_list) / len(self.acid_temp_list)
+ # 文献给出的欠酸洗高风险条件组合
+ risk_score = 0
+ if pi < 85: risk_score += 3
+ elif pi < 92: risk_score += 1
+ if speed > 140: risk_score += 2
+ if avg_conc < 120: risk_score += 2
+ if avg_temp < 68: risk_score += 2
+ if self.thickness > 4.0: risk_score += 1
+
+ if risk_score >= 5: return "HIGH"
+ elif risk_score >= 2: return "MEDIUM"
+ else: return "LOW"
+
+ def calibrate(self, actual_max_speed: float,
+ actual_quality_ok: bool) -> float:
+ """
+ 投产后标定接口:
+ 传入某卷的实际最大可用速度(操作员确认质量合格时的速度),
+ 用简单比例更新 K_cal,使模型逐步向真实工况收敛。
+
+ actual_max_speed: 实际测得质量合格的最高速度 (m/min)
+ actual_quality_ok: True=该速度下质量合格,False=出现欠酸洗
+ """
+ predicted = self.calculate()["max_speed"]
+ if not actual_quality_ok:
+ # 预测速度偏高,缩减 K_cal
+ adjustment = 0.95
+ else:
+ ratio = actual_max_speed / max(predicted, 1.0)
+ # 平滑更新,避免单次样本过拟合
+ adjustment = 1.0 + 0.3 * (ratio - 1.0)
+ adjustment = max(0.7, min(1.3, adjustment))
+
+ self.K_cal = round(self.K_cal * adjustment, 4)
+ cal = _load_cal()
+ cal[self.CAL_KEY] = self.K_cal
+ _save_cal(cal)
+ return self.K_cal
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# 2. 张力设定模型
+# ─────────────────────────────────────────────────────────────────────────────
+class TensionModel:
+ """
+ 张力模型:基于截面积×屈服强度,区间比例系数参考酸洗线工程手册。
+ 每个区段独立校准系数 zone_kcal[zone],互不干扰。
+ """
+
+ # 各区基准比例系数(酸洗线工程实践均值)
+ ZONE_RATIOS = {
+ "inlet": 1.00,
+ "s1_roller": 0.85,
+ "acid_entry": 0.78,
+ "acid1": 0.72,
+ "acid2": 0.68,
+ "acid3": 0.68,
+ "rinse": 0.70,
+ "leveler": 0.76,
+ "s2_roller": 0.88,
+ "outlet": 1.00,
+ }
+ ZONE_NAMES_CN = {
+ "inlet": "入口张力辊",
+ "s1_roller": "S1夹送辊",
+ "acid_entry": "酸洗入口辊",
+ "acid1": "1#酸槽",
+ "acid2": "2#酸槽",
+ "acid3": "3#酸槽",
+ "rinse": "漂洗段辊",
+ "leveler": "拉矫机",
+ "s2_roller": "S2夹送辊",
+ "outlet": "出口张力辊",
+ }
+
+ @staticmethod
+ def _zone_cal_key(zone: str) -> str:
+ return f"tension_zone_{zone}"
+
+ def __init__(
+ self,
+ thickness: float,
+ width: float,
+ yield_strength: float,
+ tension_coef: float = 0.25,
+ ):
+ self.thickness = thickness
+ self.width = width
+ self.yield_strength = yield_strength
+ self.tension_coef = tension_coef
+ cal = _load_cal()
+ # 每个区段独立加载自己的校准系数,默认 1.0
+ self.zone_kcal: Dict[str, float] = {
+ z: cal.get(self._zone_cal_key(z), 1.0)
+ for z in self.ZONE_RATIOS
+ }
+
+ def calculate(self) -> Dict[str, Any]:
+ cross_section = self.thickness * self.width # mm²
+ # T_max 是理论基准值(不含区段校准,区段校准在各 zone 内单独乘)
+ t_base_kn = (self.tension_coef * self.yield_strength
+ * cross_section / 1000.0) # kN
+
+ zones = {}
+ for zone, ratio in self.ZONE_RATIOS.items():
+ k = self.zone_kcal.get(zone, 1.0)
+ zones[zone] = {
+ "tension_kN": round(t_base_kn * ratio * k, 2),
+ "ratio": ratio,
+ "k_cal": k,
+ "name_cn": self.ZONE_NAMES_CN[zone],
+ }
+
+ density = 7850.0
+ mass_per_m = density * (self.thickness / 1000.0) * (self.width / 1000.0)
+ accel_kn = round(mass_per_m * (30.0 / 60.0) / 1000.0, 3)
+ t_max_kn = round(t_base_kn * self.zone_kcal.get("inlet", 1.0), 2)
+
+ return {
+ "T_max": t_max_kn,
+ "T_base": round(t_base_kn, 2),
+ "cross_section_mm2": round(cross_section, 1),
+ "zones": zones,
+ "weld_speed_limit": 60.0,
+ "weld_tension_kN": round(t_max_kn * 0.60, 2),
+ "accel_tension": accel_kn,
+ "zone_kcal": self.zone_kcal,
+ }
+
+ def calibrate(self, zone: str, measured_kn: float) -> Dict[str, float]:
+ """仅更新指定区段的校准系数,其他区段不变"""
+ if zone not in self.ZONE_RATIOS:
+ raise ValueError(f"未知区段: {zone}")
+ t_base = (self.tension_coef * self.yield_strength
+ * self.thickness * self.width / 1000.0)
+ predicted = t_base * self.ZONE_RATIOS[zone] * self.zone_kcal[zone]
+ ratio = measured_kn / max(predicted, 0.1)
+ # 平滑更新,步长 40%,范围限制在 [0.5, 2.0]
+ adjustment = 1.0 + 0.4 * (ratio - 1.0)
+ adjustment = max(0.5, min(2.0, adjustment))
+ new_k = round(self.zone_kcal[zone] * adjustment, 4)
+ self.zone_kcal[zone] = new_k
+ cal = _load_cal()
+ cal[self._zone_cal_key(zone)] = new_k
+ _save_cal(cal)
+ return self.zone_kcal
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# 3. 质量预测模型
+# ─────────────────────────────────────────────────────────────────────────────
+class QualityPredictionModel:
+ """
+ 欠酸洗风险 + 质量等级预测。
+
+ v2 变化:
+ - 使用与 AcidSpeedModel 一致的文献参数(Ea/R=5413, n=1.2)
+ - 欠酸洗风险特征阈值参考 arXiv:1207.0911 的 decision-tree 结论
+ - 增加铁离子浓度(FeCl₂)对酸洗能力的抑制修正
+ - 支持投产后用实际质量等级校准评分阈值
+ """
+ EA_R = 5413.0
+ T_REF = 348.15
+ C_REF = 180.0
+ N_CONC = 1.2
+ CAL_KEY = "quality_kcal"
+
+ def __init__(
+ self,
+ thickness: float,
+ avg_speed: float,
+ acid_conc_avg: float, # 游离酸均值 g/L
+ acid_temp_avg: float, # 温度均值 °C
+ scale_weight: float = 8.5,
+ fe_conc_avg: float = 60.0, # FeCl₂ 浓度 g/L(铁离子抑制效应)
+ ):
+ self.thickness = thickness
+ self.avg_speed = avg_speed
+ self.acid_conc_avg = acid_conc_avg
+ self.acid_temp_avg = acid_temp_avg
+ self.scale_weight = scale_weight
+ self.fe_conc_avg = fe_conc_avg
+ self.K_cal = _load_cal().get(self.CAL_KEY, 1.0)
+
+ def _pickling_index_score(self) -> float:
+ T_k = self.acid_temp_avg + 273.15
+ arrhenius = math.exp(-self.EA_R * (1.0 / T_k - 1.0 / self.T_REF))
+ conc_factor = max(self.acid_conc_avg / self.C_REF, 0.01) ** self.N_CONC
+ # 铁离子抑制:FeCl₂ > 80 g/L 时显著降低酸洗速率(文献经验)
+ fe_inhibition = 1.0 - max(0.0, (self.fe_conc_avg - 80.0) / 200.0) * 0.35
+ scale_corr = (8.5 / max(self.scale_weight, 1.0)) ** 0.3
+ exposure = (1.20 * arrhenius * conc_factor * fe_inhibition
+ * scale_corr * 18.0 * 6) / (self.avg_speed / 60.0)
+ pi_score = 100.0 * (1.0 - math.exp(-exposure / 10.0))
+ return min(max(pi_score * self.K_cal, 0.0), 100.0)
+
+ def _surface_score(self, pi_score: float) -> float:
+ # 最优速度区间 80-140 m/min(文献[4] 欠酸洗风险判别边界)
+ if self.avg_speed < 60:
+ speed_score = 80.0
+ elif self.avg_speed <= 140:
+ speed_score = 80.0 + 15.0 * (self.avg_speed - 60) / 80.0
+ else:
+ over = (self.avg_speed - 140) / 40.0
+ speed_score = 95.0 - 30.0 * over
+ return min(max(pi_score * 0.65 + speed_score * 0.35, 0.0), 100.0)
+
+ def _grade(self, pi: float, surface: float) -> str:
+ c = (pi + surface) / 2.0
+ if c >= 90: return "A1"
+ if c >= 80: return "A2"
+ if c >= 70: return "B1"
+ if c >= 60: return "B2"
+ return "C"
+
+ def _recommendations(self, pi: float, surface: float) -> List[str]:
+ recs = []
+ if self.fe_conc_avg > 80:
+ recs.append(f"铁离子浓度偏高({self.fe_conc_avg:.0f} g/L),酸洗能力受抑制,建议加速换酸或补充新酸")
+ if pi < 80:
+ recs.append("酸洗指数偏低,建议提高酸液浓度至 180 g/L 以上,或将温度升至 80°C")
+ if pi < 65:
+ recs.append(f"欠酸洗风险高,建议将线速降至 {max(self.avg_speed*0.75, 20):.0f} m/min 以下")
+ if self.acid_temp_avg < 70:
+ recs.append(f"酸液温度偏低({self.acid_temp_avg:.1f}°C),建议升温至 75~85°C")
+ if self.acid_conc_avg < 120:
+ recs.append(f"游离酸浓度偏低({self.acid_conc_avg:.0f} g/L),建议补充新酸至 150 g/L")
+ if self.avg_speed > 150:
+ recs.append(f"线速过高({self.avg_speed:.0f} m/min),欠酸洗风险,建议不超过 140 m/min")
+ if self.scale_weight > 12.0:
+ recs.append(f"氧化铁皮偏重({self.scale_weight:.1f} g/m²),建议检查加热炉气氛控制")
+ if not recs:
+ recs.append("工艺参数在正常范围内,当前设定可继续保持")
+ return recs
+
+ def calculate(self) -> Dict[str, Any]:
+ pi = round(self._pickling_index_score(), 1)
+ surface = round(self._surface_score(pi), 1)
+ return {
+ "pi_score": pi,
+ "surface_score": surface,
+ "overall_grade": self._grade(pi, surface),
+ "recommendations": self._recommendations(pi, surface),
+ "K_cal": self.K_cal,
+ }
+
+ def calibrate(self, actual_grade: str) -> float:
+ """传入实际质检等级,更新评分校准系数"""
+ grade_map = {"A1": 95, "A2": 85, "B1": 75, "B2": 65, "C": 50}
+ actual_score = grade_map.get(actual_grade, 75)
+ result = self.calculate()
+ predicted_score = (result["pi_score"] + result["surface_score"]) / 2.0
+ ratio = actual_score / max(predicted_score, 1.0)
+ adjustment = 1.0 + 0.3 * (ratio - 1.0)
+ adjustment = max(0.7, min(1.3, adjustment))
+ self.K_cal = round(self.K_cal * adjustment, 4)
+ cal = _load_cal()
+ cal[self.CAL_KEY] = self.K_cal
+ _save_cal(cal)
+ return self.K_cal
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# 4. 消耗预测模型
+# ─────────────────────────────────────────────────────────────────────────────
+class AcidConsumptionModel:
+ """
+ 单卷资源消耗预测。
+ 单位消耗定额取自浙江企鹅1250mm规格书;
+ 酸耗额外引入铁离子浓度修正(FeCl₂ 越高酸液越快失效,换酸频率越高)。
+ """
+
+ ACID_WITH_REGEN = 2.0 # kg/t
+ ACID_WITHOUT_REGEN = 35.0 # kg/t
+ STEAM_UNIT = 39.8 # kg/t
+ POWER_UNIT = 14.0 # kWh/t
+ COOLING_UNIT = 1.21 # m³/t
+
+ def __init__(
+ self,
+ thickness: float,
+ width: float,
+ coil_weight_kg: float,
+ has_regen_station: bool = True,
+ fe_conc_avg: float = 60.0, # FeCl₂ g/L,影响换酸频率
+ ):
+ self.thickness = thickness
+ self.width = width
+ self.coil_weight_kg = coil_weight_kg
+ self.has_regen_station = has_regen_station
+ self.fe_conc_avg = fe_conc_avg
+
+ def calculate(self) -> Dict[str, Any]:
+ weight_t = self.coil_weight_kg / 1000.0
+ acid_base = self.ACID_WITH_REGEN if self.has_regen_station else self.ACID_WITHOUT_REGEN
+
+ # 铁离子修正:FeCl₂ > 100 g/L 时酸液利用率下降,有效酸耗上升
+ fe_factor = 1.0 + max(0.0, (self.fe_conc_avg - 100.0) / 100.0) * 0.4
+ acid_unit = round(acid_base * fe_factor, 3)
+
+ return {
+ "coil_weight_t": round(weight_t, 3),
+ "has_regen_station": self.has_regen_station,
+ "acid_consumption_kg": round(acid_unit * weight_t, 2),
+ "acid_unit_kg_per_t": acid_unit,
+ "steam_consumption_kg": round(self.STEAM_UNIT * weight_t, 2),
+ "steam_unit_kg_per_t": self.STEAM_UNIT,
+ "power_consumption_kwh": round(self.POWER_UNIT * weight_t, 2),
+ "power_unit_kwh_per_t": self.POWER_UNIT,
+ "cooling_water_m3": round(self.COOLING_UNIT * weight_t, 3),
+ "cooling_water_unit_m3_per_t": self.COOLING_UNIT,
+ "fe_conc_factor": round(fe_factor, 3),
+ }
diff --git a/backend/requirements.txt b/backend/requirements.txt
new file mode 100644
index 0000000..2b68318
--- /dev/null
+++ b/backend/requirements.txt
@@ -0,0 +1,20 @@
+fastapi==0.111.0
+uvicorn[standard]==0.29.0
+sqlalchemy==2.0.30
+alembic==1.13.1
+asyncpg==0.29.0
+psycopg2-binary==2.9.9
+pydantic==2.7.1
+pydantic-settings==2.2.1
+python-dotenv==1.0.1
+python-jose[cryptography]==3.3.0
+passlib[bcrypt]==1.7.4
+python-multipart==0.0.9
+aiofiles==23.2.1
+websockets==12.0
+schedule==1.2.1
+APScheduler==3.10.4
+redis==5.0.4
+aioredis==2.0.1
+httpx==0.27.0
+loguru==0.7.2
diff --git a/backend/tests/test_udp_sender.py b/backend/tests/test_udp_sender.py
new file mode 100644
index 0000000..234c205
--- /dev/null
+++ b/backend/tests/test_udp_sender.py
@@ -0,0 +1,85 @@
+"""
+模拟L1发送UDP报文的测试脚本
+用法: python tests/test_udp_sender.py
+"""
+import socket
+import struct
+import time
+
+HOST = "127.0.0.1"
+PORT = 9000
+MAGIC = b'\xAA\xBB'
+
+
+def checksum(body: bytes) -> int:
+ return sum(body) & 0xFFFF
+
+
+def build_frame(msg_type: str, body_str: str, seq: int) -> bytes:
+ body = body_str.encode("gbk")
+ hdr = MAGIC
+ hdr += msg_type.encode("ascii").ljust(4)[:4]
+ hdr += struct.pack(">H", seq)
+ hdr += struct.pack(">H", len(body))
+ hdr += struct.pack(">H", checksum(body))
+ return hdr + body
+
+
+def send(sock: socket.socket, frame: bytes):
+ sock.sendto(frame, (HOST, PORT))
+ try:
+ sock.settimeout(2)
+ ack, addr = sock.recvfrom(64)
+ print(f" ACK收到: {ack[:12].hex()}")
+ except socket.timeout:
+ print(" 未收到ACK(超时)")
+
+
+if __name__ == "__main__":
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+
+ # PC20 心跳
+ print("发送 PC20 心跳...")
+ send(sock, build_frame("PC20", "20240101120000", seq=1))
+ time.sleep(0.2)
+
+ # PC01 卷材入口
+ print("发送 PC01 卷材入口...")
+ body = (
+ "HA123456789012345678" # 卷号 20B
+ "SPHC " # 钢种 10B
+ " 2.500" # 厚度 6B
+ "1250.0" # 宽度 6B
+ " 18500.0" # 重量 8B
+ "甲" # 班次 2B
+ )
+ send(sock, build_frame("PC01", body, seq=2))
+ time.sleep(0.2)
+
+ # PC03 过程数据
+ print("发送 PC03 过程数据...")
+ body = (
+ "HA123456789012345678" # 卷号 20B
+ "酸洗槽1 " # 位置 10B
+ " 80.5" # 速度 6B
+ " 5200.0" # 入口张力 8B
+ " 4800.0" # 出口张力 8B
+ " 62.3" # 酸液温度 6B
+ )
+ send(sock, build_frame("PC03", body, seq=3))
+ time.sleep(0.2)
+
+ # PC02 卷材出口
+ print("发送 PC02 卷材出口...")
+ body = (
+ "HA123456789012345678" # 卷号 20B
+ " 2.498" # 实测厚度 6B
+ "1249.5" # 实测宽度 6B
+ " 1850.00" # 处理长度 8B
+ " 78.3" # 平均速度 6B
+ "A1" # 质量等级 2B
+ )
+ send(sock, build_frame("PC02", body, seq=4))
+
+ sock.close()
+ print("测试完成")
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..c66422b
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,41 @@
+version: '3.8'
+
+services:
+ postgres:
+ image: postgres:16-alpine
+ environment:
+ POSTGRES_DB: pickling_mes
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: password
+ ports:
+ - "5432:5432"
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+
+ redis:
+ image: redis:7-alpine
+
+ backend:
+ build:
+ context: ./backend
+ dockerfile: Dockerfile
+ environment:
+ DATABASE_URL: postgresql+asyncpg://postgres:password@postgres:5432/pickling_mes
+ REDIS_URL: redis://redis:6379/0
+ depends_on:
+ - postgres
+ - redis
+ volumes:
+ - ./backend:/app
+
+ frontend:
+ build:
+ context: ./frontend
+ dockerfile: Dockerfile
+ ports:
+ - "10031:80"
+ depends_on:
+ - backend
+
+volumes:
+ postgres_data:
diff --git a/docs/L1-L2接口需求说明书_共同评估.pdf b/docs/L1-L2接口需求说明书_共同评估.pdf
new file mode 100644
index 0000000..90b7cc4
Binary files /dev/null and b/docs/L1-L2接口需求说明书_共同评估.pdf differ
diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js
new file mode 100644
index 0000000..03f8fca
--- /dev/null
+++ b/frontend/.eslintrc.js
@@ -0,0 +1,7 @@
+module.exports = {
+ root: true,
+ env: { node: true },
+ extends: ['plugin:vue/essential', 'eslint:recommended'],
+ parserOptions: { ecmaVersion: 2020 },
+ rules: {},
+}
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
new file mode 100644
index 0000000..286f2f3
--- /dev/null
+++ b/frontend/Dockerfile
@@ -0,0 +1,11 @@
+FROM node:18-alpine AS build
+WORKDIR /app
+COPY package*.json ./
+RUN npm install
+COPY . .
+RUN npm run build
+
+FROM nginx:alpine
+COPY --from=build /app/dist /usr/share/nginx/html
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+EXPOSE 80
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
new file mode 100644
index 0000000..3015b68
--- /dev/null
+++ b/frontend/nginx.conf
@@ -0,0 +1,15 @@
+server {
+ listen 80;
+ root /usr/share/nginx/html;
+ index index.html;
+
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+
+ location /api {
+ proxy_pass http://backend:8000;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..f59eee8
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,32 @@
+{
+ "name": "pickling-mes-frontend",
+ "version": "1.0.0",
+ "description": "推拉酸洗线二级系统前端",
+ "scripts": {
+ "serve": "vue-cli-service serve",
+ "build": "vue-cli-service build",
+ "lint": "vue-cli-service lint"
+ },
+ "dependencies": {
+ "vue": "^2.7.16",
+ "vue-router": "^3.6.5",
+ "vuex": "^3.6.2",
+ "element-ui": "^2.15.14",
+ "axios": "^1.7.2",
+ "echarts": "^5.5.0",
+ "vue-echarts": "^6.7.3",
+ "dayjs": "^1.11.11",
+ "nprogress": "^0.2.0"
+ },
+ "devDependencies": {
+ "@vue/cli-plugin-babel": "^5.0.8",
+ "@vue/cli-plugin-eslint": "^5.0.8",
+ "@vue/cli-service": "^5.0.8",
+ "babel-eslint": "^10.1.0",
+ "eslint": "^7.32.0",
+ "eslint-plugin-vue": "^8.7.1",
+ "vue-template-compiler": "^2.7.16",
+ "sass": "^1.77.6",
+ "sass-loader": "^12.6.0"
+ }
+}
diff --git a/frontend/public/index.html b/frontend/public/index.html
new file mode 100644
index 0000000..1414c75
--- /dev/null
+++ b/frontend/public/index.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+ 推拉酸洗线二级系统
+
+
+
+ 请启用 JavaScript 以运行本系统
+
+
+
+
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
new file mode 100644
index 0000000..184c107
--- /dev/null
+++ b/frontend/src/App.vue
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/api/auth.js b/frontend/src/api/auth.js
new file mode 100644
index 0000000..f98c9a4
--- /dev/null
+++ b/frontend/src/api/auth.js
@@ -0,0 +1,5 @@
+import request from './request'
+
+export const login = data => request.post('/auth/login', data)
+export const getMe = () => request.get('/auth/me')
+export const createUser = data => request.post('/auth/users', data)
diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js
new file mode 100644
index 0000000..c17361d
--- /dev/null
+++ b/frontend/src/api/index.js
@@ -0,0 +1,64 @@
+import request from './request'
+
+// 看板
+export const getDashboardSummary = () => request.get('/dashboard/summary')
+
+// 物料跟踪
+export const getCoils = params => request.get('/material/coils', { params })
+export const getCoil = coilNo => request.get(`/material/coils/${coilNo}`)
+export const createCoil = data => request.post('/material/coils', data)
+export const updateCoil = (coilNo, data) => request.put(`/material/coils/${coilNo}`, data)
+export const getTracking = params => request.get('/material/tracking', { params })
+
+// 实绩管理
+export const getProductionRecords = params => request.get('/production/', { params })
+export const createProductionRecord = data => request.post('/production/', data)
+export const updateProductionRecord = (id, data) => request.put(`/production/${id}`, data)
+
+// 计划管理
+export const getPlans = params => request.get('/plan/', { params })
+export const createPlan = data => request.post('/plan/', data)
+export const updatePlan = (id, data) => request.put(`/plan/${id}`, data)
+export const confirmPlan = id => request.patch(`/plan/${id}/confirm`)
+
+// 停机管理
+export const getDowntimeCategories = () => request.get('/downtime/categories')
+export const getDowntimeRecords = params => request.get('/downtime/', { params })
+export const createDowntime = data => request.post('/downtime/', data)
+export const updateDowntime = (id, data) => request.put(`/downtime/${id}`, data)
+
+// 设备管理
+export const getEquipments = params => request.get('/equipment/', { params })
+export const createEquipment = data => request.post('/equipment/', data)
+export const updateEquipment = (id, data) => request.put(`/equipment/${id}`, data)
+export const getEquipmentMaintenance = (id, params) => request.get(`/equipment/${id}/maintenance`, { params })
+export const createMaintenance = data => request.post('/equipment/maintenance', data)
+
+// 报文监控
+export const getMessageLogs = params => request.get('/message/logs', { params })
+export const getMessageLog = id => request.get(`/message/logs/${id}`)
+
+// 工艺预测模型
+export const predictAcidSpeed = data => request.post('/prediction/acid-speed', data)
+export const predictTension = data => request.post('/prediction/tension', data)
+export const predictQuality = data => request.post('/prediction/quality', data)
+export const predictConsumption = data => request.post('/prediction/consumption', data)
+
+// 模型校准
+export const getCalibration = () => request.get('/prediction/calibration')
+export const calibrateAcidSpeed = data => request.post('/prediction/calibration/acid-speed', data)
+export const calibrateTension = data => request.post('/prediction/calibration/tension', data)
+export const calibrateQuality = data => request.post('/prediction/calibration/quality', data)
+export const resetCalibration = model => request.post(`/prediction/calibration/reset/${model}`)
+
+// 设备巡检
+export const getInspectionLocations = () => request.get('/inspection/locations')
+export const createInspectionLocation = data => request.post('/inspection/locations', data)
+export const getInspectionRecords = params => request.get('/inspection/records', { params })
+export const createInspectionRecord = data => request.post('/inspection/records', data)
+
+// 质量管理
+export const getQualityList = params => request.get('/quality/', { params })
+export const createQuality = data => request.post('/quality/', data)
+export const updateQuality = (id, data) => request.put(`/quality/${id}`, data)
+export const getQualitySummary = () => request.get('/quality/summary')
diff --git a/frontend/src/api/request.js b/frontend/src/api/request.js
new file mode 100644
index 0000000..b8360dd
--- /dev/null
+++ b/frontend/src/api/request.js
@@ -0,0 +1,38 @@
+import axios from 'axios'
+import { Message } from 'element-ui'
+import store from '@/store'
+import router from '@/router'
+
+const request = axios.create({
+ baseURL: '/api',
+ timeout: 15000,
+})
+
+request.interceptors.request.use(config => {
+ const token = store.getters['auth/token']
+ if (token) config.headers.Authorization = `Bearer ${token}`
+ return config
+})
+
+request.interceptors.response.use(
+ res => {
+ const data = res.data
+ if (data.code && data.code !== 200) {
+ Message.error(data.msg || '请求失败')
+ return Promise.reject(new Error(data.msg))
+ }
+ return data
+ },
+ err => {
+ if (err.response?.status === 401) {
+ store.dispatch('auth/logout')
+ router.push('/login')
+ Message.error('登录已过期,请重新登录')
+ } else {
+ Message.error(err.response?.data?.detail || err.message || '网络异常')
+ }
+ return Promise.reject(err)
+ }
+)
+
+export default request
diff --git a/frontend/src/assets/styles/global.scss b/frontend/src/assets/styles/global.scss
new file mode 100644
index 0000000..2bc4893
--- /dev/null
+++ b/frontend/src/assets/styles/global.scss
@@ -0,0 +1,260 @@
+@import './variables';
+
+:root {
+ --bg-primary: #{$bg-primary};
+ --bg-secondary: #{$bg-secondary};
+ --bg-card: #{$bg-card};
+ --bg-panel: #{$bg-panel};
+ --bg-input: #{$bg-input};
+ --border: #{$border};
+ --text-primary: #{$text-primary};
+ --text-secondary:#{$text-secondary};
+ --text-muted: #{$text-muted};
+ --accent-green: #{$accent-green};
+ --accent-yellow: #{$accent-yellow};
+ --accent-red: #{$accent-red};
+ --accent-cyan: #{$accent-cyan};
+ --sms-blue: #{$sms-blue};
+ --sms-highlight: #{$sms-highlight};
+ --status-run: #{$status-run};
+ --status-warn: #{$status-warn};
+ --status-fault: #{$status-fault};
+ --font-mono: #{$font-mono};
+}
+
+* { box-sizing: border-box; margin: 0; padding: 0; }
+
+body {
+ font-family: $font-main;
+ background: $bg-primary;
+ color: $text-primary;
+ font-size: 13px;
+}
+
+// ─── 卡片 ───
+.card {
+ background: $bg-card;
+ border: 1px solid $border;
+ border-radius: 6px;
+ overflow: hidden;
+
+ &-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 14px;
+ background: $bg-panel;
+ border-bottom: 1px solid $border;
+ font-size: 12px;
+ font-weight: 600;
+ color: $sms-highlight;
+ letter-spacing: .4px;
+
+ .ch-badge {
+ margin-left: auto;
+ font-size: 10px;
+ padding: 1px 8px;
+ border-radius: 8px;
+ background: rgba(0,200,255,.1);
+ color: $sms-highlight;
+ border: 1px solid rgba(0,200,255,.3);
+ }
+ }
+
+ &-body { padding: 12px 14px; }
+}
+
+// ─── 指标卡 ───
+.metric-box {
+ background: $bg-panel;
+ border: 1px solid $border;
+ border-radius: 5px;
+ padding: 10px 14px;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+
+ .mb-label { font-size: 11px; color: $text-secondary; }
+ .mb-value {
+ font-size: 22px;
+ font-family: $font-mono;
+ font-weight: 700;
+ color: $sms-highlight;
+ line-height: 1;
+ }
+ .mb-unit { font-size: 11px; color: $text-muted; }
+}
+
+// ─── 数据表格 ───
+.data-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12px;
+
+ th {
+ background: $bg-panel;
+ color: $text-secondary;
+ font-weight: 600;
+ padding: 7px 10px;
+ text-align: left;
+ border-bottom: 1px solid $border;
+ white-space: nowrap;
+ }
+
+ td {
+ padding: 6px 10px;
+ border-bottom: 1px solid rgba(48,54,61,.5);
+ color: $text-primary;
+ font-family: $font-mono;
+ }
+
+ tr:hover td { background: rgba(255,255,255,.02); }
+
+ .td-num { color: $sms-highlight; }
+ .td-ok { color: $accent-green; }
+ .td-warn { color: $accent-yellow; }
+ .td-err { color: $accent-red; }
+ .td-muted{ color: $text-muted; }
+}
+
+.table-scroll {
+ overflow-x: auto;
+ &::-webkit-scrollbar { height: 4px; }
+ &::-webkit-scrollbar-thumb { background: $border; }
+}
+
+// ─── Badge ───
+.badge {
+ display: inline-block;
+ padding: 1px 8px;
+ border-radius: 8px;
+ font-size: 11px;
+ font-weight: 600;
+
+ &-green { background: #1a3a1f; color: $accent-green; border: 1px solid $accent-green; }
+ &-yellow { background: #3a2a00; color: $accent-yellow; border: 1px solid $accent-yellow; }
+ &-red { background: #3a0a0a; color: $accent-red; border: 1px solid $accent-red; }
+ &-blue { background: rgba(0,120,212,.15); color: $sms-highlight; border: 1px solid rgba(0,200,255,.3); }
+ &-gray { background: #222; color: $text-muted; border: 1px solid $border; }
+}
+
+// ─── 按钮 ───
+.btn {
+ padding: 5px 14px;
+ border-radius: 4px;
+ border: 1px solid;
+ font-size: 12px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all .15s;
+ user-select: none;
+ font-family: $font-main;
+
+ &-primary { background: $sms-blue; border-color: $sms-blue; color: #fff; &:hover { background: #1086e0; } }
+ &-success { background: #1a3a1f; border-color: $accent-green; color: $accent-green; &:hover { background: $accent-green; color: #000; } }
+ &-danger { background: #3a0a0a; border-color: $accent-red; color: $accent-red; }
+ &-outline { background: transparent; border-color: $border; color: $text-secondary; &:hover { border-color: $sms-highlight; color: $sms-highlight; } }
+ &.fw { width: 100%; }
+}
+
+// ─── 输入框 ───
+.kv-input {
+ background: $bg-input;
+ border: 1px solid $border;
+ border-radius: 3px;
+ color: $text-primary;
+ font-family: $font-mono;
+ font-size: 12px;
+ padding: 3px 7px;
+ width: 100%;
+ outline: none;
+ transition: border-color .15s;
+ &:focus { border-color: $accent-blue; }
+}
+
+// ─── KV 参数行 ───
+.kv-grid { display: grid; grid-template-columns: auto 1fr; gap: 4px 14px; align-items: center; }
+.kv-label { color: $text-secondary; font-size: 12px; white-space: nowrap; }
+.kv-value { font-family: $font-mono; font-size: 12px; color: $sms-highlight; font-weight: 600; }
+.kv-unit { color: $text-muted; font-size: 11px; }
+
+// ─── 进度条 ───
+.prog-bar-wrap { background: #111; border-radius: 3px; height: 6px; overflow: hidden; }
+.prog-bar-fill { height: 100%; border-radius: 3px; transition: width .4s; }
+
+// ─── 分区标题 ───
+.sec-title {
+ font-size: 11px;
+ font-weight: 700;
+ color: $text-muted;
+ text-transform: uppercase;
+ letter-spacing: 1.2px;
+ margin-bottom: 8px;
+ padding-bottom: 4px;
+ border-bottom: 1px solid $border;
+}
+
+// ─── Grid helpers ───
+.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
+.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 14px; }
+.grid-4 { display: grid; grid-template-columns: repeat(4,1fr); gap: 14px; }
+.grid-5 { display: grid; grid-template-columns: repeat(5,1fr); gap: 14px; }
+.section-row { display: flex; gap: 14px; > .card { flex: 1; min-width: 0; } }
+.flex-row { display: flex; gap: 10px; align-items: center; }
+.flex-col { display: flex; flex-direction: column; gap: 8px; }
+.flex-between{ display: flex; justify-content: space-between; align-items: center; }
+.mt8 { margin-top: 8px; }
+.mt12 { margin-top: 12px; }
+.fw { width: 100%; }
+
+// ─── Element UI 暗色覆写 ───
+.el-dialog {
+ background: $bg-card !important;
+ border: 1px solid $border !important;
+ border-radius: 6px !important;
+
+ &__header { background: $bg-panel; border-bottom: 1px solid $border; padding: 12px 16px; }
+ &__title { color: $sms-highlight !important; font-size: 13px; font-weight: 600; }
+ &__headerbtn .el-dialog__close { color: $text-secondary !important; }
+ &__body { background: $bg-card; color: $text-primary; padding: 16px; }
+ &__footer { background: $bg-panel; border-top: 1px solid $border; padding: 10px 16px; }
+}
+
+.el-form-item__label { color: $text-secondary !important; font-size: 12px; }
+
+.el-input__inner, .el-textarea__inner, .el-select .el-input__inner {
+ background: $bg-input !important;
+ border-color: $border !important;
+ color: $text-primary !important;
+ font-family: $font-mono;
+ font-size: 12px;
+ &:focus { border-color: $sms-blue !important; }
+}
+
+.el-select-dropdown {
+ background: $bg-panel !important;
+ border-color: $border !important;
+ .el-select-dropdown__item { color: $text-secondary; &.selected, &:hover { color: $sms-highlight; background: rgba(0,200,255,.08); } }
+}
+
+.el-date-editor .el-range-input,
+.el-date-editor .el-range-separator { background: transparent !important; color: $text-secondary !important; }
+
+.el-pagination {
+ .el-pager li { background: $bg-panel; color: $text-secondary; border: 1px solid $border;
+ &.active { color: $sms-highlight; border-color: $sms-highlight; } }
+ button { background: $bg-panel !important; color: $text-secondary !important; border: 1px solid $border; }
+}
+
+.el-input-number .el-input__inner { text-align: left; }
+
+.el-radio__label { color: $text-secondary; font-size: 12px; }
+.el-radio__inner { background: $bg-input; border-color: $border; }
+.el-radio__input.is-checked .el-radio__inner { background: $sms-blue; border-color: $sms-blue; }
+
+.el-message-box {
+ background: $bg-card !important;
+ border-color: $border !important;
+ &__title { color: $text-primary !important; }
+ &__content { color: $text-secondary !important; }
+}
diff --git a/frontend/src/assets/styles/variables.scss b/frontend/src/assets/styles/variables.scss
new file mode 100644
index 0000000..b5c4b1d
--- /dev/null
+++ b/frontend/src/assets/styles/variables.scss
@@ -0,0 +1,31 @@
+// ─── 色彩系统(与参考HTML完全一致)───
+$bg-primary: #0d1117;
+$bg-secondary: #161b22;
+$bg-card: #1c2230;
+$bg-panel: #212936;
+$bg-input: #0d1117;
+$border: #30363d;
+$border-active: #1f6feb;
+
+$text-primary: #e6edf3;
+$text-secondary: #8b949e;
+$text-muted: #6e7681;
+
+$accent-blue: #1f6feb;
+$accent-cyan: #00b4d8;
+$accent-green: #28a745;
+$accent-yellow: #f0a500;
+$accent-orange: #e05a00;
+$accent-red: #da3633;
+$accent-purple: #8957e5;
+
+$sms-blue: #0078d4;
+$sms-highlight: #00c8ff;
+
+$status-run: #28a745;
+$status-stop: #6e7681;
+$status-warn: #f0a500;
+$status-fault: #da3633;
+
+$font-main: 'Segoe UI', 'Microsoft YaHei', sans-serif;
+$font-mono: 'Consolas', 'Courier New', monospace;
diff --git a/frontend/src/main.js b/frontend/src/main.js
new file mode 100644
index 0000000..b7bf12d
--- /dev/null
+++ b/frontend/src/main.js
@@ -0,0 +1,30 @@
+import Vue from 'vue'
+import App from './App.vue'
+import router from './router'
+import store from './store'
+import ElementUI from 'element-ui'
+import 'element-ui/lib/theme-chalk/index.css'
+import NProgress from 'nprogress'
+import 'nprogress/nprogress.css'
+
+Vue.use(ElementUI, { size: 'small', zIndex: 3000 })
+
+Vue.config.productionTip = false
+
+// 路由守卫
+router.beforeEach((to, from, next) => {
+ NProgress.start()
+ const token = store.getters['auth/token']
+ if (to.meta.requiresAuth && !token) {
+ next('/login')
+ } else {
+ next()
+ }
+})
+router.afterEach(() => NProgress.done())
+
+new Vue({
+ router,
+ store,
+ render: h => h(App),
+}).$mount('#app')
diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js
new file mode 100644
index 0000000..adfe943
--- /dev/null
+++ b/frontend/src/router/index.js
@@ -0,0 +1,100 @@
+import Vue from 'vue'
+import VueRouter from 'vue-router'
+
+Vue.use(VueRouter)
+
+const routes = [
+ {
+ path: '/login',
+ name: 'Login',
+ component: () => import('@/views/Login.vue'),
+ meta: { title: '登录' }
+ },
+ {
+ path: '/',
+ component: () => import('@/views/Layout.vue'),
+ meta: { requiresAuth: true },
+ redirect: '/dashboard',
+ children: [
+ {
+ path: 'dashboard',
+ name: 'Dashboard',
+ component: () => import('@/views/Dashboard.vue'),
+ meta: { title: '生产看板', icon: 'el-icon-monitor', requiresAuth: true }
+ },
+ {
+ path: 'material',
+ name: 'Material',
+ component: () => import('@/views/Material.vue'),
+ meta: { title: '物料跟踪', icon: 'el-icon-box', requiresAuth: true }
+ },
+ {
+ path: 'production',
+ name: 'Production',
+ component: () => import('@/views/Production.vue'),
+ meta: { title: '实绩管理', icon: 'el-icon-data-analysis', requiresAuth: true }
+ },
+ {
+ path: 'plan',
+ name: 'Plan',
+ component: () => import('@/views/Plan.vue'),
+ meta: { title: '计划管理', icon: 'el-icon-date', requiresAuth: true }
+ },
+ {
+ path: 'downtime',
+ name: 'Downtime',
+ component: () => import('@/views/Downtime.vue'),
+ meta: { title: '停机管理', icon: 'el-icon-warning-outline', requiresAuth: true }
+ },
+ {
+ path: 'equipment',
+ name: 'Equipment',
+ component: () => import('@/views/Equipment.vue'),
+ meta: { title: '设备管理', icon: 'el-icon-set-up', requiresAuth: true }
+ },
+ {
+ path: 'message',
+ name: 'Message',
+ component: () => import('@/views/Message.vue'),
+ meta: { title: '报文监控', icon: 'el-icon-connection', requiresAuth: true }
+ },
+ {
+ path: 'process-model',
+ name: 'ProcessModel',
+ component: () => import('@/views/ProcessModel.vue'),
+ meta: { title: '工艺段模型', icon: 'el-icon-cpu', requiresAuth: true }
+ },
+ {
+ path: 'tension-model',
+ name: 'TensionModel',
+ component: () => import('@/views/TensionModel.vue'),
+ meta: { title: '张力设定', icon: 'el-icon-odometer', requiresAuth: true }
+ },
+ {
+ path: 'inspection',
+ name: 'Inspection',
+ component: () => import('@/views/Inspection.vue'),
+ meta: { title: '设备巡检', requiresAuth: true }
+ },
+ {
+ path: 'quality',
+ name: 'Quality',
+ component: () => import('@/views/Quality.vue'),
+ meta: { title: '质量管理', icon: 'el-icon-medal', requiresAuth: true }
+ },
+ {
+ path: 'capacity',
+ name: 'Capacity',
+ component: () => import('@/views/Capacity.vue'),
+ meta: { title: '产能分析', icon: 'el-icon-s-data', requiresAuth: true }
+ },
+ ]
+ },
+ { path: '*', redirect: '/' }
+]
+
+export default new VueRouter({
+ mode: 'history',
+ base: process.env.BASE_URL,
+ routes
+})
diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js
new file mode 100644
index 0000000..87f4367
--- /dev/null
+++ b/frontend/src/store/index.js
@@ -0,0 +1,10 @@
+import Vue from 'vue'
+import Vuex from 'vuex'
+import auth from './modules/auth'
+
+Vue.use(Vuex)
+
+export default new Vuex.Store({
+ modules: { auth },
+ strict: process.env.NODE_ENV !== 'production'
+})
diff --git a/frontend/src/store/modules/auth.js b/frontend/src/store/modules/auth.js
new file mode 100644
index 0000000..f4b6060
--- /dev/null
+++ b/frontend/src/store/modules/auth.js
@@ -0,0 +1,45 @@
+import { login, getMe } from '@/api/auth'
+
+const TOKEN_KEY = 'mes_token'
+
+export default {
+ namespaced: true,
+ state: {
+ token: localStorage.getItem(TOKEN_KEY) || '',
+ user: null,
+ },
+ getters: {
+ token: s => s.token,
+ user: s => s.user,
+ isLoggedIn: s => !!s.token,
+ },
+ mutations: {
+ SET_TOKEN(state, token) {
+ state.token = token
+ localStorage.setItem(TOKEN_KEY, token)
+ },
+ SET_USER(state, user) {
+ state.user = user
+ },
+ LOGOUT(state) {
+ state.token = ''
+ state.user = null
+ localStorage.removeItem(TOKEN_KEY)
+ },
+ },
+ actions: {
+ async login({ commit }, { username, password }) {
+ const res = await login({ username, password })
+ commit('SET_TOKEN', res.data.access_token)
+ commit('SET_USER', { username: res.data.username, role: res.data.role })
+ return res
+ },
+ async fetchMe({ commit }) {
+ const res = await getMe()
+ commit('SET_USER', res.data)
+ },
+ logout({ commit }) {
+ commit('LOGOUT')
+ },
+ }
+}
diff --git a/frontend/src/views/Capacity.vue b/frontend/src/views/Capacity.vue
new file mode 100644
index 0000000..5db4ef4
--- /dev/null
+++ b/frontend/src/views/Capacity.vue
@@ -0,0 +1,413 @@
+
+
+
产能分析
+
+
+
+
+
+
本年实际完成
+
{{ kpi.actual_yt }}
+
万吨
+
+
+
完成率
+
+ {{ kpi.completion_rate }}
+
+
%
+
+
+
日均产量
+
{{ kpi.daily_avg }}
+
吨/天
+
+
+
OEE
+
+ {{ kpi.oee }}
+
+
%
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ consLoading ? '计算中...' : '预测消耗' }}
+
+
+
+
+
预测结果
+
+ 盐酸消耗
+ {{ consResult.acid_consumption_kg }} kg ({{ consResult.acid_unit_kg_per_t }} kg/t)
+ 蒸汽消耗
+ {{ consResult.steam_consumption_kg }} kg ({{ consResult.steam_unit_kg_per_t }} kg/t)
+ 电力消耗
+ {{ consResult.power_consumption_kwh }} kWh ({{ consResult.power_unit_kwh_per_t }} kWh/t)
+ 冷却水
+ {{ consResult.cooling_water_m3 }} m³ ({{ consResult.cooling_water_unit_m3_per_t }} m³/t)
+ 计划产量
+ {{ consResult.coil_weight_t }} t
+
+
+
+
单耗指标对比
+
+
{{ item.label }}
+
+
{{ item.val }} {{ item.unit }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue
new file mode 100644
index 0000000..815ba79
--- /dev/null
+++ b/frontend/src/views/Dashboard.vue
@@ -0,0 +1,246 @@
+
+
+
+
+
+
{{ c.label }}
+
{{ c.value }}
+
{{ c.unit }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 停机类别 次数 时长(min)
+
+
+ {{ d.name }}
+ {{ d.count }}
+ {{ d.duration }}
+
+
+ 今日无停机记录
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/Downtime.vue b/frontend/src/views/Downtime.vue
new file mode 100644
index 0000000..d13f4aa
--- /dev/null
+++ b/frontend/src/views/Downtime.vue
@@ -0,0 +1,208 @@
+
+
+
+
+
+
+ 停机类别
+
+ 全部
+ {{ c.name }}
+
+
+
+ 类型
+
+ 全部
+ 计划停机
+ 非计划
+
+
+
+ 日期
+
+ ~
+
+
+
+ 查询
+ + 新增停机
+
+
+ 今日停机 {{ totalDuration }} min
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/Equipment.vue b/frontend/src/views/Equipment.vue
new file mode 100644
index 0000000..6d2905d
--- /dev/null
+++ b/frontend/src/views/Equipment.vue
@@ -0,0 +1,265 @@
+
+
+
+
+
+
+ 设备名称
+
+
+
+ 状态
+
+ 全部
+ {{ s.label }}
+
+
+
+ 查询
+ + 新增设备
+
+
+
+
+ {{ s.label }} {{ s.count }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 新增维保
+
+ 类型 标题 开始 结束 工时(h) 费用(元) 执行人 结果
+
+
+ {{ m.maintenance_type }}
+ {{ m.title }}
+ {{ fmtTime(m.start_time) }}
+ {{ fmtTime(m.end_time) }}
+ {{ m.duration || '—' }}
+ {{ m.cost || '—' }}
+ {{ m.technician || '—' }}
+
+
+ {{ m.result === 'pass' ? '通过' : m.result === 'fail' ? '失败' : '待确认' }}
+
+
+
+ 暂无记录
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/Inspection.vue b/frontend/src/views/Inspection.vue
new file mode 100644
index 0000000..783a55f
--- /dev/null
+++ b/frontend/src/views/Inspection.vue
@@ -0,0 +1,376 @@
+
+
+
+
+
+
请选择点位
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/Layout.vue b/frontend/src/views/Layout.vue
new file mode 100644
index 0000000..1feef9e
--- /dev/null
+++ b/frontend/src/views/Layout.vue
@@ -0,0 +1,246 @@
+
+
+
+
+
+
SMS L2
+
推拉酸洗线 L2 过程控制系统 | PUSH-PULL PICKLING LINE
+
+
+ ● 机组运行
+ ● L2在线
+ {{ l3StatusText }}
+
+
+ {{ user && (user.full_name || user.username) }}
+ |
+ 退出
+
+
{{ clock }}
+
+
+
+
+
+ {{ item.icon }}
+ {{ item.title }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue
new file mode 100644
index 0000000..ddbe61f
--- /dev/null
+++ b/frontend/src/views/Login.vue
@@ -0,0 +1,158 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/views/Material.vue b/frontend/src/views/Material.vue
new file mode 100644
index 0000000..553b901
--- /dev/null
+++ b/frontend/src/views/Material.vue
@@ -0,0 +1,271 @@
+
+
+
+
+
+
+
+ 卷号
+
+
+
+ 状态
+
+ 全部
+ {{ s.label }}
+
+
+
+ 查询
+ + 新增钢卷
+
+
+ 共 {{ total }} 条
+
+
+
+
+
+
+
+
+
+
+
+
+ 上一页
+ 第 {{ query.page }} 页 / 共 {{ Math.ceil(total/query.page_size) }} 页
+ 下一页
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 时间 位置 事件类型 描述 实测厚度 速度 操作员
+
+
+
+ {{ fmtTime(t.event_time) }}
+ {{ t.position || '—' }}
+ {{ t.event_type }}
+ {{ t.event_desc || '—' }}
+ {{ t.actual_thickness || '—' }}
+ {{ t.speed || '—' }}
+ {{ t.operator || '—' }}
+
+
+ 暂无跟踪记录
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/Message.vue b/frontend/src/views/Message.vue
new file mode 100644
index 0000000..659e3e1
--- /dev/null
+++ b/frontend/src/views/Message.vue
@@ -0,0 +1,167 @@
+
+
+
+
+
+
+ 报文类型
+
+
+
+ 方向
+
+ 全部
+ 接收
+ 发送
+
+
+
+ 状态
+
+ 全部
+ 成功
+ 失败
+
+
+
+ 查询
+ ↺ 刷新
+
+
+
+ 成功 {{ successCount }}
+ 失败 {{ errorCount }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 报文类型 {{ currentLog.msg_type }}
+ 接收时间 {{ fmtTime(currentLog.received_at) }}
+ 状态
+ {{ currentLog.status }}
+
+
原始报文(HEX)
+
{{ currentLog.raw_data || '—' }}
+
解析结果(JSON)
+
{{ formatJson(currentLog.parsed_data) }}
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/PDI.vue b/frontend/src/views/PDI.vue
new file mode 100644
index 0000000..560d777
--- /dev/null
+++ b/frontend/src/views/PDI.vue
@@ -0,0 +1,360 @@
+
+
+
+
+
+
+
+ 卷号
+
+
+
+ L3状态
+
+ 全部
+ 待下发
+ 已发送
+ 已确认
+ 已取消
+
+
+
+ L2状态
+
+ 全部
+ 待处理
+ 处理中
+ 已完成
+
+
+
+ 查询
+ + 新增PDI
+
+
+
+
+
+
+
+
+
PDI总数
+
{{ stats.total }}
+
条记录
+
+
+
待处理
+
{{ stats.pending }}
+
L2待确认
+
+
+
已确认
+
{{ stats.confirmed }}
+
L3已确认
+
+
+
处理中
+
{{ stats.processing }}
+
酸洗进行中
+
+
+
+
+
+
+
+
+
+ 上一页
+ 第 {{ query.page }} / {{ Math.ceil(total/query.page_size) }} 页
+ 下一页
+
+
+
+
+
+
+
+
+
+
基本信息
+
+
+
规格尺寸
+
+
+
力学性能
+
+
+
状态 / 备注
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/Plan.vue b/frontend/src/views/Plan.vue
new file mode 100644
index 0000000..1c90f78
--- /dev/null
+++ b/frontend/src/views/Plan.vue
@@ -0,0 +1,203 @@
+
+
+
+
+
+
+ 状态
+
+ 全部
+ {{ s.label }}
+
+
+
+ 日期
+
+ ~
+
+
+
+ 查询
+ + 新增计划
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/ProcessModel.vue b/frontend/src/views/ProcessModel.vue
new file mode 100644
index 0000000..9ec5807
--- /dev/null
+++ b/frontend/src/views/ProcessModel.vue
@@ -0,0 +1,546 @@
+
+
+
+
🧪 酸洗工艺段模型
+
+ 刷新时间:{{ lastRefresh }}
+
+ {{ l1Online ? 'L1在线' : '无L1数据' }}
+
+
+
+
+
+
酸槽 1#–3#
+
+
+
+
+
+ HCl 浓度
+ {{ tanks[i].conc != null ? tanks[i].conc : '—' }} g/L
+ 温度
+ {{ tanks[i].temp != null ? tanks[i].temp : '—' }} °C
+ Fe²⁺ 含量
+ {{ tanks[i].fe2 != null ? tanks[i].fe2 : '—' }} g/L
+ 停留时间
+ {{ tanks[i].rt != null ? tanks[i].rt : '—' }} s
+
+
+
+ 酸洗效率(模型估算)
+ {{ tanks[i].eff }}%
+
+
+
+
等待 L1 数据
+
+
+
+
+
+
酸槽 4#–6#
+
+
+
+
+
+ HCl 浓度
+ {{ tanks[i].conc != null ? tanks[i].conc : '—' }} g/L
+ 温度
+ {{ tanks[i].temp != null ? tanks[i].temp : '—' }} °C
+ Fe²⁺ 含量
+ {{ tanks[i].fe2 != null ? tanks[i].fe2 : '—' }} g/L
+ 停留时间
+ {{ tanks[i].rt != null ? tanks[i].rt : '—' }} s
+
+
+
+ 酸洗效率(模型估算)
+ {{ tanks[i].eff }}%
+
+
+
+
等待 L1 数据
+
+
+
+
+
+
漂洗段(5级逆流)
+
+
+
+
+
+ 漂洗段数据来自 L1 实时报文,当前无 L1 连接
+
+
+
+
+
+
+
+
+
+
+
+
各槽酸液参数
+
+ {{ l1Online ? '已从 L1 报文同步' : '手动输入(L1 上线后自动同步)' }}
+
+
+
+
+
计算中...
+
+
+
+
+
+
+
+
+
+
最大允许速度
+
{{ calcResult.max_speed }}
+
m/min
+
+
+
综合酸洗指数
+
{{ calcResult.total_pi }}
+
%
+
+
+
{{ calcResult.warning }}
+
各槽酸洗详情
+
+
+ 酸槽 停留时间 (s) 累计PI (%) 进度
+
+
+
+ {{ idx+1 }}# 槽
+ {{ calcResult.residence_time_per_tank[idx] }}
+ {{ pi }}
+
+
+
+
+
+
+
+
+
+
+
[ LOADING ]
+
{{ calculating ? '模型计算中...' : '正在加载...' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
修正记录
+
+
+ 时间 K 前 K 后 实测速度 质量 备注
+
+
+
+ {{ h.ts.slice(5,16) }}
+ {{ h.k_before }}
+ {{ h.k_after }}
+ {{ h.input.actual_speed }} m/min
+ {{ h.input.quality_ok ? '合格' : '欠酸洗' }}
+ {{ h.note || '—' }}
+
+
+ 暂无修正记录
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
当前线速
+
{{ current.speed }}
+
m/min
+
+
+
入口张力
+
{{ current.tension_inlet }}
+
kN
+
+
+
出口张力
+
{{ current.tension_outlet }}
+
kN
+
+
+
1#槽酸液温度
+
{{ current.acid_temp }}
+
°C
+
+
+
当前卷号
+
{{ current.coil_no || '—' }}
+
在线
+
+
+
+
[ NO SIGNAL ]
+
当前无 L1 实时数据。机组启动并接入 UDP 报文后自动显示。
+
L2 监听地址:0.0.0.0:9000
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/Production.vue b/frontend/src/views/Production.vue
new file mode 100644
index 0000000..2877802
--- /dev/null
+++ b/frontend/src/views/Production.vue
@@ -0,0 +1,200 @@
+
+
+
+
+
+
+ 卷号
+
+
+
+ 班次
+
+ 全部
+ {{ s }}班
+
+
+
+ 开始日期
+
+ ~
+
+
+
+ 查询
+ + 新增
+
+
+
+
+
+
+
+
+
+
+ 上一页
+ 第 {{ query.page }} / {{ Math.ceil(total/query.page_size) }} 页
+ 下一页
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/Quality.vue b/frontend/src/views/Quality.vue
new file mode 100644
index 0000000..2f78b65
--- /dev/null
+++ b/frontend/src/views/Quality.vue
@@ -0,0 +1,407 @@
+
+
+
+
+
+
+
+ 卷号
+
+
+
+ 质量等级
+
+ 全部
+ {{ g }}
+
+
+
+ 日期
+
+ ~
+
+
+
+ 查询
+ + 新增检验
+
+
+
+
+
+
+
+
+
合格率
+
+ {{ summary.pass_rate }}
+
+
%
+
+
+
平均PI评分
+
{{ summary.avg_pi_score }}
+
/ 100
+
+
+
平均表面评分
+
{{ summary.avg_surface_score }}
+
/ 100
+
+
+
记录总数
+
{{ summary.total }}
+
条
+
+
+
+
+
+
+
+
+
+
+
+ 上一页
+ 第 {{ query.page }} / {{ Math.ceil(total/query.page_size) }} 页
+ 下一页
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ predLoading ? '预测中...' : '质量预测' }}
+
+
+
+
+
+
+ PI评分
+ {{ predResult.pi_score }}
+ 表面评分
+ {{ predResult.surface_score }}
+ 综合等级
+
+
+ {{ predResult.overall_grade }}
+
+
+
+
工艺建议
+
+ 💡 {{ r }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/TensionModel.vue b/frontend/src/views/TensionModel.vue
new file mode 100644
index 0000000..555f2c9
--- /dev/null
+++ b/frontend/src/views/TensionModel.vue
@@ -0,0 +1,527 @@
+
+
+
张力设定模型
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ loading ? '计算中...' : '计算张力' }}
+
+
加载默认参数
+
+
+
+
+
+
+ T_max
+ {{ result.T_max }} kN
+ 截面积
+ {{ result.cross_section_mm2 }} mm²
+ 焊缝速限
+ {{ result.weld_speed_limit }} m/min
+ 加速附加张力
+ {{ result.accel_tension }} kN
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ rollerIcon(key) }}
+
+
{{ zone.name_cn }}
+
+ {{ zone.tension_kN }} kN
+
+
×{{ zone.ratio }}
+
+
+
+
+ 输入参数后点击「计算张力」查看张力分布
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
修正记录
+
+
+ 时间 K 前 K 后 位置 实测(kN) 预测(kN) 备注
+
+
+
+ {{ h.ts.slice(5,16) }}
+ {{ h.k_before }}
+ {{ h.k_after }}
+ {{ h.input.zone }}
+ {{ h.input.measured_kn }}
+ {{ h.input.predicted_kn }}
+ {{ h.note || '—' }}
+
+
+ 暂无修正记录
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/vue.config.js b/frontend/vue.config.js
new file mode 100644
index 0000000..76baeb2
--- /dev/null
+++ b/frontend/vue.config.js
@@ -0,0 +1,15 @@
+const { defineConfig } = require('@vue/cli-service')
+
+module.exports = defineConfig({
+ lintOnSave: false,
+ transpileDependencies: true,
+ devServer: {
+ port: 8080,
+ proxy: {
+ '/api': {
+ target: 'http://localhost:8000',
+ changeOrigin: true,
+ }
+ }
+ }
+})
diff --git a/push_pull_pickling_l2(1).html b/push_pull_pickling_l2(1).html
new file mode 100644
index 0000000..f48f60f
--- /dev/null
+++ b/push_pull_pickling_l2(1).html
@@ -0,0 +1,2263 @@
+
+
+
+
+
+推拉酸洗线 L2 过程控制系统
+
+
+
+
+
+
+
SMS X-Pact
+
推拉酸洗线 L2 过程控制系统 | PUSH-PULL PICKLING LINE
+
+
+ ● 机组运行
+ ● L2在线
+ ⚠ L3待机
+
+
--:--:--
+
+
+
+
+
🎯 带头带尾跟踪
+
+
⚡ 张力设定
+
+
🧪 工艺段模型
+
+
📐 五辊矫直机
+
+
📈 趋势报表
+
+
📋 PDI管理
+
+
📅 L3排产计划
+
+
🏭 产能分析
+
+
🎯 绩效KPI
+
+
✅ 质量报表
+
+
⭐ 质量评级
+
+
⚡ 能源消耗
+
+
🧴 原料消耗
+
+
🔧 辅料易损件
+
+
🗓 设备排产分析
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ■ 入口夹送辊
+ ■ 机械清洗
+ ■ 酸洗槽(1/2/3)
+ ■ 漂洗/钝化
+ ■ 矫直机
+ ■ 出口夹送辊
+ ▶ 带头
+ ◀ 带尾
+
+
+
+
+
+
+
+
+
+
+
+
+ 工艺段
+ 段长(m)
+ 段起点(m)
+ 带头状态
+ 带尾状态
+ 带头到达时间
+ 带尾通过时间
+ 限速(m/min)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 序号 钢卷号 厚(mm) 穿带开始 带头出口时间 带尾离夹时间 穿带速度 穿带时长(s) 状态
+
+ 1 C2024115 2.50 10:05:12 10:07:48 10:08:02 20 m/min 170 成功
+ 2 C2024114 2.00 09:42:33 09:45:01 09:45:18 22 m/min 165 成功
+ 3 C2024113 3.00 09:18:05 09:21:02 09:21:25 18 m/min 200 慢速
+ 4 C2024112 2.50 08:55:14 08:58:06 08:58:20 20 m/min 186 成功
+
+
+
+
+
+
+
+
+
+
+
+
+
带钢规格输入
+
+
张力图示
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 截面积 A —
+ 最大张力 T_max —
+ 酸槽段张力 —
+ 出口段张力 —
+ 比张力 —
+ 单位张力 —
+
+
+ 重新计算
+
+
+
张力限制
+
+ 焊缝减速张力 —
+ 加速段张力 —
+
+
+
+
+
+
+
+
+
+ 钢卷号 厚(mm) 宽(mm) σ₀(MPa) T_入(kN) T_酸(kN) T_出(kN) 焊缝速限 状态
+
+ C2024115 2.50 1000 380 23.8 19.0 21.4 40 m/min 激活
+ C2024116 2.00 1050 350 18.4 14.7 16.5 45 m/min 等待
+ C2024117 3.00 900 420 28.4 22.7 25.5 35 m/min 等待
+
+
+
+
+
+
+
+
+
+
+
+
+
+ HCl 浓度 178 g/L
+ 酸液温度 72 °C
+ Fe²⁺ 含量 82 g/L
+ 槽液液位 95 %
+ 酸洗速度 85 m/min
+ 停留时间 12.4 s
+
+
+
+
+
+
+
+
+ HCl 浓度 165 g/L
+ 酸液温度 74 °C
+ Fe²⁺ 含量 95 g/L
+ 槽液液位 93 %
+ 酸洗速度 85 m/min
+ 停留时间 12.4 s
+
+
+
酸洗效果指数
+
+
76% — 注意浓度
+
+
+
+
+
+
+
+ HCl 浓度 150 g/L
+ 酸液温度 75 °C
+ Fe²⁺ 含量 110 g/L
+ 槽液液位 91 %
+ 酸洗速度 85 m/min
+ 停留时间 12.4 s
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 漂洗段1 pH 2.8
+ 漂洗段2 pH 4.1
+ 漂洗段3 pH 6.2
+ 漂洗水温度 55 °C
+ 漂洗水流量 12.4 m³/h
+ 出口残酸量 0.12 g/m²
+
+
+
+
+
+
+
+ 刷辊压力 18 kN
+ 刷辊转速 420 rpm
+ 清洗液浓度 3.2 %
+ 清洗液温度 60 °C
+ 清洗液流量 8.5 m³/h
+ 清洗段运行 正常
+
+
+
+
+
+
+
+ 钝化液浓度 1.8 %
+ 钝化液温度 40 °C
+ 涂覆量 0.05 g/m²
+ 干燥温度 120 °C
+ 干燥时间 8 s
+ 钝化段运行 正常
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ R2
+
+ R4
+
+
+ R1
+
+ R3
+
+ R5
+
+ 入口
+ 出口
+
+
+
+
+
+
+
+
+
+
+
+
+
+ δ=1.2mm
+ δ=0.8mm
+ 固定
+ 固定
+ 固定
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ⏸ 暂停
+
+ 1 min 5 min 15 min 1 hr
+
+
+
+
+
+
+
+
+
+
+
+
+ 当前: 85.3 m/min
+ 均值: 82.1 m/min
+
+
+
+
+
+
+
+
+ 当前: 19.2 kN
+ 均值: 18.8 kN
+
+
+
+
+
+
+
+
+ 当前: 178 g/L
+ 均值: 174 g/L
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 钢卷号 钢种 厚(mm) 宽(mm) 重(t) σ₀(MPa) Rm(MPa) A(%) 工艺路径 状态
+
+ C2024115 SPCC 2.50 1000 18.2 380 520 28 P1+P2+P3 在线
+ C2024116 SPHC 2.00 1050 15.5 350 480 32 P1+P2+P3 等待
+ C2024117 SS400 3.00 900 21.0 420 560 22 P1+P2+P3+LV 等待
+ C2024118 SPCC 1.80 1200 14.8 320 440 36 P1+P2 等待
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 序号 钢卷号 客户 钢种 厚×宽(mm) 重(t) 计划时间 优先级 L3状态 L2确认
+
+ 1 C2024115 客户A SPCC 2.50×1000 18.2 08:00 高 已发送 已确认
+ 2 C2024116 客户B SPHC 2.00×1050 15.5 10:30 中 已发送 待确认
+ 3 C2024117 客户A SS400 3.00×900 21.0 13:00 中 已发送 未确认
+ 4 C2024118 客户C SPCC 1.80×1200 14.8 15:30 低 待发送 —
+ 5 C2024119 客户D Q235B 2.20×1100 17.0 17:00 低 待发送 —
+
+
+
+
+
+
+
+
+ L3连接状态 待机
+ 最后接收时间 07:58:22
+ 今日计划总量 5 卷
+ 已确认 1 卷
+ 待确认 1 卷
+ 未发送 2 卷
+ 计划总重 86.5 t
+ 计划完成率 20 %
+
+
+ 刷新计划
+ 全部确认
+
+
+
+
+
+
+
+
+
+
+
+
+ 08:00
+ 11:00
+ 14:00
+ 17:00
+ 20:00
+
+
+
+
+
+
+ C2024115 SPCC 2.5×1000 (18.2t)
+
+ C2024116 SPHC 2.0×1050
+
+ C2024117 SS400 3.0×900
+
+ C2024118 (待确认)
+
+ C2024119 (待发)
+
+
+ 现在
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 停机原因 时长(min) 占比
+
+ 换卷对焊 62
+ 换辊作业 35
+ 设备故障 18
+ 换酸作业 12
+ 其他 3
+
+
+
+
+
+
+
+
+
+
+
+
OEE 综合效率
+
82.3
+
%
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 指标 本月 目标 达成
+
+ 日均产量 380 t 400 t 95%
+ 酸洗质量合格率 99.1% 99% 达标
+ 吨钢酸耗 8.2 kg/t 8.0 kg/t 注意
+ 设备故障率 1.2% 2.0% 达标
+ 换辊次数 12 次 12 次 达标
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 钢卷号 钢种 表面等级 残酸(g/m²) 残氧(ppm) 平整度(IU) 镰刀弯(mm/m) 综合评级
+
+ C2024115 SPCC SA 2.5 0.08 12 4.2 0.8 A级
+ C2024114 SPCC SA 2.5 0.12 15 5.1 1.0 A级
+ C2024113 SPHC SA 2.0 0.22 22 7.8 1.5 B级
+ C2024112 SS400 SA 2.5 0.09 11 3.8 0.6 A级
+ C2024111 Q235B SA 1.5 0.35 35 12.1 2.8 C级
+
+
+
+
+
+
+
+
+ 检测总卷数 5
+ A级品 3 卷
+ B级品 1 卷
+ C级品 1 卷
+ 合格率 80.0 %
+ 优等品率 60.0 %
+
+
+ 导出质量报表
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 等级 残酸(g/m²) 平整度(IU) 镰刀弯(mm/m) 表面等级 综合条件 对应用途
+
+ A ≤ 0.15 ≤ 6.0 ≤ 1.0 SA 2.5 全部满足 冷轧/镀锌基料
+ B 0.15–0.25 6.0–10 1.0–2.0 SA 2.0+ 3项满足 一般用途钢板
+ C 0.25–0.40 10–15 2.0–3.0 SA 1.5+ 2项满足 结构用途
+ D > 0.40 > 15 > 3.0 < SA 1.5 不满足 降级处理
+
+
+
+
+
+
+
+
+
+
+
+
今日电耗
4,820
kWh
折合 14.1 kWh/t
+
+
+
今日压缩空气
1,240
Nm³
折合 3.6 Nm³/t
+
+
+
+
+
+
+
+ 设备/用途 电耗(kWh) 占比
+
+ 主传动 1,820
+ 加热系统 1,240
+ 酸液循环泵 640
+ 通风除酸雾 480
+ 其他辅助 640
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 槽1剩余量 1,200 L
+ 槽2剩余量 680 L
+ 槽3剩余量 320 L
+ 库存总量 12,000 L
+ 预计补酸时间 槽3: 2.4h后
+ 采购预警 充足
+
+
+ 申请补酸 (槽3)
+
+
+
+
+
+
+
+
+
+
+
+
+ 物料名称 规格型号 当前库存 安全库存 消耗速率 预计用完 上次更换 更换周期 状态
+
+ 刷辊 ø220×1200mm 4 只 2 只 2 只/月 60天 2026-04-15 30天 充足
+ 夹送辊衬套 ø120 H8 8 件 4 件 1件/2周 112天 2026-04-28 14天 充足
+ 矫直机工作辊 ø110×1250mm 2 只 2 只 1 只/季 45天 2026-03-01 90天 临界
+ 密封圈组 各规格 12 套 6 套 1套/月 360天 2026-04-10 30天 充足
+ 盐酸耐腐泵叶轮 PP-200-150 1 个 2 个 1个/季 30天 2026-02-20 90天 需补货
+ 吸酸雾滤芯 F8级 φ315 6 片 3 片 2片/月 90天 2026-04-01 45天 充足
+ 光电传感器 E3Z-D62 2M 2 个 3 个 0.5个/月 120天 2026-03-15 — 临界
+ 润滑油脂 LGWM2/18 36 kg 20 kg 8 kg/月 60天 — — 充足
+
+
+
+
+
+
+
+
+
+
+ 物料 计划日期 优先级
+
+ 盐酸耐腐泵叶轮 2026-05-25 紧急
+ 矫直机工作辊 2026-06-01 计划
+ 光电传感器 2026-06-15 计划
+ 刷辊 2026-06-20 常规
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 设备名称 型号/规格 运行状态 累计运行(h) 今日负荷(%) 计划维护日期 预警 排产建议
+
+ 主传动变频器1 ACS880 250kW 运行中 12,480 72 2026-08-01 正常 可满负荷排产
+ 主传动变频器2 ACS880 200kW 运行中 12,320 68 2026-08-01 正常 可满负荷排产
+ 焊机变压器 400kVA 待机 8,640 0 2026-09-01 正常 按需启动
+ 酸泵电机组 55kW×6台 运行中 18,200 85 2026-06-15 临近维保 尽量安排夜班维修
+ 矫直机电机 132kW 运行中 9,840 61 2026-10-01 正常 可满负荷排产
+ 除酸雾风机 22kW×4台 运行中 22,100 90 2026-05-30 需维保 本周末安排计划停机
+ 高压配电柜 10kV 运行中 35,200 78 2026-07-01 正常 按计划维保
+
+
+
+
+
+
+
+
+
+
+
+ 设备名称 型号/规格 运行状态 累计运行(h) 润滑状态 计划维护日期 预警 排产建议
+
+ 开卷机 20t 运行中 14,200 正常 2026-09-01 正常 可满负荷排产
+ 焊机压辊 ø200mm 待机 3,200 正常 2026-10-01 正常 按需启动
+ 入口活套小车 行程100m 运行中 11,600 需加脂 2026-06-01 润滑异常 近期排班检查
+ 张力辊S1 ø350mm 运行中 15,800 正常 2026-08-15 正常 可满负荷排产
+ 五辊矫直机 5×ø110mm 运行中 9,200 正常 2026-06-01 辊面磨损 计划换辊6月
+ 出口剪机 液压 400mm 运行中 8,400 正常 2026-09-01 正常 可满负荷排产
+ 卷取机 20t 运行中 14,100 正常 2026-09-01 正常 可满负荷排产
+ 刷辊组 4×ø220mm 运行中 2,100 正常 2026-06-20 正常 常规更换
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/浙江企鹅1250mm推拉酸洗机组技术规格书.pdf b/浙江企鹅1250mm推拉酸洗机组技术规格书.pdf
new file mode 100644
index 0000000..46650b8
Binary files /dev/null and b/浙江企鹅1250mm推拉酸洗机组技术规格书.pdf differ