Преглед изворни кода

feat(wp4): push work orders to MES on review and gate release

Enqueue WorkOrdMaster/Detail/Routing to 165 after plan-side review/refresh/confirm; Mes release requires WO ready (WO_MES_NOT_READY). chore: bump server 1.0.310.

Co-authored-by: Cursor <cursoragent@cursor.com>
YY968XX пре 3 дана
родитељ
комит
b6b8a92dcb
17 измењених фајлова са 1345 додато и 8 уклоњено
  1. 88 0
      doc/plan/旧DOP-MES-WMS/对接任务书/tools/_wp4_not_ready_smoke.py
  2. 42 0
      doc/plan/旧DOP-MES-WMS/对接任务书/tools/_wp4_outbox_diag.py
  3. 148 0
      doc/plan/旧DOP-MES-WMS/对接任务书/tools/_wp4_wo_push_smoke.py
  4. 94 0
      doc/plan/旧DOP-MES-WMS/对接任务书/tools/_wp9_post_smoke_check.py
  5. 83 0
      doc/plan/旧DOP-MES-WMS/对接任务书/tools/_wp9_routing_gap.py
  6. 109 0
      doc/plan/旧DOP-MES-WMS/对接任务书/tools/_wp9_smoke_release.py
  7. 55 0
      doc/plan/旧DOP-MES-WMS/对接任务书/tools/_wp9_smoke_verify.py
  8. 3 2
      doc/plan/旧DOP-MES-WMS/对接任务书/执行进度-2026-08-09.md
  9. 14 1
      doc/plan/旧DOP-MES-WMS/对接任务书/附-字段共管矩阵.md
  10. 3 3
      server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj
  11. 19 1
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Executors/MdpDbPushExecutor.cs
  12. 60 0
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Executors/MdpOutboxEnqueueService.cs
  13. 29 0
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Wms/CreatePickBillService.cs
  14. 571 0
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Wms/WorkOrdMesPushService.cs
  15. 8 1
      server/Plugins/Admin.NET.Plugin.AiDOP/Order/OrderReviewOrchestrationService.cs
  16. 2 0
      server/Plugins/Admin.NET.Plugin.AiDOP/WorkOrder/PickBillUserMessages.cs
  17. 17 0
      server/Plugins/Admin.NET.Plugin.AiDOP/WorkOrder/WorkOrderDispatchService.cs

+ 88 - 0
doc/plan/旧DOP-MES-WMS/对接任务书/tools/_wp4_not_ready_smoke.py

@@ -0,0 +1,88 @@
+"""Assert WO_MES_NOT_READY for a local-p WO not on 165."""
+import json
+from pathlib import Path
+from urllib import error, request
+from datetime import date
+
+import pymysql
+import pyodbc
+
+BASE = "http://127.0.0.1:5005"
+TENANT = 797403760988229
+
+cfg = json.loads((Path.home() / ".cursor" / "mcp.json").read_text(encoding="utf-8"))
+my = cfg["mcpServers"]["mysql-aidopdev"]["env"]
+ss = cfg["mcpServers"]["sqlserver-dopdemorq"]["env"]
+
+conn = pymysql.connect(
+    host=my["MYSQL_HOST"], port=int(my["MYSQL_PORT"]), user=my["MYSQL_USER"],
+    password=my["MYSQL_PASSWORD"], database=my["MYSQL_DATABASE"],
+    connect_timeout=15, read_timeout=60, charset="utf8mb4",
+)
+cur = conn.cursor()
+cur.execute(
+    """
+    SELECT w.WorkOrd, TRIM(IFNULL(w.Domain,''))
+    FROM WorkOrdMaster w
+    WHERE w.tenant_id=%s AND LOWER(TRIM(IFNULL(w.Status,'')))='p'
+      AND EXISTS (SELECT 1 FROM WorkOrdDetail d WHERE d.tenant_id=w.tenant_id AND d.WorkOrd=w.WorkOrd AND IFNULL(d.IsActive,0)=1)
+      AND EXISTS (SELECT 1 FROM WorkOrdRouting r WHERE r.tenant_id=w.tenant_id AND r.WorkOrd=w.WorkOrd AND IFNULL(r.IsActive,0)=1)
+    ORDER BY w.RecID DESC
+    LIMIT 10
+    """,
+    (TENANT,),
+)
+cands = cur.fetchall()
+conn.close()
+print("candidates", cands)
+
+cs = (
+    f"DRIVER={{ODBC Driver 17 for SQL Server}};"
+    f"SERVER={ss['SQLSERVER_HOST']},{ss['SQLSERVER_PORT']};"
+    f"DATABASE={ss['SQLSERVER_DATABASE']};"
+    f"UID={ss['SQLSERVER_USER']};PWD={ss['SQLSERVER_PASSWORD']};"
+    f"TrustServerCertificate=yes;"
+)
+cn = pyodbc.connect(cs, timeout=15)
+c = cn.cursor()
+target = None
+for wo, domain in cands:
+    domain = domain or "8010"
+    c.execute(
+        "SELECT COUNT(1) FROM WorkOrdDetail WHERE Domain=? AND WorkOrd=? AND ISNULL(IsActive,0)=1",
+        (domain, wo),
+    )
+    dc = c.fetchone()[0]
+    if dc == 0:
+        target = (wo, domain)
+        break
+cn.close()
+print("target_not_on_165", target)
+if not target:
+    raise SystemExit("no candidate without 165 detail")
+
+wo, domain = target
+# lot
+req = request.Request(
+    f"{BASE}/api/WorkOrder/dispatch/next-lot-serial?tenantId={TENANT}&workOrd={wo}",
+    method="GET",
+)
+with request.urlopen(req, timeout=60) as r:
+    lot = json.loads(r.read().decode()).get("lotSerial")
+print("lot", lot)
+body = json.dumps(
+    {
+        "workOrd": wo,
+        "tenantId": TENANT,
+        "ordDate": date.today().isoformat(),
+        "lotSerial": lot,
+        "userAccount": "aidop",
+    }
+).encode()
+req = request.Request(f"{BASE}/api/WorkOrder/dispatch/release", data=body, method="POST")
+req.add_header("Content-Type", "application/json; charset=utf-8")
+try:
+    with request.urlopen(req, timeout=120) as r:
+        print("release", r.status, r.read().decode("utf-8", "replace")[:800])
+except error.HTTPError as e:
+    print("release", e.code, e.read().decode("utf-8", "replace")[:800])

+ 42 - 0
doc/plan/旧DOP-MES-WMS/对接任务书/tools/_wp4_outbox_diag.py

@@ -0,0 +1,42 @@
+import json
+from pathlib import Path
+import pymysql
+
+cfg = json.loads((Path.home() / ".cursor" / "mcp.json").read_text(encoding="utf-8"))
+my = cfg["mcpServers"]["mysql-aidopdev"]["env"]
+conn = pymysql.connect(
+    host=my["MYSQL_HOST"], port=int(my["MYSQL_PORT"]), user=my["MYSQL_USER"],
+    password=my["MYSQL_PASSWORD"], database=my["MYSQL_DATABASE"],
+    connect_timeout=15, read_timeout=60, charset="utf8mb4",
+)
+cur = conn.cursor()
+cur.execute(
+    """
+    SELECT status, COUNT(1) FROM mdp_outbox
+    WHERE action_code IN ('WO_MES_WOM','WO_MES_WOD','WO_MES_WOR')
+    GROUP BY status
+    """
+)
+print("wo_mes_dist", cur.fetchall())
+cur.execute(
+    """
+    SELECT id, action_code, status, retry_count, LEFT(IFNULL(error_msg,''),120),
+           LEFT(IFNULL(last_error_code,''),40), next_retry_time
+    FROM mdp_outbox
+    WHERE action_code IN ('WO_MES_WOM','WO_MES_WOD','WO_MES_WOR')
+    ORDER BY id DESC LIMIT 8
+    """
+)
+print("sample", cur.fetchall())
+cur.execute(
+    """
+    SELECT id, action_code, status, retry_count, LEFT(IFNULL(error_msg,''),120)
+    FROM mdp_outbox
+    WHERE action_code LIKE 'PICK%%' AND idem_key LIKE '%M500000004%'
+    ORDER BY id DESC LIMIT 10
+    """
+)
+print("pick", cur.fetchall())
+cur.execute("SELECT status, COUNT(1) FROM mdp_outbox GROUP BY status")
+print("all_dist", cur.fetchall())
+conn.close()

+ 148 - 0
doc/plan/旧DOP-MES-WMS/对接任务书/tools/_wp4_wo_push_smoke.py

