730 lines
28 KiB
Python
730 lines
28 KiB
Python
"""
|
||
主账内存假账本(单测 / 无 8180 时)。
|
||
|
||
本文件职责:实现建单(每次新开)、相似检索、TMS fixture、报价写入、六态流转,语义对齐 Java 主账。
|
||
禁止:冒充已打真实 TMS;禁止被生产 Worker 当默认 Owner(生产必须 HTTP 主账)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import threading
|
||
from dataclasses import dataclass, field
|
||
from datetime import datetime
|
||
from typing import Any, Optional
|
||
from zoneinfo import ZoneInfo
|
||
|
||
from agent.schema.fingerprint import inquiry_fingerprint
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
_SHANGHAI = ZoneInfo("Asia/Shanghai")
|
||
|
||
|
||
@dataclass
|
||
class MemoryTicket:
|
||
"""内存工单。"""
|
||
|
||
work_order_no: str
|
||
sales_wecom_id: str
|
||
business_line: str
|
||
facts: dict[str, str]
|
||
fingerprint: str
|
||
status: str = "询价中"
|
||
quote: dict[str, Any] = field(default_factory=dict)
|
||
quote_version: int = 0
|
||
collab_chat_id: str = ""
|
||
collab_facts: dict[str, str] = field(default_factory=dict)
|
||
collab_member_ids: list[str] = field(default_factory=list)
|
||
product_ids: list[str] = field(default_factory=list)
|
||
product_names: list[str] = field(default_factory=list)
|
||
collab_ended: bool = False
|
||
attachments: list[dict[str, Any]] = field(default_factory=list)
|
||
quote_file_ready: bool = False
|
||
lost_reason: str = ""
|
||
next_followup_at: str = ""
|
||
deal_notify_summary: str = ""
|
||
system_exception: str = ""
|
||
system_exception_reason: str = ""
|
||
system_exception_step: str = ""
|
||
system_exception_payload: dict[str, Any] = field(default_factory=dict)
|
||
|
||
|
||
class MemoryLedger:
|
||
"""
|
||
线程安全内存主账。
|
||
|
||
TMS fixture:facts.tms_force=1002 或目的港含「无价」→ 无价;否则有价。
|
||
"""
|
||
|
||
def __init__(self) -> None:
|
||
self._lock = threading.Lock()
|
||
self._tickets: dict[str, MemoryTicket] = {}
|
||
self._seq = 0
|
||
# 单测默认同业务有一张默认模板,避免「生成 Excel」误走失败话术
|
||
self._templates: list[dict[str, Any]] = [
|
||
{
|
||
"templateId": "mem-default-air",
|
||
"name": "空运默认",
|
||
"bizType": "空运",
|
||
"keywords": [],
|
||
"isDefault": True,
|
||
}
|
||
]
|
||
self.last_render: dict[str, Any] = {}
|
||
self._sea_staff: list[dict[str, Any]] = []
|
||
self._land_staff: list[dict[str, Any]] = []
|
||
self._staff_by_wecom: dict[str, dict[str, Any]] = {}
|
||
self._staff_by_name: dict[str, dict[str, Any]] = {}
|
||
self._exception_notify_users: list[dict[str, Any]] = []
|
||
|
||
def _next_no(self) -> str:
|
||
self._seq += 1
|
||
day = datetime.now(_SHANGHAI).strftime("%Y%m%d")
|
||
return f"WO{day}{self._seq:04d}"
|
||
|
||
def find_similar_open(
|
||
self,
|
||
*,
|
||
facts: dict[str, str],
|
||
business_line: str,
|
||
exclude_work_order_no: str = "",
|
||
) -> dict[str, Any]:
|
||
"""
|
||
全库相同需求检索:需求指纹一模一样才算,不限六态。
|
||
|
||
多条只回最新一条;exclude_work_order_no 排除刚建的本单。
|
||
只供卡片展示,不复用当前工单号。
|
||
"""
|
||
fp = inquiry_fingerprint(facts, business_line=business_line)
|
||
skip = (exclude_work_order_no or "").strip()
|
||
latest: MemoryTicket | None = None
|
||
with self._lock:
|
||
for t in self._tickets.values():
|
||
if t.fingerprint != fp:
|
||
continue
|
||
if skip and t.work_order_no == skip:
|
||
continue
|
||
latest = t
|
||
if latest is None:
|
||
return {"found": False, "first_or_same": "first", "work_order_no": "", "status": ""}
|
||
return {
|
||
"found": True,
|
||
"first_or_same": "same",
|
||
"work_order_no": latest.work_order_no,
|
||
"status": latest.status,
|
||
}
|
||
|
||
def create_ticket(
|
||
self,
|
||
*,
|
||
sender_id: str,
|
||
business_line: str,
|
||
facts: dict[str, str],
|
||
idempotency_key: str = "",
|
||
) -> dict[str, Any]:
|
||
_ = idempotency_key
|
||
# 出询价确认必须新开一单,相同需求不复用未关闭工单
|
||
fp = inquiry_fingerprint(facts, business_line=business_line)
|
||
with self._lock:
|
||
no = self._next_no()
|
||
ticket = MemoryTicket(
|
||
work_order_no=no,
|
||
sales_wecom_id=sender_id,
|
||
business_line=business_line,
|
||
facts=dict(facts),
|
||
fingerprint=fp,
|
||
)
|
||
self._tickets[no] = ticket
|
||
similar = self.find_similar_open(
|
||
facts=facts, business_line=business_line, exclude_work_order_no=no
|
||
)
|
||
logger.info(
|
||
"memory_ledger.create %s first_or_same=%s similar=%s",
|
||
no,
|
||
similar.get("first_or_same"),
|
||
similar.get("work_order_no"),
|
||
)
|
||
return {
|
||
"ok": True,
|
||
"created": True,
|
||
"first_or_same": similar.get("first_or_same") or "first",
|
||
"found": bool(similar.get("found")),
|
||
"similar_work_order_no": similar.get("work_order_no") or "",
|
||
"work_order_no": no,
|
||
"status": "询价中",
|
||
}
|
||
|
||
def query_tms(
|
||
self,
|
||
*,
|
||
work_order_no: str,
|
||
facts: dict[str, str],
|
||
business_line: str = "",
|
||
) -> dict[str, Any]:
|
||
_ = business_line
|
||
dest = str(facts.get("目的港") or "")
|
||
force = str(facts.get("tms_force") or "")
|
||
if force == "1002" or force == "1003" or "无价" in dest or "歧义" in dest:
|
||
return {
|
||
"ok": True,
|
||
"classification": "TMS_NO_QUOTE_1002",
|
||
"has_price": False,
|
||
"quote": {},
|
||
}
|
||
if force == "tech":
|
||
return {"ok": False, "classification": "TMS_TECH_FAILURE", "has_price": False, "quote": {}}
|
||
quote = {
|
||
"freight": "USD 1,200",
|
||
"other_fee": "USD 80",
|
||
"total": "USD 1,280",
|
||
"eta": "3-5 天",
|
||
"valid_until": "2026-09-20",
|
||
"source": "TMS",
|
||
"source_label": "TMS 标准报价",
|
||
"fee_lines": [
|
||
{"name": "空运费", "amount": "USD 1,200"},
|
||
{"name": "其他附加费", "amount": "USD 80"},
|
||
],
|
||
}
|
||
return {
|
||
"ok": True,
|
||
"classification": "HAS_PRICE",
|
||
"has_price": True,
|
||
"quote": quote,
|
||
}
|
||
|
||
def upsert_quote(
|
||
self,
|
||
*,
|
||
work_order_no: str,
|
||
quote: dict[str, Any],
|
||
to_status: str = "",
|
||
) -> dict[str, Any]:
|
||
with self._lock:
|
||
ticket = self._tickets.get(work_order_no)
|
||
if ticket is None:
|
||
return {"ok": False, "error": "ticket_not_found"}
|
||
ticket.quote_version += 1
|
||
ticket.quote = dict(quote)
|
||
if to_status:
|
||
ticket.status = to_status
|
||
return {
|
||
"ok": True,
|
||
"quote_version": ticket.quote_version,
|
||
"status": ticket.status,
|
||
}
|
||
|
||
def transition(
|
||
self,
|
||
*,
|
||
work_order_no: str,
|
||
to_status: str,
|
||
remark: str = "",
|
||
lost_reason: str = "",
|
||
next_followup_at: str = "",
|
||
deal_notify_summary: str = "",
|
||
) -> dict[str, Any]:
|
||
_ = remark
|
||
with self._lock:
|
||
ticket = self._tickets.get(work_order_no)
|
||
if ticket is None:
|
||
return {"ok": False, "error": "ticket_not_found"}
|
||
ticket.status = to_status
|
||
if to_status == "未成交":
|
||
ticket.next_followup_at = ""
|
||
if lost_reason:
|
||
ticket.lost_reason = lost_reason
|
||
elif to_status == "已成交":
|
||
ticket.next_followup_at = ""
|
||
if deal_notify_summary:
|
||
ticket.deal_notify_summary = deal_notify_summary
|
||
elif to_status == "协商中":
|
||
ticket.next_followup_at = next_followup_at
|
||
return {"ok": True, "status": ticket.status}
|
||
|
||
def patch_lost_reason(self, *, work_order_no: str, lost_reason: str) -> dict[str, Any]:
|
||
reason = (lost_reason or "").strip()
|
||
with self._lock:
|
||
ticket = self._tickets.get(work_order_no)
|
||
if ticket is None:
|
||
return {"ok": False, "error": "ticket_not_found"}
|
||
if ticket.status != "未成交":
|
||
return {"ok": False, "error": "not_lost", "status": ticket.status}
|
||
if not reason:
|
||
return {"ok": False, "error": "reason_blank"}
|
||
ticket.lost_reason = reason
|
||
return {"ok": True, "work_order_no": work_order_no, "lost_reason": reason}
|
||
|
||
def claim_due_negotiate(self) -> list[dict[str, Any]]:
|
||
now = datetime.now(_SHANGHAI).strftime("%Y-%m-%d %H:%M:%S")
|
||
hits: list[dict[str, Any]] = []
|
||
with self._lock:
|
||
for ticket in self._tickets.values():
|
||
due = (ticket.next_followup_at or "").strip()
|
||
if ticket.status != "协商中" or not due or due > now:
|
||
continue
|
||
ticket.next_followup_at = ""
|
||
hits.append(
|
||
{
|
||
"work_order_no": ticket.work_order_no,
|
||
"sales_wecom_id": ticket.sales_wecom_id,
|
||
"sales_name": "",
|
||
"business_line": ticket.business_line,
|
||
"collab_chat_id": ticket.collab_chat_id,
|
||
"status": ticket.status,
|
||
"facts": dict(ticket.facts),
|
||
"quote": dict(ticket.quote),
|
||
}
|
||
)
|
||
return hits
|
||
|
||
def get_ticket(self, *, work_order_no: str) -> MemoryTicket | None:
|
||
with self._lock:
|
||
return self._tickets.get(work_order_no)
|
||
|
||
def set_quote_templates(self, templates: list[dict[str, Any]]) -> None:
|
||
"""单测注入后台模板列表;空列表=未维护关键词也未维护默认。"""
|
||
with self._lock:
|
||
self._templates = list(templates)
|
||
|
||
def render_quote(
|
||
self,
|
||
*,
|
||
work_order_no: str,
|
||
business_line: str,
|
||
facts: dict[str, Any],
|
||
quote: Optional[dict[str, Any]] = None,
|
||
format: str = "xlsx",
|
||
sales_wecom_id: str = "",
|
||
) -> dict[str, Any]:
|
||
"""
|
||
按询价需求匹配内存模板。不填真实 xlsx,只回假字节供流程/单测。
|
||
"""
|
||
from agent.quote_templates.match import FAIL_MESSAGE, pick_quote_template
|
||
|
||
_ = sales_wecom_id
|
||
with self._lock:
|
||
templates = list(self._templates)
|
||
picked = pick_quote_template(
|
||
templates=templates,
|
||
business_line=business_line,
|
||
facts=facts,
|
||
quote=quote,
|
||
)
|
||
if picked is None:
|
||
out = {"ok": False, "error": "NO_TEMPLATE", "message": FAIL_MESSAGE, "facts": dict(facts or {})}
|
||
self.last_render = out
|
||
return out
|
||
ext = "pdf" if str(format).lower() == "pdf" else "xlsx"
|
||
out = {
|
||
"ok": True,
|
||
"matchType": picked.match_type,
|
||
"templateId": picked.template_id,
|
||
"templateName": picked.name,
|
||
"fileName": f"{work_order_no}_quote.{ext}",
|
||
"fileBase64": "UEsDBAoAAAAAA",
|
||
"needsPdf": False,
|
||
"facts": dict(facts or {}),
|
||
}
|
||
with self._lock:
|
||
ticket = self._tickets.get((work_order_no or "").strip())
|
||
if ticket is not None:
|
||
ticket.quote_file_ready = True
|
||
self.last_render = out
|
||
return out
|
||
|
||
def get_for_agent(self, *, work_order_no: str, sender_id: str) -> dict[str, Any]:
|
||
"""
|
||
智能体续办读取:工单是否存在、是否本人、字段/报价/推断停点。
|
||
"""
|
||
no = (work_order_no or "").strip()
|
||
ticket = self.get_ticket(work_order_no=no)
|
||
if ticket is None:
|
||
return {"ok": False, "found": False, "owned": False, "work_order_no": no}
|
||
owned = ticket.sales_wecom_id == sender_id
|
||
status = ticket.status
|
||
quote = dict(ticket.quote or {})
|
||
wait_phase = "done"
|
||
if status in {"已成交", "已关闭"}:
|
||
wait_phase = "done"
|
||
elif status == "未成交":
|
||
wait_phase = "wait_lost_reason" if not (ticket.lost_reason or "").strip() else "done"
|
||
elif status == "协商中":
|
||
wait_phase = "wait_deal"
|
||
elif status == "已报价" and quote:
|
||
# 文件已发给销售 → 成交跟进;否则海运协同卡、空运方案卡
|
||
if ticket.quote_file_ready or quote.get("quoteFileIssued"):
|
||
wait_phase = "wait_deal"
|
||
elif (ticket.business_line or "").upper() == "SEA":
|
||
wait_phase = "wait_collab"
|
||
else:
|
||
wait_phase = "wait_adopt"
|
||
else:
|
||
wait_phase = "tms_miss"
|
||
return {
|
||
"ok": True,
|
||
"found": True,
|
||
"owned": owned,
|
||
"work_order_no": ticket.work_order_no,
|
||
"status": status,
|
||
"business_line": ticket.business_line,
|
||
"facts": dict(ticket.facts),
|
||
"quote": quote,
|
||
"wait_phase": wait_phase,
|
||
"collab_chat_id": ticket.collab_chat_id,
|
||
"collab_facts": dict(ticket.collab_facts),
|
||
"lost_reason": ticket.lost_reason,
|
||
"lostReason": ticket.lost_reason,
|
||
"deal_notify_summary": ticket.deal_notify_summary,
|
||
"next_followup_at": ticket.next_followup_at,
|
||
"system_exception": ticket.system_exception,
|
||
"systemException": ticket.system_exception,
|
||
"system_exception_reason": ticket.system_exception_reason,
|
||
"systemExceptionReason": ticket.system_exception_reason,
|
||
"system_exception_step": ticket.system_exception_step,
|
||
"system_exception_payload": dict(ticket.system_exception_payload),
|
||
}
|
||
|
||
def upsert_exception_notify_user(self, row: dict[str, Any]) -> None:
|
||
"""单测注入「系统异常消息通知」接收人(后台账号已绑企微)。"""
|
||
data = dict(row)
|
||
uid = str(data.get("wecomId") or data.get("wecom_id") or "").strip()
|
||
if not uid:
|
||
return
|
||
data["wecomId"] = uid
|
||
with self._lock:
|
||
self._exception_notify_users = [
|
||
x for x in self._exception_notify_users
|
||
if str(x.get("wecomId") or "") != uid
|
||
]
|
||
self._exception_notify_users.append(data)
|
||
|
||
def list_exception_notify_users(self) -> list[dict[str, Any]]:
|
||
"""角色勾了系统异常消息通知、且账号已绑企微的人。"""
|
||
with self._lock:
|
||
return [dict(x) for x in self._exception_notify_users]
|
||
|
||
def mark_system_exception(
|
||
self,
|
||
*,
|
||
work_order_no: str,
|
||
system_exception: str,
|
||
system_exception_reason: str,
|
||
step: str = "",
|
||
payload: dict[str, Any] | None = None,
|
||
) -> dict[str, Any]:
|
||
"""写入工单两列与重试上下文;已关闭拒绝。不改六态。"""
|
||
no = (work_order_no or "").strip()
|
||
with self._lock:
|
||
ticket = self._tickets.get(no)
|
||
if ticket is None:
|
||
return {"ok": False, "error": "not_found"}
|
||
if ticket.status == "已关闭":
|
||
return {"ok": False, "error": "closed"}
|
||
ticket.system_exception = (system_exception or "").strip()
|
||
ticket.system_exception_reason = (system_exception_reason or "").strip()
|
||
ticket.system_exception_step = (step or "").strip()
|
||
ticket.system_exception_payload = dict(payload or {})
|
||
return {"ok": True, "status": ticket.status}
|
||
|
||
def clear_system_exception(self, *, work_order_no: str) -> dict[str, Any]:
|
||
"""重试成功后清两列。"""
|
||
no = (work_order_no or "").strip()
|
||
with self._lock:
|
||
ticket = self._tickets.get(no)
|
||
if ticket is None:
|
||
return {"ok": False, "error": "not_found"}
|
||
ticket.system_exception = ""
|
||
ticket.system_exception_reason = ""
|
||
ticket.system_exception_step = ""
|
||
ticket.system_exception_payload = {}
|
||
return {"ok": True}
|
||
|
||
def upsert_staff(self, row: dict[str, Any]) -> None:
|
||
"""单测注入任意岗位(含会话存档账号「询价机器人」)。"""
|
||
data = dict(row)
|
||
uid = str(data.get("wecomId") or data.get("wecom_id") or "").strip()
|
||
name = str(data.get("name") or "").strip()
|
||
with self._lock:
|
||
if uid:
|
||
self._staff_by_wecom[uid] = data
|
||
if name:
|
||
self._staff_by_name[name] = data
|
||
|
||
def set_sea_staff(self, rows: list[dict[str, Any]]) -> None:
|
||
"""单测注入海运产品岗。"""
|
||
with self._lock:
|
||
self._sea_staff = [dict(x) for x in rows]
|
||
for row in self._sea_staff:
|
||
uid = str(row.get("wecomId") or row.get("wecom_id") or "").strip()
|
||
name = str(row.get("name") or "").strip()
|
||
if uid:
|
||
self._staff_by_wecom[uid] = dict(row)
|
||
if name:
|
||
self._staff_by_name[name] = dict(row)
|
||
|
||
def match_sea_staff(self, *, origin: str, destination: str) -> list[dict[str, Any]]:
|
||
"""
|
||
岗位=产品·海运、启用:写了「海运·全部线路」必拉;
|
||
写了具体港口的,对得上本单起运港/目的港才拉。
|
||
"""
|
||
origin = (origin or "").strip()
|
||
dest = (destination or "").strip()
|
||
hits: list[dict[str, Any]] = []
|
||
with self._lock:
|
||
for row in self._sea_staff:
|
||
if str(row.get("roleCode") or row.get("role_code") or "") != "sea":
|
||
continue
|
||
if str(row.get("status") or "active") != "active":
|
||
continue
|
||
routes = str(row.get("routes") or "")
|
||
if "海运·全部线路" in routes or "海运全线" in routes:
|
||
hits.append(dict(row))
|
||
continue
|
||
if origin and origin in routes:
|
||
hits.append(dict(row))
|
||
continue
|
||
if dest and dest in routes:
|
||
hits.append(dict(row))
|
||
return hits
|
||
|
||
def set_land_staff(self, rows: list[dict[str, Any]]) -> None:
|
||
"""单测注入陆运产品岗。"""
|
||
with self._lock:
|
||
self._land_staff = [dict(x) for x in rows]
|
||
for row in self._land_staff:
|
||
uid = str(row.get("wecomId") or row.get("wecom_id") or "").strip()
|
||
name = str(row.get("name") or "").strip()
|
||
if uid:
|
||
self._staff_by_wecom[uid] = dict(row)
|
||
if name:
|
||
self._staff_by_name[name] = dict(row)
|
||
|
||
def match_land_staff(self, *, route_category: str) -> list[dict[str, Any]]:
|
||
"""
|
||
岗位=产品·陆运、启用:负责线路整词含本单线路类别才拉。
|
||
不按斜杠切开「中港/中亚/中欧」。
|
||
"""
|
||
want = (route_category or "").strip()
|
||
hits: list[dict[str, Any]] = []
|
||
if not want:
|
||
return hits
|
||
with self._lock:
|
||
for row in self._land_staff:
|
||
if str(row.get("roleCode") or row.get("role_code") or "") != "land":
|
||
continue
|
||
if str(row.get("status") or "active") != "active":
|
||
continue
|
||
tokens = [
|
||
p.strip()
|
||
for p in str(row.get("routes") or "").replace(",", ",").replace("、", ",").split(",")
|
||
if p.strip()
|
||
]
|
||
if want in tokens:
|
||
hits.append(dict(row))
|
||
return hits
|
||
|
||
def get_staff_by_wecom_id(self, *, wecom_id: str) -> dict[str, Any]:
|
||
"""按企微 userid 取员工展示名。"""
|
||
with self._lock:
|
||
return dict(self._staff_by_wecom.get((wecom_id or "").strip()) or {})
|
||
|
||
def list_staff_by_role(self, *, role_code: str) -> list[dict[str, Any]]:
|
||
"""按岗位列出启用员工。空运群 @ 航线用。"""
|
||
want = (role_code or "").strip().lower()
|
||
hits: list[dict[str, Any]] = []
|
||
with self._lock:
|
||
for row in self._staff_by_wecom.values():
|
||
code = str(row.get("roleCode") or row.get("role_code") or "").strip().lower()
|
||
status = str(row.get("status") or "active").strip().lower()
|
||
if code == want and status not in {"disabled", "0", "停用"}:
|
||
hits.append(dict(row))
|
||
return hits
|
||
|
||
def find_staff_by_name(self, *, name: str) -> dict[str, Any]:
|
||
"""按员工姓名取企微 userid。"""
|
||
with self._lock:
|
||
return dict(self._staff_by_name.get((name or "").strip()) or {})
|
||
|
||
def bind_collab_group(
|
||
self,
|
||
*,
|
||
work_order_no: str,
|
||
chat_id: str,
|
||
member_ids: list[str],
|
||
product_names: list[str] | None = None,
|
||
product_ids: list[str] | None = None,
|
||
) -> dict[str, Any]:
|
||
"""记下协同群和产品姓名,不改六态。重启后群摘要还要靠这些名字 @ 到人。"""
|
||
with self._lock:
|
||
ticket = self._tickets.get(work_order_no)
|
||
if ticket is None:
|
||
return {"ok": False, "error": "ticket_not_found"}
|
||
ticket.collab_chat_id = (chat_id or "").strip()
|
||
ticket.collab_member_ids = [str(x) for x in member_ids]
|
||
ticket.product_ids = [str(x) for x in (product_ids or [])]
|
||
ticket.product_names = [str(x).strip() for x in (product_names or []) if str(x).strip()]
|
||
return {"ok": True, "chat_id": ticket.collab_chat_id}
|
||
|
||
def end_collab_group(self, *, work_order_no: str) -> dict[str, Any]:
|
||
"""协同结束:不改六态,这单不能再拉群。"""
|
||
with self._lock:
|
||
ticket = self._tickets.get(work_order_no)
|
||
if ticket is None:
|
||
return {"ok": False, "error": "ticket_not_found"}
|
||
ticket.collab_ended = True
|
||
ticket.collab_facts["__ended"] = "1"
|
||
return {"ok": True, "collab_ended": True}
|
||
|
||
def attach_file(
|
||
self,
|
||
*,
|
||
work_order_no: str,
|
||
file_name: str,
|
||
object_key: str = "",
|
||
content_type: str = "",
|
||
sender_id: str = "",
|
||
) -> dict[str, Any]:
|
||
"""把群附件记到工单。不改六态。"""
|
||
with self._lock:
|
||
ticket = self._tickets.get(work_order_no)
|
||
if ticket is None:
|
||
return {"ok": False, "error": "ticket_not_found"}
|
||
row = {
|
||
"file_name": file_name,
|
||
"object_key": object_key or file_name,
|
||
"content_type": content_type,
|
||
"sender_id": sender_id,
|
||
}
|
||
ticket.attachments.append(row)
|
||
return {"ok": True, "file_name": file_name}
|
||
|
||
def upsert_product_quote(
|
||
self,
|
||
*,
|
||
work_order_no: str,
|
||
quote: dict[str, Any],
|
||
to_status: str = "已报价",
|
||
remark: str = "",
|
||
kind: str = "",
|
||
) -> dict[str, Any]:
|
||
"""手工报价 / 销售调价盖 TMS 价。remark/kind 只给 HTTP 主账记流水。"""
|
||
_ = remark, kind
|
||
return self.upsert_quote(
|
||
work_order_no=work_order_no, quote=quote, to_status=to_status
|
||
)
|
||
|
||
def patch_collab_facts(
|
||
self,
|
||
*,
|
||
work_order_no: str,
|
||
facts: dict[str, str],
|
||
sender_id: str = "",
|
||
) -> dict[str, Any]:
|
||
"""合并协同字段。不改六态、不写报价版本。"""
|
||
_ = sender_id
|
||
with self._lock:
|
||
ticket = self._tickets.get(work_order_no)
|
||
if ticket is None:
|
||
return {"ok": False, "error": "ticket_not_found"}
|
||
for key, raw in (facts or {}).items():
|
||
name = str(key or "").strip()
|
||
if not name:
|
||
continue
|
||
val = str(raw or "").strip()
|
||
if val:
|
||
ticket.collab_facts[name] = val
|
||
else:
|
||
ticket.collab_facts.pop(name, None)
|
||
return {"ok": True, "facts": dict(ticket.collab_facts)}
|
||
|
||
def find_by_collab_chat(self, *, chat_id: str) -> MemoryTicket | None:
|
||
"""用工微群 chat_id 找回工单。"""
|
||
raw = (chat_id or "").strip()
|
||
if not raw:
|
||
return None
|
||
with self._lock:
|
||
for ticket in self._tickets.values():
|
||
if ticket.collab_chat_id == raw:
|
||
return ticket
|
||
return None
|
||
|
||
def activate_air_group(self, *, chat_id: str, work_order_no: str) -> dict[str, Any]:
|
||
"""
|
||
空运群当前工单:一单一群,未结案才能激活。
|
||
|
||
不改六态。
|
||
"""
|
||
no = (work_order_no or "").strip()
|
||
room = (chat_id or "").strip()
|
||
with self._lock:
|
||
ticket = self._tickets.get(no)
|
||
if ticket is None:
|
||
return {"ok": False, "error": "ticket_not_found"}
|
||
if (ticket.business_line or "").upper() != "AIR":
|
||
return {"ok": False, "error": "not_air", "status": ticket.status}
|
||
if ticket.status in {"已成交", "未成交", "已关闭"}:
|
||
return {"ok": False, "error": "terminal", "status": ticket.status}
|
||
if ticket.collab_chat_id and ticket.collab_chat_id != room:
|
||
return {"ok": False, "error": "occupied", "other_chat_id": ticket.collab_chat_id}
|
||
for other in self._tickets.values():
|
||
if other.work_order_no != no and other.collab_chat_id == room:
|
||
if (other.business_line or "").upper() == "AIR":
|
||
other.collab_chat_id = ""
|
||
ticket.collab_chat_id = room
|
||
return {
|
||
"ok": True,
|
||
"work_order_no": no,
|
||
"chat_id": room,
|
||
"status": ticket.status,
|
||
"business_line": ticket.business_line,
|
||
}
|
||
|
||
def lock_cabin(
|
||
self,
|
||
*,
|
||
work_order_no: str,
|
||
option_no: str,
|
||
sender_id: str = "",
|
||
operator_name: str = "",
|
||
instruction_text: str = "",
|
||
) -> dict[str, Any]:
|
||
"""单测可注入 _cabin_lock_result;默认成功。"""
|
||
_ = sender_id, operator_name, instruction_text
|
||
forced = getattr(self, "_cabin_lock_result", None)
|
||
if isinstance(forced, dict):
|
||
return dict(forced)
|
||
no = (work_order_no or "").strip()
|
||
ticket = self.get_ticket(work_order_no=no)
|
||
quote = dict(ticket.quote or {}) if ticket else {}
|
||
return {
|
||
"ok": True,
|
||
"status": "LOCKED",
|
||
"lockId": f"CA-{no}-LOCK",
|
||
"quoteId": str(quote.get("quoteId") or f"QT-{no}"),
|
||
"optionNo": option_no,
|
||
"tmsRequestId": f"TMS-LOCK-{no}",
|
||
}
|
||
|
||
def release_cabin(
|
||
self,
|
||
*,
|
||
work_order_no: str,
|
||
lock_id: str,
|
||
sender_id: str = "",
|
||
operator_name: str = "",
|
||
instruction_text: str = "",
|
||
) -> dict[str, Any]:
|
||
_ = lock_id, sender_id, operator_name, instruction_text
|
||
forced = getattr(self, "_cabin_release_result", None)
|
||
if isinstance(forced, dict):
|
||
return dict(forced)
|
||
no = (work_order_no or "").strip()
|
||
return {
|
||
"ok": True,
|
||
"status": "RELEASED",
|
||
"releaseId": f"CA-{no}-RLSE",
|
||
"tmsRequestId": f"TMS-RLSE-{no}",
|
||
}
|