Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b070a9b7a7 | ||
|
|
ae818f26b1 | ||
|
|
b15f64f419 | ||
|
|
2e8918a5c9 | ||
|
|
ab6923d8cc | ||
|
|
e197ecea10 |
@@ -18,6 +18,11 @@ from agent.policy.event_exception import (
|
|||||||
card_title,
|
card_title,
|
||||||
conversation_of,
|
conversation_of,
|
||||||
exception_type,
|
exception_type,
|
||||||
|
WAIT_ADOPT,
|
||||||
|
WAIT_AIR_QUOTE,
|
||||||
|
WAIT_DEAL,
|
||||||
|
WAIT_LOST_REASON,
|
||||||
|
mentions_for_role,
|
||||||
pick_app_targets,
|
pick_app_targets,
|
||||||
pick_mentions,
|
pick_mentions,
|
||||||
timeout_code,
|
timeout_code,
|
||||||
@@ -25,6 +30,9 @@ from agent.policy.event_exception import (
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 采用、成交跟进、未成交原因都是在等销售。点名只找本单销售。
|
||||||
|
_SALES_WAIT_KINDS = {WAIT_ADOPT, WAIT_DEAL, WAIT_LOST_REASON}
|
||||||
|
|
||||||
SendGroupFn = Callable[..., bool]
|
SendGroupFn = Callable[..., bool]
|
||||||
EnqueueFn = Callable[..., tuple[bool, str]]
|
EnqueueFn = Callable[..., tuple[bool, str]]
|
||||||
TimeoutLookupFn = Callable[..., dict[str, Any]]
|
TimeoutLookupFn = Callable[..., dict[str, Any]]
|
||||||
@@ -115,6 +123,35 @@ def notify_app_flag(override: Optional[bool]) -> bool:
|
|||||||
return notify_app_ready()
|
return notify_app_ready()
|
||||||
|
|
||||||
|
|
||||||
|
def _skip_sales_timeout(
|
||||||
|
*,
|
||||||
|
status: str,
|
||||||
|
wait_kind: str,
|
||||||
|
next_followup_at: str,
|
||||||
|
lost_reason: str,
|
||||||
|
now: datetime,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
成交跟进相关等待在终态/顺延窗内不应再告警。
|
||||||
|
|
||||||
|
返回跳过原因;空串表示继续按小时判定。
|
||||||
|
协商中:只要还排着再问时刻(next_followup_at > now),就不发销售超时;
|
||||||
|
再问认领后会清空该时刻并重新 start deal,那时才重新计时。
|
||||||
|
"""
|
||||||
|
st = (status or "").strip()
|
||||||
|
_ = wait_kind
|
||||||
|
if st == "未成交" and (lost_reason or "").strip():
|
||||||
|
return "lost_reason_recorded"
|
||||||
|
if st == "协商中":
|
||||||
|
raw = (next_followup_at or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
due = _parse_started(raw)
|
||||||
|
if due is None or due > now:
|
||||||
|
return "negotiate_followup_pending"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _handle_one(
|
def _handle_one(
|
||||||
row: dict[str, Any],
|
row: dict[str, Any],
|
||||||
*,
|
*,
|
||||||
@@ -131,6 +168,14 @@ def _handle_one(
|
|||||||
kind = str(row.get("event_wait_kind") or row.get("eventWaitKind") or "").strip()
|
kind = str(row.get("event_wait_kind") or row.get("eventWaitKind") or "").strip()
|
||||||
if not no or not kind:
|
if not no or not kind:
|
||||||
return False
|
return False
|
||||||
|
status = str(row.get("status") or "").strip()
|
||||||
|
followup = str(row.get("next_followup_at") or row.get("nextFollowupAt") or "").strip()
|
||||||
|
lost_reason = str(row.get("lost_reason") or row.get("lostReason") or "").strip()
|
||||||
|
# 产品口径:协商中顺延再问期间不算「销售无响应」;未成交已写原因不再催。
|
||||||
|
# 即使 clear_wait 失败留下陈旧 deal 等待,这里也要挡住(WO202610060024 / WO202610070034)。
|
||||||
|
skip_reason = _skip_sales_timeout(
|
||||||
|
status=status, wait_kind=kind, next_followup_at=followup, lost_reason=lost_reason, now=now
|
||||||
|
)
|
||||||
line = str(row.get("business_line") or row.get("businessLine") or "").strip()
|
line = str(row.get("business_line") or row.get("businessLine") or "").strip()
|
||||||
facts = dict(row.get("facts") or {})
|
facts = dict(row.get("facts") or {})
|
||||||
klass = str(facts.get("运输分类") or facts.get("transportType") or "").strip()
|
klass = str(facts.get("运输分类") or facts.get("transportType") or "").strip()
|
||||||
@@ -146,22 +191,23 @@ def _handle_one(
|
|||||||
started = _parse_started(row.get("event_wait_started_at") or row.get("eventWaitStartedAt"))
|
started = _parse_started(row.get("event_wait_started_at") or row.get("eventWaitStartedAt"))
|
||||||
if started is None:
|
if started is None:
|
||||||
return False
|
return False
|
||||||
|
if skip_reason:
|
||||||
|
logger.info("事件异常跳过 wo=%s kind=%s reason=%s", no, kind, skip_reason)
|
||||||
|
return False
|
||||||
if workday_elapsed_hours(started, now) < hours:
|
if workday_elapsed_hours(started, now) < hours:
|
||||||
return False
|
return False
|
||||||
watchers = [dict(x) for x in (row.get("watchers") or []) if isinstance(x, dict)]
|
watchers = [dict(x) for x in (row.get("watchers") or []) if isinstance(x, dict)]
|
||||||
sales = next((dict(w) for w in watchers if str(w.get("roleCode") or "").lower() in {"sales", ""}), None)
|
sales = _ticket_sales(row, watchers)
|
||||||
if sales is None:
|
|
||||||
sales = {
|
|
||||||
"wecomId": str(row.get("sales_wecom_id") or row.get("salesWecomId") or "").strip(),
|
|
||||||
"name": str(row.get("sales_name") or row.get("salesName") or "销售").strip() or "销售",
|
|
||||||
"roleCode": "sales",
|
|
||||||
"notifyEventException": 0,
|
|
||||||
}
|
|
||||||
people = pick_mentions(wait_kind=kind, sales=sales, members=watchers, business_line=line)
|
people = pick_mentions(wait_kind=kind, sales=sales, members=watchers, business_line=line)
|
||||||
|
# 空运群往往只绑了群号,成员列表是空的,群里就找不到航线岗,话术会落成「相关人员」。
|
||||||
|
# 只补航线超时。销售超时不能改去 @ 航线,只 @ 本单销售。
|
||||||
|
if kind == WAIT_AIR_QUOTE and not people:
|
||||||
|
people = _airline_staff_mentions(book)
|
||||||
names = [str(p.get("name") or "").strip() for p in people if str(p.get("name") or "").strip()]
|
names = [str(p.get("name") or "").strip() for p in people if str(p.get("name") or "").strip()]
|
||||||
chat_id = str(row.get("collab_chat_id") or row.get("collabChatId") or "").strip()
|
chat_id = str(row.get("collab_chat_id") or row.get("collabChatId") or "").strip()
|
||||||
where = conversation_of(wait_kind=kind, collab_chat_id=chat_id)
|
where = conversation_of(wait_kind=kind, collab_chat_id=chat_id)
|
||||||
mention = where == "group"
|
# 群里用 @。销售超时即使发到私聊,也写成 @销售姓名,不写「您」或「相关人员」。
|
||||||
|
mention = where == "group" or kind in _SALES_WAIT_KINDS
|
||||||
text = copy.event_exception_session_text(
|
text = copy.event_exception_session_text(
|
||||||
exception_type=exception_type(kind),
|
exception_type=exception_type(kind),
|
||||||
work_order_no=no,
|
work_order_no=no,
|
||||||
@@ -208,6 +254,59 @@ def _handle_one(
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _ticket_sales(row: dict[str, Any], watchers: list[dict[str, Any]]) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
这张工单的销售。销售超时只 @ 这个人。
|
||||||
|
|
||||||
|
先按工单上的销售企微对上关注人,对不上再用岗位是销售的那条。
|
||||||
|
姓名用工单上的销售名,避免点成「销售」两个字或点到航线。
|
||||||
|
"""
|
||||||
|
sid = str(row.get("sales_wecom_id") or row.get("salesWecomId") or "").strip()
|
||||||
|
sname = str(row.get("sales_name") or row.get("salesName") or "").strip()
|
||||||
|
matched: dict[str, Any] | None = None
|
||||||
|
for raw in watchers:
|
||||||
|
uid = str(raw.get("wecomId") or raw.get("wecom_id") or "").strip()
|
||||||
|
role = str(raw.get("roleCode") or raw.get("role_code") or "").strip().lower()
|
||||||
|
if sid and uid == sid:
|
||||||
|
matched = dict(raw)
|
||||||
|
break
|
||||||
|
if matched is None and role == "sales":
|
||||||
|
matched = dict(raw)
|
||||||
|
if matched is None:
|
||||||
|
matched = {
|
||||||
|
"wecomId": sid,
|
||||||
|
"roleCode": "sales",
|
||||||
|
"notifyEventException": 0,
|
||||||
|
}
|
||||||
|
name = str(matched.get("name") or "").strip()
|
||||||
|
if sname and (not name or name in {sid, "销售"}):
|
||||||
|
matched["name"] = sname
|
||||||
|
elif not name:
|
||||||
|
matched["name"] = sname or "销售"
|
||||||
|
if sid and not str(matched.get("wecomId") or "").strip():
|
||||||
|
matched["wecomId"] = sid
|
||||||
|
matched.setdefault("roleCode", "sales")
|
||||||
|
return matched
|
||||||
|
|
||||||
|
|
||||||
|
def _airline_staff_mentions(book: Any) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
主账按岗位列出启用的航线同事。
|
||||||
|
|
||||||
|
只在群成员里没有航线岗时调用。主账失败或没有这个接口时返回空,话术仍用「相关人员」。
|
||||||
|
不写工单。可与巡检线程并发,每次只读这一单的员工名单。
|
||||||
|
"""
|
||||||
|
lister = getattr(book, "list_staff_by_role", None)
|
||||||
|
if not callable(lister):
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
rows = list(lister(role_code="air") or [])
|
||||||
|
except Exception:
|
||||||
|
logger.exception("事件异常列航线岗失败")
|
||||||
|
return []
|
||||||
|
return mentions_for_role(rows, role_code="air")
|
||||||
|
|
||||||
|
|
||||||
def _send_app_cards(
|
def _send_app_cards(
|
||||||
*,
|
*,
|
||||||
people: list[dict[str, Any]],
|
people: list[dict[str, Any]],
|
||||||
|
|||||||
@@ -40,8 +40,18 @@ class HttpLedger:
|
|||||||
def _post(self, path: str, body: dict[str, Any], *, idem: str = "") -> dict[str, Any]:
|
def _post(self, path: str, body: dict[str, Any], *, idem: str = "") -> dict[str, Any]:
|
||||||
resp = self._client.request_json("POST", path, json_body=body, idempotency_key=idem)
|
resp = self._client.request_json("POST", path, json_body=body, idempotency_key=idem)
|
||||||
if not resp.ok:
|
if not resp.ok:
|
||||||
logger.warning("http_ledger fail path=%s status=%s err=%s", path, resp.status_code, resp.error)
|
# 500 时 Jeecg 仍可能带回 message(如六态非法);勿只留 http_500,
|
||||||
return {"ok": False, "error": resp.error or f"http_{resp.status_code}"}
|
# 否则成交补「已报价」重试认不出「询价中」(WO023)。
|
||||||
|
data = resp.data if isinstance(resp.data, dict) else {}
|
||||||
|
msg = ""
|
||||||
|
if isinstance(data, dict):
|
||||||
|
msg = str(data.get("message") or "").strip()
|
||||||
|
unwrapped = _unwrap(data)
|
||||||
|
if unwrapped.get("error"):
|
||||||
|
msg = str(unwrapped.get("error") or msg)
|
||||||
|
err = msg or resp.error or f"http_{resp.status_code}"
|
||||||
|
logger.warning("http_ledger fail path=%s status=%s err=%s", path, resp.status_code, err)
|
||||||
|
return {"ok": False, "error": err}
|
||||||
return _unwrap(resp.data)
|
return _unwrap(resp.data)
|
||||||
|
|
||||||
def find_similar_open(
|
def find_similar_open(
|
||||||
@@ -166,8 +176,12 @@ class HttpLedger:
|
|||||||
"""
|
"""
|
||||||
no = (work_order_no or "").strip()
|
no = (work_order_no or "").strip()
|
||||||
src = dict(quote or {})
|
src = dict(quote or {})
|
||||||
if not no or not src:
|
if not no:
|
||||||
return {"ok": True, "quote_version": 1, "status": to_status or "已报价"}
|
return {"ok": False, "error": "work_order_blank"}
|
||||||
|
# 空报价不打主账(避免抹掉已有查价),但禁止伪装成「已写入已报价」——
|
||||||
|
# 否则会话以为已报价、主账仍停询价中,成交三态会失败(WO022)。
|
||||||
|
if not src:
|
||||||
|
return {"ok": False, "error": "quote_blank", "skipped": True}
|
||||||
out = self._post(
|
out = self._post(
|
||||||
"/inquiry/agent/ticket/replaceTmsQuote",
|
"/inquiry/agent/ticket/replaceTmsQuote",
|
||||||
{"workOrderNo": no, "quote": src, "toStatus": to_status},
|
{"workOrderNo": no, "quote": src, "toStatus": to_status},
|
||||||
|
|||||||
@@ -344,12 +344,23 @@ class MemoryLedger:
|
|||||||
ticket.next_followup_at = ""
|
ticket.next_followup_at = ""
|
||||||
if lost_reason:
|
if lost_reason:
|
||||||
ticket.lost_reason = lost_reason
|
ticket.lost_reason = lost_reason
|
||||||
|
# 成交等待结束;若仍缺原因由智能体再 start lost_reason
|
||||||
|
ticket.event_wait_kind = ""
|
||||||
|
ticket.event_wait_started_at = ""
|
||||||
|
ticket.event_wait_alerted = 0
|
||||||
elif to_status == "已成交":
|
elif to_status == "已成交":
|
||||||
ticket.next_followup_at = ""
|
ticket.next_followup_at = ""
|
||||||
if deal_notify_summary:
|
if deal_notify_summary:
|
||||||
ticket.deal_notify_summary = deal_notify_summary
|
ticket.deal_notify_summary = deal_notify_summary
|
||||||
|
ticket.event_wait_kind = ""
|
||||||
|
ticket.event_wait_started_at = ""
|
||||||
|
ticket.event_wait_alerted = 0
|
||||||
elif to_status == "协商中":
|
elif to_status == "协商中":
|
||||||
ticket.next_followup_at = next_followup_at
|
ticket.next_followup_at = next_followup_at
|
||||||
|
# 顺延再问期间不盯成交超时;到期再问会重新 start deal
|
||||||
|
ticket.event_wait_kind = ""
|
||||||
|
ticket.event_wait_started_at = ""
|
||||||
|
ticket.event_wait_alerted = 0
|
||||||
return {"ok": True, "status": ticket.status}
|
return {"ok": True, "status": ticket.status}
|
||||||
|
|
||||||
def patch_lost_reason(self, *, work_order_no: str, lost_reason: str) -> dict[str, Any]:
|
def patch_lost_reason(self, *, work_order_no: str, lost_reason: str) -> dict[str, Any]:
|
||||||
@@ -363,6 +374,9 @@ class MemoryLedger:
|
|||||||
if not reason:
|
if not reason:
|
||||||
return {"ok": False, "error": "reason_blank"}
|
return {"ok": False, "error": "reason_blank"}
|
||||||
ticket.lost_reason = reason
|
ticket.lost_reason = reason
|
||||||
|
ticket.event_wait_kind = ""
|
||||||
|
ticket.event_wait_started_at = ""
|
||||||
|
ticket.event_wait_alerted = 0
|
||||||
return {"ok": True, "work_order_no": work_order_no, "lost_reason": reason}
|
return {"ok": True, "work_order_no": work_order_no, "lost_reason": reason}
|
||||||
|
|
||||||
def claim_due_negotiate(self) -> list[dict[str, Any]]:
|
def claim_due_negotiate(self) -> list[dict[str, Any]]:
|
||||||
@@ -829,7 +843,11 @@ class MemoryLedger:
|
|||||||
airline: str = "",
|
airline: str = "",
|
||||||
flight_no: str = "",
|
flight_no: str = "",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""单测可注入 _cabin_lock_result;默认成功。"""
|
"""单测可注入 _cabin_lock_result;默认成功。
|
||||||
|
|
||||||
|
纯人工且视图无 TMS 舱位时,缺 option/航司/航班 → lock_incomplete,
|
||||||
|
对齐主账「现拉仍缺才回」的口径(内存账不会真查 TMS)。
|
||||||
|
"""
|
||||||
_ = sender_id, operator_name, instruction_text, airline, flight_no
|
_ = sender_id, operator_name, instruction_text, airline, flight_no
|
||||||
forced = getattr(self, "_cabin_lock_result", None)
|
forced = getattr(self, "_cabin_lock_result", None)
|
||||||
if isinstance(forced, dict):
|
if isinstance(forced, dict):
|
||||||
@@ -846,6 +864,27 @@ class MemoryLedger:
|
|||||||
quote_id = str(quote.get("quoteId") or "").strip()
|
quote_id = str(quote.get("quoteId") or "").strip()
|
||||||
if quote_id.upper().startswith("QT-WO"):
|
if quote_id.upper().startswith("QT-WO"):
|
||||||
quote_id = ""
|
quote_id = ""
|
||||||
|
air_opts = quote.get("airOptions") or quote.get("air_options") or []
|
||||||
|
has_tms_cabin = bool(quote_id) or bool(air_opts)
|
||||||
|
opt = (option_no or "").strip()
|
||||||
|
air = (airline or "").strip()
|
||||||
|
fn = (flight_no or "").strip()
|
||||||
|
if not has_tms_cabin:
|
||||||
|
missing: list[str] = []
|
||||||
|
if not opt:
|
||||||
|
missing.append("舱位编号optionNo")
|
||||||
|
if not air:
|
||||||
|
missing.append("航司airline")
|
||||||
|
if not fn:
|
||||||
|
missing.append("航班号flightNo")
|
||||||
|
if missing:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": "lock_incomplete",
|
||||||
|
"failReason": "锁舱参数不完整,缺少:"
|
||||||
|
+ "、".join(missing)
|
||||||
|
+ "。请按「AIR-OPT01 CZ 航班CZ327」补舱位、航司和航班号后再锁。",
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"status": "LOCKED",
|
"status": "LOCKED",
|
||||||
@@ -915,6 +954,11 @@ class MemoryLedger:
|
|||||||
continue
|
continue
|
||||||
if ticket.status in {"已成交", "已关闭", "转人工"}:
|
if ticket.status in {"已成交", "已关闭", "转人工"}:
|
||||||
continue
|
continue
|
||||||
|
# 未成交已写原因、协商中仍排着再问:不进巡检列表
|
||||||
|
if ticket.status == "未成交" and (ticket.lost_reason or "").strip():
|
||||||
|
continue
|
||||||
|
if ticket.status == "协商中" and (ticket.next_followup_at or "").strip():
|
||||||
|
continue
|
||||||
watchers: list[dict[str, Any]] = []
|
watchers: list[dict[str, Any]] = []
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
sales_id = (ticket.sales_wecom_id or "").strip()
|
sales_id = (ticket.sales_wecom_id or "").strip()
|
||||||
@@ -964,6 +1008,10 @@ class MemoryLedger:
|
|||||||
"collab_member_ids": list(ticket.collab_member_ids),
|
"collab_member_ids": list(ticket.collab_member_ids),
|
||||||
"facts": dict(ticket.facts),
|
"facts": dict(ticket.facts),
|
||||||
"eventException": ticket.event_exception,
|
"eventException": ticket.event_exception,
|
||||||
|
"lost_reason": ticket.lost_reason,
|
||||||
|
"lostReason": ticket.lost_reason,
|
||||||
|
"next_followup_at": ticket.next_followup_at,
|
||||||
|
"nextFollowupAt": ticket.next_followup_at,
|
||||||
"watchers": watchers,
|
"watchers": watchers,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -770,6 +770,30 @@ _RE_MONEY = re.compile(
|
|||||||
r"|(?:USD|CNY|RMB|美金|美元)\s*(\d+(?:\.\d+)?)",
|
r"|(?:USD|CNY|RMB|美金|美元)\s*(\d+(?:\.\d+)?)",
|
||||||
re.I,
|
re.I,
|
||||||
)
|
)
|
||||||
|
# 陆运集拼:「2-500KG 单价CNY990」是分档单价,不是「燃油附加费=2」。
|
||||||
|
_RE_WEIGHT_BAND_PRICE = re.compile(
|
||||||
|
r"(?P<min>\d+)\s*[-~~—–到至]\s*(?P<max>\d+)\s*K\s*G"
|
||||||
|
r"(?:\s*以上)?"
|
||||||
|
r"\s*(?:单价)?"
|
||||||
|
r"\s*(?:(?P<ccy>USD|CNY|RMB|美金|美元|人民币)\s*)?"
|
||||||
|
r"(?P<price>\d+(?:\.\d+)?)",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
_RE_WEIGHT_BAND_OPEN = re.compile(
|
||||||
|
r"(?P<min>\d+)\s*K\s*G\s*以上"
|
||||||
|
r"\s*(?:单价)?"
|
||||||
|
r"\s*(?:(?P<ccy>USD|CNY|RMB|美金|美元|人民币)\s*)?"
|
||||||
|
r"(?P<price>\d+(?:\.\d+)?)",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
_RE_FUEL_SURCHARGE_RATE = re.compile(
|
||||||
|
r"燃油附加费率\s*[::=]?\s*(?P<rate>\d+(?:\.\d+)?)\s*%?",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
_RE_TAX_RATE = re.compile(
|
||||||
|
r"(?<![A-Za-z\u4e00-\u9fff])税率\s*[::=]?\s*(?P<rate>\d+(?:\.\d+)?)\s*%?",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
_RE_FEE_LINE = re.compile(
|
_RE_FEE_LINE = re.compile(
|
||||||
r"(?P<name>[\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费|[A-Za-z]{2,10})"
|
r"(?P<name>[\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费|[A-Za-z]{2,10})"
|
||||||
r"\s*(?:改为|改成|调整为|调整成)?"
|
r"\s*(?:改为|改成|调整为|调整成)?"
|
||||||
@@ -880,6 +904,8 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
|
|||||||
认不出价格返回空 dict,调用方回「没有识别到报价」。
|
认不出价格返回空 dict,调用方回「没有识别到报价」。
|
||||||
群里常见「码头操作费100CNY / 报关费改为300CNY」,不能只把第一笔数当成海运费。
|
群里常见「码头操作费100CNY / 报关费改为300CNY」,不能只把第一笔数当成海运费。
|
||||||
「空运费 单价…金额…」必须保留空运费,金额用「金额」后面的数,不用单价。
|
「空运费 单价…金额…」必须保留空运费,金额用「金额」后面的数,不用单价。
|
||||||
|
陆运集拼:「2-500KG 单价CNY990,燃油附加费率1%,税率6%」走分档单价+费率,
|
||||||
|
禁止把公斤档下限「2」当成燃油附加费金额(WO021)。
|
||||||
"""
|
"""
|
||||||
if isinstance(injected, dict) and (injected.get("total") or injected.get("fee_rows")):
|
if isinstance(injected, dict) and (injected.get("total") or injected.get("fee_rows")):
|
||||||
out = dict(injected)
|
out = dict(injected)
|
||||||
@@ -892,16 +918,22 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
|
|||||||
compact = stripped.replace(" ", "").replace(" ", "")
|
compact = stripped.replace(" ", "").replace(" ", "")
|
||||||
if re.fullmatch(r"WO\d{12}", compact, re.I):
|
if re.fullmatch(r"WO\d{12}", compact, re.I):
|
||||||
return {}
|
return {}
|
||||||
rows = _parse_fee_rows(raw)
|
band = _parse_weight_band_quote(raw)
|
||||||
|
if band:
|
||||||
|
return band
|
||||||
|
# 费率句若仍落到普通费用行,会把「燃油附加费」配上句首公斤数。先剥掉再抽。
|
||||||
|
scrubbed = _strip_rate_and_band_noise(raw)
|
||||||
|
rows = _parse_fee_rows(scrubbed) if scrubbed.strip() else []
|
||||||
if rows:
|
if rows:
|
||||||
ccy = rows[0].get("currency") or _currency_of(raw)
|
ccy = rows[0].get("currency") or _currency_of(raw)
|
||||||
total = rows[0]["amount"]
|
money_rows = [x for x in rows if "%" not in str(x.get("amount") or "")]
|
||||||
if len({x.get("currency") for x in rows}) == 1:
|
total = (money_rows[0] if money_rows else rows[0])["amount"]
|
||||||
|
if money_rows and len({x.get("currency") for x in money_rows}) == 1:
|
||||||
try:
|
try:
|
||||||
total = str(sum(float(x["amount"]) for x in rows))
|
total = str(sum(float(x["amount"]) for x in money_rows))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
total = rows[0]["amount"]
|
total = money_rows[0]["amount"]
|
||||||
return {
|
out = {
|
||||||
"total": total,
|
"total": total,
|
||||||
"currency": ccy,
|
"currency": ccy,
|
||||||
"source_label": "产品报价",
|
"source_label": "产品报价",
|
||||||
@@ -909,9 +941,12 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
|
|||||||
"fee_rows": rows,
|
"fee_rows": rows,
|
||||||
"fee_lines": list(rows),
|
"fee_lines": list(rows),
|
||||||
}
|
}
|
||||||
hit = _RE_MONEY.search(_ascii_amount(raw))
|
out.update(_extract_rate_fields(raw))
|
||||||
|
return out
|
||||||
|
hit = _RE_MONEY.search(_ascii_amount(scrubbed or raw))
|
||||||
if not hit:
|
if not hit:
|
||||||
return {}
|
rates = _extract_rate_fields(raw)
|
||||||
|
return rates if rates else {}
|
||||||
amount = _ascii_amount(hit.group(1) or hit.group(2) or "")
|
amount = _ascii_amount(hit.group(1) or hit.group(2) or "")
|
||||||
if not amount:
|
if not amount:
|
||||||
return {}
|
return {}
|
||||||
@@ -919,12 +954,13 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
|
|||||||
# 句里已经点了费用名(空运费)时,不能退回默认海运费。
|
# 句里已经点了费用名(空运费)时,不能退回默认海运费。
|
||||||
named = re.search(
|
named = re.search(
|
||||||
r"([\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费)",
|
r"([\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费)",
|
||||||
raw,
|
scrubbed or raw,
|
||||||
)
|
)
|
||||||
fee_name = (named.group(1) if named else "") or "海运费"
|
fee_name = (named.group(1) if named else "") or "海运费"
|
||||||
if fee_name in {"费用"}:
|
if fee_name in {"费用", "燃油附加费"}:
|
||||||
fee_name = "海运费"
|
# 「燃油附加费率」里的「燃油附加费」不是费用行名;无其它费名时用运费。
|
||||||
return {
|
fee_name = "海运费" if fee_name == "费用" else "陆运费"
|
||||||
|
out = {
|
||||||
"total": amount,
|
"total": amount,
|
||||||
"currency": ccy,
|
"currency": ccy,
|
||||||
"source_label": "产品报价",
|
"source_label": "产品报价",
|
||||||
@@ -932,3 +968,113 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
|
|||||||
"fee_rows": [{"name": fee_name, "amount": amount, "currency": ccy}],
|
"fee_rows": [{"name": fee_name, "amount": amount, "currency": ccy}],
|
||||||
"fee_lines": [{"name": fee_name, "amount": amount, "currency": ccy}],
|
"fee_lines": [{"name": fee_name, "amount": amount, "currency": ccy}],
|
||||||
}
|
}
|
||||||
|
out.update(_extract_rate_fields(raw))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _format_percent_rate(raw: str) -> str:
|
||||||
|
"""费率展示统一带 %;已有 % 保留。"""
|
||||||
|
text = (raw or "").strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
if text.endswith("%"):
|
||||||
|
return text
|
||||||
|
return f"{text}%"
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_rate_fields(text: str) -> dict[str, str]:
|
||||||
|
"""抽出燃油附加费率 / 税率;只返回出现过的键。"""
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
fuel = _RE_FUEL_SURCHARGE_RATE.search(text or "")
|
||||||
|
if fuel:
|
||||||
|
out["fuel_surcharge_rate"] = _format_percent_rate(fuel.group("rate") or "")
|
||||||
|
out["fuelSurchargeRate"] = out["fuel_surcharge_rate"]
|
||||||
|
tax = _RE_TAX_RATE.search(text or "")
|
||||||
|
if tax:
|
||||||
|
out["tax_rate"] = _format_percent_rate(tax.group("rate") or "")
|
||||||
|
out["taxRate"] = out["tax_rate"]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_rate_and_band_noise(text: str) -> str:
|
||||||
|
"""去掉分档价与费率短语,避免后续费用行/金额回退误伤。"""
|
||||||
|
cleaned = _ascii_amount(text or "")
|
||||||
|
cleaned = _RE_WEIGHT_BAND_PRICE.sub(" ", cleaned)
|
||||||
|
cleaned = _RE_WEIGHT_BAND_OPEN.sub(" ", cleaned)
|
||||||
|
cleaned = _RE_FUEL_SURCHARGE_RATE.sub(" ", cleaned)
|
||||||
|
cleaned = _RE_TAX_RATE.sub(" ", cleaned)
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_weight_band_quote(text: str) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
陆运集拼分档单价:2-500KG 单价CNY990 + 可选燃油附加费率/税率。
|
||||||
|
|
||||||
|
产出 weightPrices,供主账出单按「2-500KG」表头对档;费率进顶层字段填 Excel。
|
||||||
|
"""
|
||||||
|
raw = _ascii_amount(text or "")
|
||||||
|
tiers: list[dict[str, Any]] = []
|
||||||
|
rows: list[dict[str, str]] = []
|
||||||
|
ccy = ""
|
||||||
|
for hit in _RE_WEIGHT_BAND_PRICE.finditer(raw):
|
||||||
|
lo = int(hit.group("min") or 0)
|
||||||
|
hi = int(hit.group("max") or 0)
|
||||||
|
price = _ascii_amount(hit.group("price") or "")
|
||||||
|
if not price:
|
||||||
|
continue
|
||||||
|
line_ccy = _currency_of(raw, hit.group("ccy") or "")
|
||||||
|
ccy = ccy or line_ccy
|
||||||
|
label = f"{lo}-{hi}KG"
|
||||||
|
tiers.append({"min": lo, "max": hi, "price": price})
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"name": label,
|
||||||
|
"amount": price,
|
||||||
|
"currency": line_ccy,
|
||||||
|
"unit_price": price,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for hit in _RE_WEIGHT_BAND_OPEN.finditer(raw):
|
||||||
|
lo = int(hit.group("min") or 0)
|
||||||
|
price = _ascii_amount(hit.group("price") or "")
|
||||||
|
if not price:
|
||||||
|
continue
|
||||||
|
line_ccy = _currency_of(raw, hit.group("ccy") or "")
|
||||||
|
ccy = ccy or line_ccy
|
||||||
|
label = f"{lo}KG以上"
|
||||||
|
tiers.append({"min": lo, "max": None, "price": price})
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"name": label,
|
||||||
|
"amount": price,
|
||||||
|
"currency": line_ccy,
|
||||||
|
"unit_price": price,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if not tiers:
|
||||||
|
return {}
|
||||||
|
rates = _extract_rate_fields(raw)
|
||||||
|
# 费率行进明细方便群摘要展示;金额带 %,合计时只加数字价。
|
||||||
|
if rates.get("fuel_surcharge_rate"):
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"name": "燃油附加费率",
|
||||||
|
"amount": rates["fuel_surcharge_rate"],
|
||||||
|
"currency": "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if rates.get("tax_rate"):
|
||||||
|
rows.append({"name": "税率", "amount": rates["tax_rate"], "currency": ""})
|
||||||
|
# 分档是单价,不能把多档相加冒充费用合计。
|
||||||
|
out: dict[str, Any] = {
|
||||||
|
"total": tiers[0]["price"],
|
||||||
|
"currency": ccy or "CNY",
|
||||||
|
"source_label": "产品报价",
|
||||||
|
"raw_text": (text or "").strip(),
|
||||||
|
"fee_rows": rows,
|
||||||
|
"fee_lines": list(rows),
|
||||||
|
"weightPrices": tiers,
|
||||||
|
"price_per_kg": tiers[0]["price"],
|
||||||
|
}
|
||||||
|
out.update(rates)
|
||||||
|
return out
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
|
|
||||||
本文件职责:群里副作用(主账 + BOT 回话)。海运应用群仍走 sea_group_ops。
|
本文件职责:群里副作用(主账 + BOT 回话)。海运应用群仍走 sea_group_ops。
|
||||||
禁止:自动建群/拉人/解散;拿当前工单去猜锁舱工单号。
|
禁止:自动建群/拉人/解散;拿当前工单去猜锁舱工单号。
|
||||||
锁舱按 TMS /air/cabin/lock 拼字段出站;没有 TMS 报价编号也要锁,用工单字段补 quoteId/optionNo。
|
锁舱按 TMS /air/cabin/lock 拼字段出站;没有 TMS 报价编号也可走 MANUAL。
|
||||||
|
禁止编造 optionNo;缺舱位编号/航司/计费重时先群里补问,禁止空壳打 TMS。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -802,22 +803,46 @@ def apply_air_deal(
|
|||||||
if stopped:
|
if stopped:
|
||||||
return stopped
|
return stopped
|
||||||
from agent.policy.deal_outcome import finish_deal_outcome, harvest_inline_lost_reason
|
from agent.policy.deal_outcome import finish_deal_outcome, harvest_inline_lost_reason
|
||||||
from agent.policy.sea_group_ops import facts_of_group
|
from agent.policy.sea_group_ops import _quote_fee_rows, facts_of_group
|
||||||
|
|
||||||
view = ticket_view(ticket)
|
view = ticket_view(ticket)
|
||||||
|
no = ticket_no(ticket)
|
||||||
|
quote = dict(view.get("quote") or {})
|
||||||
|
status_now = ticket_status(ticket)
|
||||||
|
usable_total = str(quote.get("total") or "").strip()
|
||||||
|
if usable_total in {"", "-"}:
|
||||||
|
usable_total = ""
|
||||||
|
# 与海运一致:询价中且无报价时先说清原因,禁止笼统「主账状态更新失败」。
|
||||||
|
if not (usable_total or _quote_fee_rows(quote) or status_now in {"已报价", "协商中"}):
|
||||||
|
send_group_text(
|
||||||
|
group_client_of(flow), chat_id, copy.deal_fail_no_quote(work_order_no=no)
|
||||||
|
)
|
||||||
|
return "deal_need_quote"
|
||||||
inline = harvest_inline_lost_reason(text) if outcome == "未成交" else ""
|
inline = harvest_inline_lost_reason(text) if outcome == "未成交" else ""
|
||||||
result = finish_deal_outcome(
|
result = finish_deal_outcome(
|
||||||
ledger=flow.ledger,
|
ledger=flow.ledger,
|
||||||
work_order_no=ticket_no(ticket),
|
work_order_no=no,
|
||||||
outcome=outcome,
|
outcome=outcome,
|
||||||
facts=facts_of_group(view),
|
facts=facts_of_group(view),
|
||||||
quote=dict(view.get("quote") or {}),
|
quote=quote,
|
||||||
business_line="AIR",
|
business_line="AIR",
|
||||||
sales_name=str(view.get("sales_name") or ""),
|
sales_name=str(view.get("sales_name") or ""),
|
||||||
lost_reason=inline,
|
lost_reason=inline,
|
||||||
)
|
)
|
||||||
if not result.get("ok"):
|
if not result.get("ok"):
|
||||||
send_group_text(group_client_of(flow), chat_id, "主账状态更新失败,成交结果未入账。")
|
# 迁态失败且仍无报价时,同样用「没有报价」说明,避免旧笼统句。
|
||||||
|
err = str(result.get("error") or "")
|
||||||
|
still_no_quote = not (
|
||||||
|
usable_total or _quote_fee_rows(quote) or status_now in {"已报价", "协商中"}
|
||||||
|
)
|
||||||
|
if still_no_quote or "询价中" in err:
|
||||||
|
send_group_text(
|
||||||
|
group_client_of(flow), chat_id, copy.deal_fail_no_quote(work_order_no=no)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
send_group_text(
|
||||||
|
group_client_of(flow), chat_id, "主账状态更新失败,成交结果未入账。"
|
||||||
|
)
|
||||||
return "ledger_fail"
|
return "ledger_fail"
|
||||||
phase = str(result.get("phase") or "done")
|
phase = str(result.get("phase") or "done")
|
||||||
ack_reason = inline if phase == "done" and outcome == "未成交" else ""
|
ack_reason = inline if phase == "done" and outcome == "未成交" else ""
|
||||||
@@ -826,7 +851,7 @@ def apply_air_deal(
|
|||||||
chat_id,
|
chat_id,
|
||||||
copy.deal_ack(
|
copy.deal_ack(
|
||||||
outcome,
|
outcome,
|
||||||
ticket_no(ticket),
|
no,
|
||||||
str(result.get("next_followup_at") or ""),
|
str(result.get("next_followup_at") or ""),
|
||||||
lost_reason=ack_reason,
|
lost_reason=ack_reason,
|
||||||
),
|
),
|
||||||
@@ -1006,7 +1031,6 @@ def apply_air_lock(
|
|||||||
emit_group_deal(flow=flow, ticket=view, quote=dict(view.get("quote") or {}))
|
emit_group_deal(flow=flow, ticket=view, quote=dict(view.get("quote") or {}))
|
||||||
return "lock_ok"
|
return "lock_ok"
|
||||||
|
|
||||||
|
|
||||||
def _release_should_push_deal(ticket: object) -> bool:
|
def _release_should_push_deal(ticket: object) -> bool:
|
||||||
"""
|
"""
|
||||||
放舱成功后要不要再推成交跟进。
|
放舱成功后要不要再推成交跟进。
|
||||||
@@ -1537,17 +1561,6 @@ def _labels_from_incomplete_reason(reason: str) -> list[str]:
|
|||||||
return found or ["舱位编号", "航司", "航班号"]
|
return found or ["舱位编号", "航司", "航班号"]
|
||||||
|
|
||||||
|
|
||||||
def _quote_ready_for_lock(quote: dict[str, Any]) -> bool:
|
|
||||||
"""有总价、费用或报价编号才值得让航线补舱位。完全没报价仍走「还没有报价」。"""
|
|
||||||
if str(quote.get("total") or "").strip() not in {"", "-"}:
|
|
||||||
return True
|
|
||||||
if quote.get("fee_lines") or quote.get("feeItems") or quote.get("departureCharges"):
|
|
||||||
return True
|
|
||||||
if _air_options(quote):
|
|
||||||
return True
|
|
||||||
return bool(str(quote.get("quoteId") or quote.get("quote_id") or "").strip())
|
|
||||||
|
|
||||||
|
|
||||||
def _remember_partial_lock(
|
def _remember_partial_lock(
|
||||||
flow: Any,
|
flow: Any,
|
||||||
*,
|
*,
|
||||||
@@ -1577,6 +1590,140 @@ def _quote_id(quote: dict[str, Any]) -> str:
|
|||||||
return str(quote.get("quoteId") or quote.get("quote_id") or "").strip()
|
return str(quote.get("quoteId") or quote.get("quote_id") or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _quote_ready_for_lock(quote: dict[str, Any]) -> bool:
|
||||||
|
"""
|
||||||
|
有没有可锁的报价:真实 TMS 编号,或人工总价/费用明细。
|
||||||
|
与内存账本口径一致,禁止把空报价当成「缺舱位」去补问。
|
||||||
|
"""
|
||||||
|
src = dict(quote or {})
|
||||||
|
label = str(src.get("source_label") or src.get("sourceLabel") or "")
|
||||||
|
if "确认" in label and "TMS" in label.upper():
|
||||||
|
qid = str(src.get("quoteId") or "").strip()
|
||||||
|
return bool(qid) and not qid.upper().startswith("QT-")
|
||||||
|
if str(src.get("total") or "").strip() not in {"", "-"}:
|
||||||
|
return True
|
||||||
|
if src.get("fee_rows") or src.get("fee_lines") or src.get("feeItems") or src.get("departureCharges"):
|
||||||
|
return True
|
||||||
|
if _air_options(src):
|
||||||
|
return True
|
||||||
|
qid = str(src.get("quoteId") or src.get("quote_id") or "").strip()
|
||||||
|
return bool(qid) and not qid.upper().startswith("QT-WO")
|
||||||
|
|
||||||
|
|
||||||
|
def _picked_option(
|
||||||
|
options: list[dict[str, Any]], option_no: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""按 optionNo 取舱位;对不上返回空 dict。"""
|
||||||
|
want = (option_no or "").strip().upper()
|
||||||
|
if not want:
|
||||||
|
return {}
|
||||||
|
for row in options:
|
||||||
|
no = str(row.get("optionNo") or row.get("option_no") or "").strip().upper()
|
||||||
|
if no == want:
|
||||||
|
return row
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _airline_from_quote_or_option(
|
||||||
|
quote: dict[str, Any],
|
||||||
|
options: list[dict[str, Any]],
|
||||||
|
option_no: str,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
锁舱航司:舱位 airlineCode > 报价顶层 > 航班号前两位。
|
||||||
|
只回两位大写字母;猜不到就空,交给群里补问。
|
||||||
|
"""
|
||||||
|
opt = _picked_option(options, option_no)
|
||||||
|
raw = str(
|
||||||
|
opt.get("airlineCode")
|
||||||
|
or opt.get("airline")
|
||||||
|
or quote.get("airlineCode")
|
||||||
|
or quote.get("airline")
|
||||||
|
or ""
|
||||||
|
).strip().upper()
|
||||||
|
if len(raw) >= 2 and raw[:2].isalpha():
|
||||||
|
return raw[:2]
|
||||||
|
flight = str(
|
||||||
|
opt.get("flightNo") or opt.get("flight_no") or quote.get("flightNo") or ""
|
||||||
|
).strip().upper()
|
||||||
|
if len(flight) >= 2 and flight[0].isalpha() and flight[1].isalpha():
|
||||||
|
return flight[:2]
|
||||||
|
name = str(opt.get("airlineName") or quote.get("airlineName") or "").strip()
|
||||||
|
# 名称里不硬猜二字码,避免「南航」编成乱码。
|
||||||
|
_ = name
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _flight_no_from_quote_or_option(
|
||||||
|
quote: dict[str, Any],
|
||||||
|
options: list[dict[str, Any]],
|
||||||
|
option_no: str,
|
||||||
|
instruction: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""航班号:舱位/报价字段,或句中「航班CZ327」「CZ327」形态。"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
opt = _picked_option(options, option_no)
|
||||||
|
raw = str(opt.get("flightNo") or opt.get("flight_no") or quote.get("flightNo") or "").strip()
|
||||||
|
if raw:
|
||||||
|
return raw.upper()
|
||||||
|
blob = (instruction or "").upper()
|
||||||
|
m = re.search(r"(?:航班|FLIGHT)\s*([A-Z0-9]{2,8})", blob)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
m = re.search(r"\b([A-Z]{2}\d{2,4})\b", blob)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _chargeable_weight_kg(
|
||||||
|
quote: dict[str, Any],
|
||||||
|
options: list[dict[str, Any]],
|
||||||
|
option_no: str,
|
||||||
|
ticket: object,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
计费重:舱位 > 报价 > 工单事实「计费重」> 毛重。
|
||||||
|
有正数才返回;否则空串,门禁会补问。
|
||||||
|
"""
|
||||||
|
opt = _picked_option(options, option_no)
|
||||||
|
candidates = [
|
||||||
|
opt.get("chargeableWeightKg") or opt.get("chargeable_weight_kg"),
|
||||||
|
quote.get("chargeableWeightKg") or quote.get("chargeable_weight_kg"),
|
||||||
|
]
|
||||||
|
view = ticket_view(ticket)
|
||||||
|
facts = dict(view.get("facts") or {})
|
||||||
|
candidates.extend(
|
||||||
|
[
|
||||||
|
facts.get("计费重"),
|
||||||
|
facts.get("计费重量"),
|
||||||
|
view.get("chargeableWeightKg"),
|
||||||
|
facts.get("毛重"),
|
||||||
|
view.get("weightKg") or view.get("weight_kg"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for raw in candidates:
|
||||||
|
text = str(raw or "").strip().replace(",", "")
|
||||||
|
if not text or text in {"-", "null", "None"}:
|
||||||
|
continue
|
||||||
|
# 去掉「公斤」「KGS」等后缀,只留数字。
|
||||||
|
num = ""
|
||||||
|
for ch in text:
|
||||||
|
if ch.isdigit() or ch == ".":
|
||||||
|
num += ch
|
||||||
|
elif num:
|
||||||
|
break
|
||||||
|
if not num:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if float(num) > 0:
|
||||||
|
return num
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _air_options(quote: dict[str, Any]) -> list[dict[str, Any]]:
|
def _air_options(quote: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
raw = quote.get("airOptions") or quote.get("air_options")
|
raw = quote.get("airOptions") or quote.get("air_options")
|
||||||
if isinstance(raw, str) and raw.strip().startswith("["):
|
if isinstance(raw, str) and raw.strip().startswith("["):
|
||||||
|
|||||||
@@ -882,8 +882,11 @@ class AirTextInquiryFlow:
|
|||||||
reply(copy.quote_generating())
|
reply(copy.quote_generating())
|
||||||
return "wait_file"
|
return "wait_file"
|
||||||
if sess.phase == "wait_deal":
|
if sess.phase == "wait_deal":
|
||||||
# 成交卡已经绑在报价文件那条出站上。只续办、没说成交结果时不再另发一张卡。
|
# 成交卡原来挂在报价单那条消息上,聊天一上滑就找不到。
|
||||||
# 原话写了已成交/未成交/协商中时,由 _close_spoken_deal 先入账,不会走到这里。
|
# 销售再说继续工单、又没写成交结果时,再发一次成交/未成交/协商中。
|
||||||
|
# 原话已经写了结果时,_close_spoken_deal 会先入账,不会走到这里。
|
||||||
|
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
|
||||||
|
self._emit_deal(reply, sess)
|
||||||
return "wait_deal"
|
return "wait_deal"
|
||||||
if sess.phase == "wait_lost_reason":
|
if sess.phase == "wait_lost_reason":
|
||||||
reply(copy.deal_ack("未成交", sess.work_order_no))
|
reply(copy.deal_ack("未成交", sess.work_order_no))
|
||||||
@@ -1806,8 +1809,12 @@ class AirTextInquiryFlow:
|
|||||||
extra,
|
extra,
|
||||||
)
|
)
|
||||||
from agent.redis_coord.quote_file import mark_quote_file_ready
|
from agent.redis_coord.quote_file import mark_quote_file_ready
|
||||||
|
from agent.policy.deal_outcome import ensure_ticket_quoted
|
||||||
|
|
||||||
mark_quote_file_ready(sess.work_order_no)
|
mark_quote_file_ready(sess.work_order_no)
|
||||||
|
# 文件已出就必须让主账离开「询价中」,否则成交三态会被六态拒绝。
|
||||||
|
ensure_ticket_quoted(self._ledger, work_order_no=sess.work_order_no)
|
||||||
|
sess.status = sess.status or "已报价"
|
||||||
sess.phase = "wait_deal"
|
sess.phase = "wait_deal"
|
||||||
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
|
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
|
||||||
sess.wait_version += 1
|
sess.wait_version += 1
|
||||||
@@ -1952,20 +1959,37 @@ class AirTextInquiryFlow:
|
|||||||
"""点成交/未成交/协商中:先整卡置灰,再入主账。未成交同一句带原因则一次记完。"""
|
"""点成交/未成交/协商中:先整卡置灰,再入主账。未成交同一句带原因则一次记完。"""
|
||||||
self._grey_clicked_card(reply, card_meta, replace_name=status)
|
self._grey_clicked_card(reply, card_meta, replace_name=status)
|
||||||
from agent.policy.deal_outcome import finish_deal_outcome, harvest_inline_lost_reason
|
from agent.policy.deal_outcome import finish_deal_outcome, harvest_inline_lost_reason
|
||||||
|
from agent.policy.sea_group_ops import _quote_fee_rows
|
||||||
|
|
||||||
|
quote = dict(sess.quote or {})
|
||||||
|
status_now = str(sess.status or "").strip()
|
||||||
|
usable_total = str(quote.get("total") or "").strip()
|
||||||
|
if usable_total in {"", "-"}:
|
||||||
|
usable_total = ""
|
||||||
|
if not (
|
||||||
|
usable_total
|
||||||
|
or _quote_fee_rows(quote)
|
||||||
|
or status_now in {"已报价", "协商中"}
|
||||||
|
):
|
||||||
|
reply(copy.deal_fail_no_quote(work_order_no=sess.work_order_no))
|
||||||
|
return "deal_need_quote"
|
||||||
inline = harvest_inline_lost_reason(source_text) if status == "未成交" else ""
|
inline = harvest_inline_lost_reason(source_text) if status == "未成交" else ""
|
||||||
result = finish_deal_outcome(
|
result = finish_deal_outcome(
|
||||||
ledger=self._ledger,
|
ledger=self._ledger,
|
||||||
work_order_no=sess.work_order_no,
|
work_order_no=sess.work_order_no,
|
||||||
outcome=status,
|
outcome=status,
|
||||||
facts=sess.facts,
|
facts=sess.facts,
|
||||||
quote=sess.quote,
|
quote=quote,
|
||||||
business_line=sess.business_line,
|
business_line=sess.business_line,
|
||||||
sales_name=sess.invited_sales_name,
|
sales_name=sess.invited_sales_name,
|
||||||
lost_reason=inline,
|
lost_reason=inline,
|
||||||
)
|
)
|
||||||
if not result.get("ok"):
|
if not result.get("ok"):
|
||||||
reply("主账状态更新失败,成交结果未入账。")
|
err = str(result.get("error") or "")
|
||||||
|
if (not usable_total and not _quote_fee_rows(quote)) or "询价中" in err:
|
||||||
|
reply(copy.deal_fail_no_quote(work_order_no=sess.work_order_no))
|
||||||
|
else:
|
||||||
|
reply("主账状态更新失败,成交结果未入账。")
|
||||||
return "ledger_fail"
|
return "ledger_fail"
|
||||||
phase = str(result.get("phase") or "done")
|
phase = str(result.get("phase") or "done")
|
||||||
sess.status = status
|
sess.status = status
|
||||||
@@ -2021,8 +2045,10 @@ class AirTextInquiryFlow:
|
|||||||
if not no:
|
if not no:
|
||||||
return
|
return
|
||||||
from agent.redis_coord.quote_file import mark_quote_file_ready
|
from agent.redis_coord.quote_file import mark_quote_file_ready
|
||||||
|
from agent.policy.deal_outcome import ensure_ticket_quoted
|
||||||
|
|
||||||
mark_quote_file_ready(no)
|
mark_quote_file_ready(no)
|
||||||
|
ensure_ticket_quoted(self._ledger, work_order_no=no)
|
||||||
if not sender_id:
|
if not sender_id:
|
||||||
return
|
return
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
|||||||
@@ -210,7 +210,11 @@ def finish_deal_outcome(
|
|||||||
writer = getattr(ledger, "transition", None)
|
writer = getattr(ledger, "transition", None)
|
||||||
if not callable(writer):
|
if not callable(writer):
|
||||||
return {"ok": False, "error": "no_transition", "phase": "ledger_fail"}
|
return {"ok": False, "error": "no_transition", "phase": "ledger_fail"}
|
||||||
result = writer(work_order_no=no, to_status=status, **extra) or {}
|
# 报价文件已出、会话已进入成交跟进,但主账仍停在「询价中」时,
|
||||||
|
# 直接迁「未成交/已成交/协商中」会被六态拒绝。先补「已报价」再迁目标态。
|
||||||
|
result = _transition_deal_status(
|
||||||
|
writer, work_order_no=no, to_status=status, extra=extra, ledger=ledger
|
||||||
|
)
|
||||||
if not result.get("ok"):
|
if not result.get("ok"):
|
||||||
return {"ok": False, "error": result.get("error") or "transition_failed", "phase": "ledger_fail"}
|
return {"ok": False, "error": result.get("error") or "transition_failed", "phase": "ledger_fail"}
|
||||||
from agent.policy.event_exception import WAIT_LOST_REASON, clear_wait, start_wait
|
from agent.policy.event_exception import WAIT_LOST_REASON, clear_wait, start_wait
|
||||||
@@ -241,6 +245,79 @@ def finish_deal_outcome(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _transition_deal_status(
|
||||||
|
writer: Callable[..., dict[str, Any]],
|
||||||
|
*,
|
||||||
|
work_order_no: str,
|
||||||
|
to_status: str,
|
||||||
|
extra: dict[str, Any],
|
||||||
|
ledger: Any = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
写入成交三态。若主账仍停在询价中,先迁已报价再迁目标态。
|
||||||
|
|
||||||
|
运行证据:
|
||||||
|
- WO022/WO023:文件已发仍停询价中,直迁未成交被六态拒绝;
|
||||||
|
- HTTP 500 时 http_ledger 常只回 http_500(正文里的「询价中」丢失),
|
||||||
|
不能只靠错误文案判断,要以工单当前状态为准。
|
||||||
|
"""
|
||||||
|
no = (work_order_no or "").strip()
|
||||||
|
status = (to_status or "").strip()
|
||||||
|
result = writer(work_order_no=no, to_status=status, **extra) or {}
|
||||||
|
if result.get("ok"):
|
||||||
|
return result
|
||||||
|
if status not in {"已成交", "未成交", "协商中"}:
|
||||||
|
return result
|
||||||
|
err = str(result.get("error") or result.get("message") or "")
|
||||||
|
cur = ""
|
||||||
|
getter = getattr(ledger, "get_for_agent", None) if ledger is not None else None
|
||||||
|
if callable(getter):
|
||||||
|
try:
|
||||||
|
snap = getter(work_order_no=no, sender_id="") or {}
|
||||||
|
cur = str(snap.get("status") or "").strip()
|
||||||
|
except Exception:
|
||||||
|
cur = ""
|
||||||
|
# 状态确认询价中,或错误文案带询价中,或仅有 http_5xx 时仍尝试补已报价。
|
||||||
|
should_promote = (
|
||||||
|
cur == "询价中"
|
||||||
|
or "询价中" in err
|
||||||
|
or err.startswith("http_5")
|
||||||
|
or err in {"transition_failed", "ledger_error"}
|
||||||
|
)
|
||||||
|
if not should_promote:
|
||||||
|
return result
|
||||||
|
promoted = writer(
|
||||||
|
work_order_no=no,
|
||||||
|
to_status="已报价",
|
||||||
|
remark="成交跟进前补已报价",
|
||||||
|
) or {}
|
||||||
|
if not promoted.get("ok"):
|
||||||
|
return result
|
||||||
|
return writer(work_order_no=no, to_status=status, **extra) or {}
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_ticket_quoted(ledger: Any, *, work_order_no: str) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
报价文件发出 / 进入成交跟进时,保证主账至少是「已报价」。
|
||||||
|
|
||||||
|
幂等:已离开「询价中」则不再迁态(已报价→已报价会被六态拒绝)。
|
||||||
|
"""
|
||||||
|
no = (work_order_no or "").strip()
|
||||||
|
writer = getattr(ledger, "transition", None)
|
||||||
|
if not no or not callable(writer):
|
||||||
|
return {"ok": False, "error": "no_transition"}
|
||||||
|
getter = getattr(ledger, "get_for_agent", None)
|
||||||
|
if callable(getter):
|
||||||
|
try:
|
||||||
|
snap = getter(work_order_no=no, sender_id="") or {}
|
||||||
|
except Exception:
|
||||||
|
snap = {}
|
||||||
|
st = str(snap.get("status") or "").strip()
|
||||||
|
if st and st != "询价中":
|
||||||
|
return {"ok": True, "status": st, "skipped": True}
|
||||||
|
return writer(work_order_no=no, to_status="已报价", remark="报价文件已出") or {}
|
||||||
|
|
||||||
|
|
||||||
def capture_lost_reason(*, ledger: Any, work_order_no: str, reason: str) -> dict[str, Any]:
|
def capture_lost_reason(*, ledger: Any, work_order_no: str, reason: str) -> dict[str, Any]:
|
||||||
"""把销售下一句写进未成交原因。@询价小助手 不是原因。"""
|
"""把销售下一句写进未成交原因。@询价小助手 不是原因。"""
|
||||||
text = strip_bot_mentions(reason or "")
|
text = strip_bot_mentions(reason or "")
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ def pick_mentions(
|
|||||||
"""
|
"""
|
||||||
本单这次应回而未回、需要被点名的人。
|
本单这次应回而未回、需要被点名的人。
|
||||||
|
|
||||||
航线只盯群里航线岗;海运/陆运产品盯对应产品岗;销售三类只盯本单销售。
|
航线只盯群里航线岗;海运/陆运产品盯对应产品岗;销售三类只盯本单销售,不点航线。
|
||||||
不要询价机器人、不要群里其他人。
|
不要询价机器人、不要群里其他人。
|
||||||
"""
|
"""
|
||||||
kind = (wait_kind or "").strip()
|
kind = (wait_kind or "").strip()
|
||||||
@@ -206,6 +206,48 @@ def pick_mentions(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def mentions_for_role(
|
||||||
|
rows: list[dict[str, Any]] | None,
|
||||||
|
*,
|
||||||
|
role_code: str,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
从员工行里取出启用、有企微、有姓名的指定岗位。
|
||||||
|
|
||||||
|
空运群激活只记群号,协同成员经常是空的。超时巡检在群成员里找不到航线岗时,
|
||||||
|
用本函数点名员工管理里的航线·空运,和进群摘要 @ 的是同一批人。
|
||||||
|
不发消息、不写主账。
|
||||||
|
"""
|
||||||
|
want = (role_code or "").strip().lower()
|
||||||
|
if not want:
|
||||||
|
return []
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for raw in rows or []:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
continue
|
||||||
|
role = str(raw.get("roleCode") or raw.get("role_code") or "").strip().lower()
|
||||||
|
if role != want:
|
||||||
|
continue
|
||||||
|
status = str(raw.get("status") or "active").strip().lower()
|
||||||
|
if status in {"disabled", "0", "停用", "inactive"}:
|
||||||
|
continue
|
||||||
|
uid = str(raw.get("wecomId") or raw.get("wecom_id") or "").strip()
|
||||||
|
name = str(raw.get("name") or "").strip()
|
||||||
|
if not uid or not name or uid in seen:
|
||||||
|
continue
|
||||||
|
seen.add(uid)
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"wecomId": uid,
|
||||||
|
"name": name,
|
||||||
|
"roleCode": want,
|
||||||
|
"notifyEventException": raw.get("notifyEventException", 0),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def pick_app_targets(people: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
|
def pick_app_targets(people: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
|
||||||
"""勾了「接收事件异常消息提醒」且绑了企微的人。"""
|
"""勾了「接收事件异常消息提醒」且绑了企微的人。"""
|
||||||
out: list[dict[str, Any]] = []
|
out: list[dict[str, Any]] = []
|
||||||
|
|||||||
@@ -1023,7 +1023,12 @@ def quote_fee_lines(quote: dict[str, Any] | None) -> list[tuple[str, str]]:
|
|||||||
amount = str(item.get("amount") or item.get("value") or "").strip()
|
amount = str(item.get("amount") or item.get("value") or "").strip()
|
||||||
line_ccy = str(item.get("currency") or item.get("currencyCode") or header_ccy).strip()
|
line_ccy = str(item.get("currency") or item.get("currencyCode") or header_ccy).strip()
|
||||||
if name and amount and amount != "-":
|
if name and amount and amount != "-":
|
||||||
lines.append((name, money_text(amount, line_ccy)))
|
# 燃油附加费率 / 税率是百分比,禁止套成「CNY 1%」。
|
||||||
|
if "%" in amount or name.endswith("费率") or name in {"税率", "燃油附加费率"}:
|
||||||
|
shown = amount if "%" in amount else f"{amount}%"
|
||||||
|
lines.append((name, shown))
|
||||||
|
else:
|
||||||
|
lines.append((name, money_text(amount, line_ccy)))
|
||||||
if lines:
|
if lines:
|
||||||
return lines
|
return lines
|
||||||
freight = str(src.get("freight") or "").strip()
|
freight = str(src.get("freight") or "").strip()
|
||||||
@@ -1219,10 +1224,21 @@ def sea_quote_detail_text(
|
|||||||
|
|
||||||
|
|
||||||
def collab_hint(work_order_no: str) -> str:
|
def collab_hint(work_order_no: str) -> str:
|
||||||
"""空运价格卡协同说明:请把相关方拉进原有协同群,并 @ 询价小助手。紧挨生成报价单按钮上方。"""
|
"""
|
||||||
return (
|
空运价格卡协同说明:紧挨「不需要协同,直接生成报价单」上方。
|
||||||
f"请手动将相关方拉到空运原有协同群,并在空运协同群中发送"
|
|
||||||
f"“ {work_order_no}@询价小助手”,谢谢。"
|
后台 inquiring.air_collab_hint;库内模板可用 {{ticket_no}}。
|
||||||
|
无后台模板时用本默认句(已带工单号)。销售侧示例:
|
||||||
|
…发送“ WO202610060030@询价小助手”,谢谢。
|
||||||
|
"""
|
||||||
|
no = (work_order_no or "").strip()
|
||||||
|
return _r(
|
||||||
|
"inquiring.air_collab_hint",
|
||||||
|
(
|
||||||
|
f"若需要咨询内部航线人员,请手动将相关方拉到空运原有协同群,"
|
||||||
|
f"并在空运协同群中发送“ {no}@询价小助手”,谢谢。"
|
||||||
|
),
|
||||||
|
ticket_no=no,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -2667,8 +2683,24 @@ def group_deal_stale(*, work_order_no: str) -> str:
|
|||||||
return f"这张成交确认已过期。工单{no}已有更新的报价,请点击群里最新一条成交跟进。"
|
return f"这张成交确认已过期。工单{no}已有更新的报价,请点击群里最新一条成交跟进。"
|
||||||
|
|
||||||
|
|
||||||
def group_deal_need_quote() -> str:
|
def group_deal_need_quote(*, work_order_no: str = "") -> str:
|
||||||
return "还没有可确认的报价,请先完成报价再确认成交状态。"
|
"""
|
||||||
|
群里确认成交/未成交时,主账还没有可确认报价。
|
||||||
|
|
||||||
|
空运 WO027:询价中且无 inq_quote,旧句「主账状态更新失败」不说明原因。
|
||||||
|
"""
|
||||||
|
no = (work_order_no or "").strip()
|
||||||
|
if no:
|
||||||
|
return (
|
||||||
|
f"工单{no}状态更新失败,原因:没有报价。\n"
|
||||||
|
f"请先报价,然后再更新工单状态。"
|
||||||
|
)
|
||||||
|
return "工单状态更新失败,原因:没有报价。\n请先报价,然后再更新工单状态。"
|
||||||
|
|
||||||
|
|
||||||
|
def deal_fail_no_quote(*, work_order_no: str = "") -> str:
|
||||||
|
"""成交三态写入失败且判定为没有报价时的销售可见说明。与群门禁同句。"""
|
||||||
|
return group_deal_need_quote(work_order_no=work_order_no)
|
||||||
|
|
||||||
|
|
||||||
def group_deal_sales_only() -> str:
|
def group_deal_sales_only() -> str:
|
||||||
@@ -3074,6 +3106,53 @@ def air_ask_lock_option(options: list) -> str:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def air_ask_manual_lock_fields(
|
||||||
|
*,
|
||||||
|
need_option: bool = False,
|
||||||
|
need_airline: bool = False,
|
||||||
|
need_chargeable: bool = False,
|
||||||
|
need_flight: bool = False,
|
||||||
|
options: list | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
手动报价锁舱缺 TMS MANUAL 必填项时,群里补问,禁止空壳出站。
|
||||||
|
|
||||||
|
例:请回复「AIR-OPT01 CZ 航班CZ327」。
|
||||||
|
"""
|
||||||
|
bits: list[str] = []
|
||||||
|
if need_option:
|
||||||
|
bits.append("舱位编号(如 AIR-OPT01)")
|
||||||
|
if need_airline:
|
||||||
|
bits.append("航司两位码(如 CZ)")
|
||||||
|
if need_flight:
|
||||||
|
bits.append("航班号(如 CZ327)")
|
||||||
|
if need_chargeable:
|
||||||
|
bits.append("计费重(公斤)")
|
||||||
|
need = "、".join(bits) if bits else "舱位编号、航司和航班号"
|
||||||
|
lines = [
|
||||||
|
f"锁舱还缺:{need}。",
|
||||||
|
"请按「AIR-OPT01 CZ 航班CZ327」回复;",
|
||||||
|
]
|
||||||
|
if need_chargeable:
|
||||||
|
lines.append("若还缺计费重,请再写「计费重xx公斤」。")
|
||||||
|
else:
|
||||||
|
lines.append("例如:AIR-OPT01 CZ 航班CZ327")
|
||||||
|
opts = options or []
|
||||||
|
shown = False
|
||||||
|
for item in opts:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
no = str(item.get("optionNo") or item.get("option_no") or "").strip()
|
||||||
|
airline = str(item.get("airlineCode") or item.get("airlineName") or "").strip()
|
||||||
|
if not no:
|
||||||
|
continue
|
||||||
|
if not shown:
|
||||||
|
lines.append("可选舱位:")
|
||||||
|
shown = True
|
||||||
|
lines.append(f"{no} {airline}".strip() if airline else no)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def air_lock_ok(
|
def air_lock_ok(
|
||||||
*,
|
*,
|
||||||
work_order_no: str,
|
work_order_no: str,
|
||||||
@@ -3128,7 +3207,7 @@ def multi_air_lock_unsupported(*, work_order_no: str, operator: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def air_lock_fail(*, work_order_no: str, operator: str, reason: str) -> str:
|
def air_lock_fail(*, work_order_no: str, operator: str, reason: str) -> str:
|
||||||
"""空运锁舱失败。后台 inquiring.air_lock_fail。"""
|
"""空运锁舱失败(接口/系统类)。后台 inquiring.air_lock_fail。"""
|
||||||
why = (reason or "").strip() or "TMS未返回原因"
|
why = (reason or "").strip() or "TMS未返回原因"
|
||||||
default = (
|
default = (
|
||||||
f"工单{work_order_no} 空运锁舱失败。\n"
|
f"工单{work_order_no} 空运锁舱失败。\n"
|
||||||
@@ -3145,6 +3224,25 @@ def air_lock_fail(*, work_order_no: str, operator: str, reason: str) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def air_lock_incomplete(*, work_order_no: str, operator: str, reason: str = "") -> str:
|
||||||
|
"""
|
||||||
|
锁舱参数不齐(纯手工价、运价库无舱位等):业务补问,不算系统异常,不惊动 IT。
|
||||||
|
|
||||||
|
航线按示例回舱位+航司+航班后即可再锁。
|
||||||
|
"""
|
||||||
|
why = (reason or "").strip()
|
||||||
|
lines = [
|
||||||
|
f"工单{work_order_no} 还不能锁舱。",
|
||||||
|
f"操作人:{operator}",
|
||||||
|
]
|
||||||
|
if why:
|
||||||
|
lines.append(f"原因:{why}")
|
||||||
|
else:
|
||||||
|
lines.append("原因:本单没有可引用的 TMS 舱位(手工价或运价库无匹配)。")
|
||||||
|
lines.append("请直接回复「AIR-OPT01 CZ 航班CZ327」(换成实际舱位、航司、航班号)后再锁。")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def air_cabin_fail_reason(*, tms_reason: str = "", flight_date: str = "", today: str = "") -> str:
|
def air_cabin_fail_reason(*, tms_reason: str = "", flight_date: str = "", today: str = "") -> str:
|
||||||
"""
|
"""
|
||||||
锁舱/释放失败原因:原样回 TMS 的 failReason。
|
锁舱/释放失败原因:原样回 TMS 的 failReason。
|
||||||
|
|||||||
@@ -934,12 +934,14 @@ def apply_product_quote(
|
|||||||
logger.info("产品报价未识别 wo=%s text=%s", no, (text or "")[:200])
|
logger.info("产品报价未识别 wo=%s text=%s", no, (text or "")[:200])
|
||||||
send_group_text(client, chat_id, copy.group_quote_not_recognized())
|
send_group_text(client, chat_id, copy.group_quote_not_recognized())
|
||||||
return "quote_not_recognized"
|
return "quote_not_recognized"
|
||||||
|
quote = _attach_land_weight_band_segment(quote, view)
|
||||||
base = _current_quote(view, sess)
|
base = _current_quote(view, sess)
|
||||||
if _has_tms_quote(base):
|
if _has_tms_quote(base):
|
||||||
_park_pending_quote(no, quote, sess, flow)
|
_park_pending_quote(no, quote, sess, flow)
|
||||||
send_group_text(client, chat_id, copy.group_ask_tms_or_product())
|
send_group_text(client, chat_id, copy.group_ask_tms_or_product())
|
||||||
return "wait_tms_choice"
|
return "wait_tms_choice"
|
||||||
if _quote_fee_rows(base):
|
# 分档单价是完整人工价(含费率),禁止和旧错误行(如燃油附加费=2)相加。
|
||||||
|
if _quote_fee_rows(base) and not (quote.get("weightPrices") or []):
|
||||||
quote = merge_quote_fees(base, quote)
|
quote = merge_quote_fees(base, quote)
|
||||||
_save_product_quote(flow, no, quote, sess)
|
_save_product_quote(flow, no, quote, sess)
|
||||||
ticket2 = _reload(flow, chat_id, no) or ticket
|
ticket2 = _reload(flow, chat_id, no) or ticket
|
||||||
@@ -1259,8 +1261,11 @@ def apply_group_deal(
|
|||||||
return "not_sales"
|
return "not_sales"
|
||||||
quote = _current_quote(view, sess)
|
quote = _current_quote(view, sess)
|
||||||
status_now = ticket_status(ticket) or (sess.status if sess else "")
|
status_now = ticket_status(ticket) or (sess.status if sess else "")
|
||||||
if not (quote.get("total") or _quote_fee_rows(quote) or status_now in {"已报价", "协商中"}):
|
usable_total = str(quote.get("total") or "").strip()
|
||||||
send_group_text(client, chat_id, copy.group_deal_need_quote())
|
if usable_total in {"", "-"}:
|
||||||
|
usable_total = ""
|
||||||
|
if not (usable_total or _quote_fee_rows(quote) or status_now in {"已报价", "协商中"}):
|
||||||
|
send_group_text(client, chat_id, copy.group_deal_need_quote(work_order_no=no))
|
||||||
return "deal_need_quote"
|
return "deal_need_quote"
|
||||||
from agent.policy.deal_outcome import finish_deal_outcome, harvest_inline_lost_reason
|
from agent.policy.deal_outcome import finish_deal_outcome, harvest_inline_lost_reason
|
||||||
|
|
||||||
@@ -1666,6 +1671,53 @@ def merge_quote_fees(base: dict[str, Any] | None, overlay: dict[str, Any] | None
|
|||||||
return stamp_prices_for_sheet(out)
|
return stamp_prices_for_sheet(out)
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_land_weight_band_segment(
|
||||||
|
quote: dict[str, Any], view: dict[str, Any]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
陆运产品分档价:把 weightPrices / 燃油税率挂到 landSegments,出单才能填「2-500KG」列。
|
||||||
|
|
||||||
|
无分档价时原样返回。OD 取工单询价事实,不另猜城市。
|
||||||
|
"""
|
||||||
|
src = dict(quote or {})
|
||||||
|
tiers = src.get("weightPrices")
|
||||||
|
if not isinstance(tiers, list) or not tiers:
|
||||||
|
return src
|
||||||
|
facts = dict(view.get("facts") or {})
|
||||||
|
origin = str(
|
||||||
|
facts.get("始发地")
|
||||||
|
or facts.get("起运地")
|
||||||
|
or facts.get("origin")
|
||||||
|
or view.get("origin")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
dest = str(
|
||||||
|
facts.get("目的地")
|
||||||
|
or facts.get("destination")
|
||||||
|
or view.get("destination")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
seg: dict[str, Any] = {
|
||||||
|
"origin": origin,
|
||||||
|
"destination": dest,
|
||||||
|
"weightPrices": list(tiers),
|
||||||
|
"currency": str(src.get("currency") or "CNY"),
|
||||||
|
"salePrice": str(src.get("total") or tiers[0].get("price") or ""),
|
||||||
|
"price_per_kg": str(src.get("price_per_kg") or tiers[0].get("price") or ""),
|
||||||
|
}
|
||||||
|
fuel = str(src.get("fuel_surcharge_rate") or src.get("fuelSurchargeRate") or "").strip()
|
||||||
|
tax = str(src.get("tax_rate") or src.get("taxRate") or "").strip()
|
||||||
|
if fuel:
|
||||||
|
seg["fuel_surcharge_rate"] = fuel
|
||||||
|
seg["fuelSurchargeRate"] = fuel
|
||||||
|
if tax:
|
||||||
|
seg["tax_rate"] = tax
|
||||||
|
seg["taxRate"] = tax
|
||||||
|
src["landSegments"] = [seg]
|
||||||
|
src["route_rows"] = [seg]
|
||||||
|
return src
|
||||||
|
|
||||||
|
|
||||||
def _save_product_quote(
|
def _save_product_quote(
|
||||||
flow: Any,
|
flow: Any,
|
||||||
no: str,
|
no: str,
|
||||||
|
|||||||
@@ -879,6 +879,7 @@ def _resume_cabin_step(
|
|||||||
work_order_no=no,
|
work_order_no=no,
|
||||||
lock_id=lock_id,
|
lock_id=lock_id,
|
||||||
sender_id=sender,
|
sender_id=sender,
|
||||||
|
operator_name=str(extra.get("operator_name") or "").strip(),
|
||||||
instruction_text=instruction,
|
instruction_text=instruction,
|
||||||
)
|
)
|
||||||
or {}
|
or {}
|
||||||
@@ -889,15 +890,20 @@ def _resume_cabin_step(
|
|||||||
service = "空运舱位"
|
service = "空运舱位"
|
||||||
reason = "空运释放舱位接口调用失败"
|
reason = "空运释放舱位接口调用失败"
|
||||||
else:
|
else:
|
||||||
|
# 与首次群锁舱对齐:重试必须带 operatorName / airline,否则 MANUAL 出站缺姓名。
|
||||||
locker = getattr(ledger, "lock_cabin", None)
|
locker = getattr(ledger, "lock_cabin", None)
|
||||||
|
op_name = str(extra.get("operator_name") or "").strip()
|
||||||
|
airline = str(extra.get("airline") or "").strip()
|
||||||
|
flight_no = str(extra.get("flight_no") or "").strip()
|
||||||
out = call_with_retries(
|
out = call_with_retries(
|
||||||
lambda: locker(
|
lambda: locker(
|
||||||
work_order_no=no,
|
work_order_no=no,
|
||||||
option_no=option_no,
|
option_no=option_no,
|
||||||
sender_id=sender,
|
sender_id=sender,
|
||||||
|
operator_name=op_name,
|
||||||
instruction_text=instruction,
|
instruction_text=instruction,
|
||||||
airline=str(extra.get("airline") or ""),
|
airline=airline,
|
||||||
flight_no=str(extra.get("flight_no") or ""),
|
flight_no=flight_no,
|
||||||
)
|
)
|
||||||
or {}
|
or {}
|
||||||
if callable(locker)
|
if callable(locker)
|
||||||
|
|||||||
@@ -164,10 +164,17 @@ def _hint_lock_or_release(text: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _only_work_order(text: str) -> bool:
|
def _only_work_order(text: str) -> bool:
|
||||||
"""去掉工单号和 @询价小助手 后几乎没字,当作激活/切单。"""
|
"""
|
||||||
|
去掉工单号和 @询价小助手 后几乎没字,当作激活/切单。
|
||||||
|
|
||||||
|
销售常按话术复制「工单编号WO…@询价小助手」;残留「工单编号」不算业务意图。
|
||||||
|
运行证据:only_wo=false 后走 extract_product_quote→product_quote→航线岗拒绝。
|
||||||
|
"""
|
||||||
no = copy.extract_work_order_no(text)
|
no = copy.extract_work_order_no(text)
|
||||||
rest = (text or "").replace(no, "").replace(no.lower(), "")
|
rest = (text or "").replace(no, "").replace(no.lower(), "")
|
||||||
rest = rest.replace("@询价小助手", "").replace("询价小助手", "")
|
rest = rest.replace("@询价小助手", "").replace("询价小助手", "")
|
||||||
|
for noise in ("工单编号", "工单号", "编号"):
|
||||||
|
rest = rest.replace(noise, "")
|
||||||
rest = "".join(ch for ch in rest if ch.isalnum() or "\u4e00" <= ch <= "\u9fff")
|
rest = "".join(ch for ch in rest if ch.isalnum() or "\u4e00" <= ch <= "\u9fff")
|
||||||
return not rest.strip()
|
return not rest.strip()
|
||||||
|
|
||||||
|
|||||||
@@ -527,6 +527,21 @@ class AirGroupCollabTests(unittest.TestCase):
|
|||||||
classify_air_group_text("@询价小助手 WO202609160002", allow_network=False),
|
classify_air_group_text("@询价小助手 WO202609160002", allow_network=False),
|
||||||
INTENT_ACTIVATE,
|
INTENT_ACTIVATE,
|
||||||
)
|
)
|
||||||
|
# 话术前缀「工单编号」不能当成报价(WO026 复现:误走 product_quote)。
|
||||||
|
self.assertEqual(
|
||||||
|
classify_air_group_text(
|
||||||
|
"工单编号WO202610070026@询价小助手",
|
||||||
|
allow_network=False,
|
||||||
|
),
|
||||||
|
INTENT_ACTIVATE,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
classify_air_group_text(
|
||||||
|
"工单编号 WO202610060026@询价小助手",
|
||||||
|
allow_network=False,
|
||||||
|
),
|
||||||
|
INTENT_ACTIVATE,
|
||||||
|
)
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
@@ -540,6 +555,13 @@ class AirGroupCollabTests(unittest.TestCase):
|
|||||||
),
|
),
|
||||||
INTENT_ACTIVATE,
|
INTENT_ACTIVATE,
|
||||||
)
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
classify_air_group_text(
|
||||||
|
"工单编号WO202610070026@询价小助手",
|
||||||
|
allow_network=True,
|
||||||
|
),
|
||||||
|
INTENT_ACTIVATE,
|
||||||
|
)
|
||||||
|
|
||||||
def test_dispatch_aibot_goes_air(self) -> None:
|
def test_dispatch_aibot_goes_air(self) -> None:
|
||||||
from agent.routing.dispatch import dispatch_inbound as _disp
|
from agent.routing.dispatch import dispatch_inbound as _disp
|
||||||
@@ -1051,7 +1073,8 @@ class AirGroupCollabTests(unittest.TestCase):
|
|||||||
self.assertFalse(any("AIR-OPT-01" in t for t in self._texts()))
|
self.assertFalse(any("AIR-OPT-01" in t for t in self._texts()))
|
||||||
self.assertFalse(any("执行成功" in t for t in self._texts()))
|
self.assertFalse(any("执行成功" in t for t in self._texts()))
|
||||||
|
|
||||||
def test_manual_quote_without_quote_id_can_lock(self) -> None:
|
def test_manual_quote_without_option_asks_fields(self) -> None:
|
||||||
|
"""纯人工价且口令无 AIR-OPT:交主账后仍缺 → lock_incomplete(不空壳成功)。"""
|
||||||
wo = self._create_air(
|
wo = self._create_air(
|
||||||
quote={
|
quote={
|
||||||
"total": "100",
|
"total": "100",
|
||||||
@@ -1688,7 +1711,7 @@ class AirGroupCollabTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_lock_without_quote_still_shows_tms_reason(self) -> None:
|
def test_lock_without_quote_still_shows_tms_reason(self) -> None:
|
||||||
"""TMS 拒绝锁舱时原样回失败原因,不要改写成选项不匹配。"""
|
"""TMS 拒绝锁舱时原样回失败原因,不要改写成选项不匹配。"""
|
||||||
wo = self._create_air(quote={}, status="询价中")
|
wo = self._create_air()
|
||||||
self.ledger._cabin_lock_result = {
|
self.ledger._cabin_lock_result = {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"error": "lock_fail",
|
"error": "lock_fail",
|
||||||
|
|||||||
@@ -1559,7 +1559,8 @@ class AirTextInquiryTests(unittest.TestCase):
|
|||||||
source="test",
|
source="test",
|
||||||
)
|
)
|
||||||
self.assertEqual(replay, "wait_deal")
|
self.assertEqual(replay, "wait_deal")
|
||||||
self.assertNotIn("咨询成交状态", "\n".join(again))
|
self.assertIn("咨询成交状态", "\n".join(again))
|
||||||
|
self.assertNotIn("请稍候", "\n".join(again))
|
||||||
self.assertEqual(http.session_of("u-pdf-x").phase, "wait_deal")
|
self.assertEqual(http.session_of("u-pdf-x").phase, "wait_deal")
|
||||||
|
|
||||||
def test_deal_click_works_while_http_still_wait_file(self) -> None:
|
def test_deal_click_works_while_http_still_wait_file(self) -> None:
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ class ContinueTicketTests(unittest.TestCase):
|
|||||||
sender_id="u6", work_order_no=wo, reply=reply
|
sender_id="u6", work_order_no=wo, reply=reply
|
||||||
)
|
)
|
||||||
self.assertEqual(phase, "wait_deal")
|
self.assertEqual(phase, "wait_deal")
|
||||||
self.assertFalse(any("成交" in x or copy.BTN_DEAL in x for x in replies))
|
self.assertTrue(any("【成交】【未成交】【协商中】" in x for x in replies))
|
||||||
|
|
||||||
def test_resume_spoken_deal_closes(self) -> None:
|
def test_resume_spoken_deal_closes(self) -> None:
|
||||||
"""报价文件已出后,原话写工单号加已成交,要入账并回复,不能没反应。"""
|
"""报价文件已出后,原话写工单号加已成交,要入账并回复,不能没反应。"""
|
||||||
|
|||||||
@@ -144,6 +144,51 @@ class TestDealOutcome(unittest.TestCase):
|
|||||||
view = self.ledger.get_for_agent(work_order_no=self.wo, sender_id="sales1")
|
view = self.ledger.get_for_agent(work_order_no=self.wo, sender_id="sales1")
|
||||||
self.assertEqual(view.get("wait_phase"), "done")
|
self.assertEqual(view.get("wait_phase"), "done")
|
||||||
|
|
||||||
|
def test_lost_from_inquiring_promotes_quoted_first(self) -> None:
|
||||||
|
"""主账仍询价中时点未成交:先补已报价再入未成交,禁止直接失败。"""
|
||||||
|
|
||||||
|
class _StrictSix:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.status = "询价中"
|
||||||
|
self.lost_reason = ""
|
||||||
|
self.event_wait_kind = ""
|
||||||
|
|
||||||
|
def transition(self, *, work_order_no, to_status, **kwargs):
|
||||||
|
_ = work_order_no
|
||||||
|
allowed = {
|
||||||
|
"询价中": {"已报价", "协商中", "已关闭"},
|
||||||
|
"已报价": {"协商中", "已成交", "未成交", "已关闭"},
|
||||||
|
}
|
||||||
|
if to_status not in allowed.get(self.status, set()):
|
||||||
|
# 模拟 http_ledger 在 500 时只回 http_500(丢失正文)
|
||||||
|
return {"ok": False, "error": "http_500"}
|
||||||
|
self.status = to_status
|
||||||
|
if to_status == "未成交" and kwargs.get("lost_reason"):
|
||||||
|
self.lost_reason = str(kwargs.get("lost_reason") or "")
|
||||||
|
return {"ok": True, "status": self.status}
|
||||||
|
|
||||||
|
def patch_lost_reason(self, *, work_order_no, lost_reason):
|
||||||
|
_ = work_order_no
|
||||||
|
self.lost_reason = lost_reason
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
def get_for_agent(self, *, work_order_no, sender_id):
|
||||||
|
_ = (work_order_no, sender_id)
|
||||||
|
return {"status": self.status, "lost_reason": self.lost_reason}
|
||||||
|
|
||||||
|
ledger = _StrictSix()
|
||||||
|
out = finish_deal_outcome(
|
||||||
|
ledger=ledger,
|
||||||
|
work_order_no="WO202610070023",
|
||||||
|
outcome="未成交",
|
||||||
|
facts={},
|
||||||
|
quote={},
|
||||||
|
business_line="AIR",
|
||||||
|
)
|
||||||
|
self.assertTrue(out.get("ok"), out)
|
||||||
|
self.assertEqual(ledger.status, "未成交")
|
||||||
|
self.assertEqual(out.get("phase"), "wait_lost_reason")
|
||||||
|
|
||||||
def test_lost_reason_ack_repeats_user_text(self) -> None:
|
def test_lost_reason_ack_repeats_user_text(self) -> None:
|
||||||
self.assertEqual(copy.lost_reason_ack("客户不要了"), "已记下未成交原因:客户不要了。")
|
self.assertEqual(copy.lost_reason_ack("客户不要了"), "已记下未成交原因:客户不要了。")
|
||||||
self.assertEqual(copy.lost_reason_ack("价格高。"), "已记下未成交原因:价格高。")
|
self.assertEqual(copy.lost_reason_ack("价格高。"), "已记下未成交原因:价格高。")
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from agent.policy.event_exception import (
|
|||||||
WAIT_PRODUCT_QUOTE,
|
WAIT_PRODUCT_QUOTE,
|
||||||
conversation_of,
|
conversation_of,
|
||||||
format_timeout_hours,
|
format_timeout_hours,
|
||||||
|
mentions_for_role,
|
||||||
pick_app_targets,
|
pick_app_targets,
|
||||||
pick_mentions,
|
pick_mentions,
|
||||||
timeout_code,
|
timeout_code,
|
||||||
@@ -221,6 +222,9 @@ class TestPoll(unittest.TestCase):
|
|||||||
from agent.ledger.memory_ledger import MemoryLedger
|
from agent.ledger.memory_ledger import MemoryLedger
|
||||||
from agent.policy.deal_followup import SHANGHAI
|
from agent.policy.deal_followup import SHANGHAI
|
||||||
|
|
||||||
|
# 钉在周五,避免周日跑单测时工作日折算为 0
|
||||||
|
now = datetime(2026, 10, 9, 18, 0, 0, tzinfo=SHANGHAI)
|
||||||
|
|
||||||
path = os.path.join(ROOT, "agent", "jobs", "event_exception_poll.py")
|
path = os.path.join(ROOT, "agent", "jobs", "event_exception_poll.py")
|
||||||
spec = importlib.util.spec_from_file_location("ee_poll", path)
|
spec = importlib.util.spec_from_file_location("ee_poll", path)
|
||||||
self.assertIsNotNone(spec)
|
self.assertIsNotNone(spec)
|
||||||
@@ -247,9 +251,7 @@ class TestPoll(unittest.TestCase):
|
|||||||
t = book.get_ticket(work_order_no=wo)
|
t = book.get_ticket(work_order_no=wo)
|
||||||
t.collab_chat_id = "wr1"
|
t.collab_chat_id = "wr1"
|
||||||
t.collab_member_ids = ["p1"]
|
t.collab_member_ids = ["p1"]
|
||||||
t.event_wait_started_at = (datetime.now(SHANGHAI) - timedelta(hours=3)).strftime(
|
t.event_wait_started_at = (now - timedelta(hours=3)).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
"%Y-%m-%d %H:%M:%S"
|
|
||||||
)
|
|
||||||
book._timeouts["sea_product_other"] = 0.25
|
book._timeouts["sea_product_other"] = 0.25
|
||||||
groups: list[dict] = []
|
groups: list[dict] = []
|
||||||
|
|
||||||
@@ -257,7 +259,6 @@ class TestPoll(unittest.TestCase):
|
|||||||
groups.append({"chat_id": chat_id, "content": content, "line": business_line})
|
groups.append({"chat_id": chat_id, "content": content, "line": business_line})
|
||||||
return True
|
return True
|
||||||
|
|
||||||
now = datetime.now(SHANGHAI)
|
|
||||||
n = poll_event_exceptions(
|
n = poll_event_exceptions(
|
||||||
ledger=book, now=now, send_group=send_group, notify_ready=False
|
ledger=book, now=now, send_group=send_group, notify_ready=False
|
||||||
)
|
)
|
||||||
@@ -270,6 +271,349 @@ class TestPoll(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(n2, 0)
|
self.assertEqual(n2, 0)
|
||||||
|
|
||||||
|
def test_air_group_names_airline_when_members_missing(self) -> None:
|
||||||
|
"""空运群没记下成员时,超时要 @ 航线姓名,不能写成相关人员。"""
|
||||||
|
import importlib.util
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from agent.ledger.memory_ledger import MemoryLedger
|
||||||
|
from agent.policy.deal_followup import SHANGHAI
|
||||||
|
|
||||||
|
path = os.path.join(ROOT, "agent", "jobs", "event_exception_poll.py")
|
||||||
|
spec = importlib.util.spec_from_file_location("ee_poll_air", path)
|
||||||
|
self.assertIsNotNone(spec)
|
||||||
|
self.assertIsNotNone(spec.loader)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
|
||||||
|
book = MemoryLedger()
|
||||||
|
book.set_sea_staff(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"wecomId": "sales1",
|
||||||
|
"name": "罗小华",
|
||||||
|
"roleCode": "sales",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"wecomId": "air1",
|
||||||
|
"name": "吴季林",
|
||||||
|
"roleCode": "air",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
created = book.create_ticket(sender_id="sales1", business_line="AIR", facts={"起运港": "南京"})
|
||||||
|
wo = created["work_order_no"]
|
||||||
|
now = datetime(2026, 10, 9, 18, 0, 0, tzinfo=SHANGHAI)
|
||||||
|
book.start_event_wait(work_order_no=wo, wait_kind="air_quote")
|
||||||
|
ticket = book.get_ticket(work_order_no=wo)
|
||||||
|
ticket.collab_chat_id = "wr-air"
|
||||||
|
ticket.collab_member_ids = []
|
||||||
|
ticket.event_wait_started_at = (now - timedelta(hours=3)).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
book._timeouts["air_route"] = 0.25
|
||||||
|
groups: list[dict] = []
|
||||||
|
|
||||||
|
def send_group(*, chat_id: str, content: str, business_line: str = "") -> bool:
|
||||||
|
groups.append({"chat_id": chat_id, "content": content, "line": business_line})
|
||||||
|
return True
|
||||||
|
|
||||||
|
n = mod.poll_event_exceptions(
|
||||||
|
ledger=book,
|
||||||
|
now=now,
|
||||||
|
send_group=send_group,
|
||||||
|
notify_ready=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(n, 1)
|
||||||
|
self.assertEqual(len(groups), 1)
|
||||||
|
self.assertEqual(
|
||||||
|
groups[0]["content"],
|
||||||
|
f"航线响应超时\n关联询价工单 {wo}。@吴季林已超过 0.25 工作小时 无响应,事件异常已记录工单,请及时处理。",
|
||||||
|
)
|
||||||
|
self.assertNotIn("相关人员", groups[0]["content"])
|
||||||
|
|
||||||
|
def test_sales_timeout_mentions_sales_not_airline(self) -> None:
|
||||||
|
"""销售超时只 @ 本单销售。私聊也带 @,不点航线,不写相关人员。"""
|
||||||
|
import importlib.util
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from agent.ledger.memory_ledger import MemoryLedger
|
||||||
|
from agent.policy.deal_followup import SHANGHAI
|
||||||
|
|
||||||
|
path = os.path.join(ROOT, "agent", "jobs", "event_exception_poll.py")
|
||||||
|
spec = importlib.util.spec_from_file_location("ee_poll_sales", path)
|
||||||
|
self.assertIsNotNone(spec)
|
||||||
|
self.assertIsNotNone(spec.loader)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
|
||||||
|
book = MemoryLedger()
|
||||||
|
book.set_sea_staff(
|
||||||
|
[
|
||||||
|
{"wecomId": "sales1", "name": "罗小华", "roleCode": "sales", "status": "active"},
|
||||||
|
{"wecomId": "air1", "name": "吴季林", "roleCode": "air", "status": "active"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
now = datetime(2026, 10, 9, 18, 0, 0, tzinfo=SHANGHAI)
|
||||||
|
created = book.create_ticket(sender_id="sales1", business_line="AIR", facts={})
|
||||||
|
wo = created["work_order_no"]
|
||||||
|
book.start_event_wait(work_order_no=wo, wait_kind="adopt")
|
||||||
|
ticket = book.get_ticket(work_order_no=wo)
|
||||||
|
ticket.event_wait_started_at = (now - timedelta(hours=3)).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
book._timeouts["air_sales"] = 0.25
|
||||||
|
private: list[dict] = []
|
||||||
|
|
||||||
|
def enqueue(**kwargs):
|
||||||
|
private.append(kwargs)
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
def send_group(**kwargs) -> bool:
|
||||||
|
raise AssertionError("销售采用超时不应发到群")
|
||||||
|
|
||||||
|
n = mod.poll_event_exceptions(
|
||||||
|
ledger=book,
|
||||||
|
now=now,
|
||||||
|
enqueue=enqueue,
|
||||||
|
send_group=send_group,
|
||||||
|
notify_ready=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(n, 1)
|
||||||
|
self.assertEqual(len(private), 1)
|
||||||
|
self.assertEqual(
|
||||||
|
private[0]["content"],
|
||||||
|
f"销售响应超时\n关联询价工单 {wo}。@罗小华已超过 0.25 工作小时 无响应,事件异常已记录工单,请及时处理。",
|
||||||
|
)
|
||||||
|
self.assertNotIn("吴季林", private[0]["content"])
|
||||||
|
self.assertNotIn("相关人员", private[0]["content"])
|
||||||
|
|
||||||
|
created_deal = book.create_ticket(sender_id="sales1", business_line="AIR", facts={"目的港": "胡志明"})
|
||||||
|
deal_no = created_deal["work_order_no"]
|
||||||
|
book.start_event_wait(work_order_no=deal_no, wait_kind="deal")
|
||||||
|
deal = book.get_ticket(work_order_no=deal_no)
|
||||||
|
deal.collab_chat_id = "wr-air"
|
||||||
|
deal.event_wait_started_at = ticket.event_wait_started_at
|
||||||
|
groups: list[dict] = []
|
||||||
|
|
||||||
|
def send_deal_group(*, chat_id: str, content: str, business_line: str = "") -> bool:
|
||||||
|
groups.append({"chat_id": chat_id, "content": content})
|
||||||
|
return True
|
||||||
|
|
||||||
|
n_deal = mod.poll_event_exceptions(
|
||||||
|
ledger=book,
|
||||||
|
now=now,
|
||||||
|
enqueue=enqueue,
|
||||||
|
send_group=send_deal_group,
|
||||||
|
notify_ready=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(n_deal, 1)
|
||||||
|
self.assertEqual(
|
||||||
|
groups[0]["content"],
|
||||||
|
f"销售响应超时\n关联询价工单 {deal_no}。@罗小华已超过 0.25 工作小时 无响应,事件异常已记录工单,请及时处理。",
|
||||||
|
)
|
||||||
|
self.assertNotIn("吴季林", groups[0]["content"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestMentionsForRole(unittest.TestCase):
|
||||||
|
def test_keeps_active_named_role_only(self) -> None:
|
||||||
|
rows = mentions_for_role(
|
||||||
|
[
|
||||||
|
{"wecomId": "a1", "name": "吴季林", "roleCode": "air", "status": "active"},
|
||||||
|
{"wecomId": "s1", "name": "罗小华", "roleCode": "sales", "status": "active"},
|
||||||
|
{"wecomId": "a2", "name": "停用航线", "roleCode": "air", "status": "disabled"},
|
||||||
|
{"wecomId": "a3", "name": "", "roleCode": "air", "status": "active"},
|
||||||
|
],
|
||||||
|
role_code="air",
|
||||||
|
)
|
||||||
|
self.assertEqual([row["name"] for row in rows], ["吴季林"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestDealOutcomeStopsTimeout(unittest.TestCase):
|
||||||
|
"""对应现网截图:协商中顺延 / 未成交已写原因后不应再发销售响应超时。"""
|
||||||
|
|
||||||
|
def _poll_mod(self):
|
||||||
|
import importlib.util
|
||||||
|
|
||||||
|
path = os.path.join(ROOT, "agent", "jobs", "event_exception_poll.py")
|
||||||
|
spec = importlib.util.spec_from_file_location("ee_poll_deal_stop", path)
|
||||||
|
self.assertIsNotNone(spec)
|
||||||
|
self.assertIsNotNone(spec.loader)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
def _book_with_deal_wait(self):
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from agent.ledger.memory_ledger import MemoryLedger
|
||||||
|
from agent.policy.deal_followup import SHANGHAI
|
||||||
|
|
||||||
|
book = MemoryLedger()
|
||||||
|
book.set_sea_staff(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"wecomId": "sales1",
|
||||||
|
"name": "罗小华",
|
||||||
|
"roleCode": "sales",
|
||||||
|
"status": "active",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
created = book.create_ticket(sender_id="sales1", business_line="AIR", facts={})
|
||||||
|
wo = created["work_order_no"]
|
||||||
|
book.transition(work_order_no=wo, to_status="已报价")
|
||||||
|
book.start_event_wait(work_order_no=wo, wait_kind="deal")
|
||||||
|
ticket = book.get_ticket(work_order_no=wo)
|
||||||
|
ticket.collab_chat_id = "wr-deal"
|
||||||
|
ticket.event_wait_started_at = (datetime.now(SHANGHAI) - timedelta(hours=72)).strftime(
|
||||||
|
"%Y-%m-%d %H:%M:%S"
|
||||||
|
)
|
||||||
|
book._timeouts["air_sales"] = 48
|
||||||
|
return book, wo
|
||||||
|
|
||||||
|
def test_negotiate_clears_wait_no_timeout(self) -> None:
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from agent.policy.deal_followup import SHANGHAI
|
||||||
|
from agent.policy.deal_outcome import finish_deal_outcome
|
||||||
|
|
||||||
|
book, wo = self._book_with_deal_wait()
|
||||||
|
result = finish_deal_outcome(
|
||||||
|
ledger=book,
|
||||||
|
work_order_no=wo,
|
||||||
|
outcome="协商中",
|
||||||
|
facts={},
|
||||||
|
quote={"total": "100"},
|
||||||
|
business_line="AIR",
|
||||||
|
sales_name="罗小华",
|
||||||
|
notify_ready=False,
|
||||||
|
)
|
||||||
|
self.assertTrue(result.get("ok"))
|
||||||
|
ticket = book.get_ticket(work_order_no=wo)
|
||||||
|
self.assertEqual(ticket.status, "协商中")
|
||||||
|
self.assertEqual(ticket.event_wait_kind, "")
|
||||||
|
groups: list[dict] = []
|
||||||
|
|
||||||
|
n = self._poll_mod().poll_event_exceptions(
|
||||||
|
ledger=book,
|
||||||
|
now=datetime.now(SHANGHAI),
|
||||||
|
send_group=lambda **kwargs: groups.append(kwargs) or True,
|
||||||
|
notify_ready=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(n, 0)
|
||||||
|
self.assertEqual(groups, [])
|
||||||
|
|
||||||
|
def test_lost_with_reason_clears_wait_no_timeout(self) -> None:
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from agent.policy.deal_followup import SHANGHAI
|
||||||
|
from agent.policy.deal_outcome import finish_deal_outcome
|
||||||
|
|
||||||
|
book, wo = self._book_with_deal_wait()
|
||||||
|
result = finish_deal_outcome(
|
||||||
|
ledger=book,
|
||||||
|
work_order_no=wo,
|
||||||
|
outcome="未成交",
|
||||||
|
facts={},
|
||||||
|
quote={"total": "100"},
|
||||||
|
business_line="AIR",
|
||||||
|
sales_name="罗小华",
|
||||||
|
lost_reason="客户不要了",
|
||||||
|
notify_ready=False,
|
||||||
|
)
|
||||||
|
self.assertTrue(result.get("ok"))
|
||||||
|
self.assertEqual(result.get("phase"), "done")
|
||||||
|
ticket = book.get_ticket(work_order_no=wo)
|
||||||
|
self.assertEqual(ticket.status, "未成交")
|
||||||
|
self.assertEqual(ticket.lost_reason, "客户不要了")
|
||||||
|
self.assertEqual(ticket.event_wait_kind, "")
|
||||||
|
groups: list[dict] = []
|
||||||
|
|
||||||
|
n = self._poll_mod().poll_event_exceptions(
|
||||||
|
ledger=book,
|
||||||
|
now=datetime.now(SHANGHAI),
|
||||||
|
send_group=lambda **kwargs: groups.append(kwargs) or True,
|
||||||
|
notify_ready=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(n, 0)
|
||||||
|
self.assertEqual(groups, [])
|
||||||
|
|
||||||
|
def test_stale_deal_wait_during_negotiate_is_skipped(self) -> None:
|
||||||
|
"""截图回归:协商中已顺延,即使库里仍留着陈旧 deal 等待,也不再发超时。"""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from agent.policy.deal_followup import SHANGHAI
|
||||||
|
from agent.policy.deal_outcome import finish_deal_outcome
|
||||||
|
|
||||||
|
book, wo = self._book_with_deal_wait()
|
||||||
|
ticket = book.get_ticket(work_order_no=wo)
|
||||||
|
started = ticket.event_wait_started_at
|
||||||
|
result = finish_deal_outcome(
|
||||||
|
ledger=book,
|
||||||
|
work_order_no=wo,
|
||||||
|
outcome="协商中",
|
||||||
|
facts={},
|
||||||
|
quote={"total": "100"},
|
||||||
|
business_line="AIR",
|
||||||
|
sales_name="罗小华",
|
||||||
|
notify_ready=False,
|
||||||
|
)
|
||||||
|
self.assertTrue(result.get("ok"))
|
||||||
|
ticket = book.get_ticket(work_order_no=wo)
|
||||||
|
self.assertEqual(ticket.status, "协商中")
|
||||||
|
self.assertTrue((ticket.next_followup_at or "").strip())
|
||||||
|
# 人为留下陈旧等待(模拟 clear/迁态未清干净),巡检必须跳过
|
||||||
|
ticket.event_wait_kind = "deal"
|
||||||
|
ticket.event_wait_started_at = started
|
||||||
|
ticket.event_wait_alerted = 0
|
||||||
|
groups: list[dict] = []
|
||||||
|
|
||||||
|
n = self._poll_mod().poll_event_exceptions(
|
||||||
|
ledger=book,
|
||||||
|
now=datetime.now(SHANGHAI),
|
||||||
|
send_group=lambda **kwargs: groups.append(kwargs) or True,
|
||||||
|
notify_ready=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(n, 0)
|
||||||
|
self.assertEqual(groups, [])
|
||||||
|
|
||||||
|
def test_stale_deal_wait_after_lost_reason_is_skipped(self) -> None:
|
||||||
|
"""截图回归:未成交原因已记,陈旧 deal 等待不再触发销售超时。"""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from agent.policy.deal_followup import SHANGHAI
|
||||||
|
from agent.policy.deal_outcome import finish_deal_outcome
|
||||||
|
|
||||||
|
book, wo = self._book_with_deal_wait()
|
||||||
|
ticket = book.get_ticket(work_order_no=wo)
|
||||||
|
started = ticket.event_wait_started_at
|
||||||
|
result = finish_deal_outcome(
|
||||||
|
ledger=book,
|
||||||
|
work_order_no=wo,
|
||||||
|
outcome="未成交",
|
||||||
|
facts={},
|
||||||
|
quote={"total": "100"},
|
||||||
|
business_line="AIR",
|
||||||
|
sales_name="罗小华",
|
||||||
|
lost_reason="客户不要了",
|
||||||
|
notify_ready=False,
|
||||||
|
)
|
||||||
|
self.assertTrue(result.get("ok"))
|
||||||
|
ticket = book.get_ticket(work_order_no=wo)
|
||||||
|
self.assertEqual(ticket.lost_reason, "客户不要了")
|
||||||
|
ticket.event_wait_kind = "deal"
|
||||||
|
ticket.event_wait_started_at = started
|
||||||
|
ticket.event_wait_alerted = 0
|
||||||
|
groups: list[dict] = []
|
||||||
|
|
||||||
|
n = self._poll_mod().poll_event_exceptions(
|
||||||
|
ledger=book,
|
||||||
|
now=datetime.now(SHANGHAI),
|
||||||
|
send_group=lambda **kwargs: groups.append(kwargs) or True,
|
||||||
|
notify_ready=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(n, 0)
|
||||||
|
self.assertEqual(groups, [])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -1341,6 +1341,28 @@ class SeaGroupCollabFieldTests(unittest.TestCase):
|
|||||||
self.assertEqual(str(rows["海运费"].get("amount") or ""), "100.00")
|
self.assertEqual(str(rows["海运费"].get("amount") or ""), "100.00")
|
||||||
self.assertEqual(str(rows["燃油附加费"].get("amount") or ""), "15.00")
|
self.assertEqual(str(rows["燃油附加费"].get("amount") or ""), "15.00")
|
||||||
|
|
||||||
|
def test_land_weight_band_unit_price_and_rates(self) -> None:
|
||||||
|
"""陆运集拼:2-500KG 单价 + 燃油附加费率/税率,不能把档下限 2 当成燃油附加费。"""
|
||||||
|
from agent.llm.extract_text import extract_product_quote
|
||||||
|
|
||||||
|
quote = extract_product_quote(
|
||||||
|
"2-500KG 单价CNY990,燃油附加费率1%,税率6%"
|
||||||
|
)
|
||||||
|
self.assertEqual(str(quote.get("total") or ""), "990")
|
||||||
|
self.assertEqual(str(quote.get("fuel_surcharge_rate") or ""), "1%")
|
||||||
|
self.assertEqual(str(quote.get("tax_rate") or ""), "6%")
|
||||||
|
tiers = list(quote.get("weightPrices") or [])
|
||||||
|
self.assertEqual(len(tiers), 1)
|
||||||
|
self.assertEqual(int(tiers[0].get("min") or 0), 2)
|
||||||
|
self.assertEqual(int(tiers[0].get("max") or 0), 500)
|
||||||
|
self.assertEqual(str(tiers[0].get("price") or ""), "990")
|
||||||
|
rows = {str(x.get("name") or ""): x for x in (quote.get("fee_rows") or [])}
|
||||||
|
self.assertIn("2-500KG", rows)
|
||||||
|
self.assertEqual(str(rows["2-500KG"].get("amount") or ""), "990")
|
||||||
|
self.assertNotEqual(str(rows.get("燃油附加费", {}).get("amount") or ""), "2")
|
||||||
|
self.assertEqual(str(rows.get("燃油附加费率", {}).get("amount") or ""), "1%")
|
||||||
|
self.assertEqual(str(rows.get("税率", {}).get("amount") or ""), "6%")
|
||||||
|
|
||||||
def test_air_unit_qty_amount_keeps_air_freight_name(self) -> None:
|
def test_air_unit_qty_amount_keeps_air_freight_name(self) -> None:
|
||||||
"""空运费 单价/数量/金额:费用名是空运费,金额用「金额」不是单价。"""
|
"""空运费 单价/数量/金额:费用名是空运费,金额用「金额」不是单价。"""
|
||||||
from agent.llm.extract_text import extract_product_quote
|
from agent.llm.extract_text import extract_product_quote
|
||||||
@@ -1580,7 +1602,7 @@ class SeaContinueTests(unittest.TestCase):
|
|||||||
phase = handle_continue_thread(msg, thread_id=wo, store=store)
|
phase = handle_continue_thread(msg, thread_id=wo, store=store)
|
||||||
self.assertEqual(phase, "wait_deal")
|
self.assertEqual(phase, "wait_deal")
|
||||||
joined = "\n".join(item.content for item in store._outbox.values())
|
joined = "\n".join(item.content for item in store._outbox.values())
|
||||||
self.assertFalse(any(x in joined for x in (copy.BTN_DEAL, "成交")))
|
self.assertIn("【成交】【未成交】【协商中】", joined)
|
||||||
self.assertNotIn(copy.BTN_SKIP_COLLAB, joined)
|
self.assertNotIn(copy.BTN_SKIP_COLLAB, joined)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -480,27 +480,45 @@ class TestConfirmSystemException(unittest.TestCase):
|
|||||||
self.assertTrue("USD" in joined or "拉产品" in joined or "已报价" in joined)
|
self.assertTrue("USD" in joined or "拉产品" in joined or "已报价" in joined)
|
||||||
|
|
||||||
def test_admin_retry_cabin_lock_runs_lock(self) -> None:
|
def test_admin_retry_cabin_lock_runs_lock(self) -> None:
|
||||||
|
# 空运单 + 可锁报价;重试必须带上 operator_name / airline。
|
||||||
|
air = self.ledger.create_ticket(
|
||||||
|
sender_id="sales1",
|
||||||
|
business_line="AIR",
|
||||||
|
facts={"起运地": "上海", "目的地": "洛杉矶", "毛重": "100"},
|
||||||
|
)
|
||||||
|
air_wo = str(air.get("work_order_no") or "")
|
||||||
self.ledger.upsert_quote(
|
self.ledger.upsert_quote(
|
||||||
work_order_no=self.wo,
|
work_order_no=air_wo,
|
||||||
quote={"total": "100", "quoteId": "QT-AIR-1"},
|
quote={
|
||||||
|
"total": "800",
|
||||||
|
"source": "TMS",
|
||||||
|
"quoteId": "QT-AIR-1",
|
||||||
|
"airOptions": [{"optionNo": "AIR-OPT-01", "airlineCode": "CZ"}],
|
||||||
|
},
|
||||||
to_status="已报价",
|
to_status="已报价",
|
||||||
)
|
)
|
||||||
self.ledger.mark_system_exception(
|
self.ledger.mark_system_exception(
|
||||||
work_order_no=self.wo,
|
work_order_no=air_wo,
|
||||||
system_exception=TYPE_TMS,
|
system_exception=TYPE_TMS,
|
||||||
system_exception_reason="空运锁舱接口调用失败",
|
system_exception_reason="空运锁舱接口调用失败",
|
||||||
step=STEP_TMS_CABIN,
|
step=STEP_TMS_CABIN,
|
||||||
payload={
|
payload={
|
||||||
"conversation_kind": "group",
|
"conversation_kind": "group",
|
||||||
"conversation_target": "chat-air",
|
"conversation_target": "chat-air",
|
||||||
"extra": {"option_no": "AIR-OPT-01", "instruction": "锁舱"},
|
"extra": {
|
||||||
|
"option_no": "AIR-OPT-01",
|
||||||
|
"instruction": "锁舱",
|
||||||
|
"operator_name": "HangXian",
|
||||||
|
"airline": "CZ",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
hits = {"n": 0}
|
hits = {"n": 0, "kwargs": {}}
|
||||||
orig_lock = self.ledger.lock_cabin
|
orig_lock = self.ledger.lock_cabin
|
||||||
|
|
||||||
def counted(**kwargs):
|
def counted(**kwargs):
|
||||||
hits["n"] += 1
|
hits["n"] += 1
|
||||||
|
hits["kwargs"] = dict(kwargs)
|
||||||
return orig_lock(**kwargs)
|
return orig_lock(**kwargs)
|
||||||
|
|
||||||
self.ledger.lock_cabin = counted # type: ignore[method-assign]
|
self.ledger.lock_cabin = counted # type: ignore[method-assign]
|
||||||
@@ -512,12 +530,14 @@ class TestConfirmSystemException(unittest.TestCase):
|
|||||||
orig = se._send_to_conversation
|
orig = se._send_to_conversation
|
||||||
se._send_to_conversation = lambda **kwargs: talks.append(str(kwargs.get("text") or ""))
|
se._send_to_conversation = lambda **kwargs: talks.append(str(kwargs.get("text") or ""))
|
||||||
try:
|
try:
|
||||||
out = run_admin_retry(self.wo, ledger=self.ledger)
|
out = run_admin_retry(air_wo, ledger=self.ledger)
|
||||||
finally:
|
finally:
|
||||||
se._send_to_conversation = orig
|
se._send_to_conversation = orig
|
||||||
self.assertTrue(out.get("ok"))
|
self.assertTrue(out.get("ok"))
|
||||||
self.assertGreaterEqual(hits["n"], 1)
|
self.assertGreaterEqual(hits["n"], 1)
|
||||||
ticket = self.ledger.get_ticket(work_order_no=self.wo)
|
self.assertEqual(hits["kwargs"].get("operator_name"), "HangXian")
|
||||||
|
self.assertEqual(hits["kwargs"].get("airline"), "CZ")
|
||||||
|
ticket = self.ledger.get_ticket(work_order_no=air_wo)
|
||||||
self.assertFalse(ticket_is_paused(ticket))
|
self.assertFalse(ticket_is_paused(ticket))
|
||||||
self.assertTrue(any("空运舱位接口已恢复" in x for x in talks))
|
self.assertTrue(any("空运舱位接口已恢复" in x for x in talks))
|
||||||
|
|
||||||
|
|||||||
+3
-87
@@ -2,8 +2,6 @@ package org.jeecg.modules.inquiry.service.impl;
|
|||||||
|
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.apache.shiro.SecurityUtils;
|
|
||||||
import org.jeecg.common.system.vo.LoginUser;
|
|
||||||
import org.jeecg.modules.inquiry.domain.TicketStatuses;
|
import org.jeecg.modules.inquiry.domain.TicketStatuses;
|
||||||
import org.jeecg.modules.inquiry.entity.InquiryQuote;
|
import org.jeecg.modules.inquiry.entity.InquiryQuote;
|
||||||
import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
|
import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
|
||||||
@@ -14,6 +12,7 @@ import org.jeecg.modules.inquiry.mapper.InquiryStaffRoleMapper;
|
|||||||
import org.jeecg.modules.inquiry.mapper.InquiryTicketMapper;
|
import org.jeecg.modules.inquiry.mapper.InquiryTicketMapper;
|
||||||
import org.jeecg.modules.inquiry.mapper.InquiryTicketStatusLogMapper;
|
import org.jeecg.modules.inquiry.mapper.InquiryTicketStatusLogMapper;
|
||||||
import org.jeecg.modules.inquiry.service.IInquiryDashboardService;
|
import org.jeecg.modules.inquiry.service.IInquiryDashboardService;
|
||||||
|
import org.jeecg.modules.inquiry.support.InquiryDataScopeSupport;
|
||||||
import org.jeecg.modules.inquiry.support.InquiryTicketDisplaySupport;
|
import org.jeecg.modules.inquiry.support.InquiryTicketDisplaySupport;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -95,7 +94,7 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
|||||||
LambdaQueryWrapper<InquiryTicket> qw = new LambdaQueryWrapper<InquiryTicket>()
|
LambdaQueryWrapper<InquiryTicket> qw = new LambdaQueryWrapper<InquiryTicket>()
|
||||||
.ge(InquiryTicket::getCreateTime, from)
|
.ge(InquiryTicket::getCreateTime, from)
|
||||||
.le(InquiryTicket::getCreateTime, to);
|
.le(InquiryTicket::getCreateTime, to);
|
||||||
applyDashboardSelfOnly(qw);
|
InquiryDataScopeSupport.applySelfOnly(qw);
|
||||||
List<InquiryTicket> tickets = ticketMapper.selectList(qw);
|
List<InquiryTicket> tickets = ticketMapper.selectList(qw);
|
||||||
|
|
||||||
long total = tickets.size();
|
long total = tickets.size();
|
||||||
@@ -133,7 +132,7 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
|||||||
LambdaQueryWrapper<InquiryTicket> weekQw = new LambdaQueryWrapper<InquiryTicket>()
|
LambdaQueryWrapper<InquiryTicket> weekQw = new LambdaQueryWrapper<InquiryTicket>()
|
||||||
.ge(InquiryTicket::getCreateTime, weekFrom)
|
.ge(InquiryTicket::getCreateTime, weekFrom)
|
||||||
.le(InquiryTicket::getCreateTime, to);
|
.le(InquiryTicket::getCreateTime, to);
|
||||||
applyDashboardSelfOnly(weekQw);
|
InquiryDataScopeSupport.applySelfOnly(weekQw);
|
||||||
List<InquiryTicket> weekTickets = ticketMapper.selectList(weekQw);
|
List<InquiryTicket> weekTickets = ticketMapper.selectList(weekQw);
|
||||||
|
|
||||||
List<String> trendDates = new ArrayList<>(7);
|
List<String> trendDates = new ArrayList<>(7);
|
||||||
@@ -667,87 +666,4 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
|||||||
return "sales";
|
return "sales";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 账号勾选「只看自己数据」时收紧看板工单范围。
|
|
||||||
* <p>
|
|
||||||
* 口径对齐原项目:非超管且 dashboardSelfOnly=true →
|
|
||||||
* 工单 salesWecomId = 当前账号工号(企微 id)或 createBy = 登录名。
|
|
||||||
* 超管(username=admin 或角色 admin)忽略开关。
|
|
||||||
*/
|
|
||||||
private void applyDashboardSelfOnly(LambdaQueryWrapper<InquiryTicket> qw) {
|
|
||||||
LoginUser login = currentLoginUser();
|
|
||||||
if (login == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isAdministrator(login)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!Boolean.TRUE.equals(login.getDashboardSelfOnly())) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
String username = StringUtils.trimToEmpty(login.getUsername());
|
|
||||||
String wecom = dashboardWecomId(login);
|
|
||||||
qw.and(w -> {
|
|
||||||
boolean any = false;
|
|
||||||
if (StringUtils.isNotBlank(wecom)) {
|
|
||||||
w.eq(InquiryTicket::getSalesWecomId, wecom);
|
|
||||||
any = true;
|
|
||||||
}
|
|
||||||
if (StringUtils.isNotBlank(username)) {
|
|
||||||
if (any) {
|
|
||||||
w.or();
|
|
||||||
}
|
|
||||||
w.eq(InquiryTicket::getCreateBy, username);
|
|
||||||
any = true;
|
|
||||||
}
|
|
||||||
if (!any) {
|
|
||||||
// 未绑企微且无用户名:宁可不看数据,也不漏看全站
|
|
||||||
w.apply("1 = 0");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static LoginUser currentLoginUser() {
|
|
||||||
try {
|
|
||||||
Object p = SecurityUtils.getSubject().getPrincipal();
|
|
||||||
if (p instanceof LoginUser) {
|
|
||||||
return (LoginUser) p;
|
|
||||||
}
|
|
||||||
} catch (Exception ignored) {
|
|
||||||
// 无登录上下文时不做过滤
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 超管:登录名 admin,或 Shiro 角色 admin/管理员。 */
|
|
||||||
private static boolean isAdministrator(LoginUser login) {
|
|
||||||
if (login == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if ("admin".equalsIgnoreCase(StringUtils.trimToEmpty(login.getUsername()))) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return SecurityUtils.getSubject().hasRole("admin")
|
|
||||||
|| SecurityUtils.getSubject().hasRole("管理员");
|
|
||||||
} catch (Exception e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 看板「只看自己」用的企微工号。
|
|
||||||
* 不用直接调 getWorkNo:测服打包时基座 LoginUser 可能还没有这个方法。
|
|
||||||
*/
|
|
||||||
private static String dashboardWecomId(LoginUser login) {
|
|
||||||
if (login == null) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
Object v = login.getClass().getMethod("getWorkNo").invoke(login);
|
|
||||||
return v == null ? "" : StringUtils.trimToEmpty(String.valueOf(v));
|
|
||||||
} catch (ReflectiveOperationException ignored) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+49
-5
@@ -1148,16 +1148,39 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
|||||||
if (!landSegs.isEmpty()) {
|
if (!landSegs.isEmpty()) {
|
||||||
out.put("landSegments", landSegs);
|
out.put("landSegments", landSegs);
|
||||||
out.put("route_rows", landSegs);
|
out.put("route_rows", landSegs);
|
||||||
// 首段时效若 facts 尚无,用腿上 transit
|
// 首段时效/燃油/税率抬到顶层,供侧栏与标签回填(WO006 燃油列不能空着有数却写不上)
|
||||||
if (StringUtils.isBlank(out.getString("transit_time")) || "-".equals(out.getString("transit_time"))) {
|
for (int i = 0; i < landSegs.size(); i++) {
|
||||||
JSONObject firstSeg = landSegs.getJSONObject(0);
|
JSONObject seg = landSegs.getJSONObject(i);
|
||||||
if (firstSeg != null) {
|
if (seg == null) {
|
||||||
String t = firstNonBlankLocal(firstSeg.getString("transit_time"), firstSeg.getString("transitDays"));
|
continue;
|
||||||
|
}
|
||||||
|
if (StringUtils.isBlank(out.getString("transit_time")) || "-".equals(out.getString("transit_time"))) {
|
||||||
|
String t = firstNonBlankLocal(seg.getString("transit_time"), seg.getString("transitDays"));
|
||||||
if (StringUtils.isNotBlank(t)) {
|
if (StringUtils.isNotBlank(t)) {
|
||||||
out.put("transit_time", t);
|
out.put("transit_time", t);
|
||||||
out.put("transit_days", t);
|
out.put("transit_days", t);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (StringUtils.isBlank(out.getString("fuel_surcharge_rate"))) {
|
||||||
|
String fuel = firstNonBlankLocal(seg.getString("fuel_surcharge_rate"),
|
||||||
|
seg.getString("fuelSurchargeRate"));
|
||||||
|
if (StringUtils.isNotBlank(fuel)) {
|
||||||
|
out.put("fuel_surcharge_rate", fuel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (StringUtils.isBlank(out.getString("tax_rate"))) {
|
||||||
|
String tax = firstNonBlankLocal(seg.getString("tax_rate"), seg.getString("taxRate"),
|
||||||
|
seg.getString("rate"));
|
||||||
|
if (StringUtils.isNotBlank(tax)) {
|
||||||
|
out.put("tax_rate", tax);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (StringUtils.isBlank(out.getString("currency"))) {
|
||||||
|
String ccy = StringUtils.trimToEmpty(seg.getString("currency"));
|
||||||
|
if (StringUtils.isNotBlank(ccy)) {
|
||||||
|
out.put("currency", ccy);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (looksLandInquiry(src)) {
|
} else if (looksLandInquiry(src)) {
|
||||||
// 无 landSegments 时:用询价 OD + fee_lines 分项拼一行,尽量填「提货费/运输费/送货费」列
|
// 无 landSegments 时:用询价 OD + fee_lines 分项拼一行,尽量填「提货费/运输费/送货费」列
|
||||||
@@ -1171,6 +1194,27 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
|||||||
one.put("delivery_fee", out.getString("delivery_fee"));
|
one.put("delivery_fee", out.getString("delivery_fee"));
|
||||||
one.put("price_per_cbm", out.getString("price_per_cbm"));
|
one.put("price_per_cbm", out.getString("price_per_cbm"));
|
||||||
one.put("price_per_kg", out.getString("price_per_kg"));
|
one.put("price_per_kg", out.getString("price_per_kg"));
|
||||||
|
// 产品文字报「2-500KG 单价…」时 weightPrices / 费率在 quote 顶层,无 TMS landSegments。
|
||||||
|
Object wp = q.get("weightPrices");
|
||||||
|
if (wp != null) {
|
||||||
|
one.put("weightPrices", wp);
|
||||||
|
}
|
||||||
|
String fuelRate = firstNonBlankLocal(
|
||||||
|
out.getString("fuel_surcharge_rate"),
|
||||||
|
first(q, "fuel_surcharge_rate"),
|
||||||
|
first(q, "fuelSurchargeRate"));
|
||||||
|
if (StringUtils.isNotBlank(fuelRate)) {
|
||||||
|
one.put("fuel_surcharge_rate", fuelRate);
|
||||||
|
one.put("fuelSurchargeRate", fuelRate);
|
||||||
|
}
|
||||||
|
String taxRate = firstNonBlankLocal(
|
||||||
|
out.getString("tax_rate"),
|
||||||
|
first(q, "tax_rate"),
|
||||||
|
first(q, "taxRate"));
|
||||||
|
if (StringUtils.isNotBlank(taxRate)) {
|
||||||
|
one.put("tax_rate", taxRate);
|
||||||
|
one.put("taxRate", taxRate);
|
||||||
|
}
|
||||||
String landAmt = firstNonBlankLocal(out.getString("land_freight"), out.getString("price_per_cbm"));
|
String landAmt = firstNonBlankLocal(out.getString("land_freight"), out.getString("price_per_cbm"));
|
||||||
for (int i = 0; i < rows.size(); i++) {
|
for (int i = 0; i < rows.size(); i++) {
|
||||||
JSONObject fee = rows.getJSONObject(i);
|
JSONObject fee = rows.getJSONObject(i);
|
||||||
|
|||||||
+55
-5
@@ -26,6 +26,7 @@ import org.jeecg.modules.inquiry.mapper.InquiryTicketMapper;
|
|||||||
import org.jeecg.modules.inquiry.mapper.InquiryTicketStatusLogMapper;
|
import org.jeecg.modules.inquiry.mapper.InquiryTicketStatusLogMapper;
|
||||||
import org.jeecg.modules.inquiry.support.AgentSystemExceptionRetryClient;
|
import org.jeecg.modules.inquiry.support.AgentSystemExceptionRetryClient;
|
||||||
import org.jeecg.modules.inquiry.service.IInquiryWorkOrderService;
|
import org.jeecg.modules.inquiry.service.IInquiryWorkOrderService;
|
||||||
|
import org.jeecg.modules.inquiry.support.InquiryDataScopeSupport;
|
||||||
import org.jeecg.modules.inquiry.support.InquiryQuoteDisplaySupport;
|
import org.jeecg.modules.inquiry.support.InquiryQuoteDisplaySupport;
|
||||||
import org.jeecg.modules.inquiry.support.InquiryQuoteFileSupport;
|
import org.jeecg.modules.inquiry.support.InquiryQuoteFileSupport;
|
||||||
import org.jeecg.modules.inquiry.support.InquiryTicketDisplaySupport;
|
import org.jeecg.modules.inquiry.support.InquiryTicketDisplaySupport;
|
||||||
@@ -122,6 +123,8 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
|||||||
if (StringUtils.isNotBlank(dateTo)) {
|
if (StringUtils.isNotBlank(dateTo)) {
|
||||||
qw.le(InquiryTicket::getCreateTime, dateTo + " 23:59:59");
|
qw.le(InquiryTicket::getCreateTime, dateTo + " 23:59:59");
|
||||||
}
|
}
|
||||||
|
// 与看板同源:账号勾「只看自己」时收紧列表(非超管)
|
||||||
|
InquiryDataScopeSupport.applySelfOnly(qw);
|
||||||
qw.orderByDesc(InquiryTicket::getCreateTime);
|
qw.orderByDesc(InquiryTicket::getCreateTime);
|
||||||
IPage<InquiryTicket> result = page(page, qw);
|
IPage<InquiryTicket> result = page(page, qw);
|
||||||
enrichDisplayFields(result.getRecords());
|
enrichDisplayFields(result.getRecords());
|
||||||
@@ -283,24 +286,36 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
|||||||
if (TicketStatuses.CLOSED.equals(to) && StringUtils.isNotBlank(req.getRemark())) {
|
if (TicketStatuses.CLOSED.equals(to) && StringUtils.isNotBlank(req.getRemark())) {
|
||||||
ticket.setCloseReason(req.getRemark());
|
ticket.setCloseReason(req.getRemark());
|
||||||
}
|
}
|
||||||
|
boolean clearEventWait = false;
|
||||||
if (TicketStatuses.LOST.equals(to)) {
|
if (TicketStatuses.LOST.equals(to)) {
|
||||||
ticket.setNextFollowupAt(null);
|
ticket.setNextFollowupAt(null);
|
||||||
if (StringUtils.isNotBlank(req.getLostReason())) {
|
if (StringUtils.isNotBlank(req.getLostReason())) {
|
||||||
ticket.setLostReason(StringUtils.left(req.getLostReason().trim(), 500));
|
ticket.setLostReason(StringUtils.left(req.getLostReason().trim(), 500));
|
||||||
}
|
}
|
||||||
|
// 成交等待结束;缺原因时由智能体再 start lost_reason,避免陈旧 deal 继续催
|
||||||
|
clearEventWait = true;
|
||||||
} else if (TicketStatuses.DEAL.equals(to)) {
|
} else if (TicketStatuses.DEAL.equals(to)) {
|
||||||
ticket.setNextFollowupAt(null);
|
ticket.setNextFollowupAt(null);
|
||||||
if (StringUtils.isNotBlank(req.getDealNotifySummary())) {
|
if (StringUtils.isNotBlank(req.getDealNotifySummary())) {
|
||||||
ticket.setDealNotifySummary(StringUtils.left(req.getDealNotifySummary().trim(), 500));
|
ticket.setDealNotifySummary(StringUtils.left(req.getDealNotifySummary().trim(), 500));
|
||||||
}
|
}
|
||||||
|
clearEventWait = true;
|
||||||
} else if (TicketStatuses.NEGOTIATING.equals(to)) {
|
} else if (TicketStatuses.NEGOTIATING.equals(to)) {
|
||||||
Date follow = parseDateTime(req.getNextFollowupAt());
|
Date follow = parseDateTime(req.getNextFollowupAt());
|
||||||
ticket.setNextFollowupAt(follow);
|
ticket.setNextFollowupAt(follow);
|
||||||
|
// 顺延再问期间不盯销售超时;到期再问会重新 start deal
|
||||||
|
clearEventWait = true;
|
||||||
|
} else if (TicketStatuses.CLOSED.equals(to)) {
|
||||||
|
clearEventWait = true;
|
||||||
}
|
}
|
||||||
ticket.setVersion(ticket.getVersion() == null ? 2 : ticket.getVersion() + 1);
|
ticket.setVersion(ticket.getVersion() == null ? 2 : ticket.getVersion() + 1);
|
||||||
ticket.setUpdateBy(operator);
|
ticket.setUpdateBy(operator);
|
||||||
ticket.setUpdateTime(new Date());
|
ticket.setUpdateTime(new Date());
|
||||||
updateById(ticket);
|
updateById(ticket);
|
||||||
|
// updateById 默认不写 null:等待三列必须在落库后再强制清空
|
||||||
|
if (clearEventWait) {
|
||||||
|
clearEventWaitColumns(ticket);
|
||||||
|
}
|
||||||
String remark = StringUtils.defaultIfBlank(req.getRemark(),
|
String remark = StringUtils.defaultIfBlank(req.getRemark(),
|
||||||
StringUtils.defaultIfBlank(req.getDealNotifySummary(), req.getLostReason()));
|
StringUtils.defaultIfBlank(req.getDealNotifySummary(), req.getLostReason()));
|
||||||
appendLog(ticket, from, to, "transition", operator, remark);
|
appendLog(ticket, from, to, "transition", operator, remark);
|
||||||
@@ -1494,6 +1509,8 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
|||||||
ticket.setUpdateBy(StringUtils.defaultIfBlank(operator, "agent"));
|
ticket.setUpdateBy(StringUtils.defaultIfBlank(operator, "agent"));
|
||||||
ticket.setUpdateTime(new Date());
|
ticket.setUpdateTime(new Date());
|
||||||
updateById(ticket);
|
updateById(ticket);
|
||||||
|
// 原因已入账:强制清空等待三列(updateById 写不进 null)
|
||||||
|
clearEventWaitColumns(ticket);
|
||||||
appendLog(ticket, ticket.getStatus(), ticket.getStatus(), "lost_reason", operator, reason);
|
appendLog(ticket, ticket.getStatus(), ticket.getStatus(), "lost_reason", operator, reason);
|
||||||
out.put("ok", true);
|
out.put("ok", true);
|
||||||
out.put("work_order_no", no);
|
out.put("work_order_no", no);
|
||||||
@@ -1637,11 +1654,7 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
|||||||
out.put("error", "not_found");
|
out.put("error", "not_found");
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
ticket.setEventWaitKind(null);
|
clearEventWaitColumns(ticket);
|
||||||
ticket.setEventWaitStartedAt(null);
|
|
||||||
ticket.setEventWaitAlerted(0);
|
|
||||||
ticket.setUpdateTime(new Date());
|
|
||||||
updateById(ticket);
|
|
||||||
out.put("ok", true);
|
out.put("ok", true);
|
||||||
out.put("work_order_no", ticket.getWorkOrderNo());
|
out.put("work_order_no", ticket.getWorkOrderNo());
|
||||||
return out;
|
return out;
|
||||||
@@ -1661,10 +1674,21 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
|
SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
|
||||||
|
Date now = new Date();
|
||||||
for (InquiryTicket ticket : rows) {
|
for (InquiryTicket ticket : rows) {
|
||||||
if (ticket == null || !EventExceptionRule.isWatchable(ticket.getStatus())) {
|
if (ticket == null || !EventExceptionRule.isWatchable(ticket.getStatus())) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// 未成交已写原因、协商中仍排着再问:不进巡检(挡住陈旧等待)
|
||||||
|
if (TicketStatuses.LOST.equals(ticket.getStatus())
|
||||||
|
&& StringUtils.isNotBlank(ticket.getLostReason())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (TicketStatuses.NEGOTIATING.equals(ticket.getStatus())
|
||||||
|
&& ticket.getNextFollowupAt() != null
|
||||||
|
&& ticket.getNextFollowupAt().after(now)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
Map<String, Object> one = new LinkedHashMap<>();
|
Map<String, Object> one = new LinkedHashMap<>();
|
||||||
one.put("workOrderNo", ticket.getWorkOrderNo());
|
one.put("workOrderNo", ticket.getWorkOrderNo());
|
||||||
one.put("work_order_no", ticket.getWorkOrderNo());
|
one.put("work_order_no", ticket.getWorkOrderNo());
|
||||||
@@ -1688,6 +1712,8 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
|||||||
one.put("collabMemberIds", members);
|
one.put("collabMemberIds", members);
|
||||||
one.put("collab_member_ids", members);
|
one.put("collab_member_ids", members);
|
||||||
one.put("eventException", StringUtils.defaultString(ticket.getEventException()));
|
one.put("eventException", StringUtils.defaultString(ticket.getEventException()));
|
||||||
|
one.put("lostReason", StringUtils.defaultString(ticket.getLostReason()));
|
||||||
|
one.put("lost_reason", StringUtils.defaultString(ticket.getLostReason()));
|
||||||
Map<String, String> facts = new LinkedHashMap<>();
|
Map<String, String> facts = new LinkedHashMap<>();
|
||||||
if (StringUtils.isNotBlank(ticket.getFactsJson())) {
|
if (StringUtils.isNotBlank(ticket.getFactsJson())) {
|
||||||
JSONObject parsed = JSON.parseObject(ticket.getFactsJson());
|
JSONObject parsed = JSON.parseObject(ticket.getFactsJson());
|
||||||
@@ -1710,6 +1736,30 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空事件异常当前等待三列。不改六态、不改事件异常摘要。
|
||||||
|
* <p>
|
||||||
|
* 必须用 UpdateWrapper 强制写 null:MyBatis-Plus updateById 默认跳过 null 字段,
|
||||||
|
* 否则协商中/未成交后库里仍残留 deal 等待,巡检会继续催(WO202610060024 / WO202610070034)。
|
||||||
|
*/
|
||||||
|
private void clearEventWaitColumns(InquiryTicket ticket) {
|
||||||
|
if (ticket == null || StringUtils.isBlank(ticket.getId())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Date now = new Date();
|
||||||
|
lambdaUpdate()
|
||||||
|
.eq(InquiryTicket::getId, ticket.getId())
|
||||||
|
.set(InquiryTicket::getEventWaitKind, null)
|
||||||
|
.set(InquiryTicket::getEventWaitStartedAt, null)
|
||||||
|
.set(InquiryTicket::getEventWaitAlerted, 0)
|
||||||
|
.set(InquiryTicket::getUpdateTime, now)
|
||||||
|
.update();
|
||||||
|
ticket.setEventWaitKind(null);
|
||||||
|
ticket.setEventWaitStartedAt(null);
|
||||||
|
ticket.setEventWaitAlerted(0);
|
||||||
|
ticket.setUpdateTime(now);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public Map<String, Object> recordEventException(String workOrderNo, String eventException) {
|
public Map<String, Object> recordEventException(String workOrderNo, String eventException) {
|
||||||
|
|||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
package org.jeecg.modules.inquiry.support;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.apache.shiro.SecurityUtils;
|
||||||
|
import org.jeecg.common.system.vo.LoginUser;
|
||||||
|
import org.jeecg.modules.inquiry.entity.InquiryTicket;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台数据范围:账号「只看自己数据」时收紧工单查询。
|
||||||
|
* <p>
|
||||||
|
* 口径:非超管且 {@code dashboardSelfOnly=true} →
|
||||||
|
* {@code salesWecomId = 工号} 或 {@code createBy = 登录名}。
|
||||||
|
* 看板与工单列表共用,避免只看板生效、列表漏看全站。
|
||||||
|
* 线程:只读登录态,无全局锁。
|
||||||
|
*/
|
||||||
|
public final class InquiryDataScopeSupport {
|
||||||
|
|
||||||
|
private InquiryDataScopeSupport() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按当前登录账号把「只看自己」条件叠到工单查询上。
|
||||||
|
*
|
||||||
|
* @param qw 工单查询包装器;null 时直接返回
|
||||||
|
*/
|
||||||
|
public static void applySelfOnly(LambdaQueryWrapper<InquiryTicket> qw) {
|
||||||
|
if (qw == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
LoginUser login = currentLoginUser();
|
||||||
|
if (login == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isAdministrator(login)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Boolean.TRUE.equals(login.getDashboardSelfOnly())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String username = StringUtils.trimToEmpty(login.getUsername());
|
||||||
|
String wecom = wecomId(login);
|
||||||
|
qw.and(w -> {
|
||||||
|
boolean any = false;
|
||||||
|
if (StringUtils.isNotBlank(wecom)) {
|
||||||
|
w.eq(InquiryTicket::getSalesWecomId, wecom);
|
||||||
|
any = true;
|
||||||
|
}
|
||||||
|
if (StringUtils.isNotBlank(username)) {
|
||||||
|
if (any) {
|
||||||
|
w.or();
|
||||||
|
}
|
||||||
|
w.eq(InquiryTicket::getCreateBy, username);
|
||||||
|
any = true;
|
||||||
|
}
|
||||||
|
if (!any) {
|
||||||
|
// 未绑企微且无用户名:宁可不看数据,也不漏看全站
|
||||||
|
w.apply("1 = 0");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LoginUser currentLoginUser() {
|
||||||
|
try {
|
||||||
|
Object p = SecurityUtils.getSubject().getPrincipal();
|
||||||
|
if (p instanceof LoginUser) {
|
||||||
|
return (LoginUser) p;
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// 无登录上下文
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 超管:登录名 admin,或 Shiro 角色 admin/管理员。 */
|
||||||
|
private static boolean isAdministrator(LoginUser login) {
|
||||||
|
if (login == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ("admin".equalsIgnoreCase(StringUtils.trimToEmpty(login.getUsername()))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return SecurityUtils.getSubject().hasRole("admin")
|
||||||
|
|| SecurityUtils.getSubject().hasRole("管理员");
|
||||||
|
} catch (Exception e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 企微工号。反射读 getWorkNo,兼容测服旧 LoginUser 无该方法的包。
|
||||||
|
*/
|
||||||
|
public static String wecomId(LoginUser login) {
|
||||||
|
if (login == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Object v = login.getClass().getMethod("getWorkNo").invoke(login);
|
||||||
|
return v == null ? "" : StringUtils.trimToEmpty(String.valueOf(v));
|
||||||
|
} catch (ReflectiveOperationException ignored) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+294
-349
File diff suppressed because it is too large
Load Diff
+175
-18
@@ -96,11 +96,16 @@ public class InquiryTmsCabinService {
|
|||||||
Map<String, Object> quote = payloadOf(rows.get(0));
|
Map<String, Object> quote = payloadOf(rows.get(0));
|
||||||
// 「AIR-OPT01 TH」被误落成航线报价不算真正人工价 → 仍走仅 TMS(SYSTEM)。
|
// 「AIR-OPT01 TH」被误落成航线报价不算真正人工价 → 仍走仅 TMS(SYSTEM)。
|
||||||
boolean latestManual = isManualQuote(quote) && !isFakeManualLockQuote(quote);
|
boolean latestManual = isManualQuote(quote) && !isFakeManualLockQuote(quote);
|
||||||
if (!latestManual && StringUtils.isBlank(resolveQuoteId(quote, no))) {
|
// 库里只有人工价、没有舱位时也要现拉 TMS:补 quoteId/airOptions,
|
||||||
|
// 避免 MANUAL 空壳 1001,也避免逼航线手打航班号。
|
||||||
|
boolean cabinSparse = StringUtils.isBlank(resolveQuoteId(quote, no))
|
||||||
|
|| airOptions(quote).isEmpty();
|
||||||
|
if (cabinSparse) {
|
||||||
quote = refreshLockQuote(ticket, quote);
|
quote = refreshLockQuote(ticket, quote);
|
||||||
}
|
}
|
||||||
String tmsQuoteId = realTmsQuoteId(rows, no);
|
String tmsQuoteId = realTmsQuoteId(rows, no);
|
||||||
if (!latestManual && StringUtils.isBlank(tmsQuoteId)) {
|
// 现拉到的 TMS 编号也算:库里无历史行时仍可 MANUAL+quoteId。
|
||||||
|
if (StringUtils.isBlank(tmsQuoteId)) {
|
||||||
tmsQuoteId = resolveQuoteId(quote, no);
|
tmsQuoteId = resolveQuoteId(quote, no);
|
||||||
}
|
}
|
||||||
boolean hasTms = StringUtils.isNotBlank(tmsQuoteId);
|
boolean hasTms = StringUtils.isNotBlank(tmsQuoteId);
|
||||||
@@ -161,26 +166,68 @@ public class InquiryTmsCabinService {
|
|||||||
text(option, "destination"), ticket.getDestination(),
|
text(option, "destination"), ticket.getDestination(),
|
||||||
fact(ticket, "目的地", "目的港")));
|
fact(ticket, "目的地", "目的港")));
|
||||||
// 航司:句中两位码(如「AIR-OPT01 TH」)> 舱位/报价字段 > 航班号前两位。
|
// 航司:句中两位码(如「AIR-OPT01 TH」)> 舱位/报价字段 > 航班号前两位。
|
||||||
req.put("airline", firstNonBlank(
|
// airlineName 中文名不能当航司码出站,只取两位码或航班前缀。
|
||||||
|
String airlineCode = firstNonBlank(
|
||||||
text(body, "airline", "airlineCode"),
|
text(body, "airline", "airlineCode"),
|
||||||
airlineFromInstruction(text(body, "instructionText", "instruction_text")),
|
airlineFromInstruction(text(body, "instructionText", "instruction_text")),
|
||||||
text(option, "airlineCode", "airline"),
|
text(option, "airlineCode", "airline"),
|
||||||
text(option, "airlineName"),
|
text(quote, "airlineCode", "airline"),
|
||||||
text(quote, "airlineCode", "airline", "airlineName"),
|
fact(ticket, "航司代码"),
|
||||||
fact(ticket, "航司", "航司代码"),
|
airlineFromFlight(firstNonBlank(text(option, "flightNo"), text(quote, "flightNo"))),
|
||||||
airlineFromFlight(firstNonBlank(text(option, "flightNo"), text(quote, "flightNo")))));
|
airlineCodeOrBlank(text(option, "airlineName")),
|
||||||
|
airlineCodeOrBlank(text(quote, "airlineName")),
|
||||||
|
airlineCodeOrBlank(fact(ticket, "航司")));
|
||||||
|
req.put("airline", airlineCode);
|
||||||
req.put("weightKg", ticket.getWeightKg());
|
req.put("weightKg", ticket.getWeightKg());
|
||||||
req.put("volumeCbm", ticket.getVolumeCbm());
|
req.put("volumeCbm", ticket.getVolumeCbm());
|
||||||
req.put("pieceQty", ticket.getPieces());
|
req.put("pieceQty", ticket.getPieces());
|
||||||
|
// 计费重:舱位 > 报价 > 工单计费重事实 > 毛重(避免 MANUAL 空壳 1001)。
|
||||||
req.put("chargeableWeightKg", firstNonBlank(
|
req.put("chargeableWeightKg", firstNonBlank(
|
||||||
text(option, "chargeableWeightKg", "chargeable_weight_kg"),
|
text(option, "chargeableWeightKg", "chargeable_weight_kg"),
|
||||||
text(quote, "chargeableWeightKg", "chargeable_weight_kg")));
|
text(quote, "chargeableWeightKg", "chargeable_weight_kg"),
|
||||||
|
fact(ticket, "计费重", "计费重量"),
|
||||||
|
ticket.getWeightKg() == null ? "" : String.valueOf(ticket.getWeightKg()),
|
||||||
|
fact(ticket, "毛重")));
|
||||||
req.put("operatorUserId", text(body, "operatorUserId", "sender_id"));
|
req.put("operatorUserId", text(body, "operatorUserId", "sender_id"));
|
||||||
req.put("operatorName", text(body, "operatorName", "operator_name"));
|
// 重试漏传姓名时用 userid 兜底,仍要有 operatorName 键。
|
||||||
|
req.put("operatorName", firstNonBlank(
|
||||||
|
text(body, "operatorName", "operator_name"),
|
||||||
|
text(body, "operatorUserId", "sender_id")));
|
||||||
req.put("instructionText", text(body, "instructionText", "instruction_text"));
|
req.put("instructionText", text(body, "instructionText", "instruction_text"));
|
||||||
if ("MANUAL".equals(lockSource)) {
|
if ("MANUAL".equals(lockSource)) {
|
||||||
// 有 TMS 时保留 quoteId;纯人工才去掉。
|
// 有 TMS 时保留 quoteId;纯人工才去掉。
|
||||||
fillManualLock(req, quote, option, body, StringUtils.isNotBlank(quoteId));
|
fillManualLock(req, quote, option, body, StringUtils.isNotBlank(quoteId));
|
||||||
|
// 人工价常不写有效期:用航班日当天 23:59:59+08:00 兜底,避免空壳 1001。
|
||||||
|
if (StringUtils.isBlank(text(req, "validUntil"))) {
|
||||||
|
String isoUntil = normalizeValidUntilIso(text(req, "flightDate"));
|
||||||
|
if (StringUtils.isNotBlank(isoUntil)) {
|
||||||
|
req.put("validUntil", isoUntil);
|
||||||
|
if (StringUtils.isBlank(text(req, "requestedUntil"))) {
|
||||||
|
req.put("requestedUntil", isoUntil);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 缺必填仍出站会被 TMS 1001;在此拦住并回清原因。
|
||||||
|
String incomplete = validateManualLockOutbound(req);
|
||||||
|
// 现拉仍无舱位时,改成业务补问口径(禁止堆「缺少 optionNo」技术字段名)。
|
||||||
|
if (StringUtils.isNotBlank(incomplete)) {
|
||||||
|
String refreshClass = text(quote, "_lockRefreshClass");
|
||||||
|
String refreshMsg = text(quote, "_lockRefreshMsg");
|
||||||
|
boolean noMatch = "TMS_NO_QUOTE_1002".equals(refreshClass)
|
||||||
|
|| (StringUtils.isNotBlank(refreshMsg) && refreshMsg.contains("无匹配"));
|
||||||
|
if (noMatch) {
|
||||||
|
incomplete = "本单是手工报价,且运价库无匹配舱位,无法自动带出航班。"
|
||||||
|
+ "请按「AIR-OPT01 CZ 航班CZ327」补舱位、航司和航班号后再锁。"
|
||||||
|
+ "(现拉TMS:" + firstNonBlank(refreshMsg, "标准运价库无匹配报价") + ")";
|
||||||
|
} else if (StringUtils.isNotBlank(refreshMsg)) {
|
||||||
|
incomplete = incomplete + "(现拉TMS:" + refreshMsg + ")";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (StringUtils.isNotBlank(incomplete)) {
|
||||||
|
log.info("tms.lock incomplete wo={} optionNo={} missing={}",
|
||||||
|
no, resolvedOption, incomplete);
|
||||||
|
return fail("lock_incomplete", incomplete);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// 仅 TMS:source=SYSTEM + quoteId。禁止再塞 validUntil/feeItems(会 1001)。
|
// 仅 TMS:source=SYSTEM + quoteId。禁止再塞 validUntil/feeItems(会 1001)。
|
||||||
req.put("source", "SYSTEM");
|
req.put("source", "SYSTEM");
|
||||||
@@ -356,14 +403,26 @@ public class InquiryTmsCabinService {
|
|||||||
}
|
}
|
||||||
Map<String, Object> body = new LinkedHashMap<>();
|
Map<String, Object> body = new LinkedHashMap<>();
|
||||||
body.put("workOrderNo", ticket.getWorkOrderNo());
|
body.put("workOrderNo", ticket.getWorkOrderNo());
|
||||||
|
// 锁舱现拉必须用「今天」报价日:历史询价日常已无运价(1002),
|
||||||
|
// 不能把销售初询那天的日期带出去导致永远补不到舱位。
|
||||||
|
JSONObject facts = new JSONObject();
|
||||||
if (StringUtils.isNotBlank(ticket.getFactsJson())) {
|
if (StringUtils.isNotBlank(ticket.getFactsJson())) {
|
||||||
JSONObject facts = JSON.parseObject(ticket.getFactsJson());
|
JSONObject stored = JSON.parseObject(ticket.getFactsJson());
|
||||||
if (facts != null) {
|
if (stored != null) {
|
||||||
body.put("facts", facts);
|
facts.putAll(stored);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
SimpleDateFormat dayFmt = new SimpleDateFormat("yyyy-MM-dd");
|
||||||
|
dayFmt.setTimeZone(java.util.TimeZone.getTimeZone("Asia/Shanghai"));
|
||||||
|
String today = dayFmt.format(new java.util.Date());
|
||||||
|
facts.put("报价日期", today);
|
||||||
|
body.put("facts", facts);
|
||||||
try {
|
try {
|
||||||
Map<String, Object> live = queryService.queryFromAgent(body);
|
Map<String, Object> live = queryService.queryFromAgent(body);
|
||||||
|
if (live != null && live.get("classification") != null) {
|
||||||
|
current.put("_lockRefreshClass", String.valueOf(live.get("classification")));
|
||||||
|
current.put("_lockRefreshMsg", String.valueOf(live.get("message") == null ? "" : live.get("message")));
|
||||||
|
}
|
||||||
Object raw = live == null ? null : live.get("quote");
|
Object raw = live == null ? null : live.get("quote");
|
||||||
if (!(raw instanceof Map)) {
|
if (!(raw instanceof Map)) {
|
||||||
return current;
|
return current;
|
||||||
@@ -376,14 +435,19 @@ public class InquiryTmsCabinService {
|
|||||||
if (airOptions(current).isEmpty() && fetched.get("airOptions") != null) {
|
if (airOptions(current).isEmpty() && fetched.get("airOptions") != null) {
|
||||||
current.put("airOptions", fetched.get("airOptions"));
|
current.put("airOptions", fetched.get("airOptions"));
|
||||||
}
|
}
|
||||||
|
if (airOptions(current).isEmpty() && fetched.get("air_options") != null) {
|
||||||
|
current.put("airOptions", fetched.get("air_options"));
|
||||||
|
}
|
||||||
if (StringUtils.isBlank(text(current, "chargeableWeightKg", "chargeable_weight_kg"))
|
if (StringUtils.isBlank(text(current, "chargeableWeightKg", "chargeable_weight_kg"))
|
||||||
&& StringUtils.isNotBlank(text(fetched, "chargeableWeightKg", "chargeable_weight_kg"))) {
|
&& StringUtils.isNotBlank(text(fetched, "chargeableWeightKg", "chargeable_weight_kg"))) {
|
||||||
current.put("chargeableWeightKg", text(fetched, "chargeableWeightKg", "chargeable_weight_kg"));
|
current.put("chargeableWeightKg", text(fetched, "chargeableWeightKg", "chargeable_weight_kg"));
|
||||||
}
|
}
|
||||||
log.info("tms.lock refresh wo={} hasQuoteId={} airOpt={}",
|
log.info("tms.lock refresh wo={} hasQuoteId={} airOpt={} class={} quoteDate={}",
|
||||||
ticket.getWorkOrderNo(),
|
ticket.getWorkOrderNo(),
|
||||||
StringUtils.isNotBlank(text(current, "quoteId", "quote_id")),
|
StringUtils.isNotBlank(text(current, "quoteId", "quote_id")),
|
||||||
airOptions(current).size());
|
airOptions(current).size(),
|
||||||
|
live == null ? "" : live.get("classification"),
|
||||||
|
today);
|
||||||
} catch (RuntimeException ex) {
|
} catch (RuntimeException ex) {
|
||||||
log.warn("tms.lock refresh fail wo={} err={}", ticket.getWorkOrderNo(), ex.getMessage());
|
log.warn("tms.lock refresh fail wo={} err={}", ticket.getWorkOrderNo(), ex.getMessage());
|
||||||
}
|
}
|
||||||
@@ -465,7 +529,11 @@ public class InquiryTmsCabinService {
|
|||||||
if (StringUtils.isNotBlank(transfer)) {
|
if (StringUtils.isNotBlank(transfer)) {
|
||||||
req.put("transferAirport", transfer);
|
req.put("transferAirport", transfer);
|
||||||
}
|
}
|
||||||
String flightNo = firstNonBlank(text(option, "flightNo"), text(quote, "flightNo"));
|
// 句中/请求体点名的航班号优先,其次舱位与报价。
|
||||||
|
String flightNo = firstNonBlank(
|
||||||
|
text(body, "flightNo", "flight_no"),
|
||||||
|
text(option, "flightNo", "flight_no"),
|
||||||
|
text(quote, "flightNo", "flight_no"));
|
||||||
if (StringUtils.isNotBlank(flightNo)) {
|
if (StringUtils.isNotBlank(flightNo)) {
|
||||||
req.put("flightNo", flightNo);
|
req.put("flightNo", flightNo);
|
||||||
}
|
}
|
||||||
@@ -512,11 +580,41 @@ public class InquiryTmsCabinService {
|
|||||||
List<Map<String, Object>> departure = feeRows(quote, option, "departureCharges", "departure_charges");
|
List<Map<String, Object>> departure = feeRows(quote, option, "departureCharges", "departure_charges");
|
||||||
List<Map<String, Object>> fees = feeRows(quote, option, "feeItems", "fee_items");
|
List<Map<String, Object>> fees = feeRows(quote, option, "feeItems", "fee_items");
|
||||||
splitFeeRows(quote, departure, fees);
|
splitFeeRows(quote, departure, fees);
|
||||||
if (!departure.isEmpty()) {
|
// 运行证据(2026-10-07):只要出站带 departureCharges,TMS 一律 1001「参数格式错误」;
|
||||||
req.put("departureCharges", toLockFees(departure, true));
|
// 去掉后变为业务码 2008。feeItems 可保留。故 MANUAL 锁舱不再出站 departureCharges。
|
||||||
}
|
|
||||||
if (!fees.isEmpty()) {
|
if (!fees.isEmpty()) {
|
||||||
req.put("feeItems", toLockFees(fees, false));
|
req.put("feeItems", toLockFees(fees, false));
|
||||||
|
} else if (!departure.isEmpty()) {
|
||||||
|
// 仅有起运港费、无 feeItems 时,折进 feeItems,避免再走 departureCharges。
|
||||||
|
List<Map<String, Object>> asFees = new ArrayList<>();
|
||||||
|
for (Map<String, Object> row : toLockFees(departure, true)) {
|
||||||
|
Map<String, Object> fee = new LinkedHashMap<>();
|
||||||
|
String name = text(row, "chargeName", "feeName");
|
||||||
|
if (StringUtils.isNotBlank(name)) {
|
||||||
|
fee.put("feeName", name);
|
||||||
|
}
|
||||||
|
if (row.get("unitPrice") != null) {
|
||||||
|
fee.put("unitPrice", row.get("unitPrice"));
|
||||||
|
}
|
||||||
|
if (row.get("quantity") != null) {
|
||||||
|
fee.put("quantity", row.get("quantity"));
|
||||||
|
}
|
||||||
|
if (row.get("amount") != null) {
|
||||||
|
fee.put("amount", row.get("amount"));
|
||||||
|
}
|
||||||
|
if (row.get("currency") != null) {
|
||||||
|
fee.put("currency", row.get("currency"));
|
||||||
|
}
|
||||||
|
if (row.get("unit") != null) {
|
||||||
|
fee.put("unit", row.get("unit"));
|
||||||
|
}
|
||||||
|
if (!fee.isEmpty()) {
|
||||||
|
asFees.add(fee);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!asFees.isEmpty()) {
|
||||||
|
req.put("feeItems", asFees);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -740,10 +838,24 @@ public class InquiryTmsCabinService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本单真实 TMS quoteId:优先非人工报价行;人工覆盖后再锁仍要带原编号。
|
||||||
|
* 跳过 QT-WO 假编号。
|
||||||
|
*/
|
||||||
static String realTmsQuoteId(List<InquiryQuote> rows, String workOrderNo) {
|
static String realTmsQuoteId(List<InquiryQuote> rows, String workOrderNo) {
|
||||||
if (rows == null) {
|
if (rows == null) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
for (InquiryQuote row : rows) {
|
||||||
|
Map<String, Object> payload = payloadOf(row);
|
||||||
|
if (isManualQuote(payload) && !isFakeManualLockQuote(payload)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String id = resolveQuoteId(payload, workOrderNo);
|
||||||
|
if (StringUtils.isNotBlank(id)) {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
for (InquiryQuote row : rows) {
|
for (InquiryQuote row : rows) {
|
||||||
String id = resolveQuoteId(payloadOf(row), workOrderNo);
|
String id = resolveQuoteId(payloadOf(row), workOrderNo);
|
||||||
if (StringUtils.isNotBlank(id)) {
|
if (StringUtils.isNotBlank(id)) {
|
||||||
@@ -753,6 +865,51 @@ public class InquiryTmsCabinService {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MANUAL 出站必填校验。缺字段直接失败,禁止 HMAC 空壳打 TMS(会 1001)。
|
||||||
|
* 返回中文原因;齐了返回空串。
|
||||||
|
*/
|
||||||
|
static String validateManualLockOutbound(Map<String, Object> req) {
|
||||||
|
if (req == null) {
|
||||||
|
return "锁舱参数为空";
|
||||||
|
}
|
||||||
|
java.util.List<String> missing = new ArrayList<>();
|
||||||
|
if (StringUtils.isBlank(text(req, "optionNo", "option_no"))) {
|
||||||
|
missing.add("舱位编号optionNo");
|
||||||
|
}
|
||||||
|
if (StringUtils.isBlank(text(req, "airline", "airlineCode"))) {
|
||||||
|
missing.add("航司airline");
|
||||||
|
}
|
||||||
|
if (StringUtils.isBlank(text(req, "chargeableWeightKg", "chargeable_weight_kg"))) {
|
||||||
|
missing.add("计费重chargeableWeightKg");
|
||||||
|
}
|
||||||
|
if (StringUtils.isBlank(text(req, "flightNo", "flight_no"))) {
|
||||||
|
missing.add("航班号flightNo");
|
||||||
|
}
|
||||||
|
if (StringUtils.isBlank(text(req, "operatorName", "operator_name"))) {
|
||||||
|
missing.add("操作人operatorName");
|
||||||
|
}
|
||||||
|
if (StringUtils.isBlank(text(req, "validUntil", "valid_until"))) {
|
||||||
|
missing.add("有效期validUntil");
|
||||||
|
}
|
||||||
|
if (missing.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return "锁舱参数不完整,缺少:" + String.join("、", missing)
|
||||||
|
+ "。请按「AIR-OPT01 CZ 航班CZ327」补舱位、航司和航班号后再锁。";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 航司名只有恰好两位字母时才当代码;「南航」等中文名不当 airline 出站。
|
||||||
|
*/
|
||||||
|
static String airlineCodeOrBlank(String raw) {
|
||||||
|
String text = StringUtils.trimToEmpty(raw).toUpperCase();
|
||||||
|
if (text.length() == 2 && Character.isLetter(text.charAt(0)) && Character.isLetter(text.charAt(1))) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
private List<InquiryQuote> quoteRows(String workOrderNo) {
|
private List<InquiryQuote> quoteRows(String workOrderNo) {
|
||||||
if (StringUtils.isBlank(workOrderNo)) {
|
if (StringUtils.isBlank(workOrderNo)) {
|
||||||
return new ArrayList<>();
|
return new ArrayList<>();
|
||||||
|
|||||||
+168
@@ -380,8 +380,13 @@ public class InquiryTmsQueryService {
|
|||||||
quote.put("landSegments", json);
|
quote.put("landSegments", json);
|
||||||
// 同步一份 route_rows,字段名对齐填表(transit_time / salePrice 等)
|
// 同步一份 route_rows,字段名对齐填表(transit_time / salePrice 等)
|
||||||
List<Map<String, String>> rows = normalizeLandSegmentRows(raw);
|
List<Map<String, String>> rows = normalizeLandSegmentRows(raw);
|
||||||
|
// 腿上缺燃油时,从同段 feeItems 补(TMS 有时只把 fuelSurchargeRate 挂在费用行上)
|
||||||
|
enrichLandFuelTaxFromFeeItems(rows, data == null ? null : data.get("feeItems"),
|
||||||
|
data == null ? null : data.get("fee_items"));
|
||||||
if (!rows.isEmpty()) {
|
if (!rows.isEmpty()) {
|
||||||
quote.put("route_rows", JSON.toJSONString(rows));
|
quote.put("route_rows", JSON.toJSONString(rows));
|
||||||
|
// 回写补全后的腿,避免后续只读 landSegments 时又丢燃油
|
||||||
|
quote.put("landSegments", JSON.toJSONString(rows));
|
||||||
}
|
}
|
||||||
// feeItems 为空时,用各腿成本补运输费。顶层总价会被误收成「空运费」,陆运要换掉。
|
// feeItems 为空时,用各腿成本补运输费。顶层总价会被误收成「空运费」,陆运要换掉。
|
||||||
String feeLines = quote.get("fee_lines");
|
String feeLines = quote.get("fee_lines");
|
||||||
@@ -484,6 +489,21 @@ public class InquiryTmsQueryService {
|
|||||||
putExtra(row, pair[1], firstText(seg.get(pair[0]), seg.get(pair[1])));
|
putExtra(row, pair[1], firstText(seg.get(pair[0]), seg.get(pair[1])));
|
||||||
putExtra(row, pair[0], firstText(seg.get(pair[0])));
|
putExtra(row, pair[0], firstText(seg.get(pair[0])));
|
||||||
}
|
}
|
||||||
|
// 陆运 TMS:fuelSurchargeRate=燃油附加费率;rate=税率;rateFlag=是否含税(1是0否)。
|
||||||
|
// 只投影 landSegments,禁止拿 fxRates.rate(汇率)冒充税率。
|
||||||
|
String fuelRate = formatLandPercentRate(firstText(
|
||||||
|
seg.get("fuelSurchargeRate"), seg.get("fuel_surcharge_rate")));
|
||||||
|
putExtra(row, "fuel_surcharge_rate", fuelRate);
|
||||||
|
putExtra(row, "fuelSurchargeRate", fuelRate);
|
||||||
|
String taxRate = formatLandTaxRate(firstText(
|
||||||
|
seg.get("rate"), seg.get("tax_rate"), seg.get("taxRate")));
|
||||||
|
putExtra(row, "tax_rate", taxRate);
|
||||||
|
putExtra(row, "taxRate", taxRate);
|
||||||
|
putExtra(row, "rate", firstText(seg.get("rate")));
|
||||||
|
String rateFlagRaw = firstText(seg.get("rateFlag"), seg.get("rate_flag"));
|
||||||
|
putExtra(row, "rateFlag", rateFlagRaw);
|
||||||
|
putExtra(row, "rate_flag", rateFlagRaw);
|
||||||
|
putExtra(row, "tax_included", formatLandTaxIncluded(rateFlagRaw));
|
||||||
if (StringUtils.isNotBlank(row.get("origin")) || StringUtils.isNotBlank(row.get("destination"))
|
if (StringUtils.isNotBlank(row.get("origin")) || StringUtils.isNotBlank(row.get("destination"))
|
||||||
|| StringUtils.isNotBlank(row.get("amount"))) {
|
|| StringUtils.isNotBlank(row.get("amount"))) {
|
||||||
out.add(row);
|
out.add(row);
|
||||||
@@ -1004,6 +1024,154 @@ public class InquiryTmsQueryService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 陆运腿缺燃油附加费率/税率时,按同段 feeItems 补。
|
||||||
|
* <p>
|
||||||
|
* 为何:TMS 偶发只在费用行带 fuelSurchargeRate,腿上为 null;出单侧栏就会空着。
|
||||||
|
* 不编造;feeItems 也没有则保持空。
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
static void enrichLandFuelTaxFromFeeItems(List<Map<String, String>> rows, Object... feeSources) {
|
||||||
|
if (rows == null || rows.isEmpty() || feeSources == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<Map<?, ?>> fees = new ArrayList<>();
|
||||||
|
for (Object src : feeSources) {
|
||||||
|
if (src == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Object parsed = src;
|
||||||
|
if (src instanceof String) {
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(String.valueOf(src));
|
||||||
|
} catch (Exception ex) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parsed instanceof List) {
|
||||||
|
for (Object one : (List<?>) parsed) {
|
||||||
|
if (one instanceof Map) {
|
||||||
|
fees.add((Map<?, ?>) one);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fees.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (Map<String, String> row : rows) {
|
||||||
|
if (row == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
boolean needFuel = StringUtils.isBlank(row.get("fuel_surcharge_rate"))
|
||||||
|
&& StringUtils.isBlank(row.get("fuelSurchargeRate"));
|
||||||
|
boolean needTax = StringUtils.isBlank(row.get("tax_rate"))
|
||||||
|
&& StringUtils.isBlank(row.get("taxRate"));
|
||||||
|
if (!needFuel && !needTax) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String seg = firstText(row.get("segmentSequence"), "1");
|
||||||
|
String leg = firstText(row.get("routeLegSequence"), "");
|
||||||
|
for (Map<?, ?> fee : fees) {
|
||||||
|
String feeSeg = firstText(fee.get("segmentSequence"), fee.get("segment_sequence"), "1");
|
||||||
|
String feeLeg = firstText(fee.get("routeLegSequence"), fee.get("route_leg_sequence"), "");
|
||||||
|
if (!seg.equals(feeSeg)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (StringUtils.isNotBlank(leg) && StringUtils.isNotBlank(feeLeg) && !leg.equals(feeLeg)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (needFuel) {
|
||||||
|
String fuel = formatLandPercentRate(firstText(
|
||||||
|
fee.get("fuelSurchargeRate"), fee.get("fuel_surcharge_rate")));
|
||||||
|
if (StringUtils.isNotBlank(fuel)) {
|
||||||
|
row.put("fuel_surcharge_rate", fuel);
|
||||||
|
row.put("fuelSurchargeRate", fuel);
|
||||||
|
needFuel = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (needTax) {
|
||||||
|
// 费用行 rate 是税率;禁止拿无关字段冒充
|
||||||
|
String tax = formatLandTaxRate(firstText(fee.get("rate"), fee.get("tax_rate"), fee.get("taxRate")));
|
||||||
|
if (StringUtils.isNotBlank(tax)) {
|
||||||
|
row.put("tax_rate", tax);
|
||||||
|
row.put("taxRate", tax);
|
||||||
|
needTax = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!needFuel && !needTax) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 燃油附加费率展示:数字带 %;已带 % 保留。空值不编造。
|
||||||
|
*/
|
||||||
|
static String formatLandPercentRate(String raw) {
|
||||||
|
if (StringUtils.isBlank(raw) || "-".equals(raw) || "null".equalsIgnoreCase(raw.trim())) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String text = raw.trim();
|
||||||
|
if (text.contains("%")) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
if (text.matches("-?\\d+(\\.\\d+)?")) {
|
||||||
|
if (text.contains(".")) {
|
||||||
|
text = text.replaceAll("0+$", "").replaceAll("\\.$", "");
|
||||||
|
}
|
||||||
|
return text + "%";
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 税率 rate 展示:TMS 常回小数(0.09=9%);大于 1 的按百分点(10=10%)。
|
||||||
|
*/
|
||||||
|
static String formatLandTaxRate(String raw) {
|
||||||
|
if (StringUtils.isBlank(raw) || "-".equals(raw) || "null".equalsIgnoreCase(raw.trim())) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String text = raw.trim();
|
||||||
|
if (text.contains("%")) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
if (!text.matches("-?\\d+(\\.\\d+)?")) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
java.math.BigDecimal n = new java.math.BigDecimal(text);
|
||||||
|
java.math.BigDecimal abs = n.abs();
|
||||||
|
if (abs.compareTo(java.math.BigDecimal.ZERO) > 0
|
||||||
|
&& abs.compareTo(java.math.BigDecimal.ONE) <= 0) {
|
||||||
|
n = n.multiply(java.math.BigDecimal.TEN).multiply(java.math.BigDecimal.TEN)
|
||||||
|
.stripTrailingZeros();
|
||||||
|
}
|
||||||
|
String out = n.stripTrailingZeros().toPlainString();
|
||||||
|
return out + "%";
|
||||||
|
} catch (RuntimeException ex) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* rateFlag:是否含税。字典 1=是、0=否。
|
||||||
|
*/
|
||||||
|
static String formatLandTaxIncluded(String raw) {
|
||||||
|
if (StringUtils.isBlank(raw) || "-".equals(raw) || "null".equalsIgnoreCase(raw.trim())) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String text = raw.trim();
|
||||||
|
if ("1".equals(text) || "true".equalsIgnoreCase(text) || "是".equals(text) || "含税".equals(text)) {
|
||||||
|
return "含税";
|
||||||
|
}
|
||||||
|
if ("0".equals(text) || "false".equalsIgnoreCase(text) || "否".equals(text) || "不含税".equals(text)) {
|
||||||
|
return "不含税";
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
private static void addFeeLine(List<Map<String, String>> lines, String name, String amount,
|
private static void addFeeLine(List<Map<String, String>> lines, String name, String amount,
|
||||||
String currency, Map<String, String> extras) {
|
String currency, Map<String, String> extras) {
|
||||||
TmsQuoteCurrency.Money money = TmsQuoteCurrency.split(amount, currency);
|
TmsQuoteCurrency.Money money = TmsQuoteCurrency.split(amount, currency);
|
||||||
|
|||||||
+1
-1
@@ -69,7 +69,7 @@ VALUES
|
|||||||
('brv10000000000000000000000000038','deal','已成交','deal.recorded_won','销售选择「成交」','已记录为「已成交」。工单 {{ticket_no}} 已关闭智能体跟进。',1,1,'admin',NOW()),
|
('brv10000000000000000000000000038','deal','已成交','deal.recorded_won','销售选择「成交」','已记录为「已成交」。工单 {{ticket_no}} 已关闭智能体跟进。',1,1,'admin',NOW()),
|
||||||
('brv10000000000000000000000000039','negotiating','协商中','negotiating.recorded','成交跟进中选择「协商中」','已记录为「协商中」。已顺延三个工作日,{{followup_when}}的9:00再次发起成交状态询问。',1,1,'admin',NOW()),
|
('brv10000000000000000000000000039','negotiating','协商中','negotiating.recorded','成交跟进中选择「协商中」','已记录为「协商中」。已顺延三个工作日,{{followup_when}}的9:00再次发起成交状态询问。',1,1,'admin',NOW()),
|
||||||
('brv10000000000000000000000000040','lost','未成交','lost.ask_reason','成交跟进中选择「未成交」且未写原因','已记录为「未成交」。请补充未成交原因。',1,1,'admin',NOW()),
|
('brv10000000000000000000000000040','lost','未成交','lost.ask_reason','成交跟进中选择「未成交」且未写原因','已记录为「未成交」。请补充未成交原因。',1,1,'admin',NOW()),
|
||||||
('brv10000000000000000000000000041','lost','未成交','lost.reason_ack','销售回复未成交原因','已记下未成交原因:{{lost_reason}}。',1,2,'admin',NOW()),
|
('brv10000000000000000000000000041','lost','未成交','lost.reason_ack','销售回复未成交原因','已将未成交原因“{{lost_reason}}”记录在后台,工单 {{ticket_no}}已关闭,智能体不再跟进该工单,请悉知。',1,2,'admin',NOW()),
|
||||||
('brv10000000000000000000000000042','closed','已关闭','closed.air_cannot_activate','空运工单已是终态仍要激活','工单{{ticket_no}}已是「{{status}}」,不能再激活。',1,1,'admin',NOW()),
|
('brv10000000000000000000000000042','closed','已关闭','closed.air_cannot_activate','空运工单已是终态仍要激活','工单{{ticket_no}}已是「{{status}}」,不能再激活。',1,1,'admin',NOW()),
|
||||||
('brv10000000000000000000000000043','sys-ex','系统异常','sys.llm_fail','大模型调用失败(销售侧)','大模型服务异常,IT排查中...',1,1,'admin',NOW()),
|
('brv10000000000000000000000000043','sys-ex','系统异常','sys.llm_fail','大模型调用失败(销售侧)','大模型服务异常,IT排查中...',1,1,'admin',NOW()),
|
||||||
('brv10000000000000000000000000044','sys-ex','系统异常','sys.tms_fail','TMS 接口失败(销售侧)','TMS接口异常,IT运维排查中...',1,2,'admin',NOW()),
|
('brv10000000000000000000000000044','sys-ex','系统异常','sys.tms_fail','TMS 接口失败(销售侧)','TMS接口异常,IT运维排查中...',1,2,'admin',NOW()),
|
||||||
|
|||||||
+305
@@ -2,10 +2,13 @@ package org.jeecg.modules.inquiry.template;
|
|||||||
|
|
||||||
import com.alibaba.fastjson.JSONArray;
|
import com.alibaba.fastjson.JSONArray;
|
||||||
import com.alibaba.fastjson.JSONObject;
|
import com.alibaba.fastjson.JSONObject;
|
||||||
|
import org.apache.poi.ss.usermodel.FillPatternType;
|
||||||
import org.apache.poi.ss.usermodel.PrintSetup;
|
import org.apache.poi.ss.usermodel.PrintSetup;
|
||||||
import org.apache.poi.ss.usermodel.Row;
|
import org.apache.poi.ss.usermodel.Row;
|
||||||
import org.apache.poi.ss.usermodel.Sheet;
|
import org.apache.poi.ss.usermodel.Sheet;
|
||||||
import org.apache.poi.ss.usermodel.Workbook;
|
import org.apache.poi.ss.usermodel.Workbook;
|
||||||
|
import org.apache.poi.xssf.usermodel.XSSFCellStyle;
|
||||||
|
import org.apache.poi.xssf.usermodel.XSSFColor;
|
||||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
@@ -1736,6 +1739,127 @@ public class QuoteTemplateFillTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跨境整车分段切片后仍须带上车型与运价;尺寸副表头行不能被 4000 盖掉,运价写在下一行。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void landCrossBorderFtlKeepsVehiclePriceAfterOdSlice() throws Exception {
|
||||||
|
Path src = Files.createTempFile("quote-land-cb-price-src", ".xlsx");
|
||||||
|
Path out = Files.createTempFile("quote-land-cb-price-out", ".xlsx");
|
||||||
|
try (Workbook wb = new XSSFWorkbook()) {
|
||||||
|
Sheet sheet = wb.createSheet("跨境整车");
|
||||||
|
row(sheet, 5).createCell(0).setCellValue("一、国内段报价");
|
||||||
|
org.apache.poi.ss.usermodel.Row head1 = row(sheet, 7);
|
||||||
|
head1.createCell(1).setCellValue("起始地");
|
||||||
|
head1.createCell(2).setCellValue("目的地");
|
||||||
|
head1.createCell(3).setCellValue("3T");
|
||||||
|
head1.createCell(4).setCellValue("5T");
|
||||||
|
org.apache.poi.ss.usermodel.Row dim1 = row(sheet, 8);
|
||||||
|
dim1.createCell(3).setCellValue("4.1*2.1*2.3M");
|
||||||
|
dim1.createCell(4).setCellValue("6.5*2.4*2.5M");
|
||||||
|
row(sheet, 24).createCell(0).setCellValue("二、境外报价");
|
||||||
|
org.apache.poi.ss.usermodel.Row head2 = row(sheet, 26);
|
||||||
|
head2.createCell(1).setCellValue("起始地");
|
||||||
|
head2.createCell(2).setCellValue("目的地");
|
||||||
|
head2.createCell(3).setCellValue("1.25T");
|
||||||
|
head2.createCell(4).setCellValue("2.5T");
|
||||||
|
org.apache.poi.ss.usermodel.Row dim2 = row(sheet, 27);
|
||||||
|
dim2.createCell(3).setCellValue("2.8x1.5x1.6M");
|
||||||
|
dim2.createCell(4).setCellValue("3.50x1.6x1.6M");
|
||||||
|
try (java.io.OutputStream os = Files.newOutputStream(src)) {
|
||||||
|
wb.write(os);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSONObject mapping = new JSONObject(true);
|
||||||
|
mapping.put("sheet", "跨境整车");
|
||||||
|
JSONArray regions = new JSONArray();
|
||||||
|
JSONObject inland = new JSONObject(true);
|
||||||
|
inland.put("title", "国内段报价");
|
||||||
|
inland.put("headerRow", 8);
|
||||||
|
inland.put("dataStartRow", 8);
|
||||||
|
JSONObject cols1 = new JSONObject(true);
|
||||||
|
cols1.put("origin", "B");
|
||||||
|
cols1.put("destination", "C");
|
||||||
|
JSONObject rates1 = new JSONObject(true);
|
||||||
|
rates1.put("3T", "D");
|
||||||
|
rates1.put("5T", "E");
|
||||||
|
cols1.put("vehicleRates", rates1);
|
||||||
|
inland.put("columns", cols1);
|
||||||
|
regions.add(inland);
|
||||||
|
JSONObject overseas = new JSONObject(true);
|
||||||
|
overseas.put("title", "境外报价");
|
||||||
|
overseas.put("headerRow", 27);
|
||||||
|
overseas.put("dataStartRow", 27);
|
||||||
|
JSONObject cols2 = new JSONObject(true);
|
||||||
|
cols2.put("origin", "B");
|
||||||
|
cols2.put("destination", "C");
|
||||||
|
JSONObject rates2 = new JSONObject(true);
|
||||||
|
rates2.put("1.25T", "D");
|
||||||
|
rates2.put("2.5T", "E");
|
||||||
|
cols2.put("vehicleRates", rates2);
|
||||||
|
overseas.put("columns", cols2);
|
||||||
|
regions.add(overseas);
|
||||||
|
mapping.put("routeRegions", regions);
|
||||||
|
JSONObject facts = new JSONObject(true);
|
||||||
|
facts.put("origin", "深圳");
|
||||||
|
facts.put("destination", "春疆货场");
|
||||||
|
facts.put("currency", "CNY");
|
||||||
|
facts.put("route_rows",
|
||||||
|
"[{\"origin\":\"广东省-深圳市\",\"destination\":\"谅山-春疆货场\","
|
||||||
|
+ "\"vehicleType\":\"3T\",\"amount\":\"4000.00\",\"currency\":\"CNY\"}]");
|
||||||
|
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
|
||||||
|
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||||
|
Sheet sheet = filled.getSheetAt(0);
|
||||||
|
assertEquals("4.1*2.1*2.3M", read(sheet, 8, 3));
|
||||||
|
assertEquals("广东省-深圳市", read(sheet, 9, 1));
|
||||||
|
assertEquals("", read(sheet, 9, 2));
|
||||||
|
assertEquals("4000.00", read(sheet, 9, 3));
|
||||||
|
assertEquals("", read(sheet, 9, 4));
|
||||||
|
assertEquals("2.8x1.5x1.6M", read(sheet, 27, 3));
|
||||||
|
assertEquals("", read(sheet, 28, 1));
|
||||||
|
assertEquals("谅山-春疆货场", read(sheet, 28, 2));
|
||||||
|
assertEquals("", read(sheet, 28, 3));
|
||||||
|
} finally {
|
||||||
|
Files.deleteIfExists(src);
|
||||||
|
Files.deleteIfExists(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 境外段「起始地」若是白底,对齐表头时不能拿它去刷「币种」灰底。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void landCrossBorderFtlAlignHeaderSkipsWhiteOriginFill() throws Exception {
|
||||||
|
try (Workbook wb = new XSSFWorkbook()) {
|
||||||
|
Sheet sheet = wb.createSheet("跨境整车");
|
||||||
|
Row head = sheet.createRow(26);
|
||||||
|
head.createCell(1).setCellValue("起始地");
|
||||||
|
head.createCell(2).setCellValue("目的地");
|
||||||
|
head.createCell(3).setCellValue("1.25T");
|
||||||
|
head.createCell(4).setCellValue("2.5T");
|
||||||
|
head.createCell(14).setCellValue("币种");
|
||||||
|
XSSFCellStyle white = (XSSFCellStyle) wb.createCellStyle();
|
||||||
|
white.setFillForegroundColor(new XSSFColor(new byte[]{(byte) 0xFF, (byte) 0xFF, (byte) 0xFF}, null));
|
||||||
|
white.setFillPattern(FillPatternType.SOLID_FOREGROUND);
|
||||||
|
XSSFCellStyle grey = (XSSFCellStyle) wb.createCellStyle();
|
||||||
|
grey.setFillForegroundColor(new XSSFColor(new byte[]{(byte) 0xCF, (byte) 0xCF, (byte) 0xCF}, null));
|
||||||
|
grey.setFillPattern(FillPatternType.SOLID_FOREGROUND);
|
||||||
|
head.getCell(1).setCellStyle(white);
|
||||||
|
head.getCell(2).setCellStyle(white);
|
||||||
|
head.getCell(3).setCellStyle(grey);
|
||||||
|
head.getCell(4).setCellStyle(grey);
|
||||||
|
head.getCell(14).setCellStyle(grey);
|
||||||
|
byte[] sample = QuoteTemplateExcelSupport.sampleLandMatrixHeaderFill(sheet, 26);
|
||||||
|
assertFalse(QuoteTemplateExcelSupport.looksLikeWhiteOrEmptyFill(sample));
|
||||||
|
assertEquals("CFCFCF", String.format("%02X%02X%02X",
|
||||||
|
sample[0] & 0xff, sample[1] & 0xff, sample[2] & 0xff));
|
||||||
|
QuoteTemplateExcelSupport.alignLandNestedHeaderFill(wb);
|
||||||
|
byte[] currencyFill = QuoteTemplateExcelSupport.readFillRgb(head.getCell(14));
|
||||||
|
assertEquals("CFCFCF", String.format("%02X%02X%02X",
|
||||||
|
currencyFill[0] & 0xff, currencyFill[1] & 0xff, currencyFill[2] & 0xff));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 跨境整车国内段只写境内起点,境外段只写境外终点,不能两段都写成郑州→吉隆坡。
|
* 跨境整车国内段只写境内起点,境外段只写境外终点,不能两段都写成郑州→吉隆坡。
|
||||||
*/
|
*/
|
||||||
@@ -1819,6 +1943,133 @@ public class QuoteTemplateFillTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WO202610080006:跨境整车上传版表头是「时效 | 币种(错行) | 燃油 | 税率 | 含税合计」。
|
||||||
|
* 起始地不能被费用名「运输费」盖掉;CNY 只能进币种列;燃油附加费率要写到燃油列。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void landCrossBorderFtlKeepsOriginCurrencyFuelOnStaggeredHeaders() throws Exception {
|
||||||
|
Path src = Files.createTempFile("quote-land-cb-wo006-src", ".xlsx");
|
||||||
|
Path out = Files.createTempFile("quote-land-cb-wo006-out", ".xlsx");
|
||||||
|
try (Workbook wb = new XSSFWorkbook()) {
|
||||||
|
Sheet sheet = wb.createSheet("跨境整车报价模板");
|
||||||
|
row(sheet, 4).createCell(0).setCellValue("一、国内段报价");
|
||||||
|
org.apache.poi.ss.usermodel.Row band1 = row(sheet, 5);
|
||||||
|
band1.createCell(0).setCellValue("国内段报价");
|
||||||
|
band1.createCell(1).setCellValue("路线");
|
||||||
|
band1.createCell(3).setCellValue("车型");
|
||||||
|
band1.createCell(13).setCellValue("时效");
|
||||||
|
band1.createCell(15).setCellValue("燃油附加费率");
|
||||||
|
band1.createCell(16).setCellValue("税率");
|
||||||
|
band1.createCell(17).setCellValue("含税合计");
|
||||||
|
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(5, 7, 13, 13));
|
||||||
|
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(5, 7, 15, 15));
|
||||||
|
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(5, 7, 16, 16));
|
||||||
|
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(5, 7, 17, 17));
|
||||||
|
org.apache.poi.ss.usermodel.Row head1 = row(sheet, 6);
|
||||||
|
head1.createCell(1).setCellValue("起始地");
|
||||||
|
head1.createCell(2).setCellValue("目的地");
|
||||||
|
head1.createCell(3).setCellValue("3T");
|
||||||
|
head1.createCell(4).setCellValue("5T");
|
||||||
|
// 币种只印在第 7 行 O 列,和时效/燃油不在同一行(上传版真实布局)
|
||||||
|
head1.createCell(14).setCellValue("币种");
|
||||||
|
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(6, 7, 1, 1));
|
||||||
|
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(6, 7, 2, 2));
|
||||||
|
row(sheet, 7).createCell(3).setCellValue("4.1*2.1*2.3M");
|
||||||
|
row(sheet, 23).createCell(0).setCellValue("二、境外报价");
|
||||||
|
org.apache.poi.ss.usermodel.Row band2 = row(sheet, 24);
|
||||||
|
band2.createCell(0).setCellValue("境外报价");
|
||||||
|
band2.createCell(13).setCellValue("时效");
|
||||||
|
band2.createCell(15).setCellValue("燃油附加费率");
|
||||||
|
band2.createCell(16).setCellValue("税率");
|
||||||
|
band2.createCell(17).setCellValue("含税合计");
|
||||||
|
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(24, 26, 13, 13));
|
||||||
|
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(24, 26, 15, 15));
|
||||||
|
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(24, 26, 16, 16));
|
||||||
|
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(24, 26, 17, 17));
|
||||||
|
org.apache.poi.ss.usermodel.Row head2 = row(sheet, 25);
|
||||||
|
head2.createCell(1).setCellValue("起始地");
|
||||||
|
head2.createCell(2).setCellValue("目的地");
|
||||||
|
head2.createCell(3).setCellValue("1.25T");
|
||||||
|
head2.createCell(14).setCellValue("币种");
|
||||||
|
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(25, 26, 1, 1));
|
||||||
|
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(25, 26, 2, 2));
|
||||||
|
try (java.io.OutputStream os = Files.newOutputStream(src)) {
|
||||||
|
wb.write(os);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSONObject mapping = new JSONObject(true);
|
||||||
|
mapping.put("sheet", "跨境整车报价模板");
|
||||||
|
// 映射误把 currency 指到燃油列 P9:writeCell 路径也必须拒绝
|
||||||
|
JSONObject fields = new JSONObject(true);
|
||||||
|
fields.put("currency", "跨境整车报价模板!P9");
|
||||||
|
mapping.put("fields", fields);
|
||||||
|
JSONArray regions = new JSONArray();
|
||||||
|
JSONObject inland = new JSONObject(true);
|
||||||
|
inland.put("title", "国内段报价");
|
||||||
|
// headerRow 只指到带「时效/燃油」的第 6 行;币种在下一行,侧栏扫描必须仍能找到
|
||||||
|
inland.put("headerRow", 6);
|
||||||
|
inland.put("dataStartRow", 9);
|
||||||
|
JSONObject cols1 = new JSONObject(true);
|
||||||
|
cols1.put("origin", "B");
|
||||||
|
cols1.put("destination", "C");
|
||||||
|
inland.put("columns", cols1);
|
||||||
|
regions.add(inland);
|
||||||
|
JSONObject overseas = new JSONObject(true);
|
||||||
|
overseas.put("title", "境外报价");
|
||||||
|
overseas.put("headerRow", 25);
|
||||||
|
overseas.put("dataStartRow", 28);
|
||||||
|
JSONObject cols2 = new JSONObject(true);
|
||||||
|
cols2.put("origin", "B");
|
||||||
|
cols2.put("destination", "C");
|
||||||
|
overseas.put("columns", cols2);
|
||||||
|
regions.add(overseas);
|
||||||
|
mapping.put("routeRegions", regions);
|
||||||
|
// fee_rows 在 routeRegions 之后写:误指运价行时不能把「运输费」盖掉起始地
|
||||||
|
JSONObject feeRows = new JSONObject(true);
|
||||||
|
feeRows.put("anchor", "跨境整车报价模板!B9");
|
||||||
|
JSONObject feeCols = new JSONObject(true);
|
||||||
|
feeCols.put("name", "B");
|
||||||
|
feeCols.put("amount", "D");
|
||||||
|
feeRows.put("columns", feeCols);
|
||||||
|
mapping.put("fee_rows", feeRows);
|
||||||
|
JSONObject facts = new JSONObject(true);
|
||||||
|
facts.put("origin", "深圳");
|
||||||
|
facts.put("destination", "春疆货场");
|
||||||
|
facts.put("currency", "CNY");
|
||||||
|
facts.put("transit_days", "N+1");
|
||||||
|
facts.put("fuel_surcharge_rate", "8%");
|
||||||
|
facts.put("tax_rate", "0%");
|
||||||
|
facts.put("route_rows", QuoteTemplateExcelSupport.parseFeeRows(
|
||||||
|
"[{\"origin\":\"深圳\",\"destination\":\"春疆货场\",\"currency\":\"CNY\","
|
||||||
|
+ "\"transit_time\":\"N+1\",\"fuel_surcharge_rate\":\"8%\",\"tax_rate\":\"0%\","
|
||||||
|
+ "\"amount\":\"1200\"}]"));
|
||||||
|
facts.put("fee_rows", QuoteTemplateExcelSupport.parseFeeRows(
|
||||||
|
"[{\"name\":\"运输费\",\"amount\":\"1200\",\"currency\":\"CNY\"}]"));
|
||||||
|
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
|
||||||
|
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||||
|
Sheet sheet = filled.getSheetAt(0);
|
||||||
|
assertEquals("起始地", read(sheet, 6, 1));
|
||||||
|
// 国内段只写境内起点;春疆货场是境外终点,不能被「运输费」盖掉起点
|
||||||
|
assertEquals("深圳", read(sheet, 8, 1));
|
||||||
|
assertEquals("", read(sheet, 8, 2));
|
||||||
|
assertEquals("N+1", read(sheet, 8, 13));
|
||||||
|
assertEquals("CNY", read(sheet, 8, 14));
|
||||||
|
assertEquals("8%", read(sheet, 8, 15));
|
||||||
|
assertEquals("0%", read(sheet, 8, 16));
|
||||||
|
assertFalse("CNY".equalsIgnoreCase(read(sheet, 8, 15)));
|
||||||
|
assertFalse("运输费".equals(read(sheet, 8, 1)));
|
||||||
|
assertEquals("币种", read(sheet, 6, 14));
|
||||||
|
assertEquals("燃油附加费率", read(sheet, 5, 15));
|
||||||
|
// 境外段只写境外终点
|
||||||
|
assertEquals("", read(sheet, 27, 1));
|
||||||
|
assertEquals("春疆货场", read(sheet, 27, 2));
|
||||||
|
} finally {
|
||||||
|
Files.deleteIfExists(src);
|
||||||
|
Files.deleteIfExists(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 国内整车:4.2米/3T 这类车型费用也是标签右侧,不是海运费目表。
|
* 国内整车:4.2米/3T 这类车型费用也是标签右侧,不是海运费目表。
|
||||||
*/
|
*/
|
||||||
@@ -2231,4 +2482,58 @@ public class QuoteTemplateFillTest {
|
|||||||
}
|
}
|
||||||
return cell.getStringCellValue();
|
return cell.getStringCellValue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 陆运运价表:燃油附加费率、税率必须写到表头同列的数据行。
|
||||||
|
* rate=0.09→9%;rateFlag 是是否含税,不写进税率格。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void landGroupageFillsFuelSurchargeAndTaxRateOnMatrix() throws Exception {
|
||||||
|
Path src = Files.createTempFile("quote-land-fuel-tax-src", ".xlsx");
|
||||||
|
Path out = Files.createTempFile("quote-land-fuel-tax-out", ".xlsx");
|
||||||
|
try (Workbook wb = new XSSFWorkbook()) {
|
||||||
|
Sheet sheet = wb.createSheet("中越集拼报价");
|
||||||
|
org.apache.poi.ss.usermodel.Row matrix = row(sheet, 7);
|
||||||
|
matrix.createCell(1).setCellValue("始发地");
|
||||||
|
matrix.createCell(2).setCellValue("目的地");
|
||||||
|
matrix.createCell(9).setCellValue("币种");
|
||||||
|
matrix.createCell(10).setCellValue("燃油附加费率");
|
||||||
|
matrix.createCell(11).setCellValue("税率");
|
||||||
|
row(sheet, 9);
|
||||||
|
try (java.io.OutputStream os = Files.newOutputStream(src)) {
|
||||||
|
wb.write(os);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSONObject mapping = new JSONObject(true);
|
||||||
|
mapping.put("sheet", "中越集拼报价");
|
||||||
|
JSONArray regions = new JSONArray();
|
||||||
|
JSONObject region = new JSONObject(true);
|
||||||
|
region.put("headerRow", 8);
|
||||||
|
region.put("dataStartRow", 10);
|
||||||
|
JSONObject columns = new JSONObject(true);
|
||||||
|
columns.put("origin", "B");
|
||||||
|
columns.put("destination", "C");
|
||||||
|
region.put("columns", columns);
|
||||||
|
regions.add(region);
|
||||||
|
mapping.put("routeRegions", regions);
|
||||||
|
JSONObject facts = new JSONObject(true);
|
||||||
|
facts.put("route_rows", QuoteTemplateExcelSupport.parseFeeRows(
|
||||||
|
"[{\"origin\":\"广州\",\"destination\":\"河内\",\"fuelSurchargeRate\":\"8\",\"rate\":\"0.09\",\"rateFlag\":\"1\"}]"));
|
||||||
|
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
|
||||||
|
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||||
|
Sheet sheet = filled.getSheetAt(0);
|
||||||
|
assertEquals("广州", read(sheet, 9, 1));
|
||||||
|
assertEquals("河内", read(sheet, 9, 2));
|
||||||
|
assertEquals("8%", read(sheet, 9, 10));
|
||||||
|
assertEquals("9%", read(sheet, 9, 11));
|
||||||
|
assertEquals("燃油附加费率", read(sheet, 7, 10));
|
||||||
|
assertEquals("税率", read(sheet, 7, 11));
|
||||||
|
} finally {
|
||||||
|
Files.deleteIfExists(src);
|
||||||
|
Files.deleteIfExists(out);
|
||||||
|
}
|
||||||
|
assertEquals("9%", QuoteTemplateExcelSupport.formatLandTaxRateDisplay("0.09"));
|
||||||
|
assertEquals("8%", QuoteTemplateExcelSupport.formatLandPercentDisplay("8.0"));
|
||||||
|
assertEquals("含税", QuoteTemplateExcelSupport.formatLandTaxIncludedDisplay("1"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-4
@@ -187,10 +187,8 @@ public class InquiryTmsCabinServiceTest {
|
|||||||
InquiryTmsCabinService.fillLockQuoteExtras(req, quote, option);
|
InquiryTmsCabinService.fillLockQuoteExtras(req, quote, option);
|
||||||
|
|
||||||
assertEquals("2026-09-30T23:59:59+08:00", req.get("validUntil"));
|
assertEquals("2026-09-30T23:59:59+08:00", req.get("validUntil"));
|
||||||
@SuppressWarnings("unchecked")
|
// departureCharges 出站会 TMS 1001,只保留 feeItems。
|
||||||
java.util.List<Map<String, Object>> dep =
|
org.junit.jupiter.api.Assertions.assertNull(req.get("departureCharges"));
|
||||||
(java.util.List<Map<String, Object>>) req.get("departureCharges");
|
|
||||||
assertEquals("运输费", dep.get(0).get("chargeName"));
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
java.util.List<Map<String, Object>> fees =
|
java.util.List<Map<String, Object>> fees =
|
||||||
(java.util.List<Map<String, Object>>) req.get("feeItems");
|
(java.util.List<Map<String, Object>>) req.get("feeItems");
|
||||||
@@ -298,4 +296,40 @@ public class InquiryTmsCabinServiceTest {
|
|||||||
InquiryTmsCabinService.fillManualLock(req, quote, new HashMap<>(), new HashMap<>(), false);
|
InquiryTmsCabinService.fillManualLock(req, quote, new HashMap<>(), new HashMap<>(), false);
|
||||||
org.junit.jupiter.api.Assertions.assertNull(req.get("quoteId"));
|
org.junit.jupiter.api.Assertions.assertNull(req.get("quoteId"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void validateManualLockOutboundReportsMissingFields() {
|
||||||
|
Map<String, Object> empty = new HashMap<>();
|
||||||
|
String why = InquiryTmsCabinService.validateManualLockOutbound(empty);
|
||||||
|
org.junit.jupiter.api.Assertions.assertTrue(why.contains("optionNo"));
|
||||||
|
org.junit.jupiter.api.Assertions.assertTrue(why.contains("airline"));
|
||||||
|
org.junit.jupiter.api.Assertions.assertTrue(why.contains("chargeableWeightKg"));
|
||||||
|
|
||||||
|
Map<String, Object> ok = new HashMap<>();
|
||||||
|
ok.put("optionNo", "AIR-OPT01");
|
||||||
|
ok.put("airline", "CZ");
|
||||||
|
ok.put("chargeableWeightKg", "100");
|
||||||
|
ok.put("flightNo", "CZ327");
|
||||||
|
ok.put("operatorName", "航线");
|
||||||
|
ok.put("validUntil", "2026-10-01T23:59:59+08:00");
|
||||||
|
assertEquals("", InquiryTmsCabinService.validateManualLockOutbound(ok));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void airlineCodeOrBlankRejectsChineseName() {
|
||||||
|
assertEquals("CZ", InquiryTmsCabinService.airlineCodeOrBlank("CZ"));
|
||||||
|
assertEquals("", InquiryTmsCabinService.airlineCodeOrBlank("南航"));
|
||||||
|
assertEquals("", InquiryTmsCabinService.airlineCodeOrBlank(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void realTmsQuoteIdSkipsManualAndKeepsOlderTms() {
|
||||||
|
org.jeecg.modules.inquiry.entity.InquiryQuote manual = new org.jeecg.modules.inquiry.entity.InquiryQuote();
|
||||||
|
manual.setPayloadJson("{\"source\":\"PRODUCT\",\"source_label\":\"航线报价\",\"total\":\"200\"}");
|
||||||
|
org.jeecg.modules.inquiry.entity.InquiryQuote tms = new org.jeecg.modules.inquiry.entity.InquiryQuote();
|
||||||
|
tms.setPayloadJson("{\"source\":\"TMS\",\"quoteId\":\"AIR-REAL-1\",\"airOptions\":[{\"optionNo\":\"AIR-OPT01\"}]}");
|
||||||
|
String id = InquiryTmsCabinService.realTmsQuoteId(
|
||||||
|
java.util.Arrays.asList(manual, tms), "WO202610030009");
|
||||||
|
assertEquals("AIR-REAL-1", id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+69
@@ -184,6 +184,75 @@ public class TmsQuoteCurrencyTest {
|
|||||||
assertTrue(quote.get("fee_lines").contains("2800"));
|
assertTrue(quote.get("fee_lines").contains("2800"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 陆运 TMS:fuelSurchargeRate=燃油附加费率;rate=税率;rateFlag=是否含税。
|
||||||
|
* 禁止用 fxRates.rate(汇率)冒充税率。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void toAgentQuoteProjectsLandFuelAndTaxRate() {
|
||||||
|
Map<String, Object> seg = new HashMap<>();
|
||||||
|
seg.put("segmentSequence", 1);
|
||||||
|
seg.put("origin", "广州市");
|
||||||
|
seg.put("destination", "深圳市");
|
||||||
|
seg.put("costPrice", "110");
|
||||||
|
seg.put("currency", "CNY");
|
||||||
|
seg.put("fuelSurchargeRate", 8);
|
||||||
|
seg.put("rate", 0.09);
|
||||||
|
seg.put("rateFlag", 1);
|
||||||
|
Map<String, Object> data = new HashMap<>();
|
||||||
|
data.put("quoteFound", true);
|
||||||
|
data.put("currency", "CNY");
|
||||||
|
data.put("landSegments", Collections.singletonList(seg));
|
||||||
|
Map<String, Object> tms = new HashMap<>();
|
||||||
|
tms.put("code", 1);
|
||||||
|
tms.put("tmsData", data);
|
||||||
|
Map<String, String> quote = InquiryTmsQueryService.toAgentQuote(tms);
|
||||||
|
assertTrue(quote.get("route_rows").contains("\"fuel_surcharge_rate\":\"8%\""));
|
||||||
|
assertTrue(quote.get("route_rows").contains("\"tax_rate\":\"9%\""));
|
||||||
|
assertTrue(quote.get("route_rows").contains("\"tax_included\":\"含税\""));
|
||||||
|
assertTrue(quote.get("route_rows").contains("\"rateFlag\":\"1\""));
|
||||||
|
assertFalse(quote.get("route_rows").contains("fxRates"));
|
||||||
|
assertEquals("8%", InquiryTmsQueryService.formatLandPercentRate("8"));
|
||||||
|
assertEquals("9%", InquiryTmsQueryService.formatLandTaxRate("0.09"));
|
||||||
|
assertEquals("10%", InquiryTmsQueryService.formatLandTaxRate("10"));
|
||||||
|
assertEquals("含税", InquiryTmsQueryService.formatLandTaxIncluded("1"));
|
||||||
|
assertEquals("不含税", InquiryTmsQueryService.formatLandTaxIncluded("0"));
|
||||||
|
assertEquals("", InquiryTmsQueryService.formatLandPercentRate(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 腿上燃油为空时,从同段 feeItems.fuelSurchargeRate 补到 route_rows,供出单侧栏写入。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void toAgentQuoteFillsLandFuelFromFeeItemsWhenSegmentMissing() {
|
||||||
|
Map<String, Object> seg = new HashMap<>();
|
||||||
|
seg.put("segmentSequence", 1);
|
||||||
|
seg.put("routeLegSequence", 1);
|
||||||
|
seg.put("origin", "深圳");
|
||||||
|
seg.put("destination", "春疆货场");
|
||||||
|
seg.put("costPrice", "1200");
|
||||||
|
seg.put("currency", "CNY");
|
||||||
|
Map<String, Object> fee = new HashMap<>();
|
||||||
|
fee.put("segmentSequence", 1);
|
||||||
|
fee.put("routeLegSequence", 1);
|
||||||
|
fee.put("feeName", "运输费");
|
||||||
|
fee.put("amount", 1200);
|
||||||
|
fee.put("currency", "CNY");
|
||||||
|
fee.put("fuelSurchargeRate", 8);
|
||||||
|
fee.put("rate", 0);
|
||||||
|
Map<String, Object> data = new HashMap<>();
|
||||||
|
data.put("quoteFound", true);
|
||||||
|
data.put("currency", "CNY");
|
||||||
|
data.put("landSegments", Collections.singletonList(seg));
|
||||||
|
data.put("feeItems", Collections.singletonList(fee));
|
||||||
|
Map<String, Object> tms = new HashMap<>();
|
||||||
|
tms.put("code", 1);
|
||||||
|
tms.put("tmsData", data);
|
||||||
|
Map<String, String> quote = InquiryTmsQueryService.toAgentQuote(tms);
|
||||||
|
assertTrue(quote.get("route_rows").contains("\"fuel_surcharge_rate\":\"8%\""));
|
||||||
|
assertTrue(quote.get("route_rows").contains("\"tax_rate\":\"0%\""));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void toAgentQuoteKeepsSeaBucketTotals() {
|
public void toAgentQuoteKeepsSeaBucketTotals() {
|
||||||
Map<String, Object> pol = new HashMap<>();
|
Map<String, Object> pol = new HashMap<>();
|
||||||
|
|||||||
+11
-2
@@ -568,13 +568,22 @@ public class LoginController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 切换菜单表为vue3的表
|
* 切换菜单表为vue3的表。
|
||||||
|
* <p>
|
||||||
|
* 询价新库若已无 {@code sys_permission_v3},服务层会拒绝切换并返回错误,避免把现用菜单表 rename 掉。
|
||||||
|
* </p>
|
||||||
*/
|
*/
|
||||||
@RequiresRoles({"admin"})
|
@RequiresRoles({"admin"})
|
||||||
@GetMapping(value = "/switchVue3Menu")
|
@GetMapping(value = "/switchVue3Menu")
|
||||||
public Result<String> switchVue3Menu(HttpServletResponse response) {
|
public Result<String> switchVue3Menu(HttpServletResponse response) {
|
||||||
Result<String> res = new Result<String>();
|
Result<String> res = new Result<String>();
|
||||||
sysPermissionService.switchVue3Menu();
|
try {
|
||||||
|
sysPermissionService.switchVue3Menu();
|
||||||
|
res.success("切换成功");
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("switchVue3Menu rejected or failed: {}", e.getMessage(), e);
|
||||||
|
res.error500(e.getMessage());
|
||||||
|
}
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
@@ -49,6 +49,18 @@ public interface SysPermissionMapper extends BaseMapper<SysPermission> {
|
|||||||
public void backupVue2Menu();
|
public void backupVue2Menu();
|
||||||
@Update("alter table sys_permission_v3 rename to sys_permission")
|
@Update("alter table sys_permission_v3 rename to sys_permission")
|
||||||
public void changeVue3Menu();
|
public void changeVue3Menu();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否存在 Jeecg 官方 Vue3 菜单备份表 {@code sys_permission_v3}。
|
||||||
|
* <p>
|
||||||
|
* 询价新库 {@code inquiry_robot} 的菜单种子本身已是 Vue3 形态,通常没有 v3 表;
|
||||||
|
* 若此时仍执行 rename 切换,会把正在使用的 {@code sys_permission} 改名后找不到替代表,侧栏全挂。
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @return 存在则 > 0
|
||||||
|
*/
|
||||||
|
@Select("SELECT COUNT(1) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'sys_permission_v3'")
|
||||||
|
Long countVue3MenuTable();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取模糊匹配规则的数据权限URL
|
* 获取模糊匹配规则的数据权限URL
|
||||||
|
|||||||
+16
-2
@@ -55,10 +55,24 @@ public class SysPermissionServiceImpl extends ServiceImpl<SysPermissionMapper, S
|
|||||||
@Resource
|
@Resource
|
||||||
private SysDepartRolePermissionMapper sysDepartRolePermissionMapper;
|
private SysDepartRolePermissionMapper sysDepartRolePermissionMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将菜单表从 Vue2 备份切换为 Vue3 表(Jeecg 官方升级路径)。
|
||||||
|
* <p>
|
||||||
|
* 副作用:{@code sys_permission} rename 为 {@code sys_permission_v2},再把 {@code sys_permission_v3}
|
||||||
|
* rename 为 {@code sys_permission}。线程:请求线程,需 admin 角色。
|
||||||
|
* </p>
|
||||||
|
* <p>
|
||||||
|
* 为何加护栏:询价测服库菜单已是 Vue3,且没有 {@code sys_permission_v3};盲目切换会弄挂后台菜单。
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* 询价新库永久拒绝切表:即便存在 sys_permission_v3 也不自动 rename,
|
||||||
|
* 避免非管理员误点前端「Vue2 菜单」弹窗后弄挂 inquiry_robot 侧栏。
|
||||||
|
* 真要升级菜单表须走人工迁移脚本,禁止在线一键切换。
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public void switchVue3Menu() {
|
public void switchVue3Menu() {
|
||||||
sysPermissionMapper.backupVue2Menu();
|
throw new JeecgBootException("已禁用在线切换 Vue3 菜单表;inquiry_robot 菜单按迁移脚本维护,禁止 rename sys_permission");
|
||||||
sysPermissionMapper.changeVue3Menu();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -24,10 +24,12 @@ export const getMenuList = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 切换成vue3菜单
|
* 切换成 vue3 菜单 —— 询价库永久禁用。
|
||||||
|
* <p>
|
||||||
|
* Jeecg 原逻辑会把 sys_permission rename 成 v2 再切 v3 表;测服 inquiry_robot
|
||||||
|
* 菜单已是 Vue3 且常无 sys_permission_v3,执行会弄挂侧栏。前端不再发切表请求。
|
||||||
*/
|
*/
|
||||||
export const switchVue3Menu = () => {
|
export const switchVue3Menu = () => {
|
||||||
return new Promise((resolve) => {
|
console.warn('[ytd] switchVue3Menu disabled: inquiry_robot menus are already Vue3');
|
||||||
defHttp.get({ url: Api.SwitchVue3Menu });
|
return Promise.resolve({ success: false, message: 'switchVue3Menu disabled' });
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,8 +8,10 @@
|
|||||||
import { useRoute, useRouter, RouterView } from 'vue-router';
|
import { useRoute, useRouter, RouterView } from 'vue-router';
|
||||||
import { message, ConfigProvider } from 'ant-design-vue';
|
import { message, ConfigProvider } from 'ant-design-vue';
|
||||||
import { useUserStore } from '/@/store/modules/user';
|
import { useUserStore } from '/@/store/modules/user';
|
||||||
|
import { usePermission } from '/@/hooks/web/usePermission';
|
||||||
import ProtoIcon from '/@/views/inquiry/components/ProtoIcon.vue';
|
import ProtoIcon from '/@/views/inquiry/components/ProtoIcon.vue';
|
||||||
import AdminModal from '/@/views/inquiry/components/AdminModal.vue';
|
import AdminModal from '/@/views/inquiry/components/AdminModal.vue';
|
||||||
|
import { canSeeInquiryPage } from '/@/views/inquiry/shared/inquiryPagePerms';
|
||||||
import '/@/views/inquiry/styles/admin-prototype.css';
|
import '/@/views/inquiry/styles/admin-prototype.css';
|
||||||
import '/@/views/inquiry/styles/admin-fidelity.css';
|
import '/@/views/inquiry/styles/admin-fidelity.css';
|
||||||
import '/@/views/inquiry/styles/dashboard-prototype.css';
|
import '/@/views/inquiry/styles/dashboard-prototype.css';
|
||||||
@@ -74,12 +76,26 @@
|
|||||||
components: { RouterView, ProtoIcon, AdminModal, ConfigProvider },
|
components: { RouterView, ProtoIcon, AdminModal, ConfigProvider },
|
||||||
setup() {
|
setup() {
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
|
const { hasPermission } = usePermission();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const userMenuOpen = ref(false);
|
const userMenuOpen = ref(false);
|
||||||
const profileOpen = ref(false);
|
const profileOpen = ref(false);
|
||||||
const passwordOpen = ref(false);
|
const passwordOpen = ref(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 侧栏按查看权限码过滤;无权限的分组整组隐藏。
|
||||||
|
* 不再渲染写死全量 navGroups,避免「没权限仍看见菜单」。
|
||||||
|
*/
|
||||||
|
const visibleNavGroups = computed(() =>
|
||||||
|
navGroups
|
||||||
|
.map((g) => ({
|
||||||
|
...g,
|
||||||
|
items: g.items.filter((item) => canSeeInquiryPage(item.key, hasPermission)),
|
||||||
|
}))
|
||||||
|
.filter((g) => g.items.length > 0),
|
||||||
|
);
|
||||||
|
|
||||||
const pageTitle = computed(() => String(route.meta?.title || '友通达智能体运营后台'));
|
const pageTitle = computed(() => String(route.meta?.title || '友通达智能体运营后台'));
|
||||||
const crumb = computed(() => (route.meta?.crumb ? String(route.meta.crumb) : ''));
|
const crumb = computed(() => (route.meta?.crumb ? String(route.meta.crumb) : ''));
|
||||||
|
|
||||||
@@ -167,11 +183,15 @@
|
|||||||
if (!t.closest('.admin-user-menu')) closeUserMenu();
|
if (!t.closest('.admin-user-menu')) closeUserMenu();
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => document.addEventListener('click', onDocClick));
|
onMounted(() => {
|
||||||
onUnmounted(() => document.removeEventListener('click', onDocClick));
|
document.addEventListener('click', onDocClick);
|
||||||
|
});
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('click', onDocClick);
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
navGroups,
|
navGroups: visibleNavGroups,
|
||||||
themeToken,
|
themeToken,
|
||||||
route,
|
route,
|
||||||
pageTitle,
|
pageTitle,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { AppRouteRecordRaw, Menu } from '/@/router/types';
|
|||||||
|
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { store } from '/@/store';
|
import { store } from '/@/store';
|
||||||
import { useI18n } from '/@/hooks/web/useI18n';
|
|
||||||
import { useUserStore } from './user';
|
import { useUserStore } from './user';
|
||||||
import { useAppStoreWithOut } from './app';
|
import { useAppStoreWithOut } from './app';
|
||||||
import { toRaw } from 'vue';
|
import { toRaw } from 'vue';
|
||||||
@@ -18,16 +17,16 @@ import { ERROR_LOG_ROUTE, PAGE_NOT_FOUND_ROUTE } from '/@/router/routes/basic';
|
|||||||
|
|
||||||
import { filter } from '/@/utils/helper/treeHelper';
|
import { filter } from '/@/utils/helper/treeHelper';
|
||||||
|
|
||||||
import { getMenuList,switchVue3Menu } from '/@/api/sys/menu';
|
import { getMenuList } from '/@/api/sys/menu';
|
||||||
import { getPermCode } from '/@/api/sys/user';
|
import { getPermCode } from '/@/api/sys/user';
|
||||||
|
|
||||||
import { useMessage } from '/@/hooks/web/useMessage';
|
|
||||||
import { PageEnum } from '/@/enums/pageEnum';
|
import { PageEnum } from '/@/enums/pageEnum';
|
||||||
|
import { INQUIRY_PAGE_VIEW_PERMS } from '/@/views/inquiry/shared/inquiryPagePerms';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 询价原型侧栏路径是写死的;Jeecg BACK 模式只注册后台菜单里的路由。
|
* 询价手写页:纠正菜单种子里的组件路径/标题。
|
||||||
* 菜单种子缺失/组件路径不对时,点侧栏会进 404(本机与线上相同)。
|
* 禁止把未授权页强行补进路由——否则 backMenuList 会膨胀成全量侧栏,无权限也看得见。
|
||||||
* 这里按侧栏清单补挂手写页,不依赖种子是否已导入。
|
* 仅当「后台菜单已有该路径」或「用户已有对应查看权限码」时才补挂缺失叶子。
|
||||||
*/
|
*/
|
||||||
type InquiryLeaf = {
|
type InquiryLeaf = {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -80,16 +79,65 @@ function findParent(list: AppRouteRecordRaw[] = [], parentPath: string): AppRout
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureInquiryHandwrittenRoutes(routeList: AppRouteRecordRaw[]) {
|
/**
|
||||||
const groups: Record<string, { title: string; redirect: string; leaves: InquiryLeaf[] }> = {
|
* 永久禁止 Jeecg「Vue2 菜单表」切表弹窗。
|
||||||
|
* <p>
|
||||||
|
* 检测条件:顶层既没有 component===layouts/default/index,图标也不含冒号。
|
||||||
|
* 非管理员若只分到缺标记的询价菜单,会被误判;点确认会 rename sys_permission,测服无 v3 表会弄挂侧栏。
|
||||||
|
* 本函数恒为 false,禁止再恢复弹窗逻辑。
|
||||||
|
*/
|
||||||
|
function shouldPromptVue2MenuSwitch(_routeList: AppRouteRecordRaw[]): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 给菜单补齐 Vue3 标记,避免未来误恢复 Jeecg 检测时再次弹窗。
|
||||||
|
* 副作用:就地改 component / meta.icon;不改业务权限。
|
||||||
|
*/
|
||||||
|
function normalizeVue3MenuMarkers(routeList: AppRouteRecordRaw[]): AppRouteRecordRaw[] {
|
||||||
|
for (const item of routeList || []) {
|
||||||
|
const comp = String((item as any).component || '');
|
||||||
|
// Jeecg 检测认 layouts/default/index;LAYOUT 也会被 transform 成询价壳
|
||||||
|
if (!comp || comp.toUpperCase() === 'LAYOUT') {
|
||||||
|
(item as any).component = 'layouts/default/index';
|
||||||
|
}
|
||||||
|
item.meta = item.meta || ({} as any);
|
||||||
|
const icon = String((item.meta as any).icon || '');
|
||||||
|
if (!icon) {
|
||||||
|
(item.meta as any).icon = 'ant-design:menu-outlined';
|
||||||
|
} else if (!icon.includes(':')) {
|
||||||
|
(item.meta as any).icon = `ant-design:${icon}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return routeList;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureInquiryHandwrittenRoutes(routeList: AppRouteRecordRaw[], permCodes: Array<string | number> = []) {
|
||||||
|
const codeSet = new Set((permCodes || []).map(String));
|
||||||
|
const canAddLeaf = (leaf: InquiryLeaf) => {
|
||||||
|
// 后台菜单已下发该路径:只纠正组件,不算越权补挂
|
||||||
|
if (findRouteByPath(routeList, leaf.path)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const need = INQUIRY_PAGE_VIEW_PERMS[leaf.path];
|
||||||
|
// 无权限码表时不补挂(宁可少菜单,也不把未授权页塞进侧栏)
|
||||||
|
if (!need) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return codeSet.has(need);
|
||||||
|
};
|
||||||
|
|
||||||
|
const groups: Record<string, { title: string; redirect: string; icon: string; leaves: InquiryLeaf[] }> = {
|
||||||
'/inquiry': {
|
'/inquiry': {
|
||||||
title: '询价',
|
title: '询价',
|
||||||
redirect: '/inquiry/dashboard',
|
redirect: '/inquiry/dashboard',
|
||||||
|
icon: 'ant-design:robot-outlined',
|
||||||
leaves: INQUIRY_LEAVES.filter((l) => l.path.startsWith('/inquiry/') && !l.path.startsWith('/inquiry-sys')),
|
leaves: INQUIRY_LEAVES.filter((l) => l.path.startsWith('/inquiry/') && !l.path.startsWith('/inquiry-sys')),
|
||||||
},
|
},
|
||||||
'/inquiry-sys': {
|
'/inquiry-sys': {
|
||||||
title: '询价系统配置',
|
title: '询价系统配置',
|
||||||
redirect: '/inquiry-sys/system-setting',
|
redirect: '/inquiry-sys/system-setting',
|
||||||
|
icon: 'ant-design:setting-outlined',
|
||||||
leaves: INQUIRY_LEAVES.filter((l) => l.path.startsWith('/inquiry-sys/')),
|
leaves: INQUIRY_LEAVES.filter((l) => l.path.startsWith('/inquiry-sys/')),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -107,6 +155,10 @@ function ensureInquiryHandwrittenRoutes(routeList: AppRouteRecordRaw[]) {
|
|||||||
existing.meta = { ...(existing.meta || {}), title: leaf.title, ignoreKeepAlive: true };
|
existing.meta = { ...(existing.meta || {}), title: leaf.title, ignoreKeepAlive: true };
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// 角色未授权且无对应查看权限:禁止补挂(根因:曾把 8 个询价页全塞进 backMenuList)
|
||||||
|
if (!canAddLeaf(leaf)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const node: AppRouteRecordRaw = {
|
const node: AppRouteRecordRaw = {
|
||||||
path: leaf.path,
|
path: leaf.path,
|
||||||
name: leaf.name,
|
name: leaf.name,
|
||||||
@@ -121,12 +173,23 @@ function ensureInquiryHandwrittenRoutes(routeList: AppRouteRecordRaw[]) {
|
|||||||
parent = {
|
parent = {
|
||||||
path: parentPath,
|
path: parentPath,
|
||||||
name: parentPath.replace(/\//g, '-').replace(/^-/, '') || 'inquiry-root',
|
name: parentPath.replace(/\//g, '-').replace(/^-/, '') || 'inquiry-root',
|
||||||
component: 'LAYOUT' as any,
|
// 必须用 layouts/default/index + 带冒号图标,满足 Jeecg Vue3 菜单检测(实际仍映射到询价壳)
|
||||||
|
component: 'layouts/default/index' as any,
|
||||||
redirect: group.redirect,
|
redirect: group.redirect,
|
||||||
meta: { title: group.title },
|
meta: { title: group.title, icon: group.icon },
|
||||||
children: [],
|
children: [],
|
||||||
};
|
};
|
||||||
routeList.push(parent);
|
routeList.push(parent);
|
||||||
|
} else {
|
||||||
|
// 已有父级也补标记,防止非管理员菜单缺图标被误判 Vue2
|
||||||
|
const pComp = String((parent as any).component || '');
|
||||||
|
if (!pComp || pComp.toUpperCase() === 'LAYOUT') {
|
||||||
|
(parent as any).component = 'layouts/default/index';
|
||||||
|
}
|
||||||
|
parent.meta = {
|
||||||
|
...(parent.meta || {}),
|
||||||
|
icon: (parent.meta as any)?.icon || group.icon,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
parent.children = parent.children || [];
|
parent.children = parent.children || [];
|
||||||
if (leaf.path === group.redirect) {
|
if (leaf.path === group.redirect) {
|
||||||
@@ -139,6 +202,49 @@ function ensureInquiryHandwrittenRoutes(routeList: AppRouteRecordRaw[]) {
|
|||||||
return routeList;
|
return routeList;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 去掉无查看权限的询价叶子(角色树勾了菜单但没勾按钮、或历史脏数据)。
|
||||||
|
* 父节点若子级删空则一并去掉,避免侧栏空分组。
|
||||||
|
*/
|
||||||
|
function pruneInquiryLeavesWithoutViewPerm(
|
||||||
|
routeList: AppRouteRecordRaw[],
|
||||||
|
permCodes: Array<string | number> = [],
|
||||||
|
): AppRouteRecordRaw[] {
|
||||||
|
const codeSet = new Set((permCodes || []).map(String));
|
||||||
|
// 权限码尚未就绪时禁止裁剪:否则会把 getMenuList 结果清空 → 重登 404
|
||||||
|
if (codeSet.size === 0) {
|
||||||
|
return routeList;
|
||||||
|
}
|
||||||
|
const norm = (p: string) => {
|
||||||
|
const s = String(p || '');
|
||||||
|
return s.startsWith('/') ? s : `/${s}`;
|
||||||
|
};
|
||||||
|
const walk = (list: AppRouteRecordRaw[] = []): AppRouteRecordRaw[] => {
|
||||||
|
const out: AppRouteRecordRaw[] = [];
|
||||||
|
for (const item of list) {
|
||||||
|
const path = norm(item.path || '');
|
||||||
|
const need = INQUIRY_PAGE_VIEW_PERMS[path];
|
||||||
|
if (need && !codeSet.has(need)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const next = { ...item };
|
||||||
|
if (next.children?.length) {
|
||||||
|
next.children = walk(next.children as AppRouteRecordRaw[]);
|
||||||
|
// 询价分组下无子页则不保留空壳
|
||||||
|
if (
|
||||||
|
(path === '/inquiry' || path === '/inquiry-sys') &&
|
||||||
|
(!next.children || next.children.length === 0)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(next);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
return walk(routeList);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// 系统权限
|
// 系统权限
|
||||||
interface AuthItem {
|
interface AuthItem {
|
||||||
@@ -246,7 +352,6 @@ export const usePermissionStore = defineStore({
|
|||||||
this.setAuthData(systemPermission);
|
this.setAuthData(systemPermission);
|
||||||
},
|
},
|
||||||
async buildRoutesAction(): Promise<AppRouteRecordRaw[]> {
|
async buildRoutesAction(): Promise<AppRouteRecordRaw[]> {
|
||||||
const { t } = useI18n();
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const appStore = useAppStoreWithOut();
|
const appStore = useAppStoreWithOut();
|
||||||
|
|
||||||
@@ -322,61 +427,31 @@ export const usePermissionStore = defineStore({
|
|||||||
|
|
||||||
// 后台菜单构建
|
// 后台菜单构建
|
||||||
case PermissionModeEnum.BACK:
|
case PermissionModeEnum.BACK:
|
||||||
const { createMessage, createWarningModal } = useMessage();
|
|
||||||
// 菜单加载提示
|
|
||||||
// createMessage.loading({
|
|
||||||
// content: t('sys.app.menuLoading'),
|
|
||||||
// duration: 1,
|
|
||||||
// });
|
|
||||||
|
|
||||||
// 从后台获取权限码,
|
// 从后台获取权限码,
|
||||||
// 这个函数可能只需要执行一次,并且实际的项目可以在正确的时间被放置
|
// 这个函数可能只需要执行一次,并且实际的项目可以在正确的时间被放置
|
||||||
let routeList: AppRouteRecordRaw[] = [];
|
let routeList: AppRouteRecordRaw[] = [];
|
||||||
try {
|
try {
|
||||||
this.changePermissionCode();
|
// 必须 await:logout 会清空 permCodeList;若此处不候等,后面 prune 会在空权限码下
|
||||||
|
// 把询价叶子全删掉 → 侧栏仍能靠稍后到的权限码显示,点进去却是 404。
|
||||||
|
await this.changePermissionCode();
|
||||||
routeList = (await getMenuList()) as AppRouteRecordRaw[];
|
routeList = (await getMenuList()) as AppRouteRecordRaw[];
|
||||||
// update-begin----author:sunjianlei---date:20220315------for: 判断是否是 vue3 版本的菜单 ---
|
// 永久禁止 Vue2→Vue3 切表弹窗(shouldPromptVue2MenuSwitch 恒 false)
|
||||||
let hasIndex: boolean = false;
|
if (shouldPromptVue2MenuSwitch(routeList)) {
|
||||||
let hasIcon: boolean = false;
|
// 不可达;保留分支防止以后误合并 Jeecg 原弹窗代码
|
||||||
for (let menuItem of routeList) {
|
|
||||||
// 条件1:判断组件是否是 layouts/default/index
|
|
||||||
if (!hasIndex) {
|
|
||||||
hasIndex = menuItem.component === 'layouts/default/index';
|
|
||||||
}
|
|
||||||
// 条件2:判断图标是否带有 冒号
|
|
||||||
if (!hasIcon) {
|
|
||||||
hasIcon = !!menuItem.meta?.icon?.includes(':');
|
|
||||||
}
|
|
||||||
// 满足任何一个条件都直接跳出循环
|
|
||||||
if (hasIcon || hasIndex) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// 两个条件都不满足,就弹出提示框
|
// 非管理员菜单常缺 Vue3 标记 → 误弹「菜单表是 Vue2」;先规范化再往下走
|
||||||
if (!hasIcon && !hasIndex) {
|
routeList = normalizeVue3MenuMarkers(routeList || []);
|
||||||
// 延迟1.5秒之后再出现提示,否则提示框出不来
|
|
||||||
setTimeout(
|
|
||||||
() =>
|
|
||||||
createWarningModal({
|
|
||||||
title: '检测提示',
|
|
||||||
content:
|
|
||||||
'当前菜单表是 <b>Vue2版本</b>,导致菜单加载异常!<br>点击确认,切换到Vue3版菜单!',
|
|
||||||
onOk:function () {
|
|
||||||
switchVue3Menu();
|
|
||||||
location.reload();
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
100
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// update-end----author:sunjianlei---date:20220315------for: 判断是否是 vue3 版本的菜单 ---
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
// 菜单接口失败时仍补挂询价手写页,避免侧栏点进去全是 404
|
// 菜单接口失败时仍补挂询价手写页,避免侧栏点进去全是 404
|
||||||
routeList = Array.isArray(routeList) ? routeList : [];
|
routeList = Array.isArray(routeList) ? routeList : [];
|
||||||
}
|
}
|
||||||
// 无论菜单接口成功与否,都按侧栏清单补齐手写路由
|
// 只纠正/补挂「已有菜单或已有查看权限」的手写页,禁止灌入未授权路由
|
||||||
routeList = ensureInquiryHandwrittenRoutes(routeList || []);
|
const permCodes = this.getPermCodeList || [];
|
||||||
|
routeList = ensureInquiryHandwrittenRoutes(routeList || [], permCodes);
|
||||||
|
routeList = pruneInquiryLeavesWithoutViewPerm(routeList, permCodes);
|
||||||
|
// 补挂后再标一次(ensure 新建的父级也要带 Vue3 标记)
|
||||||
|
routeList = normalizeVue3MenuMarkers(routeList);
|
||||||
// 组件地址前加斜杠处理 author: lsq date:2021-09-08
|
// 组件地址前加斜杠处理 author: lsq date:2021-09-08
|
||||||
routeList = addSlashToRouteComponent(routeList);
|
routeList = addSlashToRouteComponent(routeList);
|
||||||
// 动态引入组件
|
// 动态引入组件
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ import {
|
|||||||
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
|
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
|
||||||
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
|
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
|
||||||
import AdminPagination from "/@/views/inquiry/components/AdminPagination.vue";
|
import AdminPagination from "/@/views/inquiry/components/AdminPagination.vue";
|
||||||
|
import { usePermission } from "/@/hooks/web/usePermission";
|
||||||
|
import { InquiryBtnPerm } from "/@/views/inquiry/shared/inquiryPagePerms";
|
||||||
|
|
||||||
|
const { hasPermission } = usePermission();
|
||||||
|
/** 账号新建/编辑/删除/重置密码均属 edit 权限 */
|
||||||
|
const canEditAccount = () => hasPermission(InquiryBtnPerm.accountEdit);
|
||||||
|
|
||||||
const rows = ref<AccountRow[]>([]);
|
const rows = ref<AccountRow[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
@@ -299,7 +305,7 @@ function removeAccount(row: AccountRow) {
|
|||||||
<button type="button" class="btn btn-secondary" disabled title="导出待后续开放">
|
<button type="button" class="btn btn-secondary" disabled title="导出待后续开放">
|
||||||
<ProtoIcon name="download" :size="14" />导出
|
<ProtoIcon name="download" :size="14" />导出
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-primary" @click="openCreate">
|
<button v-if="canEditAccount()" type="button" class="btn btn-primary" @click="openCreate">
|
||||||
<ProtoIcon name="plus" :size="14" />新建账号
|
<ProtoIcon name="plus" :size="14" />新建账号
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -325,6 +331,7 @@ function removeAccount(row: AccountRow) {
|
|||||||
<th>账号</th>
|
<th>账号</th>
|
||||||
<th>姓名</th>
|
<th>姓名</th>
|
||||||
<th>绑定角色</th>
|
<th>绑定角色</th>
|
||||||
|
<th>数据范围</th>
|
||||||
<th>手机号</th>
|
<th>手机号</th>
|
||||||
<th>最后登录</th>
|
<th>最后登录</th>
|
||||||
<th>状态</th>
|
<th>状态</th>
|
||||||
@@ -333,10 +340,10 @@ function removeAccount(row: AccountRow) {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-if="loading">
|
<tr v-if="loading">
|
||||||
<td colspan="7" style="text-align: center; color: #94a3b8">加载中…</td>
|
<td colspan="8" style="text-align: center; color: #94a3b8">加载中…</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-else-if="!rows.length">
|
<tr v-else-if="!rows.length">
|
||||||
<td colspan="7" style="text-align: center; color: #94a3b8">暂无账号</td>
|
<td colspan="8" style="text-align: center; color: #94a3b8">暂无账号</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-for="row in rows" :key="row.id">
|
<tr v-for="row in rows" :key="row.id">
|
||||||
<td class="account-username">{{ row.username }}</td>
|
<td class="account-username">{{ row.username }}</td>
|
||||||
@@ -344,6 +351,20 @@ function removeAccount(row: AccountRow) {
|
|||||||
<td>
|
<td>
|
||||||
<span class="tag" :class="row.roleTag">{{ row.roleLabel }}</span>
|
<span class="tag" :class="row.roleTag">{{ row.roleLabel }}</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<!-- 与编辑弹窗「数据范围」同源:dashboardSelfOnly -->
|
||||||
|
<span
|
||||||
|
class="tag"
|
||||||
|
:class="row.dashboardSelfOnly ? 'tag-amber' : 'tag-teal'"
|
||||||
|
:title="
|
||||||
|
row.dashboardSelfOnly
|
||||||
|
? '分析看板仅统计本账号企微归属的工单'
|
||||||
|
: '分析看板可看权限内全部数据'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ row.dashboardSelfOnly ? "只看自己数据" : "全部数据" }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td>{{ row.phoneMasked }}</td>
|
<td>{{ row.phoneMasked }}</td>
|
||||||
<td>{{ row.lastLogin }}</td>
|
<td>{{ row.lastLogin }}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -354,7 +375,7 @@ function removeAccount(row: AccountRow) {
|
|||||||
<td>
|
<td>
|
||||||
<div class="account-row-actions">
|
<div class="account-row-actions">
|
||||||
<button
|
<button
|
||||||
v-if="!row.isSuperAdmin"
|
v-if="canEditAccount() && !row.isSuperAdmin"
|
||||||
class="link-action"
|
class="link-action"
|
||||||
type="button"
|
type="button"
|
||||||
@click="openEdit(row)"
|
@click="openEdit(row)"
|
||||||
@@ -363,6 +384,7 @@ function removeAccount(row: AccountRow) {
|
|||||||
</button>
|
</button>
|
||||||
<!-- 超管只隐藏编辑/删除;重置密码仍可用。不展示「不可编辑」占位文案 -->
|
<!-- 超管只隐藏编辑/删除;重置密码仍可用。不展示「不可编辑」占位文案 -->
|
||||||
<button
|
<button
|
||||||
|
v-if="canEditAccount()"
|
||||||
class="link-action link-muted"
|
class="link-action link-muted"
|
||||||
type="button"
|
type="button"
|
||||||
@click="resetPassword(row)"
|
@click="resetPassword(row)"
|
||||||
@@ -370,7 +392,7 @@ function removeAccount(row: AccountRow) {
|
|||||||
重置密码
|
重置密码
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="!row.isSuperAdmin"
|
v-if="canEditAccount() && !row.isSuperAdmin"
|
||||||
class="link-action link-danger"
|
class="link-action link-danger"
|
||||||
type="button"
|
type="button"
|
||||||
@click="removeAccount(row)"
|
@click="removeAccount(row)"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* 机器人回复语:对接主账 /inquiry/botReply。
|
* 机器人回复语:对接主账 /inquiry/botReply。
|
||||||
* 分类壳用本地元数据;条目内容全部来自库。
|
* 分类壳用本地元数据;条目内容全部来自库。
|
||||||
@@ -19,6 +19,13 @@ import {
|
|||||||
} from "/@/views/inquiry/botReply/replyCategories";
|
} from "/@/views/inquiry/botReply/replyCategories";
|
||||||
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
|
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
|
||||||
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
|
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
|
||||||
|
import { usePermission } from "/@/hooks/web/usePermission";
|
||||||
|
import { InquiryBtnPerm } from "/@/views/inquiry/shared/inquiryPagePerms";
|
||||||
|
|
||||||
|
const { hasPermission } = usePermission();
|
||||||
|
const canAdd = () => hasPermission(InquiryBtnPerm.replyAdd);
|
||||||
|
const canEdit = () => hasPermission(InquiryBtnPerm.replyEdit);
|
||||||
|
const canDisable = () => hasPermission(InquiryBtnPerm.replyDisable);
|
||||||
|
|
||||||
const categories = ref<ReplyCategory[]>(emptyCategories());
|
const categories = ref<ReplyCategory[]>(emptyCategories());
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
@@ -152,7 +159,7 @@ async function save() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Teleport defer to="#page-topbar-actions">
|
<Teleport defer to="#page-topbar-actions">
|
||||||
<button type="button" class="btn btn-primary" @click="openCreate()">
|
<button v-if="canAdd()" type="button" class="btn btn-primary" @click="openCreate()">
|
||||||
<ProtoIcon name="plus" :size="16" />添加话术
|
<ProtoIcon name="plus" :size="16" />添加话术
|
||||||
</button>
|
</button>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
@@ -177,13 +184,21 @@ async function save() {
|
|||||||
</div>
|
</div>
|
||||||
<div style="display: flex; align-items: center; gap: 8px; flex-shrink: 0">
|
<div style="display: flex; align-items: center; gap: 8px; flex-shrink: 0">
|
||||||
<button
|
<button
|
||||||
|
v-if="canDisable()"
|
||||||
type="button"
|
type="button"
|
||||||
class="switch"
|
class="switch"
|
||||||
:class="{ 'is-on': item.enabled }"
|
:class="{ 'is-on': item.enabled }"
|
||||||
:aria-label="item.enabled ? '停用' : '启用'"
|
:aria-label="item.enabled ? '停用' : '启用'"
|
||||||
@click="toggle(item)"
|
@click="toggle(item)"
|
||||||
/>
|
/>
|
||||||
<button type="button" class="link-action" @click="openEdit(cat.id, item)">编辑</button>
|
<button
|
||||||
|
v-if="canEdit()"
|
||||||
|
type="button"
|
||||||
|
class="link-action"
|
||||||
|
@click="openEdit(cat.id, item)"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
/**
|
/**
|
||||||
* 询价后台登录页:视觉 1:1 对齐 admin/LoginView(居中白卡片 + 品牌区,无验证码)。
|
* 询价后台登录页:视觉 1:1 对齐 admin/LoginView(居中白卡片 + 品牌区,无验证码)。
|
||||||
* 鉴权走 Jeecg loginApi;主账 inquiry 配置关闭图形验证码(jeecg.loginCaptchaEnabled=false)。
|
* 鉴权走 Jeecg loginApi;主账 inquiry 配置关闭图形验证码(jeecg.loginCaptchaEnabled=false)。
|
||||||
|
*
|
||||||
|
* 禁止写死 admin/123456:退出后再进登录页会误显示测试账号。
|
||||||
|
* 仅记住上次成功登录的账号(不存密码),方便换人登录时改账号。
|
||||||
*/
|
*/
|
||||||
import { reactive, ref, toRaw } from 'vue';
|
import { reactive, ref, toRaw } from 'vue';
|
||||||
import { ConfigProvider, message } from 'ant-design-vue';
|
import { ConfigProvider, message } from 'ant-design-vue';
|
||||||
@@ -10,11 +13,29 @@
|
|||||||
import '/@/views/inquiry/styles/admin-prototype.css';
|
import '/@/views/inquiry/styles/admin-prototype.css';
|
||||||
import '/@/views/inquiry/styles/admin-fidelity.css';
|
import '/@/views/inquiry/styles/admin-fidelity.css';
|
||||||
|
|
||||||
|
const LAST_USERNAME_KEY = 'inquiry_admin_last_username';
|
||||||
|
|
||||||
|
function readLastUsername(): string {
|
||||||
|
try {
|
||||||
|
return String(localStorage.getItem(LAST_USERNAME_KEY) || '').trim();
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberUsername(username: string) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LAST_USERNAME_KEY, username);
|
||||||
|
} catch {
|
||||||
|
/* 隐私模式等写失败时忽略 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
username: 'admin',
|
username: readLastUsername(),
|
||||||
password: '123456',
|
password: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const themeToken = {
|
const themeToken = {
|
||||||
@@ -36,9 +57,10 @@
|
|||||||
}
|
}
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
|
const username = form.username.trim();
|
||||||
const { userInfo } = await userStore.login(
|
const { userInfo } = await userStore.login(
|
||||||
toRaw({
|
toRaw({
|
||||||
username: form.username.trim(),
|
username,
|
||||||
password: form.password,
|
password: form.password,
|
||||||
// 主账已关验证码;仍传空字段以兼容 LoginParams 类型
|
// 主账已关验证码;仍传空字段以兼容 LoginParams 类型
|
||||||
captcha: '',
|
captcha: '',
|
||||||
@@ -48,6 +70,9 @@
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
if (userInfo) {
|
if (userInfo) {
|
||||||
|
rememberUsername(username);
|
||||||
|
// 成功后清空密码,避免浏览器后退/再进登录页时残留明文
|
||||||
|
form.password = '';
|
||||||
message.success(`登录成功,欢迎 ${userInfo.realname || userInfo.username}`);
|
message.success(`登录成功,欢迎 ${userInfo.realname || userInfo.username}`);
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|||||||
@@ -342,7 +342,7 @@ export const mockReplyCategories: ReplyCategory[] = [
|
|||||||
id: "brv10000000000000000000000000041",
|
id: "brv10000000000000000000000000041",
|
||||||
replyCode: "lost.reason_ack",
|
replyCode: "lost.reason_ack",
|
||||||
trigger: "销售回复未成交原因",
|
trigger: "销售回复未成交原因",
|
||||||
text: "已记下未成交原因:{{lost_reason}}。",
|
text: "已将未成交原因“{{lost_reason}}”记录在后台,工单 {{ticket_no}}已关闭,智能体不再跟进该工单,请悉知。",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ import {
|
|||||||
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
|
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
|
||||||
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
|
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
|
||||||
import { joinPermissionIds } from "/@/views/inquiry/role/rolePermission";
|
import { joinPermissionIds } from "/@/views/inquiry/role/rolePermission";
|
||||||
|
import { usePermission } from "/@/hooks/web/usePermission";
|
||||||
|
import { InquiryBtnPerm } from "/@/views/inquiry/shared/inquiryPagePerms";
|
||||||
|
|
||||||
|
const { hasPermission } = usePermission();
|
||||||
|
const canEditRole = () => hasPermission(InquiryBtnPerm.roleEdit);
|
||||||
|
|
||||||
const rows = ref<RoleRow[]>([]);
|
const rows = ref<RoleRow[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
@@ -250,7 +255,7 @@ function onDataPermCheck(checked: string[] | { checked: string[] }) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Teleport defer to="#page-topbar-actions">
|
<Teleport defer to="#page-topbar-actions">
|
||||||
<button type="button" class="btn btn-primary" @click="openCreate">
|
<button v-if="canEditRole()" type="button" class="btn btn-primary" @click="openCreate">
|
||||||
<ProtoIcon name="plus" :size="14" />新增角色
|
<ProtoIcon name="plus" :size="14" />新增角色
|
||||||
</button>
|
</button>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
@@ -295,7 +300,7 @@ function onDataPermCheck(checked: string[] | { checked: string[] }) {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<template v-if="row.editable">
|
<template v-if="canEditRole() && row.editable">
|
||||||
<div class="role-row-actions">
|
<div class="role-row-actions">
|
||||||
<button class="link-action" type="button" @click="openEdit(row)">编辑</button>
|
<button class="link-action" type="button" @click="openEdit(row)">编辑</button>
|
||||||
<button class="link-action link-danger" type="button" @click="removeRole(row)">
|
<button class="link-action link-danger" type="button" @click="removeRole(row)">
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* 询价后台页面级权限码:侧栏显隐与「查看」按钮同源。
|
||||||
|
* <p>
|
||||||
|
* 为何:侧栏曾写死全量菜单,且 ensureInquiryHandwrittenRoutes 会把未授权页补进路由,
|
||||||
|
* 导致无 reply:list 等权限的人仍能看到「机器人回复语」。以 Jeecg 权限码为准过滤。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 侧栏路径 → 进入该页至少需要的查看类权限码 */
|
||||||
|
export const INQUIRY_PAGE_VIEW_PERMS: Record<string, string> = {
|
||||||
|
'/inquiry/dashboard': 'inquiry:dashboard:view',
|
||||||
|
'/inquiry/work-order': 'inquiry:workorder:list',
|
||||||
|
'/inquiry/staff-role': 'inquiry:staff:list',
|
||||||
|
'/inquiry/template': 'inquiry:template:view',
|
||||||
|
'/inquiry/bot-reply': 'inquiry:reply:list',
|
||||||
|
'/inquiry-sys/system-setting': 'inquiry:timeout:list',
|
||||||
|
'/inquiry-sys/user': 'inquiry:account:view',
|
||||||
|
'/inquiry-sys/role': 'inquiry:role:view',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 按钮细粒度权限码(与 sys_permission / @RequiresPermissions 对齐) */
|
||||||
|
export const InquiryBtnPerm = {
|
||||||
|
workorderList: 'inquiry:workorder:list',
|
||||||
|
workorderDetail: 'inquiry:workorder:detail',
|
||||||
|
workorderTransition: 'inquiry:workorder:transition',
|
||||||
|
workorderExport: 'inquiry:workorder:export',
|
||||||
|
replyList: 'inquiry:reply:list',
|
||||||
|
replyAdd: 'inquiry:reply:add',
|
||||||
|
replyEdit: 'inquiry:reply:edit',
|
||||||
|
replyDisable: 'inquiry:reply:disable',
|
||||||
|
staffList: 'inquiry:staff:list',
|
||||||
|
staffAdd: 'inquiry:staff:add',
|
||||||
|
staffEdit: 'inquiry:staff:edit',
|
||||||
|
staffDisable: 'inquiry:staff:disable',
|
||||||
|
templateView: 'inquiry:template:view',
|
||||||
|
templateUpload: 'inquiry:template:upload',
|
||||||
|
templateEdit: 'inquiry:template:edit',
|
||||||
|
templateToggle: 'inquiry:template:toggle',
|
||||||
|
accountView: 'inquiry:account:view',
|
||||||
|
accountEdit: 'inquiry:account:edit',
|
||||||
|
roleView: 'inquiry:role:view',
|
||||||
|
roleEdit: 'inquiry:role:edit',
|
||||||
|
timeoutList: 'inquiry:timeout:list',
|
||||||
|
timeoutEdit: 'inquiry:timeout:edit',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前权限码是否允许进入某侧栏路径。
|
||||||
|
* @param path 侧栏 key,如 /inquiry/bot-reply
|
||||||
|
* @param hasPerm hasPermission 回调
|
||||||
|
*/
|
||||||
|
export function canSeeInquiryPage(
|
||||||
|
path: string,
|
||||||
|
hasPerm: (code: string) => boolean,
|
||||||
|
): boolean {
|
||||||
|
const code = INQUIRY_PAGE_VIEW_PERMS[path];
|
||||||
|
if (!code) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return hasPerm(code);
|
||||||
|
}
|
||||||
@@ -25,6 +25,13 @@ import {
|
|||||||
dataPermTitle,
|
dataPermTitle,
|
||||||
normalizeStaffRouteKeys,
|
normalizeStaffRouteKeys,
|
||||||
} from "/@/views/inquiry/shared/dataPermCatalog";
|
} from "/@/views/inquiry/shared/dataPermCatalog";
|
||||||
|
import { usePermission } from "/@/hooks/web/usePermission";
|
||||||
|
import { InquiryBtnPerm } from "/@/views/inquiry/shared/inquiryPagePerms";
|
||||||
|
|
||||||
|
const { hasPermission } = usePermission();
|
||||||
|
const canAddStaff = () => hasPermission(InquiryBtnPerm.staffAdd);
|
||||||
|
const canEditStaff = () => hasPermission(InquiryBtnPerm.staffEdit);
|
||||||
|
const canDisableStaff = () => hasPermission(InquiryBtnPerm.staffDisable);
|
||||||
|
|
||||||
type StaffRole = "sales" | "air" | "sea" | "land";
|
type StaffRole = "sales" | "air" | "sea" | "land";
|
||||||
|
|
||||||
@@ -485,10 +492,16 @@ function filterRouteOption(input: string, option: any) {
|
|||||||
<template>
|
<template>
|
||||||
<Teleport defer to="#page-topbar-actions">
|
<Teleport defer to="#page-topbar-actions">
|
||||||
<div class="staff-topbar-actions">
|
<div class="staff-topbar-actions">
|
||||||
<button type="button" class="btn btn-secondary" :disabled="syncing" @click="syncWecom">
|
<button
|
||||||
|
v-if="canEditStaff()"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
:disabled="syncing"
|
||||||
|
@click="syncWecom"
|
||||||
|
>
|
||||||
<ProtoIcon name="refresh" :size="16" />{{ syncing ? "同步中…" : "同步企业微信" }}
|
<ProtoIcon name="refresh" :size="16" />{{ syncing ? "同步中…" : "同步企业微信" }}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-primary" @click="openCreate">
|
<button v-if="canAddStaff()" type="button" class="btn btn-primary" @click="openCreate">
|
||||||
<ProtoIcon name="userPlus" :size="16" />新增员工
|
<ProtoIcon name="userPlus" :size="16" />新增员工
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -586,9 +599,16 @@ function filterRouteOption(input: string, option: any) {
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div style="display: flex; align-items: center; gap: 12px">
|
<div style="display: flex; align-items: center; gap: 12px">
|
||||||
<button class="link-action" type="button" @click="openEdit(row)">编辑</button>
|
|
||||||
<button
|
<button
|
||||||
v-if="row.status === 'active'"
|
v-if="canEditStaff()"
|
||||||
|
class="link-action"
|
||||||
|
type="button"
|
||||||
|
@click="openEdit(row)"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canDisableStaff() && row.status === 'active'"
|
||||||
class="link-action link-danger"
|
class="link-action link-danger"
|
||||||
type="button"
|
type="button"
|
||||||
@click="removeStaff(row)"
|
@click="removeStaff(row)"
|
||||||
|
|||||||
@@ -23,36 +23,78 @@ body {
|
|||||||
grid-template-columns: 220px minmax(0, 1fr) !important;
|
grid-template-columns: 220px minmax(0, 1fr) !important;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow-x: hidden;
|
/* 锁视口:避免 html overflow:hidden 裁切后整页既无文档滚、内容区又不内滚 */
|
||||||
|
height: 100vh !important;
|
||||||
|
max-height: 100vh !important;
|
||||||
|
min-height: 0 !important;
|
||||||
|
overflow: hidden !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-stage {
|
.main-stage {
|
||||||
background: #f7f8fb !important;
|
background: #f7f8fb !important;
|
||||||
min-width: 0 !important;
|
min-width: 0 !important;
|
||||||
overflow-x: hidden;
|
/*
|
||||||
/* 主区锁视口高度:列表页表格区内滚;看板等非列表页在 .admin-content 内滚 */
|
* 中间主区 = 滚动容器(看板/非列表页)。
|
||||||
|
* 实测仅改 .admin-content 在生产壳层仍会被裁切且无条;改由本列横竖自滚最稳。
|
||||||
|
*/
|
||||||
height: 100vh !important;
|
height: 100vh !important;
|
||||||
max-height: 100vh !important;
|
max-height: 100vh !important;
|
||||||
min-height: 0 !important;
|
min-height: 0 !important;
|
||||||
overflow: hidden !important;
|
overflow-x: auto !important;
|
||||||
|
overflow-y: scroll !important;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
flex-direction: column !important;
|
flex-direction: column !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.main-stage {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #cbd5e1 transparent;
|
||||||
|
}
|
||||||
|
.main-stage::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
.main-stage::-webkit-scrollbar-thumb {
|
||||||
|
background: #cbd5e1;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
.main-stage::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #94a3b8;
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
.main-stage::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.main-stage::-webkit-scrollbar-corner {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-content {
|
.admin-content {
|
||||||
padding: 16px 16px 40px !important;
|
padding: 16px 16px 40px !important;
|
||||||
gap: 12px !important;
|
gap: 12px !important;
|
||||||
min-width: 0 !important;
|
min-width: 0 !important;
|
||||||
/*
|
/* 随内容增高/增宽;滚动条在 .main-stage */
|
||||||
* 主区被锁成一屏。flex-basis 用 auto 时,格子会按内容把内容区撑高,
|
flex: 0 0 auto !important;
|
||||||
* 再被主区裁掉,页面上就没有滚动条,鼠标也滚不动。
|
height: auto !important;
|
||||||
* basis 取 0、高度先归零,再由 flex 撑满顶栏下方,多出来的内容在这里滚。
|
max-height: none !important;
|
||||||
*/
|
min-height: 0 !important;
|
||||||
flex: 1 1 0% !important;
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 列表页:主区不滚,仍由表格区内滚(工单/账号等) */
|
||||||
|
.main-stage:has(.is-list-page) {
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
.main-stage:has(.is-list-page) .admin-content {
|
||||||
|
flex: 1 1 0 !important;
|
||||||
height: 0 !important;
|
height: 0 !important;
|
||||||
min-height: 0 !important;
|
min-height: 0 !important;
|
||||||
overflow-x: hidden !important;
|
max-height: none !important;
|
||||||
overflow-y: auto !important;
|
overflow: hidden !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -68,6 +110,12 @@ body {
|
|||||||
display: flex !important;
|
display: flex !important;
|
||||||
align-items: center !important;
|
align-items: center !important;
|
||||||
border-bottom: 1px solid #e8edf4 !important;
|
border-bottom: 1px solid #e8edf4 !important;
|
||||||
|
/* 中间主区滚动时顶栏吸顶,不跟着内容滚走 */
|
||||||
|
position: sticky !important;
|
||||||
|
top: 0 !important;
|
||||||
|
z-index: 40 !important;
|
||||||
|
flex-shrink: 0 !important;
|
||||||
|
background: rgba(255, 255, 255, 0.96) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-brand {
|
.admin-brand {
|
||||||
@@ -1077,9 +1125,21 @@ h6 {
|
|||||||
.admin-content:has(.ref-surface--metrics) {
|
.admin-content:has(.ref-surface--metrics) {
|
||||||
padding: 14px 14px 36px !important;
|
padding: 14px 14px 36px !important;
|
||||||
gap: 10px !important;
|
gap: 10px !important;
|
||||||
|
/* 分析看板:内容区不裁切,滚动交给 .main-stage */
|
||||||
|
overflow: visible !important;
|
||||||
|
max-height: none !important;
|
||||||
align-content: start !important;
|
align-content: start !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 看板缩窄时允许卡片/图表收缩,避免右侧被裁且无横滚 */
|
||||||
|
.admin-content .ref-surface,
|
||||||
|
.admin-content .ref-charts-section,
|
||||||
|
.admin-content .ref-metrics-grid,
|
||||||
|
.admin-content .ref-chart-panel {
|
||||||
|
min-width: 0 !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- 话术 ---- */
|
/* ---- 话术 ---- */
|
||||||
.reply-category {
|
.reply-category {
|
||||||
border: 1px solid #d9e2ef !important;
|
border: 1px solid #d9e2ef !important;
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ import {
|
|||||||
getTimeoutListAll,
|
getTimeoutListAll,
|
||||||
saveTimeoutHours,
|
saveTimeoutHours,
|
||||||
} from '/@/views/inquiry/systemSetting/systemSetting.api';
|
} from '/@/views/inquiry/systemSetting/systemSetting.api';
|
||||||
|
import { usePermission } from '/@/hooks/web/usePermission';
|
||||||
|
import { InquiryBtnPerm } from '/@/views/inquiry/shared/inquiryPagePerms';
|
||||||
|
|
||||||
|
const { hasPermission } = usePermission();
|
||||||
|
const canEditTimeout = () => hasPermission(InquiryBtnPerm.timeoutEdit);
|
||||||
|
|
||||||
/** 分块顺序:海运 → 空运 → 陆运(产品确认同页分块) */
|
/** 分块顺序:海运 → 空运 → 陆运(产品确认同页分块) */
|
||||||
const MODE_BLOCKS = [
|
const MODE_BLOCKS = [
|
||||||
@@ -153,7 +158,7 @@ async function commitHours(row) {
|
|||||||
min="0"
|
min="0"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
:value="draftHours[row.id]"
|
:value="draftHours[row.id]"
|
||||||
:disabled="savingId === row.id"
|
:disabled="!canEditTimeout() || savingId === row.id"
|
||||||
@input="onHoursInput(row.id, $event.target.value)"
|
@input="onHoursInput(row.id, $event.target.value)"
|
||||||
@blur="commitHours(row)"
|
@blur="commitHours(row)"
|
||||||
@keydown.enter.prevent="commitHours(row)"
|
@keydown.enter.prevent="commitHours(row)"
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ import {
|
|||||||
summarizeMappingParse,
|
summarizeMappingParse,
|
||||||
type MappingParseSummary,
|
type MappingParseSummary,
|
||||||
} from "/@/views/inquiry/template/previewSample";
|
} from "/@/views/inquiry/template/previewSample";
|
||||||
|
import { usePermission } from "/@/hooks/web/usePermission";
|
||||||
|
import { InquiryBtnPerm } from "/@/views/inquiry/shared/inquiryPagePerms";
|
||||||
|
|
||||||
|
const { hasPermission } = usePermission();
|
||||||
|
const canUploadTemplate = () => hasPermission(InquiryBtnPerm.templateUpload);
|
||||||
|
const canEditTemplate = () => hasPermission(InquiryBtnPerm.templateEdit);
|
||||||
|
const canToggleTemplate = () => hasPermission(InquiryBtnPerm.templateToggle);
|
||||||
|
|
||||||
// buildTemplateFileUrl 已由 downloadTemplateFile 替代(鉴权 blob 下载)
|
// buildTemplateFileUrl 已由 downloadTemplateFile 替代(鉴权 blob 下载)
|
||||||
|
|
||||||
@@ -683,7 +690,12 @@ async function downloadPreviewOrOriginal() {
|
|||||||
<template>
|
<template>
|
||||||
<!-- 唯一上传入口:顶栏 -->
|
<!-- 唯一上传入口:顶栏 -->
|
||||||
<Teleport defer to="#page-topbar-actions">
|
<Teleport defer to="#page-topbar-actions">
|
||||||
<button type="button" class="btn btn-primary" @click.stop.prevent="openCreate">
|
<button
|
||||||
|
v-if="canUploadTemplate()"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary"
|
||||||
|
@click.stop.prevent="openCreate"
|
||||||
|
>
|
||||||
<ProtoIcon name="upload" :size="16" />上传模板
|
<ProtoIcon name="upload" :size="16" />上传模板
|
||||||
</button>
|
</button>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
@@ -773,8 +785,16 @@ async function downloadPreviewOrOriginal() {
|
|||||||
<button type="button" class="link-action link-muted" @click.stop="openPreview(row)">
|
<button type="button" class="link-action link-muted" @click.stop="openPreview(row)">
|
||||||
预览
|
预览
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="link-action" @click.stop="openEdit(row)">编辑</button>
|
|
||||||
<button
|
<button
|
||||||
|
v-if="canEditTemplate()"
|
||||||
|
type="button"
|
||||||
|
class="link-action"
|
||||||
|
@click.stop="openEdit(row)"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canEditTemplate()"
|
||||||
type="button"
|
type="button"
|
||||||
class="link-action"
|
class="link-action"
|
||||||
:disabled="row.parseStatus === '解析中'"
|
:disabled="row.parseStatus === '解析中'"
|
||||||
@@ -788,6 +808,7 @@ async function downloadPreviewOrOriginal() {
|
|||||||
重新解析
|
重新解析
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="canToggleTemplate()"
|
||||||
type="button"
|
type="button"
|
||||||
class="link-action"
|
class="link-action"
|
||||||
:class="row.status === 'enabled' ? 'link-danger' : ''"
|
:class="row.status === 'enabled' ? 'link-danger' : ''"
|
||||||
@@ -795,7 +816,12 @@ async function downloadPreviewOrOriginal() {
|
|||||||
>
|
>
|
||||||
{{ row.status === "enabled" ? "禁用" : "启用" }}
|
{{ row.status === "enabled" ? "禁用" : "启用" }}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="link-action link-danger" @click.stop="removeTemplate(row)">
|
<button
|
||||||
|
v-if="canEditTemplate()"
|
||||||
|
type="button"
|
||||||
|
class="link-action link-danger"
|
||||||
|
@click.stop="removeTemplate(row)"
|
||||||
|
>
|
||||||
删除
|
删除
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,6 +22,23 @@ import {
|
|||||||
quantitySummary,
|
quantitySummary,
|
||||||
type DemandField,
|
type DemandField,
|
||||||
} from "/@/views/inquiry/workOrder/demandFields";
|
} from "/@/views/inquiry/workOrder/demandFields";
|
||||||
|
import { usePermission } from "/@/hooks/web/usePermission";
|
||||||
|
import { InquiryBtnPerm } from "/@/views/inquiry/shared/inquiryPagePerms";
|
||||||
|
|
||||||
|
const { hasPermission } = usePermission();
|
||||||
|
const canCloseTicket = () => hasPermission(InquiryBtnPerm.workorderTransition);
|
||||||
|
const canRetryTicket = () => hasPermission(InquiryBtnPerm.workorderTransition);
|
||||||
|
const canViewDetail = () =>
|
||||||
|
hasPermission(InquiryBtnPerm.workorderDetail) || hasPermission(InquiryBtnPerm.workorderList);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 与主账 TicketStatuses 一致:仅询价中/已报价/协商中/转人工可关到「已关闭」。
|
||||||
|
* 已成交/未成交/已关闭为终态,禁止再显示「关闭工单」(否则点确认会报不允许流转)。
|
||||||
|
*/
|
||||||
|
const CLOSEABLE_STATUSES = new Set(["询价中", "已报价", "协商中", "转人工"]);
|
||||||
|
function canShowCloseForStatus(status: string) {
|
||||||
|
return canCloseTicket() && CLOSEABLE_STATUSES.has(String(status || "").trim());
|
||||||
|
}
|
||||||
|
|
||||||
/** 单条费用明细:名称 + 带币种金额,对齐企微价格明细。 */
|
/** 单条费用明细:名称 + 带币种金额,对齐企微价格明细。 */
|
||||||
type TicketQuoteFee = { name: string; amount: string };
|
type TicketQuoteFee = { name: string; amount: string };
|
||||||
@@ -253,9 +270,88 @@ function parseQuoteFees(q: any): TicketQuoteFee[] {
|
|||||||
pushRows(asFeeArray(payload[key] || payload[bucket]));
|
pushRows(asFeeArray(payload[key] || payload[bucket]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 陆运 TMS:燃油附加费率 / 税率挂在线路段上,不是金额费目行。
|
||||||
|
for (const extra of parseLandRateFees(payload)) {
|
||||||
|
if (!out.some((x) => x.name === extra.name && x.amount === extra.amount)) {
|
||||||
|
out.push(extra);
|
||||||
|
}
|
||||||
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 陆运线路:fuelSurchargeRate=燃油附加费率;rate=税率;rateFlag=是否含税(1是0否)。
|
||||||
|
* 只读 payload,不写库。海运/空运没有这些键则不出行。
|
||||||
|
*/
|
||||||
|
function parseLandRateFees(payload: Record<string, any>): TicketQuoteFee[] {
|
||||||
|
const rows = asFeeArray(payload.route_rows || payload.landSegments || payload.land_segments);
|
||||||
|
const out: TicketQuoteFee[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row || typeof row !== "object") continue;
|
||||||
|
const fuel = formatLandPercent(row.fuel_surcharge_rate ?? row.fuelSurchargeRate);
|
||||||
|
const tax = formatLandTaxRate(row.tax_rate ?? row.taxRate ?? row.rate);
|
||||||
|
const included = formatLandTaxIncluded(
|
||||||
|
row.tax_included ?? row.rateFlag ?? row.rate_flag
|
||||||
|
);
|
||||||
|
if (fuel) {
|
||||||
|
const key = `fuel:${fuel}`;
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
out.push({ name: "燃油附加费率", amount: fuel });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tax) {
|
||||||
|
const key = `tax:${tax}`;
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
out.push({ name: "税率", amount: tax });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (included) {
|
||||||
|
const key = `inc:${included}`;
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
out.push({ name: "是否含税", amount: included });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 燃油附加费率:数字带 %。 */
|
||||||
|
function formatLandPercent(raw: unknown): string {
|
||||||
|
const s = String(raw ?? "").trim();
|
||||||
|
if (!s || s === "-" || s.toLowerCase() === "null") return "";
|
||||||
|
if (s.includes("%")) return s;
|
||||||
|
if (/^-?\d+(\.\d+)?$/.test(s)) {
|
||||||
|
return `${s.replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, "")}%`;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 税率 rate:0.09→9%;大于 1 按百分点。 */
|
||||||
|
function formatLandTaxRate(raw: unknown): string {
|
||||||
|
const s = String(raw ?? "").trim();
|
||||||
|
if (!s || s === "-" || s.toLowerCase() === "null") return "";
|
||||||
|
if (s.includes("%")) return s;
|
||||||
|
if (!/^-?\d+(\.\d+)?$/.test(s)) return s;
|
||||||
|
const n = Number(s);
|
||||||
|
if (!Number.isFinite(n)) return s;
|
||||||
|
const abs = Math.abs(n);
|
||||||
|
const pct = abs > 0 && abs <= 1 ? n * 100 : n;
|
||||||
|
return `${String(pct).replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, "")}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** rateFlag:1=含税,0=不含税。 */
|
||||||
|
function formatLandTaxIncluded(raw: unknown): string {
|
||||||
|
const s = String(raw ?? "").trim();
|
||||||
|
if (!s || s === "-" || s.toLowerCase() === "null") return "";
|
||||||
|
if (s === "1" || s.toLowerCase() === "true" || s === "是" || s === "含税") return "含税";
|
||||||
|
if (s === "0" || s.toLowerCase() === "false" || s === "否" || s === "不含税") return "不含税";
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 主账 inq_ticket_status_log.action 是内部码,禁止当备注展示。
|
* 主账 inq_ticket_status_log.action 是内部码,禁止当备注展示。
|
||||||
* transition 与「已报价」等 toStatus 重复,无用户备注时不再跟一段英文。
|
* transition 与「已报价」等 toStatus 重复,无用户备注时不再跟一段英文。
|
||||||
@@ -536,6 +632,10 @@ async function openDetail(row: TicketRow) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openClose(row: TicketRow) {
|
function openClose(row: TicketRow) {
|
||||||
|
if (!canShowCloseForStatus(row.status)) {
|
||||||
|
message.warning(`「${row.status}」为终态,不能关闭工单`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
current.value = row;
|
current.value = row;
|
||||||
closeReason.value = "";
|
closeReason.value = "";
|
||||||
closeOpen.value = true;
|
closeOpen.value = true;
|
||||||
@@ -824,7 +924,7 @@ function refresh() {
|
|||||||
<td class="align-top col-actions col-sticky-right">
|
<td class="align-top col-actions col-sticky-right">
|
||||||
<div class="ticket-row-actions">
|
<div class="ticket-row-actions">
|
||||||
<button
|
<button
|
||||||
v-if="row.systemException !== '-' && row.status !== '已关闭'"
|
v-if="canRetryTicket() && row.systemException !== '-' && row.status !== '已关闭'"
|
||||||
class="link-action"
|
class="link-action"
|
||||||
type="button"
|
type="button"
|
||||||
@click="retryTicket(row)"
|
@click="retryTicket(row)"
|
||||||
@@ -832,14 +932,21 @@ function refresh() {
|
|||||||
重试
|
重试
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="row.status !== '已关闭'"
|
v-if="canShowCloseForStatus(row.status)"
|
||||||
class="link-action"
|
class="link-action"
|
||||||
type="button"
|
type="button"
|
||||||
@click="openClose(row)"
|
@click="openClose(row)"
|
||||||
>
|
>
|
||||||
关闭工单
|
关闭工单
|
||||||
</button>
|
</button>
|
||||||
<button class="link-action" type="button" @click="openDetail(row)">查看详情</button>
|
<button
|
||||||
|
v-if="canViewDetail()"
|
||||||
|
class="link-action"
|
||||||
|
type="button"
|
||||||
|
@click="openDetail(row)"
|
||||||
|
>
|
||||||
|
查看详情
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -867,7 +974,12 @@ function refresh() {
|
|||||||
<template #titleExtra>
|
<template #titleExtra>
|
||||||
<span v-if="current" class="ticket-detail-no">{{ current.id }}</span>
|
<span v-if="current" class="ticket-detail-no">{{ current.id }}</span>
|
||||||
<button
|
<button
|
||||||
v-if="current && current.systemException !== '-' && current.status !== '已关闭'"
|
v-if="
|
||||||
|
canRetryTicket() &&
|
||||||
|
current &&
|
||||||
|
current.systemException !== '-' &&
|
||||||
|
current.status !== '已关闭'
|
||||||
|
"
|
||||||
class="link-action"
|
class="link-action"
|
||||||
type="button"
|
type="button"
|
||||||
@click="retryTicket(current)"
|
@click="retryTicket(current)"
|
||||||
|
|||||||
Reference in New Issue
Block a user