@@ -0,0 +1,148 @@
+"""WP4 smoke: push WO snapshot to 165, wait outbox, verify counts."""
+import json
+import time
+from pathlib import Path
+from urllib import error, request
+
+import pymysql
+import pyodbc
+
+BASE = "http://127.0.0.1:5005"
+WO = "M500000004"
+TENANT = 797403760988229
+DOMAIN = "8010"
+
+
+def http_json(method, url, body=None, timeout=180):
+    data = None if body is None else json.dumps(body).encode("utf-8")
+    req = request.Request(url, data=data, method=method)
+    req.add_header("Content-Type", "application/json; charset=utf-8")
+    try:
+        with request.urlopen(req, timeout=timeout) as resp:
+            raw = resp.read().decode("utf-8", errors="replace")
+            return resp.status, json.loads(raw) if raw else None
+    except error.HTTPError as e:
+        raw = e.read().decode("utf-8", errors="replace")
+        try:
+            payload = json.loads(raw)
+        except Exception:
+            payload = {"raw": raw}
+        return e.code, payload
+
+
+print("=== push ===")
+st, res = http_json(
+    "POST",
+    f"{BASE}/api/aidop/wms-workord/push",
+    {"workOrd": WO, "tenantId": TENANT, "domain": DOMAIN},
+)
+print(st, json.dumps(res, ensure_ascii=False)[:1200])
+
+cfg = json.loads((Path.home() / ".cursor" / "mcp.json").read_text(encoding="utf-8"))
+my = cfg["mcpServers"]["mysql-aidopdev"]["env"]
+ss = cfg["mcpServers"]["sqlserver-dopdemorq"]["env"]
+
+conn = pymysql.connect(
+    host=my["MYSQL_HOST"],
+    port=int(my["MYSQL_PORT"]),
+    user=my["MYSQL_USER"],
+    password=my["MYSQL_PASSWORD"],
+    database=my["MYSQL_DATABASE"],
+    connect_timeout=15,
+    read_timeout=60,
+    charset="utf8mb4",
+)
+cur = conn.cursor()
+
+for i in range(30):
+    cur.execute(
+        """
+        SELECT status, COUNT(1)
+        FROM mdp_outbox
+        WHERE action_code LIKE 'WO_MES%%' AND idem_key LIKE %s
+        GROUP BY status
+        """,
+        (f"%{WO}%",),
+    )
+    dist = cur.fetchall()
+    print(f"outbox[{i}]", dist)
+    pending = sum(c for s, c in dist if s == 0)
+    failed = sum(c for s, c in dist if s == 2)
+    if pending == 0 and failed == 0 and dist:
+        break
+    if failed:
+        cur.execute(
+            """
+            SELECT id, action_code, LEFT(IFNULL(error_msg,''),160)
+            FROM mdp_outbox
+            WHERE action_code LIKE 'WO_MES%%' AND idem_key LIKE %s AND status=2
+            ORDER BY id DESC LIMIT 5
+            """,
+            (f"%{WO}%",),
+        )
+        print("failures", cur.fetchall())
+        break
+    time.sleep(2)
+
+cur.execute(
+    """
+    SELECT COUNT(1) FROM WorkOrdDetail
+    WHERE tenant_id=%s AND WorkOrd=%s AND IFNULL(IsActive,0)=1
+    """,
+    (TENANT, WO),
+)
+local_d = cur.fetchone()[0]
+cur.execute(
+    """
+    SELECT COUNT(1) FROM WorkOrdRouting
+    WHERE tenant_id=%s AND WorkOrd=%s AND IFNULL(IsActive,0)=1
+    """,
+    (TENANT, WO),
+)
+local_r = cur.fetchone()[0]
+print("local_counts", local_d, local_r)
+conn.close()
+
+cs = (
+    f"DRIVER={{ODBC Driver 17 for SQL Server}};"
+    f"SERVER={ss['SQLSERVER_HOST']},{ss['SQLSERVER_PORT']};"
+    f"DATABASE={ss['SQLSERVER_DATABASE']};"
+    f"UID={ss['SQLSERVER_USER']};PWD={ss['SQLSERVER_PASSWORD']};"
+    f"TrustServerCertificate=yes;"
+)
+cn = pyodbc.connect(cs, timeout=15)
+c = cn.cursor()
+c.execute(
+    "SELECT WorkOrd, Status, Batch FROM WorkOrdMaster WHERE Domain=? AND WorkOrd=?",
+    (DOMAIN, WO),
+)
+print("165_wo", c.fetchall())
+c.execute(
+    "SELECT COUNT(1) FROM WorkOrdDetail WHERE Domain=? AND WorkOrd=? AND ISNULL(IsActive,0)=1",
+    (DOMAIN, WO),
+)
+print("165_detail", c.fetchone())
+c.execute(
+    "SELECT COUNT(1) FROM WorkOrdRouting WHERE Domain=? AND WorkOrd=? AND ISNULL(IsActive,0)=1",
+    (DOMAIN, WO),
+)
+print("165_routing", c.fetchone())
+c.execute(
+    """
+    SELECT TOP 5 OP, Status, MilestoneOp
+    FROM WorkOrdRouting
+    WHERE Domain=? AND WorkOrd=? AND MilestoneOp=1
+    ORDER BY OP
+    """,
+    (DOMAIN, WO),
+)
+print("165_milestones", c.fetchall())
+cn.close()
+
+print("=== create-pick (existed + WOR reconcile) ===")
+st2, res2 = http_json(
+    "POST",
+    f"{BASE}/api/aidop/wms-pick/create",
+    {"workOrd": WO, "tenantId": TENANT, "domain": DOMAIN, "userAccount": "aidop"},
+)
+print(st2, json.dumps(res2, ensure_ascii=False)[:1500])

+ 94 - 0
doc/plan/旧DOP-MES-WMS/对接任务书/tools/_wp9_post_smoke_check.py

@@ -0,0 +1,94 @@
+"""Post WP9 smoke: outbox + 165 WO/routing/MobileTask."""
+import json
+from pathlib import Path
+
+import pymysql
+import pyodbc
+
+WO = "M500000004"
+NBR = "SM20260809005"
+
+cfg = json.loads((Path.home() / ".cursor" / "mcp.json").read_text(encoding="utf-8"))
+my = cfg["mcpServers"]["mysql-aidopdev"]["env"]
+ss = cfg["mcpServers"]["sqlserver-dopdemorq"]["env"]
+
+conn = pymysql.connect(
+    host=my["MYSQL_HOST"],
+    port=int(my["MYSQL_PORT"]),
+    user=my["MYSQL_USER"],
+    password=my["MYSQL_PASSWORD"],
+    database=my["MYSQL_DATABASE"],
+    connect_timeout=15,
+    read_timeout=60,
+    charset="utf8mb4",
+)
+cur = conn.cursor()
+cur.execute(
+    """
+    SELECT id, action_code, status, retry_count,
+           LEFT(IFNULL(error_msg,''), 100) AS err,
+           LEFT(idem_key, 60) AS idem
+    FROM mdp_outbox
+    WHERE idem_key LIKE %s OR payload_json LIKE %s
+    ORDER BY id DESC
+    LIMIT 30
+    """,
+    (f"%{WO}%", f"%{WO}%"),
+)
+print("outbox", cur.fetchall())
+cur.execute(
+    "SELECT WorkOrd, Status, Batch, LotSerial FROM WorkOrdMaster WHERE WorkOrd=%s",
+    (WO,),
+)
+print("local_wo", cur.fetchone())
+cur.execute(
+    """
+    SELECT status, COUNT(1)
+    FROM mdp_outbox
+    GROUP BY status
+    ORDER BY status
+    """
+)
+print("outbox_status_dist", cur.fetchall())
+conn.close()
+
+cs = (
+    f"DRIVER={{ODBC Driver 17 for SQL Server}};"
+    f"SERVER={ss['SQLSERVER_HOST']},{ss['SQLSERVER_PORT']};"
+    f"DATABASE={ss['SQLSERVER_DATABASE']};"
+    f"UID={ss['SQLSERVER_USER']};PWD={ss['SQLSERVER_PASSWORD']};"
+    f"TrustServerCertificate=yes;"
+)
+cn = pyodbc.connect(cs, timeout=15)
+c = cn.cursor()
+c.execute(
+    "SELECT WorkOrd, Status, Batch, LotSerial FROM WorkOrdMaster WHERE WorkOrd=?",
+    (WO,),
+)
+print("165_wo", c.fetchall())
+c.execute(
+    "SELECT TOP 8 OP, Status FROM WorkOrdRouting WHERE WorkOrd=? ORDER BY OP",
+    (WO,),
+)
+print("165_routing", c.fetchall())
+c.execute(
+    "SELECT COUNT(1) FROM MobileTask WHERE TaskID=? OR TaskID LIKE ?",
+    (NBR, f"%{NBR}%"),
+)
+print("mobile_task_cnt", c.fetchone())
+c.execute(
+    "SELECT Nbr, WorkOrd, Status, User1, IsActive FROM NbrMaster WHERE Nbr=?",
+    (NBR,),
+)
+print("165_sm", c.fetchall())
+c.execute(
+    """
+    SELECT COUNT(1) AS dc
+    FROM NbrDetail d
+    JOIN NbrMaster m ON d.NbrRecID = m.RecID
+    WHERE m.Nbr=?
+    """,
+    (NBR,),
+)
+print("165_sm_detail", c.fetchone())
+cn.close()

+ 83 - 0
doc/plan/旧DOP-MES-WMS/对接任务书/tools/_wp9_routing_gap.py

@@ -0,0 +1,83 @@
+"""Investigate missing 165 WorkOrdRouting after M500000004 release."""
+import json
+from pathlib import Path
+
+import pymysql
+import pyodbc
+
+WO = "M500000004"
+TENANT = 797403760988229
+
+cfg = json.loads((Path.home() / ".cursor" / "mcp.json").read_text(encoding="utf-8"))
+my = cfg["mcpServers"]["mysql-aidopdev"]["env"]
+ss = cfg["mcpServers"]["sqlserver-dopdemorq"]["env"]
+
+conn = pymysql.connect(
+    host=my["MYSQL_HOST"],
+    port=int(my["MYSQL_PORT"]),
+    user=my["MYSQL_USER"],
+    password=my["MYSQL_PASSWORD"],
+    database=my["MYSQL_DATABASE"],
+    connect_timeout=15,
+    read_timeout=60,
+    charset="utf8mb4",
+)
+cur = conn.cursor()
+cur.execute(
+    """
+    SELECT COUNT(1), SUM(CASE WHEN IFNULL(MilestoneOp,0)=1 THEN 1 ELSE 0 END)
+    FROM WorkOrdRouting
+    WHERE tenant_id=%s AND WorkOrd=%s
+    """,
+    (TENANT, WO),
+)
+print("local_routing_total_milestone", cur.fetchone())
+cur.execute(
+    """
+    SELECT OP, Status, MilestoneOp
+    FROM WorkOrdRouting
+    WHERE tenant_id=%s AND WorkOrd=%s
+    ORDER BY OP
+    LIMIT 15
+    """,
+    (TENANT, WO),
+)
+print("local_routing_sample", cur.fetchall())
+cur.execute(
+    """
+    SELECT id, action_code, status, LEFT(idem_key,80), LEFT(IFNULL(error_msg,''),120)
+    FROM mdp_outbox
+    WHERE action_code LIKE 'PICK%%' OR idem_key LIKE %s OR payload_json LIKE %s
+    ORDER BY id DESC
+    LIMIT 40
+    """,
+    (f"%{WO}%", f"%{WO}%"),
+)
+print("pick_outbox", cur.fetchall())
+cur.execute(
+    """
+    SELECT LEFT(detail_json, 800)
+    FROM aidop_action_run_log
+    WHERE biz_no=%s
+    ORDER BY id DESC
+    LIMIT 1
+    """,
+    (WO,),
+)
+print("run_log_detail", cur.fetchone())
+conn.close()
+
+cs = (
+    f"DRIVER={{ODBC Driver 17 for SQL Server}};"
+    f"SERVER={ss['SQLSERVER_HOST']},{ss['SQLSERVER_PORT']};"
+    f"DATABASE={ss['SQLSERVER_DATABASE']};"
+    f"UID={ss['SQLSERVER_USER']};PWD={ss['SQLSERVER_PASSWORD']};"
+    f"TrustServerCertificate=yes;"
+)
+cn = pyodbc.connect(cs, timeout=15)
+c = cn.cursor()
+c.execute("SELECT COUNT(1) FROM WorkOrdRouting WHERE WorkOrd=?", (WO,))
+print("165_routing_cnt", c.fetchone())
+c.execute("SELECT COUNT(1) FROM WorkOrdDetail WHERE WorkOrd=?", (WO,))
+print("165_detail_cnt", c.fetchone())
+cn.close()

