落地海运协同群:测服靠会话存档听群话,报价单能带上协同字段。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -24,6 +24,15 @@ WECOM_CALLBACK_REPLY_TEXT=询价智能体接通成功。已收到:{content}
|
||||
WECOM_OPS_AGENT_ID=1000014
|
||||
# 会话存档席:后台员工管理里「询价机器人」的企微 userid,每个协同群必拉。可逗号分隔多个。
|
||||
WECOM_ARCHIVE_SEAT_USER_IDS=
|
||||
# 微盛会话存档(Worker 按存档席拉群消息)。游标只写 REDIS_KEY_PREFIX,禁止推现网官方 seq。
|
||||
WSHOTO_BASE_URL=https://open.wshoto.com
|
||||
WSHOTO_APP_ID=
|
||||
WSHOTO_APP_SECRET=
|
||||
WSHOTO_TENANT_ID=
|
||||
WSHOTO_POLL_ENABLED=true
|
||||
WSHOTO_POLL_INTERVAL_SEC=2
|
||||
WSHOTO_BOOTSTRAP_LOOKBACK_SEC=10800
|
||||
WSHOTO_ATTACHMENT_MIN_INTERVAL_MS=1050
|
||||
DEV_INJECT_ENABLED=true
|
||||
|
||||
# 本机端口(切流前并存:勿占用现网 8080/8810/8813)
|
||||
|
||||
@@ -68,12 +68,20 @@ class WeComAppClient:
|
||||
"""
|
||||
extra = dict(payload or {})
|
||||
if extra.get("msgtype") == "file":
|
||||
return self.send_file(
|
||||
file_result = self.send_file(
|
||||
touser=touser,
|
||||
filename=str(extra.get("filename") or "quote.xlsx"),
|
||||
filepath=str(extra.get("filepath") or ""),
|
||||
file_b64=str(extra.get("file_b64") or extra.get("fileBase64") or ""),
|
||||
)
|
||||
# 成交卡绑在同一条出站:文件 HTTP 成功后再发卡。
|
||||
# 企微文件气泡要等素材落地,卡片是即时的;同一毫秒连发,销售会先看到成交跟进。
|
||||
if file_result.ok:
|
||||
gap = float(getattr(self, "_followup_gap_sec", 1.6) or 0)
|
||||
if gap > 0:
|
||||
time.sleep(gap)
|
||||
self._send_followup_card(touser=touser, extra=extra)
|
||||
return file_result
|
||||
if extra.get("msgtype") == "update_template_card":
|
||||
return self.update_template_card(
|
||||
touser=touser,
|
||||
@@ -142,6 +150,36 @@ class WeComAppClient:
|
||||
logger.info("企微模板卡已置灰 touser=%s", touser)
|
||||
return WeComSendResult(ok=True, errcode=0, errmsg=errmsg, raw=data)
|
||||
|
||||
def _send_followup_card(self, *, touser: str, extra: dict[str, Any]) -> None:
|
||||
"""
|
||||
报价文件发出后再发跟进卡。失败不回滚文件、不触发整条重试。
|
||||
|
||||
跟进卡单独入队会被另一条出站线程抢走,销售就会先看到成交跟进。
|
||||
"""
|
||||
card = extra.get("followup_card")
|
||||
if not isinstance(card, dict) or not card:
|
||||
nested = extra.get("followup_payload")
|
||||
if isinstance(nested, dict):
|
||||
card = nested.get("template_card")
|
||||
if not isinstance(card, dict) or not card:
|
||||
return
|
||||
result = self._post_message(
|
||||
{
|
||||
"touser": touser,
|
||||
"msgtype": "template_card",
|
||||
"agentid": self._agent_id,
|
||||
"template_card": card,
|
||||
"enable_id_trans": 0,
|
||||
},
|
||||
preview="[followup_card]",
|
||||
)
|
||||
if not result.ok:
|
||||
logger.warning(
|
||||
"报价文件已发出,跟进卡失败 err=%s:%s",
|
||||
result.errcode,
|
||||
result.errmsg,
|
||||
)
|
||||
|
||||
def send_file(
|
||||
self,
|
||||
*,
|
||||
@@ -283,12 +321,21 @@ class WeComAppClient:
|
||||
logger.info("企微群已补人 chat=%s", chat_id)
|
||||
return {"ok": True, "error": ""}
|
||||
|
||||
def send_group(self, *, chat_id: str, content: str) -> dict[str, Any]:
|
||||
def send_group(
|
||||
self,
|
||||
*,
|
||||
chat_id: str,
|
||||
content: str,
|
||||
mention_userids: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
向应用群发文本。必须走 appchat/send,不能当私聊 message/send。
|
||||
|
||||
mention_userids 只给调用方记账,不传给企微。
|
||||
正文开头已经写了 @姓名;再传企微点人名单,结尾会再点一次。
|
||||
失败回 ok=False,由调用方决定是否重试;不抛给回调线程。
|
||||
"""
|
||||
_ = mention_userids
|
||||
if not chat_id or not content:
|
||||
return {"ok": False, "error": "chat_or_content"}
|
||||
if not self._corp_id or not self._secret:
|
||||
@@ -299,7 +346,7 @@ class WeComAppClient:
|
||||
logger.exception("群消息取 token 失败")
|
||||
return {"ok": False, "error": str(exc)}
|
||||
url = f"{self._api_base}/cgi-bin/appchat/send"
|
||||
body = {"chatid": chat_id, "msgtype": "text", "text": {"content": content}}
|
||||
body: dict[str, Any] = {"chatid": chat_id, "msgtype": "text", "text": {"content": content}}
|
||||
try:
|
||||
with httpx.Client(timeout=self._timeout) as client:
|
||||
resp = client.post(url, params={"access_token": token}, json=body)
|
||||
@@ -313,6 +360,117 @@ class WeComAppClient:
|
||||
return {"ok": False, "error": str(data.get("errmsg") or f"errcode_{errcode}")}
|
||||
return {"ok": True, "error": ""}
|
||||
|
||||
def send_group_file(
|
||||
self,
|
||||
*,
|
||||
chat_id: str,
|
||||
filename: str,
|
||||
filepath: str = "",
|
||||
file_b64: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
向应用群发文件:先上传临时素材,再 appchat/send。
|
||||
|
||||
与私聊 send_file 相同素材接口,但消息必须走群,不能 message/send。
|
||||
失败回 ok=False,不抛给回调线程。
|
||||
"""
|
||||
if not chat_id:
|
||||
return {"ok": False, "error": "chat_id"}
|
||||
raw = b""
|
||||
if filepath:
|
||||
try:
|
||||
from pathlib import Path
|
||||
|
||||
raw = Path(filepath).read_bytes()
|
||||
except OSError as exc:
|
||||
return {"ok": False, "error": f"read_file:{exc}"}
|
||||
elif file_b64:
|
||||
import base64
|
||||
|
||||
try:
|
||||
raw = base64.b64decode(file_b64)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"ok": False, "error": f"b64:{exc}"}
|
||||
if not raw:
|
||||
return {"ok": False, "error": "file_empty"}
|
||||
media_id, up = self._upload_media(filename=filename or "quote.xlsx", content=raw)
|
||||
if not media_id:
|
||||
return {"ok": False, "error": up.errmsg or "upload_fail"}
|
||||
return self._post_appchat(
|
||||
{
|
||||
"chatid": chat_id,
|
||||
"msgtype": "file",
|
||||
"file": {"media_id": media_id},
|
||||
}
|
||||
)
|
||||
|
||||
def send_group_card(self, *, chat_id: str, template_card: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
向应用群发卡。企微 appchat 对 template_card 支持不稳定,失败由调用方已发过文本兜底。
|
||||
"""
|
||||
if not chat_id or not template_card:
|
||||
return {"ok": False, "error": "chat_or_card"}
|
||||
return self._post_appchat(
|
||||
{
|
||||
"chatid": chat_id,
|
||||
"msgtype": "template_card",
|
||||
"template_card": template_card,
|
||||
}
|
||||
)
|
||||
|
||||
def dismiss_group(self, *, chat_id: str, userids: list[str]) -> dict[str, Any]:
|
||||
"""
|
||||
解散应用群:官方无 dismiss,改为 appchat/update 移出成员。
|
||||
|
||||
不抛给回调。移完人不改工单六态。
|
||||
"""
|
||||
users = [str(x).strip() for x in (userids or []) if str(x).strip()]
|
||||
if not chat_id or not users:
|
||||
return {"ok": False, "error": "chat_or_users"}
|
||||
try:
|
||||
token = self._get_token()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("解散群取 token 失败")
|
||||
return {"ok": False, "error": str(exc)}
|
||||
url = f"{self._api_base}/cgi-bin/appchat/update"
|
||||
body = {"chatid": chat_id, "del_user_list": users}
|
||||
try:
|
||||
with httpx.Client(timeout=self._timeout) as client:
|
||||
resp = client.post(url, params={"access_token": token}, json=body)
|
||||
data = resp.json()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("企微解散群网络失败")
|
||||
return {"ok": False, "error": str(exc)}
|
||||
errcode = int(data.get("errcode") or 0)
|
||||
if errcode != 0:
|
||||
logger.warning("企微解散群失败 errcode=%s", errcode)
|
||||
return {"ok": False, "error": str(data.get("errmsg") or f"errcode_{errcode}")}
|
||||
logger.info("企微群已移出成员 chat=%s", chat_id)
|
||||
return {"ok": True, "error": ""}
|
||||
|
||||
def _post_appchat(self, body: dict[str, Any]) -> dict[str, Any]:
|
||||
"""appchat/send 公共发送。失败不抛。"""
|
||||
if not self._corp_id or not self._secret:
|
||||
return {"ok": False, "error": "WECOM_CORP_ID/SECRET 未配置"}
|
||||
try:
|
||||
token = self._get_token()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("群发送取 token 失败")
|
||||
return {"ok": False, "error": str(exc)}
|
||||
url = f"{self._api_base}/cgi-bin/appchat/send"
|
||||
try:
|
||||
with httpx.Client(timeout=self._timeout) as client:
|
||||
resp = client.post(url, params={"access_token": token}, json=body)
|
||||
data = resp.json()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("企微 appchat/send 网络失败")
|
||||
return {"ok": False, "error": str(exc)}
|
||||
errcode = int(data.get("errcode") or 0)
|
||||
if errcode != 0:
|
||||
logger.warning("企微 appchat/send 失败 errcode=%s", errcode)
|
||||
return {"ok": False, "error": str(data.get("errmsg") or f"errcode_{errcode}")}
|
||||
return {"ok": True, "error": ""}
|
||||
|
||||
def send_text(self, *, touser: str, content: str) -> WeComSendResult:
|
||||
"""
|
||||
向指定企微 userid 发送文本应用消息。
|
||||
|
||||
@@ -238,7 +238,7 @@ class MemoryMessageStore:
|
||||
return state == TaskState.DONE
|
||||
if state == TaskState.CLAIMED:
|
||||
saw_claim = True
|
||||
# 单测没有出站线程:一直 PENDING 就不要空等 12 秒。测服有出站线程会立刻认领。
|
||||
# 没有出站线程时(单测)不要空等;已被认领则等到发完。
|
||||
if not saw_claim and state == TaskState.PENDING and time.time() - started >= 0.4:
|
||||
return True
|
||||
if not state and time.time() - started >= 0.3:
|
||||
|
||||
@@ -53,6 +53,15 @@ def parse_plain_xml(xml_text: str, *, default_agent_id: str = "") -> InboundMess
|
||||
if chat_id:
|
||||
chat_type = "group"
|
||||
|
||||
media: dict[str, object] = {}
|
||||
if msg_type.lower() in {"file", "image", "video"}:
|
||||
media = {
|
||||
"media_id": _text(root, "MediaId") or _text(root, "MediaID"),
|
||||
"filename": _text(root, "FileName") or _text(root, "Title"),
|
||||
"file_ext": _text(root, "FileExt"),
|
||||
"content_type": "",
|
||||
}
|
||||
|
||||
return InboundMessage(
|
||||
sender_id=sender_id,
|
||||
message_id=msg_id,
|
||||
@@ -62,7 +71,7 @@ def parse_plain_xml(xml_text: str, *, default_agent_id: str = "") -> InboundMess
|
||||
chat_type=chat_type,
|
||||
agent_id=agent_id,
|
||||
create_time=create_time,
|
||||
media={},
|
||||
media=media,
|
||||
raw={
|
||||
"event": event,
|
||||
"event_key": event_key,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
微盛会话存档通道:把管家查询结果归一成 InboundMessage。
|
||||
|
||||
本包只做 HTTP 查询与解析。轮询循环在 Worker HTTP 槽(jobs/archive_poll)。
|
||||
禁止:推现网官方会话存档 SDK 游标;禁止在本包建工单、调 LLM。
|
||||
"""
|
||||
@@ -0,0 +1,197 @@
|
||||
"""
|
||||
微盛 OpenAPI 客户端:换票 + 拉员工群消息。
|
||||
|
||||
本文件职责:带超时的 HTTPS GET;token 缓存在本前缀 Redis。
|
||||
禁止:在回调线程调用;禁止把 access_token / secret 打进日志;禁止动现网官方 seq。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from agent.config import Settings, get_settings
|
||||
from agent.redis_coord import get_redis_runtime
|
||||
from agent.redis_coord.client import RedisClient
|
||||
from agent.redis_coord.token_cache import get_wshoto_token, set_wshoto_token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TOKEN_PATH = "/openapi/access_token"
|
||||
_LIST_PATH = "/openapi/chat/message/list"
|
||||
_HTTP_TIMEOUT = 8.0
|
||||
|
||||
|
||||
class WshotoError(RuntimeError):
|
||||
"""管家接口业务失败(code!=0)。message 不得含密钥。"""
|
||||
|
||||
def __init__(self, code: int, msg: str) -> None:
|
||||
self.code = int(code)
|
||||
self.msg = str(msg or "")
|
||||
super().__init__(f"wshoto code={self.code} msg={self.msg}")
|
||||
|
||||
|
||||
def wshoto_ready(settings: Optional[Settings] = None) -> bool:
|
||||
"""有 AppId/Secret 且开关未关,才允许 Worker 去拉。"""
|
||||
cfg = settings or get_settings()
|
||||
if not bool(getattr(cfg, "wshoto_poll_enabled", True)):
|
||||
return False
|
||||
return bool((cfg.wshoto_app_id or "").strip() and (cfg.wshoto_app_secret or "").strip())
|
||||
|
||||
|
||||
def _is_token_error(code: int, msg: str) -> bool:
|
||||
text = (msg or "").lower()
|
||||
return code in {40001, 40014, 42001} or "token" in text or "过期" in (msg or "")
|
||||
|
||||
|
||||
class WshotoOpenClient:
|
||||
"""
|
||||
微盛查询客户端。
|
||||
|
||||
调用时机:Worker HTTP 槽。失败抛 WshotoError 或返回空列表由调用方决定。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: Optional[Settings] = None,
|
||||
*,
|
||||
redis: Optional[RedisClient] = None,
|
||||
) -> None:
|
||||
self.settings = settings or get_settings()
|
||||
self._redis = redis
|
||||
|
||||
def _redis_client(self) -> Optional[RedisClient]:
|
||||
if self._redis is not None:
|
||||
return self._redis
|
||||
try:
|
||||
return get_redis_runtime().client
|
||||
except Exception:
|
||||
logger.exception("微盛 token 缓存:Redis 不可用,本次直打换票")
|
||||
return None
|
||||
|
||||
def _base(self) -> str:
|
||||
return (self.settings.wshoto_base_url or "https://open.wshoto.com").rstrip("/")
|
||||
|
||||
def fetch_token(self, *, force: bool = False) -> str:
|
||||
"""
|
||||
取 access_token:先读本前缀缓存,没有再换票。
|
||||
|
||||
副作用:可能写 Redis token:wshoto。不会推进任何消息游标。
|
||||
"""
|
||||
app_id = (self.settings.wshoto_app_id or "").strip()
|
||||
secret = (self.settings.wshoto_app_secret or "").strip()
|
||||
if not app_id or not secret:
|
||||
raise WshotoError(0, "missing_app_credentials")
|
||||
cache = self._redis_client()
|
||||
if cache is not None and not force:
|
||||
cached = get_wshoto_token(cache, app_id=app_id)
|
||||
if cached:
|
||||
return cached
|
||||
url = f"{self._base()}{_TOKEN_PATH}"
|
||||
try:
|
||||
resp = httpx.get(
|
||||
url,
|
||||
params={"appid": app_id, "app_secret": secret},
|
||||
timeout=_HTTP_TIMEOUT,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
body = resp.json()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise WshotoError(0, f"token_http:{type(exc).__name__}") from exc
|
||||
code = int(body.get("code") if body.get("code") is not None else -1)
|
||||
if code != 0:
|
||||
raise WshotoError(code, str(body.get("msg") or "token_failed"))
|
||||
data = body.get("data") if isinstance(body.get("data"), dict) else {}
|
||||
token = str(data.get("access_token") or "").strip()
|
||||
if not token:
|
||||
raise WshotoError(code, "empty_access_token")
|
||||
expires = int(data.get("expires_in") or 7200)
|
||||
if cache is not None:
|
||||
set_wshoto_token(cache, app_id=app_id, token=token, ttl_seconds=max(60, expires - 120))
|
||||
logger.info("微盛换票成功 expires_in=%s", expires)
|
||||
return token
|
||||
|
||||
def list_group_messages(
|
||||
self,
|
||||
*,
|
||||
userid: str,
|
||||
target_id: str = "",
|
||||
last_seq: int = 0,
|
||||
page_size: int = 50,
|
||||
msg_time_start: int = 0,
|
||||
msg_time_end: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
拉指定员工的群聊消息(type=1)。
|
||||
|
||||
last_seq 只是本次查询过滤,不是官方 SDK 消费确认。
|
||||
首次不要传 last_seq=0 翻全量,应配合时间窗。
|
||||
"""
|
||||
token = self.fetch_token()
|
||||
return self._list_once(
|
||||
token=token,
|
||||
userid=userid,
|
||||
target_id=target_id,
|
||||
last_seq=last_seq,
|
||||
page_size=page_size,
|
||||
msg_time_start=msg_time_start,
|
||||
msg_time_end=msg_time_end,
|
||||
retry_token=True,
|
||||
)
|
||||
|
||||
def _list_once(
|
||||
self,
|
||||
*,
|
||||
token: str,
|
||||
userid: str,
|
||||
target_id: str,
|
||||
last_seq: int,
|
||||
page_size: int,
|
||||
msg_time_start: int,
|
||||
msg_time_end: int,
|
||||
retry_token: bool,
|
||||
) -> list[dict[str, Any]]:
|
||||
params: dict[str, Any] = {
|
||||
"access_token": token,
|
||||
"type": 1,
|
||||
"page_size": max(1, min(int(page_size or 50), 100)),
|
||||
}
|
||||
if userid:
|
||||
params["userid"] = userid
|
||||
if target_id:
|
||||
params["target_id"] = target_id
|
||||
if last_seq > 0:
|
||||
params["last_seq"] = int(last_seq)
|
||||
if msg_time_start > 0 and msg_time_end > 0:
|
||||
params["msg_time_start"] = int(msg_time_start)
|
||||
params["msg_time_end"] = int(msg_time_end)
|
||||
url = f"{self._base()}{_LIST_PATH}"
|
||||
try:
|
||||
resp = httpx.get(url, params=params, timeout=_HTTP_TIMEOUT)
|
||||
resp.raise_for_status()
|
||||
body = resp.json()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise WshotoError(0, f"list_http:{type(exc).__name__}") from exc
|
||||
code = int(body.get("code") if body.get("code") is not None else -1)
|
||||
msg = str(body.get("msg") or "")
|
||||
if code != 0:
|
||||
if retry_token and _is_token_error(code, msg):
|
||||
fresh = self.fetch_token(force=True)
|
||||
return self._list_once(
|
||||
token=fresh,
|
||||
userid=userid,
|
||||
target_id=target_id,
|
||||
last_seq=last_seq,
|
||||
page_size=page_size,
|
||||
msg_time_start=msg_time_start,
|
||||
msg_time_end=msg_time_end,
|
||||
retry_token=False,
|
||||
)
|
||||
raise WshotoError(code, msg or "list_failed")
|
||||
data = body.get("data") if isinstance(body.get("data"), dict) else {}
|
||||
records = data.get("records") if isinstance(data, dict) else None
|
||||
if not isinstance(records, list):
|
||||
return []
|
||||
return [dict(x) for x in records if isinstance(x, dict)]
|
||||
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
微盛群消息 → InboundMessage。
|
||||
|
||||
本文件职责:从管家 records 抽出 sender_id / chat_id / 正文 / 附件名。
|
||||
禁止:把存档席「询价机器人」当成发言人入业务;禁止再向企微换 ID。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Mapping, Optional, Sequence
|
||||
|
||||
from agent.channel.wecom.models import InboundMessage
|
||||
|
||||
# 存档席在群里的显示名;from 偶发不是 userid 时也要丢掉
|
||||
SEAT_DISPLAY_NAMES = frozenset({"询价机器人"})
|
||||
_SKIP_TYPES = frozenset({"revoke", "recall", "event", "unknown"})
|
||||
_FILE_TYPES = frozenset({"file", "image", "video", "emotion", "voice"})
|
||||
|
||||
|
||||
def _as_mapping(raw: Any) -> dict[str, Any]:
|
||||
if isinstance(raw, Mapping):
|
||||
return dict(raw)
|
||||
if isinstance(raw, str) and raw.strip().startswith("{"):
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return dict(parsed) if isinstance(parsed, Mapping) else {}
|
||||
return {}
|
||||
|
||||
|
||||
def _create_time(msgtime: Any) -> int:
|
||||
"""微盛 msgtime 常是毫秒;InboundMessage.create_time 用秒。"""
|
||||
try:
|
||||
n = int(msgtime or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
if n > 10**12:
|
||||
return n // 1000
|
||||
return n
|
||||
|
||||
|
||||
def _filename(msg_type: str, content: str, payload: Mapping[str, Any]) -> str:
|
||||
for key in ("filename", "file_name", "FileName", "title", "name"):
|
||||
val = str(payload.get(key) or "").strip()
|
||||
if val:
|
||||
return val
|
||||
if msg_type in _FILE_TYPES:
|
||||
return str(content or "").strip()
|
||||
return ""
|
||||
|
||||
|
||||
def archive_record_to_inbound(
|
||||
record: Mapping[str, Any],
|
||||
*,
|
||||
seat_userids: Sequence[str] = (),
|
||||
) -> Optional[InboundMessage]:
|
||||
"""
|
||||
一条管家记录变成群入站。
|
||||
|
||||
返回 None:不是群、没有发言人、发言人是存档席、或撤回类消息。
|
||||
message_id 带 wshoto: 前缀,避免和回调 MsgId 撞车。
|
||||
"""
|
||||
room_id = str(record.get("room_id") or record.get("roomid") or "").strip()
|
||||
sender_id = str(record.get("from") or record.get("from_id") or "").strip()
|
||||
if not room_id or not sender_id:
|
||||
return None
|
||||
seats = {str(x).strip() for x in seat_userids if str(x).strip()}
|
||||
if sender_id in seats or sender_id in SEAT_DISPLAY_NAMES:
|
||||
return None
|
||||
|
||||
msg_type = str(record.get("msgtype") or record.get("msg_type") or "text").strip().lower()
|
||||
if msg_type in _SKIP_TYPES:
|
||||
return None
|
||||
|
||||
payload = _as_mapping(record.get("msg_content") or record.get("msgcontent"))
|
||||
content = str(record.get("content") or payload.get("content") or "").strip()
|
||||
msgid = str(record.get("msgid") or record.get("msg_id") or "").strip()
|
||||
seq = record.get("seq")
|
||||
if not msgid:
|
||||
msgid = f"{room_id}:{seq or 0}:{sender_id}"
|
||||
|
||||
media: dict[str, Any] = {}
|
||||
inbound_type = "text"
|
||||
if msg_type in _FILE_TYPES:
|
||||
inbound_type = "file" if msg_type == "file" else msg_type
|
||||
media = {
|
||||
"filename": _filename(msg_type, content, payload),
|
||||
"media_id": str(payload.get("sdkfileid") or payload.get("sdk_file_id") or ""),
|
||||
"file_ext": str(payload.get("fileext") or payload.get("file_ext") or ""),
|
||||
"content_type": "",
|
||||
"filesize": payload.get("filesize") or payload.get("file_size") or 0,
|
||||
}
|
||||
if not content:
|
||||
content = str(media.get("filename") or "")
|
||||
|
||||
return InboundMessage(
|
||||
sender_id=sender_id,
|
||||
message_id=f"wshoto:{msgid}",
|
||||
content=content,
|
||||
msg_type=inbound_type,
|
||||
chat_id=room_id,
|
||||
chat_type="group",
|
||||
create_time=_create_time(record.get("msgtime") or record.get("msg_time")),
|
||||
media=media,
|
||||
raw={
|
||||
"source": "wshoto_archive",
|
||||
"seq": seq,
|
||||
"msgtype": msg_type,
|
||||
"direction": record.get("direction") or "",
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,246 @@
|
||||
"""
|
||||
微盛群消息一轮:按「询价机器人」席位拉新话,写入 inbox。
|
||||
|
||||
本文件职责:换票查询、归一、入 inbox、推进测服 last_seq。
|
||||
Worker 独立线程轮询,不占 HTTP 槽、不堵回调。入队后由 inbox 线程分发。
|
||||
禁止:堵回调;禁止推现网官方 SDK 游标;禁止把存档席当发言人。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Callable, Optional, Sequence
|
||||
|
||||
from agent.channel.queue import get_message_store
|
||||
from agent.channel.wshoto.client import WshotoError, WshotoOpenClient, wshoto_ready
|
||||
from agent.channel.wshoto.parse import archive_record_to_inbound
|
||||
from agent.channel.wshoto.rooms import (
|
||||
list_watched_rooms,
|
||||
load_last_seq,
|
||||
mark_room_hot,
|
||||
save_last_seq,
|
||||
)
|
||||
from agent.config import Settings, get_settings
|
||||
from agent.redis_coord.client import RedisClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 新群还没有游标时,只回看最近几分钟,避免每轮翻三小时。
|
||||
_NEW_ROOM_LOOKBACK_SEC = 180
|
||||
|
||||
|
||||
def archive_seat_userids(settings: Optional[Settings] = None) -> list[str]:
|
||||
"""
|
||||
会话存档账号 userid。
|
||||
|
||||
测服群里的「询价机器人」就是这一席;默认读 WECOM_ARCHIVE_SEAT_USER_IDS(AI001)。
|
||||
"""
|
||||
cfg = settings or get_settings()
|
||||
raw = str(getattr(cfg, "wecom_archive_seat_user_ids", "") or "").strip()
|
||||
ids: list[str] = []
|
||||
for part in raw.replace(";", ",").split(","):
|
||||
uid = part.strip()
|
||||
if uid and uid not in ids:
|
||||
ids.append(uid)
|
||||
return ids
|
||||
|
||||
|
||||
def _record_seq(record: dict[str, Any]) -> int:
|
||||
try:
|
||||
return int(record.get("seq") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def ingest_records(
|
||||
records: Sequence[dict[str, Any]],
|
||||
*,
|
||||
seat_userids: Sequence[str],
|
||||
min_seq: int = 0,
|
||||
store: Any = None,
|
||||
) -> tuple[int, int]:
|
||||
"""
|
||||
把一页管家记录写入 inbox。
|
||||
|
||||
返回:(入队成功数, 跳过数)。无 sender / 存档席 / 重复 message_id 都算跳过。
|
||||
"""
|
||||
book = store if store is not None else get_message_store()
|
||||
enqueued = 0
|
||||
skipped = 0
|
||||
for rec in records:
|
||||
if _record_seq(rec) <= min_seq:
|
||||
skipped += 1
|
||||
continue
|
||||
msg = archive_record_to_inbound(rec, seat_userids=seat_userids)
|
||||
if msg is None:
|
||||
skipped += 1
|
||||
continue
|
||||
ok, reason = book.enqueue_inbound(msg)
|
||||
if ok:
|
||||
enqueued += 1
|
||||
mark_room_hot(msg.chat_id)
|
||||
logger.info(
|
||||
"存档入队 sender=%s chat=%s type=%s reason=%s",
|
||||
msg.sender_id,
|
||||
msg.chat_id,
|
||||
msg.msg_type,
|
||||
reason,
|
||||
)
|
||||
else:
|
||||
skipped += 1
|
||||
return enqueued, skipped
|
||||
|
||||
|
||||
def _needs_target(exc: WshotoError) -> bool:
|
||||
text = (exc.msg or "").lower()
|
||||
return "target_id" in text or "targetid" in text or "房间" in (exc.msg or "")
|
||||
|
||||
|
||||
def poll_once(
|
||||
*,
|
||||
client: Optional[WshotoOpenClient] = None,
|
||||
settings: Optional[Settings] = None,
|
||||
redis: Optional[RedisClient] = None,
|
||||
store: Any = None,
|
||||
now: Optional[Callable[[], float]] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
拉一页并存入 inbox。
|
||||
|
||||
已有 last_seq:按 seq 增量查。没有:只看最近若干小时,避免从 0 翻全公司历史。
|
||||
已登记协同群按房间号拉(不要带存档席 userid,应用群否则给空页)。
|
||||
还没有群时才按席位试拉。
|
||||
"""
|
||||
cfg = settings or get_settings()
|
||||
if not wshoto_ready(cfg):
|
||||
return {"ok": False, "reason": "not_configured"}
|
||||
seats = archive_seat_userids(cfg)
|
||||
if not seats:
|
||||
logger.warning("微盛轮询:未配置 WECOM_ARCHIVE_SEAT_USER_IDS,只能靠显示名丢掉询价机器人自己的话")
|
||||
api = client or WshotoOpenClient(cfg, redis=redis)
|
||||
lookback = max(60, int(getattr(cfg, "wshoto_bootstrap_lookback_sec", 10800) or 10800))
|
||||
page_size = int(getattr(cfg, "wshoto_page_size", 50) or 50)
|
||||
clock = now or time.time
|
||||
rooms = list_watched_rooms(redis=redis)
|
||||
if rooms:
|
||||
# 应用群必须带房间号、不要带存档席 userid,否则管家返回空页。
|
||||
records, mode, last = _poll_by_rooms(
|
||||
api,
|
||||
redis=redis,
|
||||
page_size=page_size,
|
||||
lookback=lookback,
|
||||
clock=clock,
|
||||
)
|
||||
else:
|
||||
seat = seats[0] if seats else ""
|
||||
last = load_last_seq(f"seat:{seat or 'default'}", redis=redis)
|
||||
time_start = 0
|
||||
time_end = 0
|
||||
if last <= 0:
|
||||
time_end = int(clock())
|
||||
time_start = time_end - lookback
|
||||
try:
|
||||
records = api.list_group_messages(
|
||||
userid=seat,
|
||||
last_seq=last,
|
||||
page_size=page_size,
|
||||
msg_time_start=time_start,
|
||||
msg_time_end=time_end,
|
||||
)
|
||||
mode = "seat"
|
||||
except WshotoError as exc:
|
||||
if not _needs_target(exc):
|
||||
logger.warning("微盛按席拉取失败 code=%s msg=%s", exc.code, exc.msg)
|
||||
return {"ok": False, "reason": "list_failed", "code": exc.code}
|
||||
records, mode, last = _poll_by_rooms(
|
||||
api,
|
||||
redis=redis,
|
||||
page_size=page_size,
|
||||
lookback=lookback,
|
||||
clock=clock,
|
||||
)
|
||||
# 按房间拉时各群自己的 last_seq 已经过滤过,这里不要再用席游标误丢。
|
||||
ingest_min = 0 if mode == "rooms" else last
|
||||
enqueued, skipped = ingest_records(
|
||||
records,
|
||||
seat_userids=seats,
|
||||
min_seq=ingest_min,
|
||||
store=store,
|
||||
)
|
||||
max_seq = last
|
||||
for rec in records:
|
||||
seq = _record_seq(rec)
|
||||
if seq > max_seq:
|
||||
max_seq = seq
|
||||
if max_seq > last and not rooms:
|
||||
save_last_seq(f"seat:{(seats[0] if seats else 'default')}", max_seq, redis=redis)
|
||||
logger.info(
|
||||
"微盛轮询 mode=%s fetched=%s enqueued=%s skipped=%s last_seq=%s→%s",
|
||||
mode,
|
||||
len(records),
|
||||
enqueued,
|
||||
skipped,
|
||||
last,
|
||||
max_seq,
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"mode": mode,
|
||||
"fetched": len(records),
|
||||
"enqueued": enqueued,
|
||||
"skipped": skipped,
|
||||
"last_seq": max_seq,
|
||||
}
|
||||
|
||||
|
||||
def _poll_by_rooms(
|
||||
api: WshotoOpenClient,
|
||||
*,
|
||||
redis: Optional[RedisClient],
|
||||
page_size: int,
|
||||
lookback: int,
|
||||
clock: Callable[[], float],
|
||||
) -> tuple[list[dict[str, Any]], str, int]:
|
||||
"""只拉已登记协同群。应用群不要带存档席 userid,否则管家给空页。"""
|
||||
rooms = list_watched_rooms(redis=redis)
|
||||
if not rooms:
|
||||
logger.warning("微盛按房间拉取:还没有登记过的协同群")
|
||||
return [], "rooms", 0
|
||||
rooms = sorted(
|
||||
rooms,
|
||||
key=lambda r: (0 if load_last_seq(f"room:{r}", redis=redis) <= 0 else 1, r),
|
||||
)
|
||||
merged: list[dict[str, Any]] = []
|
||||
floor = 0
|
||||
for room in rooms:
|
||||
scope = f"room:{room}"
|
||||
last = load_last_seq(scope, redis=redis)
|
||||
time_start = 0
|
||||
time_end = 0
|
||||
if last <= 0:
|
||||
time_end = int(clock())
|
||||
time_start = time_end - min(lookback, _NEW_ROOM_LOOKBACK_SEC)
|
||||
try:
|
||||
rows = api.list_group_messages(
|
||||
userid="",
|
||||
target_id=room,
|
||||
last_seq=last,
|
||||
page_size=page_size,
|
||||
msg_time_start=time_start,
|
||||
msg_time_end=time_end,
|
||||
)
|
||||
except WshotoError as exc:
|
||||
logger.warning("微盛房间拉取失败 chat=%s code=%s msg=%s", room, exc.code, exc.msg)
|
||||
continue
|
||||
max_seq = last
|
||||
for rec in rows:
|
||||
seq = _record_seq(rec)
|
||||
if seq > max_seq:
|
||||
max_seq = seq
|
||||
if max_seq > last:
|
||||
save_last_seq(scope, max_seq, redis=redis)
|
||||
if last > floor:
|
||||
floor = last
|
||||
merged.extend(rows)
|
||||
return merged, "rooms", floor
|
||||
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
测服自己的微盛监听名单与 last_seq。
|
||||
|
||||
本文件职责:把协同群 chat_id、按席/按房间的查询游标写到 REDIS_KEY_PREFIX 下。
|
||||
禁止:写现网 ytd:prod: 前缀;禁止把游标当六态。
|
||||
线程:可被建群线程与 Worker 轮询同时调用,只做单 key 读写。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from agent.redis_coord import get_redis_runtime
|
||||
from agent.redis_coord.client import RedisClient
|
||||
from agent.redis_coord.keys import WSHOTO_LAST_SEQ, WSHOTO_ROOM_TICKET, WSHOTO_ROOMS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 建群后或刚听到群话:立刻唤醒轮询,并在热窗内更勤地扫。
|
||||
_poll_wake = threading.Event()
|
||||
_hot_until: dict[str, float] = {}
|
||||
_hot_lock = threading.Lock()
|
||||
HOT_WINDOW_SEC = 90.0
|
||||
|
||||
|
||||
def wake_wshoto_poll() -> None:
|
||||
"""打断轮询等待。建群线程可调;真正拉消息仍在 Worker 轮询线程。"""
|
||||
_poll_wake.set()
|
||||
|
||||
|
||||
def wait_wshoto_poll(timeout: float) -> bool:
|
||||
"""等下一轮或被唤醒。返回是否被踢醒。"""
|
||||
fired = _poll_wake.wait(timeout=max(0.1, float(timeout)))
|
||||
if fired:
|
||||
_poll_wake.clear()
|
||||
return fired
|
||||
|
||||
|
||||
def mark_room_hot(chat_id: str, *, ttl_sec: float = HOT_WINDOW_SEC) -> None:
|
||||
"""这群接下来几十秒更勤扫:刚建群或刚有人说话。"""
|
||||
raw = (chat_id or "").strip()
|
||||
if not raw:
|
||||
return
|
||||
until = time.time() + max(5.0, float(ttl_sec))
|
||||
with _hot_lock:
|
||||
_hot_until[raw] = until
|
||||
wake_wshoto_poll()
|
||||
|
||||
|
||||
def has_hot_room() -> bool:
|
||||
"""是否还有热窗内的协同群。"""
|
||||
now = time.time()
|
||||
with _hot_lock:
|
||||
stale = [k for k, until in _hot_until.items() if until <= now]
|
||||
for k in stale:
|
||||
_hot_until.pop(k, None)
|
||||
return any(until > now for until in _hot_until.values())
|
||||
|
||||
|
||||
def _client(redis: Optional[RedisClient] = None) -> Optional[RedisClient]:
|
||||
if redis is not None:
|
||||
return redis
|
||||
try:
|
||||
return get_redis_runtime().client
|
||||
except Exception:
|
||||
logger.exception("微盛监听名单:Redis 不可用")
|
||||
return None
|
||||
|
||||
|
||||
def watch_collab_room(
|
||||
chat_id: str,
|
||||
*,
|
||||
work_order_no: str = "",
|
||||
redis: Optional[RedisClient] = None,
|
||||
) -> None:
|
||||
"""
|
||||
建群成功后登记 chat_id,供按房间补拉。
|
||||
|
||||
可同时记下房间→工单号,主账按群查找暂时不可用时也能对上单。
|
||||
副作用:改 Redis。失败只打日志,不挡销售侧建群。
|
||||
"""
|
||||
raw = (chat_id or "").strip()
|
||||
if not raw:
|
||||
return
|
||||
client = _client(redis)
|
||||
if client is None:
|
||||
return
|
||||
key = client.key(WSHOTO_ROOMS)
|
||||
current = client.raw.get(key) or ""
|
||||
if isinstance(current, bytes):
|
||||
current = current.decode("utf-8")
|
||||
rooms = {x.strip() for x in str(current).split(",") if x.strip()}
|
||||
rooms.add(raw)
|
||||
client.raw.set(key, ",".join(sorted(rooms)))
|
||||
no = (work_order_no or "").strip()
|
||||
if no:
|
||||
client.raw.set(client.key(WSHOTO_ROOM_TICKET, raw), no)
|
||||
logger.info("存档监听已登记群数=%s", len(rooms))
|
||||
mark_room_hot(raw)
|
||||
|
||||
|
||||
def ticket_no_for_room(chat_id: str, *, redis: Optional[RedisClient] = None) -> str:
|
||||
"""用协同群 chat_id 找回工单号(测服 Redis 短索引,不是六态)。"""
|
||||
raw = (chat_id or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
client = _client(redis)
|
||||
if client is None:
|
||||
return ""
|
||||
val = client.raw.get(client.key(WSHOTO_ROOM_TICKET, raw))
|
||||
if val is None:
|
||||
return ""
|
||||
if isinstance(val, bytes):
|
||||
val = val.decode("utf-8")
|
||||
return str(val).strip()
|
||||
|
||||
|
||||
def unwatch_collab_room(chat_id: str, *, redis: Optional[RedisClient] = None) -> None:
|
||||
"""协同结束后去掉监听,避免解散群还被当成活群补拉。"""
|
||||
raw = (chat_id or "").strip()
|
||||
if not raw:
|
||||
return
|
||||
client = _client(redis)
|
||||
if client is None:
|
||||
return
|
||||
key = client.key(WSHOTO_ROOMS)
|
||||
current = client.raw.get(key) or ""
|
||||
if isinstance(current, bytes):
|
||||
current = current.decode("utf-8")
|
||||
rooms = {x.strip() for x in str(current).split(",") if x.strip() and x.strip() != raw}
|
||||
client.raw.set(key, ",".join(sorted(rooms)))
|
||||
client.raw.delete(client.key(WSHOTO_ROOM_TICKET, raw))
|
||||
|
||||
|
||||
def list_watched_rooms(*, redis: Optional[RedisClient] = None) -> list[str]:
|
||||
"""当前要按房间补拉的协同群。"""
|
||||
client = _client(redis)
|
||||
if client is None:
|
||||
return []
|
||||
current = client.raw.get(client.key(WSHOTO_ROOMS)) or ""
|
||||
if isinstance(current, bytes):
|
||||
current = current.decode("utf-8")
|
||||
return [x.strip() for x in str(current).split(",") if x.strip()]
|
||||
|
||||
|
||||
def load_last_seq(scope: str, *, redis: Optional[RedisClient] = None) -> int:
|
||||
"""
|
||||
读取本前缀下的 last_seq。
|
||||
|
||||
scope:seat:AI001 或 room:wrxxx。没有记录返回 0(调用方改走时间窗,避免从 0 翻全量历史)。
|
||||
"""
|
||||
client = _client(redis)
|
||||
if client is None:
|
||||
return 0
|
||||
val = client.raw.get(client.key(WSHOTO_LAST_SEQ, scope))
|
||||
if val is None:
|
||||
return 0
|
||||
try:
|
||||
return int(val)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def save_last_seq(scope: str, seq: int, *, redis: Optional[RedisClient] = None) -> None:
|
||||
"""推进测服自己的查询游标。seq 必须单调不减。"""
|
||||
if seq <= 0:
|
||||
return
|
||||
client = _client(redis)
|
||||
if client is None:
|
||||
return
|
||||
prev = load_last_seq(scope, redis=client)
|
||||
if seq < prev:
|
||||
return
|
||||
client.raw.set(client.key(WSHOTO_LAST_SEQ, scope), str(int(seq)))
|
||||
@@ -64,6 +64,23 @@ class Settings(BaseSettings):
|
||||
default="",
|
||||
alias="WECOM_ARCHIVE_SEAT_USER_IDS",
|
||||
)
|
||||
# 微盛企微管家:Worker 轮询群消息。游标只写本进程 Redis 前缀,禁止去推现网官方 seq。
|
||||
wshoto_base_url: str = Field(default="https://open.wshoto.com", alias="WSHOTO_BASE_URL")
|
||||
wshoto_app_id: str = Field(default="", alias="WSHOTO_APP_ID")
|
||||
wshoto_app_secret: str = Field(default="", alias="WSHOTO_APP_SECRET")
|
||||
wshoto_tenant_id: str = Field(default="", alias="WSHOTO_TENANT_ID")
|
||||
wshoto_poll_enabled: bool = Field(default=True, alias="WSHOTO_POLL_ENABLED")
|
||||
# 群消息靠会话存档,扫得越勤,听到得越快。热窗内还会再压到 1 秒。
|
||||
wshoto_poll_interval_sec: float = Field(default=2.0, alias="WSHOTO_POLL_INTERVAL_SEC")
|
||||
wshoto_bootstrap_lookback_sec: int = Field(
|
||||
default=10800,
|
||||
alias="WSHOTO_BOOTSTRAP_LOOKBACK_SEC",
|
||||
)
|
||||
wshoto_page_size: int = Field(default=50, alias="WSHOTO_PAGE_SIZE")
|
||||
wshoto_attachment_min_interval_ms: int = Field(
|
||||
default=1050,
|
||||
alias="WSHOTO_ATTACHMENT_MIN_INTERVAL_MS",
|
||||
)
|
||||
wecom_callback_token: str = Field(default="", alias="WECOM_CALLBACK_TOKEN")
|
||||
wecom_encoding_aes_key: str = Field(default="", alias="WECOM_ENCODING_AES_KEY")
|
||||
wecom_callback_reply_text: str = Field(
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
"""
|
||||
意图:海运协同群里补协同字段。
|
||||
意图:海运协同群入站(文字意图 / 附件)。
|
||||
|
||||
本文件职责:群聊文字 → 只抽协同六字段 → 静默写主账。
|
||||
禁止:回「已记下」;把闲聊/报价金额写成报价版本;改工单六态。
|
||||
本文件职责:对上工单后交给 sea_group_ops;一类动作一条分支。
|
||||
禁止:改询价七项;终态装没看见;把销售附件当报价。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.channel.wecom.models import InboundMessage
|
||||
from agent.llm.extract_text import extract_collab_fields
|
||||
from agent.policy.sea_group_ops import (
|
||||
apply_collab_update,
|
||||
apply_product_quote,
|
||||
dissolve_group,
|
||||
group_client_of,
|
||||
handle_group_file,
|
||||
send_group_text,
|
||||
ticket_no,
|
||||
ticket_view,
|
||||
)
|
||||
from agent.policy.sea_text_flow import get_sea_text_flow
|
||||
from agent.schema.field_validate import SEA_COLLAB_FIELDS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -22,12 +31,36 @@ def handle_group_collab(
|
||||
*,
|
||||
flow=None,
|
||||
ledger=None,
|
||||
injected_intent: str = "",
|
||||
injected_quote: Optional[dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
处理一条群聊文字。
|
||||
处理一条群聊入站。兼容旧测试名。
|
||||
|
||||
必须能对上已绑定工单,且发言人是被拉进来的销售或产品。
|
||||
识别到协同字段才写库;否则忽略。不回复。
|
||||
"""
|
||||
return handle_group_inbound(
|
||||
message,
|
||||
flow=flow,
|
||||
ledger=ledger,
|
||||
injected_intent=injected_intent,
|
||||
injected_quote=injected_quote,
|
||||
)
|
||||
|
||||
|
||||
def handle_group_inbound(
|
||||
message: InboundMessage,
|
||||
*,
|
||||
flow=None,
|
||||
ledger=None,
|
||||
injected_intent: str = "",
|
||||
injected_quote: Optional[dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
群里一条文字或附件。
|
||||
|
||||
文字走 DeepSeek A(单测可注入);附件只看扩展名。
|
||||
协同已结束或终态要回话,不能当没看见。
|
||||
"""
|
||||
if (message.chat_type or "") != "group":
|
||||
return "group_ignore"
|
||||
@@ -41,40 +74,105 @@ def handle_group_collab(
|
||||
|
||||
ticket = _find_ticket(book, chat_id)
|
||||
if ticket is None:
|
||||
logger.info("group_inbound 对不上工单 chat=%s", chat_id)
|
||||
return "group_ignore"
|
||||
if not _sender_allowed(ticket, message.sender_id):
|
||||
logger.info("group_collab 忽略非成员 sender=%s chat=%s", message.sender_id, chat_id)
|
||||
logger.info("group_inbound 忽略非成员 sender=%s chat=%s", message.sender_id, chat_id)
|
||||
return "group_ignore"
|
||||
|
||||
facts = extract_collab_fields(message.content or "")
|
||||
if not facts:
|
||||
return "group_ignore"
|
||||
sess = _session_of_ticket(engine, ticket)
|
||||
view = ticket_view(ticket)
|
||||
if view.get("collab_ended") or (sess is not None and getattr(sess, "collab_ended", False)):
|
||||
from agent.policy import inquiry_copy as copy
|
||||
|
||||
if isinstance(ticket, dict):
|
||||
wo = str(ticket.get("work_order_no") or "")
|
||||
else:
|
||||
wo = str(getattr(ticket, "work_order_no", "") or "")
|
||||
if not wo:
|
||||
return "group_ignore"
|
||||
send_group_text(
|
||||
group_client_of(engine),
|
||||
chat_id,
|
||||
copy.group_dissolved(work_order_no=ticket_no(ticket)),
|
||||
)
|
||||
return "group_already_ended"
|
||||
|
||||
engine.apply_collab_facts(
|
||||
work_order_no=wo,
|
||||
facts=facts,
|
||||
kind = (message.msg_type or "text").lower()
|
||||
if kind in {"file", "image", "video"}:
|
||||
media = dict(message.media or {})
|
||||
filename = str(
|
||||
media.get("filename") or media.get("FileName") or (message.content or "")
|
||||
).strip()
|
||||
return handle_group_file(
|
||||
flow=engine,
|
||||
ticket=ticket,
|
||||
sender_id=message.sender_id,
|
||||
filename=filename,
|
||||
media=media,
|
||||
sess=sess,
|
||||
)
|
||||
logger.info(
|
||||
"group_collab 写入 wo=%s keys=%s sender=%s",
|
||||
wo,
|
||||
sorted(k for k in facts if k in SEA_COLLAB_FIELDS),
|
||||
message.sender_id,
|
||||
|
||||
from agent.routing.group_intent import (
|
||||
INTENT_DISSOLVE,
|
||||
INTENT_FIELDS,
|
||||
INTENT_QUOTE,
|
||||
classify_group_text,
|
||||
)
|
||||
return "collab_field_saved"
|
||||
|
||||
intent = classify_group_text(message.content or "", injected=injected_intent)
|
||||
if intent == INTENT_FIELDS:
|
||||
return apply_collab_update(
|
||||
flow=engine,
|
||||
ticket=ticket,
|
||||
text=message.content or "",
|
||||
sender_id=message.sender_id,
|
||||
sess=sess,
|
||||
)
|
||||
if intent == INTENT_QUOTE:
|
||||
return apply_product_quote(
|
||||
flow=engine,
|
||||
ticket=ticket,
|
||||
text=message.content or "",
|
||||
sender_id=message.sender_id,
|
||||
sess=sess,
|
||||
injected_quote=injected_quote,
|
||||
)
|
||||
if intent == INTENT_DISSOLVE:
|
||||
return dissolve_group(flow=engine, ticket=ticket, sess=sess)
|
||||
logger.info("group_inbound 其它意图 chat=%s intent=%s", chat_id, intent)
|
||||
return "group_ignore"
|
||||
|
||||
|
||||
def _find_ticket(book: object, chat_id: str):
|
||||
finder = getattr(book, "find_by_collab_chat", None)
|
||||
if callable(finder):
|
||||
return finder(chat_id=chat_id)
|
||||
hit = finder(chat_id=chat_id)
|
||||
if hit is not None:
|
||||
return hit
|
||||
try:
|
||||
from agent.channel.wshoto.rooms import ticket_no_for_room
|
||||
|
||||
no = ticket_no_for_room(chat_id)
|
||||
except Exception:
|
||||
logger.exception("存档房间反查工单失败 chat=%s", chat_id)
|
||||
no = ""
|
||||
if not no:
|
||||
return None
|
||||
getter = getattr(book, "get_ticket", None)
|
||||
if callable(getter):
|
||||
hit = getter(work_order_no=no)
|
||||
if hit is not None:
|
||||
return hit
|
||||
getter = getattr(book, "get_for_agent", None)
|
||||
if callable(getter):
|
||||
return getter(work_order_no=no, sender_id="")
|
||||
return None
|
||||
|
||||
|
||||
def _session_of_ticket(engine: object, ticket: object):
|
||||
"""用工单号找回销售侧书签,方便写 collab_facts / deal_version。"""
|
||||
no = ticket_no(ticket)
|
||||
getter = getattr(engine, "session_by_work_order", None)
|
||||
if callable(getter) and no:
|
||||
return getter(no)
|
||||
by_ticket = getattr(engine, "_by_ticket", None)
|
||||
if isinstance(by_ticket, dict) and no:
|
||||
return by_ticket.get(no)
|
||||
return None
|
||||
|
||||
|
||||
@@ -82,12 +180,9 @@ def _sender_allowed(ticket: object, sender_id: str) -> bool:
|
||||
uid = (sender_id or "").strip()
|
||||
if not uid:
|
||||
return False
|
||||
if isinstance(ticket, dict):
|
||||
sales = str(ticket.get("sales_wecom_id") or ticket.get("salesWecomId") or "")
|
||||
members = ticket.get("collab_member_ids") or ticket.get("memberIds") or []
|
||||
else:
|
||||
sales = str(getattr(ticket, "sales_wecom_id", "") or "")
|
||||
members = list(getattr(ticket, "collab_member_ids", []) or [])
|
||||
view = ticket_view(ticket)
|
||||
sales = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "")
|
||||
members = view.get("collab_member_ids") or view.get("memberIds") or []
|
||||
if uid == sales:
|
||||
return True
|
||||
return uid in {str(x) for x in members}
|
||||
|
||||
@@ -110,6 +110,7 @@ class WorkerRuntime:
|
||||
threading.Thread(target=self._llm_loop, name="llm-loop", daemon=True),
|
||||
threading.Thread(target=self._recognition_loop, name="recog-loop", daemon=True),
|
||||
threading.Thread(target=self._archive_loop, name="archive-loop", daemon=True),
|
||||
threading.Thread(target=self._wshoto_poll_loop, name="wshoto-poll", daemon=True),
|
||||
threading.Thread(target=self._pdf_loop, name="pdf-loop", daemon=True),
|
||||
]
|
||||
for t in self._threads:
|
||||
@@ -241,6 +242,34 @@ class WorkerRuntime:
|
||||
self.http_jobs.run(_job, label="archive")
|
||||
time.sleep(0.2)
|
||||
|
||||
def _wshoto_poll_loop(self) -> None:
|
||||
"""
|
||||
独立线程轮询微盛群消息,写入 inbox。
|
||||
|
||||
不占回调、不占 HTTP 槽、不与 soffice 串队。建群或刚有人说话会立刻再扫。
|
||||
未配钥匙时只空等,避免打无密钥请求。
|
||||
"""
|
||||
from agent.channel.wshoto.client import wshoto_ready
|
||||
from agent.channel.wshoto.poll import poll_once
|
||||
from agent.channel.wshoto.rooms import has_hot_room, wait_wshoto_poll
|
||||
|
||||
interval = float(getattr(self.settings, "wshoto_poll_interval_sec", 2.0) or 2.0)
|
||||
interval = min(30.0, max(1.0, interval))
|
||||
logged_skip = False
|
||||
while not self._stop.is_set():
|
||||
if not wshoto_ready(self.settings):
|
||||
if not logged_skip:
|
||||
logger.info("微盛轮询未开启:缺少 WSHOTO_APP_ID/SECRET 或开关已关")
|
||||
logged_skip = True
|
||||
self._stop.wait(15.0)
|
||||
continue
|
||||
try:
|
||||
poll_once(settings=self.settings, redis=self.redis.client if self.redis else None)
|
||||
except Exception:
|
||||
logger.exception("微盛轮询失败")
|
||||
wait = 1.0 if has_hot_room() else interval
|
||||
wait_wshoto_poll(wait)
|
||||
|
||||
def _pdf_loop(self) -> None:
|
||||
"""仅 LibreOffice 单槽消费 PDF Stream。"""
|
||||
if not self.redis:
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""
|
||||
微盛存档任务壳:独立 Stream,与 LibreOffice 槽隔离。
|
||||
|
||||
本文件职责:enqueue / process_stub;正式下载与登记后续接对象存储 + 主账附件元数据。
|
||||
本文件职责:enqueue / process_stub;附件落对象存储后续再接。
|
||||
群消息轮询不在本 Stream,见 jobs/archive_poll(Worker HTTP 槽拉微盛)。
|
||||
禁止:与 soffice 共用阻塞线;禁止把存档成功冒充业务成交成功。
|
||||
"""
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
微盛轮询入口(Worker 槽调用)。
|
||||
|
||||
实现在 channel/wshoto/poll.py,避免 jobs 包导入把通道单测拖进 Graph。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from agent.channel.wshoto.client import wshoto_ready
|
||||
from agent.channel.wshoto.poll import archive_seat_userids, ingest_records, poll_once
|
||||
|
||||
__all__ = ["archive_seat_userids", "ingest_records", "poll_once", "wshoto_ready"]
|
||||
@@ -136,28 +136,30 @@ def _deliver_quote_file(data: dict[str, Any], result: LibreOfficeResult) -> None
|
||||
from agent.policy.inquiry_copy import file_ready
|
||||
|
||||
store = get_message_store()
|
||||
quote = data.get("quote") if isinstance(data.get("quote"), dict) else {}
|
||||
facts = data.get("facts") if isinstance(data.get("facts"), dict) else {}
|
||||
from agent.policy import inquiry_copy as copy
|
||||
|
||||
store.enqueue_outbound(
|
||||
touser=touser,
|
||||
content=file_ready(kind="PDF", work_order_no=wo, filename=filename),
|
||||
content="\n".join(
|
||||
[
|
||||
file_ready(kind="PDF", work_order_no=wo, filename=filename),
|
||||
copy.deal_card(work_order_no=wo, quote=quote, facts=facts),
|
||||
]
|
||||
),
|
||||
dedupe_key=f"pdf-file:{wo}:{filename}",
|
||||
payload={
|
||||
"msgtype": "file",
|
||||
"filepath": result.pdf_path,
|
||||
"filename": filename,
|
||||
"followup_card": copy.deal_wecom_payload(
|
||||
work_order_no=wo, quote=quote, facts=facts
|
||||
).get("template_card")
|
||||
or {},
|
||||
},
|
||||
)
|
||||
# 文件先入队,成交卡后入队;出站单线程保证销售先看到文件
|
||||
quote = data.get("quote") if isinstance(data.get("quote"), dict) else {}
|
||||
facts = data.get("facts") if isinstance(data.get("facts"), dict) else {}
|
||||
if wo:
|
||||
from agent.policy import inquiry_copy as copy
|
||||
|
||||
store.enqueue_outbound(
|
||||
touser=touser,
|
||||
content=copy.deal_card(work_order_no=wo, quote=quote, facts=facts),
|
||||
dedupe_key=f"deal-card:{wo}:{filename}",
|
||||
payload=copy.deal_wecom_payload(work_order_no=wo, quote=quote, facts=facts),
|
||||
)
|
||||
try:
|
||||
from agent.policy.air_text_flow import get_air_text_flow
|
||||
|
||||
|
||||
@@ -102,6 +102,15 @@ class HttpLedger:
|
||||
return {"ok": False, "found": False, "owned": False, "work_order_no": work_order_no}
|
||||
return out
|
||||
|
||||
def get_ticket(self, *, work_order_no: str) -> dict[str, Any] | None:
|
||||
"""用工单号读快照。给群入站在按房间反查时用。找不到返回 None。"""
|
||||
out = self.get_for_agent(work_order_no=work_order_no, sender_id="")
|
||||
if not out or (not out.get("found") and not out.get("work_order_no") and not out.get("ok")):
|
||||
return None
|
||||
if out.get("found") is False and out.get("ok") is False:
|
||||
return None
|
||||
return out
|
||||
|
||||
def query_tms(
|
||||
self,
|
||||
*,
|
||||
@@ -224,6 +233,7 @@ class HttpLedger:
|
||||
chat_id: str,
|
||||
member_ids: list[str],
|
||||
product_names: list[str] | None = None,
|
||||
product_ids: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
out = self._post(
|
||||
"/inquiry/agent/ticket/bindCollabGroup",
|
||||
@@ -232,6 +242,7 @@ class HttpLedger:
|
||||
"chatId": chat_id,
|
||||
"memberIds": member_ids,
|
||||
"productNames": product_names or [],
|
||||
"productIds": product_ids or [],
|
||||
},
|
||||
idem=f"bind-group:{work_order_no}",
|
||||
)
|
||||
@@ -261,3 +272,50 @@ class HttpLedger:
|
||||
if not out.get("found") and not out.get("work_order_no"):
|
||||
return None
|
||||
return out
|
||||
|
||||
def end_collab_group(self, *, work_order_no: str) -> dict[str, Any]:
|
||||
"""协同结束,这单不能再拉群。不改六态。"""
|
||||
return self._post(
|
||||
"/inquiry/agent/ticket/endCollabGroup",
|
||||
{"workOrderNo": work_order_no},
|
||||
idem=f"end-collab:{work_order_no}",
|
||||
)
|
||||
|
||||
def attach_file(
|
||||
self,
|
||||
*,
|
||||
work_order_no: str,
|
||||
file_name: str,
|
||||
object_key: str = "",
|
||||
content_type: str = "",
|
||||
sender_id: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""群附件元数据写入主账。"""
|
||||
return self._post(
|
||||
"/inquiry/agent/ticket/attachFile",
|
||||
{
|
||||
"workOrderNo": work_order_no,
|
||||
"fileName": file_name,
|
||||
"objectKey": object_key or file_name,
|
||||
"contentType": content_type,
|
||||
"senderId": sender_id,
|
||||
},
|
||||
)
|
||||
|
||||
def upsert_product_quote(
|
||||
self,
|
||||
*,
|
||||
work_order_no: str,
|
||||
quote: dict[str, Any],
|
||||
to_status: str = "已报价",
|
||||
) -> dict[str, Any]:
|
||||
"""产品报价写入主账并迁已报价。"""
|
||||
return self._post(
|
||||
"/inquiry/agent/ticket/upsertProductQuote",
|
||||
{
|
||||
"workOrderNo": work_order_no,
|
||||
"quote": quote,
|
||||
"toStatus": to_status,
|
||||
},
|
||||
idem=f"product-quote:{work_order_no}:{sorted((quote or {}).keys())}",
|
||||
)
|
||||
|
||||
@@ -36,6 +36,10 @@ class MemoryTicket:
|
||||
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
|
||||
|
||||
|
||||
@@ -367,17 +371,64 @@ class MemoryLedger:
|
||||
chat_id: str,
|
||||
member_ids: list[str],
|
||||
product_names: list[str] | None = None,
|
||||
product_ids: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""记下协同群,不改六态。"""
|
||||
_ = product_names
|
||||
"""记下协同群和产品姓名,不改六态。重启后群摘要还要靠这些名字 @ 到人。"""
|
||||
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 = "已报价",
|
||||
) -> dict[str, Any]:
|
||||
"""产品报价盖 TMS 价。"""
|
||||
return self.upsert_quote(
|
||||
work_order_no=work_order_no, quote=quote, to_status=to_status
|
||||
)
|
||||
|
||||
def patch_collab_facts(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -23,12 +23,12 @@ _MODE_WORDS = (
|
||||
_LABEL_LINE = re.compile(
|
||||
r"^(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
|
||||
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|车型数量|"
|
||||
r"贸易条款|货好时间|HS编码|HS|是否含油|是否含电|是否含磁)\s*[::]\s*(.+)$"
|
||||
r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁)\s*[::]\s*(.+)$"
|
||||
)
|
||||
_LABEL_INLINE = re.compile(
|
||||
r"(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
|
||||
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|车型数量|"
|
||||
r"贸易条款|货好时间|HS编码|HS|是否含油|是否含电|是否含磁)\s*[::]\s*(\S+)"
|
||||
r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁)\s*[::]\s*(\S+)"
|
||||
)
|
||||
|
||||
|
||||
@@ -118,18 +118,76 @@ def extract_inquiry_snapshot(
|
||||
}
|
||||
|
||||
|
||||
def extract_collab_fields(text: str) -> dict[str, str]:
|
||||
def extract_collab_fields(
|
||||
text: str,
|
||||
*,
|
||||
allow_b: bool = False,
|
||||
injected: Optional[dict[str, str]] = None,
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
群里协同字段:只收带标签的非空值。
|
||||
群里协同字段:标签「键:值」+ 口语含油/含电/含磁、货好时间。
|
||||
|
||||
闲聊、报价金额、附件本轮不进这里。HS 折成 HS编码。
|
||||
闲聊、报价金额不进这里。商品海关编码 / HS 折成 HS编码。
|
||||
allow_b 保留给调用方;协同六项不走询价七项的 B 合同。
|
||||
"""
|
||||
from agent.schema.field_validate import SEA_COLLAB_FIELDS, normalize_facts
|
||||
from agent.schema.field_validate import (
|
||||
SEA_COLLAB_FIELDS,
|
||||
harvest_oral_collab,
|
||||
normalize_facts,
|
||||
)
|
||||
|
||||
del allow_b
|
||||
if injected:
|
||||
return {
|
||||
k: str(v).strip()
|
||||
for k, v in normalize_facts(dict(injected)).items()
|
||||
if k in SEA_COLLAB_FIELDS and str(v or "").strip()
|
||||
}
|
||||
labeled = normalize_facts(parse_labeled_facts(text))
|
||||
oral = harvest_oral_collab(text)
|
||||
merged = dict(oral)
|
||||
merged.update({k: v for k, v in labeled.items() if v})
|
||||
out: dict[str, str] = {}
|
||||
for key in SEA_COLLAB_FIELDS:
|
||||
val = (labeled.get(key) or "").strip()
|
||||
val = (merged.get(key) or "").strip()
|
||||
if val:
|
||||
out[key] = val
|
||||
return out
|
||||
|
||||
|
||||
_RE_MONEY = re.compile(
|
||||
r"(?:USD|CNY|RMB|美金|美元|人民币)?\s*(\d+(?:\.\d+)?)\s*(?:USD|CNY|RMB|美金|美元|人民币|元)?"
|
||||
r"|(?:USD|CNY|RMB|美金|美元)\s*(\d+(?:\.\d+)?)",
|
||||
re.I,
|
||||
)
|
||||
|
||||
|
||||
def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = None) -> dict[str, Any]:
|
||||
"""
|
||||
产品文字报价:抽出合计/币别。注入优先。
|
||||
|
||||
认不出价格返回空 dict,调用方回「没有识别到报价」。
|
||||
"""
|
||||
if isinstance(injected, dict) and (injected.get("total") or injected.get("fee_rows")):
|
||||
out = dict(injected)
|
||||
out.setdefault("source_label", "产品报价")
|
||||
return out
|
||||
raw = (text or "").strip()
|
||||
if not raw:
|
||||
return {}
|
||||
hit = _RE_MONEY.search(raw)
|
||||
if not hit:
|
||||
return {}
|
||||
amount = (hit.group(1) or hit.group(2) or "").strip()
|
||||
if not amount:
|
||||
return {}
|
||||
ccy = "USD"
|
||||
if any(x in raw.upper() for x in ("CNY", "RMB", "人民币", "元")):
|
||||
ccy = "CNY"
|
||||
return {
|
||||
"total": amount,
|
||||
"currency": ccy,
|
||||
"source_label": "产品报价",
|
||||
"raw_text": raw,
|
||||
"fee_rows": [{"name": "海运费", "amount": amount, "currency": ccy}],
|
||||
}
|
||||
|
||||
@@ -50,6 +50,8 @@ class FlowSession:
|
||||
invited_userids: tuple[str, ...] = ()
|
||||
invited_sales_name: str = ""
|
||||
invited_product_names: tuple[str, ...] = ()
|
||||
deal_version: int = 0
|
||||
collab_ended: bool = False
|
||||
|
||||
|
||||
class AirTextInquiryFlow:
|
||||
@@ -74,6 +76,14 @@ class AirTextInquiryFlow:
|
||||
with self._lock:
|
||||
return self._by_sender.get(sender_id)
|
||||
|
||||
def session_by_work_order(self, work_order_no: str) -> Optional[FlowSession]:
|
||||
"""用工单号找回书签,不校验 sender。群成交卡校验用。"""
|
||||
no = (work_order_no or "").strip()
|
||||
if not no:
|
||||
return None
|
||||
with self._lock:
|
||||
return self._by_ticket.get(no)
|
||||
|
||||
def session_by_ticket(self, sender_id: str, work_order_no: str) -> Optional[FlowSession]:
|
||||
"""
|
||||
只查进程内该书签,不读主账。
|
||||
@@ -298,7 +308,8 @@ class AirTextInquiryFlow:
|
||||
reply("报价单正在生成,请稍候。")
|
||||
return "wait_file"
|
||||
if sess.phase == "wait_deal":
|
||||
self._emit_deal(reply, sess)
|
||||
# 成交卡已经绑在报价文件那条出站上。这里再发一张独立卡,
|
||||
# 会在文件还在上传时被另一条出站线程抢走,销售又先看到成交跟进。
|
||||
return "wait_deal"
|
||||
if sess.phase == "done":
|
||||
reply(copy.deal_ack(sess.status or "已关闭", sess.work_order_no))
|
||||
@@ -814,15 +825,33 @@ class AirTextInquiryFlow:
|
||||
file_extra: dict[str, Any],
|
||||
) -> str:
|
||||
"""
|
||||
先发报价文件,等这条出站真正发出,再出成交跟进卡。
|
||||
报价文件与成交跟进走同一条出站:先传文件,成功后再发卡。
|
||||
|
||||
避免卡片比文件先到销售手机。单测 reply 不回 id 则不等待。
|
||||
两张消息分两条出站时,另一条消费线程会在上传期间把成交卡发出去。
|
||||
单测 reply 不回 id 则不等待。
|
||||
"""
|
||||
out_id = reply(
|
||||
copy.file_ready(kind=kind, work_order_no=sess.work_order_no, filename=filename),
|
||||
file_extra,
|
||||
extra = dict(file_extra or {})
|
||||
extra["followup_card"] = copy.deal_wecom_payload(
|
||||
work_order_no=sess.work_order_no,
|
||||
quote=sess.quote,
|
||||
facts=sess.facts,
|
||||
).get("template_card") or {}
|
||||
reply(
|
||||
"\n".join(
|
||||
[
|
||||
copy.file_ready(
|
||||
kind=kind, work_order_no=sess.work_order_no, filename=filename
|
||||
),
|
||||
copy.deal_card(
|
||||
work_order_no=sess.work_order_no,
|
||||
status=sess.status or "已报价",
|
||||
quote=sess.quote,
|
||||
facts=sess.facts,
|
||||
),
|
||||
]
|
||||
),
|
||||
extra,
|
||||
)
|
||||
self._wait_outbound_sent(str(out_id or ""), work_order_no=sess.work_order_no, what="报价文件")
|
||||
from agent.redis_coord.quote_file import mark_quote_file_ready
|
||||
|
||||
mark_quote_file_ready(sess.work_order_no)
|
||||
@@ -830,7 +859,6 @@ class AirTextInquiryFlow:
|
||||
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
|
||||
sess.wait_version += 1
|
||||
self._save(sess)
|
||||
self._emit_deal(reply, sess)
|
||||
return "wait_deal"
|
||||
|
||||
def _emit_deal(self, reply: ReplyFn, sess: FlowSession) -> None:
|
||||
|
||||
@@ -13,7 +13,12 @@ import time
|
||||
import uuid
|
||||
from typing import Any, Iterable
|
||||
|
||||
from agent.schema.field_validate import AIR_REQUIRED_FIELDS, SEA_COLLAB_FIELDS, SEA_REQUIRED_FIELDS
|
||||
from agent.schema.field_validate import (
|
||||
AIR_REQUIRED_FIELDS,
|
||||
SEA_COLLAB_DISPLAY,
|
||||
SEA_COLLAB_FIELDS,
|
||||
SEA_REQUIRED_FIELDS,
|
||||
)
|
||||
|
||||
# 空运补问:与合同 8 项一致。报价日期默认当天,不进待补充。
|
||||
_AIR_EXAMPLES = {
|
||||
@@ -379,6 +384,20 @@ def work_order_from_card_meta(
|
||||
return ""
|
||||
|
||||
|
||||
def deal_card_version(card_meta: dict[str, Any] | None = None, *, text: str = "") -> int:
|
||||
"""从成交卡 EventKey 取出 vN;没有则 0(旧私聊卡)。"""
|
||||
src = card_meta or {}
|
||||
for raw in (
|
||||
str(src.get("event_key") or ""),
|
||||
str(src.get("task_id") or ""),
|
||||
text or "",
|
||||
):
|
||||
hit = re.search(r":v(\d+)\s*$", raw)
|
||||
if hit:
|
||||
return int(hit.group(1))
|
||||
return 0
|
||||
|
||||
|
||||
def _work_order_in_token(raw: str) -> str:
|
||||
"""从 TaskId / EventKey 里抽出 WO 工单号;没有则空。"""
|
||||
text = (raw or "").strip()
|
||||
@@ -505,19 +524,18 @@ def sea_quote_detail_text(
|
||||
history_work_order_no: str = "",
|
||||
status: str = "已报价",
|
||||
) -> str:
|
||||
"""海运完整价格明细文本。不受卡片引用条字数限制。"""
|
||||
"""
|
||||
海运完整价格明细文本:来源、工单号、费用行、合计。
|
||||
|
||||
时效、报价有效期只在卡片上出现,这里不写,避免和卡重复。
|
||||
不受卡片引用条字数限制。first_or_same 只兼容调用方。
|
||||
"""
|
||||
_ = first_or_same, history_work_order_no, status
|
||||
src = dict(quote or {})
|
||||
source = str(src.get("source_label") or "TMS标准报价").replace(" ", "")
|
||||
lines = [source, f"{work_order_no}价格明细"]
|
||||
lines.extend(
|
||||
quote_detail_lines(
|
||||
src,
|
||||
first_or_same=first_or_same,
|
||||
history_work_order_no=history_work_order_no,
|
||||
status=status,
|
||||
return "\n".join(
|
||||
[source, f"{work_order_no}价格明细", quote_area_fee_text(src)]
|
||||
)
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def collab_hint(work_order_no: str) -> str:
|
||||
@@ -957,6 +975,11 @@ def sea_group_no_staff() -> str:
|
||||
return "没有可拉的产品人员。请先在后台「员工管理」维护本线路的海运产品岗后再试。"
|
||||
|
||||
|
||||
def group_sheet_send_fail() -> str:
|
||||
"""模板已填好但没发进群。"""
|
||||
return "报价单已生成,发到群里失败,请稍后重试。"
|
||||
|
||||
|
||||
def sea_group_create_fail(*, reason: str = "") -> str:
|
||||
"""建群失败,按钮保持可点。"""
|
||||
extra = (reason or "").strip()
|
||||
@@ -978,7 +1001,8 @@ def sea_group_brief(
|
||||
"""
|
||||
群里自动摘要:@销售 @产品 + 工单号 + 询价字段 + 协同字段 + TMS 报价。
|
||||
|
||||
协同没填写 -。无价写明暂无匹配报价。
|
||||
三段之间空一行,方便群里扫。协同没填写 -。
|
||||
HS编码对外写成商品海关编码。无价只写「TMS 暂无匹配报价」。
|
||||
"""
|
||||
no = (work_order_no or "").strip()
|
||||
sales = (sales_name or "").strip() or "销售"
|
||||
@@ -988,16 +1012,19 @@ def sea_group_brief(
|
||||
lines.extend(
|
||||
inquiry_field_lines(work_order_no=no, facts=facts, transport_mode="海运")[1:]
|
||||
)
|
||||
lines.append("")
|
||||
lines.append("协同补充字段:")
|
||||
collab = dict(collab_facts or {})
|
||||
for key in SEA_COLLAB_FIELDS:
|
||||
val = (collab.get(key) or "").strip() or "-"
|
||||
lines.append(f"{key}:{val}")
|
||||
label = SEA_COLLAB_DISPLAY.get(key, key)
|
||||
val = (collab.get(key) or collab.get(label) or "").strip() or "-"
|
||||
lines.append(f"{label}:{val}")
|
||||
lines.append("")
|
||||
lines.append("TMS报价:")
|
||||
if has_price and quote:
|
||||
lines.extend(quote_detail_lines(quote))
|
||||
else:
|
||||
lines.append("TMS 暂无匹配报价 / 未查到合适报价")
|
||||
lines.append("TMS 暂无匹配报价")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -1244,6 +1271,7 @@ def deal_wecom_payload(
|
||||
work_order_no: str,
|
||||
quote: dict[str, Any] | None = None,
|
||||
facts: dict[str, str] | None = None,
|
||||
card_version: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
成交跟进卡:对齐现网 button_interaction。
|
||||
@@ -1254,6 +1282,8 @@ def deal_wecom_payload(
|
||||
src = dict(quote or {})
|
||||
source = str(src.get("source_label") or "TMS标准报价").replace(" ", "")
|
||||
no = (work_order_no or "").strip()
|
||||
ver = int(card_version or 0)
|
||||
suffix = f":v{ver}" if ver > 0 else ""
|
||||
return {
|
||||
"msgtype": "template_card",
|
||||
"template_card": {
|
||||
@@ -1270,23 +1300,75 @@ def deal_wecom_payload(
|
||||
{
|
||||
"text": BTN_DEAL,
|
||||
"style": 1,
|
||||
"key": f"{BTN_DEAL_KEY}:{no}",
|
||||
"key": f"{BTN_DEAL_KEY}:{no}{suffix}",
|
||||
},
|
||||
{
|
||||
"text": BTN_LOST,
|
||||
"style": 1,
|
||||
"key": f"{BTN_LOST_KEY}:{no}",
|
||||
"key": f"{BTN_LOST_KEY}:{no}{suffix}",
|
||||
},
|
||||
{
|
||||
"text": BTN_NEGOTIATE,
|
||||
"style": 1,
|
||||
"key": f"{BTN_NEGOTIATE_KEY}:{no}",
|
||||
"key": f"{BTN_NEGOTIATE_KEY}:{no}{suffix}",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def ticket_locked(*, work_order_no: str, status: str) -> str:
|
||||
"""已成交/未成交/已关闭后,群里改字段或再报价。"""
|
||||
no = (work_order_no or "").strip() or "该工单"
|
||||
st = (status or "").strip() or "已关闭"
|
||||
return f"工单{no}{st},不能修改工单信息"
|
||||
|
||||
|
||||
def ticket_locked_file(*, work_order_no: str, status: str) -> str:
|
||||
"""终态工单再发 Excel/PDF。"""
|
||||
no = (work_order_no or "").strip() or "该工单"
|
||||
st = (status or "").strip() or "已关闭"
|
||||
return f"工单{no}{st},不能再上传附件"
|
||||
|
||||
|
||||
def group_only_excel_pdf() -> str:
|
||||
return "群里只收 Excel 和 PDF,其它文件不会存入工单"
|
||||
|
||||
|
||||
def group_quote_not_recognized() -> str:
|
||||
return "没有识别到报价,请产品再发一次价格或 Excel/PDF"
|
||||
|
||||
|
||||
def group_fields_not_recognized() -> str:
|
||||
return "听到了。请直接说贸易条款、货好时间、商品海关编码、是否含油/含电/含磁。"
|
||||
|
||||
|
||||
def group_deal_stale(*, work_order_no: str) -> str:
|
||||
"""点了上一版成交卡。"""
|
||||
no = (work_order_no or "").strip()
|
||||
return f"这张成交确认已过期。工单{no}已有更新的报价,请点击群里最新一条成交跟进。"
|
||||
|
||||
|
||||
def group_deal_sales_only() -> str:
|
||||
return "只有负责该工单的销售可以确认成交状态。"
|
||||
|
||||
|
||||
def group_dissolved(*, work_order_no: str) -> str:
|
||||
no = (work_order_no or "").strip()
|
||||
return f"群已解散。工单{no}协同已结束。"
|
||||
|
||||
|
||||
def group_cannot_recreate(*, work_order_no: str) -> str:
|
||||
"""解散后再点拉群。"""
|
||||
no = (work_order_no or "").strip() or "该工单"
|
||||
return f"工单{no}协同已结束,不能再拉群。"
|
||||
|
||||
|
||||
def group_deal_mention(*, sales_name: str, work_order_no: str) -> str:
|
||||
name = (sales_name or "").strip() or "销售"
|
||||
return f"@{name}\n{deal_card(work_order_no=work_order_no)}"
|
||||
|
||||
|
||||
def deal_ack(outcome: str, work_order_no: str) -> str:
|
||||
if outcome == "已成交":
|
||||
return f"已记录为「已成交」。工单 {work_order_no} 已关闭智能体跟进。"
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
按海运询价单标签补写协同字段。
|
||||
|
||||
主账填表只认英文码,货好时间 / 海关编码 / 含油电磁常空着。
|
||||
本文件只改 Excel 字节,不打主账、不占回调线程。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 长的「含油,电,磁」必须排在「是否含油」前面,避免只写到油。
|
||||
_COLLAB_LABEL_MARKERS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("是否含油,电,磁", ("oil_elec_mag", "是否含油,电,磁")),
|
||||
("OIL,ELEC,MAG", ("oil_elec_mag", "是否含油,电,磁")),
|
||||
("贸易条款", ("贸易条款", "trade_terms")),
|
||||
("货好时间", ("货好时间", "cargo_ready_date")),
|
||||
("商品海关编码", ("商品海关编码", "HS编码", "海关编码", "hs_code")),
|
||||
("HS Code", ("商品海关编码", "HS编码", "海关编码", "hs_code")),
|
||||
("是否含油", ("是否含油", "has_oil")),
|
||||
("是否含电", ("是否含电", "has_battery")),
|
||||
("是否含磁", ("是否含磁", "has_magnet")),
|
||||
)
|
||||
|
||||
|
||||
def _fact_for_markers(facts: Mapping[str, Any], keys: tuple[str, ...]) -> str:
|
||||
for key in keys:
|
||||
val = str((facts or {}).get(key) or "").strip()
|
||||
if val and val != "-":
|
||||
return val
|
||||
return ""
|
||||
|
||||
|
||||
def overlay_labeled_facts(
|
||||
*,
|
||||
facts: Mapping[str, Any],
|
||||
filepath: str = "",
|
||||
file_b64: str = "",
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
按模板标签再写协同格。失败则原样交回。
|
||||
|
||||
副作用:可能写临时 xlsx。
|
||||
"""
|
||||
import base64
|
||||
import tempfile
|
||||
|
||||
raw = b""
|
||||
if filepath:
|
||||
try:
|
||||
raw = Path(filepath).read_bytes()
|
||||
except OSError as exc:
|
||||
logger.warning("overlay 读文件失败 err=%s", exc)
|
||||
if not raw and file_b64 and len(file_b64) > 64:
|
||||
try:
|
||||
raw = base64.b64decode(file_b64)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("overlay 解码失败 err=%s", exc)
|
||||
if not raw:
|
||||
return {"filepath": filepath, "file_b64": file_b64}
|
||||
try:
|
||||
from openpyxl import load_workbook
|
||||
except ImportError:
|
||||
return {"filepath": filepath, "file_b64": file_b64}
|
||||
|
||||
tmp = Path(tempfile.mkdtemp(prefix="quote-overlay-")) / "filled.xlsx"
|
||||
tmp.write_bytes(raw)
|
||||
written = 0
|
||||
try:
|
||||
wb = load_workbook(tmp)
|
||||
ws = wb.worksheets[0]
|
||||
pending: list[tuple[int, int, str]] = []
|
||||
used_rows: set[int] = set()
|
||||
for row in ws.iter_rows(min_row=1, max_row=ws.max_row or 1, max_col=min(ws.max_column or 1, 16)):
|
||||
for cell in row:
|
||||
text = str(cell.value or "").strip()
|
||||
if not text or cell.row in used_rows:
|
||||
continue
|
||||
keys = _match_collab_label(text)
|
||||
if not keys:
|
||||
continue
|
||||
val = _fact_for_markers(facts, keys)
|
||||
if not val:
|
||||
continue
|
||||
dest = _label_value_cell(ws, cell)
|
||||
pending.append((dest.row, dest.column, val))
|
||||
used_rows.add(cell.row)
|
||||
break
|
||||
for r, c, val in pending:
|
||||
ws.cell(row=r, column=c, value=val)
|
||||
written += 1
|
||||
wb.save(tmp)
|
||||
wb.close()
|
||||
except Exception:
|
||||
logger.exception("overlay 协同字段失败")
|
||||
return {"filepath": filepath, "file_b64": file_b64}
|
||||
|
||||
out_b64 = base64.b64encode(tmp.read_bytes()).decode("ascii")
|
||||
logger.info("overlay 协同字段 written=%s", written)
|
||||
return {"filepath": str(tmp), "file_b64": out_b64}
|
||||
|
||||
|
||||
def _match_collab_label(text: str) -> tuple[str, ...] | None:
|
||||
raw = (text or "").replace(":", ":").strip()
|
||||
for marker, keys in _COLLAB_LABEL_MARKERS:
|
||||
if marker in raw:
|
||||
return keys
|
||||
return None
|
||||
|
||||
|
||||
def _label_value_cell(ws, label_cell):
|
||||
"""标签右侧格子;合并标签则从合并区右边开始。"""
|
||||
row_idx = label_cell.row
|
||||
start_col = label_cell.column + 1
|
||||
merged = getattr(ws, "merged_cells", None)
|
||||
if merged is not None:
|
||||
for rng in merged.ranges:
|
||||
if rng.min_row <= row_idx <= rng.max_row and rng.min_col <= label_cell.column <= rng.max_col:
|
||||
start_col = rng.max_col + 1
|
||||
break
|
||||
return ws.cell(row=row_idx, column=start_col)
|
||||
@@ -0,0 +1,702 @@
|
||||
"""
|
||||
海运协同群业务:填表进群、补字段重发、产品报价、附件、解散、成交跟进。
|
||||
|
||||
本文件职责:群里副作用(主账+企微群)。私聊按钮仍走 sea_text_flow.on_button。
|
||||
禁止:改询价七项;终态默默忽略;把销售附件当报价。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.llm.extract_text import extract_collab_fields, extract_product_quote
|
||||
from agent.policy.quote_overlay import overlay_labeled_facts
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.schema.field_validate import SEA_COLLAB_DISPLAY, SEA_COLLAB_FIELDS
|
||||
|
||||
# 主账填表按英文码取格子;中文键必须同时带上,否则货好时间/条款写不进表。
|
||||
_COLLAB_FILL_ALIASES = {
|
||||
"贸易条款": ("贸易条款", "trade_terms"),
|
||||
"货好时间": ("货好时间", "cargo_ready_date"),
|
||||
"HS编码": ("HS编码", "商品海关编码", "海关编码", "hs_code"),
|
||||
"是否含油": ("是否含油", "has_oil"),
|
||||
"是否含电": ("是否含电", "has_battery"),
|
||||
"是否含磁": ("是否含磁", "has_magnet"),
|
||||
}
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TERMINAL = {"已成交", "未成交", "已关闭"}
|
||||
OPEN_EDIT = {"询价中", "已报价", "协商中"}
|
||||
EXCEL_PDF = {".xlsx", ".xls", ".xlsm", ".pdf"}
|
||||
|
||||
|
||||
def ticket_view(ticket: object) -> dict[str, Any]:
|
||||
"""主账工单或内存票统一成 dict。"""
|
||||
if ticket is None:
|
||||
return {}
|
||||
if isinstance(ticket, dict):
|
||||
view = dict(ticket)
|
||||
view.setdefault("work_order_no", view.get("workOrderNo") or "")
|
||||
view.setdefault("sales_wecom_id", view.get("salesWecomId") or view.get("sales_wecom_id") or "")
|
||||
view.setdefault("salesWecomId", view.get("sales_wecom_id") or view.get("salesWecomId") or "")
|
||||
view.setdefault(
|
||||
"collab_member_ids",
|
||||
view.get("memberIds") or view.get("collabMemberIds") or view.get("collab_member_ids") or [],
|
||||
)
|
||||
view.setdefault(
|
||||
"product_ids",
|
||||
view.get("productIds") or view.get("product_ids") or [],
|
||||
)
|
||||
view.setdefault(
|
||||
"product_names",
|
||||
view.get("productNames") or view.get("product_names") or [],
|
||||
)
|
||||
view.setdefault("sales_name", view.get("salesName") or view.get("sales_name") or "")
|
||||
facts = dict(view.get("collab_facts") or view.get("collabFacts") or {})
|
||||
view.setdefault("collab_facts", facts)
|
||||
flag = str(facts.get("__ended") or view.get("collab_ended") or view.get("collabEnded") or "").strip().lower()
|
||||
if flag in {"1", "true", "yes"}:
|
||||
view["collab_ended"] = True
|
||||
return view
|
||||
facts = dict(getattr(ticket, "collab_facts", {}) or {})
|
||||
ended = bool(getattr(ticket, "collab_ended", False)) or str(
|
||||
facts.get("__ended") or ""
|
||||
).strip().lower() in {"1", "true", "yes"}
|
||||
return {
|
||||
"work_order_no": getattr(ticket, "work_order_no", "") or "",
|
||||
"status": getattr(ticket, "status", "") or "",
|
||||
"sales_wecom_id": getattr(ticket, "sales_wecom_id", "") or "",
|
||||
"salesWecomId": getattr(ticket, "sales_wecom_id", "") or "",
|
||||
"business_line": getattr(ticket, "business_line", "") or "SEA",
|
||||
"facts": dict(getattr(ticket, "facts", {}) or {}),
|
||||
"quote": dict(getattr(ticket, "quote", {}) or {}),
|
||||
"collab_facts": dict(getattr(ticket, "collab_facts", {}) or {}),
|
||||
"collab_chat_id": getattr(ticket, "collab_chat_id", "") or "",
|
||||
"collab_member_ids": list(getattr(ticket, "collab_member_ids", []) or []),
|
||||
"collab_ended": ended,
|
||||
"product_ids": list(getattr(ticket, "product_ids", []) or []),
|
||||
"product_names": list(getattr(ticket, "product_names", []) or []),
|
||||
"sales_name": str(getattr(ticket, "sales_name", "") or ""),
|
||||
}
|
||||
|
||||
|
||||
def ticket_status(ticket: object) -> str:
|
||||
return str(ticket_view(ticket).get("status") or "").strip()
|
||||
|
||||
|
||||
def ticket_no(ticket: object) -> str:
|
||||
return str(ticket_view(ticket).get("work_order_no") or "").strip()
|
||||
|
||||
|
||||
def is_terminal(ticket: object) -> bool:
|
||||
return ticket_status(ticket) in TERMINAL
|
||||
|
||||
|
||||
def is_sales(ticket: object, sender_id: str) -> bool:
|
||||
uid = (sender_id or "").strip()
|
||||
view = ticket_view(ticket)
|
||||
sales = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "").strip()
|
||||
return bool(uid) and uid == sales
|
||||
|
||||
|
||||
def is_product(ticket: object, sender_id: str) -> bool:
|
||||
"""产品岗:成员里且不是销售。"""
|
||||
uid = (sender_id or "").strip()
|
||||
if not uid or is_sales(ticket, uid):
|
||||
return False
|
||||
view = ticket_view(ticket)
|
||||
members = {str(x) for x in (view.get("collab_member_ids") or view.get("memberIds") or [])}
|
||||
products = {str(x) for x in (view.get("product_ids") or [])}
|
||||
if products:
|
||||
return uid in products
|
||||
return uid in members
|
||||
|
||||
|
||||
def filename_is_excel_pdf(name: str) -> bool:
|
||||
raw = (name or "").strip().lower()
|
||||
return any(raw.endswith(ext) for ext in EXCEL_PDF)
|
||||
|
||||
|
||||
def group_client_of(flow: Any) -> Any:
|
||||
"""正式路径没有注入客户端时,走流程自己的默认企微客户端。"""
|
||||
getter = getattr(flow, "group_client", None)
|
||||
if callable(getter):
|
||||
return getter()
|
||||
return getattr(flow, "_group_client", None)
|
||||
|
||||
|
||||
def _archive_seat_ids(flow: Any) -> set[str]:
|
||||
"""会话存档账号(测服 AI001 / 询价机器人)不能出现在群 @ 里。"""
|
||||
seats: set[str] = {"AI001", "询价机器人"}
|
||||
getter = getattr(flow, "_archive_seat_userids", None)
|
||||
if callable(getter):
|
||||
try:
|
||||
seats.update(str(x).strip() for x in (getter() or []) if str(x).strip())
|
||||
except Exception:
|
||||
logger.warning("读会话存档账号失败,按默认 AI001 排除")
|
||||
return {x for x in seats if x}
|
||||
|
||||
|
||||
def _staff_display_name(flow: Any, wecom_id: str) -> str:
|
||||
"""按企微 userid 取后台员工姓名;查不到就退回 userid,仍是具体人。"""
|
||||
uid = (wecom_id or "").strip()
|
||||
if not uid:
|
||||
return ""
|
||||
book = getattr(flow, "ledger", None) or getattr(flow, "_ledger", None)
|
||||
finder = getattr(book, "get_staff_by_wecom_id", None) if book is not None else None
|
||||
if callable(finder):
|
||||
try:
|
||||
row = finder(wecom_id=uid) or {}
|
||||
name = str(row.get("name") or "").strip()
|
||||
if name:
|
||||
return name
|
||||
except Exception:
|
||||
logger.warning("查群成员姓名失败 uid=%s", uid)
|
||||
return uid
|
||||
|
||||
|
||||
def resolve_group_mentions(
|
||||
flow: Any,
|
||||
ticket: object,
|
||||
sess: Any = None,
|
||||
) -> tuple[str, list[str], list[str]]:
|
||||
"""
|
||||
群摘要必须 @ 到具体销售和每位产品,禁止只写岗位词「销售」。
|
||||
|
||||
进程重启后内存会话没了,主账 byCollabChat 也可能不带姓名:
|
||||
用销售 userid + 群成员(去掉存档账号)反查员工管理。
|
||||
返回:(销售展示名, 产品展示名列表, 要点到的 userid)。
|
||||
"""
|
||||
view = ticket_view(ticket)
|
||||
sales_id = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "").strip()
|
||||
if sess is not None:
|
||||
sales_id = sales_id or str(getattr(sess, "sales_wecom_id", "") or getattr(sess, "sender_id", "") or "").strip()
|
||||
members = [
|
||||
str(x).strip()
|
||||
for x in (view.get("collab_member_ids") or view.get("memberIds") or [])
|
||||
if str(x).strip()
|
||||
]
|
||||
if sess is not None:
|
||||
for uid in getattr(sess, "invited_userids", ()) or ():
|
||||
raw = str(uid).strip()
|
||||
if raw and raw not in members:
|
||||
members.append(raw)
|
||||
seats = _archive_seat_ids(flow)
|
||||
product_ids = [
|
||||
str(x).strip()
|
||||
for x in (view.get("product_ids") or view.get("productIds") or [])
|
||||
if str(x).strip() and str(x).strip() not in seats
|
||||
]
|
||||
if not product_ids:
|
||||
product_ids = [m for m in members if m and m != sales_id and m not in seats]
|
||||
|
||||
sales_name = ""
|
||||
if sess is not None:
|
||||
sales_name = str(getattr(sess, "invited_sales_name", "") or "").strip()
|
||||
sales_name = sales_name or str(view.get("sales_name") or view.get("salesName") or "").strip()
|
||||
if not sales_name or sales_name == "销售":
|
||||
looked = _staff_display_name(flow, sales_id) if sales_id else ""
|
||||
if looked:
|
||||
sales_name = looked
|
||||
if not sales_name:
|
||||
sales_name = sales_id or "销售"
|
||||
|
||||
products: list[str] = []
|
||||
if sess is not None:
|
||||
products = [
|
||||
str(n).strip()
|
||||
for n in (getattr(sess, "invited_product_names", None) or [])
|
||||
if str(n or "").strip()
|
||||
]
|
||||
if not products:
|
||||
products = [
|
||||
str(n).strip()
|
||||
for n in (view.get("product_names") or view.get("productNames") or [])
|
||||
if str(n or "").strip()
|
||||
]
|
||||
if not products:
|
||||
products = [name for name in (_staff_display_name(flow, pid) for pid in product_ids) if name]
|
||||
|
||||
mention_ids: list[str] = []
|
||||
if sales_id and sales_id not in seats:
|
||||
mention_ids.append(sales_id)
|
||||
for pid in product_ids:
|
||||
if pid and pid not in mention_ids and pid not in seats:
|
||||
mention_ids.append(pid)
|
||||
return sales_name, products, mention_ids
|
||||
|
||||
|
||||
def send_group_text(
|
||||
client: Any,
|
||||
chat_id: str,
|
||||
content: str,
|
||||
mention_userids: Optional[list[str]] = None,
|
||||
) -> None:
|
||||
"""群文本可带企微 userid,方便客户端真正点到人。旧客户端无此参数就只发正文。"""
|
||||
if not client or not chat_id or not content:
|
||||
return
|
||||
sender = getattr(client, "send_group", None)
|
||||
if not callable(sender):
|
||||
return
|
||||
mentions = [str(x).strip() for x in (mention_userids or []) if str(x).strip()]
|
||||
try:
|
||||
sender(chat_id=chat_id, content=content, mention_userids=mentions)
|
||||
except TypeError:
|
||||
sender(chat_id=chat_id, content=content)
|
||||
|
||||
|
||||
def hydrate_ticket(flow: Any, ticket: object) -> object:
|
||||
"""
|
||||
按群找回的工单若缺询价七项,用工单号+销售再读主账补全。
|
||||
|
||||
测服 byCollabChat 可能只回协同字段;续办 get 空 sender 也不回 facts。
|
||||
禁止用空 facts 把已确认港口冲成「-」。
|
||||
"""
|
||||
view = ticket_view(ticket)
|
||||
facts = dict(view.get("facts") or {})
|
||||
if any(str(facts.get(k) or "").strip() for k in ("起运港", "目的港", "品名", "货量")):
|
||||
return ticket
|
||||
no = ticket_no(ticket)
|
||||
sales = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "").strip()
|
||||
book = getattr(flow, "ledger", None)
|
||||
if book is None or not no:
|
||||
return ticket
|
||||
full: Any = None
|
||||
getter = getattr(book, "get_for_agent", None)
|
||||
if callable(getter) and sales:
|
||||
full = getter(work_order_no=no, sender_id=sales)
|
||||
if not isinstance(full, dict) or not dict(full.get("facts") or {}):
|
||||
getter = getattr(book, "get_ticket", None)
|
||||
if callable(getter):
|
||||
full = getter(work_order_no=no)
|
||||
if full is None:
|
||||
return ticket
|
||||
merged = dict(view)
|
||||
full_view = ticket_view(full)
|
||||
if full_view.get("facts"):
|
||||
merged["facts"] = dict(full_view.get("facts") or {})
|
||||
if full_view.get("quote") and not merged.get("quote"):
|
||||
merged["quote"] = dict(full_view.get("quote") or {})
|
||||
if full_view.get("status"):
|
||||
merged.setdefault("status", full_view.get("status"))
|
||||
collab = dict(full_view.get("collab_facts") or {})
|
||||
collab.update(dict(view.get("collab_facts") or {}))
|
||||
merged["collab_facts"] = collab
|
||||
if view.get("collab_chat_id"):
|
||||
merged["collab_chat_id"] = view.get("collab_chat_id")
|
||||
return merged
|
||||
|
||||
|
||||
def send_brief_and_sheet(
|
||||
*,
|
||||
flow: Any,
|
||||
ticket: object,
|
||||
sess: Any = None,
|
||||
quote_override: Optional[dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
群里再发一版摘要 + 填好的海运模板。旧消息不撤。
|
||||
|
||||
无价也发表,询价和协同能填的先填。
|
||||
"""
|
||||
ticket = hydrate_ticket(flow, ticket)
|
||||
view = ticket_view(ticket)
|
||||
no = ticket_no(ticket)
|
||||
chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "").strip()
|
||||
client = group_client_of(flow)
|
||||
facts = dict(view.get("facts") or {})
|
||||
if sess is not None and getattr(sess, "facts", None):
|
||||
facts.update(dict(sess.facts or {}))
|
||||
collab = dict(view.get("collab_facts") or {})
|
||||
if sess is not None and getattr(sess, "collab_facts", None):
|
||||
collab.update(dict(sess.collab_facts or {}))
|
||||
quote = dict(quote_override or (sess.quote if sess else None) or view.get("quote") or {})
|
||||
sales_name, products, mention_ids = resolve_group_mentions(flow, ticket, sess)
|
||||
has_price = bool(quote.get("total") or quote.get("fee_rows"))
|
||||
brief = copy.sea_group_brief(
|
||||
work_order_no=no,
|
||||
facts=facts,
|
||||
quote=quote,
|
||||
collab_facts=collab,
|
||||
sales_name=sales_name,
|
||||
product_names=products,
|
||||
has_price=has_price,
|
||||
)
|
||||
send_group_text(client, chat_id, brief, mention_userids=mention_ids)
|
||||
send_filled_sheet(flow=flow, ticket=ticket, sess=sess, quote_override=quote_override)
|
||||
return "brief_and_sheet"
|
||||
|
||||
|
||||
def send_filled_sheet(
|
||||
*,
|
||||
flow: Any,
|
||||
ticket: object,
|
||||
sess: Any = None,
|
||||
quote_override: Optional[dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""只发填好的模板文件,不再发文字摘要。"""
|
||||
ticket = hydrate_ticket(flow, ticket)
|
||||
view = ticket_view(ticket) if ticket is not None else {}
|
||||
no = (sess.work_order_no if sess else "") or ticket_no(ticket)
|
||||
chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "").strip()
|
||||
facts = dict(view.get("facts") or {})
|
||||
if sess is not None and getattr(sess, "facts", None):
|
||||
facts.update(dict(sess.facts or {}))
|
||||
collab = dict(view.get("collab_facts") or {})
|
||||
if sess is not None and getattr(sess, "collab_facts", None):
|
||||
collab.update(dict(sess.collab_facts or {}))
|
||||
quote = dict(quote_override or (sess.quote if sess else None) or view.get("quote") or {})
|
||||
_render_and_send_sheet(
|
||||
flow=flow,
|
||||
chat_id=chat_id,
|
||||
work_order_no=no,
|
||||
facts=_facts_for_fill(facts, collab),
|
||||
quote=quote,
|
||||
sales_id=str(
|
||||
(sess.sender_id if sess else "")
|
||||
or view.get("sales_wecom_id")
|
||||
or view.get("salesWecomId")
|
||||
or ""
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _collab_value(collab: dict[str, str], key: str) -> str:
|
||||
"""协同六项:内部键、对外展示名都能取到。"""
|
||||
src = dict(collab or {})
|
||||
val = str(src.get(key) or "").strip()
|
||||
if val:
|
||||
return val
|
||||
label = SEA_COLLAB_DISPLAY.get(key, "")
|
||||
if label:
|
||||
val = str(src.get(label) or "").strip()
|
||||
return val
|
||||
|
||||
|
||||
def _oil_elec_mag_text(collab: dict[str, str]) -> str:
|
||||
"""
|
||||
海运模板是一格「是否含油,电,磁」,三句话拼成一句再写入。
|
||||
没说的写成 -,不编造。
|
||||
"""
|
||||
oil = _collab_value(collab, "是否含油")
|
||||
elec = _collab_value(collab, "是否含电")
|
||||
mag = _collab_value(collab, "是否含磁")
|
||||
if not (oil or elec or mag):
|
||||
return ""
|
||||
return f"油:{oil or '-'};电:{elec or '-'};磁:{mag or '-'}"
|
||||
|
||||
|
||||
def _facts_for_fill(facts: dict[str, str], collab: dict[str, str]) -> dict[str, str]:
|
||||
"""
|
||||
询价七项 + 协同六项。中文键和填表英文码一起带上。
|
||||
主账 writeFactsByLabel 只认 cargo_ready_date / trade_terms,只传「货好时间」会空着。
|
||||
"""
|
||||
merged = dict(facts or {})
|
||||
src = dict(collab or {})
|
||||
for key in SEA_COLLAB_FIELDS:
|
||||
val = _collab_value(src, key)
|
||||
if not val:
|
||||
continue
|
||||
for alias in _COLLAB_FILL_ALIASES.get(key, (key,)):
|
||||
merged[alias] = val
|
||||
oem = _oil_elec_mag_text(src)
|
||||
if oem:
|
||||
merged["oil_elec_mag"] = oem
|
||||
merged["是否含油,电,磁"] = oem
|
||||
return merged
|
||||
|
||||
|
||||
def _render_and_send_sheet(
|
||||
*,
|
||||
flow: Any,
|
||||
chat_id: str,
|
||||
work_order_no: str,
|
||||
facts: dict[str, Any],
|
||||
quote: dict[str, Any],
|
||||
sales_id: str,
|
||||
) -> None:
|
||||
renderer = getattr(flow.ledger, "render_quote", None)
|
||||
client = group_client_of(flow)
|
||||
if not callable(renderer) or not chat_id:
|
||||
return
|
||||
result = renderer(
|
||||
work_order_no=work_order_no,
|
||||
business_line="SEA",
|
||||
facts=facts,
|
||||
quote=quote,
|
||||
format="xlsx",
|
||||
sales_wecom_id=sales_id,
|
||||
) or {}
|
||||
if not result.get("ok"):
|
||||
logger.warning("群填表失败 wo=%s err=%s", work_order_no, result.get("error"))
|
||||
return
|
||||
filename = str(result.get("fileName") or f"{work_order_no}_quote.xlsx")
|
||||
sender = getattr(client, "send_group_file", None)
|
||||
if not callable(sender):
|
||||
logger.warning("群发文件无客户端 wo=%s chat=%s", work_order_no, chat_id)
|
||||
return
|
||||
painted = overlay_labeled_facts(
|
||||
filepath=str(result.get("filePath") or ""),
|
||||
file_b64=str(result.get("fileBase64") or ""),
|
||||
facts=facts,
|
||||
)
|
||||
sent = sender(
|
||||
chat_id=chat_id,
|
||||
filename=filename,
|
||||
filepath=str(painted.get("filepath") or ""),
|
||||
file_b64=str(painted.get("file_b64") or ""),
|
||||
) or {}
|
||||
if sent.get("ok") is False:
|
||||
logger.warning("群发文件失败 wo=%s err=%s", work_order_no, sent.get("error"))
|
||||
send_group_text(client, chat_id, copy.group_sheet_send_fail())
|
||||
return
|
||||
logger.info("群已发模板 wo=%s file=%s", work_order_no, filename)
|
||||
|
||||
|
||||
def apply_collab_update(
|
||||
*,
|
||||
flow: Any,
|
||||
ticket: object,
|
||||
text: str,
|
||||
sender_id: str,
|
||||
sess: Any = None,
|
||||
) -> str:
|
||||
"""补协同六项。终态要说明原因。"""
|
||||
no = ticket_no(ticket)
|
||||
chat_id = str((sess.collab_chat_id if sess else "") or ticket_view(ticket).get("collab_chat_id") or "")
|
||||
client = group_client_of(flow)
|
||||
if is_terminal(ticket):
|
||||
send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=ticket_status(ticket)))
|
||||
return "ticket_locked"
|
||||
fields = extract_collab_fields(text, allow_b=True)
|
||||
if not fields:
|
||||
send_group_text(client, chat_id, copy.group_fields_not_recognized())
|
||||
return "collab_not_recognized"
|
||||
flow.apply_collab_facts(work_order_no=no, facts=fields, sender_id=sender_id)
|
||||
if sess is not None:
|
||||
merged = dict(sess.collab_facts)
|
||||
merged.update(fields)
|
||||
sess.collab_facts = merged
|
||||
flow._save(sess)
|
||||
ticket2 = _reload(flow, chat_id, no) or ticket
|
||||
view = ticket_view(ticket2)
|
||||
collab = dict(view.get("collab_facts") or {})
|
||||
collab.update(fields)
|
||||
view["collab_facts"] = collab
|
||||
if chat_id and not view.get("collab_chat_id"):
|
||||
view["collab_chat_id"] = chat_id
|
||||
send_brief_and_sheet(flow=flow, ticket=view, sess=sess)
|
||||
return "collab_field_saved"
|
||||
|
||||
|
||||
def apply_product_quote(
|
||||
*,
|
||||
flow: Any,
|
||||
ticket: object,
|
||||
text: str,
|
||||
sender_id: str,
|
||||
sess: Any = None,
|
||||
injected_quote: Optional[dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
产品文字报价:盖 TMS 价、填表发出、群里成交跟进。
|
||||
|
||||
一句话里的协同字段一并写入。非产品或终态要回话。
|
||||
"""
|
||||
no = ticket_no(ticket)
|
||||
view = ticket_view(ticket)
|
||||
chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "")
|
||||
client = group_client_of(flow)
|
||||
if is_terminal(ticket):
|
||||
send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=ticket_status(ticket)))
|
||||
return "ticket_locked"
|
||||
if not is_product(ticket, sender_id):
|
||||
return "group_ignore"
|
||||
fields = extract_collab_fields(text)
|
||||
if fields:
|
||||
flow.apply_collab_facts(work_order_no=no, facts=fields, sender_id=sender_id)
|
||||
if sess is not None:
|
||||
merged = dict(sess.collab_facts)
|
||||
merged.update(fields)
|
||||
sess.collab_facts = merged
|
||||
quote = extract_product_quote(text, injected=injected_quote)
|
||||
if not quote:
|
||||
send_group_text(client, chat_id, copy.group_quote_not_recognized())
|
||||
return "quote_not_recognized"
|
||||
_save_product_quote(flow, no, quote, sess)
|
||||
ticket2 = _reload(flow, chat_id, no) or ticket
|
||||
send_brief_and_sheet(flow=flow, ticket=ticket2, sess=sess, quote_override=quote)
|
||||
emit_group_deal(flow=flow, ticket=ticket2, sess=sess, quote=quote)
|
||||
return "product_quote_sent"
|
||||
|
||||
|
||||
def handle_group_file(
|
||||
*,
|
||||
flow: Any,
|
||||
ticket: object,
|
||||
sender_id: str,
|
||||
filename: str,
|
||||
media: dict[str, Any],
|
||||
sess: Any = None,
|
||||
) -> str:
|
||||
"""
|
||||
群附件:只收 Excel/PDF。终态不存要提醒。
|
||||
产品 Excel/PDF 当报价并出成交跟进;别人的只存档。
|
||||
"""
|
||||
no = ticket_no(ticket)
|
||||
view = ticket_view(ticket)
|
||||
chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "")
|
||||
client = group_client_of(flow)
|
||||
if is_terminal(ticket):
|
||||
send_group_text(client, chat_id, copy.ticket_locked_file(work_order_no=no, status=ticket_status(ticket)))
|
||||
return "ticket_locked_file"
|
||||
if not filename_is_excel_pdf(filename):
|
||||
send_group_text(client, chat_id, copy.group_only_excel_pdf())
|
||||
return "file_type_rejected"
|
||||
_archive_file(flow, no, filename, media, sender_id)
|
||||
if is_product(ticket, sender_id):
|
||||
quote = {
|
||||
"source_label": "产品附件报价",
|
||||
"file_name": filename,
|
||||
"total": str((view.get("quote") or {}).get("total") or "-"),
|
||||
}
|
||||
_save_product_quote(flow, no, quote, sess)
|
||||
emit_group_deal(flow=flow, ticket=ticket, sess=sess, quote=quote)
|
||||
return "product_file_quote"
|
||||
return "file_archived"
|
||||
|
||||
|
||||
def dissolve_group(*, flow: Any, ticket: object, sess: Any = None) -> str:
|
||||
"""解散应用群,这单协同结束,不改六态。"""
|
||||
view = ticket_view(ticket)
|
||||
no = ticket_no(ticket)
|
||||
chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "")
|
||||
client = group_client_of(flow)
|
||||
members = list(view.get("collab_member_ids") or view.get("memberIds") or [])
|
||||
if sess is not None:
|
||||
members = list(sess.invited_userids or members)
|
||||
dismiss = getattr(client, "dismiss_group", None)
|
||||
if callable(dismiss) and chat_id:
|
||||
dismiss(chat_id=chat_id, userids=members)
|
||||
if chat_id:
|
||||
try:
|
||||
from agent.channel.wshoto.rooms import unwatch_collab_room
|
||||
|
||||
unwatch_collab_room(chat_id)
|
||||
except Exception:
|
||||
logger.exception("取消存档监听失败 chat=%s", chat_id)
|
||||
ender = getattr(flow.ledger, "end_collab_group", None)
|
||||
if callable(ender):
|
||||
ender(work_order_no=no)
|
||||
if sess is not None:
|
||||
sess.collab_ended = True
|
||||
flow._save(sess)
|
||||
send_group_text(client, chat_id, copy.group_dissolved(work_order_no=no))
|
||||
return "group_dissolved"
|
||||
|
||||
|
||||
def emit_group_deal(
|
||||
*,
|
||||
flow: Any,
|
||||
ticket: object,
|
||||
sess: Any = None,
|
||||
quote: Optional[dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""群里只发一张成交跟进。版本 +1,旧卡点了要提示。"""
|
||||
view = ticket_view(ticket)
|
||||
no = ticket_no(ticket)
|
||||
chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "")
|
||||
client = group_client_of(flow)
|
||||
q = dict(quote or (sess.quote if sess else None) or view.get("quote") or {})
|
||||
facts = dict((sess.facts if sess else None) or view.get("facts") or {})
|
||||
ver = 1
|
||||
if sess is not None:
|
||||
sess.deal_version = int(getattr(sess, "deal_version", 0) or 0) + 1
|
||||
ver = sess.deal_version
|
||||
sess.phase = "wait_deal"
|
||||
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
|
||||
sess.status = "已报价"
|
||||
sess.quote = q
|
||||
sess.wait_version += 1
|
||||
flow._save(sess)
|
||||
sales_name, _products, mention_ids = resolve_group_mentions(flow, ticket, sess)
|
||||
text = f"@{sales_name}\n" + copy.deal_card(work_order_no=no, quote=q, facts=facts)
|
||||
# 成交跟进只点销售:产品不能点这张卡。
|
||||
sales_mention = mention_ids[:1] if mention_ids else []
|
||||
send_group_text(client, chat_id, text, mention_userids=sales_mention)
|
||||
carder = getattr(client, "send_group_card", None)
|
||||
payload = copy.deal_wecom_payload(work_order_no=no, quote=q, facts=facts, card_version=ver)
|
||||
if callable(carder):
|
||||
carder(chat_id=chat_id, template_card=payload.get("template_card") or {})
|
||||
|
||||
|
||||
def explain_deal_click(
|
||||
*,
|
||||
flow: Any,
|
||||
sender_id: str,
|
||||
action: str,
|
||||
card_meta: Optional[dict[str, Any]],
|
||||
sess: Any,
|
||||
) -> str:
|
||||
"""
|
||||
成交卡点错人 / 旧版 / 终态:群里说清楚,不当没反应。
|
||||
|
||||
返回空表示可以继续走成交。
|
||||
"""
|
||||
if action not in {copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE}:
|
||||
return ""
|
||||
if sess is None:
|
||||
return ""
|
||||
chat_id = (sess.collab_chat_id or "").strip()
|
||||
client = group_client_of(flow)
|
||||
no = sess.work_order_no
|
||||
if sess.status in TERMINAL:
|
||||
send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=sess.status))
|
||||
return "ticket_locked"
|
||||
if sender_id != sess.sender_id:
|
||||
send_group_text(client, chat_id, copy.group_deal_sales_only())
|
||||
return "not_sales"
|
||||
clicked = copy.deal_card_version(card_meta)
|
||||
current = int(getattr(sess, "deal_version", 0) or 0)
|
||||
if current and clicked and clicked != current:
|
||||
send_group_text(client, chat_id, copy.group_deal_stale(work_order_no=no))
|
||||
return "stale_deal"
|
||||
return ""
|
||||
|
||||
|
||||
def _save_product_quote(flow: Any, no: str, quote: dict[str, Any], sess: Any) -> None:
|
||||
writer = getattr(flow.ledger, "upsert_product_quote", None) or getattr(
|
||||
flow.ledger, "upsert_quote", None
|
||||
)
|
||||
if callable(writer):
|
||||
writer(work_order_no=no, quote=quote, to_status="已报价")
|
||||
if sess is not None:
|
||||
sess.quote = dict(quote)
|
||||
sess.status = "已报价"
|
||||
flow._save(sess)
|
||||
|
||||
|
||||
def _archive_file(flow: Any, no: str, filename: str, media: dict[str, Any], sender_id: str) -> None:
|
||||
saver = getattr(flow.ledger, "attach_file", None)
|
||||
if callable(saver):
|
||||
saver(
|
||||
work_order_no=no,
|
||||
file_name=filename,
|
||||
object_key=str(media.get("media_id") or media.get("MediaId") or filename),
|
||||
content_type=str(media.get("content_type") or ""),
|
||||
sender_id=sender_id,
|
||||
)
|
||||
|
||||
|
||||
def _reload(flow: Any, chat_id: str, no: str) -> object:
|
||||
finder = getattr(flow.ledger, "find_by_collab_chat", None)
|
||||
if callable(finder) and chat_id:
|
||||
hit = finder(chat_id=chat_id)
|
||||
if hit is not None:
|
||||
return hit
|
||||
getter = getattr(flow.ledger, "get_ticket", None)
|
||||
if callable(getter) and no:
|
||||
return getter(work_order_no=no)
|
||||
return None
|
||||
@@ -44,6 +44,16 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
super().__init__(ledger=ledger)
|
||||
self._group_client = group_client
|
||||
|
||||
def group_client(self) -> Any:
|
||||
"""
|
||||
企微应用群客户端。测服正式不注入,现场再取默认 WeCom 客户端。
|
||||
|
||||
单测可注入 FakeGroupClient。取到后挂在流程上,后续补字段/发文件共用。
|
||||
"""
|
||||
if self._group_client is None:
|
||||
self._group_client = _default_group_client()
|
||||
return self._group_client
|
||||
|
||||
def on_text(
|
||||
self,
|
||||
*,
|
||||
@@ -374,6 +384,23 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
sess = self.session_for_action(sender_id, card_meta)
|
||||
if sess and action == copy.BTN_PULL_COLLAB and action in sess.allowed:
|
||||
return self._pull_collab_group(sess, reply, card_meta=card_meta)
|
||||
if action in {copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE}:
|
||||
from agent.policy.sea_group_ops import explain_deal_click
|
||||
|
||||
ticket_sess = sess
|
||||
if ticket_sess is None:
|
||||
wo = copy.work_order_from_card_meta(card_meta)
|
||||
ticket_sess = self.session_by_work_order(wo)
|
||||
if ticket_sess and (ticket_sess.collab_chat_id or "").strip():
|
||||
reason = explain_deal_click(
|
||||
flow=self,
|
||||
sender_id=sender_id,
|
||||
action=action,
|
||||
card_meta=card_meta,
|
||||
sess=ticket_sess,
|
||||
)
|
||||
if reason:
|
||||
return reason
|
||||
return super().on_button(
|
||||
sender_id=sender_id, action=action, reply=reply, card_meta=card_meta
|
||||
)
|
||||
@@ -390,10 +417,22 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
没人可拉 / 建群失败:说明原因,按钮保持可点。已有群:不新建,置灰。
|
||||
"""
|
||||
no = (sess.work_order_no or "").strip()
|
||||
existing = (sess.collab_chat_id or "").strip()
|
||||
if not existing:
|
||||
if sess.collab_ended:
|
||||
reply(copy.group_cannot_recreate(work_order_no=no))
|
||||
return "collab_ended"
|
||||
getter = getattr(self._ledger, "get_ticket", None)
|
||||
ticket = getter(work_order_no=no) if callable(getter) else None
|
||||
if ticket is not None:
|
||||
facts = dict(getattr(ticket, "collab_facts", {}) or {})
|
||||
if bool(getattr(ticket, "collab_ended", False)) or str(
|
||||
facts.get("__ended") or ""
|
||||
).strip() in {"1", "true"}:
|
||||
sess.collab_ended = True
|
||||
self._save(sess)
|
||||
reply(copy.group_cannot_recreate(work_order_no=no))
|
||||
return "collab_ended"
|
||||
existing = (sess.collab_chat_id or "").strip()
|
||||
if not existing and ticket is not None:
|
||||
existing = str(getattr(ticket, "collab_chat_id", "") or "")
|
||||
if existing:
|
||||
sess.collab_chat_id = existing
|
||||
@@ -402,6 +441,12 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
self._save(sess)
|
||||
self._grey_clicked_card(reply, card_meta)
|
||||
self._ensure_archive_seats_in_group(existing)
|
||||
try:
|
||||
from agent.channel.wshoto.rooms import watch_collab_room
|
||||
|
||||
watch_collab_room(existing, work_order_no=no)
|
||||
except Exception:
|
||||
logger.exception("登记已有存档监听群失败 chat=%s", existing)
|
||||
reply(copy.sea_group_exists(work_order_no=no))
|
||||
return "collab_group"
|
||||
|
||||
@@ -429,13 +474,19 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
if uid not in userids:
|
||||
userids.append(uid)
|
||||
|
||||
client = self._group_client or _default_group_client()
|
||||
client = self.group_client()
|
||||
created = client.create_group(name=no, userids=userids) or {}
|
||||
if not created.get("ok") or not created.get("chat_id"):
|
||||
reply(copy.sea_group_create_fail(reason=str(created.get("error") or "")))
|
||||
return sess.phase or "wait_collab"
|
||||
|
||||
chat_id = str(created.get("chat_id") or "").strip()
|
||||
try:
|
||||
from agent.channel.wshoto.rooms import watch_collab_room
|
||||
|
||||
watch_collab_room(chat_id, work_order_no=no)
|
||||
except Exception:
|
||||
logger.exception("登记存档监听群失败 chat=%s", chat_id)
|
||||
sales_name = self._sales_display_name(sess.sender_id)
|
||||
binder = getattr(self._ledger, "bind_collab_group", None)
|
||||
if callable(binder):
|
||||
@@ -444,6 +495,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
chat_id=chat_id,
|
||||
member_ids=userids,
|
||||
product_names=product_names,
|
||||
product_ids=product_ids,
|
||||
)
|
||||
|
||||
brief = copy.sea_group_brief(
|
||||
@@ -456,8 +508,10 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
has_price=bool(sess.quote and sess.status == "已报价"),
|
||||
)
|
||||
client.send_group(chat_id=chat_id, content=brief)
|
||||
|
||||
sess.collab_chat_id = chat_id
|
||||
from agent.policy.sea_group_ops import send_filled_sheet
|
||||
|
||||
send_filled_sheet(flow=self, ticket=None, sess=sess)
|
||||
sess.invited_userids = tuple(userids)
|
||||
sess.invited_sales_name = sales_name
|
||||
sess.invited_product_names = tuple(product_names)
|
||||
@@ -525,7 +579,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
seats = self._archive_seat_userids()
|
||||
if not chat_id or not seats:
|
||||
return
|
||||
client = self._group_client or _default_group_client()
|
||||
client = self.group_client()
|
||||
adder = getattr(client, "add_group_members", None)
|
||||
if not callable(adder):
|
||||
return
|
||||
|
||||
@@ -44,7 +44,12 @@ STREAM_WAKE = "stream:wake"
|
||||
IDEMPOTENCY = "idem"
|
||||
RATE_LIMIT = "ratelimit"
|
||||
TOKEN_WECOM = "token:wecom"
|
||||
TOKEN_WSHOTO = "token:wshoto"
|
||||
TOKEN_COPY_TICKET = "token:copy_ticket"
|
||||
TOKEN_JSAPI = "token:jsapi"
|
||||
# 微盛查询游标(本前缀下的 last_seq,不是现网官方 SDK 消费游标)
|
||||
WSHOTO_LAST_SEQ = "wshoto:last_seq"
|
||||
WSHOTO_ROOMS = "wshoto:rooms"
|
||||
WSHOTO_ROOM_TICKET = "wshoto:room_ticket"
|
||||
# Worker 出完报价文件后写给 HTTP:短协调,不当六态
|
||||
QUOTE_FILE_READY = "quote_file_ready"
|
||||
|
||||
@@ -11,7 +11,7 @@ import logging
|
||||
from typing import Optional
|
||||
|
||||
from agent.redis_coord.client import RedisClient
|
||||
from agent.redis_coord.keys import TOKEN_WECOM
|
||||
from agent.redis_coord.keys import TOKEN_WECOM, TOKEN_WSHOTO
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -40,3 +40,25 @@ def set_wecom_token(
|
||||
key = client.key(TOKEN_WECOM, agent_id)
|
||||
client.raw.set(key, token, ex=ttl_seconds)
|
||||
logger.info("企微 token 已缓存 agent_id=%s ttl=%s", agent_id, ttl_seconds)
|
||||
|
||||
|
||||
def get_wshoto_token(client: RedisClient, *, app_id: str) -> Optional[str]:
|
||||
"""读取微盛 access_token 缓存;未命中返回 None。不把 token 当业务真相。"""
|
||||
key = client.key(TOKEN_WSHOTO, app_id or "default")
|
||||
val = client.raw.get(key)
|
||||
if val is None:
|
||||
return None
|
||||
return val if isinstance(val, str) else val.decode("utf-8")
|
||||
|
||||
|
||||
def set_wshoto_token(
|
||||
client: RedisClient,
|
||||
*,
|
||||
app_id: str,
|
||||
token: str,
|
||||
ttl_seconds: int = 7000,
|
||||
) -> None:
|
||||
"""写入微盛 token;TTL 略短于接口 expires_in,避免用过期票去拉群。"""
|
||||
key = client.key(TOKEN_WSHOTO, app_id or "default")
|
||||
client.raw.set(key, token, ex=max(60, int(ttl_seconds)))
|
||||
logger.info("微盛 token 已缓存 ttl=%s", ttl_seconds)
|
||||
|
||||
@@ -68,12 +68,17 @@ def _wrap_card(message: InboundMessage) -> None:
|
||||
from agent.policy import inquiry_copy as copy
|
||||
|
||||
action = copy.canonical_button(message.content or "") or (message.content or "").strip()
|
||||
payload = dict(message.raw or {})
|
||||
payload.setdefault("event_key", message.content or "")
|
||||
if message.chat_id:
|
||||
payload["chat_id"] = message.chat_id
|
||||
payload["chat_type"] = message.chat_type
|
||||
card_action.handle_card_action(
|
||||
sender_id=message.sender_id,
|
||||
thread_id="",
|
||||
wait_version=0,
|
||||
action=action or "unknown",
|
||||
payload=dict(message.raw or {}),
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
@@ -136,6 +141,17 @@ def dispatch_inbound(message: InboundMessage, decision: RouteDecision) -> str:
|
||||
副作用:仅 handler 内产生;本函数不做业务。
|
||||
"""
|
||||
if (message.chat_type or "") == "group":
|
||||
from agent.policy import inquiry_copy as copy
|
||||
|
||||
event = str((message.raw or {}).get("event") or "").lower()
|
||||
action = copy.canonical_button(message.content or "")
|
||||
if event in {"template_card_event", "click", "templatecardevent"} or action in {
|
||||
copy.BTN_DEAL,
|
||||
copy.BTN_LOST,
|
||||
copy.BTN_NEGOTIATE,
|
||||
}:
|
||||
_wrap_card(message)
|
||||
return "card_action"
|
||||
phase = handle_group_collab(message)
|
||||
logger.info("dispatch group_collab phase=%s chat=%s", phase, message.chat_id)
|
||||
return "group_collab"
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
海运协同群文字意图:DeepSeek A 判断补字段 / 产品报价 / 解散 / 其它。
|
||||
|
||||
本文件职责:只分类,不写主账、不发企微。
|
||||
禁止:把「解散群」写成唯一口令;私聊按钮不走这里。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from agent.llm.extract_text import extract_collab_fields
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
INTENT_FIELDS = "collab_fields"
|
||||
INTENT_QUOTE = "product_quote"
|
||||
INTENT_DISSOLVE = "dissolve"
|
||||
INTENT_OTHER = "other"
|
||||
|
||||
_VALID = {INTENT_FIELDS, INTENT_QUOTE, INTENT_DISSOLVE, INTENT_OTHER}
|
||||
|
||||
|
||||
def classify_group_text(
|
||||
text: str,
|
||||
*,
|
||||
injected: str = "",
|
||||
allow_network: Optional[bool] = None,
|
||||
) -> str:
|
||||
"""
|
||||
群里一句文字的意图。单测可注入;正式走模型。
|
||||
|
||||
模型不可用时:只认带标签的协同字段(键:值是合同,不是口令),其余当其它。
|
||||
"""
|
||||
hint = (injected or "").strip()
|
||||
if hint in _VALID:
|
||||
return hint
|
||||
raw = (text or "").strip()
|
||||
if not raw:
|
||||
return INTENT_OTHER
|
||||
try:
|
||||
from agent.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
use_net = (
|
||||
allow_network
|
||||
if allow_network is not None
|
||||
else bool(getattr(settings, "llm_allow_network", False))
|
||||
)
|
||||
if use_net and getattr(settings, "llm_data_usage_confirmed", False):
|
||||
got = _classify_with_model(raw, settings)
|
||||
if got in _VALID:
|
||||
return got
|
||||
except Exception:
|
||||
logger.exception("群意图模型失败,回退标签字段")
|
||||
if extract_collab_fields(raw):
|
||||
return INTENT_FIELDS
|
||||
from agent.llm.extract_text import extract_product_quote
|
||||
|
||||
if extract_product_quote(raw):
|
||||
return INTENT_QUOTE
|
||||
return INTENT_OTHER
|
||||
|
||||
|
||||
def _classify_with_model(text: str, settings) -> str:
|
||||
"""DeepSeek 只回一个意图词。失败空串。"""
|
||||
from agent.llm.http_client import LlmHttpClient
|
||||
from agent.llm.modes import LlmMode
|
||||
|
||||
client = LlmHttpClient.from_settings(settings)
|
||||
system = (
|
||||
"你是海运协同群意图分类。只回复一个英文词,不要解释。"
|
||||
"collab_fields=在补贸易条款/货好时间/商品海关编码/含油含电含磁;"
|
||||
"product_quote=产品在报价格或费用;"
|
||||
"dissolve=要把这个协同群解散;"
|
||||
"other=闲聊、问候、对询价港口的随口更正(不当改单)。"
|
||||
"一句话里既补字段又报价,回 product_quote。"
|
||||
)
|
||||
data = client.chat(
|
||||
mode=LlmMode.ORDERED_ACTIONS,
|
||||
messages=[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": text[:2000]},
|
||||
],
|
||||
)
|
||||
content = str(getattr(data, "content", "") or "").strip().lower()
|
||||
for token in (INTENT_QUOTE, INTENT_DISSOLVE, INTENT_FIELDS, INTENT_OTHER):
|
||||
if token in content:
|
||||
return token
|
||||
return ""
|
||||
@@ -44,8 +44,12 @@ SEA_REQUIRED_FIELDS = (
|
||||
)
|
||||
SEA_DISPLAY = {key: label for key, label in SEA_REQUIRED_FIELDS}
|
||||
|
||||
# 协同补充字段:拉群后可静默写入,不挡首次查价。
|
||||
# 协同补充字段:拉群后可静默写入,不挡首次查价。内部键仍是 HS编码。
|
||||
SEA_COLLAB_FIELDS = ("贸易条款", "货好时间", "HS编码", "是否含油", "是否含电", "是否含磁")
|
||||
# 群摘要对外展示名。内部继续用 HS编码,避免改合同键。
|
||||
SEA_COLLAB_DISPLAY = {
|
||||
"HS编码": "商品海关编码",
|
||||
}
|
||||
|
||||
# 口语里已经带上 TMS 文档规定的单位时,补进空字段。
|
||||
# 只认「数字+单位」,不猜港口,不把长宽高换算成立方。
|
||||
@@ -80,6 +84,8 @@ _ALIASES = {
|
||||
"车型/数量": "车型数量",
|
||||
"HS": "HS编码",
|
||||
"HS CODE": "HS编码",
|
||||
"商品海关编码": "HS编码",
|
||||
"海关编码": "HS编码",
|
||||
}
|
||||
|
||||
|
||||
@@ -178,6 +184,32 @@ def harvest_oral_measures(text: str, facts: dict[str, str] | None = None) -> dic
|
||||
return harvest_named_air_route(raw, merged)
|
||||
|
||||
|
||||
def harvest_oral_collab(text: str) -> dict[str, str]:
|
||||
"""
|
||||
群里口语协同三项:含油/含电/含磁、货好时间。
|
||||
|
||||
只认原话里已经说死的词(不含电、随时可提),不猜贸易条款和海关编码。
|
||||
"""
|
||||
raw = text or ""
|
||||
out: dict[str, str] = {}
|
||||
yes_no = (
|
||||
("是否含电", ("不含电", "不带电", "无电"), ("含电", "带电")),
|
||||
("是否含油", ("不含油", "不带油", "无油"), ("含油", "带油")),
|
||||
("是否含磁", ("不含磁", "不带磁", "无磁"), ("含磁", "带磁")),
|
||||
)
|
||||
for key, no_words, yes_words in yes_no:
|
||||
if any(w in raw for w in no_words):
|
||||
out[key] = "否"
|
||||
elif any(w in raw for w in yes_words):
|
||||
out[key] = "是"
|
||||
ready = ("随时可提", "货好随时", "随时货好", "货已好", "已经货好")
|
||||
for word in ready:
|
||||
if word in raw:
|
||||
out["货好时间"] = word
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def project_sea_cargo_qty(facts: dict[str, str] | None) -> dict[str, str]:
|
||||
"""
|
||||
海运货物数量投影:原话「15件」常被抽成空运「件数」。
|
||||
|
||||
@@ -296,29 +296,22 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
self.assertEqual(phase, "wait_deal")
|
||||
self.assertIn("Excel", "\n".join(self.replies))
|
||||
self.assertIn("咨询成交状态", "\n".join(self.replies))
|
||||
deal_cards = [
|
||||
x["template_card"]
|
||||
for x in self.extras
|
||||
if x.get("msgtype") == "template_card"
|
||||
and str((x.get("template_card") or {}).get("task_id") or "").startswith("deal-")
|
||||
]
|
||||
self.assertTrue(deal_cards)
|
||||
deal_rows = {r["keyname"]: r["value"] for r in deal_cards[-1]["horizontal_content_list"]}
|
||||
file_extras = [x for x in self.extras if x.get("msgtype") == "file"]
|
||||
self.assertTrue(file_extras)
|
||||
follow = file_extras[-1].get("followup_card") or {}
|
||||
self.assertTrue(str(follow.get("task_id") or "").startswith("deal-"))
|
||||
deal_rows = {r["keyname"]: r["value"] for r in follow.get("horizontal_content_list") or []}
|
||||
self.assertIn("总销售价合计", deal_rows)
|
||||
self.assertNotIn("历史报价", deal_rows)
|
||||
self.assertNotIn("工单状态", deal_rows)
|
||||
self.assertNotIn("来源", deal_rows)
|
||||
|
||||
file_idx = next(
|
||||
i for i, x in enumerate(self.extras) if x.get("msgtype") == "file"
|
||||
)
|
||||
deal_idx = next(
|
||||
i
|
||||
for i, x in enumerate(self.extras)
|
||||
separate_deal = [
|
||||
x
|
||||
for x in self.extras
|
||||
if x.get("msgtype") == "template_card"
|
||||
and str((x.get("template_card") or {}).get("task_id") or "").startswith("deal-")
|
||||
)
|
||||
self.assertLess(file_idx, deal_idx)
|
||||
]
|
||||
self.assertEqual(separate_deal, [])
|
||||
phase = self.flow.on_button(
|
||||
sender_id="u1",
|
||||
action=copy.BTN_DEAL,
|
||||
@@ -1268,7 +1261,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
source="test",
|
||||
)
|
||||
self.assertEqual(replay, "wait_deal")
|
||||
self.assertIn("咨询成交状态", "\n".join(again))
|
||||
self.assertNotIn("咨询成交状态", "\n".join(again))
|
||||
self.assertEqual(http.session_of("u-pdf-x").phase, "wait_deal")
|
||||
|
||||
def test_deal_click_works_while_http_still_wait_file(self) -> None:
|
||||
@@ -1315,3 +1308,44 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
self.assertEqual(phase, "done")
|
||||
self.assertEqual(flow.session_of("u-pdf-d").phase, "done")
|
||||
self.assertTrue(any(x.get("msgtype") == "update_template_card" for x in extras))
|
||||
|
||||
|
||||
class FileThenDealOrderTests(unittest.TestCase):
|
||||
def test_send_outbound_sends_file_before_followup_card(self) -> None:
|
||||
"""同一条出站必须先传文件再发成交卡。"""
|
||||
from agent.channel.outbox.sender import WeComAppClient, WeComSendResult
|
||||
|
||||
order: list[str] = []
|
||||
|
||||
class _Client(WeComAppClient):
|
||||
def send_file(self, **kwargs): # type: ignore[override]
|
||||
order.append("file")
|
||||
return WeComSendResult(ok=True)
|
||||
|
||||
def _post_message(self, body, preview=""): # type: ignore[override]
|
||||
order.append(str(preview or body.get("msgtype")))
|
||||
return WeComSendResult(ok=True)
|
||||
|
||||
client = _Client(corp_id="c", secret="s", agent_id=1)
|
||||
client._followup_gap_sec = 0
|
||||
client.send_outbound(
|
||||
touser="u1",
|
||||
content="file+deal",
|
||||
payload={
|
||||
"msgtype": "file",
|
||||
"filename": "WO1.xlsx",
|
||||
"file_b64": "QQ==",
|
||||
"followup_card": {"task_id": "deal-WO1"},
|
||||
},
|
||||
)
|
||||
self.assertEqual(order, ["file", "[followup_card]"])
|
||||
|
||||
def test_wait_outbound_stays_until_claimed_item_finishes(self) -> None:
|
||||
"""已被认领的出站必须等到发完,不能中途当成功。"""
|
||||
store = MemoryMessageStore()
|
||||
ok, oid = store.enqueue_outbound(touser="u1", content="报价文件")
|
||||
self.assertTrue(ok)
|
||||
claimed = store.claim_outbound()
|
||||
self.assertIsNotNone(claimed)
|
||||
done = store.wait_outbound_done(oid, timeout_sec=0.45)
|
||||
self.assertFalse(done)
|
||||
|
||||
@@ -215,7 +215,7 @@ class ContinueTicketTests(unittest.TestCase):
|
||||
sender_id="u6", work_order_no=wo, reply=reply
|
||||
)
|
||||
self.assertEqual(phase, "wait_deal")
|
||||
self.assertTrue(any("成交" in x or copy.BTN_DEAL in x for x in replies))
|
||||
self.assertFalse(any("成交" in x or copy.BTN_DEAL in x for x in replies))
|
||||
|
||||
def test_resume_missing_wo_asks(self) -> None:
|
||||
flow = AirTextInquiryFlow(MemoryLedger())
|
||||
|
||||
@@ -62,8 +62,29 @@ class FakeGroupClient:
|
||||
self.created.append({"name": "", "userids": list(userids), "chat_id": chat_id, "add": True})
|
||||
return {"ok": True}
|
||||
|
||||
def send_group(self, *, chat_id: str, content: str) -> dict:
|
||||
self.sent.append({"chat_id": chat_id, "content": content})
|
||||
def send_group(self, *, chat_id: str, content: str, mention_userids: list | None = None) -> dict:
|
||||
self.sent.append(
|
||||
{
|
||||
"chat_id": chat_id,
|
||||
"content": content,
|
||||
"kind": "text",
|
||||
"mention_userids": list(mention_userids or []),
|
||||
}
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
def send_group_file(self, *, chat_id: str, filename: str, filepath: str = "", file_b64: str = "") -> dict:
|
||||
self.sent.append(
|
||||
{"chat_id": chat_id, "filename": filename, "kind": "file", "file_b64": file_b64}
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
def send_group_card(self, *, chat_id: str, template_card: dict) -> dict:
|
||||
self.sent.append({"chat_id": chat_id, "kind": "card", "template_card": template_card})
|
||||
return {"ok": True}
|
||||
|
||||
def dismiss_group(self, *, chat_id: str, userids: list) -> dict:
|
||||
self.sent.append({"chat_id": chat_id, "kind": "dismiss", "userids": list(userids)})
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -213,21 +234,40 @@ class SeaTmsCardTests(unittest.TestCase):
|
||||
],
|
||||
}
|
||||
text = copy.sea_quote_detail_text(
|
||||
work_order_no="WO202609150029", quote=long_quote
|
||||
work_order_no="WO202609150030", quote=long_quote
|
||||
)
|
||||
self.assertIn("海运费:USD 100.00", text)
|
||||
self.assertIn("报关费:CNY 250.00", text)
|
||||
self.assertIn("总销售价合计:CNY 2287.65", text)
|
||||
self.assertEqual(
|
||||
text,
|
||||
"\n".join(
|
||||
[
|
||||
"TMS标准报价",
|
||||
"WO202609150030价格明细",
|
||||
"海运费:USD 100.00",
|
||||
"燃油附加费:USD 15.00",
|
||||
"操作费:CNY 680.00",
|
||||
"文件费:CNY 450.00",
|
||||
"封条费(SEAI):CNY 50.00",
|
||||
"设备费:CNY 50.00",
|
||||
"报关费:CNY 250.00",
|
||||
"总销售价合计:CNY 2287.65",
|
||||
]
|
||||
),
|
||||
)
|
||||
self.assertNotIn("时效", text)
|
||||
self.assertNotIn("报价有效期", text)
|
||||
card_text = copy.quote_area_safe_text(long_quote)
|
||||
self.assertIn("总销售价合计:CNY 2287.65", card_text)
|
||||
self.assertNotIn("海运费:USD 100.00\n燃油附加费", card_text)
|
||||
self.assertLessEqual(len(card_text), 80)
|
||||
payload = copy.sea_tms_hit_wecom_payload(
|
||||
work_order_no="WO202609150029", quote=long_quote
|
||||
work_order_no="WO202609150030", quote=long_quote
|
||||
)
|
||||
self.assertEqual(
|
||||
payload["template_card"]["quote_area"]["quote_text"], card_text
|
||||
)
|
||||
sub = payload["template_card"]["sub_title_text"]
|
||||
self.assertIn("时效:1天", sub)
|
||||
self.assertIn("报价有效期:2026-09-30", sub)
|
||||
|
||||
def test_sea_hit_card_has_price_and_two_buttons(self) -> None:
|
||||
phase = self.flow.on_text(
|
||||
@@ -393,9 +433,12 @@ class SeaSkipAndPullTests(unittest.TestCase):
|
||||
self.assertNotIn("AI001", self.groups.created[0]["userids"])
|
||||
self.assertTrue(self.groups.sent)
|
||||
group_text = self.groups.sent[0]["content"]
|
||||
self.assertTrue(any(x.get("kind") == "file" for x in self.groups.sent))
|
||||
self.assertIn(wo, group_text)
|
||||
self.assertIn("起运港", group_text)
|
||||
self.assertIn("贸易条款", group_text)
|
||||
self.assertIn("商品海关编码", group_text)
|
||||
self.assertNotIn("HS编码", group_text)
|
||||
self.assertIn("TMS", group_text)
|
||||
sess = self.flow.session_of("s1")
|
||||
self.assertNotIn(copy.BTN_PULL_COLLAB, sess.allowed)
|
||||
@@ -405,6 +448,40 @@ class SeaSkipAndPullTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(again, "card_discard")
|
||||
|
||||
def test_pull_sends_sheet_when_client_not_injected(self) -> None:
|
||||
"""测服正式路径不注入 group_client,拉群仍要发出填好的模板。"""
|
||||
from unittest.mock import patch
|
||||
|
||||
live = FakeGroupClient()
|
||||
self.ledger.set_sea_staff(
|
||||
[
|
||||
{
|
||||
"name": "张产品",
|
||||
"wecomId": "prod1",
|
||||
"roleCode": "sea",
|
||||
"routes": "海运·全部线路",
|
||||
"status": "active",
|
||||
}
|
||||
]
|
||||
)
|
||||
with patch("agent.policy.sea_text_flow._default_group_client", return_value=live):
|
||||
flow = SeaTextInquiryFlow(self.ledger, group_client=None)
|
||||
flow.on_text(
|
||||
sender_id="s9",
|
||||
text="海运有价",
|
||||
reply=self._reply,
|
||||
injected_facts=COMPLETE_SEA,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
phase = flow.on_button(
|
||||
sender_id="s9",
|
||||
action=copy.BTN_PULL_COLLAB,
|
||||
reply=self._reply,
|
||||
)
|
||||
self.assertEqual(phase, "collab_group")
|
||||
self.assertTrue(any(x.get("kind") == "text" for x in live.sent))
|
||||
self.assertTrue(any(x.get("kind") == "file" for x in live.sent))
|
||||
|
||||
def test_pull_group_includes_archive_bot(self) -> None:
|
||||
self.ledger.upsert_staff(
|
||||
{
|
||||
@@ -509,12 +586,27 @@ class SeaSkipAndPullTests(unittest.TestCase):
|
||||
reply=self._reply,
|
||||
)
|
||||
self.assertTrue(self.groups.sent)
|
||||
self.assertIn("暂无匹配报价", self.groups.sent[0]["content"])
|
||||
group_text = self.groups.sent[0]["content"]
|
||||
self.assertIn("TMS 暂无匹配报价", group_text)
|
||||
self.assertNotIn("未查到合适报价", group_text)
|
||||
self.assertIn("\n\n协同补充字段:\n", group_text)
|
||||
self.assertIn("\n\nTMS报价:\n", group_text)
|
||||
|
||||
|
||||
class SeaGroupCollabFieldTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.ledger = MemoryLedger()
|
||||
self.ledger.set_quote_templates(
|
||||
[
|
||||
{
|
||||
"templateId": "mem-default-sea",
|
||||
"name": "海运默认",
|
||||
"bizType": "海运",
|
||||
"keywords": [],
|
||||
"isDefault": True,
|
||||
}
|
||||
]
|
||||
)
|
||||
self.groups = FakeGroupClient()
|
||||
self.flow = SeaTextInquiryFlow(self.ledger, group_client=self.groups)
|
||||
self.replies: list[str] = []
|
||||
@@ -602,8 +694,10 @@ class SeaGroupCollabFieldTests(unittest.TestCase):
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "group_ignore")
|
||||
self.assertFalse(ticket.quote_version > 1 and "1200" in str(ticket.quote))
|
||||
self.assertEqual(phase, "product_quote_sent")
|
||||
self.assertEqual(str(ticket.quote.get("total") or ""), "1200")
|
||||
deal_texts = [x["content"] for x in self.groups.sent if x.get("kind") == "text" and "成交" in (x.get("content") or "")]
|
||||
self.assertTrue(deal_texts)
|
||||
|
||||
|
||||
class SeaContinueTests(unittest.TestCase):
|
||||
@@ -761,6 +855,17 @@ class SeaContinueTests(unittest.TestCase):
|
||||
card_meta={"event_key": f"adopt_excel:{wo}", "response_code": "rc-xl"},
|
||||
)
|
||||
self.assertEqual(self.flow.session_of("c3").phase, "wait_deal")
|
||||
file_extras = [x for x in self.extras if x.get("msgtype") == "file"]
|
||||
self.assertTrue(file_extras)
|
||||
follow = file_extras[-1].get("followup_card") or {}
|
||||
self.assertTrue(str(follow.get("task_id") or "").startswith("deal-"))
|
||||
separate_deal = [
|
||||
x
|
||||
for x in self.extras
|
||||
if x.get("msgtype") == "template_card"
|
||||
and str((x.get("template_card") or {}).get("task_id") or "").startswith("deal-")
|
||||
]
|
||||
self.assertEqual(separate_deal, [])
|
||||
empty = SeaTextInquiryFlow(self.ledger)
|
||||
store = MemoryMessageStore()
|
||||
msg = InboundMessage(
|
||||
@@ -777,11 +882,37 @@ class SeaContinueTests(unittest.TestCase):
|
||||
phase = handle_continue_thread(msg, thread_id=wo, store=store)
|
||||
self.assertEqual(phase, "wait_deal")
|
||||
joined = "\n".join(item.content for item in store._outbox.values())
|
||||
self.assertTrue(any(x in joined for x in (copy.BTN_DEAL, "成交")))
|
||||
self.assertFalse(any(x in joined for x in (copy.BTN_DEAL, "成交")))
|
||||
self.assertNotIn(copy.BTN_SKIP_COLLAB, joined)
|
||||
|
||||
|
||||
class SeaGroupCopyTests(unittest.TestCase):
|
||||
def test_group_brief_uses_customs_code_and_section_breaks(self) -> None:
|
||||
text = copy.sea_group_brief(
|
||||
work_order_no="WO202609150023",
|
||||
facts={
|
||||
"起运港": "纽约港",
|
||||
"目的港": "巴生西港",
|
||||
"品名": "机器设备",
|
||||
"货量": "15",
|
||||
"整柜或拼柜": "整柜",
|
||||
"箱型箱量": "40HQ*1",
|
||||
"报价日期": "2026-09-15",
|
||||
},
|
||||
quote=None,
|
||||
collab_facts={},
|
||||
sales_name="吴季林",
|
||||
product_names=["罗小华"],
|
||||
has_price=False,
|
||||
)
|
||||
self.assertIn("@吴季林 @罗小华", text)
|
||||
self.assertIn("询价字段:\n运输方式:海运", text)
|
||||
self.assertIn("\n\n协同补充字段:\n", text)
|
||||
self.assertIn("商品海关编码:-", text)
|
||||
self.assertNotIn("HS编码", text)
|
||||
self.assertIn("\n\nTMS报价:\nTMS 暂无匹配报价", text)
|
||||
self.assertNotIn("未查到合适报价", text)
|
||||
|
||||
def test_scheme_card_uses_sea_fields(self) -> None:
|
||||
facts = {
|
||||
"起运港": "上海",
|
||||
@@ -831,6 +962,312 @@ class SeaGroupCopyTests(unittest.TestCase):
|
||||
"已邀请:销售吴季林、产品罗小华。请在群聊中继续确认报价。",
|
||||
)
|
||||
|
||||
def test_group_only_excel_pdf_copy(self) -> None:
|
||||
self.assertEqual(
|
||||
copy.group_only_excel_pdf(),
|
||||
"群里只收 Excel 和 PDF,其它文件不会存入工单",
|
||||
)
|
||||
|
||||
|
||||
class SeaGroupOpsTests(unittest.TestCase):
|
||||
"""群协同:附件类型、产品报价、解散后再拉、终态提醒。"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.ledger = MemoryLedger()
|
||||
self.ledger.set_quote_templates(
|
||||
[
|
||||
{
|
||||
"templateId": "mem-default-sea",
|
||||
"name": "海运默认",
|
||||
"bizType": "海运",
|
||||
"keywords": [],
|
||||
"isDefault": True,
|
||||
}
|
||||
]
|
||||
)
|
||||
self.ledger.set_sea_staff(
|
||||
[
|
||||
{
|
||||
"name": "赵产品",
|
||||
"wecomId": "prod4",
|
||||
"roleCode": "sea",
|
||||
"routes": "海运·全部线路",
|
||||
"status": "active",
|
||||
}
|
||||
]
|
||||
)
|
||||
self.ledger.upsert_staff({"name": "吴吉林", "wecomId": "sales1"})
|
||||
self.groups = FakeGroupClient()
|
||||
self.flow = SeaTextInquiryFlow(self.ledger, group_client=self.groups)
|
||||
self.replies: list[str] = []
|
||||
|
||||
def _reply(self, text: str, extra=None) -> None:
|
||||
self.replies.append(text)
|
||||
|
||||
def _open_group(self) -> tuple[str, str]:
|
||||
self.flow.on_text(
|
||||
sender_id="sales1",
|
||||
text="海运有价",
|
||||
reply=self._reply,
|
||||
injected_facts=COMPLETE_SEA,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.flow.on_button(
|
||||
sender_id="sales1",
|
||||
action=copy.BTN_PULL_COLLAB,
|
||||
reply=self._reply,
|
||||
)
|
||||
sess = self.flow.session_of("sales1")
|
||||
return sess.work_order_no, sess.collab_chat_id
|
||||
|
||||
def test_group_rejects_non_excel_pdf(self) -> None:
|
||||
wo, chat = self._open_group()
|
||||
phase = handle_group_collab(
|
||||
InboundMessage(
|
||||
sender_id="sales1",
|
||||
message_id="img1",
|
||||
content="",
|
||||
msg_type="image",
|
||||
chat_id=chat,
|
||||
chat_type="group",
|
||||
media={"filename": "现场.jpg", "media_id": "mid-1"},
|
||||
),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "file_type_rejected")
|
||||
texts = [x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"]
|
||||
self.assertIn("群里只收 Excel 和 PDF,其它文件不会存入工单", texts)
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
self.assertFalse(ticket.attachments)
|
||||
|
||||
def test_sales_excel_is_archived_not_quote(self) -> None:
|
||||
wo, chat = self._open_group()
|
||||
phase = handle_group_collab(
|
||||
InboundMessage(
|
||||
sender_id="sales1",
|
||||
message_id="xls1",
|
||||
content="",
|
||||
msg_type="file",
|
||||
chat_id=chat,
|
||||
chat_type="group",
|
||||
media={"filename": "对账单.xlsx", "media_id": "mid-2"},
|
||||
),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "file_archived")
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
self.assertEqual(ticket.attachments[0]["file_name"], "对账单.xlsx")
|
||||
self.assertNotEqual(ticket.quote.get("source_label"), "产品附件报价")
|
||||
|
||||
def test_product_excel_quotes_and_deal(self) -> None:
|
||||
wo, chat = self._open_group()
|
||||
phase = handle_group_collab(
|
||||
InboundMessage(
|
||||
sender_id="prod4",
|
||||
message_id="xls2",
|
||||
content="",
|
||||
msg_type="file",
|
||||
chat_id=chat,
|
||||
chat_type="group",
|
||||
media={"filename": "产品报价.pdf", "media_id": "mid-3"},
|
||||
),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "product_file_quote")
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
self.assertEqual(ticket.attachments[0]["file_name"], "产品报价.pdf")
|
||||
self.assertEqual(ticket.status, "已报价")
|
||||
self.assertTrue(any(x.get("kind") == "card" for x in self.groups.sent))
|
||||
|
||||
def test_terminal_file_speaks(self) -> None:
|
||||
wo, chat = self._open_group()
|
||||
self.ledger.transition(work_order_no=wo, to_status="已成交")
|
||||
sess = self.flow.session_of("sales1")
|
||||
sess.status = "已成交"
|
||||
phase = handle_group_collab(
|
||||
InboundMessage(
|
||||
sender_id="prod4",
|
||||
message_id="xls3",
|
||||
content="",
|
||||
msg_type="file",
|
||||
chat_id=chat,
|
||||
chat_type="group",
|
||||
media={"filename": "晚到.xlsx", "media_id": "mid-4"},
|
||||
),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "ticket_locked_file")
|
||||
texts = [x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"]
|
||||
self.assertTrue(any("不能再上传附件" in t for t in texts))
|
||||
self.assertFalse(self.ledger.get_ticket(work_order_no=wo).attachments)
|
||||
|
||||
def test_dissolve_then_cannot_recreate(self) -> None:
|
||||
wo, chat = self._open_group()
|
||||
phase = handle_group_collab(
|
||||
InboundMessage(
|
||||
sender_id="sales1",
|
||||
message_id="dis1",
|
||||
content="这个群解散吧",
|
||||
chat_id=chat,
|
||||
chat_type="group",
|
||||
),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="dissolve",
|
||||
)
|
||||
self.assertEqual(phase, "group_dissolved")
|
||||
self.assertTrue(any(x.get("kind") == "dismiss" for x in self.groups.sent))
|
||||
self.assertTrue(self.ledger.get_ticket(work_order_no=wo).collab_ended)
|
||||
sess = self.flow.session_of("sales1")
|
||||
sess.allowed = (copy.BTN_PULL_COLLAB,)
|
||||
again = self.flow.on_button(
|
||||
sender_id="sales1",
|
||||
action=copy.BTN_PULL_COLLAB,
|
||||
reply=self._reply,
|
||||
)
|
||||
self.assertEqual(again, "collab_ended")
|
||||
self.assertIn("不能再拉群", "\n".join(self.replies))
|
||||
|
||||
def test_group_oral_keeps_inquiry_facts_when_snapshot_skinny(self) -> None:
|
||||
wo, chat = self._open_group()
|
||||
real_find = self.ledger.find_by_collab_chat
|
||||
|
||||
def skinny(*, chat_id: str):
|
||||
ticket = real_find(chat_id=chat_id)
|
||||
if ticket is None:
|
||||
return None
|
||||
return {
|
||||
"found": True,
|
||||
"work_order_no": ticket.work_order_no,
|
||||
"sales_wecom_id": ticket.sales_wecom_id,
|
||||
"collab_chat_id": ticket.collab_chat_id,
|
||||
"collab_member_ids": list(ticket.collab_member_ids),
|
||||
"collab_facts": dict(ticket.collab_facts),
|
||||
}
|
||||
|
||||
self.ledger.find_by_collab_chat = skinny # type: ignore[method-assign]
|
||||
self.flow._by_ticket.clear()
|
||||
self.flow._by_sender.clear()
|
||||
phase = handle_group_collab(
|
||||
InboundMessage(
|
||||
sender_id="sales1",
|
||||
message_id="oral-keep-facts-1",
|
||||
content="不含电、不含油、随时可提",
|
||||
chat_id=chat,
|
||||
chat_type="group",
|
||||
),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="collab_fields",
|
||||
)
|
||||
self.assertEqual(phase, "collab_field_saved")
|
||||
texts = "\n".join(x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text")
|
||||
self.assertIn("上海", texts)
|
||||
self.assertIn("洛杉矶", texts)
|
||||
self.assertIn("随时可提", texts)
|
||||
self.assertNotRegex(texts, r"起运港:\s*-")
|
||||
self.assertIn("@吴吉林 @赵产品", texts)
|
||||
self.assertNotRegex(texts, r"^@销售(\s|$)")
|
||||
briefs = [
|
||||
x
|
||||
for x in self.groups.sent
|
||||
if x.get("kind") == "text" and "询价字段" in (x.get("content") or "")
|
||||
]
|
||||
self.assertTrue(briefs)
|
||||
self.assertIn("sales1", briefs[-1].get("mention_userids") or [])
|
||||
self.assertIn("prod4", briefs[-1].get("mention_userids") or [])
|
||||
self.assertNotIn("AI001", briefs[-1].get("mention_userids") or [])
|
||||
|
||||
def test_group_oral_oil_power_ready_is_saved(self) -> None:
|
||||
wo, chat = self._open_group()
|
||||
n = len([x for x in self.groups.sent if x.get("kind") == "text"])
|
||||
phase = handle_group_collab(
|
||||
InboundMessage(
|
||||
sender_id="sales1",
|
||||
message_id="oral-collab-1",
|
||||
content="不含电、不含油、随时可提",
|
||||
chat_id=chat,
|
||||
chat_type="group",
|
||||
),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="collab_fields",
|
||||
)
|
||||
self.assertEqual(phase, "collab_field_saved")
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
self.assertEqual(ticket.collab_facts.get("是否含电"), "否")
|
||||
self.assertEqual(ticket.collab_facts.get("是否含油"), "否")
|
||||
self.assertEqual(ticket.collab_facts.get("货好时间"), "随时可提")
|
||||
texts = [x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"]
|
||||
self.assertGreater(len(texts), n)
|
||||
|
||||
def test_collab_facts_are_projected_for_quote_sheet(self) -> None:
|
||||
from agent.policy.quote_overlay import overlay_labeled_facts
|
||||
from agent.policy.sea_group_ops import _facts_for_fill
|
||||
from openpyxl import Workbook, load_workbook
|
||||
|
||||
filled = _facts_for_fill(
|
||||
{"起运港": "DONGGUAN", "目的港": "海防"},
|
||||
{"货好时间": "随时可提", "是否含油": "否", "是否含电": "否", "HS编码": "8479"},
|
||||
)
|
||||
self.assertEqual(filled["cargo_ready_date"], "随时可提")
|
||||
self.assertEqual(filled["hs_code"], "8479")
|
||||
self.assertIn("油:否", filled["oil_elec_mag"])
|
||||
self.assertIn("电:否", filled["oil_elec_mag"])
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws["A1"] = "*货好时间/Cargo Ready Date:"
|
||||
ws["A2"] = "*商品海关编码/HS Code:"
|
||||
ws["A3"] = "*是否含油,电,磁/OIL,ELEC,MAG:"
|
||||
path = str(Path(tempfile.mkdtemp(prefix="collab-sheet-")) / "src.xlsx")
|
||||
wb.save(path)
|
||||
out = overlay_labeled_facts(filepath=path, facts=filled)
|
||||
painted = load_workbook(out["filepath"])
|
||||
sheet = painted.active
|
||||
self.assertEqual(sheet["B1"].value, "随时可提")
|
||||
self.assertEqual(sheet["B2"].value, "8479")
|
||||
self.assertIn("油:否", str(sheet["B3"].value or ""))
|
||||
|
||||
def test_stale_deal_card_speaks(self) -> None:
|
||||
from agent.policy.sea_group_ops import explain_deal_click
|
||||
|
||||
self._open_group()
|
||||
sess = self.flow.session_of("sales1")
|
||||
sess.deal_version = 2
|
||||
sess.collab_chat_id = sess.collab_chat_id or "wr_sea_1"
|
||||
reason = explain_deal_click(
|
||||
flow=self.flow,
|
||||
sender_id="sales1",
|
||||
action=copy.BTN_DEAL,
|
||||
card_meta={"event_key": f"deal:{sess.work_order_no}:v1"},
|
||||
sess=sess,
|
||||
)
|
||||
self.assertEqual(reason, "stale_deal")
|
||||
texts = [x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"]
|
||||
self.assertTrue(any("已过期" in t for t in texts))
|
||||
|
||||
def test_parse_group_file_fills_media(self) -> None:
|
||||
from agent.channel.wecom.parse import parse_plain_xml
|
||||
|
||||
xml = (
|
||||
"<xml><FromUserName>prod4</FromUserName><CreateTime>1</CreateTime>"
|
||||
"<MsgType>file</MsgType><MediaId>mid-x</MediaId>"
|
||||
"<FileName>报价.xlsx</FileName><ChatId>wr_sea_1</ChatId></xml>"
|
||||
)
|
||||
msg = parse_plain_xml(xml)
|
||||
self.assertEqual(msg.chat_type, "group")
|
||||
self.assertEqual(msg.chat_id, "wr_sea_1")
|
||||
self.assertEqual(msg.media.get("filename"), "报价.xlsx")
|
||||
self.assertEqual(msg.media.get("media_id"), "mid-x")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
"""
|
||||
微盛会话存档:解析、跳过存档席、测服游标、入 inbox。
|
||||
|
||||
不连微盛外网、不连现网 Redis。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
os.environ.setdefault("YTD_ENV", "test")
|
||||
os.environ.setdefault("REDIS_BACKEND", "memory")
|
||||
os.environ.setdefault("CHECKPOINT_BACKEND", "memory")
|
||||
os.environ.setdefault("MESSAGE_STORE_BACKEND", "memory")
|
||||
os.environ.setdefault("LLM_DATA_USAGE_CONFIRMED", "false")
|
||||
os.environ.setdefault("LLM_ALLOW_NETWORK", "false")
|
||||
os.environ.setdefault("LEDGER_BACKEND", "memory")
|
||||
|
||||
from agent.channel.queue import MemoryMessageStore
|
||||
from agent.channel.wshoto.client import wshoto_ready
|
||||
from agent.channel.wshoto.parse import archive_record_to_inbound
|
||||
from agent.channel.wshoto.rooms import (
|
||||
list_watched_rooms,
|
||||
load_last_seq,
|
||||
save_last_seq,
|
||||
unwatch_collab_room,
|
||||
watch_collab_room,
|
||||
)
|
||||
from agent.channel.wshoto.poll import ingest_records, poll_once
|
||||
from agent.redis_coord.client import MemoryRedis, RedisClient
|
||||
|
||||
|
||||
def _redis() -> RedisClient:
|
||||
return RedisClient(raw=MemoryRedis(), key_prefix="inquiry_robot:", backend="memory")
|
||||
|
||||
|
||||
class _Cfg:
|
||||
wshoto_poll_enabled = True
|
||||
wshoto_app_id = "app"
|
||||
wshoto_app_secret = "secret"
|
||||
wecom_archive_seat_user_ids = "AI001"
|
||||
wshoto_bootstrap_lookback_sec = 3600
|
||||
wshoto_page_size = 50
|
||||
|
||||
|
||||
class _FakeApi:
|
||||
def __init__(self, records: list[dict], *, error: WshotoError | None = None) -> None:
|
||||
self.records = records
|
||||
self.error = error
|
||||
self.calls: list[dict] = []
|
||||
|
||||
def list_group_messages(self, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
last = int(kwargs.get("last_seq") or 0)
|
||||
return [r for r in self.records if int(r.get("seq") or 0) > last]
|
||||
|
||||
|
||||
class WshotoParseTests(unittest.TestCase):
|
||||
def test_member_text_becomes_group_inbound(self) -> None:
|
||||
msg = archive_record_to_inbound(
|
||||
{
|
||||
"msgid": "m1",
|
||||
"seq": 10,
|
||||
"from": "WuJiLin",
|
||||
"room_id": "wr_room_1",
|
||||
"msgtype": "text",
|
||||
"content": "运费 1200",
|
||||
"msgtime": 1_700_000_000_000,
|
||||
},
|
||||
seat_userids=["AI001"],
|
||||
)
|
||||
self.assertIsNotNone(msg)
|
||||
assert msg is not None
|
||||
self.assertEqual(msg.sender_id, "WuJiLin")
|
||||
self.assertEqual(msg.chat_id, "wr_room_1")
|
||||
self.assertEqual(msg.chat_type, "group")
|
||||
self.assertEqual(msg.content, "运费 1200")
|
||||
self.assertEqual(msg.message_id, "wshoto:m1")
|
||||
self.assertEqual(msg.create_time, 1_700_000_000)
|
||||
|
||||
def test_skip_archive_seat_and_display_name(self) -> None:
|
||||
self.assertIsNone(
|
||||
archive_record_to_inbound(
|
||||
{
|
||||
"msgid": "m2",
|
||||
"from": "AI001",
|
||||
"room_id": "wr_room_1",
|
||||
"msgtype": "text",
|
||||
"content": "群简介",
|
||||
},
|
||||
seat_userids=["AI001"],
|
||||
)
|
||||
)
|
||||
self.assertIsNone(
|
||||
archive_record_to_inbound(
|
||||
{
|
||||
"msgid": "m3",
|
||||
"from": "询价机器人",
|
||||
"room_id": "wr_room_1",
|
||||
"msgtype": "text",
|
||||
"content": "系统话术",
|
||||
},
|
||||
seat_userids=["AI001"],
|
||||
)
|
||||
)
|
||||
|
||||
def test_file_keeps_filename(self) -> None:
|
||||
msg = archive_record_to_inbound(
|
||||
{
|
||||
"msgid": "m4",
|
||||
"from": "LuoXiaoHua",
|
||||
"room_id": "wr_room_1",
|
||||
"msgtype": "file",
|
||||
"content": "",
|
||||
"msg_content": {"filename": "报价.xlsx", "sdkfileid": "sdk-1"},
|
||||
},
|
||||
seat_userids=["AI001"],
|
||||
)
|
||||
self.assertIsNotNone(msg)
|
||||
assert msg is not None
|
||||
self.assertEqual(msg.msg_type, "file")
|
||||
self.assertEqual(msg.media.get("filename"), "报价.xlsx")
|
||||
self.assertEqual(msg.media.get("media_id"), "sdk-1")
|
||||
|
||||
def test_skip_revoke_and_missing_room(self) -> None:
|
||||
self.assertIsNone(
|
||||
archive_record_to_inbound(
|
||||
{"msgid": "m5", "from": "WuJiLin", "room_id": "", "msgtype": "text", "content": "x"},
|
||||
seat_userids=["AI001"],
|
||||
)
|
||||
)
|
||||
self.assertIsNone(
|
||||
archive_record_to_inbound(
|
||||
{
|
||||
"msgid": "m6",
|
||||
"from": "WuJiLin",
|
||||
"room_id": "wr_room_1",
|
||||
"msgtype": "revoke",
|
||||
"content": "x",
|
||||
},
|
||||
seat_userids=["AI001"],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class WshotoPollTests(unittest.TestCase):
|
||||
def test_ready_needs_keys(self) -> None:
|
||||
cfg = _Cfg()
|
||||
self.assertTrue(wshoto_ready(cfg))
|
||||
cfg.wshoto_app_secret = ""
|
||||
self.assertFalse(wshoto_ready(cfg))
|
||||
|
||||
def test_watch_rooms_and_cursor(self) -> None:
|
||||
redis = _redis()
|
||||
watch_collab_room("wr_a", redis=redis)
|
||||
watch_collab_room("wr_b", redis=redis)
|
||||
self.assertEqual(list_watched_rooms(redis=redis), ["wr_a", "wr_b"])
|
||||
unwatch_collab_room("wr_a", redis=redis)
|
||||
self.assertEqual(list_watched_rooms(redis=redis), ["wr_b"])
|
||||
save_last_seq("seat:AI001", 88, redis=redis)
|
||||
self.assertEqual(load_last_seq("seat:AI001", redis=redis), 88)
|
||||
watch_collab_room("wr_c", work_order_no="WO1", redis=redis)
|
||||
from agent.channel.wshoto.rooms import ticket_no_for_room
|
||||
|
||||
self.assertEqual(ticket_no_for_room("wr_c", redis=redis), "WO1")
|
||||
|
||||
def test_watch_room_enters_hot_window(self) -> None:
|
||||
from agent.channel.wshoto.rooms import has_hot_room, wait_wshoto_poll
|
||||
|
||||
redis = _redis()
|
||||
watch_collab_room("wr_hot", redis=redis)
|
||||
self.assertTrue(has_hot_room())
|
||||
self.assertTrue(wait_wshoto_poll(0.2))
|
||||
|
||||
def test_new_room_lookback_is_capped(self) -> None:
|
||||
store = MemoryMessageStore()
|
||||
redis = _redis()
|
||||
watch_collab_room("wr_room_1", redis=redis)
|
||||
api = _FakeApi(
|
||||
[
|
||||
{
|
||||
"msgid": "fresh",
|
||||
"seq": 7,
|
||||
"from": "WuJiLin",
|
||||
"room_id": "wr_room_1",
|
||||
"msgtype": "text",
|
||||
"content": "随时可提",
|
||||
}
|
||||
]
|
||||
)
|
||||
out = poll_once(
|
||||
client=api,
|
||||
settings=_Cfg(),
|
||||
redis=redis,
|
||||
store=store,
|
||||
now=lambda: 1_800_000_000,
|
||||
)
|
||||
self.assertTrue(out["ok"])
|
||||
call = api.calls[0]
|
||||
self.assertEqual(call.get("msg_time_end"), 1_800_000_000)
|
||||
self.assertEqual(call.get("msg_time_start"), 1_800_000_000 - 180)
|
||||
|
||||
def test_poll_enqueues_member_skips_seat_and_advances_seq(self) -> None:
|
||||
store = MemoryMessageStore()
|
||||
redis = _redis()
|
||||
api = _FakeApi(
|
||||
[
|
||||
{
|
||||
"msgid": "old",
|
||||
"seq": 10,
|
||||
"from": "WuJiLin",
|
||||
"room_id": "wr_room_1",
|
||||
"msgtype": "text",
|
||||
"content": "旧消息",
|
||||
},
|
||||
{
|
||||
"msgid": "seat",
|
||||
"seq": 11,
|
||||
"from": "AI001",
|
||||
"room_id": "wr_room_1",
|
||||
"msgtype": "text",
|
||||
"content": "机器人自己说的",
|
||||
},
|
||||
{
|
||||
"msgid": "new",
|
||||
"seq": 12,
|
||||
"from": "WuJiLin",
|
||||
"room_id": "wr_room_1",
|
||||
"msgtype": "text",
|
||||
"content": "运费 1500",
|
||||
},
|
||||
]
|
||||
)
|
||||
save_last_seq("seat:AI001", 10, redis=redis)
|
||||
out = poll_once(client=api, settings=_Cfg(), redis=redis, store=store)
|
||||
self.assertTrue(out["ok"])
|
||||
self.assertEqual(out["enqueued"], 1)
|
||||
self.assertEqual(out["last_seq"], 12)
|
||||
self.assertEqual(load_last_seq("seat:AI001", redis=redis), 12)
|
||||
item = store.claim_inbound()
|
||||
self.assertIsNotNone(item)
|
||||
assert item is not None
|
||||
self.assertEqual(item.message.content, "运费 1500")
|
||||
self.assertEqual(item.message.chat_type, "group")
|
||||
|
||||
def test_first_poll_uses_time_window_not_seq_zero(self) -> None:
|
||||
store = MemoryMessageStore()
|
||||
redis = _redis()
|
||||
api = _FakeApi(
|
||||
[
|
||||
{
|
||||
"msgid": "recent",
|
||||
"seq": 5,
|
||||
"from": "WuJiLin",
|
||||
"room_id": "wr_room_1",
|
||||
"msgtype": "text",
|
||||
"content": "刚说的",
|
||||
}
|
||||
]
|
||||
)
|
||||
out = poll_once(
|
||||
client=api,
|
||||
settings=_Cfg(),
|
||||
redis=redis,
|
||||
store=store,
|
||||
now=lambda: 1_800_000_000,
|
||||
)
|
||||
self.assertTrue(out["ok"])
|
||||
self.assertEqual(len(api.calls), 1)
|
||||
call = api.calls[0]
|
||||
self.assertEqual(call.get("last_seq"), 0)
|
||||
self.assertGreater(call.get("msg_time_start") or 0, 0)
|
||||
self.assertEqual(call.get("msg_time_end"), 1_800_000_000)
|
||||
self.assertEqual(out["enqueued"], 1)
|
||||
|
||||
def test_ingest_dedupes_message_id(self) -> None:
|
||||
store = MemoryMessageStore()
|
||||
rec = {
|
||||
"msgid": "same",
|
||||
"seq": 3,
|
||||
"from": "WuJiLin",
|
||||
"room_id": "wr_room_1",
|
||||
"msgtype": "text",
|
||||
"content": "重复",
|
||||
}
|
||||
a, _ = ingest_records([rec], seat_userids=["AI001"], store=store)
|
||||
b, skipped = ingest_records([rec], seat_userids=["AI001"], store=store)
|
||||
self.assertEqual(a, 1)
|
||||
self.assertEqual(b, 0)
|
||||
self.assertEqual(skipped, 1)
|
||||
|
||||
def test_watched_rooms_preferred_without_seat_userid(self) -> None:
|
||||
store = MemoryMessageStore()
|
||||
redis = _redis()
|
||||
watch_collab_room("wr_room_1", redis=redis)
|
||||
api = _FakeApi(
|
||||
[
|
||||
{
|
||||
"msgid": "room-msg",
|
||||
"seq": 21,
|
||||
"from": "LuoXiaoHua",
|
||||
"room_id": "wr_room_1",
|
||||
"msgtype": "text",
|
||||
"content": "20GP 1",
|
||||
}
|
||||
]
|
||||
)
|
||||
out = poll_once(client=api, settings=_Cfg(), redis=redis, store=store)
|
||||
self.assertTrue(out["ok"])
|
||||
self.assertEqual(out["mode"], "rooms")
|
||||
self.assertEqual(out["enqueued"], 1)
|
||||
self.assertEqual(api.calls[0].get("userid"), "")
|
||||
self.assertEqual(api.calls[0].get("target_id"), "wr_room_1")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+30
@@ -207,6 +207,36 @@ public class InquiryAgentController {
|
||||
return Result.OK(workOrderService.findByCollabChat(firstStr(body, "chatId", "chat_id")));
|
||||
}
|
||||
|
||||
@ApiOperation("协同结束(不改六态,这单不能再拉群)")
|
||||
@PostMapping("/ticket/endCollabGroup")
|
||||
public Result<Map<String, Object>> endCollabGroup(@RequestBody Map<String, Object> body,
|
||||
HttpServletRequest http) {
|
||||
if (!authed(http)) {
|
||||
return Result.error(401, "unauthorized");
|
||||
}
|
||||
return Result.OK(workOrderService.endCollabGroup(firstStr(body, "workOrderNo", "work_order_no")));
|
||||
}
|
||||
|
||||
@ApiOperation("群附件元数据入库(不改六态)")
|
||||
@PostMapping("/ticket/attachFile")
|
||||
public Result<Map<String, Object>> attachFile(@RequestBody Map<String, Object> body,
|
||||
HttpServletRequest http) {
|
||||
if (!authed(http)) {
|
||||
return Result.error(401, "unauthorized");
|
||||
}
|
||||
return Result.OK(workOrderService.attachFile(body));
|
||||
}
|
||||
|
||||
@ApiOperation("产品报价写入新版本")
|
||||
@PostMapping("/ticket/upsertProductQuote")
|
||||
public Result<Map<String, Object>> upsertProductQuote(@RequestBody Map<String, Object> body,
|
||||
HttpServletRequest http) {
|
||||
if (!authed(http)) {
|
||||
return Result.error(401, "unauthorized");
|
||||
}
|
||||
return Result.OK(workOrderService.upsertProductQuote(body));
|
||||
}
|
||||
|
||||
private boolean authed(HttpServletRequest http) {
|
||||
String got = http.getHeader("X-Agent-Token");
|
||||
if (StringUtils.isBlank(got)) {
|
||||
|
||||
+15
@@ -89,4 +89,19 @@ public interface IInquiryWorkOrderService extends IService<InquiryTicket> {
|
||||
* 用协同群 chat_id 找回工单。只读。
|
||||
*/
|
||||
Map<String, Object> findByCollabChat(String chatId);
|
||||
|
||||
/**
|
||||
* 协同结束:不改六态,这单不能再拉群。
|
||||
*/
|
||||
Map<String, Object> endCollabGroup(String workOrderNo);
|
||||
|
||||
/**
|
||||
* 群附件元数据入库。不改六态。
|
||||
*/
|
||||
Map<String, Object> attachFile(Map<String, Object> body);
|
||||
|
||||
/**
|
||||
* 产品报价写入新版本并视情况迁「已报价」。
|
||||
*/
|
||||
Map<String, Object> upsertProductQuote(Map<String, Object> body);
|
||||
}
|
||||
|
||||
+21
-1
@@ -538,10 +538,30 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
out.put("pod", first(src, "目的港", "pod", "destination"));
|
||||
out.put("destination", first(src, "目的港", "destination", "pod"));
|
||||
out.put("commodity", first(src, "品名", "commodity"));
|
||||
// 海运七项:合同键是 quantity / fcl_or_lcl / container_type_qty,工单事实是中文键
|
||||
out.put("quantity", first(src, "货量", "货物数量", "quantity"));
|
||||
out.put("fcl_or_lcl", first(src, "整柜或拼柜", "fcl_or_lcl"));
|
||||
out.put("container_type_qty", first(src, "箱型箱量", "container_type_qty"));
|
||||
out.put("pieces", first(src, "件数", "pieces"));
|
||||
out.put("weight_kg", first(src, "毛重", "weight_kg"));
|
||||
out.put("volume_cbm", first(src, "体积", "volume_cbm"));
|
||||
out.put("quote_date", first(src, "报价日期", "quote_date"));
|
||||
out.put("quote_date", first(src, "报价日期", "quote_date", "询价日期"));
|
||||
out.put("trade_terms", first(src, "贸易条款", "trade_terms"));
|
||||
out.put("cargo_ready_date", first(src, "货好时间", "cargo_ready_date"));
|
||||
out.put("hs_code", first(src, "商品海关编码", "HS编码", "海关编码", "hs_code"));
|
||||
String oil = first(src, "是否含油", "has_oil");
|
||||
String elec = first(src, "是否含电", "has_battery");
|
||||
String mag = first(src, "是否含磁", "has_magnet");
|
||||
String oem = first(src, "oil_elec_mag", "是否含油,电,磁");
|
||||
if (StringUtils.isBlank(oem) && (StringUtils.isNotBlank(oil) || StringUtils.isNotBlank(elec) || StringUtils.isNotBlank(mag))) {
|
||||
oem = "油:" + (StringUtils.isNotBlank(oil) ? oil : "-")
|
||||
+ ";电:" + (StringUtils.isNotBlank(elec) ? elec : "-")
|
||||
+ ";磁:" + (StringUtils.isNotBlank(mag) ? mag : "-");
|
||||
}
|
||||
if (StringUtils.isNotBlank(oem)) {
|
||||
out.put("oil_elec_mag", oem);
|
||||
out.put("是否含油,电,磁", oem);
|
||||
}
|
||||
if (StringUtils.isBlank(out.getString("quote_date"))) {
|
||||
out.put("quote_date", new java.text.SimpleDateFormat("yyyy-MM-dd").format(new Date()));
|
||||
}
|
||||
|
||||
+143
@@ -756,9 +756,152 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
}
|
||||
}
|
||||
out.put("collab_facts", collabFacts);
|
||||
boolean ended = "1".equals(collabFacts.get("__ended"))
|
||||
|| "true".equalsIgnoreCase(StringUtils.trimToEmpty(collabFacts.get("__ended")));
|
||||
out.put("collab_ended", ended);
|
||||
out.put("status", StringUtils.defaultString(ticket.getStatus()));
|
||||
out.put("business_line", StringUtils.defaultString(ticket.getBusinessLine()));
|
||||
out.put("facts", parseFactsJson(ticket.getFactsJson()));
|
||||
out.put("quote", latestQuotePayload(ticket.getWorkOrderNo()));
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 协同结束:在协同字段里打结束标记。不改六态、不清 chat_id(方便群里回话)。
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> endCollabGroup(String workOrderNo) {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("workOrderNo", workOrderNo);
|
||||
Map<String, String> flag = new LinkedHashMap<>();
|
||||
flag.put("__ended", "1");
|
||||
body.put("facts", flag);
|
||||
Map<String, Object> out = patchCollabFacts(body);
|
||||
out.put("collab_ended", Boolean.TRUE);
|
||||
InquiryTicket ticket = findByWorkOrderNo(workOrderNo);
|
||||
if (ticket != null) {
|
||||
appendLog(ticket, ticket.getStatus(), ticket.getStatus(), "end_collab_group", "agent", "协同结束");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 群附件只存元数据。终态拦截在智能体侧,这里不再改六态。
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> attachFile(Map<String, Object> body) {
|
||||
String no = text(body, "workOrderNo", "work_order_no");
|
||||
InquiryTicket ticket = findByWorkOrderNo(no);
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
if (ticket == null) {
|
||||
out.put("ok", false);
|
||||
out.put("error", "ticket_not_found");
|
||||
return out;
|
||||
}
|
||||
InquiryAttachment row = new InquiryAttachment();
|
||||
row.setTicketId(ticket.getId());
|
||||
row.setWorkOrderNo(no);
|
||||
row.setFileName(text(body, "fileName", "file_name"));
|
||||
row.setObjectKey(text(body, "objectKey", "object_key"));
|
||||
row.setContentType(text(body, "contentType", "content_type"));
|
||||
row.setCreateTime(new Date());
|
||||
attachmentMapper.insert(row);
|
||||
out.put("ok", true);
|
||||
out.put("work_order_no", no);
|
||||
out.put("file_name", row.getFileName());
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 产品报价盖 TMS 价:新插一条 inq_quote,主表售价跟着改。
|
||||
* 询价中/协商中可迁已报价;已报价只加版本。终态由智能体先拦。
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@SuppressWarnings("unchecked")
|
||||
public Map<String, Object> upsertProductQuote(Map<String, Object> body) {
|
||||
String no = text(body, "workOrderNo", "work_order_no");
|
||||
InquiryTicket ticket = findByWorkOrderNo(no);
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
if (ticket == null) {
|
||||
out.put("ok", false);
|
||||
out.put("error", "ticket_not_found");
|
||||
return out;
|
||||
}
|
||||
Object raw = body == null ? null : body.get("quote");
|
||||
Map<String, Object> quote = raw instanceof Map ? (Map<String, Object>) raw : new LinkedHashMap<>();
|
||||
Long n = quoteMapper.selectCount(new LambdaQueryWrapper<InquiryQuote>()
|
||||
.eq(InquiryQuote::getWorkOrderNo, no));
|
||||
int ver = n == null ? 1 : n.intValue() + 1;
|
||||
InquiryQuote row = new InquiryQuote();
|
||||
row.setTicketId(ticket.getId());
|
||||
row.setWorkOrderNo(no);
|
||||
row.setQuoteVersion(ver);
|
||||
row.setSource("PRODUCT");
|
||||
row.setSourceLabel(String.valueOf(quote.getOrDefault("source_label", "产品报价")));
|
||||
row.setPriceText(String.valueOf(quote.getOrDefault("total", "")));
|
||||
row.setHighlighted(1);
|
||||
row.setPayloadJson(JSON.toJSONString(quote));
|
||||
row.setCreateTime(new Date());
|
||||
quoteMapper.insert(row);
|
||||
Object total = quote.get("total");
|
||||
Object ccy = quote.get("currency");
|
||||
if (total != null && StringUtils.isNotBlank(String.valueOf(total))) {
|
||||
String sale = (ccy == null ? "" : String.valueOf(ccy) + " ") + total;
|
||||
ticket.setSalePrice(sale.trim());
|
||||
ticket.setUpdateTime(new Date());
|
||||
updateById(ticket);
|
||||
}
|
||||
String to = text(body, "toStatus", "to_status");
|
||||
if (StringUtils.isBlank(to)) {
|
||||
to = TicketStatuses.QUOTED;
|
||||
}
|
||||
if (TicketStatuses.canTransit(ticket.getStatus(), to)) {
|
||||
TicketTransitionRequest tr = new TicketTransitionRequest();
|
||||
tr.setWorkOrderNo(no);
|
||||
tr.setToStatus(to);
|
||||
transition(tr, "agent");
|
||||
}
|
||||
out.put("ok", true);
|
||||
out.put("quote_version", ver);
|
||||
out.put("status", ticket.getStatus());
|
||||
return out;
|
||||
}
|
||||
|
||||
private static Map<String, String> parseFactsJson(String raw) {
|
||||
Map<String, String> facts = new LinkedHashMap<>();
|
||||
if (StringUtils.isBlank(raw)) {
|
||||
return facts;
|
||||
}
|
||||
JSONObject parsed = JSON.parseObject(raw);
|
||||
if (parsed == null) {
|
||||
return facts;
|
||||
}
|
||||
for (String key : parsed.keySet()) {
|
||||
if (parsed.get(key) != null) {
|
||||
facts.put(key, String.valueOf(parsed.get(key)));
|
||||
}
|
||||
}
|
||||
return facts;
|
||||
}
|
||||
|
||||
private Map<String, Object> latestQuotePayload(String workOrderNo) {
|
||||
Map<String, Object> quote = new LinkedHashMap<>();
|
||||
List<InquiryQuote> quotes = quoteMapper.selectList(new LambdaQueryWrapper<InquiryQuote>()
|
||||
.eq(InquiryQuote::getWorkOrderNo, workOrderNo)
|
||||
.orderByDesc(InquiryQuote::getCreateTime)
|
||||
.last("LIMIT 1"));
|
||||
if (quotes != null && !quotes.isEmpty() && StringUtils.isNotBlank(quotes.get(0).getPayloadJson())) {
|
||||
JSONObject parsed = JSON.parseObject(quotes.get(0).getPayloadJson());
|
||||
if (parsed != null) {
|
||||
quote.putAll(parsed);
|
||||
}
|
||||
}
|
||||
return quote;
|
||||
}
|
||||
|
||||
private static String memberIdsJson(Map<String, Object> body) {
|
||||
if (body == null) {
|
||||
return "[]";
|
||||
|
||||
+627
-109
@@ -32,6 +32,45 @@ public final class QuoteTemplateExcelSupport {
|
||||
"FFFFCC00", "FFFFD966", "FFFFF2CC", "FFFFE699"
|
||||
));
|
||||
|
||||
/**
|
||||
* 按标签找格子时用的词典。与合同 labelDictionary 对齐,并补上海运询价单上的「费用总计 / RFQ No」。
|
||||
* 只用于填表兜底,不发明价格。
|
||||
*/
|
||||
private static final Map<String, String> FILL_BY_LABEL = new LinkedHashMap<>();
|
||||
|
||||
static {
|
||||
FILL_BY_LABEL.put("询价日期", "quote_date");
|
||||
FILL_BY_LABEL.put("报价日期", "quote_date");
|
||||
FILL_BY_LABEL.put("品名", "commodity");
|
||||
FILL_BY_LABEL.put("货物数量", "quantity");
|
||||
FILL_BY_LABEL.put("整柜或拼柜", "fcl_or_lcl");
|
||||
FILL_BY_LABEL.put("箱型箱量", "container_type_qty");
|
||||
FILL_BY_LABEL.put("起运港", "pol");
|
||||
FILL_BY_LABEL.put("目的港", "pod");
|
||||
FILL_BY_LABEL.put("报价有效期", "valid_until");
|
||||
FILL_BY_LABEL.put("有效期至", "valid_until");
|
||||
FILL_BY_LABEL.put("费用总计", "total_amount");
|
||||
FILL_BY_LABEL.put("报价合计", "total_amount");
|
||||
FILL_BY_LABEL.put("RFQ No", "quote_no");
|
||||
FILL_BY_LABEL.put("报价单编号", "quote_no");
|
||||
FILL_BY_LABEL.put("报价编号", "quote_no");
|
||||
FILL_BY_LABEL.put("贸易条款", "trade_terms");
|
||||
FILL_BY_LABEL.put("货好时间", "cargo_ready_date");
|
||||
FILL_BY_LABEL.put("商品海关编码", "hs_code");
|
||||
FILL_BY_LABEL.put("HS编码", "hs_code");
|
||||
FILL_BY_LABEL.put("海关编码", "hs_code");
|
||||
FILL_BY_LABEL.put("是否含油,电,磁", "oil_elec_mag");
|
||||
FILL_BY_LABEL.put("件数", "pieces");
|
||||
FILL_BY_LABEL.put("重量", "weight_kg");
|
||||
FILL_BY_LABEL.put("体积", "volume_cbm");
|
||||
FILL_BY_LABEL.put("始发地", "origin");
|
||||
FILL_BY_LABEL.put("目的地", "destination");
|
||||
FILL_BY_LABEL.put("币别", "currency");
|
||||
FILL_BY_LABEL.put("业务员", "sales_name");
|
||||
FILL_BY_LABEL.put("客户名称", "customer_name");
|
||||
FILL_BY_LABEL.put("联系人", "contact_name");
|
||||
}
|
||||
|
||||
private QuoteTemplateExcelSupport() {
|
||||
}
|
||||
|
||||
@@ -188,6 +227,8 @@ public final class QuoteTemplateExcelSupport {
|
||||
writeCell(wb, cellRef, String.valueOf(val));
|
||||
}
|
||||
}
|
||||
// 海运询价单映射常只对上 4 个点,且起运港映进了标签合并格。按标签再写一遍正确格子。
|
||||
writeFactsByLabel(wb, facts, yellow);
|
||||
writeFeeRows(wb, mapping, facts, yellow);
|
||||
// 后台常未配 fee_rows:按「空运费」等名称写入金额列,SUM 才能加算
|
||||
writeFeeAmountsByName(wb, facts, yellow);
|
||||
@@ -235,7 +276,7 @@ public final class QuoteTemplateExcelSupport {
|
||||
writeFeeCol(sheet, r, columns.getString("currency"), rowObj.getString("currency"), yellow, sheet.getSheetName());
|
||||
}
|
||||
if (columns.containsKey("remark")) {
|
||||
writeFeeCol(sheet, r, columns.getString("remark"), rowObj.getString("remark"), yellow, sheet.getSheetName());
|
||||
writeFeeCol(sheet, r, columns.getString("remark"), formatFeeRemark(rowObj.getString("remark")), yellow, sheet.getSheetName());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -258,6 +299,8 @@ public final class QuoteTemplateExcelSupport {
|
||||
cell = row.createCell(col);
|
||||
}
|
||||
writeNumericOrText(cell, value);
|
||||
// 映射表写出的备注同样要换行,否则只看得见半截
|
||||
applyRemarkWrap(sheet, row, cell, value);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -302,9 +345,21 @@ public final class QuoteTemplateExcelSupport {
|
||||
if (cell == null || cell.getCellType() != CellType.FORMULA) {
|
||||
continue;
|
||||
}
|
||||
String formula = cell.getCellFormula();
|
||||
try {
|
||||
ev.evaluateFormulaCell(cell);
|
||||
CellType result = ev.evaluateFormulaCell(cell);
|
||||
// 空的 D*G 会算出错误,海运未用费目行不要留下一个「=」
|
||||
if (result == CellType.ERROR && StringUtils.isNotBlank(formula)) {
|
||||
cell.setCellFormula(formula);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
try {
|
||||
if (StringUtils.isNotBlank(formula)) {
|
||||
cell.setCellFormula(formula);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 还原失败不挡出单
|
||||
}
|
||||
log.warn("报价公式未重算 {}!{}: {}",
|
||||
sheet.getSheetName(),
|
||||
new CellReference(cell).formatAsString(),
|
||||
@@ -321,7 +376,11 @@ public final class QuoteTemplateExcelSupport {
|
||||
|
||||
/**
|
||||
* 按费用名称把 TMS 费用行写进模板:单位 / 币别 / 单价 / 数量 / 金额 / 备注。
|
||||
* 映射没有 fee_rows 时也能对上「空运费」等已有行。没有的字段不编、不覆盖模板原字。
|
||||
* 映射没有 fee_rows 时也能对上「空运费」等已有行。
|
||||
* <p>
|
||||
* 海运询价单有三段费用表,且「海运费用」先出现在整行标题里。
|
||||
* 必须跳过标题行,列映射取费目上方最近的表头,对不上的费目追加到空行。
|
||||
* 禁止把数量写进「货物数量」标签列,否则会把费目名盖成 1。
|
||||
*/
|
||||
public static void writeFeeAmountsByName(Workbook wb, JSONObject facts, Set<String> yellow) {
|
||||
JSONArray rows = parseFeeRows(facts == null ? null : facts.get("fee_rows"));
|
||||
@@ -329,16 +388,11 @@ public final class QuoteTemplateExcelSupport {
|
||||
return;
|
||||
}
|
||||
String headerCurrency = facts == null ? "" : StringUtils.trimToEmpty(facts.getString("currency"));
|
||||
String boxType = firstFact(facts, "container_type_qty", "箱型箱量");
|
||||
for (int s = 0; s < wb.getNumberOfSheets(); s++) {
|
||||
Sheet sheet = wb.getSheetAt(s);
|
||||
Map<String, Integer> cols = findFeeTableColumns(sheet);
|
||||
if (cols.isEmpty()) {
|
||||
cols = fallbackFeeColumns(sheet);
|
||||
}
|
||||
Integer amountCol = cols.get("amount");
|
||||
if (amountCol == null) {
|
||||
amountCol = 6;
|
||||
}
|
||||
Set<Integer> usedRows = new HashSet<>();
|
||||
List<JSONObject> unmatched = new ArrayList<>();
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
JSONObject rowObj = rows.getJSONObject(i);
|
||||
if (rowObj == null) {
|
||||
@@ -349,11 +403,51 @@ public final class QuoteTemplateExcelSupport {
|
||||
if (StringUtils.isBlank(name) || StringUtils.isBlank(amount) || "-".equals(amount)) {
|
||||
continue;
|
||||
}
|
||||
Cell nameCell = findCellByFeeName(sheet, name);
|
||||
Cell nameCell = findCellByFeeName(sheet, name, usedRows);
|
||||
if (nameCell == null) {
|
||||
unmatched.add(rowObj);
|
||||
continue;
|
||||
}
|
||||
int r = nameCell.getRowIndex();
|
||||
usedRows.add(r);
|
||||
Map<String, Integer> cols = findFeeTableColumnsAbove(sheet, r);
|
||||
writeFeeDetailRow(sheet, r, cols, rowObj, headerCurrency, boxType, yellow, false);
|
||||
}
|
||||
for (JSONObject rowObj : unmatched) {
|
||||
int empty = findEmptyFeeDataRow(sheet, usedRows);
|
||||
if (empty < 0) {
|
||||
log.warn("报价单没有空行可追加费用 {}", rowObj.getString("name"));
|
||||
continue;
|
||||
}
|
||||
usedRows.add(empty);
|
||||
Map<String, Integer> cols = findFeeTableColumnsAbove(sheet, empty);
|
||||
writeFeeDetailRow(sheet, empty, cols, rowObj, headerCurrency, boxType, yellow, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写一行费用。writeName=false 时不碰已有费目名,避免把「文件费/Doc.」盖成数量 1。
|
||||
*/
|
||||
private static void writeFeeDetailRow(Sheet sheet, int r, Map<String, Integer> cols, JSONObject rowObj,
|
||||
String headerCurrency, String boxType, Set<String> yellow, boolean writeName) {
|
||||
if (cols == null || cols.isEmpty()) {
|
||||
cols = fallbackFeeColumns(sheet);
|
||||
}
|
||||
Integer amountCol = cols.get("amount");
|
||||
if (amountCol == null) {
|
||||
amountCol = 7;
|
||||
}
|
||||
Integer nameCol = cols.get("name");
|
||||
Integer qtyCol = cols.get("quantity");
|
||||
// 数量列若和费目名同列,多半是误把「货物数量」当成了费用数量
|
||||
if (qtyCol != null && nameCol != null && qtyCol.equals(nameCol)) {
|
||||
qtyCol = null;
|
||||
}
|
||||
if (writeName) {
|
||||
writeMappedText(sheet, r, nameCol == null ? 0 : nameCol, rowObj.getString("name"), yellow);
|
||||
}
|
||||
String amount = StringUtils.trimToEmpty(rowObj.getString("amount"));
|
||||
writeAmountCell(sheet, r, amountCol, amount, yellow);
|
||||
String lineCurrency = StringUtils.trimToEmpty(rowObj.getString("currency"));
|
||||
if (StringUtils.isBlank(lineCurrency)) {
|
||||
@@ -361,56 +455,82 @@ public final class QuoteTemplateExcelSupport {
|
||||
}
|
||||
writeMappedText(sheet, r, cols.get("currency"), lineCurrency, yellow);
|
||||
writeMappedText(sheet, r, cols.get("unit"), rowObj.getString("unit"), yellow);
|
||||
writeMappedNumber(sheet, r, cols.get("quantity"), rowObj.getString("quantity"), yellow);
|
||||
writeMappedNumber(sheet, r, qtyCol, rowObj.getString("quantity"), yellow);
|
||||
writeMappedNumber(sheet, r, cols.get("unit_price"), rowObj.getString("unit_price"), yellow);
|
||||
writeMappedText(sheet, r, cols.get("remark"), rowObj.getString("remark"), yellow);
|
||||
}
|
||||
writeMappedText(sheet, r, cols.get("remark"), formatFeeRemark(rowObj.getString("remark")), yellow);
|
||||
Integer boxCol = pickContainerRateColumn(cols, boxType);
|
||||
if (boxCol != null && !boxCol.equals(amountCol)) {
|
||||
String rate = firstNonBlank(rowObj.getString("unit_price"), amount);
|
||||
writeMappedNumber(sheet, r, boxCol, rate, yellow);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 只认「费用名称 + 金额」那一行表头,避免把货物信息里的件/KG 当成费用列。
|
||||
* 只认「费用名称/项目 + 金额」那一行表头,避免把货物信息里的件/KG 当成费用列。
|
||||
* 海运询价单表头是「项目/Items」不是「费用名称」。
|
||||
* 币别和数量中间的空列或 CNY 列,按现网空运模板当成单价。
|
||||
*/
|
||||
static Map<String, Integer> findFeeTableColumns(Sheet sheet) {
|
||||
Map<String, Integer> cols = new LinkedHashMap<>();
|
||||
if (sheet == null) {
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
int maxRow = Math.min(sheet.getLastRowNum(), 80);
|
||||
for (int r = 0; r <= maxRow; r++) {
|
||||
Map<String, Integer> cols = parseFeeHeaderRow(sheet, r);
|
||||
if (!cols.isEmpty()) {
|
||||
return cols;
|
||||
}
|
||||
int headerRow = -1;
|
||||
int lastCol = 0;
|
||||
int maxRow = Math.min(sheet.getLastRowNum(), 40);
|
||||
for (int r = 0; r <= maxRow; r++) {
|
||||
}
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从费目行往上找最近的费用表头。海运有始发/海运/目的三段,列不能都用第一张表。
|
||||
*/
|
||||
static Map<String, Integer> findFeeTableColumnsAbove(Sheet sheet, int dataRow) {
|
||||
if (sheet == null) {
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
for (int r = Math.max(dataRow - 1, 0); r >= 0; r--) {
|
||||
Map<String, Integer> cols = parseFeeHeaderRow(sheet, r);
|
||||
if (!cols.isEmpty()) {
|
||||
return cols;
|
||||
}
|
||||
}
|
||||
Map<String, Integer> first = findFeeTableColumns(sheet);
|
||||
return first.isEmpty() ? fallbackFeeColumns(sheet) : first;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析一行是否为费用表头,并记下各列。
|
||||
*/
|
||||
static Map<String, Integer> parseFeeHeaderRow(Sheet sheet, int r) {
|
||||
Map<String, Integer> cols = new LinkedHashMap<>();
|
||||
Row row = sheet.getRow(r);
|
||||
if (row == null) {
|
||||
continue;
|
||||
return cols;
|
||||
}
|
||||
boolean hasAmount = false;
|
||||
boolean hasFeeName = false;
|
||||
short last = row.getLastCellNum();
|
||||
for (int c = 0; c < last; c++) {
|
||||
String text = readCellText(row.getCell(c));
|
||||
if (text.contains("金额") || text.contains("金額") || "amount".equalsIgnoreCase(text)) {
|
||||
if (isAmountHeader(text)) {
|
||||
hasAmount = true;
|
||||
}
|
||||
if (text.contains("费用名称") || text.contains("费用名")) {
|
||||
if (isFeeNameHeader(text)) {
|
||||
hasFeeName = true;
|
||||
}
|
||||
}
|
||||
if (hasAmount && hasFeeName) {
|
||||
headerRow = r;
|
||||
lastCol = last;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (headerRow < 0) {
|
||||
if (!hasAmount || !hasFeeName) {
|
||||
return cols;
|
||||
}
|
||||
Row row = sheet.getRow(headerRow);
|
||||
for (int c = 0; c < lastCol; c++) {
|
||||
for (int c = 0; c < last; c++) {
|
||||
String text = StringUtils.trimToEmpty(readCellText(row.getCell(c)));
|
||||
if (text.contains("费用名称") || text.contains("费用名")) {
|
||||
if (isFeeNameHeader(text)) {
|
||||
cols.put("name", c);
|
||||
} else if (isContainerTypeHeader(text)) {
|
||||
cols.put(normalizeContainerType(text), c);
|
||||
} else if (text.contains("单位") || text.contains("單位")) {
|
||||
cols.put("unit", c);
|
||||
} else if (text.contains("币别") || text.contains("币種") || text.contains("币种")) {
|
||||
@@ -419,7 +539,7 @@ public final class QuoteTemplateExcelSupport {
|
||||
cols.put("unit_price", c);
|
||||
} else if (text.contains("数量") || text.contains("數量")) {
|
||||
cols.put("quantity", c);
|
||||
} else if (text.contains("金额") || text.contains("金額") || "amount".equalsIgnoreCase(text)) {
|
||||
} else if (isAmountHeader(text)) {
|
||||
cols.put("amount", c);
|
||||
} else if (text.contains("备注") || text.contains("備註")) {
|
||||
cols.put("remark", c);
|
||||
@@ -431,29 +551,25 @@ public final class QuoteTemplateExcelSupport {
|
||||
cols.put("unit_price", c);
|
||||
}
|
||||
}
|
||||
if (!cols.containsKey("name")) {
|
||||
cols.put("name", 0);
|
||||
}
|
||||
return cols;
|
||||
}
|
||||
|
||||
/**
|
||||
* 只从费用表头行取列。禁止扫到「货物数量」把数量列认成 A。
|
||||
*/
|
||||
private static Map<String, Integer> fallbackFeeColumns(Sheet sheet) {
|
||||
Map<String, Integer> found = findFeeTableColumns(sheet);
|
||||
if (!found.isEmpty()) {
|
||||
return found;
|
||||
}
|
||||
Map<String, Integer> cols = new LinkedHashMap<>();
|
||||
putCol(cols, "amount", findHeaderColumn(sheet, Arrays.asList("金额", "金額", "amount")));
|
||||
putCol(cols, "currency", findHeaderColumn(sheet, Arrays.asList("币别", "币種", "币种")));
|
||||
putCol(cols, "unit", findHeaderColumn(sheet, Arrays.asList("单位", "單位")));
|
||||
putCol(cols, "quantity", findHeaderColumn(sheet, Arrays.asList("数量", "數量")));
|
||||
putCol(cols, "unit_price", findHeaderColumn(sheet, Arrays.asList("单价", "單價")));
|
||||
putCol(cols, "remark", findHeaderColumn(sheet, Arrays.asList("备注", "備註")));
|
||||
if (!cols.containsKey("amount")) {
|
||||
cols.put("amount", 6);
|
||||
}
|
||||
return cols;
|
||||
}
|
||||
|
||||
private static void putCol(Map<String, Integer> cols, String key, int col) {
|
||||
if (col >= 0) {
|
||||
cols.put(key, col);
|
||||
}
|
||||
}
|
||||
|
||||
private static void writeMappedText(Sheet sheet, int rowIdx, Integer col, String value, Set<String> yellow) {
|
||||
if (col == null || StringUtils.isBlank(value) || "-".equals(value.trim())) {
|
||||
return;
|
||||
@@ -502,35 +618,17 @@ public final class QuoteTemplateExcelSupport {
|
||||
return new JSONArray();
|
||||
}
|
||||
|
||||
private static int findHeaderColumn(Sheet sheet, List<String> titles) {
|
||||
int maxRow = Math.min(sheet.getLastRowNum(), 40);
|
||||
for (int r = 0; r <= maxRow; r++) {
|
||||
Row row = sheet.getRow(r);
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
short last = row.getLastCellNum();
|
||||
for (int c = 0; c < last; c++) {
|
||||
Cell cell = row.getCell(c);
|
||||
String text = readCellText(cell);
|
||||
if (StringUtils.isBlank(text)) {
|
||||
continue;
|
||||
}
|
||||
for (String t : titles) {
|
||||
if (text.contains(t)) {
|
||||
return c;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static Cell findCellByFeeName(Sheet sheet, String name) {
|
||||
/**
|
||||
* 按费目名找数据行。跳过整行标题(「海运费用」先出现在 A29 合并标题里)。
|
||||
* 只认「海运费↔海运费用」这种加「用/费用」的宽松匹配,不对「操作费」误中「码头操作费」。
|
||||
*/
|
||||
private static Cell findCellByFeeName(Sheet sheet, String name, Set<Integer> usedRows) {
|
||||
String want = name.trim();
|
||||
Cell fuzzy = null;
|
||||
int maxRow = Math.min(sheet.getLastRowNum(), 80);
|
||||
for (int r = 0; r <= maxRow; r++) {
|
||||
if (usedRows != null && usedRows.contains(r)) {
|
||||
continue;
|
||||
}
|
||||
Row row = sheet.getRow(r);
|
||||
if (row == null) {
|
||||
continue;
|
||||
@@ -539,35 +637,39 @@ public final class QuoteTemplateExcelSupport {
|
||||
for (int c = 0; c < last; c++) {
|
||||
Cell cell = row.getCell(c);
|
||||
String text = readCellText(cell);
|
||||
if (StringUtils.isBlank(text)) {
|
||||
if (StringUtils.isBlank(text) || isFeeTableHeader(text) || isSectionTitle(sheet, cell)) {
|
||||
continue;
|
||||
}
|
||||
if (want.equals(text.trim())) {
|
||||
if (feeNameMatches(text, want)) {
|
||||
return cell;
|
||||
}
|
||||
if (fuzzy == null && text.contains(want) && want.length() >= 2) {
|
||||
fuzzy = cell;
|
||||
}
|
||||
}
|
||||
}
|
||||
return fuzzy;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 费用明细表头原字,回填系统字段时不得覆盖。
|
||||
* 海运表头带英文「项目/Items」「金额/Ammount」,以及 20GP 箱型列。
|
||||
*/
|
||||
static boolean isFeeTableHeader(String text) {
|
||||
if (StringUtils.isBlank(text)) {
|
||||
return false;
|
||||
}
|
||||
String t = text.trim();
|
||||
return "项目".equals(t) || "费用名称".equals(t) || "费用名".equals(t)
|
||||
|| "单位".equals(t) || "單位".equals(t)
|
||||
|| "币别".equals(t) || "币種".equals(t) || "币种".equals(t)
|
||||
|| "单价".equals(t) || "單價".equals(t)
|
||||
|| "数量".equals(t) || "數量".equals(t)
|
||||
|| "金额".equals(t) || "金額".equals(t)
|
||||
|| "备注".equals(t) || "備註".equals(t);
|
||||
if (isContainerTypeHeader(text)) {
|
||||
return true;
|
||||
}
|
||||
String primary = feePrimaryName(text);
|
||||
return "项目".equals(primary) || "费用名称".equals(primary) || "费用名".equals(primary)
|
||||
|| "单位".equals(primary) || "單位".equals(primary)
|
||||
|| "币别".equals(primary) || "币種".equals(primary) || "币种".equals(primary)
|
||||
|| "单价".equals(primary) || "單價".equals(primary)
|
||||
|| "数量".equals(primary) || "數量".equals(primary)
|
||||
|| "金额".equals(primary) || "金額".equals(primary)
|
||||
|| "备注".equals(primary) || "備註".equals(primary)
|
||||
|| "Items".equalsIgnoreCase(primary) || "Cur.".equalsIgnoreCase(primary)
|
||||
|| "Unit".equalsIgnoreCase(primary) || "Qty.".equalsIgnoreCase(primary)
|
||||
|| "Ammount".equalsIgnoreCase(primary) || "Amount".equalsIgnoreCase(primary);
|
||||
}
|
||||
|
||||
private static String readCellText(Cell cell) {
|
||||
@@ -589,8 +691,17 @@ public final class QuoteTemplateExcelSupport {
|
||||
row = sheet.createRow(dest[0]);
|
||||
}
|
||||
Cell cell = row.getCell(dest[1]);
|
||||
CellStyle style = cell == null ? null : cell.getCellStyle();
|
||||
// 海运模板金额格是 =D31*G31。只改缓存的话,后面 refreshFormulas 会按空的 40HQ 列算回 0。
|
||||
if (cell != null && cell.getCellType() == CellType.FORMULA) {
|
||||
row.removeCell(cell);
|
||||
cell = null;
|
||||
}
|
||||
if (cell == null) {
|
||||
cell = row.createCell(dest[1]);
|
||||
if (style != null) {
|
||||
cell.setCellStyle(style);
|
||||
}
|
||||
}
|
||||
String norm = stripMoneyPrefix(amount);
|
||||
try {
|
||||
@@ -628,7 +739,93 @@ public final class QuoteTemplateExcelSupport {
|
||||
if (cell == null) {
|
||||
cell = row.createCell(dest[1]);
|
||||
}
|
||||
cell.setCellValue(value == null ? "" : value);
|
||||
String text = value == null ? "" : value;
|
||||
cell.setCellValue(text);
|
||||
applyRemarkWrap(sheet, row, cell, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* 海运备注常是「计价单位:柜;加点系数:0.0000000」。
|
||||
* 模板备注列很窄(约 13 个英文字宽),整句挤一行只能看见半截;
|
||||
* 按分号拆成两行,销售一眼能看完两段。
|
||||
*/
|
||||
static String formatFeeRemark(String raw) {
|
||||
if (StringUtils.isBlank(raw)) {
|
||||
return raw;
|
||||
}
|
||||
String text = raw.trim();
|
||||
if (!text.contains("计价") && !text.contains("加点") && !text.contains("计费")) {
|
||||
return text;
|
||||
}
|
||||
return text.replace(';', '\n').replace(';', '\n').replaceAll("\n+", "\n").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 备注打开换行、顶对齐,并按最长一行把列加宽、按行数加高。
|
||||
* 只加宽备注这类长句所在列,不改其它列,避免整页打印缩放被带跑。
|
||||
*/
|
||||
static void applyRemarkWrap(Sheet sheet, Row row, Cell cell, String text) {
|
||||
if (sheet == null || row == null || cell == null || StringUtils.isBlank(text) || text.length() < 10) {
|
||||
return;
|
||||
}
|
||||
String shown = formatFeeRemark(text);
|
||||
if (!shown.equals(text)) {
|
||||
cell.setCellValue(shown);
|
||||
text = shown;
|
||||
}
|
||||
CellStyle copy = sheet.getWorkbook().createCellStyle();
|
||||
if (cell.getCellStyle() != null) {
|
||||
copy.cloneStyleFrom(cell.getCellStyle());
|
||||
}
|
||||
copy.setWrapText(true);
|
||||
copy.setShrinkToFit(false);
|
||||
copy.setVerticalAlignment(VerticalAlignment.TOP);
|
||||
cell.setCellStyle(copy);
|
||||
boolean feeRemark = text.contains("\n") || text.contains("计价") || text.contains("加点") || text.contains("计费");
|
||||
if (feeRemark) {
|
||||
widenRemarkColumn(sheet, cell.getColumnIndex(), text);
|
||||
}
|
||||
int lines = countWrappedLines(sheet, cell.getColumnIndex(), text);
|
||||
float need = Math.max(row.getHeightInPoints(), Math.min(16f * lines + 6f, 80f));
|
||||
if (row.getHeightInPoints() < need) {
|
||||
row.setHeightInPoints(need);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按最长一行估算列宽。中文按 2 个英文字宽,上限 26,避免把整表撑破。
|
||||
*/
|
||||
static void widenRemarkColumn(Sheet sheet, int col, String text) {
|
||||
if (sheet == null || col < 0 || StringUtils.isBlank(text)) {
|
||||
return;
|
||||
}
|
||||
int longest = 0;
|
||||
for (String part : text.split("\\r?\\n")) {
|
||||
longest = Math.max(longest, displayWidth(part));
|
||||
}
|
||||
int want = Math.min(Math.max(longest + 2, 18), 26);
|
||||
int current = sheet.getColumnWidth(col) / 256;
|
||||
if (current < want) {
|
||||
sheet.setColumnWidth(col, want * 256);
|
||||
}
|
||||
}
|
||||
|
||||
private static int countWrappedLines(Sheet sheet, int col, String text) {
|
||||
int charsPerLine = Math.max(sheet.getColumnWidth(col) / 256, 8);
|
||||
int lines = 0;
|
||||
for (String part : text.split("\\r?\\n", -1)) {
|
||||
int width = Math.max(displayWidth(part), 1);
|
||||
lines += Math.max(1, (width + charsPerLine - 1) / charsPerLine);
|
||||
}
|
||||
return Math.max(lines, 1);
|
||||
}
|
||||
|
||||
private static int displayWidth(String text) {
|
||||
int n = 0;
|
||||
for (int i = 0; i < text.length(); i++) {
|
||||
n += text.charAt(i) > 127 ? 2 : 1;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
private static int[] resolveMergeOrigin(Sheet sheet, int row, int col) {
|
||||
@@ -662,13 +859,17 @@ public final class QuoteTemplateExcelSupport {
|
||||
String text = value == null ? "" : value;
|
||||
// 标签格「报价编号:」只换冒号后的值,避免整格被工单号顶掉看起来像残缺
|
||||
String existing = readCellText(cell);
|
||||
// 「币别」右侧常被误映到「单价」表头,不能把 CNY 盖掉单价二字
|
||||
if (isFeeTableHeader(existing)) {
|
||||
// 映射点常落在合并标签格 / 20GP 表头上,不能把原字盖掉
|
||||
if (isFeeTableHeader(existing) || isLabelLike(existing) || isSectionTitle(sheet, cell)) {
|
||||
return;
|
||||
}
|
||||
int colon = Math.max(existing.indexOf(':'), existing.indexOf(':'));
|
||||
if (colon >= 0 && colon == existing.length() - 1) {
|
||||
text = existing + text;
|
||||
if (colon >= 0) {
|
||||
String prefix = existing.substring(0, colon + 1);
|
||||
if (colon == existing.length() - 1 || prefix.toUpperCase(Locale.ROOT).contains("RFQ")
|
||||
|| prefix.contains("编号")) {
|
||||
text = prefix + text;
|
||||
}
|
||||
}
|
||||
cell.setCellValue(text);
|
||||
CellStyle style = cell.getCellStyle();
|
||||
@@ -756,39 +957,137 @@ public final class QuoteTemplateExcelSupport {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 标签右侧的可填格。海运询价单标签常是 E4:G4 合并,值在 H4:J4;
|
||||
* 不能再取合并区内的 F4,否则会把「起运港」四字盖掉。
|
||||
* 表头「币别」右侧是 20GP,对不上就空着,不要映到箱型列。
|
||||
*/
|
||||
private static String resolveFillTarget(Sheet sheet, Cell labelCell, String sheetName, String text, DataFormatter fmt) {
|
||||
// 同一格「标签:值」
|
||||
int idx = Math.max(text.indexOf(':'), text.indexOf(':'));
|
||||
if (idx >= 0 && idx < text.length() - 1 && StringUtils.isNotBlank(text.substring(idx + 1).trim())) {
|
||||
// 值已在标签格内:映射到本格(生成时会整格重写为纯值,运营需知悉)
|
||||
// RFQ No:样例 / 报价编号:WO 这类:值在本格冒号后
|
||||
return sheetName + "!" + new CellReference(labelCell).formatAsString();
|
||||
}
|
||||
// 右侧相邻非黄空格或任意右侧格
|
||||
int rowIdx = labelCell.getRowIndex();
|
||||
int startCol = labelCell.getColumnIndex() + 1;
|
||||
for (int i = 0; i < sheet.getNumMergedRegions(); i++) {
|
||||
org.apache.poi.ss.util.CellRangeAddress range = sheet.getMergedRegion(i);
|
||||
if (range.isInRange(rowIdx, labelCell.getColumnIndex())) {
|
||||
startCol = range.getLastColumn() + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Row row = labelCell.getRow();
|
||||
int nextCol = labelCell.getColumnIndex() + 1;
|
||||
Cell right = row.getCell(nextCol);
|
||||
if (right == null) {
|
||||
right = row.createCell(nextCol);
|
||||
if (row == null) {
|
||||
return null;
|
||||
}
|
||||
String firstEmpty = null;
|
||||
String wideEmpty = null;
|
||||
int last = Math.max(row.getLastCellNum(), startCol + 10);
|
||||
for (int c = startCol; c <= last; c++) {
|
||||
int[] dest = resolveMergeOrigin(sheet, rowIdx, c);
|
||||
Cell cand = row.getCell(dest[1]);
|
||||
String exist = readCellText(cand);
|
||||
if (isFeeTableHeader(exist) || isLabelLike(exist) || isSectionTitle(sheet, cand)) {
|
||||
continue;
|
||||
}
|
||||
String ref = sheetName + "!" + new CellReference(dest[0], dest[1]).formatAsString();
|
||||
if (StringUtils.isNotBlank(exist)) {
|
||||
// 映射已经写过本字段,不要再跨到右侧「航程时间」那一格
|
||||
if (firstEmpty == null && wideEmpty == null) {
|
||||
return ref;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (firstEmpty == null) {
|
||||
firstEmpty = ref;
|
||||
}
|
||||
if (wideEmpty == null && mergeWidth(sheet, dest[0], dest[1]) >= 2) {
|
||||
wideEmpty = ref;
|
||||
}
|
||||
}
|
||||
return wideEmpty != null ? wideEmpty : firstEmpty;
|
||||
}
|
||||
|
||||
/**
|
||||
* 映射点不够时,按标签把询价七项 / 合计写到标签右侧空格。
|
||||
* 币别若落在费用表头行则跳过,避免 CNY 盖掉 20GP。
|
||||
*/
|
||||
static void writeFactsByLabel(Workbook wb, JSONObject facts, Set<String> yellow) {
|
||||
if (wb == null || facts == null) {
|
||||
return;
|
||||
}
|
||||
DataFormatter fmt = new DataFormatter();
|
||||
List<String[]> pending = new ArrayList<>();
|
||||
for (int s = 0; s < wb.getNumberOfSheets(); s++) {
|
||||
Sheet sheet = wb.getSheetAt(s);
|
||||
if (sheet == null) {
|
||||
continue;
|
||||
}
|
||||
String sheetName = sheet.getSheetName();
|
||||
int lastRow = sheet.getLastRowNum();
|
||||
for (int r = 0; r <= lastRow; r++) {
|
||||
Row row = sheet.getRow(r);
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
boolean feeHeader = !parseFeeHeaderRow(sheet, r).isEmpty();
|
||||
short lastCol = row.getLastCellNum();
|
||||
for (int c = 0; c < lastCol; c++) {
|
||||
Cell cell = row.getCell(c);
|
||||
if (cell == null) {
|
||||
continue;
|
||||
}
|
||||
String text = StringUtils.trimToEmpty(fmt.formatCellValue(cell));
|
||||
if (text.isEmpty() || isSectionTitle(sheet, cell) || isFeeTableHeader(text)) {
|
||||
continue;
|
||||
}
|
||||
String code = matchLabel(text.replace(":", ":").trim(), FILL_BY_LABEL);
|
||||
if (StringUtils.isBlank(code)) {
|
||||
continue;
|
||||
}
|
||||
if ("currency".equals(code) && feeHeader) {
|
||||
continue;
|
||||
}
|
||||
String val = firstFact(facts, code, text.replace(":", "").replace(":", "").trim());
|
||||
if (StringUtils.isBlank(val) || "-".equals(val)) {
|
||||
continue;
|
||||
}
|
||||
String fillRef = resolveFillTarget(sheet, cell, sheetName, text, fmt);
|
||||
if (StringUtils.isBlank(fillRef) || (yellow != null && yellow.contains(fillRef))) {
|
||||
continue;
|
||||
}
|
||||
pending.add(new String[]{fillRef, val});
|
||||
}
|
||||
}
|
||||
}
|
||||
// 先扫完再写:POI 遍历行时 createCell 会 ConcurrentModificationException
|
||||
for (String[] one : pending) {
|
||||
writeCell(wb, one[0], one[1]);
|
||||
}
|
||||
return sheetName + "!" + new CellReference(right).formatAsString();
|
||||
}
|
||||
|
||||
private static String matchLabel(String normalized, Map<String, String> dict) {
|
||||
if (dict == null || dict.isEmpty()) {
|
||||
if (dict == null || dict.isEmpty() || StringUtils.isBlank(normalized)) {
|
||||
return null;
|
||||
}
|
||||
String key = normalized;
|
||||
String key = normalized.replace(":", ":").trim();
|
||||
if (dict.containsKey(key)) {
|
||||
return dict.get(key);
|
||||
}
|
||||
// 去冒号再试
|
||||
String noColon = key.replace(":", "").trim();
|
||||
// 「*货物数量/Quantity…」只取斜杠前中文,避免星号和英文把词典对不上
|
||||
String primary = feePrimaryName(key);
|
||||
if (dict.containsKey(primary)) {
|
||||
return dict.get(primary);
|
||||
}
|
||||
String noColon = primary.replace(":", "").trim();
|
||||
for (Map.Entry<String, String> e : dict.entrySet()) {
|
||||
String dk = e.getKey().replace(":", ":").replace(":", "").trim();
|
||||
if (dk.equalsIgnoreCase(noColon) || noColon.startsWith(dk) || dk.startsWith(noColon)) {
|
||||
if (dk.length() >= 2 && noColon.length() >= 2) {
|
||||
return e.getValue();
|
||||
if (dk.length() < 2 || noColon.length() < 2) {
|
||||
continue;
|
||||
}
|
||||
if (dk.equalsIgnoreCase(noColon) || noColon.startsWith(dk) || dk.startsWith(noColon)) {
|
||||
return e.getValue();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -837,6 +1136,225 @@ public final class QuoteTemplateExcelSupport {
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* 询价单左侧/右侧说明文字。写进去会把「起运港/POL…」盖成港口名,销售看起来像空表。
|
||||
*/
|
||||
static boolean isLabelLike(String text) {
|
||||
if (StringUtils.isBlank(text) || text.length() < 4) {
|
||||
return false;
|
||||
}
|
||||
if (isFeeTableHeader(text) || isContainerTypeHeader(text)) {
|
||||
return false;
|
||||
}
|
||||
String[] markers = {
|
||||
"起运港", "目的港", "询价日期", "品名", "货物数量", "整柜或拼柜", "箱型箱量",
|
||||
"贸易条款", "货好时间", "商品海关编码", "是否含油", "提货地址", "送货地址", "报价有效期", "费用总计",
|
||||
"始发港", "目的地", "Inquiry Date", "Commodity", "Place of Receipt"
|
||||
};
|
||||
for (String m : markers) {
|
||||
if (text.contains(m) && (text.contains("/") || text.contains(":") || text.contains("(") || text.length() >= 6)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 整行分区标题:始发港费用 / 海运费用 / 汇总。模糊匹配「海运费」时必须跳过。
|
||||
*/
|
||||
static boolean isSectionTitle(Sheet sheet, Cell cell) {
|
||||
if (cell == null) {
|
||||
return false;
|
||||
}
|
||||
String text = readCellText(cell);
|
||||
if (StringUtils.isBlank(text)) {
|
||||
return false;
|
||||
}
|
||||
if (text.contains("汇总") || text.contains("免用箱期") || text.contains("Origin Charges")
|
||||
|| text.contains("Destination Charges") || text.contains("Local Charges")) {
|
||||
return true;
|
||||
}
|
||||
int row = cell.getRowIndex();
|
||||
int col = cell.getColumnIndex();
|
||||
for (int i = 0; i < sheet.getNumMergedRegions(); i++) {
|
||||
org.apache.poi.ss.util.CellRangeAddress range = sheet.getMergedRegion(i);
|
||||
if (range.isInRange(row, col) && range.getLastColumn() - range.getFirstColumn() >= 6
|
||||
&& (text.contains("费用") || text.toLowerCase(Locale.ROOT).contains("freight")
|
||||
|| text.toLowerCase(Locale.ROOT).contains("charges"))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static boolean isAmountHeader(String text) {
|
||||
if (StringUtils.isBlank(text)) {
|
||||
return false;
|
||||
}
|
||||
String primary = feePrimaryName(text);
|
||||
return "金额".equals(primary) || "金額".equals(primary)
|
||||
|| "amount".equalsIgnoreCase(primary) || "ammount".equalsIgnoreCase(primary)
|
||||
|| text.contains("金额") || text.contains("金額");
|
||||
}
|
||||
|
||||
static boolean isFeeNameHeader(String text) {
|
||||
if (StringUtils.isBlank(text)) {
|
||||
return false;
|
||||
}
|
||||
String primary = feePrimaryName(text);
|
||||
return "费用名称".equals(primary) || "费用名".equals(primary) || "项目".equals(primary)
|
||||
|| text.contains("费用名称") || text.startsWith("项目/") || "项目/Items".equals(text.trim());
|
||||
}
|
||||
|
||||
static boolean isContainerTypeHeader(String text) {
|
||||
if (StringUtils.isBlank(text)) {
|
||||
return false;
|
||||
}
|
||||
String t = text.trim().toUpperCase(Locale.ROOT).replace(" ", "");
|
||||
return t.equals("20GP") || t.equals("40GP") || t.equals("40HQ") || t.equals("40HC")
|
||||
|| t.equals("45HQ") || t.equals("40NOR") || t.equals("20HQ");
|
||||
}
|
||||
|
||||
static String normalizeContainerType(String text) {
|
||||
return text == null ? "" : text.trim().toUpperCase(Locale.ROOT).replace(" ", "");
|
||||
}
|
||||
|
||||
static String feePrimaryName(String text) {
|
||||
if (text == null) {
|
||||
return "";
|
||||
}
|
||||
String t = text.trim();
|
||||
int slash = t.indexOf('/');
|
||||
if (slash < 0) {
|
||||
slash = t.indexOf('/');
|
||||
}
|
||||
if (slash > 0) {
|
||||
t = t.substring(0, slash).trim();
|
||||
}
|
||||
if (t.startsWith("*")) {
|
||||
t = t.substring(1).trim();
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
/**
|
||||
* 海运费=海运费用;文件费=文件费/Doc.。不对「操作费」误中「码头操作费」。
|
||||
*/
|
||||
static boolean feeNameMatches(String cellText, String want) {
|
||||
if (StringUtils.isBlank(cellText) || StringUtils.isBlank(want)) {
|
||||
return false;
|
||||
}
|
||||
String full = cellText.trim();
|
||||
if (full.equals(want)) {
|
||||
return true;
|
||||
}
|
||||
String primary = feePrimaryName(full);
|
||||
if (primary.equals(want)) {
|
||||
return true;
|
||||
}
|
||||
return primary.equals(want + "用") || primary.equals(want + "费用")
|
||||
|| want.equals(primary + "用") || want.equals(primary + "费用");
|
||||
}
|
||||
|
||||
static Integer pickContainerRateColumn(Map<String, Integer> cols, String boxType) {
|
||||
if (cols == null || StringUtils.isBlank(boxType)) {
|
||||
return null;
|
||||
}
|
||||
String box = boxType.toUpperCase(Locale.ROOT).replace(" ", "");
|
||||
String[] keys = {"45HQ", "40HQ", "40HC", "40GP", "20GP", "20HQ"};
|
||||
for (String key : keys) {
|
||||
if (box.contains(key) && cols.containsKey(key)) {
|
||||
return cols.get(key);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 费用表里费目名为空的数据行,用来追加 TMS 有、模板没预印的费目。
|
||||
*/
|
||||
static int findEmptyFeeDataRow(Sheet sheet, Set<Integer> usedRows) {
|
||||
if (sheet == null) {
|
||||
return -1;
|
||||
}
|
||||
int maxRow = Math.min(sheet.getLastRowNum() + 8, 80);
|
||||
List<Integer> headers = new ArrayList<>();
|
||||
for (int r = 0; r <= maxRow; r++) {
|
||||
if (!parseFeeHeaderRow(sheet, r).isEmpty()) {
|
||||
headers.add(r);
|
||||
}
|
||||
}
|
||||
for (Integer header : headers) {
|
||||
Map<String, Integer> cols = parseFeeHeaderRow(sheet, header);
|
||||
int nameCol = cols.getOrDefault("name", 0);
|
||||
for (int r = header + 1; r <= maxRow; r++) {
|
||||
if (!parseFeeHeaderRow(sheet, r).isEmpty()) {
|
||||
break;
|
||||
}
|
||||
Row row = sheet.getRow(r);
|
||||
Cell first = row == null ? null : row.getCell(nameCol);
|
||||
if (isSectionTitle(sheet, first)) {
|
||||
break;
|
||||
}
|
||||
if (usedRows != null && usedRows.contains(r)) {
|
||||
continue;
|
||||
}
|
||||
String name = readCellText(first);
|
||||
if (StringUtils.isBlank(name)) {
|
||||
return r;
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
static int mergeWidth(Sheet sheet, int row, int col) {
|
||||
for (int i = 0; i < sheet.getNumMergedRegions(); i++) {
|
||||
org.apache.poi.ss.util.CellRangeAddress range = sheet.getMergedRegion(i);
|
||||
if (range.isInRange(row, col)) {
|
||||
return range.getLastColumn() - range.getFirstColumn() + 1;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
static String firstFact(JSONObject facts, String... keys) {
|
||||
if (facts == null) {
|
||||
return "";
|
||||
}
|
||||
for (String k : keys) {
|
||||
Object v = facts.get(k);
|
||||
if (v != null && StringUtils.isNotBlank(String.valueOf(v))) {
|
||||
return String.valueOf(v).trim();
|
||||
}
|
||||
}
|
||||
// 只传了「货好时间」时,按标签词典反查 cargo_ready_date
|
||||
if (keys != null && keys.length > 0) {
|
||||
String code = keys[0];
|
||||
for (Map.Entry<String, String> e : FILL_BY_LABEL.entrySet()) {
|
||||
if (code.equals(e.getValue())) {
|
||||
Object v = facts.get(e.getKey());
|
||||
if (v != null && StringUtils.isNotBlank(String.valueOf(v))) {
|
||||
return String.valueOf(v).trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
static String firstNonBlank(String... vals) {
|
||||
if (vals == null) {
|
||||
return "";
|
||||
}
|
||||
for (String v : vals) {
|
||||
if (StringUtils.isNotBlank(v) && !"-".equals(v.trim())) {
|
||||
return v.trim();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/** sheet!A1 解析。 */
|
||||
private static final class ParsedRef {
|
||||
final String sheet;
|
||||
|
||||
+5
-1
@@ -95,7 +95,11 @@
|
||||
"重量": "weight_kg",
|
||||
"重 量": "weight_kg",
|
||||
"体积": "volume_cbm",
|
||||
"体 积": "volume_cbm"
|
||||
"体 积": "volume_cbm",
|
||||
"费用总计": "total_amount",
|
||||
"报价合计": "total_amount",
|
||||
"RFQ No": "quote_no",
|
||||
"询价单号": "quote_no"
|
||||
},
|
||||
"samplePreviewFacts": {
|
||||
"customer_name": "ABC Trading Co.",
|
||||
|
||||
+259
@@ -122,6 +122,46 @@ public class QuoteTemplateFillTest {
|
||||
assertEquals(1333.6, row.getCell(5).getNumericCellValue(), 0.01);
|
||||
assertEquals(14669.60, row.getCell(6).getNumericCellValue(), 0.01);
|
||||
assertEquals("计费重进位+1000KG", row.getCell(7).getStringCellValue());
|
||||
assertTrue(row.getCell(7).getCellStyle().getWrapText());
|
||||
assertTrue(row.getHeightInPoints() >= 28f);
|
||||
} finally {
|
||||
Files.deleteIfExists(src);
|
||||
Files.deleteIfExists(out);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void seaFeeRemarkSplitsAndWidensColumn() throws Exception {
|
||||
Path src = Files.createTempFile("quote-remark-src", ".xlsx");
|
||||
Path out = Files.createTempFile("quote-remark-out", ".xlsx");
|
||||
try (Workbook wb = new XSSFWorkbook()) {
|
||||
Sheet sheet = wb.createSheet("海运询价单");
|
||||
sheet.setColumnWidth(8, 13 * 256);
|
||||
org.apache.poi.ss.usermodel.Row head = sheet.createRow(17);
|
||||
head.createCell(0).setCellValue("项目");
|
||||
head.createCell(7).setCellValue("金额");
|
||||
head.createCell(8).setCellValue("备注");
|
||||
sheet.createRow(18).createCell(0).setCellValue("燃油附加费");
|
||||
try (java.io.OutputStream os = Files.newOutputStream(src)) {
|
||||
wb.write(os);
|
||||
}
|
||||
}
|
||||
JSONObject mapping = new JSONObject();
|
||||
mapping.put("fields", new JSONObject());
|
||||
JSONObject facts = new JSONObject(true);
|
||||
facts.put("fee_rows", QuoteTemplateExcelSupport.parseFeeRows(
|
||||
"[{\"name\":\"燃油附加费\",\"amount\":\"15\",\"remark\":\"计价单位:柜;加点系数:0.0000000\"}]"));
|
||||
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
|
||||
try (Workbook wb = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||
Sheet sheet = wb.getSheetAt(0);
|
||||
org.apache.poi.ss.usermodel.Row row = sheet.getRow(18);
|
||||
String remark = row.getCell(8).getStringCellValue();
|
||||
assertTrue(remark.contains("计价单位:柜"));
|
||||
assertTrue(remark.contains("加点系数:0.0000000"));
|
||||
assertTrue(remark.contains("\n"));
|
||||
assertTrue(row.getCell(8).getCellStyle().getWrapText());
|
||||
assertTrue(sheet.getColumnWidth(8) >= 18 * 256);
|
||||
assertTrue(row.getHeightInPoints() >= 30f);
|
||||
} finally {
|
||||
Files.deleteIfExists(src);
|
||||
Files.deleteIfExists(out);
|
||||
@@ -244,4 +284,223 @@ public class QuoteTemplateFillTest {
|
||||
Files.deleteIfExists(out);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void buildFillFactsProjectsSeaSevenFields() {
|
||||
Map<String, Object> facts = new HashMap<>();
|
||||
facts.put("起运港", "DONGGUAN");
|
||||
facts.put("目的港", "海防");
|
||||
facts.put("品名", "机器设备");
|
||||
facts.put("货量", "15件");
|
||||
facts.put("整柜或拼柜", "整柜");
|
||||
facts.put("箱型箱量", "20GP*1");
|
||||
facts.put("报价日期", "2026-09-15");
|
||||
Map<String, Object> quote = new HashMap<>();
|
||||
quote.put("total", "2287.65");
|
||||
quote.put("currency", "CNY");
|
||||
quote.put("fee_lines",
|
||||
"[{\"name\":\"海运费\",\"amount\":\"100\",\"currency\":\"USD\",\"quantity\":\"1\",\"unit\":\"柜\"},"
|
||||
+ "{\"name\":\"文件费\",\"amount\":\"450\",\"currency\":\"CNY\",\"quantity\":\"1\",\"unit\":\"票\"}]");
|
||||
JSONObject out = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl
|
||||
.buildFillFacts("WO202609150032", facts, quote, "WuJiLin");
|
||||
assertEquals("DONGGUAN", out.getString("pol"));
|
||||
assertEquals("海防", out.getString("pod"));
|
||||
assertEquals("机器设备", out.getString("commodity"));
|
||||
assertEquals("15件", out.getString("quantity"));
|
||||
assertEquals("整柜", out.getString("fcl_or_lcl"));
|
||||
assertEquals("20GP*1", out.getString("container_type_qty"));
|
||||
assertEquals("2287.65", out.getString("total_amount"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void buildFillFactsProjectsCollabFields() {
|
||||
Map<String, Object> facts = new HashMap<>();
|
||||
facts.put("起运港", "DONGGUAN");
|
||||
facts.put("货好时间", "随时可提");
|
||||
facts.put("是否含油", "否");
|
||||
facts.put("是否含电", "否");
|
||||
facts.put("HS编码", "8479");
|
||||
JSONObject out = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl
|
||||
.buildFillFacts("WO202609150041", facts, new HashMap<>(), "WuJiLin");
|
||||
assertEquals("随时可提", out.getString("cargo_ready_date"));
|
||||
assertEquals("8479", out.getString("hs_code"));
|
||||
assertTrue(out.getString("oil_elec_mag").contains("油:否"));
|
||||
assertTrue(out.getString("oil_elec_mag").contains("电:否"));
|
||||
assertEquals(2, out.getJSONArray("fee_rows").size());
|
||||
}
|
||||
|
||||
/**
|
||||
* 海运询价单:起运港标签是 E4:G4 合并,值在 H4:J4。
|
||||
* 旧映射把 pol 指到 F4,会把标签盖成港口名;费用数量还会误中「货物数量」列把费目名改成 1。
|
||||
* 出单必须:标签还在、港口/货量写在值格、TMS 金额进金额列。
|
||||
*/
|
||||
@Test
|
||||
public void seaInquiryFormKeepsLabelsAndWritesTmsFees() throws Exception {
|
||||
Path src = Files.createTempFile("quote-sea-src", ".xlsx");
|
||||
Path out = Files.createTempFile("quote-sea-out", ".xlsx");
|
||||
try (Workbook wb = new XSSFWorkbook()) {
|
||||
Sheet sheet = wb.createSheet("海运询价单");
|
||||
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(3, 3, 1, 3));
|
||||
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(3, 3, 4, 6));
|
||||
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(3, 3, 7, 9));
|
||||
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(4, 4, 1, 3));
|
||||
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(4, 4, 4, 6));
|
||||
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(4, 4, 7, 9));
|
||||
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(5, 5, 1, 3));
|
||||
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(7, 7, 1, 3));
|
||||
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(28, 28, 0, 9));
|
||||
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(43, 43, 2, 9));
|
||||
row(sheet, 3).createCell(0).setCellValue("*询价日期/Inquiry Date:");
|
||||
row(sheet, 3).createCell(4).setCellValue("起运港/POL(有指定起运港口的必填)");
|
||||
row(sheet, 4).createCell(0).setCellValue("*品名/Commodity:");
|
||||
row(sheet, 4).createCell(4).setCellValue("目的港/POD(有指定目的港口的必填)");
|
||||
row(sheet, 5).createCell(0).setCellValue("*货物数量/Quantity (或提供装箱单/Packing List)");
|
||||
row(sheet, 6).createCell(0).setCellValue("*整柜或拼柜/FCL or LCL:");
|
||||
row(sheet, 7).createCell(0).setCellValue("*箱型箱量/Type & Qty of Cont.:");
|
||||
row(sheet, 17).createCell(0).setCellValue("项目/Items");
|
||||
row(sheet, 17).createCell(1).setCellValue("币别/Cur.");
|
||||
row(sheet, 17).createCell(2).setCellValue("20GP");
|
||||
row(sheet, 17).createCell(7).setCellValue("金额/Ammount");
|
||||
row(sheet, 28).createCell(0).setCellValue("海运费用/Ocean Freight");
|
||||
org.apache.poi.ss.usermodel.Row oceanHead = row(sheet, 29);
|
||||
oceanHead.createCell(0).setCellValue("项目/Items");
|
||||
oceanHead.createCell(1).setCellValue("币别/Cur.");
|
||||
oceanHead.createCell(2).setCellValue("20GP");
|
||||
oceanHead.createCell(7).setCellValue("金额/Ammount");
|
||||
row(sheet, 30).createCell(0).setCellValue("海运费用/Ocean Freight");
|
||||
row(sheet, 30).createCell(7).setCellFormula("D31*G31");
|
||||
org.apache.poi.ss.usermodel.Row destHead = row(sheet, 34);
|
||||
destHead.createCell(0).setCellValue("项目/Items");
|
||||
destHead.createCell(1).setCellValue("币别/Cur.");
|
||||
destHead.createCell(7).setCellValue("金额/Ammount");
|
||||
row(sheet, 35).createCell(0).setCellValue("文件费/Doc.");
|
||||
row(sheet, 43).createCell(0).setCellValue("费用总计/Total Costs");
|
||||
try (java.io.OutputStream os = Files.newOutputStream(src)) {
|
||||
wb.write(os);
|
||||
}
|
||||
}
|
||||
JSONObject mapping = new JSONObject();
|
||||
JSONObject fields = new JSONObject();
|
||||
fields.put("pol", "海运询价单!F4");
|
||||
fields.put("pod", "海运询价单!F5");
|
||||
fields.put("currency", "海运询价单!C18");
|
||||
mapping.put("fields", fields);
|
||||
JSONObject facts = new JSONObject(true);
|
||||
facts.put("pol", "DONGGUAN");
|
||||
facts.put("pod", "海防");
|
||||
facts.put("commodity", "机器设备");
|
||||
facts.put("quantity", "15件");
|
||||
facts.put("fcl_or_lcl", "整柜");
|
||||
facts.put("container_type_qty", "20GP*1");
|
||||
facts.put("currency", "CNY");
|
||||
facts.put("total_amount", "2287.65");
|
||||
facts.put("fee_rows", QuoteTemplateExcelSupport.parseFeeRows(
|
||||
"[{\"name\":\"海运费\",\"amount\":\"100\",\"currency\":\"USD\",\"quantity\":\"1\",\"unit\":\"柜\"},"
|
||||
+ "{\"name\":\"文件费\",\"amount\":\"450\",\"currency\":\"CNY\",\"quantity\":\"1\",\"unit\":\"票\"},"
|
||||
+ "{\"name\":\"燃油附加费\",\"amount\":\"15\",\"currency\":\"USD\",\"quantity\":\"1\",\"unit\":\"柜\"}]"));
|
||||
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
|
||||
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||
Sheet sheet = filled.getSheetAt(0);
|
||||
assertTrue(read(sheet, 3, 4).contains("起运港"));
|
||||
assertEquals("DONGGUAN", read(sheet, 3, 7));
|
||||
assertTrue(read(sheet, 4, 4).contains("目的港"));
|
||||
assertEquals("海防", read(sheet, 4, 7));
|
||||
assertEquals("机器设备", read(sheet, 4, 1));
|
||||
assertEquals("15件", read(sheet, 5, 1));
|
||||
assertEquals("整柜", read(sheet, 6, 1));
|
||||
assertEquals("20GP*1", read(sheet, 7, 1));
|
||||
assertEquals("20GP", read(sheet, 17, 2));
|
||||
assertTrue(read(sheet, 28, 0).contains("海运费用"));
|
||||
assertTrue(read(sheet, 30, 0).contains("海运费用"));
|
||||
assertEquals(CellType.NUMERIC, sheet.getRow(30).getCell(7).getCellType());
|
||||
assertEquals(100.0, sheet.getRow(30).getCell(7).getNumericCellValue(), 0.01);
|
||||
assertEquals("USD", read(sheet, 30, 1));
|
||||
assertTrue(read(sheet, 35, 0).contains("文件费"));
|
||||
assertEquals(450.0, sheet.getRow(35).getCell(7).getNumericCellValue(), 0.01);
|
||||
assertEquals("燃油附加费", read(sheet, 18, 0));
|
||||
assertEquals(15.0, sheet.getRow(18).getCell(7).getNumericCellValue(), 0.01);
|
||||
assertEquals(2287.65, sheet.getRow(43).getCell(2).getNumericCellValue(), 0.01);
|
||||
} finally {
|
||||
Files.deleteIfExists(src);
|
||||
Files.deleteIfExists(out);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void realSeaTemplateWritesPortsAndFees() throws Exception {
|
||||
Path src = java.nio.file.Paths.get("d:/Desktop/友通达询价智能体重制版/deploy/sql/sea_tpl.xlsx");
|
||||
if (!Files.isRegularFile(src)) {
|
||||
return;
|
||||
}
|
||||
Path out = Files.createTempFile("quote-sea-real", ".xlsx");
|
||||
JSONObject mapping = new JSONObject();
|
||||
JSONObject fields = new JSONObject();
|
||||
fields.put("pol", "海运询价单!F4");
|
||||
fields.put("pod", "海运询价单!F5");
|
||||
fields.put("currency", "海运询价单!C18");
|
||||
fields.put("valid_until", "海运询价单!B16");
|
||||
mapping.put("fields", fields);
|
||||
JSONObject facts = new JSONObject(true);
|
||||
facts.put("pol", "DONGGUAN");
|
||||
facts.put("pod", "海防");
|
||||
facts.put("commodity", "机器设备");
|
||||
facts.put("quantity", "15件");
|
||||
facts.put("fcl_or_lcl", "整柜");
|
||||
facts.put("container_type_qty", "20GP*1");
|
||||
facts.put("quote_date", "2026-09-15");
|
||||
facts.put("valid_until", "2026-09-30");
|
||||
facts.put("currency", "CNY");
|
||||
facts.put("total_amount", "2287.65");
|
||||
facts.put("fee_rows", QuoteTemplateExcelSupport.parseFeeRows(
|
||||
"[{\"name\":\"海运费\",\"amount\":\"100.00\",\"currency\":\"USD\",\"quantity\":\"1\",\"unit\":\"柜\"},"
|
||||
+ "{\"name\":\"燃油附加费\",\"amount\":\"15.00\",\"currency\":\"USD\",\"quantity\":\"1\",\"unit\":\"柜\"},"
|
||||
+ "{\"name\":\"操作费\",\"amount\":\"680.00\",\"currency\":\"CNY\",\"quantity\":\"1\",\"unit\":\"票\"},"
|
||||
+ "{\"name\":\"文件费\",\"amount\":\"450.00\",\"currency\":\"CNY\",\"quantity\":\"1\",\"unit\":\"票\"},"
|
||||
+ "{\"name\":\"封条费\",\"amount\":\"50.00\",\"currency\":\"CNY\",\"quantity\":\"1\",\"unit\":\"柜\"},"
|
||||
+ "{\"name\":\"设备费\",\"amount\":\"50.00\",\"currency\":\"CNY\",\"quantity\":\"1\",\"unit\":\"柜\"},"
|
||||
+ "{\"name\":\"报关费\",\"amount\":\"200.00\",\"currency\":\"CNY\",\"quantity\":\"1\",\"unit\":\"票\"}]"));
|
||||
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
|
||||
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||
Sheet sheet = filled.getSheet("海运询价单");
|
||||
assertTrue(read(sheet, 3, 4).contains("起运港"));
|
||||
assertEquals("DONGGUAN", read(sheet, 3, 7));
|
||||
assertTrue(read(sheet, 4, 4).contains("目的港"));
|
||||
assertEquals("海防", read(sheet, 4, 7));
|
||||
assertEquals("机器设备", read(sheet, 4, 1));
|
||||
assertEquals("15件", read(sheet, 5, 1));
|
||||
assertEquals("整柜", read(sheet, 6, 1));
|
||||
assertEquals("20GP*1", read(sheet, 7, 1));
|
||||
assertEquals("20GP", read(sheet, 17, 2));
|
||||
assertTrue(read(sheet, 28, 0).contains("海运费用"));
|
||||
assertTrue(read(sheet, 30, 0).contains("海运费用"));
|
||||
assertEquals(CellType.NUMERIC, sheet.getRow(30).getCell(7).getCellType());
|
||||
assertEquals(100.0, sheet.getRow(30).getCell(7).getNumericCellValue(), 0.01);
|
||||
assertTrue(read(sheet, 35, 0).contains("文件费"));
|
||||
assertEquals(CellType.NUMERIC, sheet.getRow(35).getCell(7).getCellType());
|
||||
assertEquals("", read(sheet, 15, 7));
|
||||
assertEquals(450.0, sheet.getRow(35).getCell(7).getNumericCellValue(), 0.01);
|
||||
assertEquals("操作费", read(sheet, 18, 0));
|
||||
assertEquals(680.0, sheet.getRow(18).getCell(7).getNumericCellValue(), 0.01);
|
||||
assertEquals(2287.65, sheet.getRow(43).getCell(2).getNumericCellValue(), 0.01);
|
||||
} finally {
|
||||
Files.deleteIfExists(out);
|
||||
}
|
||||
}
|
||||
|
||||
private static org.apache.poi.ss.usermodel.Row row(Sheet sheet, int r) {
|
||||
org.apache.poi.ss.usermodel.Row exist = sheet.getRow(r);
|
||||
return exist == null ? sheet.createRow(r) : exist;
|
||||
}
|
||||
|
||||
private static String read(Sheet sheet, int r, int c) {
|
||||
org.apache.poi.ss.usermodel.Row row = sheet.getRow(r);
|
||||
if (row == null || row.getCell(c) == null) {
|
||||
return "";
|
||||
}
|
||||
org.apache.poi.ss.usermodel.Cell cell = row.getCell(c);
|
||||
if (cell.getCellType() == CellType.NUMERIC) {
|
||||
return String.valueOf(cell.getNumericCellValue());
|
||||
}
|
||||
return cell.getStringCellValue();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user