feat: 移除PDI和订单号字段,新增设备巡检模块

- 从物料跟踪页面移除订单号列和表单字段
- 从导航菜单移除PDI管理,添加设备巡检
- 新增InspectionLocation和InspectionRecord后端模型和API
- 新增设备巡检前端页面(左侧点位列表,右侧设备和历史记录)
This commit is contained in:
2026-05-27 16:38:40 +08:00
commit 193da0018f
86 changed files with 11379 additions and 0 deletions

0
backend/app/__init__.py Normal file
View File

View File

@@ -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=["设备巡检"])

48
backend/app/api/auth.py Normal file
View File

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

View File

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

108
backend/app/api/downtime.py Normal file
View File

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

View File

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

View File

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

102
backend/app/api/material.py Normal file
View File

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

View File

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

112
backend/app/api/pdi.py Normal file
View File

@@ -0,0 +1,112 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, 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))

103
backend/app/api/plan.py Normal file
View File

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

View File

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

View File

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

134
backend/app/api/quality.py Normal file
View File

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

29
backend/app/config.py Normal file
View File

@@ -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()

33
backend/app/database.py Normal file
View File

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

76
backend/app/main.py Normal file
View File

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

View File

@@ -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",
]

View File

@@ -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())

View File

@@ -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())

View File

@@ -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())

View File

@@ -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())

View File

@@ -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")

View File

@@ -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())

59
backend/app/models/pdi.py Normal file
View File

@@ -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())

View File

@@ -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())

View File

@@ -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())

View File

@@ -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())

View File

@@ -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())

View File

@@ -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

View File

@@ -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]

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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']}")

View File

@@ -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()

View File

@@ -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 K45 kJ/mol 实验值,文献[1]
- 浓度指数: 0.6 → 1.2H⁺ 二阶动力学,文献[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/minPI≥95%
# 推导t_total = 6×18/(125/60)=51.8sk=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),
}