+ 109 - 0
doc/plan/旧DOP-MES-WMS/对接任务书/tools/_wp9_smoke_release.py

@@ -0,0 +1,109 @@
+"""WP9 smoke: release one WO via dispatch/release, verify MySQL + 165."""
+import json
+import sys
+from datetime import date
+from pathlib import Path
+from urllib import error, request
+
+import pymysql
+import pyodbc
+
+BASE = "http://127.0.0.1:5005"
+WO = sys.argv[1] if len(sys.argv) > 1 else "M500000004"
+TENANT = int(sys.argv[2]) if len(sys.argv) > 2 else 797403760988229
+
+
+def http_json(method: str, url: str, body=None, timeout=120):
+    data = None if body is None else json.dumps(body).encode("utf-8")
+    req = request.Request(url, data=data, method=method)
+    req.add_header("Content-Type", "application/json; charset=utf-8")
+    try:
+        with request.urlopen(req, timeout=timeout) as resp:
+            raw = resp.read().decode("utf-8", errors="replace")
+            return resp.status, json.loads(raw) if raw else None
+    except error.HTTPError as e:
+        raw = e.read().decode("utf-8", errors="replace")
+        try:
+            payload = json.loads(raw)
+        except Exception:
+            payload = {"raw": raw}
+        return e.code, payload
+
+
+print("=== next lot ===")
+st, lot = http_json(
+    "GET",
+    f"{BASE}/api/WorkOrder/dispatch/next-lot-serial?tenantId={TENANT}&workOrd={WO}",
+)
+print(st, lot)
+lot_serial = None
+if isinstance(lot, dict):
+    lot_serial = lot.get("lotSerial")
+    if not lot_serial and isinstance(lot.get("data"), dict):
+        lot_serial = lot["data"].get("lotSerial")
+    if not lot_serial and isinstance(lot.get("result"), dict):
+        lot_serial = lot["result"].get("lotSerial")
+if not lot_serial:
+    raise SystemExit(f"failed to get lotSerial: {st} {lot}")
+
+print("lot_serial", lot_serial)
+
+print("=== release ===")
+body = {
+    "workOrd": WO,
+    "tenantId": TENANT,
+    "ordDate": date.today().isoformat(),
+    "lotSerial": lot_serial,
+    "userAccount": "aidop",
+}
+st, res = http_json("POST", f"{BASE}/api/WorkOrder/dispatch/release", body)
+print(st, json.dumps(res, ensure_ascii=False, indent=2)[:2000])
+
+cfg = json.loads((Path.home() / ".cursor" / "mcp.json").read_text(encoding="utf-8"))
+my = cfg["mcpServers"]["mysql-aidopdev"]["env"]
+ss = cfg["mcpServers"]["sqlserver-dopdemorq"]["env"]
+
+conn = pymysql.connect(
+    host=my["MYSQL_HOST"], port=int(my["MYSQL_PORT"]), user=my["MYSQL_USER"],
+    password=my["MYSQL_PASSWORD"], database=my["MYSQL_DATABASE"],
+    connect_timeout=15, read_timeout=60, charset="utf8mb4",
+)
+cur = conn.cursor()
+cur.execute(
+    "SELECT WorkOrd, Status, Batch, LotSerial FROM WorkOrdMaster WHERE WorkOrd=%s AND tenant_id=%s",
+    (WO, TENANT),
+)
+print("local_wo", cur.fetchone())
+cur.execute(
+    """
+    SELECT message, LEFT(detail_json, 500)
+    FROM aidop_action_run_log
+    WHERE action_code='S2_WORK_ORDER_RELEASE' AND biz_no=%s
+    ORDER BY id DESC LIMIT 1
+    """,
+    (WO,),
+)
+print("run_log", cur.fetchone())
+cur.execute("SELECT COUNT(1) FROM mdp_std_production_issue")
+print("std_rows", cur.fetchone()[0])
+conn.close()
+
+server = f"{ss['SQLSERVER_HOST']},{ss['SQLSERVER_PORT']}"
+cs = (
+    f"DRIVER={{ODBC Driver 17 for SQL Server}};SERVER={server};DATABASE={ss['SQLSERVER_DATABASE']};"
+    f"UID={ss['SQLSERVER_USER']};PWD={ss['SQLSERVER_PASSWORD']};TrustServerCertificate=yes;"
+)
+cn = pyodbc.connect(cs, timeout=15)
+c2 = cn.cursor()
+c2.execute(
+    """
+    SELECT Nbr, WorkOrd, Status, User1,
+           (SELECT COUNT(1) FROM NbrDetail d WHERE d.NbrRecID=m.RecID) AS dc
+    FROM NbrMaster m
+    WHERE Type='SM' AND WorkOrd=?
+    ORDER BY RecID DESC
+    """,
+    (WO,),
+)
+print("165_sm", c2.fetchall())
+cn.close()

+ 55 - 0
doc/plan/旧DOP-MES-WMS/对接任务书/tools/_wp9_smoke_verify.py

@@ -0,0 +1,55 @@
+"""WP9 smoke verify: std sync + create-pick idempotency."""
+import json
+from pathlib import Path
+from urllib import error, request
+
+import pymysql
+
+BASE = "http://127.0.0.1:5005"
+WO = "M500000004"
+TENANT = 797403760988229
+
+cfg = json.loads((Path.home() / ".cursor" / "mcp.json").read_text(encoding="utf-8"))
+my = cfg["mcpServers"]["mysql-aidopdev"]["env"]
+conn = pymysql.connect(
+    host=my["MYSQL_HOST"],
+    port=int(my["MYSQL_PORT"]),
+    user=my["MYSQL_USER"],
+    password=my["MYSQL_PASSWORD"],
+    database=my["MYSQL_DATABASE"],
+    connect_timeout=15,
+    read_timeout=60,
+    charset="utf8mb4",
+)
+cur = conn.cursor()
+cur.execute(
+    """
+    SELECT nbr, work_ord, source_system, status, sync_batch_id
+    FROM mdp_std_production_issue
+    ORDER BY id DESC
+    LIMIT 5
+    """
+)
+print("std", cur.fetchall())
+cur.execute(
+    "SELECT COUNT(1) FROM NbrMaster WHERE Type=%s AND WorkOrd=%s",
+    ("SM", WO),
+)
+print("local_sm_count", cur.fetchone())
+conn.close()
+
+body = json.dumps(
+    {
+        "domain": "8010",
+        "workOrd": WO,
+        "tenantId": TENANT,
+        "userAccount": "aidop",
+    }
+).encode()
+req = request.Request(f"{BASE}/api/aidop/wms-pick/create", data=body, method="POST")
+req.add_header("Content-Type", "application/json; charset=utf-8")
+try:
+    with request.urlopen(req, timeout=120) as r:
+        print("create", r.status, r.read().decode("utf-8", "replace")[:2000])
+except error.HTTPError as e:
+    print("create", e.code, e.read().decode("utf-8", "replace")[:2000])

+ 3 - 2
doc/plan/旧DOP-MES-WMS/对接任务书/执行进度-2026-08-09.md

@@ -1,13 +1,14 @@
 # 执行进度(2026-08-09)
 # 执行进度(2026-08-09)
 
 
-后端版本目标 **1.0.309** / 前端 **2.4.279**(WP10 全包 + WP9 S1–S5)。
+后端版本目标 **1.0.310** / 前端 **2.4.279**(WP10 全包 + WP9 S1–S5 + WP4 工单入 165)。
 
 
 ## 已完成(含本轮收尾)
 ## 已完成(含本轮收尾)
 
 
 | WP | 内容 | 证据 |
 | WP | 内容 | 证据 |
 |----|------|------|
 |----|------|------|
 | 源启用 / WP2 / WP3 / WP7§2–3 API / WP8 | 见前序 | 真库取号、宿主 Outbox `via=DB`、职责种子 |
 | 源启用 / WP2 / WP3 / WP7§2–3 API / WP8 | 见前序 | 真库取号、宿主 Outbox `via=DB`、职责种子 |
