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 @@ + + + + + + + + 推拉酸洗线二级系统 + + + +
+ + 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 @@ + + + + + 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 @@ + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 过程控制系统 + + + + + +
+ +
推拉酸洗线 L2 过程控制系统  |  PUSH-PULL PICKLING LINE
+
+
+ ● 机组运行 + ● L2在线 + ⚠ L3待机 +
+
--:--:--
+
+ + + + + +
+ + +
+ + +
+
+
穿带状态
+
正常轧制
+
机组速度模式
+
+
+
带头位置
+
+
m(从入口夹送辊)
+
+
+
带尾位置
+
+
m(从入口夹送辊)
+
+
+
机组线速度
+
85.3
+
m/min
+
+
+
带钢总长
+
+
m
+
+
+ + +
+
+ 🎯带头 / 带尾 实时位置跟踪 + 运行中 +
+ + +
+
+
+ +
+ +
+ +
+ ■ 入口夹送辊 + ■ 机械清洗 + ■ 酸洗槽(1/2/3) + ■ 漂洗/钝化 + ■ 矫直机 + ■ 出口夹送辊 + ▶ 带头 + ◀ 带尾 +
+
+
+ + +
+
+
📍 各工艺段通过状态
+
+ + + + + + + + + + + + + + + + +
工艺段段长(m)段起点(m)带头状态带尾状态带头到达时间带尾通过时间限速(m/min)
+
+
+ +
+
⚡ 带头带尾速度设定模型
+
+
穿带参数设定
+
+ 穿带速度 V_thread + + m/min + + 加速度 a + + m/min/s + + 正常线速 V_line + + m/min + + 带头加速完成位 + + m + + 带尾减速触发位 + + m (尾到夹持辊) + + 带尾最低速 + + m/min +
+
计算结果
+
+ 穿带加速距 + 穿带时间估算 + 带尾减速距 + 全程通过时间 +
+
+ +
+
+
+
+ + +
+
📈 带头/带尾速度曲线(位置 vs 速度)
+
+ +
+
+ + +
+
📋 穿带历史记录
+
+ + + + + + + + +
序号钢卷号厚(mm)穿带开始带头出口时间带尾离夹时间穿带速度穿带时长(s)状态
1C20241152.5010:05:1210:07:4810:08:0220 m/min170成功
2C20241142.0009:42:3309:45:0109:45:1822 m/min165成功
3C20241133.0009:18:0509:21:0209:21:2518 m/min200慢速
4C20241122.5008:55:1408:58:0608:58:2020 m/min186成功
+
+
+
+ + +
+
+
+
⚡ 张力自动设定模型 计算中
+
+
带钢规格输入
+
+
+
厚度 h
+ mm +
+
+
宽度 w
+ mm +
+
+
屈服强度 σ₀
+ MPa +
+
+
张力系数 k
+ +
+
+
张力图示
+
+
开卷
开卷
+
+
S1
S1辊
+
+
入口辊
+
+
酸1
酸槽1
+
+
酸2
酸槽2
+
+
酸3
酸槽3
+
+
漂洗
+
+
矫直机
+
+
S2
S2辊
+
+
卷取
卷取
+
+
+
+
+
⚡ 计算结果
+
+
+ 截面积 A + 最大张力 T_max + 酸槽段张力 + 出口段张力 + 比张力 + 单位张力 +
+
+ +
+
+
张力限制
+
+ 焊缝减速张力 + 加速段张力 +
+
+
+
+
+
+
⚡ 张力设定表(按钢卷批次)
+
+ + + + + + + +
钢卷号厚(mm)宽(mm)σ₀(MPa)T_入(kN)T_酸(kN)T_出(kN)焊缝速限状态
C20241152.50100038023.819.021.440 m/min激活
C20241162.00105035018.414.716.545 m/min等待
C20241173.0090042028.422.725.535 m/min等待
+
+
+
+ + +
+
+
+
🧪 酸洗槽 #1
+
+
+ HCl 浓度178 g/L + 酸液温度72 °C + Fe²⁺ 含量82 g/L + 槽液液位95 % + 酸洗速度85 m/min + 停留时间12.4 s +
+
+
酸洗效果指数
+
+
82% — 良好
+
+
+
+
+
🧪 酸洗槽 #2
+
+
+ HCl 浓度165 g/L + 酸液温度74 °C + Fe²⁺ 含量95 g/L + 槽液液位93 % + 酸洗速度85 m/min + 停留时间12.4 s +
+
+
酸洗效果指数
+
+
76% — 注意浓度
+
+
+
+
+
🧪 酸洗槽 #3 (出口段)
+
+
+ HCl 浓度150 g/L + 酸液温度75 °C + Fe²⁺ 含量110 g/L + 槽液液位91 % + 酸洗速度85 m/min + 停留时间12.4 s +
+
+
酸洗效果指数
+
+
68% — 需补酸
+
+
+
+
+ +
+
🧪 酸洗工艺计算模型
+
+
+
+
带钢氧化皮重量 (g/m²)
+ +
+
+
单槽有效长度 (m)
+ +
+
+
目标酸洗指数 (%)
+ +
+
+
酸浓度目标 (g/L)
+ +
+
+
+
+
理论停留时间
+
12.7
+
秒/槽
+
+
+
最高允许速度
+
85
+
m/min
+
+
+
补酸量
+
42
+
L/t
+
+
+
废酸生成量
+
18
+
L/t
+
+
+
+
+ +
+
+
🚿 漂洗段
+
+
+ 漂洗段1 pH2.8 + 漂洗段2 pH4.1 + 漂洗段3 pH6.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 + 固定 + 固定 + 固定 + +
+
+
+
入口延伸率
+
0.85
+
%
+
+
+
总延伸率
+
1.20
+
%
+
+
+
矫直力
+
3.4
+
MN
+
+
+
弯曲力矩
+
124
+
kN·m
+
+
+
残余应力
+
42
+
MPa
+
+
+
+
+
+
📐 矫直机参数输入
+
+
+ 辊1(R1)压下量 mm + 辊2(R2)压下量 mm + 辊3(R3)压下量 mm + 辊4(R4)压下量 mm + 辊5(R5)压下量 mm + 带钢厚度 mm + 辊径 D mm + 屈服强度 MPa +
+
+ + +
+
+
+
+
+
📐 矫直效果历史趋势
+
+ +
+
+
+ + +
+
+
📈 实时趋势监控
+
+
+
+
速度/张力
+
酸洗参数
+
质量参数
+
+
+ + +
+
+ +
+
+
+
+
📈 速度趋势
+
+ +
+ 当前: 85.3 m/min + 均值: 82.1 m/min +
+
+
+
+
📈 张力趋势
+
+ +
+ 当前: 19.2 kN + 均值: 18.8 kN +
+
+
+
+
📈 酸洗浓度趋势
+
+ +
+ 当前: 178 g/L + 均值: 174 g/L +
+
+
+
+
+ + +
+
+
+
📋 PDI 数据管理 当前卷批
+
+ + + + + + + + +
钢卷号钢种厚(mm)宽(mm)重(t)σ₀(MPa)Rm(MPa)A(%)工艺路径状态
C2024115SPCC2.50100018.238052028P1+P2+P3在线
C2024116SPHC2.00105015.535048032P1+P2+P3等待
C2024117SS4003.0090021.042056022P1+P2+P3+LV等待
C2024118SPCC1.80120014.832044036P1+P2等待
+
+
+
+
📋 PDI 编辑器
+
+
+ 钢卷号 + 钢种 + + 厚度 (mm) + 宽度 (mm) + 重量 (t) + 工艺路径 + +
+
+ + + +
+
+
+
+
+ + +
+
+
+
📅 L3 排产计划接收 今日计划
+
+ + + + + + + + + +
序号钢卷号客户钢种厚×宽(mm)重(t)计划时间优先级L3状态L2确认
1C2024115客户ASPCC2.50×100018.208:00已发送已确认
2C2024116客户BSPHC2.00×105015.510:30已发送待确认
3C2024117客户ASS4003.00×90021.013:00已发送未确认
4C2024118客户CSPCC1.80×120014.815:30待发送
5C2024119客户DQ235B2.20×110017.017:00待发送
+
+
+
+
📅 L3通讯状态
+
+
+ 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 (待发) + + + 现在 + +
+
+
+ + +
+
+
今日产量
342
▲ +8.2% vs 昨日
+
本月累计
8,420
▲ +3.1%
+
机组利用率
78.4
%
▲ +2.0%
+
有效作业时间
18.8
h / 24h
▼ -0.5h
+
+
+
+
🏭 日产能趋势 (近14天)
+
+ +
+
+
+
🏭 停机时间分析
+
+ + + + + + + + + +
停机原因时长(min)占比
换卷对焊62
换辊作业35
设备故障18
换酸作业12
其他3
+
+
+
+
+ + +
+
+
+
OEE 综合效率
+
82.3
+
%
+
+
+
+
可用率 A
+
91.2
+
%
+
+
+
+
性能率 P
+
87.4
+
%
+
+
+
+
质量率 Q
+
98.6
+
%
+
+
+
+
计划完成率
+
94.8
+
%
+
+
+
+
+
+
🎯 月度KPI趋势
+
+ +
+
+
+
🎯 KPI 明细
+
+ + + + + + + + + +
指标本月目标达成
日均产量380 t400 t95%
酸洗质量合格率99.1%99%达标
吨钢酸耗8.2 kg/t8.0 kg/t注意
设备故障率1.2%2.0%达标
换辊次数12 次12 次达标
+
+
+
+
+ + +
+
+
+
✅ 质量检测报表
+
+ + + + + + + + + +
钢卷号钢种表面等级残酸(g/m²)残氧(ppm)平整度(IU)镰刀弯(mm/m)综合评级
C2024115SPCCSA 2.50.08124.20.8A级
C2024114SPCCSA 2.50.12155.11.0A级
C2024113SPHCSA 2.00.22227.81.5B级
C2024112SS400SA 2.50.09113.80.6A级
C2024111Q235BSA 1.50.353512.12.8C级
+
+
+
+
✅ 质量汇总
+
+
+ 检测总卷数5 + A级品3 卷 + B级品1 卷 + C级品1 卷 + 合格率80.0 % + 优等品率60.0 % +
+
+ +
+
+
+
+
+ + +
+
+
+
⭐ 评级分布
+
+ +
+
+
+
⭐ 质量评级规则
+
+ + + + + + + + +
等级残酸(g/m²)平整度(IU)镰刀弯(mm/m)表面等级综合条件对应用途
A≤ 0.15≤ 6.0≤ 1.0SA 2.5全部满足冷轧/镀锌基料
B0.15–0.256.0–101.0–2.0SA 2.0+3项满足一般用途钢板
C0.25–0.4010–152.0–3.0SA 1.5+2项满足结构用途
D> 0.40> 15> 3.0< SA 1.5不满足降级处理
+
+
+
+
+
⭐ 月度质量评级统计
+
+ +
+
+
+ + +
+
+
今日电耗
4,820
kWh
折合 14.1 kWh/t
+
今日蒸汽耗
28.4
GJ
折合 83 MJ/t
+
今日水耗
186
折合 0.54 m³/t
+
今日压缩空气
1,240
Nm³
折合 3.6 Nm³/t
+
+
+
+
⚡ 能源消耗趋势 (近14天)
+
+ +
+
+
+
⚡ 能源分布
+
+ + + + + + + + + +
设备/用途电耗(kWh)占比
主传动1,820
加热系统1,240
酸液循环泵640
通风除酸雾480
其他辅助640
+
+
+
+
+ + +
+
+
今日盐酸消耗
2,840
L
折合 8.3 L/t
+
漂洗水消耗
186
折合 0.54 m³/t
+
钝化液消耗
42
L
折合 0.12 L/t
+
清洗液消耗
68
L
折合 0.20 L/t
+
+
+
+
🧴 原料消耗趋势 (近14天)
+
+ +
+
+
+
🧴 补酸计划
+
+
+ 槽1剩余量1,200 L + 槽2剩余量680 L + 槽3剩余量320 L + 库存总量12,000 L + 预计补酸时间槽3: 2.4h后 + 采购预警充足 +
+
+ +
+
+
+
+
+ + +
+
+
🔧 辅料及易损件统计
+
+ + + + + + + + + + + + +
物料名称规格型号当前库存安全库存消耗速率预计用完上次更换更换周期状态
刷辊ø220×1200mm4 只2 只2 只/月60天2026-04-1530天充足
夹送辊衬套ø120 H88 件4 件1件/2周112天2026-04-2814天充足
矫直机工作辊ø110×1250mm2 只2 只1 只/季45天2026-03-0190天临界
密封圈组各规格12 套6 套1套/月360天2026-04-1030天充足
盐酸耐腐泵叶轮PP-200-1501 个2 个1个/季30天2026-02-2090天需补货
吸酸雾滤芯F8级 φ3156 片3 片2片/月90天2026-04-0145天充足
光电传感器E3Z-D62 2M2 个3 个0.5个/月120天2026-03-15临界
润滑油脂LGWM2/1836 kg20 kg8 kg/月60天充足
+
+
+
+
+
🔧 易损件更换历史
+
+ +
+
+
+
🔧 近期更换计划
+
+ + + + + + + + +
物料计划日期优先级
盐酸耐腐泵叶轮2026-05-25紧急
矫直机工作辊2026-06-01计划
光电传感器2026-06-15计划
刷辊2026-06-20常规
+
+
+
+
+ + +
+
+
⚡ 电气设备
+
⚙ 机械设备
+
+
+
+
⚡ 电气设备运行状态与排产分析
+
+ + + + + + + + + + + +
设备名称型号/规格运行状态累计运行(h)今日负荷(%)计划维护日期预警排产建议
主传动变频器1ACS880 250kW运行中12,480722026-08-01正常可满负荷排产
主传动变频器2ACS880 200kW运行中12,320682026-08-01正常可满负荷排产
焊机变压器400kVA待机8,64002026-09-01正常按需启动
酸泵电机组55kW×6台运行中18,200852026-06-15临近维保尽量安排夜班维修
矫直机电机132kW运行中9,840612026-10-01正常可满负荷排产
除酸雾风机22kW×4台运行中22,100902026-05-30需维保本周末安排计划停机
高压配电柜10kV运行中35,200782026-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