feat(钢卷跟踪): 从哪开始获取计划?一次性获取多少个计划?不同批次的顺序号相同怎么处理?

已实现的功能:
Systemcount+信号变化才算有效
状态机逻辑:信号1必须配合计数器变化才触发,然后等待信号2
信号2必须配合计数器变化+保持2秒才触发
第一批1-5,第二批2-6,第三批3-7
每次取5个钢卷,顺序号滑动+1
信号2触发时更新Oracle追踪表
OPC页面配置点位
信号1(入口钢卷)节点配置
信号2(焊接完成)节点配置
计数器节点配置
保存后自动重启OPC服务
前端操作中间表 
TrackCoil页面可增删改查临时表
可手动调整顺序
模拟信号1/信号2按钮可测试

- 后端新增钢卷跟踪相关API和数据库表
- 前端添加钢卷跟踪管理页面
- OPC服务增加信号节点监控和状态机处理
- 实现钢卷跟踪的自动更新逻辑
This commit is contained in:
2026-04-11 14:52:47 +08:00
parent 742802d7db
commit 538401017a
11 changed files with 685 additions and 16 deletions

View File

@@ -5,7 +5,8 @@ from contextlib import asynccontextmanager
from typing import Optional
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException, Query
from fastapi import FastAPI, HTTPException, Query, APIRouter
import datetime
from fastapi.middleware.cors import CORSMiddleware
load_dotenv()
@@ -13,9 +14,14 @@ load_dotenv()
from database import get_connection
from models import PDIPLTMCreate, PDIPLTMUpdate, OpcConfig
from opc_service import opc_service
router = APIRouter()
from sqlite_sync import (
init_db, sync_all_from_oracle,
sqlite_upsert_pdi, sqlite_delete_pdi
sqlite_upsert_pdi, sqlite_delete_pdi,
sqlite_get_coil_track, sqlite_update_coil_track_item,
sqlite_add_coil_track_item, sqlite_delete_coil_track_item,
sqlite_clear_coil_track, sqlite_get_coils_by_sequencenb_range
)
logging.basicConfig(level=logging.INFO)
@@ -362,6 +368,8 @@ def get_opc_config():
"counter_node": opc_service.counter_node,
"trackmap_nodes": opc_service.trackmap_nodes,
"poll_interval": opc_service.poll_interval,
"signal1_node": opc_service.signal1_node,
"signal2_node": opc_service.signal2_node,
"running": opc_service.running,
"last_counter": opc_service.last_counter,
"last_update": opc_service.last_update,
@@ -375,6 +383,8 @@ async def save_opc_config(config: OpcConfig):
opc_service.counter_node = config.counter_node
opc_service.trackmap_nodes = config.trackmap_nodes
opc_service.poll_interval = config.poll_interval
opc_service.signal1_node = config.signal1_node
opc_service.signal2_node = config.signal2_node
try:
opc_service.save_config()
except Exception as e:
@@ -391,6 +401,7 @@ def opc_status():
"last_counter": opc_service.last_counter,
"last_update": opc_service.last_update,
"log": opc_service.event_log[-50:],
"track_state": opc_service.track_state,
}
@@ -471,7 +482,7 @@ def get_l2_model_grades():
conn.close()
@router.get("/api/pdi/next-numbers")
@app.get("/api/pdi/next-numbers")
def get_next_numbers():
"""
获取下一个可用的批次编号和顺序号
@@ -536,3 +547,104 @@ def get_next_numbers():
cursor.close()
if 'conn' in locals():
conn.close()
# ─────────────────────────────────────────────
# COIL_TRACK_TEMP 临时跟踪表 CRUD
# ─────────────────────────────────────────────
@app.get("/api/track/coils")
def get_track_coils():
"""获取临时跟踪表中的钢卷列表"""
coils = sqlite_get_coil_track()
return {"data": coils}
@app.post("/api/track/coils")
def add_track_coil(data: dict):
"""新增一个钢卷到临时跟踪表"""
coilid = data.get("coilid")
sequencenb = data.get("sequencenb")
rollprogramnb = data.get("rollprogramnb")
if not coilid:
raise HTTPException(status_code=400, detail="coilid不能为空")
sqlite_add_coil_track_item(coilid, sequencenb or 0, rollprogramnb or 0)
return {"message": "添加成功"}
@app.put("/api/track/coils/{id}")
def update_track_coil(id: int, data: dict):
"""更新临时跟踪表中的钢卷"""
coilid = data.get("coilid")
sequencenb = data.get("sequencenb", 0)
rollprogramnb = data.get("rollprogramnb", 0)
position = data.get("position", 1)
if not coilid:
raise HTTPException(status_code=400, detail="coilid不能为空")
sqlite_update_coil_track_item(id, coilid, sequencenb, rollprogramnb, position)
return {"message": "更新成功"}
@app.delete("/api/track/coils/{id}")
def delete_track_coil(id: int):
"""删除临时跟踪表中的钢卷"""
sqlite_delete_coil_track_item(id)
return {"message": "删除成功"}
@app.delete("/api/track/coils")
def clear_track_coils():
"""清空临时跟踪表"""
sqlite_clear_coil_track()
return {"message": "清空成功"}
@app.get("/api/track/coils/range")
def get_coils_by_range(start: int = Query(1), end: int = Query(5)):
"""根据顺序号范围查询钢卷"""
coils = sqlite_get_coils_by_sequencenb_range(start, end)
return {"data": coils}
# ─────────────────────────────────────────────
# OPC Signal Configuration
# ─────────────────────────────────────────────
@app.get("/api/opc/signals")
def get_signal_config():
"""获取信号节点配置"""
return {
"signal1_node": opc_service.signal1_node,
"signal2_node": opc_service.signal2_node,
}
@app.post("/api/opc/signals")
async def save_signal_config(data: dict):
"""保存信号节点配置"""
opc_service.signal1_node = data.get("signal1_node", opc_service.signal1_node)
opc_service.signal2_node = data.get("signal2_node", opc_service.signal2_node)
try:
opc_service.save_config()
except Exception as e:
logger.warning("Save signal config failed: %s", e)
raise HTTPException(status_code=500, detail=f"配置保存失败: {e}")
return {"message": "信号节点配置已保存"}
# ─────────────────────────────────────────────
# 模拟信号接口 (用于测试)
# ─────────────────────────────────────────────
@app.post("/api/track/simulate/signal1")
async def simulate_signal1():
"""模拟信号1触发 - 获取下5个钢卷"""
await opc_service._handle_signal1()
return {"message": "信号1已触发"}
@app.post("/api/track/simulate/signal2")
async def simulate_signal2():
"""模拟信号2触发 - 更新追踪表"""
await opc_service._handle_signal2()
return {"message": "信号2已触发"}

View File

@@ -182,3 +182,5 @@ class OpcConfig(BaseModel):
counter_node: str
poll_interval: int = 2
trackmap_nodes: Dict[str, str] = {}
signal1_node: str = ""
signal2_node: str = ""

8
backend/opc_config.json Normal file
View File

@@ -0,0 +1,8 @@
{
"opc_url": "opc.tcp://192.168.1.100:4840",
"counter_node": "ns=2;s=PL.TRACKMAP.COUNTER",
"poll_interval": 2,
"trackmap_nodes": {},
"signal1_node": "ns=2;s=PL.Signal.EntryCoil",
"signal2_node": "ns=2;s=PL.Signal.WeldDone"
}

View File

@@ -8,6 +8,9 @@ Logic:
`trackmap_nodes` (a dict: {oracle_column -> node_id}).
4. For each row returned by the trackmap nodes build an UPDATE statement
and apply it to PLTM.CMPT_PL_TRACKMAP.
5. Monitor two signal nodes:
- Signal1 (Entry): When 0->1, fetch next 5 coils from PDI and save to SQLite temp table
- Signal2 (WeldDone): When 0->1 and held for 2s, update CMPT_PL_TRACKMAP
`trackmap_nodes` example (stored in .env or configured via UI):
{
@@ -42,6 +45,16 @@ class OpcService:
"OPC_COUNTER_NODE", "ns=2;s=PL.TRACKMAP.COUNTER"
)
self.poll_interval: int = int(os.getenv("OPC_POLL_INTERVAL", "2"))
self.signal1_node: str = os.getenv("OPC_SIGNAL1_NODE", "ns=2;s=PL.Signal.EntryCoil")
self.signal2_node: str = os.getenv("OPC_SIGNAL2_NODE", "ns=2;s=PL.Signal.WeldDone")
self.signal1_last: Optional[int] = None
self.signal2_last: Optional[int] = None
self.signal2_rise_time: Optional[datetime] = None
self.signal1_coils: List[Dict[str, Any]] = []
self.current_seq_start: int = 1
# 状态机: WAIT_S1=等待信号1, WAIT_S2=等待信号2
self.track_state: str = "WAIT_S1"
self.last_counter_at_state_change: Optional[Any] = None
# Mapping: oracle_column_name -> OPC node id
# Populated from .env or via API
self.trackmap_nodes: Dict[str, str] = self._load_trackmap_nodes()
@@ -75,6 +88,8 @@ class OpcService:
self.counter_node = cfg.get("counter_node", self.counter_node)
self.poll_interval = int(cfg.get("poll_interval", self.poll_interval))
self.trackmap_nodes = cfg.get("trackmap_nodes", self.trackmap_nodes) or {}
self.signal1_node = cfg.get("signal1_node", self.signal1_node)
self.signal2_node = cfg.get("signal2_node", self.signal2_node)
self._log(f"Loaded OPC config from {self.config_path}")
except Exception as exc:
logger.warning("Failed to load OPC config %s: %s", self.config_path, exc)
@@ -86,6 +101,8 @@ class OpcService:
"counter_node": self.counter_node,
"poll_interval": self.poll_interval,
"trackmap_nodes": self.trackmap_nodes,
"signal1_node": self.signal1_node,
"signal2_node": self.signal2_node,
}
os.makedirs(os.path.dirname(self.config_path), exist_ok=True)
with open(self.config_path, "w", encoding="utf-8") as f:
@@ -134,7 +151,8 @@ class OpcService:
self._log(f"Connected to OPC server: {self.opc_url}")
try:
while not self._stop_event.is_set():
await self._tick(client)
current_counter = await self._tick_with_counter(client)
await self._check_signals(client, current_counter)
await asyncio.sleep(self.poll_interval)
finally:
client.disconnect()
@@ -147,17 +165,17 @@ class OpcService:
self._log("OPC polling stopped")
# ------------------------------------------------------------------
async def _tick(self, client):
"""Read counter; if changed, fetch trackmap nodes and update Oracle."""
async def _tick_with_counter(self, client):
"""Read counter and update trackmap; return current_counter."""
try:
counter_node = client.get_node(self.counter_node)
current_counter = counter_node.get_value()
except Exception as exc:
self._log(f"Failed to read counter node: {exc}")
return
return None
if current_counter == self.last_counter:
return # nothing changed
return current_counter
self._log(
f"Counter changed: {self.last_counter} -> {current_counter}. "
@@ -168,7 +186,7 @@ class OpcService:
if not self.trackmap_nodes:
self._log("No trackmap nodes configured skipping DB update")
return
return current_counter
# Read all configured nodes
data: Dict[str, Any] = {}
@@ -180,16 +198,165 @@ class OpcService:
self._log(f"Failed to read node {node_id}: {exc}")
if not data:
return
return current_counter
# Determine POSITION from data (must be one of the mapped columns)
position = data.get("position")
if position is None:
self._log("'position' not in trackmap_nodes data cannot update row")
return
return current_counter
await self._update_oracle(position, data)
return current_counter
# ------------------------------------------------------------------
async def _check_signals(self, client, current_counter):
"""Check signal1 and signal2 with state machine and counter validation."""
try:
signal1_node = client.get_node(self.signal1_node)
signal1_value = signal1_node.get_value()
except Exception as exc:
self._log(f"Failed to read signal1 node: {exc}")
signal1_value = None
try:
signal2_node = client.get_node(self.signal2_node)
signal2_value = signal2_node.get_value()
except Exception as exc:
self._log(f"Failed to read signal2 node: {exc}")
signal2_value = None
counter_changed = current_counter != self.last_counter_at_state_change
# State machine for signal processing
if self.track_state == "WAIT_S1":
if signal1_value is not None and self.signal1_last is not None:
if self.signal1_last == 0 and signal1_value == 1 and counter_changed:
self._log(f"Signal1: Entry coil triggered (0->1) with counter change, state={self.track_state}")
await self._handle_signal1()
self.track_state = "WAIT_S2"
self.last_counter_at_state_change = current_counter
elif self.track_state == "WAIT_S2":
if signal2_value is not None and self.signal2_last is not None:
if self.signal2_last == 0 and signal2_value == 1 and counter_changed:
self.signal2_rise_time = datetime.now()
self._log("Signal2: Weld done rising edge (0->1) with counter change")
elif self.signal2_last == 1 and signal2_value == 0:
self.signal2_rise_time = None
if signal2_value == 1 and self.signal2_rise_time and counter_changed:
elapsed = (datetime.now() - self.signal2_rise_time).total_seconds()
if elapsed >= 2.0:
self._log(f"Signal2: Held {elapsed:.1f}s, triggering trackmap update")
await self._handle_signal2()
self.track_state = "WAIT_S1"
self.signal2_rise_time = None
self.last_counter_at_state_change = current_counter
if signal1_value is not None:
self.signal1_last = signal1_value
if signal2_value is not None:
self.signal2_last = signal2_value
async def _handle_signal1(self):
"""Handle signal1: fetch next 5 coils from PDI and save to SQLite temp table."""
from sqlite_sync import (
sqlite_get_max_sequencenb,
sqlite_get_coils_by_sequencenb_range,
sqlite_save_coils_to_track
)
def _do_fetch():
max_seq = sqlite_get_max_sequencenb()
if max_seq is None or max_seq < 1:
self._log("Signal1: No PDI data available")
return
start_seq = self.current_seq_start
end_seq = min(start_seq + 4, max_seq)
if end_seq < start_seq:
self._log(f"Signal1: Insufficient PDI data (max_seq={max_seq}, start={start_seq})")
return
coils = sqlite_get_coils_by_sequencenb_range(start_seq, end_seq)
if len(coils) == 0:
self._log(f"Signal1: No coils found")
return
self.signal1_coils = coils
sqlite_save_coils_to_track(coils)
self._log(f"Signal1: Saved {len(coils)} coils (seq {start_seq}-{end_seq}) to temp table")
self.current_seq_start = start_seq + 1
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, _do_fetch)
async def _handle_signal2(self):
"""Handle signal2: update CMPT_PL_TRACKMAP with temp table data."""
from sqlite_sync import sqlite_get_coil_track
from database import get_connection
def _do_update():
coils = sqlite_get_coil_track()
if not coils:
self._log("Signal2: No coils in temp track table")
return
try:
conn = get_connection()
cursor = conn.cursor()
try:
coil_count = len(coils)
for i in range(5):
target_position = i + 1
coil_index = target_position - 1
if coil_index >= 0 and coil_index < coil_count:
coil = coils[coil_index]
cursor.execute("SELECT COUNT(*) FROM PLTM.CMPT_PL_TRACKMAP WHERE POSITION = :pos", {"pos": target_position})
exists = cursor.fetchone()[0] > 0
if exists:
cursor.execute("""
UPDATE PLTM.CMPT_PL_TRACKMAP
SET COILID = :coilid, TOM = SYSDATE
WHERE POSITION = :position
""", {"coilid": coil["coilid"], "position": target_position})
else:
cursor.execute("""
INSERT INTO PLTM.CMPT_PL_TRACKMAP (POSITION, COILID, TOM)
VALUES (:position, :coilid, SYSDATE)
""", {"position": target_position, "coilid": coil["coilid"]})
else:
cursor.execute("SELECT COUNT(*) FROM PLTM.CMPT_PL_TRACKMAP WHERE POSITION = :pos", {"pos": target_position})
exists = cursor.fetchone()[0] > 0
if exists:
cursor.execute("""
UPDATE PLTM.CMPT_PL_TRACKMAP
SET COILID = NULL, TOM = SYSDATE
WHERE POSITION = :position
""", {"position": target_position})
else:
cursor.execute("""
INSERT INTO PLTM.CMPT_PL_TRACKMAP (POSITION, COILID, TOM)
VALUES (:position, NULL, SYSDATE)
""", {"position": target_position})
conn.commit()
self._log(f"Signal2: Updated 5 positions (coils: {coil_count})")
finally:
cursor.close()
conn.close()
except Exception as exc:
self._log(f"Signal2: Oracle update failed: {exc}")
import traceback
self._log(f"Signal2 traceback: {traceback.format_exc()}")
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, _do_update)
# ------------------------------------------------------------------
async def _update_oracle(self, position: Any, data: Dict[str, Any]):
"""Write fetched OPC values into PLTM.CMPT_PL_TRACKMAP."""

View File

@@ -102,6 +102,12 @@ CREATE TABLE IF NOT EXISTS PDI_PLTM (
C REAL, SI REAL, MN REAL, P REAL, S REAL,
CU REAL, NI REAL, CR REAL, MO REAL, V REAL,
TI REAL, SOL_AL REAL, FE REAL, NB REAL, N REAL, B REAL,
MG REAL, PB REAL, SN REAL, ZN REAL, ZR REAL,
NA REAL, LI REAL, GA REAL, CA REAL,
BE REAL, BI REAL, W REAL,
TA REAL, O REAL, H REAL,
AR REAL, AG REAL, AS1 REAL, CD REAL, CL REAL,
CO REAL, K REAL, SB REAL, SE REAL,
SEND_FLAG TEXT,
SEND_DATE TEXT,
TRANSACTION_ID TEXT,
@@ -152,6 +158,16 @@ CREATE TABLE IF NOT EXISTS CMPT_PL_TRACKMAP (
)
"""
COIL_TRACK_DDL = """
CREATE TABLE IF NOT EXISTS COIL_TRACK_TEMP (
ID INTEGER PRIMARY KEY AUTOINCREMENT,
COILID TEXT NOT NULL,
SEQUENCENB INTEGER NOT NULL,
ROLLPROGRAMNB INTEGER NOT NULL,
CREATED_DT TEXT DEFAULT (datetime('now')),
POSITION INTEGER DEFAULT 0
)
"""
def init_db():
"""Create tables if they don't exist."""
@@ -159,6 +175,7 @@ def init_db():
try:
conn.execute(PDI_DDL)
conn.execute(TRACKMAP_DDL)
conn.execute(COIL_TRACK_DDL)
conn.commit()
logger.info("SQLite schema ready: %s", DB_PATH)
finally:
@@ -301,3 +318,107 @@ def sqlite_upsert_trackmap(row: Dict[str, Any]):
sc.commit()
finally:
sc.close()
def sqlite_get_max_sequencenb() -> Optional[int]:
sc = get_sqlite()
try:
cursor = sc.execute("SELECT MAX(SEQUENCENB) FROM PDI_PLTM")
row = cursor.fetchone()
return row[0] if row and row[0] is not None else None
finally:
sc.close()
def sqlite_get_coils_by_sequencenb_range(start_seq: int, end_seq: int) -> List[Dict[str, Any]]:
sc = get_sqlite()
try:
cursor = sc.execute("""
SELECT COILID, SEQUENCENB, ROLLPROGRAMNB
FROM PDI_PLTM
WHERE SEQUENCENB >= ? AND SEQUENCENB <= ?
ORDER BY SEQUENCENB DESC
""", (start_seq, end_seq))
rows = cursor.fetchall()
return [{"coilid": r[0], "sequencenb": r[1], "rollprogramnb": r[2]} for r in rows]
finally:
sc.close()
def sqlite_save_coils_to_track(coils: List[Dict[str, Any]]):
sc = get_sqlite()
try:
sc.execute("DELETE FROM COIL_TRACK_TEMP")
reversed_coils = list(reversed(coils))
for i, coil in enumerate(reversed_coils):
sc.execute("""
INSERT INTO COIL_TRACK_TEMP (COILID, SEQUENCENB, ROLLPROGRAMNB, POSITION)
VALUES (?, ?, ?, ?)
""", (coil["coilid"], coil["sequencenb"], coil["rollprogramnb"], i + 1))
sc.commit()
logger.info(f"Saved {len(coils)} coils to track temp")
finally:
sc.close()
def sqlite_get_coil_track() -> List[Dict[str, Any]]:
sc = get_sqlite()
try:
cursor = sc.execute("""
SELECT ID, COILID, SEQUENCENB, ROLLPROGRAMNB, CREATED_DT, POSITION
FROM COIL_TRACK_TEMP
ORDER BY POSITION ASC
""")
rows = cursor.fetchall()
return [
{"id": r[0], "coilid": r[1], "sequencenb": r[2], "rollprogramnb": r[3], "created_dt": r[4], "position": r[5]}
for r in rows
]
finally:
sc.close()
def sqlite_update_coil_track_item(id: int, coilid: str, sequencenb: int, rollprogramnb: int, position: int):
sc = get_sqlite()
try:
sc.execute("""
UPDATE COIL_TRACK_TEMP
SET COILID = ?, SEQUENCENB = ?, ROLLPROGRAMNB = ?, POSITION = ?
WHERE ID = ?
""", (coilid, sequencenb, rollprogramnb, position, id))
sc.commit()
finally:
sc.close()
def sqlite_add_coil_track_item(coilid: str, sequencenb: int, rollprogramnb: int):
sc = get_sqlite()
try:
cursor = sc.execute("SELECT MAX(POSITION) FROM COIL_TRACK_TEMP")
row = cursor.fetchone()
max_pos = (row[0] or 0) + 1
sc.execute("""
INSERT INTO COIL_TRACK_TEMP (COILID, SEQUENCENB, ROLLPROGRAMNB, POSITION)
VALUES (?, ?, ?, ?)
""", (coilid, sequencenb, rollprogramnb, max_pos))
sc.commit()
finally:
sc.close()
def sqlite_delete_coil_track_item(id: int):
sc = get_sqlite()
try:
sc.execute("DELETE FROM COIL_TRACK_TEMP WHERE ID = ?", (id,))
sc.commit()
finally:
sc.close()
def sqlite_clear_coil_track():
sc = get_sqlite()
try:
sc.execute("DELETE FROM COIL_TRACK_TEMP")
sc.commit()
finally:
sc.close()