-| **WP4 CreatePickBill** | `CreatePickBillService`:本库读工单→`sm` 取号→165 直写 `NbrMaster/Detail`→本库 `Status/Batch=r`→Outbox **UPSERT** 工单头;里程碑工序 165 直写 | API 烟测:`M500000005` → `SM20260809003`;本轮改为 UPSERT,165 无 WO 时可插入计划侧最小集 |
+| **WP4 工单入 165** | 评审/刷新/确认后 `WorkOrdMesPushService` Outbox UPSERT Master/Detail/Routing;补偿 `POST /api/aidop/wms-workord/push`;Mes 下达校验 `WO_MES_NOT_READY` | 烟测 `M500000004`:165 明细 55 / 工序 36;`wms-pick/create` NOOP + `PICK_WOR_STATUS`×3 |
+| **WP4 CreatePickBill** | `CreatePickBillService`:本库读工单→`sm` 取号→165 直写 `NbrMaster/Detail`→本库 `Status/Batch=r`→Outbox **UPSERT** 工单头;里程碑工序 Outbox UPDATE | API 烟测:`M500000005` → `SM20260809003`;本轮改为 UPSERT |
 | **D1** | 本动作**不写** Routing `C→W` | 仅 MilestoneOp 且非 C → `r` |
 | **D1** | 本动作**不写** Routing `C→W` | 仅 MilestoneOp 且非 C → `r` |
 | **WP7 §2 前端** | S5 仓储下 6 页:`/aidop/s5/wms-base/*`,`FUNC-S5-020`…`025`,调用 `wms-base/page|upsert` | 菜单种子 + `wmsBaseMasterList.vue` |
 | **WP7 §2 前端** | S5 仓储下 6 页:`/aidop/s5/wms-base/*`,`FUNC-S5-020`…`025`,调用 `wms-base/page|upsert` | 菜单种子 + `wmsBaseMasterList.vue` |
 | **下达挂接** | S1 工单池下达:工具栏「MES下达建领料」+ 行「MES领料」→ `wms-pick/create` | `workOrderDispatchList.vue` |
 | **下达挂接** | S1 工单池下达:工具栏「MES下达建领料」+ 行「MES领料」→ `wms-pick/create` | `workOrderDispatchList.vue` |

+ 14 - 1
doc/plan/旧DOP-MES-WMS/对接任务书/附-字段共管矩阵.md

@@ -292,8 +292,21 @@
 
 
 | 动作 | 表.列(允许) | 明确禁止 |
 | 动作 | 表.列(允许) | 明确禁止 |
 |------|---------------|----------|
 |------|---------------|----------|
+| 评审写工单 `WorkOrdMesPush` | `WorkOrdMaster/Detail/Routing` 计划侧列 UPSERT(建行 + `QtyOrded`/`QtyRequired`/`Status` 计划态) | 执行列 `QtyComplete*`/`QtyIssued`/`QtyPosted` 等 |
 | 建领料 `CreatePickBill` | `WorkOrdMaster.Status→r`,`Batch`;里程碑 `WorkOrdRouting.Status→r`;`NbrMaster`/`NbrDetail` 整行 INSERT(含 User1/2 **仅此次**) | 之后 UPDATE 带 `User1`/`User2`;写 `QtyRec`/`IsConfirm` |
 | 建领料 `CreatePickBill` | `WorkOrdMaster.Status→r`,`Batch`;里程碑 `WorkOrdRouting.Status→r`;`NbrMaster`/`NbrDetail` 整行 INSERT(含 User1/2 **仅此次**) | 之后 UPDATE 带 `User1`/`User2`;写 `QtyRec`/`IsConfirm` |
 
 
+### 13.0 计划侧工单入 165(WP4 · `WorkOrdMesPushService`)
+
+挂接点:订单评审 / 计划刷新 / 交期确认(本库三人组齐后入队);补偿 `POST /api/aidop/wms-workord/push`。
+
+| 目标 | 通道 | keys(对齐 165 唯一索引) | 幂等键 |
+|------|------|---------------------------|--------|
+| 165 `WorkOrdMaster` | Outbox UPSERT `WO_MES_WOM` | `{Domain, Typed, WorkOrd}` | `wo\|{domain}\|{workOrd}\|wom` |
+| 165 `WorkOrdDetail` | Outbox UPSERT `WO_MES_WOD` | `{Domain, ID='', WorkOrd, Op, Line, ItemNum, Dimension1='', Dimension2=''}` | `wo\|{domain}\|{workOrd}\|wod\|{line}\|{op}\|{item}` |
+| 165 `WorkOrdRouting` | Outbox UPSERT `WO_MES_WOR` | `{Domain, ID='', OP, WorkOrd}` | `wo\|{domain}\|{workOrd}\|wor\|{op}` |
+
+下达前 Mes 模式校验 165 三人组就绪(`WO_MES_NOT_READY`),未推送不得建领料。
+
 ### 13.1 建领料的回写通道([`WP10`](./WP10-跨库成败判定与失败兜底.md) S1 后)
 ### 13.1 建领料的回写通道([`WP10`](./WP10-跨库成败判定与失败兜底.md) S1 后)
 
 
 白名单**列**不变,但**写入通道**有调整,对账与故障处置需按此判断:
 白名单**列**不变,但**写入通道**有调整,对账与故障处置需按此判断:
@@ -302,7 +315,7 @@
 |------|------|--------|
 |------|------|--------|
 | 165 `NbrMaster` / `NbrDetail` | **165 直写**,包在 165 本地事务内(需 identity `RecID` 回填明细) | 无(靠建单前的三分类探查去重) |
 | 165 `NbrMaster` / `NbrDetail` | **165 直写**,包在 165 本地事务内(需 identity `RecID` 回填明细) | 无(靠建单前的三分类探查去重) |
 | 165 `WorkOrdMaster.Status`/`Batch` | **Outbox UPSERT**(`action_code=PICK_WOM_UPSERT`) | `pick\|{domain}\|{workOrd}\|wom` |
 | 165 `WorkOrdMaster.Status`/`Batch` | **Outbox UPSERT**(`action_code=PICK_WOM_UPSERT`) | `pick\|{domain}\|{workOrd}\|wom` |
-| 165 里程碑 `WorkOrdRouting.Status` | **Outbox UPDATE,逐工序一条**(原为裸直写,WP10 F8 改造)。执行器按全 keys 等值匹配,故不能一条消息覆盖多工序。keys = `{Domain, WorkOrd, OP}`(**工序列名是 `OP`、int**;唯一索引虽含 `ID`,但该列 Ai-DOP 不写、值为 NULL,**不得入 keys**) | `pick\|{domain}\|{workOrd}\|{op}\|wor` |
+| 165 里程碑 `WorkOrdRouting.Status` | **Outbox UPDATE,逐工序一条**(原为裸直写,WP10 F8 改造)。keys = `{Domain, WorkOrd, OP}`(行须已由 §13.0 推入) | `pick\|{domain}\|{workOrd}\|{op}\|wor` |
 | 本库 `WorkOrdMaster` / `WorkOrdRouting` | MySQL 本地事务,与 Outbox 入队同事务 | — |
 | 本库 `WorkOrdMaster` / `WorkOrdRouting` | MySQL 本地事务,与 Outbox 入队同事务 | — |
 
 
 > 三条 165 通道**均无跨库事务**。判断「该列是否已生效」时,Outbox 通道要多看一步 `mdp_outbox.status`;直写通道则以 165 现值为准。
 > 三条 165 通道**均无跨库事务**。判断「该列是否已生效」时,Outbox 通道要多看一步 `mdp_outbox.status`;直写通道则以 165 现值为准。

+ 3 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET ͨ��Ȩ�޿���ƽ̨</Description>
     <Description>Admin.NET ͨ��Ȩ�޿���ƽ̨</Description>
-    <AssemblyVersion>1.0.309</AssemblyVersion>
-    <FileVersion>1.0.309</FileVersion>
-    <Version>1.0.309</Version>
+    <AssemblyVersion>1.0.310</AssemblyVersion>
+    <FileVersion>1.0.310</FileVersion>
+    <Version>1.0.310</Version>
   </PropertyGroup>
   </PropertyGroup>
 
 
   <ItemGroup>
   <ItemGroup>

+ 19 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Executors/MdpDbPushExecutor.cs

@@ -127,7 +127,25 @@ public sealed class MdpDbPushExecutor : IMdpTargetPushExecutor, ITransient
         if (p.Keys.Count == 0) return MdpPushResult.Fail("UPSERT 必须提供 keys");
         if (p.Keys.Count == 0) return MdpPushResult.Fail("UPSERT 必须提供 keys");
         if (await ExistsAsync(db, p.Table, p.Keys, ct))
         if (await ExistsAsync(db, p.Table, p.Keys, ct))
             return await ExecUpdateAsync(db, p, ct);
             return await ExecUpdateAsync(db, p, ct);
-        return await ExecInsertAsync(db, p, ct);
+
+        try
+        {
+            return await ExecInsertAsync(db, p, ct);
+        }
+        catch (Exception ex)
+        {
+            // 并发或唯一索引列宽于 keys 时,INSERT 撞唯一约束 → 收敛为 UPDATE
+            var err = ex.Message ?? "";
+            if (err.Contains("唯一", StringComparison.Ordinal)
+                || err.Contains("UNIQUE", StringComparison.OrdinalIgnoreCase)
+                || err.Contains("duplicate", StringComparison.OrdinalIgnoreCase)
+                || err.Contains("2601", StringComparison.Ordinal)
+                || err.Contains("2627", StringComparison.Ordinal))
+            {
+                return await ExecUpdateAsync(db, p, ct);
+            }
+            throw;
+        }
     }
     }
 
 
     private static async Task<bool> ExistsAsync(
     private static async Task<bool> ExistsAsync(

+ 60 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Executors/MdpOutboxEnqueueService.cs

@@ -37,4 +37,64 @@ public sealed class MdpOutboxEnqueueService : ITransient
         if (pulse) _wake.Pulse();
         if (pulse) _wake.Pulse();
         return true;
         return true;
     }
     }
