feat: 移除PDI和订单号字段,新增设备巡检模块
- 从物料跟踪页面移除订单号列和表单字段 - 从导航菜单移除PDI管理,添加设备巡检 - 新增InspectionLocation和InspectionRecord后端模型和API - 新增设备巡检前端页面(左侧点位列表,右侧设备和历史记录)
This commit is contained in:
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
17
backend/app/api/__init__.py
Normal file
17
backend/app/api/__init__.py
Normal 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
48
backend/app/api/auth.py
Normal 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))
|
||||
55
backend/app/api/dashboard.py
Normal file
55
backend/app/api/dashboard.py
Normal 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
108
backend/app/api/downtime.py
Normal 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))
|
||||
112
backend/app/api/equipment.py
Normal file
112
backend/app/api/equipment.py
Normal 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))
|
||||
75
backend/app/api/inspection.py
Normal file
75
backend/app/api/inspection.py
Normal 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
102
backend/app/api/material.py
Normal 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))
|
||||
82
backend/app/api/message.py
Normal file
82
backend/app/api/message.py
Normal 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
112
backend/app/api/pdi.py
Normal 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
103
backend/app/api/plan.py
Normal 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))
|
||||
294
backend/app/api/prediction.py
Normal file
294
backend/app/api/prediction.py
Normal 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})
|
||||
89
backend/app/api/production.py
Normal file
89
backend/app/api/production.py
Normal 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
134
backend/app/api/quality.py
Normal 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
29
backend/app/config.py
Normal 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
33
backend/app/database.py
Normal 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
76
backend/app/main.py
Normal 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)
|
||||
25
backend/app/models/__init__.py
Normal file
25
backend/app/models/__init__.py
Normal 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",
|
||||
]
|
||||
38
backend/app/models/downtime.py
Normal file
38
backend/app/models/downtime.py
Normal 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())
|
||||
36
backend/app/models/energy.py
Normal file
36
backend/app/models/energy.py
Normal 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())
|
||||
53
backend/app/models/equipment.py
Normal file
53
backend/app/models/equipment.py
Normal 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())
|
||||
28
backend/app/models/inspection.py
Normal file
28
backend/app/models/inspection.py
Normal 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())
|
||||
56
backend/app/models/material.py
Normal file
56
backend/app/models/material.py
Normal 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")
|
||||
20
backend/app/models/message.py
Normal file
20
backend/app/models/message.py
Normal 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
59
backend/app/models/pdi.py
Normal 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())
|
||||
33
backend/app/models/plan.py
Normal file
33
backend/app/models/plan.py
Normal 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())
|
||||
28
backend/app/models/production.py
Normal file
28
backend/app/models/production.py
Normal 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())
|
||||
38
backend/app/models/quality.py
Normal file
38
backend/app/models/quality.py
Normal 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())
|
||||
15
backend/app/models/user.py
Normal file
15
backend/app/models/user.py
Normal 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())
|
||||
7
backend/app/schemas/__init__.py
Normal file
7
backend/app/schemas/__init__.py
Normal 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
|
||||
30
backend/app/schemas/common.py
Normal file
30
backend/app/schemas/common.py
Normal 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]
|
||||
67
backend/app/schemas/downtime.py
Normal file
67
backend/app/schemas/downtime.py
Normal 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
|
||||
79
backend/app/schemas/equipment.py
Normal file
79
backend/app/schemas/equipment.py
Normal 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
|
||||
37
backend/app/schemas/inspection.py
Normal file
37
backend/app/schemas/inspection.py
Normal 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
|
||||
82
backend/app/schemas/material.py
Normal file
82
backend/app/schemas/material.py
Normal 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
|
||||
81
backend/app/schemas/pdi.py
Normal file
81
backend/app/schemas/pdi.py
Normal 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
|
||||
48
backend/app/schemas/plan.py
Normal file
48
backend/app/schemas/plan.py
Normal 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
|
||||
57
backend/app/schemas/production.py
Normal file
57
backend/app/schemas/production.py
Normal 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
|
||||
66
backend/app/schemas/quality.py
Normal file
66
backend/app/schemas/quality.py
Normal 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
|
||||
41
backend/app/schemas/user.py
Normal file
41
backend/app/schemas/user.py
Normal 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
|
||||
70
backend/app/services/auth_service.py
Normal file
70
backend/app/services/auth_service.py
Normal 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
|
||||
70
backend/app/services/material_service.py
Normal file
70
backend/app/services/material_service.py
Normal 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']}")
|
||||
348
backend/app/services/message_parser.py
Normal file
348
backend/app/services/message_parser.py
Normal 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()
|
||||
482
backend/app/services/prediction.py
Normal file
482
backend/app/services/prediction.py
Normal 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 K(45 kJ/mol 实验值,文献[1])
|
||||
- 浓度指数: 0.6 → 1.2(H⁺ 二阶动力学,文献[2])
|
||||
- 增加氧化铁皮结构修正(FeO/Fe₃O₄双层模型,文献[4])
|
||||
- 内置 K_cal 校准系数,支持投产后在线标定
|
||||
"""
|
||||
|
||||
# 文献实验值(碳钢 HCl 连续酸洗)
|
||||
TANK_LENGTH = 18.0 # m,单槽有效长度(按设备规格书)
|
||||
NUM_TANKS = 6
|
||||
# K0 由实际工况反推:
|
||||
# 目标:75°C、180 g/L 正常酸液条件下,最大速度约 120~130 m/min(PI≥95%)
|
||||
# 推导:t_total = 6×18/(125/60)=51.8s,k=ln(20)/51.8=0.058 s⁻¹
|
||||
# k=K0×SCALE_RATE_FACTOR → K0=0.058/0.765≈0.075 s⁻¹
|
||||
K0 = 0.075 # 指前因子 s⁻¹,由设备规格反推标定
|
||||
EA_R = 5413.0 # Ea/R (K),Ea=45 kJ/mol / R=8.314(文献实验值[1])
|
||||
T_REF = 348.15 # 参考温度 75°C (K)
|
||||
C_REF = 180.0 # 参考游离酸浓度 g/L
|
||||
N_CONC = 1.2 # 浓度动力学阶次(文献[2] 取保守值)
|
||||
V_MIN = 20.0
|
||||
V_MAX = 180.0
|
||||
CAL_KEY = "acid_speed_kcal"
|
||||
|
||||
# 氧化铁皮结构系数(FeO 快速溶解 + Fe₃O₄ 慢速溶解,文献[4])
|
||||
# 热轧碳钢铁皮组成约:FeO 70%,Fe₃O₄ 20%,Fe₂O₃ 10%
|
||||
# FeO 溶速约为 Fe₃O₄ 的 4 倍;有效速率取加权平均
|
||||
SCALE_RATE_FACTOR = 0.70 * 1.0 + 0.20 * 0.25 + 0.10 * 0.15 # ≈ 0.765
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
thickness: float, # mm
|
||||
width: float, # mm
|
||||
steel_grade: str,
|
||||
acid_conc_list: List[float], # 各槽游离酸 g/L
|
||||
acid_temp_list: List[float], # 各槽温度 °C
|
||||
scale_weight: float = 8.5, # g/m²,氧化铁皮重量
|
||||
target_pi: float = 95.0,
|
||||
):
|
||||
if len(acid_conc_list) != self.NUM_TANKS:
|
||||
raise ValueError(f"acid_conc_list 需要 {self.NUM_TANKS} 个元素")
|
||||
if len(acid_temp_list) != self.NUM_TANKS:
|
||||
raise ValueError(f"acid_temp_list 需要 {self.NUM_TANKS} 个元素")
|
||||
|
||||
self.thickness = thickness
|
||||
self.width = width
|
||||
self.steel_grade = steel_grade
|
||||
self.acid_conc_list = acid_conc_list
|
||||
self.acid_temp_list = acid_temp_list
|
||||
self.scale_weight = scale_weight
|
||||
self.target_pi = target_pi
|
||||
self.K_cal = _load_cal().get(self.CAL_KEY, 1.0)
|
||||
|
||||
def _k_i(self, conc: float, temp_c: float) -> float:
|
||||
"""单槽有效酸洗速率常数(含文献参数 + 铁皮结构修正)"""
|
||||
T_k = temp_c + 273.15
|
||||
arrhenius = math.exp(-self.EA_R * (1.0 / T_k - 1.0 / self.T_REF))
|
||||
conc_factor = max(conc / self.C_REF, 0.01) ** self.N_CONC
|
||||
# 铁皮越厚,有效接触面积越低(正比于 1/scale_weight^0.3 经验修正)
|
||||
scale_corr = (8.5 / max(self.scale_weight, 1.0)) ** 0.3
|
||||
return self.K0 * arrhenius * conc_factor * self.SCALE_RATE_FACTOR * scale_corr * self.K_cal
|
||||
|
||||
def _compute_pi(self, v_mpm: float) -> Tuple[float, List[float], List[float]]:
|
||||
v_mps = v_mpm / 60.0
|
||||
pi_prev = 0.0
|
||||
pi_per_tank, rt_per_tank = [], []
|
||||
for i in range(self.NUM_TANKS):
|
||||
t_i = self.TANK_LENGTH / v_mps
|
||||
k_i = self._k_i(self.acid_conc_list[i], self.acid_temp_list[i])
|
||||
# 精确解析解:dPI/dt = k*(1-PI/100) → PI_new = 100-(100-PI_old)*exp(-k*t)
|
||||
# 避免 Euler 一阶近似在 k*t 较大时的严重失真
|
||||
pi_prev = 100.0 - (100.0 - pi_prev) * math.exp(-k_i * t_i)
|
||||
pi_per_tank.append(round(pi_prev, 2))
|
||||
rt_per_tank.append(round(t_i, 1))
|
||||
return pi_prev, pi_per_tank, rt_per_tank
|
||||
|
||||
def calculate(self) -> Dict[str, Any]:
|
||||
# Nelder-Mead 单维退化为二分搜索(文献[5]验证有效)
|
||||
pi_at_min, _, _ = self._compute_pi(self.V_MIN)
|
||||
if pi_at_min < self.target_pi:
|
||||
pi, pp, rt = self._compute_pi(self.V_MIN)
|
||||
return {
|
||||
"max_speed": self.V_MIN,
|
||||
"pi_per_tank": pp,
|
||||
"residence_time_per_tank": rt,
|
||||
"total_pi": round(pi, 2),
|
||||
"under_pickling_risk": self._risk_level(self.V_MIN, pi),
|
||||
"warning": "酸液条件不足,即使最低速下酸洗指数仍低于目标,请检查酸浓度和温度",
|
||||
"K_cal": self.K_cal,
|
||||
}
|
||||
|
||||
lo, hi, best_v = self.V_MIN, self.V_MAX, self.V_MIN
|
||||
while hi - lo >= 0.5:
|
||||
mid = (lo + hi) / 2.0
|
||||
pi_mid, _, _ = self._compute_pi(mid)
|
||||
if pi_mid >= self.target_pi:
|
||||
best_v = mid; lo = mid + 0.5
|
||||
else:
|
||||
hi = mid - 0.5
|
||||
|
||||
best_v = math.floor(best_v)
|
||||
total_pi, pi_per_tank, rt_per_tank = self._compute_pi(best_v)
|
||||
|
||||
return {
|
||||
"max_speed": best_v,
|
||||
"pi_per_tank": pi_per_tank,
|
||||
"residence_time_per_tank": rt_per_tank,
|
||||
"total_pi": round(total_pi, 2),
|
||||
"under_pickling_risk": self._risk_level(best_v, total_pi),
|
||||
"warning": None,
|
||||
"K_cal": self.K_cal,
|
||||
}
|
||||
|
||||
def _risk_level(self, speed: float, pi: float) -> str:
|
||||
"""
|
||||
欠酸洗风险评估(文献[4] decision-tree 特征阈值)
|
||||
输入:speed(m/min), pi(%),结合厚度、浓度综合判断
|
||||
"""
|
||||
avg_conc = sum(self.acid_conc_list) / len(self.acid_conc_list)
|
||||
avg_temp = sum(self.acid_temp_list) / len(self.acid_temp_list)
|
||||
# 文献给出的欠酸洗高风险条件组合
|
||||
risk_score = 0
|
||||
if pi < 85: risk_score += 3
|
||||
elif pi < 92: risk_score += 1
|
||||
if speed > 140: risk_score += 2
|
||||
if avg_conc < 120: risk_score += 2
|
||||
if avg_temp < 68: risk_score += 2
|
||||
if self.thickness > 4.0: risk_score += 1
|
||||
|
||||
if risk_score >= 5: return "HIGH"
|
||||
elif risk_score >= 2: return "MEDIUM"
|
||||
else: return "LOW"
|
||||
|
||||
def calibrate(self, actual_max_speed: float,
|
||||
actual_quality_ok: bool) -> float:
|
||||
"""
|
||||
投产后标定接口:
|
||||
传入某卷的实际最大可用速度(操作员确认质量合格时的速度),
|
||||
用简单比例更新 K_cal,使模型逐步向真实工况收敛。
|
||||
|
||||
actual_max_speed: 实际测得质量合格的最高速度 (m/min)
|
||||
actual_quality_ok: True=该速度下质量合格,False=出现欠酸洗
|
||||
"""
|
||||
predicted = self.calculate()["max_speed"]
|
||||
if not actual_quality_ok:
|
||||
# 预测速度偏高,缩减 K_cal
|
||||
adjustment = 0.95
|
||||
else:
|
||||
ratio = actual_max_speed / max(predicted, 1.0)
|
||||
# 平滑更新,避免单次样本过拟合
|
||||
adjustment = 1.0 + 0.3 * (ratio - 1.0)
|
||||
adjustment = max(0.7, min(1.3, adjustment))
|
||||
|
||||
self.K_cal = round(self.K_cal * adjustment, 4)
|
||||
cal = _load_cal()
|
||||
cal[self.CAL_KEY] = self.K_cal
|
||||
_save_cal(cal)
|
||||
return self.K_cal
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 2. 张力设定模型
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
class TensionModel:
|
||||
"""
|
||||
张力模型:基于截面积×屈服强度,区间比例系数参考酸洗线工程手册。
|
||||
每个区段独立校准系数 zone_kcal[zone],互不干扰。
|
||||
"""
|
||||
|
||||
# 各区基准比例系数(酸洗线工程实践均值)
|
||||
ZONE_RATIOS = {
|
||||
"inlet": 1.00,
|
||||
"s1_roller": 0.85,
|
||||
"acid_entry": 0.78,
|
||||
"acid1": 0.72,
|
||||
"acid2": 0.68,
|
||||
"acid3": 0.68,
|
||||
"rinse": 0.70,
|
||||
"leveler": 0.76,
|
||||
"s2_roller": 0.88,
|
||||
"outlet": 1.00,
|
||||
}
|
||||
ZONE_NAMES_CN = {
|
||||
"inlet": "入口张力辊",
|
||||
"s1_roller": "S1夹送辊",
|
||||
"acid_entry": "酸洗入口辊",
|
||||
"acid1": "1#酸槽",
|
||||
"acid2": "2#酸槽",
|
||||
"acid3": "3#酸槽",
|
||||
"rinse": "漂洗段辊",
|
||||
"leveler": "拉矫机",
|
||||
"s2_roller": "S2夹送辊",
|
||||
"outlet": "出口张力辊",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _zone_cal_key(zone: str) -> str:
|
||||
return f"tension_zone_{zone}"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
thickness: float,
|
||||
width: float,
|
||||
yield_strength: float,
|
||||
tension_coef: float = 0.25,
|
||||
):
|
||||
self.thickness = thickness
|
||||
self.width = width
|
||||
self.yield_strength = yield_strength
|
||||
self.tension_coef = tension_coef
|
||||
cal = _load_cal()
|
||||
# 每个区段独立加载自己的校准系数,默认 1.0
|
||||
self.zone_kcal: Dict[str, float] = {
|
||||
z: cal.get(self._zone_cal_key(z), 1.0)
|
||||
for z in self.ZONE_RATIOS
|
||||
}
|
||||
|
||||
def calculate(self) -> Dict[str, Any]:
|
||||
cross_section = self.thickness * self.width # mm²
|
||||
# T_max 是理论基准值(不含区段校准,区段校准在各 zone 内单独乘)
|
||||
t_base_kn = (self.tension_coef * self.yield_strength
|
||||
* cross_section / 1000.0) # kN
|
||||
|
||||
zones = {}
|
||||
for zone, ratio in self.ZONE_RATIOS.items():
|
||||
k = self.zone_kcal.get(zone, 1.0)
|
||||
zones[zone] = {
|
||||
"tension_kN": round(t_base_kn * ratio * k, 2),
|
||||
"ratio": ratio,
|
||||
"k_cal": k,
|
||||
"name_cn": self.ZONE_NAMES_CN[zone],
|
||||
}
|
||||
|
||||
density = 7850.0
|
||||
mass_per_m = density * (self.thickness / 1000.0) * (self.width / 1000.0)
|
||||
accel_kn = round(mass_per_m * (30.0 / 60.0) / 1000.0, 3)
|
||||
t_max_kn = round(t_base_kn * self.zone_kcal.get("inlet", 1.0), 2)
|
||||
|
||||
return {
|
||||
"T_max": t_max_kn,
|
||||
"T_base": round(t_base_kn, 2),
|
||||
"cross_section_mm2": round(cross_section, 1),
|
||||
"zones": zones,
|
||||
"weld_speed_limit": 60.0,
|
||||
"weld_tension_kN": round(t_max_kn * 0.60, 2),
|
||||
"accel_tension": accel_kn,
|
||||
"zone_kcal": self.zone_kcal,
|
||||
}
|
||||
|
||||
def calibrate(self, zone: str, measured_kn: float) -> Dict[str, float]:
|
||||
"""仅更新指定区段的校准系数,其他区段不变"""
|
||||
if zone not in self.ZONE_RATIOS:
|
||||
raise ValueError(f"未知区段: {zone}")
|
||||
t_base = (self.tension_coef * self.yield_strength
|
||||
* self.thickness * self.width / 1000.0)
|
||||
predicted = t_base * self.ZONE_RATIOS[zone] * self.zone_kcal[zone]
|
||||
ratio = measured_kn / max(predicted, 0.1)
|
||||
# 平滑更新,步长 40%,范围限制在 [0.5, 2.0]
|
||||
adjustment = 1.0 + 0.4 * (ratio - 1.0)
|
||||
adjustment = max(0.5, min(2.0, adjustment))
|
||||
new_k = round(self.zone_kcal[zone] * adjustment, 4)
|
||||
self.zone_kcal[zone] = new_k
|
||||
cal = _load_cal()
|
||||
cal[self._zone_cal_key(zone)] = new_k
|
||||
_save_cal(cal)
|
||||
return self.zone_kcal
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 3. 质量预测模型
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
class QualityPredictionModel:
|
||||
"""
|
||||
欠酸洗风险 + 质量等级预测。
|
||||
|
||||
v2 变化:
|
||||
- 使用与 AcidSpeedModel 一致的文献参数(Ea/R=5413, n=1.2)
|
||||
- 欠酸洗风险特征阈值参考 arXiv:1207.0911 的 decision-tree 结论
|
||||
- 增加铁离子浓度(FeCl₂)对酸洗能力的抑制修正
|
||||
- 支持投产后用实际质量等级校准评分阈值
|
||||
"""
|
||||
EA_R = 5413.0
|
||||
T_REF = 348.15
|
||||
C_REF = 180.0
|
||||
N_CONC = 1.2
|
||||
CAL_KEY = "quality_kcal"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
thickness: float,
|
||||
avg_speed: float,
|
||||
acid_conc_avg: float, # 游离酸均值 g/L
|
||||
acid_temp_avg: float, # 温度均值 °C
|
||||
scale_weight: float = 8.5,
|
||||
fe_conc_avg: float = 60.0, # FeCl₂ 浓度 g/L(铁离子抑制效应)
|
||||
):
|
||||
self.thickness = thickness
|
||||
self.avg_speed = avg_speed
|
||||
self.acid_conc_avg = acid_conc_avg
|
||||
self.acid_temp_avg = acid_temp_avg
|
||||
self.scale_weight = scale_weight
|
||||
self.fe_conc_avg = fe_conc_avg
|
||||
self.K_cal = _load_cal().get(self.CAL_KEY, 1.0)
|
||||
|
||||
def _pickling_index_score(self) -> float:
|
||||
T_k = self.acid_temp_avg + 273.15
|
||||
arrhenius = math.exp(-self.EA_R * (1.0 / T_k - 1.0 / self.T_REF))
|
||||
conc_factor = max(self.acid_conc_avg / self.C_REF, 0.01) ** self.N_CONC
|
||||
# 铁离子抑制:FeCl₂ > 80 g/L 时显著降低酸洗速率(文献经验)
|
||||
fe_inhibition = 1.0 - max(0.0, (self.fe_conc_avg - 80.0) / 200.0) * 0.35
|
||||
scale_corr = (8.5 / max(self.scale_weight, 1.0)) ** 0.3
|
||||
exposure = (1.20 * arrhenius * conc_factor * fe_inhibition
|
||||
* scale_corr * 18.0 * 6) / (self.avg_speed / 60.0)
|
||||
pi_score = 100.0 * (1.0 - math.exp(-exposure / 10.0))
|
||||
return min(max(pi_score * self.K_cal, 0.0), 100.0)
|
||||
|
||||
def _surface_score(self, pi_score: float) -> float:
|
||||
# 最优速度区间 80-140 m/min(文献[4] 欠酸洗风险判别边界)
|
||||
if self.avg_speed < 60:
|
||||
speed_score = 80.0
|
||||
elif self.avg_speed <= 140:
|
||||
speed_score = 80.0 + 15.0 * (self.avg_speed - 60) / 80.0
|
||||
else:
|
||||
over = (self.avg_speed - 140) / 40.0
|
||||
speed_score = 95.0 - 30.0 * over
|
||||
return min(max(pi_score * 0.65 + speed_score * 0.35, 0.0), 100.0)
|
||||
|
||||
def _grade(self, pi: float, surface: float) -> str:
|
||||
c = (pi + surface) / 2.0
|
||||
if c >= 90: return "A1"
|
||||
if c >= 80: return "A2"
|
||||
if c >= 70: return "B1"
|
||||
if c >= 60: return "B2"
|
||||
return "C"
|
||||
|
||||
def _recommendations(self, pi: float, surface: float) -> List[str]:
|
||||
recs = []
|
||||
if self.fe_conc_avg > 80:
|
||||
recs.append(f"铁离子浓度偏高({self.fe_conc_avg:.0f} g/L),酸洗能力受抑制,建议加速换酸或补充新酸")
|
||||
if pi < 80:
|
||||
recs.append("酸洗指数偏低,建议提高酸液浓度至 180 g/L 以上,或将温度升至 80°C")
|
||||
if pi < 65:
|
||||
recs.append(f"欠酸洗风险高,建议将线速降至 {max(self.avg_speed*0.75, 20):.0f} m/min 以下")
|
||||
if self.acid_temp_avg < 70:
|
||||
recs.append(f"酸液温度偏低({self.acid_temp_avg:.1f}°C),建议升温至 75~85°C")
|
||||
if self.acid_conc_avg < 120:
|
||||
recs.append(f"游离酸浓度偏低({self.acid_conc_avg:.0f} g/L),建议补充新酸至 150 g/L")
|
||||
if self.avg_speed > 150:
|
||||
recs.append(f"线速过高({self.avg_speed:.0f} m/min),欠酸洗风险,建议不超过 140 m/min")
|
||||
if self.scale_weight > 12.0:
|
||||
recs.append(f"氧化铁皮偏重({self.scale_weight:.1f} g/m²),建议检查加热炉气氛控制")
|
||||
if not recs:
|
||||
recs.append("工艺参数在正常范围内,当前设定可继续保持")
|
||||
return recs
|
||||
|
||||
def calculate(self) -> Dict[str, Any]:
|
||||
pi = round(self._pickling_index_score(), 1)
|
||||
surface = round(self._surface_score(pi), 1)
|
||||
return {
|
||||
"pi_score": pi,
|
||||
"surface_score": surface,
|
||||
"overall_grade": self._grade(pi, surface),
|
||||
"recommendations": self._recommendations(pi, surface),
|
||||
"K_cal": self.K_cal,
|
||||
}
|
||||
|
||||
def calibrate(self, actual_grade: str) -> float:
|
||||
"""传入实际质检等级,更新评分校准系数"""
|
||||
grade_map = {"A1": 95, "A2": 85, "B1": 75, "B2": 65, "C": 50}
|
||||
actual_score = grade_map.get(actual_grade, 75)
|
||||
result = self.calculate()
|
||||
predicted_score = (result["pi_score"] + result["surface_score"]) / 2.0
|
||||
ratio = actual_score / max(predicted_score, 1.0)
|
||||
adjustment = 1.0 + 0.3 * (ratio - 1.0)
|
||||
adjustment = max(0.7, min(1.3, adjustment))
|
||||
self.K_cal = round(self.K_cal * adjustment, 4)
|
||||
cal = _load_cal()
|
||||
cal[self.CAL_KEY] = self.K_cal
|
||||
_save_cal(cal)
|
||||
return self.K_cal
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 4. 消耗预测模型
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
class AcidConsumptionModel:
|
||||
"""
|
||||
单卷资源消耗预测。
|
||||
单位消耗定额取自浙江企鹅1250mm规格书;
|
||||
酸耗额外引入铁离子浓度修正(FeCl₂ 越高酸液越快失效,换酸频率越高)。
|
||||
"""
|
||||
|
||||
ACID_WITH_REGEN = 2.0 # kg/t
|
||||
ACID_WITHOUT_REGEN = 35.0 # kg/t
|
||||
STEAM_UNIT = 39.8 # kg/t
|
||||
POWER_UNIT = 14.0 # kWh/t
|
||||
COOLING_UNIT = 1.21 # m³/t
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
thickness: float,
|
||||
width: float,
|
||||
coil_weight_kg: float,
|
||||
has_regen_station: bool = True,
|
||||
fe_conc_avg: float = 60.0, # FeCl₂ g/L,影响换酸频率
|
||||
):
|
||||
self.thickness = thickness
|
||||
self.width = width
|
||||
self.coil_weight_kg = coil_weight_kg
|
||||
self.has_regen_station = has_regen_station
|
||||
self.fe_conc_avg = fe_conc_avg
|
||||
|
||||
def calculate(self) -> Dict[str, Any]:
|
||||
weight_t = self.coil_weight_kg / 1000.0
|
||||
acid_base = self.ACID_WITH_REGEN if self.has_regen_station else self.ACID_WITHOUT_REGEN
|
||||
|
||||
# 铁离子修正:FeCl₂ > 100 g/L 时酸液利用率下降,有效酸耗上升
|
||||
fe_factor = 1.0 + max(0.0, (self.fe_conc_avg - 100.0) / 100.0) * 0.4
|
||||
acid_unit = round(acid_base * fe_factor, 3)
|
||||
|
||||
return {
|
||||
"coil_weight_t": round(weight_t, 3),
|
||||
"has_regen_station": self.has_regen_station,
|
||||
"acid_consumption_kg": round(acid_unit * weight_t, 2),
|
||||
"acid_unit_kg_per_t": acid_unit,
|
||||
"steam_consumption_kg": round(self.STEAM_UNIT * weight_t, 2),
|
||||
"steam_unit_kg_per_t": self.STEAM_UNIT,
|
||||
"power_consumption_kwh": round(self.POWER_UNIT * weight_t, 2),
|
||||
"power_unit_kwh_per_t": self.POWER_UNIT,
|
||||
"cooling_water_m3": round(self.COOLING_UNIT * weight_t, 3),
|
||||
"cooling_water_unit_m3_per_t": self.COOLING_UNIT,
|
||||
"fe_conc_factor": round(fe_factor, 3),
|
||||
}
|
||||
Reference in New Issue
Block a user