+
+    /// <summary>
+    /// 入队或刷新:同 idem_key 已存在则覆盖 payload 并复位为待推(供工单快照重推)。
+    /// 返回 true 表示新建或已刷新;false 仅在参数非法时不应出现。
+    /// </summary>
+    public async Task<bool> TryEnqueueOrRefreshAsync(MdpOutbox item, CancellationToken ct = default, bool pulse = true)
+    {
+        if (item == null) throw new ArgumentNullException(nameof(item));
+        if (string.IsNullOrWhiteSpace(item.IdemKey))
+            throw new ArgumentException("idem_key 不能为空", nameof(item));
+
+        var existingList = await _db.Queryable<MdpOutbox>()
+            .Where(x => x.IdemKey == item.IdemKey)
+            .OrderByDescending(x => x.Id)
+            .Take(1)
+            .ToListAsync(ct);
+        var existing = existingList.FirstOrDefault();
+
+        var now = DateTime.Now;
+        if (existing != null)
+        {
+            existing.TargetSourceCode = item.TargetSourceCode;
+            existing.ActionCode = item.ActionCode;
+            existing.PayloadJson = item.PayloadJson;
+            existing.TenantId = item.TenantId;
+            existing.Status = 0;
+            existing.RetryCount = 0;
+            existing.NextRetryTime = null;
+            existing.LastErrorCode = null;
+            existing.ErrorMsg = null;
+            existing.ResponseJson = null;
+            existing.UpdateTime = now;
+            await _db.Updateable(existing)
+                .UpdateColumns(x => new
+                {
+                    x.TargetSourceCode,
+                    x.ActionCode,
+                    x.PayloadJson,
+                    x.TenantId,
+                    x.Status,
+                    x.RetryCount,
+                    x.NextRetryTime,
+                    x.LastErrorCode,
+                    x.ErrorMsg,
+                    x.ResponseJson,
+                    x.UpdateTime
+                })
+                .ExecuteCommandAsync(ct);
+            if (pulse) _wake.Pulse();
+            return true;
+        }
+
+        item.Status = 0;
+        item.RetryCount = 0;
+        item.CreateTime = now;
+        item.UpdateTime = now;
+        await _db.Insertable(item).ExecuteCommandAsync(ct);
+        if (pulse) _wake.Pulse();
+        return true;
+    }
 }
 }

+ 29 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Wms/CreatePickBillService.cs

@@ -30,6 +30,7 @@ public class CreatePickBillService : IDynamicApiController, ITransient
     private readonly KeeperResolveService _keeper;
     private readonly KeeperResolveService _keeper;
     private readonly MdpOutboxEnqueueService _enqueue;
     private readonly MdpOutboxEnqueueService _enqueue;
     private readonly MdpOutboxWakeSignal _wake;
     private readonly MdpOutboxWakeSignal _wake;
+    private readonly WorkOrdMesPushService _workOrdMesPush;
     private readonly ILogger<CreatePickBillService> _logger;
     private readonly ILogger<CreatePickBillService> _logger;
 
 
     public CreatePickBillService(
     public CreatePickBillService(
@@ -39,6 +40,7 @@ public class CreatePickBillService : IDynamicApiController, ITransient
         KeeperResolveService keeper,
         KeeperResolveService keeper,
         MdpOutboxEnqueueService enqueue,
         MdpOutboxEnqueueService enqueue,
         MdpOutboxWakeSignal wake,
         MdpOutboxWakeSignal wake,
+        WorkOrdMesPushService workOrdMesPush,
         ILogger<CreatePickBillService> logger)
         ILogger<CreatePickBillService> logger)
     {
     {
         _db = db;
         _db = db;
@@ -47,6 +49,7 @@ public class CreatePickBillService : IDynamicApiController, ITransient
         _keeper = keeper;
         _keeper = keeper;
         _enqueue = enqueue;
         _enqueue = enqueue;
         _wake = wake;
         _wake = wake;
+        _workOrdMesPush = workOrdMesPush;
         _logger = logger;
         _logger = logger;
     }
     }
 
 
@@ -237,6 +240,32 @@ public class CreatePickBillService : IDynamicApiController, ITransient
                 continue;
                 continue;
             }
             }
 
 
+            // WP4:下达/建领料前要求 165 已有完整工单(评审推送)
+            try
+            {
+                var ready = await _workOrdMesPush.ProbeReadyOnMesAsync(domain, woNo, ct);
+                if (!ready.Ready)
+                {
+                    items.Add(new PickBillItemResult
+                    {
+                        WorkOrd = woNo, Result = "failed", ReasonCode = "WO_MES_NOT_READY",
+                        Reason = $"MES 端工单未就绪(头={ready.MasterCnt},明细={ready.DetailCnt},工序={ready.RoutingCnt})",
+                        Hint = "请先订单评审/计划刷新推送,或 POST /api/aidop/wms-workord/push"
+                    });
+                    continue;
+                }
+            }
+            catch (Exception ex)
+            {
+                var code = ClassifyMesError(ex);
+                items.Add(new PickBillItemResult
+                {
+                    WorkOrd = woNo, Result = "failed", ReasonCode = code,
+                    Reason = DescribeMes(code, ex), Hint = HintFor(code)
+                });
+                continue;
+            }
+
             if (probe.TryGetValue(woNo, out var p) && p.HasDetail)
             if (probe.TryGetValue(woNo, out var p) && p.HasDetail)
             {
             {
                 toReconcile.Add((wo, p.Nbr));
                 toReconcile.Add((wo, p.Nbr));

+ 571 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Wms/WorkOrdMesPushService.cs

@@ -0,0 +1,571 @@
+using System.Text.Json;
+using Admin.NET.Plugin.AiDOP.DataPlatform.Executors;
+using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
+using Admin.NET.Plugin.AiDOP.WorkOrder;
+using Microsoft.Extensions.Logging;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.DataPlatform.Wms;
+
+/// <summary>
+/// WP4 · 计划侧工单入 165:本库 WorkOrdMaster/Detail/Routing 快照 → Outbox UPSERT。
+/// 挂接点:订单评审 / 计划刷新 / 交期确认;下达侧只推进状态(CreatePickBill)。
+/// </summary>
+[ApiDescriptionSettings(Order = 328, Description = "工单推送 MES")]
+[Route("api/aidop/wms-workord")]
+[AllowAnonymous]
+[NonUnify]
+public class WorkOrdMesPushService : IDynamicApiController, ITransient
+{
+    public const string TargetSource = "DOPDEMORQ_SQLSERVER";
+    public const string ActionWom = "WO_MES_WOM";
+    public const string ActionWod = "WO_MES_WOD";
+    public const string ActionWor = "WO_MES_WOR";
+
+    private readonly ISqlSugarClient _db;
+    private readonly MdpSourceScopeFactory _scopeFactory;
+    private readonly MdpOutboxEnqueueService _enqueue;
+    private readonly MdpOutboxWakeSignal _wake;
+    private readonly ILogger<WorkOrdMesPushService> _logger;
+
+    public WorkOrdMesPushService(
+        ISqlSugarClient db,
+        MdpSourceScopeFactory scopeFactory,
+        MdpOutboxEnqueueService enqueue,
+        MdpOutboxWakeSignal wake,
+        ILogger<WorkOrdMesPushService> logger)
+    {
+        _db = db;
+        _scopeFactory = scopeFactory;
+        _enqueue = enqueue;
+        _wake = wake;
+        _logger = logger;
+    }
+
+    public sealed class PushInput
+    {
+        public string WorkOrd { get; set; } = "";
+        public long TenantId { get; set; }
+        public string? Domain { get; set; }
+    }
+
+    public sealed class PushResult
+    {
+        public bool Ok { get; set; }
+        public string WorkOrd { get; set; } = "";
+        public string Domain { get; set; } = "";
+        public int Enqueued { get; set; }
+        public int DetailCount { get; set; }
+        public int RoutingCount { get; set; }
+        public string? Message { get; set; }
+    }
+
+    /// <summary>补偿 / 联调:按本库快照入队推送 165。</summary>
+    [DisplayName("推送工单到 MES")]
+    [HttpPost("push")]
+    public async Task<PushResult> Push([FromBody] PushInput input, CancellationToken ct = default)
+    {
+        if (input == null || string.IsNullOrWhiteSpace(input.WorkOrd))
+            throw Oops.Oh("工单号不能为空");
+
+        var tenantId = input.TenantId;
+        var workOrd = input.WorkOrd.Trim();
+        var domain = string.IsNullOrWhiteSpace(input.Domain)
+            ? await ResolveDomainAsync(tenantId, workOrd)
+            : input.Domain.Trim();
+
+        return await EnqueueSnapshotAsync(tenantId, domain, workOrd, ct);
+    }
+
+    /// <summary>评审/刷新/确认后调用:失败只记日志,不抛(避免拖垮本库评审事务)。</summary>
+    public async Task TryEnqueueSnapshotSafeAsync(long tenantId, string workOrd, List<string>? warnings = null)
+    {
+        try
+        {
+            if (string.IsNullOrWhiteSpace(workOrd)) return;
+            var domain = await ResolveDomainAsync(tenantId, workOrd);
+            var r = await EnqueueSnapshotAsync(tenantId, domain, workOrd);
+            warnings?.Add($"工单 {workOrd} 已入队推送 MES({r.Enqueued} 条 Outbox,明细 {r.DetailCount}/工序 {r.RoutingCount})");
+        }
+        catch (Exception ex)
+        {
+            _logger.LogWarning(ex, "[WorkOrdMesPush] enqueue failed tenant={Tenant} wo={WorkOrd}", tenantId, workOrd);
+            warnings?.Add($"工单 {workOrd} 推送 MES 入队失败:{ex.Message}(可稍后调用 /api/aidop/wms-workord/push 补偿)");
+        }
+    }
+
+    /// <summary>从本库读三人组并入队(主表 → 明细 → 工序);末尾 Pulse 一次。</summary>
+    public async Task<PushResult> EnqueueSnapshotAsync(
+        long tenantId, string domain, string workOrd, CancellationToken ct = default)
+    {
+        domain = string.IsNullOrWhiteSpace(domain) ? "8010" : domain.Trim();
+        workOrd = workOrd.Trim();
+
+        var masters = await _db.Queryable<WorkOrdMaster>()
+            .Where(m => m.TenantId == tenantId && m.WorkOrd == workOrd)
+            .Take(1)
+            .ToListAsync(ct);
+        if (masters.Count == 0)
+            throw Oops.Oh($"本库未找到工单 {workOrd}");
+        var master = masters[0];
+
+        var details = await _db.Ado.SqlQueryAsync<DetailRow>(
+            """
+            SELECT
+                TRIM(IFNULL(Domain, '')) AS Domain,
+                WorkOrd,
+                IFNULL(LineNum, 0) AS LineNum,
+                TRIM(IFNULL(ItemNum, '')) AS ItemNum,
+                IFNULL(Op, 0) AS Op,
+                TRIM(IFNULL(Location, '')) AS Location,
+                TRIM(IFNULL(Site, '')) AS Site,
+                IFNULL(QtyRequired, 0) AS QtyRequired,
+                IFNULL(FrozenBOMQty, 0) AS FrozenBOMQty,
+                TRIM(IFNULL(LotSerial, '')) AS LotSerial,
+                TRIM(IFNULL(Status, '')) AS Status,
+                TRIM(IFNULL(Typed, '')) AS Typed,
+                TRIM(IFNULL(UM, '')) AS UM,
+                IFNULL(IsActive, 0) AS IsActive,
+                IFNULL(BusinessID, 0) AS BusinessID,
+                CreateUser, CreateTime, UpdateUser, UpdateTime
+            FROM WorkOrdDetail
+            WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
+              AND IFNULL(IsActive, 0) = 1
+            ORDER BY LineNum, Op, ItemNum
+            """,
+            new SugarParameter("@TenantId", tenantId),
+            new SugarParameter("@WorkOrd", workOrd));
+
+        var routings = await _db.Ado.SqlQueryAsync<RoutingRow>(
+            """
+            SELECT
+                TRIM(IFNULL(Domain, '')) AS Domain,
+                WorkOrd,
+                OP,
+                TRIM(IFNULL(ItemNum, '')) AS ItemNum,
+                TRIM(IFNULL(Descr, '')) AS Descr,
+                IFNULL(MilestoneOp, 0) AS MilestoneOp,
+                IFNULL(ParentOp, 0) AS ParentOp,
+                IFNULL(QtyOrded, 0) AS QtyOrded,
+                TRIM(IFNULL(Status, '')) AS Status,
+                TRIM(IFNULL(ProdLine, '')) AS ProdLine,
+                TRIM(IFNULL(WorkCtr, '')) AS WorkCtr,
+                TRIM(IFNULL(Machine, '')) AS Machine,
+                IFNULL(RunCrew, 0) AS RunCrew,
+                IFNULL(StdSetupTime, 0) AS StdSetupTime,
+                IFNULL(ProcessOut, 0) AS ProcessOut,
+                IFNULL(IsActive, 0) AS IsActive,
+                CreateUser, CreateTime, UpdateUser, UpdateTime
+            FROM WorkOrdRouting
+            WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
+              AND IFNULL(IsActive, 0) = 1
+            ORDER BY OP
+            """,
+            new SugarParameter("@TenantId", tenantId),
+            new SugarParameter("@WorkOrd", workOrd));
+
+        if (details.Count == 0)
+            throw Oops.Oh($"工单 {workOrd} 无活跃物料明细,无法推送 MES");
+        if (routings.Count == 0)
+            throw Oops.Oh($"工单 {workOrd} 无活跃工序,无法推送 MES");
+
+        var masterStatus = StatusForMasterPush(master.Status);
+        var now = DateTime.Now;
+        var enqueued = 0;
+
+        var womInsert = BuildMasterInsert(domain, master, masterStatus, now);
+        var womUpdate = BuildMasterUpdate(master, masterStatus, now);
+        // 165 唯一索引 IX_WorkOrdMaster = (Domain, Typed, WorkOrd)
+        var typed = string.IsNullOrWhiteSpace(master.Typed) ? "" : master.Typed.Trim();
+        if (await EnqueueRowAsync(tenantId, $"wo|{domain}|{workOrd}|wom", ActionWom, "WorkOrdMaster",
+                new Dictionary<string, object?>
+                {
+                    ["Domain"] = domain,
+                    ["Typed"] = typed,
+                    ["WorkOrd"] = workOrd
+                },
+                womInsert, womUpdate, ct))
+            enqueued++;
+
+        foreach (var d in details)
+        {
+            var line = d.LineNum;
+            var item = d.ItemNum ?? "";
+            var op = d.Op;
+            // 165 唯一索引 IX_WorkOrdDetail = (Domain, ID, WorkOrd, Op, Line, ItemNum, Dimension1, Dimension2)
+            var keys = new Dictionary<string, object?>
+            {
+                ["Domain"] = domain,
+                ["ID"] = "",
+                ["WorkOrd"] = workOrd,
+                ["Op"] = op,
+                ["Line"] = line,
+                ["ItemNum"] = item,
+                ["Dimension1"] = "",
+                ["Dimension2"] = ""
+            };
+            var insert = BuildDetailInsert(domain, workOrd, d, now);
+            var update = BuildDetailUpdate(d, now);
+            var idem = $"wo|{domain}|{workOrd}|wod|{line}|{op}|{item}";
+            if (await EnqueueRowAsync(tenantId, idem, ActionWod, "WorkOrdDetail", keys, insert, update, ct))
+                enqueued++;
+        }
+
+        foreach (var r in routings)
+        {
+            var routingStatus = StatusForRoutingPush(masterStatus, r.Status);
+            // 165 唯一索引 IX_WorkOrdRouting_1 = (Domain, ID, OP, WorkOrd)
+            var keys = new Dictionary<string, object?>
+            {
+                ["Domain"] = domain,
+                ["ID"] = "",
+                ["OP"] = r.OP,
+                ["WorkOrd"] = workOrd
+            };
+            var insert = BuildRoutingInsert(domain, workOrd, r, routingStatus, now);
+            var update = BuildRoutingUpdate(r, routingStatus, now);
+            var idem = $"wo|{domain}|{workOrd}|wor|{r.OP}";
+            if (await EnqueueRowAsync(tenantId, idem, ActionWor, "WorkOrdRouting", keys, insert, update, ct))
+                enqueued++;
+        }
+
+        _wake.Pulse();
+
+        return new PushResult
+        {
+            Ok = true,
+            WorkOrd = workOrd,
+            Domain = domain,
+            Enqueued = enqueued,
+            DetailCount = details.Count,
+            RoutingCount = routings.Count,
+            Message = $"已入队 {enqueued} 条(明细 {details.Count} / 工序 {routings.Count})"
+        };
+    }
+
+    /// <summary>探测 165 是否已有完整工单三人组。</summary>
+    public async Task<(bool Ready, int MasterCnt, int DetailCnt, int RoutingCnt)> ProbeReadyOnMesAsync(
+        string domain, string workOrd, CancellationToken ct = default)
+    {
+        domain = string.IsNullOrWhiteSpace(domain) ? "8010" : domain.Trim();
+        workOrd = workOrd.Trim();
+        var ss = await _scopeFactory.GetScopeAsync(TargetSource, ct);
+        var masterCnt = await ss.Ado.GetIntAsync(
+            "SELECT COUNT(1) FROM WorkOrdMaster WHERE Domain=@d AND WorkOrd=@w",
+            new SugarParameter("@d", domain),
+            new SugarParameter("@w", workOrd));
+        var detailCnt = await ss.Ado.GetIntAsync(
+            "SELECT COUNT(1) FROM WorkOrdDetail WHERE Domain=@d AND WorkOrd=@w AND ISNULL(IsActive,0)=1",
+            new SugarParameter("@d", domain),
+            new SugarParameter("@w", workOrd));
+        var routingCnt = await ss.Ado.GetIntAsync(
+            "SELECT COUNT(1) FROM WorkOrdRouting WHERE Domain=@d AND WorkOrd=@w AND ISNULL(IsActive,0)=1",
+            new SugarParameter("@d", domain),
+            new SugarParameter("@w", workOrd));
+        return (masterCnt > 0 && detailCnt > 0 && routingCnt > 0, masterCnt, detailCnt, routingCnt);
+    }
+
+    /// <summary>Mes 下达前置:165 上须已有完整工单三人组。</summary>
+    public async Task AssertReadyOnMesAsync(string domain, string workOrd, CancellationToken ct = default)
+    {
+        try
+        {
+            var probe = await ProbeReadyOnMesAsync(domain, workOrd, ct);
+            if (!probe.Ready)
+            {
+                throw Oops.Oh(PickBillUserMessages.ForReleaseFailure("WO_MES_NOT_READY"));
+            }
+        }
+        catch (Exception ex) when (ex is not AppFriendlyException)
+        {
+            throw Oops.Oh(PickBillUserMessages.ForReleaseFailure("MES_UNREACHABLE"));
+        }
+    }
+
+    private async Task<string> ResolveDomainAsync(long tenantId, string workOrd)
+    {
+        var domain = await _db.Ado.GetStringAsync(
+            """
+            SELECT TRIM(IFNULL(`Domain`, '')) FROM WorkOrdMaster
+            WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd LIMIT 1
+            """,
+            new SugarParameter("@TenantId", tenantId),
+            new SugarParameter("@WorkOrd", workOrd));
+        return string.IsNullOrWhiteSpace(domain) ? "8010" : domain;
+    }
+
+    private async Task<bool> EnqueueRowAsync(
+        long tenantId,
+        string idem,
+        string action,
+        string table,
+        Dictionary<string, object?> keys,
+        Dictionary<string, object?> insert,
+        Dictionary<string, object?> update,
+        CancellationToken ct)
+    {
+        var payload = new Dictionary<string, object?>
+        {
+            ["op"] = "UPSERT",
+            ["table"] = table,
+            ["keys"] = keys,
+            ["insert"] = insert,
+            ["update"] = update,
+            ["expect"] = new Dictionary<string, object?>()
+        };
+        var item = new MdpOutbox
+        {
+            TenantId = tenantId,
+            TargetSourceCode = TargetSource,
+            ActionCode = action,
+            IdemKey = idem.Length > 200 ? idem[..200] : idem,
+            PayloadJson = JsonSerializer.Serialize(payload)
+        };
+        return await _enqueue.TryEnqueueOrRefreshAsync(item, ct, pulse: false);
+    }
+
+    private static string StatusForMasterPush(string? local)
+    {
+        var t = (local ?? "").Trim().ToLowerInvariant();
+        return t is "r" or "w" or "s" or "c" or "p" ? t : "p";
+    }
+
+    private static string StatusForRoutingPush(string masterStatus, string? localRouting)
+    {
+        var m = (masterStatus ?? "").Trim().ToLowerInvariant();
+        if (m is "r" or "w" or "s")
+        {
+            var r = (localRouting ?? "").Trim().ToLowerInvariant();
+            return string.IsNullOrEmpty(r) ? "r" : r;
+        }
+        // 计划态:不写执行态;空串表示未下达
+        return "";
+    }
+
+    private static Dictionary<string, object?> BuildMasterInsert(
+        string domain, WorkOrdMaster m, string status, DateTime now)
+    {
+        var insert = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
+        {
+            ["Domain"] = domain,
+            ["WorkOrd"] = m.WorkOrd,
+            ["ID"] = m.WorkOrd,
+            ["Status"] = status,
+            ["Batch"] = m.Batch ?? "",
+            ["ItemNum"] = m.ItemNum ?? "",
+            ["Site"] = m.Site ?? "",
+            ["Typed"] = string.IsNullOrWhiteSpace(m.Typed) ? "" : m.Typed.Trim(),
+            ["QtyOrded"] = m.QtyOrded,
+            ["ProdLine"] = m.ProdLine ?? "",
+            ["Location"] = m.Location ?? "",
+            ["Priority"] = m.Priority,
+            ["Urgent"] = m.Urgent,
+            ["ItemName"] = Trunc(m.ItemName, 256),
+            ["Department"] = Trunc(m.Department, 64),
+            ["RoutingCode"] = m.RoutingCode ?? "",
+            ["BOMFormula"] = m.BOMFormula ?? "",
+            ["Project"] = m.Project ?? "",
+            ["Drawing"] = Trunc(m.Drawing, 64),
+            ["Rev"] = m.Rev ?? "",
+            ["CustNo"] = Trunc(m.CustNo, 64),
+            ["BusinessID"] = m.BusinessID,
+            ["IsActive"] = m.IsActive,
+            ["IsConfirm"] = m.IsConfirm,
+            ["CreateUser"] = Trunc(m.CreateUser ?? "aidop", 24),
+            ["UpdateUser"] = Trunc(m.UpdateUser ?? m.CreateUser ?? "aidop", 24),
+            ["CreateTime"] = Fmt(m.CreateTime ?? now),
+            ["UpdateTime"] = Fmt(m.UpdateTime ?? now)
+        };
+        if (m.OrdDate != null) insert["OrdDate"] = Fmt(m.OrdDate);
+        if (m.DueDate != null) insert["DueDate"] = Fmt(m.DueDate);
+        if (m.ReleaseDate != null) insert["ReleaseDate"] = Fmt(m.ReleaseDate);
+        return insert;
+    }
+
+    private static Dictionary<string, object?> BuildMasterUpdate(WorkOrdMaster m, string status, DateTime now)
+    {
+        var update = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
+        {
+            ["Status"] = status,
+            ["Batch"] = m.Batch ?? "",
+            ["ItemNum"] = m.ItemNum ?? "",
+            ["Site"] = m.Site ?? "",
+            ["Typed"] = m.Typed ?? "",
+            ["QtyOrded"] = m.QtyOrded,
+            ["ProdLine"] = m.ProdLine ?? "",
+            ["Location"] = m.Location ?? "",
+            ["Priority"] = m.Priority,
+            ["Urgent"] = m.Urgent,
+            ["ItemName"] = Trunc(m.ItemName, 256),
+            ["Department"] = Trunc(m.Department, 64),
+            ["RoutingCode"] = m.RoutingCode ?? "",
+            ["BOMFormula"] = m.BOMFormula ?? "",
+            ["Project"] = m.Project ?? "",
+            ["Drawing"] = Trunc(m.Drawing, 64),
+            ["Rev"] = m.Rev ?? "",
+            ["CustNo"] = Trunc(m.CustNo, 64),
+            ["IsActive"] = m.IsActive,
+            ["UpdateUser"] = Trunc(m.UpdateUser ?? "aidop", 24),
+            ["UpdateTime"] = Fmt(now)
+        };
+        if (m.OrdDate != null) update["OrdDate"] = Fmt(m.OrdDate);
+        if (m.DueDate != null) update["DueDate"] = Fmt(m.DueDate);
+        if (m.ReleaseDate != null) update["ReleaseDate"] = Fmt(m.ReleaseDate);
+        return update;
+    }
+
+    private static Dictionary<string, object?> BuildDetailInsert(string domain, string workOrd, DetailRow d, DateTime now)
+    {
+        return new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
+        {
+            ["Domain"] = domain,
+            ["ID"] = "",
+            ["WorkOrd"] = workOrd,
+            ["LineNum"] = d.LineNum,
+            ["Line"] = d.LineNum,
+            ["ItemNum"] = d.ItemNum ?? "",
+            ["Op"] = d.Op,
+            ["Dimension1"] = "",
+            ["Dimension2"] = "",
+            ["Location"] = d.Location ?? "",
+            ["Site"] = d.Site ?? "",
+            ["QtyRequired"] = d.QtyRequired,
+            ["FrozenBOMQty"] = d.FrozenBOMQty,
+            ["LotSerial"] = Trunc(d.LotSerial, 25),
+            ["Status"] = "",
+            ["Typed"] = d.Typed ?? "",
+            ["UM"] = d.UM ?? "",
+            ["IsActive"] = d.IsActive != 0,
+            ["BusinessID"] = d.BusinessID,
+            ["QtyReturned"] = 0m,
+            ["CreateUser"] = Trunc(d.CreateUser ?? "aidop", 24),
+            ["UpdateUser"] = Trunc(d.UpdateUser ?? d.CreateUser ?? "aidop", 24),
+            ["CreateTime"] = Fmt(d.CreateTime ?? now),
+            ["UpdateTime"] = Fmt(d.UpdateTime ?? now)
+        };
+    }
+
+    private static Dictionary<string, object?> BuildDetailUpdate(DetailRow d, DateTime now)
+    {
+        return new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
+        {
+            ["Location"] = d.Location ?? "",
+            ["Site"] = d.Site ?? "",
+            ["QtyRequired"] = d.QtyRequired,
+            ["FrozenBOMQty"] = d.FrozenBOMQty,
+            ["LotSerial"] = Trunc(d.LotSerial, 25),
+            ["Typed"] = d.Typed ?? "",
+            ["UM"] = d.UM ?? "",
+            ["IsActive"] = d.IsActive != 0,
+            ["UpdateUser"] = Trunc(d.UpdateUser ?? "aidop", 24),
+            ["UpdateTime"] = Fmt(now)
+        };
+    }
+
+    private static Dictionary<string, object?> BuildRoutingInsert(
+        string domain, string workOrd, RoutingRow r, string status, DateTime now)
+    {
+        return new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
+        {
+            ["Domain"] = domain,
+            ["ID"] = "",
+            ["WorkOrd"] = workOrd,
+            ["OP"] = r.OP,
+            ["ItemNum"] = r.ItemNum ?? "",
+            ["Descr"] = Trunc(r.Descr, 256),
+            ["MilestoneOp"] = r.MilestoneOp != 0 ? 1 : 0,
+            ["ParentOp"] = r.ParentOp,
+            ["QtyOrded"] = r.QtyOrded,
+            ["Status"] = status,
+            ["ProdLine"] = Trunc(r.ProdLine, 8),
+            ["WorkCtr"] = Trunc(r.WorkCtr, 8),
+            ["Machine"] = Trunc(r.Machine, 800),
+            ["RunCrew"] = r.RunCrew,
+            ["StdSetupTime"] = r.StdSetupTime,
+            ["ProcessOut"] = r.ProcessOut,
+            ["IsActive"] = r.IsActive != 0,
+            ["CreateUser"] = Trunc(r.CreateUser ?? "aidop", 24),
+            ["UpdateUser"] = Trunc(r.UpdateUser ?? r.CreateUser ?? "aidop", 24),
+            ["CreateTime"] = Fmt(r.CreateTime ?? now),
+            ["UpdateTime"] = Fmt(r.UpdateTime ?? now)
+        };
+    }
+
+    private static Dictionary<string, object?> BuildRoutingUpdate(RoutingRow r, string status, DateTime now)
+    {
+        return new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
+        {
+            ["ItemNum"] = r.ItemNum ?? "",
+            ["Descr"] = Trunc(r.Descr, 256),
+            ["MilestoneOp"] = r.MilestoneOp != 0 ? 1 : 0,
+            ["ParentOp"] = r.ParentOp,
+            ["QtyOrded"] = r.QtyOrded,
+            ["Status"] = status,
+            ["ProdLine"] = Trunc(r.ProdLine, 8),
+            ["WorkCtr"] = Trunc(r.WorkCtr, 8),
+            ["Machine"] = Trunc(r.Machine, 800),
+            ["RunCrew"] = r.RunCrew,
+            ["StdSetupTime"] = r.StdSetupTime,
+            ["ProcessOut"] = r.ProcessOut,
+            ["IsActive"] = r.IsActive != 0,
+            ["UpdateUser"] = Trunc(r.UpdateUser ?? "aidop", 24),
+            ["UpdateTime"] = Fmt(now)
+        };
+    }
+
+    private static string Fmt(DateTime? dt) =>
+        (dt ?? DateTime.Now).ToString("yyyy-MM-dd HH:mm:ss");
+
+    private static string? Trunc(string? s, int max)
+    {
+        if (string.IsNullOrEmpty(s)) return s ?? "";
+        return s.Length <= max ? s : s[..max];
+    }
+
+    private sealed class DetailRow
+    {
+        public string? Domain { get; set; }
+        public string? WorkOrd { get; set; }
+        public int LineNum { get; set; }
+        public string? ItemNum { get; set; }
+        public int Op { get; set; }
+        public string? Location { get; set; }
+        public string? Site { get; set; }
+        public decimal QtyRequired { get; set; }
+        public decimal FrozenBOMQty { get; set; }
+        public string? LotSerial { get; set; }
+        public string? Status { get; set; }
+        public string? Typed { get; set; }
+        public string? UM { get; set; }
+        public int IsActive { get; set; }
+        public long BusinessID { get; set; }
+        public string? CreateUser { get; set; }
+        public DateTime? CreateTime { get; set; }
+        public string? UpdateUser { get; set; }
+        public DateTime? UpdateTime { get; set; }
+    }
+
+    private sealed class RoutingRow
+    {
+        public string? Domain { get; set; }
+        public string? WorkOrd { get; set; }
+        public int OP { get; set; }
+        public string? ItemNum { get; set; }
+        public string? Descr { get; set; }
+        public int MilestoneOp { get; set; }
+        public int ParentOp { get; set; }
+        public decimal QtyOrded { get; set; }
+        public string? Status { get; set; }
+        public string? ProdLine { get; set; }
+        public string? WorkCtr { get; set; }
+        public string? Machine { get; set; }
+        public decimal RunCrew { get; set; }
+        public decimal StdSetupTime { get; set; }
+        public int ProcessOut { get; set; }
+        public int IsActive { get; set; }
+        public string? CreateUser { get; set; }
+        public DateTime? CreateTime { get; set; }
+        public string? UpdateUser { get; set; }
+        public DateTime? UpdateTime { get; set; }
+    }
+}

+ 8 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Order/OrderReviewOrchestrationService.cs

@@ -1,3 +1,4 @@
+using Admin.NET.Plugin.AiDOP.DataPlatform.Wms;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
 using Admin.NET.Plugin.AiDOP.Production;
 using Admin.NET.Plugin.AiDOP.Production;
 using Admin.NET.Plugin.AiDOP.Supply;
 using Admin.NET.Plugin.AiDOP.Supply;
@@ -28,6 +29,7 @@ public class OrderReviewOrchestrationService : ITransient
     private readonly AidopActionRunLogWriter _runLog;
     private readonly AidopActionRunLogWriter _runLog;
     private readonly ProductionScheduleGenerationService _scheduleGen;
     private readonly ProductionScheduleGenerationService _scheduleGen;
     private readonly ProcurementPipelineService _pipeline;
     private readonly ProcurementPipelineService _pipeline;
+    private readonly WorkOrdMesPushService _workOrdMesPush;
 
 
     public OrderReviewOrchestrationService(
     public OrderReviewOrchestrationService(
         ISqlSugarClient db,
         ISqlSugarClient db,
@@ -39,7 +41,8 @@ public class OrderReviewOrchestrationService : ITransient
         MdpRefreshDispatcher refreshDispatcher,
         MdpRefreshDispatcher refreshDispatcher,
         AidopActionRunLogWriter runLog,
         AidopActionRunLogWriter runLog,
         ProductionScheduleGenerationService scheduleGen,
         ProductionScheduleGenerationService scheduleGen,
-        ProcurementPipelineService pipeline)
+        ProcurementPipelineService pipeline,
+        WorkOrdMesPushService workOrdMesPush)
     {
     {
         _db = db;
         _db = db;
         _userManager = userManager;
         _userManager = userManager;
@@ -51,6 +54,7 @@ public class OrderReviewOrchestrationService : ITransient
         _runLog = runLog;
         _runLog = runLog;
         _scheduleGen = scheduleGen;
         _scheduleGen = scheduleGen;
         _pipeline = pipeline;
         _pipeline = pipeline;
+        _workOrdMesPush = workOrdMesPush;
     }
     }
 
 
     public Task<SeOrderReviewExecuteResult> ReviewAsync(IReadOnlyList<long> orderIds) =>
     public Task<SeOrderReviewExecuteResult> ReviewAsync(IReadOnlyList<long> orderIds) =>
@@ -95,6 +99,7 @@ public class OrderReviewOrchestrationService : ITransient
 
 
                 await _materialDetailSync.EnsureFromResourceCheckAsync(entry.TenantId, wo.WorkOrd, account);
                 await _materialDetailSync.EnsureFromResourceCheckAsync(entry.TenantId, wo.WorkOrd, account);
                 await _routingSync.EnsureFromRoutingAsync(entry.TenantId, wo.WorkOrd, account);
                 await _routingSync.EnsureFromRoutingAsync(entry.TenantId, wo.WorkOrd, account);
+                await _workOrdMesPush.TryEnqueueSnapshotSafeAsync(entry.TenantId, wo.WorkOrd, warnings);
 
 
                 // 当工单状态为下达/投产/暂停(R、W、S)时,更新对应领料单数据
                 // 当工单状态为下达/投产/暂停(R、W、S)时,更新对应领料单数据
                 await UpdatePickingListForActiveWorkOrderAsync(entry.TenantId, wo.WorkOrd, account, warnings);
                 await UpdatePickingListForActiveWorkOrderAsync(entry.TenantId, wo.WorkOrd, account, warnings);
@@ -313,6 +318,7 @@ public class OrderReviewOrchestrationService : ITransient
                 await _resourceCheck.WriteResultAsync(order, entry, wo.WorkOrd, lines, account);
                 await _resourceCheck.WriteResultAsync(order, entry, wo.WorkOrd, lines, account);
                 await _materialDetailSync.EnsureFromResourceCheckAsync(entry.TenantId, wo.WorkOrd, account);
                 await _materialDetailSync.EnsureFromResourceCheckAsync(entry.TenantId, wo.WorkOrd, account);
                 await _routingSync.EnsureFromRoutingAsync(entry.TenantId, wo.WorkOrd, account);
                 await _routingSync.EnsureFromRoutingAsync(entry.TenantId, wo.WorkOrd, account);
+                await _workOrdMesPush.TryEnqueueSnapshotSafeAsync(entry.TenantId, wo.WorkOrd, warnings);
                 warnings.Add($"订单行 {entry.EntrySeq} 存在缺料(工单 {wo.WorkOrd})");
                 warnings.Add($"订单行 {entry.EntrySeq} 存在缺料(工单 {wo.WorkOrd})");
             }
             }
             else
             else
@@ -387,6 +393,7 @@ public class OrderReviewOrchestrationService : ITransient
 
 
                     // 交期确认后同步工单物料明细,使其与资源检查子物料一致
                     // 交期确认后同步工单物料明细,使其与资源检查子物料一致
                     await _materialDetailSync.EnsureFromResourceCheckAsync(entry.TenantId, workOrd, account);
                     await _materialDetailSync.EnsureFromResourceCheckAsync(entry.TenantId, workOrd, account);
+                    await _workOrdMesPush.TryEnqueueSnapshotSafeAsync(entry.TenantId, workOrd, warnings);
                 }
                 }
             }
             }
             else
             else

+ 2 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/WorkOrder/PickBillUserMessages.cs

@@ -26,6 +26,8 @@ public static class PickBillUserMessages
                 "下达失败!当前工单状态不允许下达,请刷新列表后重试。",
                 "下达失败!当前工单状态不允许下达,请刷新列表后重试。",
             "ORPHAN_DETECTED" =>
             "ORPHAN_DETECTED" =>
                 "下达失败!MES 端存在异常领料单数据,请联系系统管理员处理。",
                 "下达失败!MES 端存在异常领料单数据,请联系系统管理员处理。",
+            "WO_MES_NOT_READY" =>
+                "下达失败!MES 端尚未具备完整工单(头/明细/工序),请先完成订单评审或计划刷新推送,或调用工单推送补偿后重试。",
             _ => BuildFallback(reason, hint)
             _ => BuildFallback(reason, hint)
         };
         };
         return msg;
         return msg;

+ 17 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/WorkOrder/WorkOrderDispatchService.cs

@@ -24,6 +24,7 @@ public class WorkOrderDispatchService : IDynamicApiController, ITransient
     private readonly AidopPickBillOptions _pickBillOpt;
     private readonly AidopPickBillOptions _pickBillOpt;
     private readonly MdpSourceScopeFactory _scopeFactory;
     private readonly MdpSourceScopeFactory _scopeFactory;
     private readonly KeeperResolveService _keeper;
     private readonly KeeperResolveService _keeper;
+    private readonly WorkOrdMesPushService _workOrdMesPush;
     private readonly AidopActionRunLogWriter _runLog;
     private readonly AidopActionRunLogWriter _runLog;
     private readonly ILogger<WorkOrderDispatchService> _logger;
     private readonly ILogger<WorkOrderDispatchService> _logger;
 
 
@@ -38,6 +39,7 @@ public class WorkOrderDispatchService : IDynamicApiController, ITransient
         IOptions<AidopPickBillOptions> pickBillOpt,
         IOptions<AidopPickBillOptions> pickBillOpt,
         MdpSourceScopeFactory scopeFactory,
         MdpSourceScopeFactory scopeFactory,
         KeeperResolveService keeper,
         KeeperResolveService keeper,
+        WorkOrdMesPushService workOrdMesPush,
         AidopActionRunLogWriter runLog,
         AidopActionRunLogWriter runLog,
         ILogger<WorkOrderDispatchService> logger)
         ILogger<WorkOrderDispatchService> logger)
     {
     {
@@ -48,6 +50,7 @@ public class WorkOrderDispatchService : IDynamicApiController, ITransient
         _pickBillOpt = pickBillOpt.Value;
         _pickBillOpt = pickBillOpt.Value;
         _scopeFactory = scopeFactory;
         _scopeFactory = scopeFactory;
         _keeper = keeper;
         _keeper = keeper;
+        _workOrdMesPush = workOrdMesPush;
         _runLog = runLog;
         _runLog = runLog;
         _logger = logger;
         _logger = logger;
     }
     }
@@ -459,6 +462,20 @@ public class WorkOrderDispatchService : IDynamicApiController, ITransient
                 "下达失败!工单物料明细为空,请先同步或维护物料后重试。",
                 "下达失败!工单物料明细为空,请先同步或维护物料后重试。",
                 "NO_DETAIL", tenantId, domain, workOrd);
                 "NO_DETAIL", tenantId, domain, workOrd);
 
 
+        try
+        {
+            await _workOrdMesPush.AssertReadyOnMesAsync(domain, workOrd);
+        }
+        catch (Exception ex)
+        {
+            var code = ex.Message.Contains("尚未具备完整工单", StringComparison.Ordinal)
+                ? "WO_MES_NOT_READY"
+                : "MES_UNREACHABLE";
+            throw ThrowReleaseFail(
+                PickBillUserMessages.ForReleaseFailure(code),
+                code, tenantId, domain, workOrd, ex);
+        }
+
         KeeperResolveService.KeeperResolveResult keeper;
         KeeperResolveService.KeeperResolveResult keeper;
         try
         try
         {
         {