Compare commits

..
Author SHA1 Message Date
linmianxunandCursor 277cdc1c28 角色业务范围收紧工单与模板;报价日期不当费用;未选舱不迁已报价;另开一票可清违禁 hold
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 21:00:01 +08:00
jillion886andCursor a0c705683f 私聊改口成普通货后不再被上一句违禁词拦住。
销售说运送衣服、改成衣服时,按新品名继续询价。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 20:30:53 +08:00
jillion886andCursor 617c5e2619 私聊建单前拦住枪支弹药爆炸物和毒品,避免真家伙进入报价。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 20:24:13 +08:00
42 changed files with 2786 additions and 221 deletions
@@ -205,6 +205,46 @@ def handle_air_group(
intent,
wo or "-",
)
# #region agent log
try:
import json
import time
from pathlib import Path
from agent.llm.extract_text import labeled_quote_date
_payload = {
"sessionId": "717349",
"runId": "post-fix",
"hypothesisId": "H1",
"location": "air_group_collab.py:handle_air_group",
"message": "air group inbound classified",
"data": {
"chat_tail": chat_id[-8:],
"sender_tail": (message.sender_id or "")[-8:],
"intent": intent,
"wo": wo or "",
"text_head": (text or "")[:100],
"labeled_qd": labeled_quote_date(text),
"source": str((message.raw or {}).get("source") or ""),
},
"timestamp": int(time.time() * 1000),
}
_line = json.dumps(_payload, ensure_ascii=False) + "\n"
for _p in (
Path(__file__).resolve().parents[3] / "debug-717349.log",
Path(r"C:\Users\ytd_extdev_0903\incoming\debug-717349.log"),
Path(r"E:\wwwroot\agent\current\debug-717349.log"),
):
try:
_p.parent.mkdir(parents=True, exist_ok=True)
with _p.open("a", encoding="utf-8") as _fh:
_fh.write(_line)
except Exception:
pass
except Exception:
pass
# #endregion
if intent in {INTENT_LOCK, INTENT_RELEASE, INTENT_RELEASE_ASK}:
return _handle_cabin(
engine,
@@ -548,6 +588,36 @@ def _handle_bound(
keep_tms=intent == INTENT_KEEP_TMS,
)
if intent == INTENT_FIELDS:
# #region agent log
try:
import json
import time
from pathlib import Path
_p = Path(__file__).resolve().parents[3] / "debug-866775.log"
with _p.open("a", encoding="utf-8") as _fh:
_fh.write(
json.dumps(
{
"sessionId": "866775",
"hypothesisId": "C",
"location": "air_group_collab.py:_handle_bound",
"message": "INTENT_FIELDS path",
"data": {
"text_head": (message.content or "")[:80],
"has_quote_date_label": (
"报价日期" in (message.content or "")
),
},
"timestamp": int(time.time() * 1000),
},
ensure_ascii=False,
)
+ "\n"
)
except Exception:
pass
# #endregion
return apply_air_fields(
flow=engine,
ticket=ticket,
+8 -3
View File
@@ -31,6 +31,8 @@ def _front_bookmark(sess) -> bool:
return False
phase = (sess.phase or "").strip()
line = (sess.business_line or "").upper()
if phase == "sensitive_block":
return True
if phase == "need_mode" and not line:
return False
return phase in {"clarify", "need_mode", "need_land_options", "wait_confirm"}
@@ -87,7 +89,9 @@ def _carry_waiting_inquiry_to_owner(*, owner: str, sender_id: str) -> None:
raw = (sess.immutable_text or "").strip()
if raw and raw not in texts:
texts.append(raw)
if not facts and not texts:
holds = [str(getattr(item, "sensitive_hold", "") or "").strip() for item in drafts]
holds = [item for item in holds if item]
if not facts and not texts and not holds:
return
from agent.policy.air_text_flow import FlowSession
@@ -99,6 +103,7 @@ def _carry_waiting_inquiry_to_owner(*, owner: str, sender_id: str) -> None:
business_line="",
facts=facts,
immutable_text="\n".join(texts),
sensitive_hold="\n".join(holds),
)
)
logger.info(
@@ -245,7 +250,7 @@ def _pick_text_flow(*, sender_id: str, text: str, injected_mode: str = "", prefe
if (
multi_draft
and (multi_draft.business_line or "").upper() == "MULTI"
and (multi_draft.phase or "") == "clarify"
and (multi_draft.phase or "") in {"clarify", "sensitive_block"}
):
return multi
_drop_other_mode_drafts(owner=spoken, sender_id=sender_id)
@@ -265,7 +270,7 @@ def _pick_text_flow(*, sender_id: str, text: str, injected_mode: str = "", prefe
if (
multi_sess
and (multi_sess.business_line or "").upper() == "MULTI"
and (multi_sess.phase or "") in {"clarify", "wait_confirm"}
and (multi_sess.phase or "") in {"clarify", "wait_confirm", "sensitive_block"}
and not (looks_like_confirm(text) and (multi_sess.phase or "") != "wait_confirm")
):
return multi
-38
View File
@@ -476,44 +476,6 @@ class HttpLedger:
body["airline"] = airline.strip().upper()
if (flight_no or "").strip():
body["flightNo"] = flight_no.strip().upper()
# #region agent log
try:
import json as _json
import time as _time
from pathlib import Path as _Path
_line = _json.dumps(
{
"sessionId": "3ba460",
"runId": "pre-fix",
"hypothesisId": "C",
"location": "http_ledger.py:lock_cabin",
"message": "ledger_lock_called",
"data": {
"wo": work_order_no,
"optionNo": option_no,
"airline": (airline or "").strip(),
"flightNo": (flight_no or "").strip(),
"instruction_head": (instruction_text or "")[:80],
},
"timestamp": int(_time.time() * 1000),
},
ensure_ascii=False,
) + "\n"
for _p in (
_Path(r"d:\own\友通达询价智能体(新)\debug-3ba460.log"),
_Path(r"C:/Users/ytd_extdev_0903/incoming/debug-3ba460.log"),
_Path(r"E:/wwwroot/agent/current/debug-3ba460.log"),
):
try:
_p.parent.mkdir(parents=True, exist_ok=True)
with _p.open("a", encoding="utf-8") as _fh:
_fh.write(_line)
except Exception:
pass
except Exception:
pass
# #endregion
return self._post(
"/inquiry/agent/tms/lockCabin",
body,
+8 -2
View File
@@ -304,12 +304,18 @@ class MemoryLedger:
}
def update_facts(self, *, work_order_no: str, facts: dict[str, str]) -> dict[str, Any]:
"""选定线路后写回起运港等询价事实。"""
"""选定线路后写回起运港等询价事实。空值不删已有键,与主账合并语义一致。"""
with self._lock:
ticket = self._tickets.get(work_order_no)
if ticket is None:
return {"ok": False, "error": "ticket_not_found"}
ticket.facts = dict(facts)
merged = dict(ticket.facts or {})
for key, raw in (facts or {}).items():
name = str(key or "").strip()
val = str(raw or "").strip()
if name and val:
merged[name] = val
ticket.facts = merged
return {"ok": True}
def void_quote_files(self, *, work_order_no: str) -> dict[str, Any]:
+142 -21
View File
@@ -46,7 +46,8 @@ _LABEL_KEYS = (
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|"
+ _QUOTE_DATE_LABEL_RE
+ r"|运输分类|车型/数量|车型数量|"
r"贸易条款|货好时间|货源地|提货地址|收货地址|送货地址|增值服务(非必填)|增值服务|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|"
r"贸易条款|货好时间|货源地|提货地址|收货地址|送货地址|增值服务(非必填)|增值服务|商品海关编码|海关编码|HS编码|HS|"
r"是否含油|是否含电|是否带电|是否含磁|"
r"客户名称|货值|是否为危险品|通关口岸(非必填)|通关口岸"
)
_LABEL_LINE = re.compile(rf"^({_LABEL_KEYS})\s*[::]\s*(.+)$")
@@ -739,7 +740,11 @@ def _merge_optional_inquiry_labels(text: str, facts: dict[str, str]) -> dict[str
def _harvest_labels(text: str, labels: tuple[str, ...]) -> dict[str, str]:
"""按标签切原话。同一起点只留最长标签。"""
"""
按标签切原话。同一起点只留最长标签。
冒号后本行为空视为未填,不跨行吞下一栏(核对卡复制粘贴时空起运地不能变成「目的地:…」)。
"""
raw = text or ""
hits: list[tuple[int, str]] = []
for label in labels:
@@ -771,8 +776,15 @@ def _harvest_labels(text: str, labels: tuple[str, ...]) -> dict[str, str]:
# 增值服务只认冒号后同一行。空行不能把下一行「报价日期(非必填):」收成服务。
if label.startswith("增值服务"):
chunk = chunk.split("\n", 1)[0]
chunk = re.sub(r"^[\s::,,、]+", "", chunk)
chunk = re.sub(r"[\s,,、]+$", "", chunk)
same_line = chunk.split("\n", 1)[0]
same_line_body = re.sub(r"^[\s::,,、]+", "", same_line).strip()
# 冒号后本行为空 = 未填。禁止跨行把下一栏「目的地:吉隆坡」收成起运地。
# 核对卡复制粘贴时非必填常整行只有「起运地(非必填):」,下一行才是必填目的地。
if not same_line_body:
chunk = ""
else:
chunk = re.sub(r"^[\s::,,、]+", "", chunk)
chunk = re.sub(r"[\s,,、]+$", "", chunk)
if chunk:
out[label] = chunk
return out
@@ -834,15 +846,18 @@ def extract_air_collab_fields(
injected: Optional[dict[str, str]] = None,
) -> dict[str, str]:
"""
空运群只认是否含电、是否含磁。
空运群协同项:是否含电、是否含磁(「是否带电」折成是否含电)。
复用海运口语收割,再滤成这两项。
"""
keys = ("是否含电", "是否含磁")
if injected:
from agent.schema.field_validate import normalize_facts
norm = normalize_facts(dict(injected))
return {
k: str(v).strip()
for k, v in (injected or {}).items()
for k, v in norm.items()
if k in keys and str(v or "").strip()
}
got = extract_collab_fields(text, allow_b=allow_b)
@@ -933,28 +948,38 @@ def _ascii_amount(raw: str) -> str:
def _currency_of(text: str, hint: str = "") -> str:
"""
从正文/提示抽三字码。正文没写币种时只回传 hint,禁止默认编造 USD。
从正文抽三字码;正文没写币种时才用 hint,禁止默认编造 USD。
正文显式币种优先于 baseline:TMS 已是 HKD 时,产品写「1RMB/KG」必须落 CNY,
禁止把 hint 拼进正文导致 HKD 盖掉 RMB(WO202610140040)。
RMB 是人民币拼音缩写,统一映射 CNY。
港币必须先于「元」判断,避免「港元」被误判成 CNY。
WO202610090107:TMS 是 HKD,产品只写「运输费调整为1100」时必须继承 HKD。
WO202610090107:TMS 是 HKD,产品只写「运输费调整为1100」时继承 HKD。
"""
blob = f"{hint} {text}".upper()
raw = f"{hint} {text}"
if any(x in blob for x in ("HKD", "港币", "港元", "HK$")) or "港币" in raw or "港元" in raw:
text_only = (text or "").upper()
text_raw = text or ""
# 正文显式币种(不含 hint),与「仅继承 TMS」区分开。
text_has_hkd = any(x in text_only for x in ("HKD", "港币", "港元", "HK$")) or (
"港币" in text_raw or "港元" in text_raw
)
text_has_cny = any(x in text_only for x in ("CNY", "RMB", "人民币")) or (
"元" in text_raw and "港元" not in text_raw and "港币" not in text_raw
)
# 先看正文,再退回 hint;禁止 hint+正文混检让 baseline HKD 压过正文 RMB。
if text_has_hkd:
return "HKD"
if any(x in blob for x in ("CNY", "RMB", "人民币")) or (
"元" in raw and "港元" not in raw and "港币" not in raw
):
if text_has_cny:
return "CNY"
if any(x in blob for x in ("USD", "美金", "美元")):
if any(x in text_only for x in ("USD", "美金", "美元")):
return "USD"
if any(x in blob for x in ("EUR", "欧元")):
if any(x in text_only for x in ("EUR", "欧元")):
return "EUR"
if any(x in blob for x in ("JPY", "日元")):
if any(x in text_only for x in ("JPY", "日元")):
return "JPY"
if any(x in blob for x in ("GBP", "英镑")):
if any(x in text_only for x in ("GBP", "英镑")):
return "GBP"
return str(hint or "").strip().upper()
hint_u = str(hint or "").strip().upper()
return "CNY" if hint_u == "RMB" else hint_u
def _normalize_fee_project(raw: str) -> str:
@@ -983,6 +1008,94 @@ def _strip_group_bot_mentions(text: str) -> str:
return cleaned.strip()
# 报价日期整段:标签+日期。费用抽取前剥掉,避免把年份 2026 当成金额。
_RE_QUOTE_DATE_CHUNK = re.compile(
rf"(?:{_QUOTE_DATE_LABEL_RE})(?:(非必填)|\(非必填\))?\s*[::]?\s*"
r"(?:\d{4}\s*[-/.年]\s*\d{1,2}\s*[-/.月]\s*\d{1,2}\s*日?"
r"|\d{1,2}\s*[-/.月]\s*\d{1,2}\s*日?)",
re.I,
)
# 报价有效期(TMS 展示用,不是航线报价格);剥掉避免年份当金额。
_RE_VALID_UNTIL_CHUNK = re.compile(
r"报价有效期\s*[::]?\s*"
r"(?:\d{4}\s*[-/.年]\s*\d{1,2}\s*[-/.月]\s*\d{1,2}\s*日?"
r"|\d{1,2}\s*[-/.月]\s*\d{1,2}\s*日?)",
re.I,
)
# 时效要求:空运无此字段;剥掉避免「3天」被抽成费用 3。
_RE_TRANSIT_REQ_CHUNK = re.compile(
r"(?:时效要求|要求运输时效)\s*[::]?\s*"
r"\d+(?:\.\d+)?\s*(?:天|日|工作日|个工作日)?",
re.I,
)
def _strip_quote_date_chunks(text: str) -> str:
"""去掉「报价日期:2026-10-15」整段,留给费用行抽取。"""
return _RE_QUOTE_DATE_CHUNK.sub(" ", text or "")
def _strip_non_fee_meta_chunks(text: str) -> str:
"""
去掉不能当费用金额的元数据段:报价日期、报价有效期、时效要求。
运行证据(717349):「报价有效期:2026-10-31」抽成 total=2026;
「时效要求:3天」抽成 total=3。
"""
out = _strip_quote_date_chunks(text or "")
out = _RE_VALID_UNTIL_CHUNK.sub(" ", out)
out = _RE_TRANSIT_REQ_CHUNK.sub(" ", out)
return out
def labeled_quote_date(text: str) -> str:
"""
群里带标签的报价日期,规范成 yyyy-MM-dd;没有则空串。
给空运协同意图与补字段用。不改主账。
"""
from agent.schema.field_validate import _quote_date_labeled_value
return str(_quote_date_labeled_value(text) or "").strip()
def is_quote_date_only_message(text: str) -> bool:
"""
整句只是在补报价日期(可带 @、工单号),没有费用金额。
运行证据:报价日期:2026-10-15 曾被抽成空运费 2026。
"""
raw = (text or "").strip()
if not raw or not labeled_quote_date(raw):
return False
rest = _strip_group_bot_mentions(_strip_non_fee_meta_chunks(raw))
rest = re.sub(r"WO\d{12}", "", rest, flags=re.I)
rest = re.sub(r"[\s::,,。、;;@#]+", "", rest)
return not rest.strip()
def is_non_fee_meta_only_message(text: str) -> bool:
"""
整句只是报价日期 / 报价有效期 / 时效要求(可带 @、工单号),没有费用。
这类句不能走航线报价;有效期来自 TMS,时效空运不写工单。
"""
raw = (text or "").strip()
if not raw:
return False
has_meta = bool(
labeled_quote_date(raw)
or _RE_VALID_UNTIL_CHUNK.search(raw)
or _RE_TRANSIT_REQ_CHUNK.search(raw)
)
if not has_meta:
return False
rest = _strip_group_bot_mentions(_strip_non_fee_meta_chunks(raw))
rest = re.sub(r"WO\d{12}", "", rest, flags=re.I)
rest = re.sub(r"[\s::,,。、;;@#]+", "", rest)
return not rest.strip()
def _split_fee_project_prefix(text: str) -> tuple[str, str]:
"""
拆句首分段标签。例:起运港、增值费_板,单价… → (起运港, 增值费_板,单价…)
@@ -1111,12 +1224,18 @@ def extract_product_quote(
compact = stripped.replace(" ", "").replace(" ", "")
if re.fullmatch(r"WO\d{12}", compact, re.I):
return {}
# 只补报价日期/有效期/时效要求时直接空:年份、天数不能当费用金额。
if is_quote_date_only_message(raw) or is_non_fee_meta_only_message(raw):
return {}
band = _parse_weight_band_quote(stripped or raw, baseline_currency=base_ccy)
if band:
return band
# 费率句若仍落到普通费用行,会把「燃油附加费」配上句首公斤数。先剥掉再抽。
# 必须用剥掉 @询价小助手 后的正文,否则句首「起运港、」分段标签匹配不到。
scrubbed = _strip_rate_and_band_noise(stripped or raw)
# 顺带剥掉报价日期/有效期/时效要求,避免年份、天数再抽成金额。
scrubbed = _strip_rate_and_band_noise(
_strip_non_fee_meta_chunks(stripped or raw)
)
rows = _parse_fee_rows(scrubbed, baseline_currency=base_ccy) if scrubbed.strip() else []
if rows:
ccy = rows[0].get("currency") or _currency_of(raw, base_ccy) or base_ccy
@@ -1137,7 +1256,9 @@ def extract_product_quote(
}
out.update(_extract_rate_fields(raw))
return out
hit = _RE_MONEY.search(_ascii_amount(scrubbed or raw))
# 剥掉报价日期后只剩空白时,禁止回退到原文再抽金额(否则年份 2026 会当空运费)。
money_src = scrubbed if scrubbed.strip() else ""
hit = _RE_MONEY.search(_ascii_amount(money_src)) if money_src else None
if not hit:
rates = _extract_rate_fields(raw)
return rates if rates else {}
+36 -2
View File
@@ -135,8 +135,11 @@ def build_extract_messages(text: str) -> list[dict[str, str]]:
"陆运「通关口岸:皇岗」「通关口岸皇岗」「过皇岗」有说就写入通关口岸,没说留空,禁止编造口岸。"
"原话单独出现 DAP、DDP、CIF、CIP、FOB、EXW 时写入贸易条款;没出现留空,禁止编造。"
"「从南京飞吉隆坡」起运港=南京、目的港=吉隆坡;「北京飞吉隆坡」起运港=北京。"
"「货交上海」「货交:上海」「货交到上海」是提货地址=上海,不是起运港。"
"没有另写起运地、三字码航线或「从哪飞/到」时,起运港必须空串。"
"陆运口语「广州到深圳」「广东省深圳市福田区到香港」→起运港=左侧地名、目的港=右侧地名;"
"不要把左侧写成提货地址,右侧目的地也不能丢。"
"「货交上海」「货交:上海」「货交到上海」才是提货地址=上海,不是起运港。"
"仅空运且只有「货交…」、没有另写起运地/三字码航线/「从哪飞」时,起运港必须空串;"
"陆运「A到B」必须写入起运港和目的港。"
"原话是机场三字码或航线就原样记,禁止翻译成城市名:"
"SZX-CGK / SZX/CGK / SZX到CGK → 起运港=SZX、目的港=CGK,不要写成深圳、雅加达。"
"换城市必须覆盖旧值,不要沿用上一句的港口。"
@@ -214,4 +217,35 @@ def invoke_extract_fields(
parsed.get("business_line") or "-",
sorted((parsed.get("facts") or {}).keys()),
)
# #region agent log
try:
import json
import time
from pathlib import Path
_facts = dict(parsed.get("facts") or {})
_log = {
"sessionId": "0593f5",
"runId": "post-fix",
"hypothesisId": "A",
"location": "mode_extract_fields.py:invoke_extract_fields",
"message": "DeepSeek B place fields",
"data": {
"text_head": (text or "")[:80],
"ok": parsed.get("ok"),
"business_line": parsed.get("business_line"),
"land_subtype": parsed.get("land_subtype"),
"起运港": _facts.get("起运港"),
"目的港": _facts.get("目的港"),
"提货地址": _facts.get("提货地址"),
"fact_keys": sorted(_facts.keys()),
},
"timestamp": int(time.time() * 1000),
}
Path(__file__).resolve().parents[3].joinpath("debug-0593f5.log").open(
"a", encoding="utf-8"
).write(json.dumps(_log, ensure_ascii=False) + "\n")
except Exception:
pass
# #endregion
return parsed
+40 -6
View File
@@ -320,7 +320,10 @@ def send_air_brief_and_sheet(
必须先发出工单信息,模板等摘要发出后再发,禁止后台线程抢先发文件。
填表报价必须带上 TMS 总销售价:群票上的 latest quote 常只有增点费/拆板费明细,
若直接拿去出单,预估费用会落成明细加总(如 200)而不是 salePrice(如 7918.25)。
调用方刚写回的询价字段(如报价日期)优先,不能被 hydrate 旧快照盖掉。
"""
incoming_view = ticket_view(ticket) if ticket is not None else {}
incoming_facts = dict(incoming_view.get("facts") or {})
ticket = hydrate_ticket(flow, ticket)
view = ticket_view(ticket)
no = ticket_no(ticket)
@@ -328,6 +331,12 @@ def send_air_brief_and_sheet(
if room and not view.get("collab_chat_id"):
view["collab_chat_id"] = room
facts = dict(view.get("facts") or {})
# 群里刚补的报价日期等:有值就盖到摘要用的 facts 上。
for key, raw in incoming_facts.items():
val = str(raw or "").strip()
if key and val:
facts[key] = val
view["facts"] = facts
collab = _seed_air_activate_collab(flow, view)
view["collab_facts"] = collab
view_q = dict(view.get("quote") or {})
@@ -617,11 +626,19 @@ def resolve_cabin_ticket(
def apply_air_fields(*, flow: Any, ticket: object, text: str, sender_id: str, chat_id: str) -> str:
"""
补含电/含磁。对齐海运:写入后重发群摘要 + 空运模板,不当一句「已记下」。
补含电/含磁,以及报价日期。
含电含磁走协同字段;报价日期写入询价 facts(主账 quote_date 列)。
货好时间、贸易条款、客户名称不在群里补(产品口径)。
写入后重发群摘要 + 空运模板,不当一句「已记下」。
"""
from agent.llm.extract_text import labeled_quote_date
ticket = hydrate_ticket(flow, ticket)
no = ticket_no(ticket)
client = group_client_of(flow)
fields = extract_air_collab_fields(text, allow_b=True)
quote_date = labeled_quote_date(text)
if not is_air_ticket(ticket):
send_group_text(client, chat_id, copy.air_need_work_order())
return "need_work_order"
@@ -631,11 +648,23 @@ def apply_air_fields(*, flow: Any, ticket: object, text: str, sender_id: str, ch
if not can_fill_fields(flow, ticket, sender_id):
send_group_text(client, chat_id, copy.air_fields_denied())
return "fields_denied"
fields = extract_air_collab_fields(text, allow_b=True)
if not fields:
if not fields and not quote_date:
send_group_text(client, chat_id, copy.air_fields_not_recognized())
return "fields_empty"
flow.ledger.patch_collab_facts(work_order_no=no, facts=fields, sender_id=sender_id)
if fields:
flow.ledger.patch_collab_facts(work_order_no=no, facts=fields, sender_id=sender_id)
write_ok = True
if quote_date:
# 只打补丁键:主账侧合并,避免整包 facts 误伤。
patch = {"报价日期": quote_date}
writer = getattr(flow.ledger, "update_facts", None)
if callable(writer):
try:
out = writer(work_order_no=no, facts=patch) or {}
write_ok = bool(out.get("ok", True))
except Exception:
write_ok = False
logger.exception("空运群写报价日期失败 wo=%s", no)
# 必须回读本单。按群再查会拿到同群更早的陆运/海运旧单(测服 WO202609230007)。
getter = getattr(flow.ledger, "get_ticket", None)
reloaded = getter(work_order_no=no) if callable(getter) else None
@@ -644,6 +673,10 @@ def apply_air_fields(*, flow: Any, ticket: object, text: str, sender_id: str, ch
collab = dict(view.get("collab_facts") or {})
collab.update(fields)
view["collab_facts"] = collab
if quote_date:
facts_view = dict(view.get("facts") or {})
facts_view["报价日期"] = quote_date
view["facts"] = facts_view
view["collab_chat_id"] = chat_id
send_air_brief_and_sheet(flow=flow, ticket=view, chat_id=chat_id)
return "fields_saved"
@@ -1012,8 +1045,9 @@ def apply_air_lock(
航线锁舱。只有工单已是「已成交」才打主账。
还没回复成交:点名销售先确认。协商中、未成交各用一句,不催销售改口。
舱位编号、航司、航班号没齐时只请补全,不调 TMS 锁舱,不标系统异常。
三项齐了(话里写的优先,报价里已有的可以补上)才打主账锁舱。
话里写的优先,报价里已有的舱位可以补上;补不上也要打主账——
主账会合并历史 TMS / 现拉运价库(产品报价无舱位时不能在智能体侧先挡住)。
主账回 lock_params_incomplete 再群里补问,不标系统异常。
上次锁舱失败标过暂停的,航线再锁仍要打 TMS。锁成功不再推成交跟进。
"""
if not is_airline(flow, sender_id):
+141 -11
View File
@@ -32,6 +32,20 @@ logger = logging.getLogger(__name__)
ReplyFn = Callable[..., Optional[str]]
def _ledger_quote_pending_cabin(quote: dict[str, Any]) -> dict[str, Any]:
"""
未选舱前写主账用的薄快照:保留 airOptions 供恢复,清空总价/费用明细,
并打 pending_cabin_pick,避免后台把备选舱位当成已报价。
"""
out = dict(quote or {})
out["pending_cabin_pick"] = "1"
out["fee_lines"] = []
out["total"] = ""
out.pop("selected_air_option", None)
out.pop("selectedAirOption", None)
return out
@dataclass
class FlowSession:
"""一单会话书签(运行账,不是六态真相)。"""
@@ -68,6 +82,8 @@ class FlowSession:
virtual_work_order_no: str = ""
# 核对卡还没有工单号时,图片/附件先记在这里,确定建单后再挂到主账。
pending_files: tuple[dict[str, str], ...] = ()
# 建单前出现过、还没被品名改口清掉的违禁原话。空表示当前没有。
sensitive_hold: str = ""
def is_waiting_mode_session(sess) -> bool:
@@ -152,6 +168,7 @@ def _session_from_payload(raw: dict[str, Any]) -> FlowSession:
for item in (raw.get("pending_files") or [])
if isinstance(item, dict) and str(item.get("object_key") or "").strip()
),
sensitive_hold=str(raw.get("sensitive_hold") or ""),
)
@@ -306,6 +323,66 @@ class AirTextInquiryFlow:
"invited_product_names": sess.invited_product_names,
}
def _refuse_sensitive(
self,
*,
sender_id: str,
text: str,
facts: dict[str, Any],
reply: ReplyFn,
sess: Optional[FlowSession],
business_line: str = "",
) -> Optional[str]:
"""
私聊还没建单时,违禁品只回一句并停在原处。
已有正式工单号的会话直接放过,销售后面补的字按原流程。
没拦住时把品名改口写进 facts,并清掉书签上的旧原话。
拦住时保存已认定字段,方便改口后接着补问。不建单、不追问缺项。
调用在 Worker 的 on_text 里,不占企微回调线程。
"""
from agent.policy.handoff_human import is_virtual_no
from agent.policy.sensitive_cargo import BLOCK_REPLY, judge
if sess is not None:
number = (sess.work_order_no or "").strip()
if number and not is_virtual_no(number):
return None
hold_before = str(getattr(sess, "sensitive_hold", "") or "") if sess else ""
blocked, new_hold, _edits = judge(
text=text,
facts=facts,
hold=hold_before,
)
if sess is not None:
sess.sensitive_hold = new_hold
if not blocked:
return None
kept = self._handoff_state(sess)
pending = tuple(getattr(sess, "pending_files", ()) or ()) if sess else ()
line = (business_line or (getattr(sess, "business_line", "") if sess else "") or "").strip()
self._save(
FlowSession(
sender_id=sender_id,
thread_id=sender_id,
phase="sensitive_block",
business_line=line,
facts={str(key): str(value) for key, value in (facts or {}).items()},
immutable_text=(sess.immutable_text if sess and sess.immutable_text else text),
sensitive_hold=new_hold,
wait_version=(sess.wait_version + 1) if sess else 1,
pending_files=pending,
clarify_missing_sig=str(kept.get("clarify_missing_sig") or ""),
clarify_retry=int(kept.get("clarify_retry") or 0),
handoff_offer=str(kept.get("handoff_offer") or ""),
handoff_committed=bool(kept.get("handoff_committed")),
virtual_work_order_no=str(kept.get("virtual_work_order_no") or ""),
)
)
reply(BLOCK_REPLY)
logger.info("sensitive_block sender=%s line=%s", sender_id, line or "-")
return "sensitive_block"
def _block_if_handoff_committed(self, sess: Optional[FlowSession], reply: ReplyFn) -> bool:
"""真正转人工之后这一票不再跟。返回 True 表示已经拦住。"""
from agent.policy.handoff_human import is_handoff_frozen
@@ -963,6 +1040,16 @@ class AirTextInquiryFlow:
if sess and sess.phase == "wait_confirm" and looks_like_confirm(text):
# 确定查价不再抽字段,避免「确定」被模型改成空 facts。
logger.info("air.confirm sender=%s", sender_id)
refused = self._refuse_sensitive(
sender_id=sender_id,
text=text,
facts=dict(sess.facts),
reply=reply,
sess=sess,
business_line=sess.business_line,
)
if refused:
return refused
return self._confirm_or_hold(
sender_id=sender_id,
facts=dict(sess.facts),
@@ -997,7 +1084,7 @@ class AirTextInquiryFlow:
return llm_phase
mode = (snap.get("business_line") or "").upper()
facts = dict(snap.get("facts") or {})
if sess and sess.phase in {"clarify", "need_mode", "wait_confirm"}:
if sess and sess.phase in {"clarify", "need_mode", "wait_confirm", "sensitive_block"}:
# 补问轮:旧字段打底;运输方式沿用上一句(飞=空运)
# 增值服务按句累加,本句没提时不能把上一句已记下的服务冲掉。
from agent.llm.extract_text import detect_transport_mode
@@ -1019,6 +1106,18 @@ class AirTextInquiryFlow:
facts = apply_air_value_added(added_source, facts, previous=prev_added)
if not mode or mode == "AIR":
refused = self._refuse_sensitive(
sender_id=sender_id,
text=text,
facts=facts,
reply=reply,
sess=sess,
business_line=mode or (sess.business_line if sess else ""),
)
if refused:
return refused
if not mode:
reply(copy.ask_transport())
self._save(
@@ -1195,6 +1294,16 @@ class AirTextInquiryFlow:
from agent.policy.handoff_human import void_virtual_no
old = self.session_of(sender_id)
refused = self._refuse_sensitive(
sender_id=sender_id,
text="",
facts=facts,
reply=reply,
sess=old,
business_line="AIR",
)
if refused:
return refused
if old:
void_virtual_no(old)
self._save(
@@ -1285,19 +1394,23 @@ class AirTextInquiryFlow:
options = parse_air_options(quote)
# 没填报价日期:不按起运地收口,TMS 返回的舱位全部给销售点。1 条也要点。
# 未选定前六态保持询价中,禁止把备选舱位费写进后台「已报价」。
if not str((sess.facts or {}).get("报价日期") or "").strip() and options:
quote = dict(quote)
quote["airOptions"] = sort_cabin_options(options)
up = self._ledger.upsert_quote(work_order_no=no, quote=quote, to_status="已报价")
ledger_quote = _ledger_quote_pending_cabin(quote)
up = self._ledger.upsert_quote(
work_order_no=no, quote=ledger_quote, to_status="询价中"
)
if not up.get("ok"):
reply("主账写入报价失败,不能当作已报价。")
reply("主账写入舱位备选失败,请稍后重试。")
sess.phase = "ledger_fail"
self._save(sess)
return "ledger_fail"
sess.quote = quote
copy.remember_tms_quote(sess, quote)
sess.tms_quote = dict(quote)
sess.status = "已报价"
sess.status = "询价中"
return self._offer_air_routes(
sess=sess,
work_order_no=no,
@@ -1308,6 +1421,9 @@ class AirTextInquiryFlow:
stated_origin = str(
(sess.facts or {}).get("起运港") or (sess.facts or {}).get("起运地") or ""
).strip()
# 摘要占位「-」等于没写起运地,禁止当地名过滤舱位。
if stated_origin in {"-", "—", "-"}:
stated_origin = ""
# 写了起运地:只认这个起运地的线路,直接报价,不让销售再选。
# 没写起运地:TMS 按目的地回多条时,才进选线页。
if stated_origin:
@@ -1331,6 +1447,27 @@ class AirTextInquiryFlow:
)
elif options and not needs_route_pick(quote):
quote = project_selected_quote(quote, options[0])
# 仍要选线:未选定前只落备选快照,六态保持询价中。
if not stated_origin and needs_route_pick(quote):
ledger_quote = _ledger_quote_pending_cabin(quote)
up = self._ledger.upsert_quote(
work_order_no=no, quote=ledger_quote, to_status="询价中"
)
if not up.get("ok"):
reply("主账写入线路备选失败,请稍后重试。")
sess.phase = "ledger_fail"
self._save(sess)
return "ledger_fail"
sess.quote = quote
copy.remember_tms_quote(sess, quote)
sess.tms_quote = dict(quote)
sess.status = "询价中"
return self._offer_air_routes(
sess=sess,
work_order_no=no,
quote=quote,
reply=reply,
)
up = self._ledger.upsert_quote(work_order_no=no, quote=quote, to_status="已报价")
if not up.get("ok"):
reply("主账写入报价失败,不能当作已报价。")
@@ -1341,13 +1478,6 @@ class AirTextInquiryFlow:
copy.remember_tms_quote(sess, quote)
sess.tms_quote = dict(quote)
sess.status = "已报价"
if not stated_origin and needs_route_pick(quote):
return self._offer_air_routes(
sess=sess,
work_order_no=no,
quote=quote,
reply=reply,
)
sess.phase = "wait_collab"
sess.allowed = (copy.BTN_SKIP_COLLAB,)
sess.wait_version += 1
+64 -3
View File
@@ -650,6 +650,42 @@ def ask_clarify(
if key == "起运港":
continue
val = known(src, key)
# #region agent log
if key == "added_services":
try:
import json as _dbg_json
import time as _dbg_time
from pathlib import Path as _DbgPath
_payload = {
"sessionId": "5ea5b3",
"runId": "post-fix",
"hypothesisId": "H3",
"location": "inquiry_copy.py:ask_clarify",
"message": "clarify display added_services",
"data": {
"raw_facts": str(src.get("added_services") or ""),
"display_val": val,
"label": label,
},
"timestamp": int(_dbg_time.time() * 1000),
}
for _p in (
_DbgPath(__file__).resolve().parents[3] / "debug-5ea5b3.log",
_DbgPath(r"d:/own/友通达询价智能体(新)/debug-5ea5b3.log"),
_DbgPath(r"C:/Users/ytd_extdev_0903/incoming/debug-5ea5b3.log"),
):
try:
_p.parent.mkdir(parents=True, exist_ok=True)
with _p.open("a", encoding="utf-8") as _f:
_f.write(
_dbg_json.dumps(_payload, ensure_ascii=False) + "\n"
)
except Exception:
pass
except Exception:
pass
# #endregion
if val:
lines.append(f"{label}:{val}")
lines.append("")
@@ -818,6 +854,30 @@ def inquiry_field_lines(
]
# 建单确认和空运群:有内容才多一行。没填不出现。核对卡的空行不在这里。
added = (facts.get("added_services") or facts.get("增值服务") or "").strip()
# #region agent log
try:
import json as _dbg_json
import time as _dbg_time
from pathlib import Path as _DbgPath
_payload = {
"sessionId": "5ea5b3",
"runId": "pre-fix",
"hypothesisId": "H3",
"location": "inquiry_copy.py:air_confirm_lines",
"message": "display added_services value",
"data": {
"added": added,
"facts_keys": sorted(str(k) for k in (facts or {}).keys()),
},
"timestamp": int(_dbg_time.time() * 1000),
}
_path = _DbgPath(__file__).resolve().parents[3] / "debug-5ea5b3.log"
with _path.open("a", encoding="utf-8") as _f:
_f.write(_dbg_json.dumps(_payload, ensure_ascii=False) + "\n")
except Exception:
pass
# #endregion
if added and added not in {"-", "—", "-"}:
lines.insert(-1, f"增值服务:{added}")
return lines
@@ -3140,7 +3200,7 @@ def air_sales_only_deal() -> str:
def air_fields_not_recognized() -> str:
return "听到了。空运群里可补充是否含电、是否含磁。"
return "听到了。空运群里可补充是否含电、是否含磁,或报价日期。"
def air_quote_not_recognized() -> str:
@@ -3148,7 +3208,7 @@ def air_quote_not_recognized() -> str:
def air_fields_denied() -> str:
return "只有这张单的销售或航线可以补含电/含磁。"
return "只有这张单的销售或航线可以补含电/含磁或报价日期。"
def air_no_tms_quote_to_lock(work_order_no: str) -> str:
@@ -3346,9 +3406,10 @@ def air_text_quote_for_lock_example(work_order_no: str = "") -> str:
需要锁舱时,请航线用纯文字报价的示例句。
附件报价不拆金额、常锁不成;产品口径改为引导航线改发文字价。
示例必须带计费单位(空运常见 KG),避免航线漏写单位导致文字价无法落库。
"""
no = (work_order_no or "").strip() or "工单号"
return f"{no},空运段,空运费 单价CNY99.00,数量100 ,金额CNY9900.00@询价小助手"
return f"{no},空运段,KG,空运费 单价CNY99.00,数量100 ,金额CNY9900.00@询价小助手"
def air_attachment_quote_lock_tip(*, work_order_no: str) -> str:
+68 -1
View File
@@ -40,7 +40,9 @@ from agent.schema.tms_land_query import assemble_land_query
logger = logging.getLogger(__name__)
_LAND_FRONT = frozenset({"need_mode", "need_land_options", "clarify", "wait_confirm", "wait_land_route", "idle", ""})
_LAND_FRONT = frozenset(
{"need_mode", "need_land_options", "clarify", "wait_confirm", "wait_land_route", "sensitive_block", "idle", ""}
)
class LandTextInquiryFlow(SeaTextInquiryFlow):
@@ -97,6 +99,16 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
if sess and sess.phase == "wait_confirm" and looks_like_confirm(text):
# 确定查价不再抽字段,避免「确定」被模型改成空运/空 facts。
logger.info("land.confirm sender=%s", sender_id)
refused = self._refuse_sensitive(
sender_id=sender_id,
text=text,
facts=dict(sess.facts),
reply=reply,
sess=sess,
business_line=sess.business_line,
)
if refused:
return refused
return self._confirm_or_hold(
sender_id=sender_id,
facts=dict(sess.facts),
@@ -126,6 +138,39 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
sess.work_order_no or "-",
)
sess = None
# #region agent log
try:
import json as _json
import time as _time
from pathlib import Path as _Path
with _Path(r"d:\own\友通达询价智能体(新)\debug-d331ea.log").open("a", encoding="utf-8") as _dbg:
_dbg.write(
_json.dumps(
{
"sessionId": "d331ea",
"runId": "post-fix",
"hypothesisId": "D",
"location": "land_text_flow.py:on_text:sess_keep",
"message": "land sess keep-or-clear",
"data": {
"phase_in": phase,
"kept_sess": sess is not None,
"in_front": phase in _LAND_FRONT,
"sensitive_hold": (
str(getattr(sess, "sensitive_hold", "") or "")[:120] if sess else ""
),
"text": (text or "")[:120],
},
"timestamp": int(_time.time() * 1000),
},
ensure_ascii=False,
)
+ "\n"
)
except Exception:
pass
# #endregion
snap = extract_inquiry_snapshot(
text,
@@ -176,6 +221,18 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
# 复制清单冒号空着的项,菜单文字和模型猜测都不能当成已填。
facts = drop_blank_paste_options(facts, source)
if not mode or mode == "LAND":
refused = self._refuse_sensitive(
sender_id=sender_id,
text=text,
facts=facts,
reply=reply,
sess=sess,
business_line=mode or (sess.business_line if sess else ""),
)
if refused:
return refused
if not mode:
mode = "LAND" if (sess and (sess.business_line or "").upper() == "LAND") else ""
if not mode:
@@ -296,6 +353,16 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
"""
import uuid
refused = self._refuse_sensitive(
sender_id=sender_id,
text="",
facts=facts,
reply=reply,
sess=self.session_of(sender_id),
business_line="LAND",
)
if refused:
return refused
tms_ready = assemble_land_query(facts)
if not tms_ready.get("ok"):
return self._advance_front(
+101 -1
View File
@@ -43,7 +43,7 @@ from agent.schema.land_options import (
logger = logging.getLogger(__name__)
# 只有补问/核对还能续同一张多段草稿。已出号的拉群停点不能拦住新询价。
_FRONT = frozenset({"clarify", "wait_confirm"})
_FRONT = frozenset({"clarify", "wait_confirm", "sensitive_block"})
class MultiTextInquiryFlow:
@@ -170,6 +170,52 @@ class MultiTextInquiryFlow:
reply("多段询价请先把各段需求发齐并回复确定。需要人来接管时,请在工单创建后再说。")
return "multi_handoff_later"
def _gate_sensitive(
self,
*,
sender_id: str,
text: str,
reply: ReplyFn,
segments: list[Segment],
sess: Optional[FlowSession],
immutable: str,
) -> Optional[str]:
"""
多段私聊建单前的违禁品拦截。
已有工单号的旧会话不把违禁原话带进新的一段。没命中则把品名改口写回各段。
命中只回一句,不补问、不建单。
"""
from agent.policy.sensitive_cargo import BLOCK_REPLY, judge
facts: dict[str, str] = {}
for seg in segments:
for key, value in (seg.facts or {}).items():
if str(value or "").strip():
facts[str(key)] = str(value)
use_old = sess is not None and not (sess.work_order_no or "").strip()
blocked, new_hold, edits = judge(
text=text,
facts=facts,
hold=str(sess.sensitive_hold or "") if use_old else "",
)
for seg in segments:
for key, value in edits.items():
seg.facts[key] = value
if not blocked:
if use_old and sess is not None:
sess.sensitive_hold = new_hold
if edits:
self._put_segments(sess, segments)
self._save(sess)
return None
draft = self._draft(sender_id, segments, phase="sensitive_block", immutable=immutable or text)
draft.sensitive_hold = new_hold
self._save(draft)
reply(BLOCK_REPLY)
logger.info("multi.sensitive_block sender=%s", sender_id)
return "sensitive_block"
def on_text(
self,
*,
@@ -196,6 +242,19 @@ class MultiTextInquiryFlow:
sess = self.session_of(sid)
if sess and self.match_button(raw, sess):
return self._pull_group(sess, reply)
if sess and (sess.phase or "") == "sensitive_block" and not (sess.work_order_no or "").strip():
refused = self._gate_sensitive(
sender_id=sid,
text=raw,
reply=reply,
segments=self._segments(sess),
sess=sess,
immutable=sess.immutable_text or raw,
)
if refused:
return refused
sess.phase = "clarify"
self._save(sess)
verdict = classify_multi_text(raw)
if verdict == "same_mode":
reply(copy.MULTI_SAME_MODE)
@@ -276,7 +335,28 @@ class MultiTextInquiryFlow:
附件口播经常没有「起运港:」这种标签,要按段走和单聊一样的抽取,
不能只认标签行,否则会整段待补充。
"""
current = self.session_of(sender_id)
refused = self._gate_sensitive(
sender_id=sender_id,
text=immutable,
reply=reply,
segments=list(segments),
sess=current if current and not (current.work_order_no or "").strip() else None,
immutable=immutable,
)
if refused:
return refused
filled = self._enrich_segments(segments, allow_b=allow_b, chat_fn=chat_fn)
refused = self._gate_sensitive(
sender_id=sender_id,
text="",
reply=reply,
segments=filled,
sess=self.session_of(sender_id),
immutable=immutable,
)
if refused:
return refused
ready, updated, gaps = refresh_segments(filled)
if not ready:
self._reply_gaps(updated, gaps, reply)
@@ -358,6 +438,16 @@ class MultiTextInquiryFlow:
口语补句走单聊同一套抽取,不在这里猜「广州-深圳」。
"""
refused = self._gate_sensitive(
sender_id=sess.sender_id,
text=text,
reply=reply,
segments=self._segments(sess),
sess=sess,
immutable=sess.immutable_text or text,
)
if refused:
return refused
from agent.llm.extract_text import extract_inquiry_snapshot
from agent.policy.system_exception import handle_extract_tech_fail
@@ -407,6 +497,16 @@ class MultiTextInquiryFlow:
def _create_ticket(self, sess: FlowSession, reply: ReplyFn) -> str:
"""回复确定后建一张 MULTI 工单。含空运只给手动拉群提示,不含空运出拉群卡。"""
segments = self._segments(sess)
refused = self._gate_sensitive(
sender_id=sess.sender_id,
text="",
reply=reply,
segments=segments,
sess=sess,
immutable=sess.immutable_text,
)
if refused:
return refused
ready, updated, gaps = refresh_segments(segments)
if not ready:
self._reply_gaps(updated, gaps, reply)
+7 -2
View File
@@ -931,12 +931,13 @@ def apply_product_quote(
sess.collab_facts = merged
line = str(view.get("business_line") or view.get("businessLine") or "").strip().upper()
base = _current_quote(view, sess)
baseline_ccy = _quote_currency_hint(base)
quote = extract_product_quote(
text,
injected=injected_quote,
business_line=line,
transport_mode="陆运" if line == "LAND" else ("海运" if line == "SEA" else ""),
baseline_currency=_quote_currency_hint(base),
baseline_currency=baseline_ccy,
)
if not quote:
logger.info("产品报价未识别 wo=%s text=%s", no, (text or "")[:200])
@@ -1644,9 +1645,13 @@ def _merged_total(
总销售价:有 TMS 原合计就按改价差额加减,不能只加产品改的那几行。
美元行已含在 TMS 人民币合计里,不擅自换汇;只调整和合计同币种的差额。
产品正文已点币种(如 RMB→CNY)时,表头跟产品,禁止 TMS HKD 再盖回去(WO040)。
"""
src = dict(base or {})
header_ccy = str(src.get("currency") or incoming.get("currency") or "").strip()
# 产品显式币种优先:否则「1RMB/KG」合并后表头仍停在 TMS HKD。
header_ccy = str(incoming.get("currency") or src.get("currency") or "").strip()
if header_ccy.upper() == "RMB":
header_ccy = "CNY"
base_total = _parse_fee_amount(src.get("total"))
if base_total is not None:
total_ccy = (header_ccy or "CNY").upper()
+33 -1
View File
@@ -113,6 +113,16 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
if sess and sess.phase == "wait_confirm" and looks_like_confirm(text):
# 确定查价不再抽字段,避免「确定」被模型改成空 facts。
logger.info("sea.confirm sender=%s", sender_id)
refused = self._refuse_sensitive(
sender_id=sender_id,
text=text,
facts=dict(sess.facts),
reply=reply,
sess=sess,
business_line=sess.business_line,
)
if refused:
return refused
return self._confirm_or_hold(
sender_id=sender_id,
facts=dict(sess.facts),
@@ -145,7 +155,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
return llm_phase
mode = (snap.get("business_line") or "").upper()
facts = dict(snap.get("facts") or {})
if sess and sess.phase in {"clarify", "need_mode", "wait_confirm"}:
if sess and sess.phase in {"clarify", "need_mode", "wait_confirm", "sensitive_block"}:
from agent.llm.extract_text import detect_transport_mode
from agent.schema.field_validate import harvest_oral_measures
@@ -156,6 +166,18 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
if not mode:
mode = detect_transport_mode(f"{sess.immutable_text or ''}\n{text}")
if not mode or mode == "SEA":
refused = self._refuse_sensitive(
sender_id=sender_id,
text=text,
facts=facts,
reply=reply,
sess=sess,
business_line=mode or (sess.business_line if sess else ""),
)
if refused:
return refused
if not mode:
reply(copy.ask_transport())
self._save(
@@ -374,6 +396,16 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
from agent.policy.handoff_human import void_virtual_no
old = self.session_of(sender_id)
refused = self._refuse_sensitive(
sender_id=sender_id,
text="",
facts=facts,
reply=reply,
sess=old,
business_line="SEA",
)
if refused:
return refused
if old:
void_virtual_no(old)
pending = tuple(getattr(old, "pending_files", ()) or ()) if old else ()
@@ -0,0 +1,268 @@
"""
私聊建单前的违禁品拦截。
本文件职责:用固定词表判断当前这句话、尚未改口的原话,以及品名、起运地、备注
是否涉及真家伙(枪支、弹药、爆炸物、毒品)。命中则不能建单。
群聊、已经建成的工单不在这里判断,由调用方避开。
调用:Worker 里的私聊文字流程,在出补问或建单之前。只做字符串匹配,不调模型。
禁止:把水枪、玩具枪、仿真枪、气枪、烟花、礼炮、鞭炮算进去;禁止拦化学品、电池、油品。
"""
from __future__ import annotations
import re
from typing import Any
# 销售私聊里只回这一句。不要再追问缺项,也不要带工单号。
BLOCK_REPLY = "涉及危险品,无法发起报价。"
# 先圈出放行说法。词表里的「枪支」若落在这些说法内部,不算命中。
_ALLOW = (
"玩具枪",
"水枪",
"仿真枪",
"气枪",
"烟花",
"礼炮",
"鞭炮",
"炮仗",
)
# 真家伙。不用单字「枪」「炮」「弹」,避免误伤水枪和礼炮。
_BLOCK = (
"汽油弹",
"燃烧弹",
"原子弹",
"手榴弹",
"导火索",
"甲基苯丙胺",
"氯胺酮",
"高射炮",
"榴弹炮",
"冲锋枪",
"狙击枪",
"霰弹枪",
"火药枪",
"摇头丸",
"海洛因",
"可卡因",
"炸弹",
"炸药",
"爆破",
"雷管",
"手雷",
"地雷",
"导弹",
"核弹",
"枪支",
"枪械",
"手枪",
"步枪",
"机枪",
"猎枪",
"子弹",
"弹药",
"弹头",
"大炮",
"火炮",
"毒品",
"冰毒",
"大麻",
"鸦片",
"吗啡",
"K粉",
)
# 改口动词。多段原文里的「品名:衣服」不能走这里,否则会抄到每一段。
_FIELD_EDIT = re.compile(
r"(货物品名|品名|起运地|起运港|始发站|备注)\s*(?:改为|改成|是|为)\s*(\S+)"
)
# 单票里销售按清单重写「品名:服装」。多段「段1 / 段2」整单不靠这个改字段。
_LABEL_EDIT = re.compile(
r"(货物品名|品名|起运地|起运港|始发站|备注)\s*[::]\s*(\S+)"
)
_MULTI_SHEET = re.compile(r"段\s*\d|第\s*[一二三四五六七八九\d]\s*段")
# 口语改口:运送衣服、改成衣服、换成衣服、炸弹换衣服。不认「换一下」「换个港口」。
_SPOKEN_CARGO = re.compile(
r"(?:运送|托运|发运|改成|改为|换成|换为)\s*([^\s,,。;;、请]{1,16})"
r"|换(?!成|为|个|一下)([^\s,,。;;、请]{1,16})"
)
_CARGO_TAIL = re.compile(r"(请报价|报价|一下|吧|了)+$")
_CARGO_SKIP = frozenset({"一下", "个", "报价", "价", "询价", "港口", "地方"})
# 销售侧说法写回内部键。起运地在主账里仍是起运港。
_WRITE_KEYS = {
"品名": ("品名", "货物品名"),
"货物品名": ("品名", "货物品名"),
"起运地": ("起运港", "起运地", "始发站"),
"起运港": ("起运港", "起运地", "始发站"),
"始发站": ("起运港", "起运地", "始发站"),
"备注": ("备注",),
}
# 建单前要看的当前字段。没写进这几项、但原话里出现过的,靠 sensitive_hold 记住。
_SCAN_KEYS = ("品名", "货物品名", "起运港", "起运地", "始发站", "备注")
# 新开一票的说法。命中且本句没有违禁词时,丢掉上一句没改口的 hold。
_FRESH_INQUIRY = re.compile(
r"(查一下|帮我查|询一下|询个价|询价|请报价|报一下价|报个价|费用)"
)
# 只补量尺、件数、包装:不算新询价,旧 hold 继续有效。
_MEASURE_SUPPLEMENT = re.compile(
r"(?:"
r"重量|毛重|体积|件数|货量|包装(?:方式|类型)?"
r")\s*[::]?\s*\d"
r"|\d+\s*(?:kgs?|kg|公斤|千克|吨|t|cbm|方|件|箱|托)",
re.I,
)
def looks_like_fresh_inquiry(text: str) -> bool:
"""
当前句是不是另开一票,而不是接着补上一票的字段。
「查一下目的地吉隆坡的费用」算新开;「重量100KGS」不算。
用来在本句已无违禁词时清掉 sticky hold,避免正常询价被上一句炸弹拖死。
"""
body = (text or "").strip()
if not body:
return False
if _MEASURE_SUPPLEMENT.search(body) and not _FRESH_INQUIRY.search(body):
return False
return bool(_FRESH_INQUIRY.search(body))
def scrub_blocked_scan_fields(facts: dict[str, Any]) -> None:
"""新开一票时,把品名/起运/备注里仍带着违禁词的旧值清掉,避免 field_hit 再拦。"""
for key in _SCAN_KEYS:
if contains_blocked(str((facts or {}).get(key) or "")):
facts[key] = ""
def contains_blocked(text: str) -> bool:
"""
这句话里有没有要拦的词。
放行说法内部的字不算。例如「玩具枪」放行,「玩具枪支」仍因「枪支」拦下。
不做英文、拼音、谐音。纯字符串查找,可在 Worker 线程同步调用。
"""
raw = text or ""
if not raw:
return False
allow_spans: list[tuple[int, int]] = []
for phrase in _ALLOW:
start = 0
while True:
found = raw.find(phrase, start)
if found < 0:
break
allow_spans.append((found, found + len(phrase)))
start = found + len(phrase)
def _inside(begin: int, end: int) -> bool:
return any(left <= begin and end <= right for left, right in allow_spans)
for word in _BLOCK:
start = 0
while True:
found = raw.find(word, start)
if found < 0:
break
if not _inside(found, found + len(word)):
return True
start = found + len(word)
return False
def spoken_cargo(text: str) -> str:
"""
从这一句里取出销售刚说的品名。
「运送衣服」「改成衣服」「换成衣服」「炸弹换衣服」都算。
取最后一个说法,所以「炸弹换衣服」得到衣服。
说出来的还是违禁词,或者只是「换一下」,就当没改口。
"""
found = ""
for match in _SPOKEN_CARGO.finditer(text or ""):
raw = match.group(1) or match.group(2) or ""
value = _CARGO_TAIL.sub("", raw).strip(",,。;;、 ")
if not value or value in _CARGO_SKIP or contains_blocked(value):
continue
found = value
return found
def apply_field_edits(text: str, facts: dict[str, Any]) -> dict[str, str]:
"""
把当前这句话里的「品名改为…」「起运地改为…」「备注改为…」写进字段。
只改这一句写明的项。返回实际写下的键值,方便多段把品名写回每一段。
会改传入的 facts。值末尾的标点去掉,避免「服装,」留在品名里。
"""
written: dict[str, str] = {}
pairs = list(_FIELD_EDIT.findall(text or ""))
if not _MULTI_SHEET.search(text or ""):
pairs.extend(_LABEL_EDIT.findall(text or ""))
for label, raw_value in pairs:
value = str(raw_value or "").strip(",,。;;、 ")
if not value:
continue
for key in _WRITE_KEYS.get(label, (label,)):
facts[key] = value
written[key] = value
cargo = spoken_cargo(text)
if cargo:
for key in ("品名", "货物品名"):
facts[key] = cargo
written[key] = cargo
return written
def is_cargo_rename(text: str) -> bool:
"""当前句有没有把品名重新说成一个新值。没说品名的补重量不算改口。"""
if spoken_cargo(text):
return True
body = text or ""
labels = [label for label, _value in _FIELD_EDIT.findall(body)]
if not _MULTI_SHEET.search(body):
labels.extend(label for label, _value in _LABEL_EDIT.findall(body))
return any(label in {"品名", "货物品名"} for label in labels)
def judge(*, text: str, facts: dict[str, Any], hold: str) -> tuple[bool, str]:
"""
决定这一轮私聊能不能继续往建单走。
返回 (是否拦截, 新的未改口原话, 本句写下的字段)。
当前句仍只是在说违禁词:拦住,并把这句记下来。
当前句已经把品名说成普通货(运送衣服、改成衣服、换成衣服):前面的违禁原话作废。
同一句里先提到旧词再改口,按改口后的品名放行。
只补重量、件数:旧原话继续有效。
本句无违禁词且像另开一票(查一下…费用):旧 hold 作废,并清掉字段里残留的违禁词。
品名改干净之后,起运地或备注里还留着违禁词,仍然拦截。
会就地更新 facts 里本句写明的品名、起运地、备注。
第三个返回值是本句写下的键,多段用来写回每一段,避免把别的字段盖掉。
"""
edits = apply_field_edits(text, facts)
replaced = bool(spoken_cargo(text)) or is_cargo_rename(text)
message_hit = contains_blocked(text)
fresh = False
if replaced and not contains_blocked(str(facts.get("品名") or "")):
# 已经说出新的普通货品名。旧词留在这句里只是在改口,不再记住。
new_hold = ""
message_hit = False
elif message_hit:
new_hold = (text or "").strip() or (hold or "")
elif hold and looks_like_fresh_inquiry(text):
# 本句没有违禁词,且是另开一票(查一下…费用),上一句违禁原话作废。
# 只补重量/件数不走这里,旧 hold 继续拦。
fresh = True
new_hold = ""
scrub_blocked_scan_fields(facts)
else:
new_hold = hold or ""
field_text = "\n".join(str((facts or {}).get(key) or "") for key in _SCAN_KEYS)
hold_hit = contains_blocked(new_hold)
field_hit = contains_blocked(field_text)
blocked = message_hit or hold_hit or field_hit
return blocked, new_hold, edits
@@ -7,7 +7,10 @@
from __future__ import annotations
import json
import logging
import time
from pathlib import Path
from typing import Optional
from agent.policy import inquiry_copy as copy
@@ -32,6 +35,7 @@ from agent.routing.group_intent import (
logger = logging.getLogger(__name__)
INTENT_ACTIVATE = "air_activate"
INTENT_LOCK = "air_lock"
INTENT_RELEASE = "air_release"
@@ -131,18 +135,50 @@ def classify_air_group_text(
if use_net and getattr(settings, "llm_data_usage_confirmed", False):
got = _classify_with_model(raw, settings)
if got in _VALID:
return _prefer_staff_quote(got, raw)
preferred = _prefer_staff_quote(got, raw)
from agent.llm.extract_text import (
extract_product_quote,
is_non_fee_meta_only_message,
is_quote_date_only_message,
labeled_quote_date,
)
# 模型若把「报价日期/有效期/时效」判成报价:有日期/含电含磁回补字段,否则 other。
if preferred == INTENT_QUOTE and (
is_quote_date_only_message(raw)
or is_non_fee_meta_only_message(raw)
or (labeled_quote_date(raw) and not extract_product_quote(raw))
):
if labeled_quote_date(raw) or (
"是否含电" in raw or "是否含磁" in raw or "是否带电" in raw
):
preferred = INTENT_FIELDS
elif is_non_fee_meta_only_message(raw):
preferred = INTENT_OTHER
else:
preferred = INTENT_FIELDS
return preferred
except Exception:
logger.exception("空运群意图模型失败,回退规则")
from agent.llm.extract_text import extract_air_collab_fields, extract_product_quote
from agent.llm.extract_text import (
extract_air_collab_fields,
extract_product_quote,
is_non_fee_meta_only_message,
is_quote_date_only_message,
labeled_quote_date,
)
from agent.schema.quote_adjust import extract_adjust
# 费用金额先当航线报价。「空运费改为900」不是销售调价。
# 报价有效期/时效要求整句不是报价。
if is_non_fee_meta_only_message(raw) and not labeled_quote_date(raw):
return INTENT_OTHER
if text_is_staff_fee_quote(raw):
return INTENT_QUOTE
if extract_adjust(raw).is_adjust:
return INTENT_ADJUST
if extract_product_quote(raw):
quote_hit = extract_product_quote(raw)
if quote_hit:
return INTENT_QUOTE
confirmed = _hint_confirm_tms(raw)
if confirmed:
@@ -153,7 +189,9 @@ def classify_air_group_text(
closed = _hint_deal_outcome(raw)
if closed:
return closed
if extract_air_collab_fields(raw):
air_fields = extract_air_collab_fields(raw)
quote_date = labeled_quote_date(raw)
if air_fields or quote_date:
return INTENT_FIELDS
if _looks_like_option(raw):
return INTENT_PICK_OPTION
@@ -244,7 +282,8 @@ def _classify_with_model(text: str, settings) -> str:
"说出释放舱位、释放舱、把舱位释放了时不要回 air_release;"
"air_activate=只是指定/切换工单号;"
"air_pick_option=在补充或选择舱位编号、航司、航班号,写法不限;"
"collab_fields=在补是否含电/是否含磁;"
"collab_fields=在补是否含电/是否含磁/是否带电,或补报价日期;"
"只有报价有效期或时效要求、没有费用金额时回 other,不要回 product_quote;"
"quote_adjust=销售在按比例改已有费用(上调、上浮、加10%、减5%、加一成),或只改总价/总销售价;"
"product_quote=航线在报某一项费用的金额。写出「空运费9900」「空运费改为900USD」是报价,不是调价;"
"quote_confirm_tms=确认沿用当前TMS报价;"
@@ -264,5 +303,7 @@ def _classify_with_model(text: str, settings) -> str:
{"role": "user", "content": text[:500]},
],
)
got = str((data or {}).get("content") or "").strip().split()[0] if data else ""
# chat 返回 LlmHttpResult,不是 dict。
content = str(getattr(data, "content", "") or "").strip()
got = content.split()[0] if content else ""
return got if got in _VALID else ""
+120 -24
View File
@@ -3,6 +3,7 @@
本文件职责:把销售原话收成多项 added_services,供确认、工单和 TMS 的 addedServices 使用。
12 个服务词只要完整出现就记;名单外的必须写在「增值服务」后面才记,并保留原词。
服务词后面紧跟的「+数字」(如 TC +1.5、托盘+1)并进同一项,写成 TC+1.5、托盘+1。
包装类型/包装方式这一栏里的「托盘」只算包装,不抄进增值服务。
「增值服务:」同一行为空时,不把下一行收进来,也不沿用上一句已经记下的服务。
不挡查价。不改六态。海运、陆运不走这里。
@@ -30,6 +31,8 @@ _CN_RE = re.compile("|".join(re.escape(word) for word in _CN_TERMS))
_MUST_GO_RE = re.compile(r"(?i)MUST\s*GO")
_DG_RE = re.compile(r"(?i)(?<![A-Za-z])DG(?![A-Za-z])")
_TC_RE = re.compile(r"(?i)(?<![A-Za-z])TC(?![A-Za-z])")
# 服务词后的加价/数量:允许空格,记成「词+数字」,不把空格留进销售可见文案。
_QTY_SUFFIX_RE = re.compile(r"[^\S\n]*\+[^\S\n]*(\d+(?:\.\d+)?)")
# 冒号后只吃同一行。\s 会跨行,空着的增值服务会把下一行「报价日期(非必填):」吞进来。
_LABEL_RE = re.compile(
r"增值服务(?:(非必填)|\(非必填\))?[^\S\n]*[::][^\S\n]*([^\n]*)"
@@ -76,12 +79,13 @@ def harvest_added_services(text: str) -> list[str]:
补问示例行「(如:托盘、…)」整行不看,避免清单被当成已填。
「不带电」不算带电;「卡转低消」不算再加一项卡转。
「TC +1.5」记成 TC+1.5,数量跟服务名同一项。
副作用:无。
"""
raw = _without_clarify_examples(text or "")
events: list[tuple[int, str]] = []
# 包装栏里的托盘已在 _canonical_events 排除,这里只收正文里真正的服务词。
events.extend(_canonical_events(raw))
events.extend((start, word) for start, _end, word in _canonical_events(raw))
for match in _LABEL_RE.finditer(raw):
value = (match.group(1) or "").strip()
if not value or value.startswith("(如") or value.startswith("(如"):
@@ -100,7 +104,52 @@ def harvest_added_services(text: str) -> list[str]:
if extra:
events.append((base + at + max(piece.find(extra), 0), extra))
events.sort(key=lambda item: item[0])
return _dedupe([word for _pos, word in events])
result = _dedupe([word for _pos, word in events])
# #region agent log
try:
import json as _dbg_json
import time as _dbg_time
from pathlib import Path as _DbgPath
_tails: list[dict[str, str]] = []
for _m in list(_TC_RE.finditer(raw)) + list(_CN_RE.finditer(raw)):
if _m.group(0) not in {"TC", "tc", "托盘"} and _m.group(0).casefold() != "tc":
continue
_end = _m.end()
_tails.append(
{
"word": _m.group(0),
"after": raw[_end : _end + 12],
}
)
_payload = {
"sessionId": "5ea5b3",
"runId": "post-fix",
"hypothesisId": "H1",
"location": "added_services.py:harvest_added_services",
"message": "harvest result vs trailing modifiers",
"data": {
"text_preview": (raw or "")[:120],
"found": result,
"match_tails": _tails,
},
"timestamp": int(_dbg_time.time() * 1000),
}
for _path in (
_DbgPath(__file__).resolve().parents[3] / "debug-5ea5b3.log",
_DbgPath(r"C:/Users/ytd_extdev_0903/incoming/debug-5ea5b3.log"),
_DbgPath(r"d:/own/友通达询价智能体(新)/debug-5ea5b3.log"),
):
try:
_path.parent.mkdir(parents=True, exist_ok=True)
with _path.open("a", encoding="utf-8") as _f:
_f.write(_dbg_json.dumps(_payload, ensure_ascii=False) + "\n")
except Exception:
pass
except Exception:
pass
# #endregion
return result
def apply_air_value_added(
@@ -115,6 +164,7 @@ def apply_air_value_added(
只应在空运调用。副作用:返回新字典,不改入参。
"""
merged = dict(facts or {})
before_added = str(merged.get("added_services") or "")
found = harvest_added_services(text)
labeled = _labeled_added_value(text)
# 销售把「增值服务:」留空:这一栏就是空的。
@@ -122,23 +172,81 @@ def apply_air_value_added(
# 本句没写这一栏、也没提到服务时,才保留已经记下的旧值。
if labeled == "":
items = found
branch = "labeled_empty"
elif found:
items = _dedupe(split_added_services(previous) + found)
branch = "found_merge"
else:
items = _dedupe(
split_added_services(previous)
+ split_added_services(str(merged.get("added_services") or ""))
)
branch = "keep_previous"
if items:
merged["added_services"] = "、".join(items)
elif not str(merged.get("added_services") or "").strip():
merged.pop("added_services", None)
_keep_stated_package(text, merged)
# #region agent log
try:
import json as _dbg_json
import time as _dbg_time
from pathlib import Path as _DbgPath
_payload = {
"sessionId": "5ea5b3",
"runId": "post-fix",
"hypothesisId": "H2",
"location": "added_services.py:apply_air_value_added",
"message": "apply merge before/after",
"data": {
"text_preview": (text or "")[:120],
"before_added": before_added,
"found": found,
"previous": previous,
"labeled": labeled,
"branch": branch,
"after_added": str(merged.get("added_services") or ""),
"pack": str(merged.get("包装方式") or ""),
},
"timestamp": int(_dbg_time.time() * 1000),
}
for _path in (
_DbgPath(__file__).resolve().parents[3] / "debug-5ea5b3.log",
_DbgPath(r"C:/Users/ytd_extdev_0903/incoming/debug-5ea5b3.log"),
_DbgPath(r"d:/own/友通达询价智能体(新)/debug-5ea5b3.log"),
):
try:
_path.parent.mkdir(parents=True, exist_ok=True)
with _path.open("a", encoding="utf-8") as _f:
_f.write(_dbg_json.dumps(_payload, ensure_ascii=False) + "\n")
except Exception:
pass
except Exception:
pass
# #endregion
return merged
def _canonical_events(raw: str) -> list[tuple[int, str]]:
"""按原文位置列出 12 个标准词。重叠区间只留更长的那一项。"""
def _attach_qty_suffix(raw: str, start: int, end: int, word: str) -> tuple[int, int, str]:
"""
服务词后面若有 +数字,并进同一项。
例:TC +1.5 → (起点, 终点, "TC+1.5")。没有数量则原样返回。
为何:销售口播常写「TC +1.5、托盘 +1」,只记服务名会丢数量。
"""
match = _QTY_SUFFIX_RE.match(raw or "", end)
if not match:
return start, end, word
return start, match.end(), f"{word}+{match.group(1)}"
def _canonical_events(raw: str) -> list[tuple[int, int, str]]:
"""
按原文位置列出 12 个标准词(可带 +数量)。
返回 (起点, 终点, 展示词)。重叠区间只留更长的那一项。
"""
found: list[tuple[int, int, str]] = []
package_pallets = _package_pallet_spans(raw)
for match in _CN_RE.finditer(raw):
@@ -147,21 +255,21 @@ def _canonical_events(raw: str) -> list[tuple[int, str]]:
continue
if word == "托盘" and _span_inside(match.start(), match.end(), package_pallets):
continue
found.append((match.start(), match.end(), word))
found.append(_attach_qty_suffix(raw, match.start(), match.end(), word))
for match in _MUST_GO_RE.finditer(raw):
found.append((match.start(), match.end(), "MUST GO"))
found.append(_attach_qty_suffix(raw, match.start(), match.end(), "MUST GO"))
for match in _DG_RE.finditer(raw):
found.append((match.start(), match.end(), "DG"))
found.append(_attach_qty_suffix(raw, match.start(), match.end(), "DG"))
for match in _TC_RE.finditer(raw):
found.append((match.start(), match.end(), "TC"))
found.append(_attach_qty_suffix(raw, match.start(), match.end(), "TC"))
found.sort(key=lambda item: (item[0], -(item[1] - item[0])))
accepted: list[tuple[int, int]] = []
events: list[tuple[int, str]] = []
events: list[tuple[int, int, str]] = []
for start, end, word in found:
if any(not (end <= left or start >= right) for left, right in accepted):
continue
accepted.append((start, end))
events.append((start, word))
events.append((start, end, word))
return events
@@ -171,31 +279,19 @@ def _outside_list_text(piece: str) -> str:
「托盘」本身会被标准词扫到,这里不重复收。
「打木架和带电」只把打木架留下来,带电仍由标准词按出现位置记。
带 +数量 的标准词整段擦掉,避免残留「+1.5」再当成名单外服务。
"""
spans = _canonical_events(piece)
if not spans:
return piece.strip()
chars = list(piece)
for start, word in spans:
# _canonical_events 只有起点。用标准词长度擦掉;MUST GO 用原文长度。
end = start + _span_len(piece, start, word)
for start, end, _word in spans:
for index in range(start, min(end, len(chars))):
chars[index] = " "
rest = _FILLER.sub("", "".join(chars))
return rest.strip()
def _span_len(piece: str, start: int, word: str) -> int:
"""标准词在原文里占几位。MUST GO 中间空格可有可无。"""
if word == "MUST GO":
match = _MUST_GO_RE.match(piece, start)
if match:
return match.end() - match.start()
if word in {"DG", "TC"}:
return 2
return len(word)
def _labeled_added_value(text: str) -> str | None:
"""
原话里有没有单独的「增值服务」栏。
@@ -432,6 +432,8 @@ def project_selected_quote(quote: dict[str, Any] | None, option: dict[str, Any]
base["valid_until"] = src.get("validUntil")
base["selected_air_option"] = src
base["route_line"] = route_line(src, {})
# 选定后不再是待选舱快照。
base.pop("pending_cabin_pick", None)
return base
+21 -5
View File
@@ -76,7 +76,8 @@ LAND_DISPLAY = {
# 协同补充字段:拉群后可静默写入,不挡首次查价。内部键仍是 HS编码。
SEA_COLLAB_FIELDS = ("货好时间", "HS编码", "是否含油", "是否含电", "是否含磁")
# 空运群只补含电/含磁,摘要不要把海运协同五项搬过去。
# 空运群只补含电/含磁(及报价日期),摘要不要把海运协同五项搬过去。
# 货好时间、贸易条款、客户名称不在群里补(产品口径)。
AIR_COLLAB_FIELDS = ("是否含电", "是否含磁")
# 群摘要对外展示名。内部继续用 HS编码,避免改合同键。
SEA_COLLAB_DISPLAY = {
@@ -222,6 +223,10 @@ _ALIASES = {
"海关编码": "HS编码",
"客户名称": "客户名称",
"货值": "货值",
# 模板/口语常写「是否带电」,合同内部键仍是是否含电。
"是否带电": "是否含电",
"是否含电": "是否含电",
"是否含磁": "是否含磁",
"是否为危险品": "是否为危险品",
"通关口岸": "通关口岸",
"通关口岸(非必填)": "通关口岸",
@@ -253,11 +258,15 @@ def shanghai_today() -> str:
return datetime.now(_SHANGHAI).date().isoformat()
# 销售卡片/摘要占位横杠:等于没写,禁止落进 facts 再当起运地出站(运行证据 WO202610150086)。
_PLACEHOLDER_DASHES = frozenset({"-", "—", "-"})
def normalize_facts(facts: dict[str, Any] | None) -> dict[str, str]:
"""
把展示名/别名折成合同内部键,去掉首尾空白。
空值丢弃;不解释语义。
空值与摘要占位「-」丢弃;不解释语义。
"""
out: dict[str, str] = {}
for raw_key, raw_val in (facts or {}).items():
@@ -268,7 +277,7 @@ def normalize_facts(facts: dict[str, Any] | None) -> dict[str, str]:
if raw_val is None:
continue
val = str(raw_val).strip()
if not val:
if not val or val in _PLACEHOLDER_DASHES:
continue
out[key] = val
_fold_land_vehicle_qty(out)
@@ -527,9 +536,16 @@ def harvest_oral_collab(text: str) -> dict[str, str]:
只认原话里已经说死的词(不含电、随时可提、普货),不猜贸易条款和海关编码。
「(如:随时可提)」这种补问示例行不算销售说了货好时间。
危险品:否词(不是危险品/非危/普货)先于是词,避免「不是危险品」收成是。
「是否带电:否」里的「带电」不能当成肯定(运行证据 session 717349)。
"""
raw = _without_clarify_examples(text or "")
out: dict[str, str] = {}
# 先剥掉「是否带电/含电/含磁:是|否」标签行,再口语匹配,避免标签里的「带电」误判成是。
oral_src = re.sub(
r"是否(?:带电|含电|含磁|含油)\s*[::]\s*[是否]",
" ",
raw,
)
yes_no = (
("是否含电", ("不含电", "不带电", "无电"), ("含电", "带电")),
("是否含油", ("不含油", "不带油", "无油"), ("含油", "带油")),
@@ -541,9 +557,9 @@ def harvest_oral_collab(text: str) -> dict[str, str]:
),
)
for key, no_words, yes_words in yes_no:
if any(w in raw for w in no_words):
if any(w in oral_src for w in no_words):
out[key] = "否"
elif any(w in raw for w in yes_words):
elif any(w in oral_src for w in yes_words):
out[key] = "是"
ready = ("随时可提", "货好随时", "随时货好", "货已好", "已经货好")
for word in ready:
@@ -202,15 +202,27 @@ def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]:
包装只看销售是否说了,散货等原文原样保留。
"""
src = dict(facts or {})
# 摘要占位「-」当空:不能拿去 resolve 机场,否则 TMS 回 1003 起飞机场无法匹配。
_dash = frozenset({"-", "—", "-"})
origin = str(src.get("起运港") or src.get("起运地") or "").strip()
if origin in _dash:
origin = ""
dest = str(src.get("目的港") or "").strip()
if dest in _dash:
dest = ""
cargo = str(src.get("品名") or "").strip()
if cargo in _dash:
cargo = ""
pieces = _first_number(str(src.get("件数") or ""))
weight = _first_number(str(src.get("毛重") or ""))
volume = volume_cbm_number(str(src.get("体积") or ""))
pack_raw = str(src.get("包装方式") or src.get("包装类型") or "").strip()
if pack_raw in _dash:
pack_raw = ""
pack_code = resolve_package_type(pack_raw)
quote_date = str(src.get("报价日期") or "").strip()
if quote_date in _dash:
quote_date = ""
# 三字码必须按当前地名重算,禁止沿用上一句的 NKG/KUL
origin_code = resolve_airport_code(origin)
dest_code = resolve_airport_code(dest)
@@ -240,6 +252,10 @@ def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]:
ok = not missing
out_facts = dict(src)
# 横杠占位从出站 facts 清掉,避免建单把「-」写入起运港列。
for _k in ("起运港", "起运地"):
if str(out_facts.get(_k) or "").strip() in _dash:
out_facts.pop(_k, None)
if origin:
out_facts["起运港"] = origin
if origin_code:
@@ -247,6 +263,8 @@ def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]:
else:
out_facts.pop("originCode", None)
else:
out_facts.pop("起运港", None)
out_facts.pop("起运地", None)
out_facts.pop("originCode", None)
if dest:
out_facts["目的港"] = dest
@@ -51,6 +51,16 @@ class AirAddedServicesTests(unittest.TestCase):
self.assertEqual(comma, ["托盘", "前置仓", "带电"])
spoken = harvest_added_services("这票 must go,要dg和tc")
self.assertEqual(spoken, ["MUST GO", "DG", "TC"])
# 服务名后的 +数量要跟进同一项,不能只剩 TC、托盘。
self.assertEqual(
harvest_added_services("查一下香港到吉隆坡书籍TC +1.5、托盘 +1的报价"),
["TC+1.5", "托盘+1"],
)
self.assertEqual(
harvest_added_services("增值服务:TC+1.5、托盘 +1"),
["TC+1.5", "托盘+1"],
)
def test_longest_match_and_negation(self) -> None:
self.assertEqual(harvest_added_services("要卡转低消"), ["卡转低消"])
@@ -369,11 +369,41 @@ class LandGroupCollabTests(unittest.TestCase):
self.assertTrue(rows)
self.assertEqual(str(rows[0].get("name") or ""), "陆运费")
self.assertEqual(str(land.get("total") or ""), "1")
self.assertEqual(str(land.get("currency") or ""), "CNY")
sea = extract_product_quote("价格为1RMB/KG", business_line="SEA")
self.assertEqual(str((sea.get("fee_rows") or [{}])[0].get("name") or ""), "海运费")
air = extract_product_quote("价格为1RMB/KG", business_line="AIR")
self.assertEqual(str((air.get("fee_rows") or [{}])[0].get("name") or ""), "空运费")
def test_land_rmb_overrides_tms_hkd_baseline(self) -> None:
"""WO040:TMS 已是 HKD 时,产品写「价格为1RMB/KG」必须落 CNY,禁止 hint 盖成 HKD。"""
from agent.llm.extract_text import extract_product_quote
from agent.policy.sea_group_ops import _quote_currency_hint, merge_quote_fees
tms = {
"currency": "HKD",
"cost": "HKD 1000",
"total": "1000",
"source": "TMS",
"fee_lines": [
{"name": "运输费", "amount": "1000", "currency": "HKD"},
],
}
self.assertEqual(_quote_currency_hint(tms), "HKD")
quote = extract_product_quote(
"价格为1RMB/KG",
business_line="LAND",
baseline_currency=_quote_currency_hint(tms),
)
self.assertEqual(str(quote.get("currency") or ""), "CNY")
self.assertEqual(str((quote.get("fee_rows") or [{}])[0].get("currency") or ""), "CNY")
merged = merge_quote_fees(tms, quote)
self.assertEqual(str(merged.get("currency") or ""), "CNY")
self.assertEqual(
str((merged.get("fee_rows") or [{}])[-1].get("currency") or ""),
"CNY",
)
def test_land_adjust_without_currency_inherits_tms_hkd(self) -> None:
"""WO202610090107:TMS 港币,产品只写「运输费调整为1100」时继承 HKD,禁止默认 USD。"""
from agent.llm.extract_text import extract_product_quote
+220
View File
@@ -0,0 +1,220 @@
"""
私聊建单前违禁品:词表、改口、不建单。
不连企微、TMS、主账。群聊不走这条拦截。
"""
from __future__ import annotations
import os
import sys
import unittest
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
os.environ.setdefault("YTD_ENV", "test")
os.environ.setdefault("REDIS_BACKEND", "memory")
os.environ.setdefault("CHECKPOINT_BACKEND", "memory")
os.environ.setdefault("MESSAGE_STORE_BACKEND", "memory")
os.environ.setdefault("LLM_DATA_USAGE_CONFIRMED", "false")
os.environ.setdefault("LLM_ALLOW_NETWORK", "false")
os.environ.setdefault("LEDGER_BACKEND", "memory")
from agent.ledger.memory_ledger import MemoryLedger
from agent.policy.sensitive_cargo import BLOCK_REPLY, contains_blocked, judge
from agent.policy.sea_text_flow import SeaTextInquiryFlow, reset_sea_text_flow_for_test
COMPLETE_SEA = {
"起运港": "上海",
"目的港": "洛杉矶",
"品名": "普货",
"货量": "20吨",
"整柜或拼柜": "整柜",
"箱型箱量": "1x40HQ",
"贸易条款": "FOB",
"运输分类": "Port to Port",
}
class WordListTests(unittest.TestCase):
def test_real_weapons_and_drugs_block(self) -> None:
for word in ("炸弹", "枪支", "手枪", "子弹", "海洛因", "火炮"):
self.assertTrue(contains_blocked(f"上海到洛杉矶,品名{word}"), word)
def test_toys_and_fireworks_pass(self) -> None:
for word in ("玩具枪", "水枪", "仿真枪", "气枪", "烟花", "礼炮", "鞭炮"):
self.assertFalse(contains_blocked(f"品名{word}"), word)
def test_toy_prefix_does_not_hide_real_gun(self) -> None:
self.assertTrue(contains_blocked("玩具枪支"))
def test_ordinary_dangerous_goods_pass(self) -> None:
self.assertFalse(contains_blocked("这票是危险品,含电,锂电池"))
class JudgeTests(unittest.TestCase):
def test_stray_sentence_sticks_until_cargo_rename(self) -> None:
facts = {"品名": "服装", "起运港": "上海"}
blocked, hold, _edits = judge(text="客户提到有炸弹", facts=dict(facts), hold="")
self.assertTrue(blocked)
self.assertIn("炸弹", hold)
still, hold2, _edits = judge(text="重量100KGS", facts=dict(facts), hold=hold)
self.assertTrue(still)
self.assertEqual(hold2, hold)
released, hold3, edits = judge(text="品名改为服装", facts=dict(facts), hold=hold)
self.assertFalse(released)
self.assertEqual(hold3, "")
self.assertEqual(edits.get("品名"), "服装")
def test_fresh_inquiry_clears_hold_without_cargo_rename(self) -> None:
"""问过炸弹被拦后,再查吉隆坡费用应放行;不能继续套旧 hold。"""
hold = "查一下广西到胡志明炸弹的费用"
facts = {"品名": "炸弹", "起运港": "广西"}
blocked, new_hold, _edits = judge(
text="查一下目的地吉隆坡的费用",
facts=facts,
hold=hold,
)
self.assertFalse(blocked)
self.assertEqual(new_hold, "")
self.assertFalse(contains_blocked(str(facts.get("品名") or "")))
def test_rename_sentence_that_still_says_the_word_stays_blocked(self) -> None:
blocked, hold, _edits = judge(text="不是炸弹,品名服装", facts={"品名": "服装"}, hold="")
self.assertTrue(blocked)
self.assertIn("炸弹", hold)
def test_origin_and_remark_remain_after_cargo_rename(self) -> None:
facts = {"品名": "炸弹", "起运港": "上海", "备注": "内有枪支"}
blocked, hold, _edits = judge(text="品名改为服装", facts=facts, hold="品名炸弹")
self.assertTrue(blocked)
self.assertEqual(hold, "")
self.assertEqual(facts["品名"], "服装")
self.assertIn("枪支", facts["备注"])
def test_remark_can_be_rewritten(self) -> None:
facts = {"品名": "服装", "备注": "枪支"}
blocked, hold, _edits = judge(text="备注改为无", facts=facts, hold="")
self.assertFalse(blocked)
self.assertEqual(facts["备注"], "无")
self.assertEqual(hold, "")
class SeaPrivateFlowTests(unittest.TestCase):
def setUp(self) -> None:
reset_sea_text_flow_for_test()
self.ledger = MemoryLedger()
self.flow = SeaTextInquiryFlow(self.ledger)
self.replies: list[str] = []
def _reply(self, text: str, extra=None) -> None:
_ = extra
self.replies.append(text)
def test_private_text_blocks_before_ticket(self) -> None:
facts = dict(COMPLETE_SEA)
facts["品名"] = "炸弹"
phase = self.flow.on_text(
sender_id="s-bomb",
text="海运 上海到洛杉矶 品名炸弹",
reply=self._reply,
injected_facts=facts,
injected_mode="SEA",
)
self.assertEqual(phase, "sensitive_block")
self.assertEqual(self.replies, [BLOCK_REPLY])
self.assertEqual(self.ledger._tickets, {})
sess = self.flow.session_of("s-bomb")
self.assertFalse((sess.work_order_no or "").strip())
def test_file_facts_block_even_if_caption_is_plain(self) -> None:
facts = dict(COMPLETE_SEA)
facts["品名"] = "枪支"
phase = self.flow.on_text(
sender_id="s-file",
text="海运报价单",
reply=self._reply,
injected_facts=facts,
injected_mode="SEA",
)
self.assertEqual(phase, "sensitive_block")
self.assertEqual(self.ledger._tickets, {})
def test_rename_then_confirm_creates_ticket(self) -> None:
facts = dict(COMPLETE_SEA)
facts["品名"] = "炸弹"
self.flow.on_text(
sender_id="s-fix",
text="海运 品名炸弹 上海到洛杉矶",
reply=self._reply,
injected_facts=facts,
injected_mode="SEA",
)
self.replies.clear()
fixed = dict(COMPLETE_SEA)
phase = self.flow.on_text(
sender_id="s-fix",
text="品名改为服装",
reply=self._reply,
injected_facts=fixed,
injected_mode="SEA",
)
self.assertNotEqual(phase, "sensitive_block")
self.assertNotIn(BLOCK_REPLY, self.replies)
self.replies.clear()
created = self.flow.on_text(sender_id="s-fix", text="确定", reply=self._reply)
self.assertNotEqual(created, "sensitive_block")
self.assertTrue(self.ledger._tickets)
joined = "\n".join(self.replies)
self.assertNotIn(BLOCK_REPLY, joined)
def test_oral_restatement_clears_previous_bomb(self) -> None:
"""销售不说「品名改为」,直接重说运送衣服,或说改成衣服。"""
hold = "10月30日,从广州去深圳,运送炸弹,请报价"
facts = {"品名": "炸弹", "起运港": "广州"}
for text in (
"10月30日,从广州去深圳,运送衣服,请报价",
"炸弹错了,给我改成衣服",
"炸弹换衣服",
):
blocked, new_hold, edits = judge(text=text, facts=dict(facts), hold=hold)
self.assertFalse(blocked, text)
self.assertEqual(new_hold, "", text)
self.assertEqual(edits.get("品名"), "衣服", text)
def test_weight_alone_does_not_clear(self) -> None:
facts = dict(COMPLETE_SEA)
facts["品名"] = "弹药"
self.flow.on_text(
sender_id="s-hold",
text="海运 品名弹药",
reply=self._reply,
injected_facts=facts,
injected_mode="SEA",
)
self.replies.clear()
phase = self.flow.on_text(
sender_id="s-hold",
text="重量 20吨",
reply=self._reply,
injected_facts=facts,
injected_mode="SEA",
)
self.assertEqual(phase, "sensitive_block")
self.assertEqual(self.replies, [BLOCK_REPLY])
self.assertEqual(self.ledger._tickets, {})
def test_toy_gun_can_continue(self) -> None:
facts = dict(COMPLETE_SEA)
facts["品名"] = "玩具枪"
phase = self.flow.on_text(
sender_id="s-toy",
text="海运 品名玩具枪 上海到洛杉矶",
reply=self._reply,
injected_facts=facts,
injected_mode="SEA",
)
self.assertEqual(phase, "wait_confirm")
self.assertNotIn(BLOCK_REPLY, self.replies)
@@ -13,6 +13,7 @@ import org.jeecg.common.system.vo.LoginUser;
import org.jeecg.modules.inquiry.entity.InquiryQuoteTemplate;
import org.jeecg.modules.inquiry.entity.InquiryQuoteTemplateVersion;
import org.jeecg.modules.inquiry.service.IInquiryQuoteTemplateService;
import org.jeecg.modules.inquiry.support.InquiryQuoteTemplateBizScope;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
@@ -32,7 +33,8 @@ import java.util.Map;
* 报价模板管理 API。
* <p>
* 路径 /inquiry/quoteTemplate;权限 inquiry:template:*。
* 智能体只读列表走 /agent/listEnabled(服务账号 token)。
* 有菜单权限只表示能进页面。列表按角色业务范围收窄:陆运中港只看陆运模板。
* 智能体只读列表走 /agent/listEnabled(服务账号 token),不按登录人的角色过滤。
* 禁止:在本 Controller 调 LLM;LibreOffice 仅允许预览截图接口短等本机 soffice。
*/
@Slf4j
@@ -48,12 +50,22 @@ public class InquiryQuoteTemplateController {
@ApiOperation("分页列表(含当前版本摘要)")
@RequiresPermissions("inquiry:template:view")
@GetMapping("/list")
public Result<IPage<Map<String, Object>>> list(@RequestParam(required = false) String keyword,
@RequestParam(required = false) String bizType,
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "1") Integer pageNo,
@RequestParam(defaultValue = "20") Integer pageSize) {
return Result.OK(quoteTemplateService.pageWithVersion(keyword, bizType, status, pageNo, pageSize));
public Result<Map<String, Object>> list(@RequestParam(required = false) String keyword,
@RequestParam(required = false) String bizType,
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "1") Integer pageNo,
@RequestParam(defaultValue = "20") Integer pageSize) {
IPage<Map<String, Object>> page = quoteTemplateService.pageWithVersion(
keyword, bizType, status, pageNo, pageSize);
Map<String, Object> body = new HashMap<>();
body.put("records", page.getRecords());
body.put("total", page.getTotal());
body.put("size", page.getSize());
body.put("current", page.getCurrent());
body.put("pages", page.getPages());
// 前端业务下拉只渲染这些项。超管是空运、海运、陆运三项。
body.put("allowedBizTypes", InquiryQuoteTemplateBizScope.allowedLabelsForCurrentUser());
return Result.OK(body);
}
@AutoLog(value = "报价模板-上传版本")
@@ -143,7 +155,11 @@ public class InquiryQuoteTemplateController {
@RequiresPermissions("inquiry:template:view")
@GetMapping("/queryById")
public Result<InquiryQuoteTemplate> queryById(@RequestParam String id) {
return Result.OK(quoteTemplateService.getById(id));
InquiryQuoteTemplate row = quoteTemplateService.getById(id);
if (row != null) {
InquiryQuoteTemplateBizScope.assertBizAllowed(row.getBizType());
}
return Result.OK(row);
}
@ApiOperation("编辑逻辑头(名称/业务/关键词/模式/默认标记,不换文件)")
@@ -157,10 +173,12 @@ public class InquiryQuoteTemplateController {
if (db == null) {
return Result.error("模板不存在");
}
InquiryQuoteTemplateBizScope.assertBizAllowed(db.getBizType());
if (StringUtils.isNotBlank(body.getName())) {
db.setName(body.getName());
}
if (StringUtils.isNotBlank(body.getBizType())) {
InquiryQuoteTemplateBizScope.assertBizAllowed(body.getBizType());
db.setBizType(body.getBizType());
}
if (StringUtils.isNotBlank(body.getKeywordsJson())) {
@@ -14,6 +14,7 @@ import org.jeecg.modules.inquiry.dto.TicketAbnormalRequest;
import org.jeecg.modules.inquiry.dto.TicketTransitionRequest;
import org.jeecg.modules.inquiry.entity.InquiryTicket;
import org.jeecg.modules.inquiry.service.IInquiryWorkOrderService;
import org.jeecg.modules.inquiry.support.InquiryDataScopeSupport;
import org.apache.shiro.authz.annotation.Logical;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@@ -28,6 +29,7 @@ import java.util.Map;
* <p>
* 请求线程只做 DB 事务与校验,不调用 LLM / LibreOffice / Graph。
* 权限:inquiry:workorder:list / transition 等。
* 有菜单权限只表示能进页面。列表、导出、详情再按角色业务范围收窄。
*/
@Slf4j
@Api(tags = "询价-工单")
@@ -42,7 +44,7 @@ public class InquiryWorkOrderController {
@ApiOperation("后台列表")
@RequiresPermissions("inquiry:workorder:list")
@GetMapping("/view/list")
public Result<IPage<InquiryTicket>> viewList(
public Result<Map<String, Object>> viewList(
@RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
@RequestParam(name = "pageSize", defaultValue = "20") Integer pageSize,
HttpServletRequest req) {
@@ -55,13 +57,21 @@ public class InquiryWorkOrderController {
copyParam(req, filters, "systemException");
copyParam(req, filters, "dateFrom");
copyParam(req, filters, "dateTo");
return Result.OK(workOrderService.pageView(new Page<>(pageNo, pageSize), filters));
IPage<InquiryTicket> page = workOrderService.pageView(new Page<>(pageNo, pageSize), filters);
Map<String, Object> body = new HashMap<>();
body.put("records", page.getRecords());
body.put("total", page.getTotal());
body.put("size", page.getSize());
body.put("current", page.getCurrent());
body.put("pages", page.getPages());
body.put("allowedBizTypes", InquiryDataScopeSupport.ticketBizOptionsForCurrentUser());
return Result.OK(body);
}
@ApiOperation("兼容 list 路径")
@RequiresPermissions("inquiry:workorder:list")
@GetMapping("/list")
public Result<IPage<InquiryTicket>> list(
public Result<Map<String, Object>> list(
@RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
@RequestParam(name = "pageSize", defaultValue = "20") Integer pageSize,
HttpServletRequest req) {
@@ -105,7 +115,11 @@ public class InquiryWorkOrderController {
Map<String, Object> detail = workOrderService.detailView(workOrderNo);
return Result.OK((InquiryTicket) detail.get("ticket"));
}
return Result.OK(workOrderService.getById(id));
InquiryTicket row = workOrderService.getById(id);
if (row != null) {
InquiryDataScopeSupport.assertTicketBizAllowed(row);
}
return Result.OK(row);
}
@AutoLog(value = "工单-六态流转")
@@ -90,6 +90,7 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
.ge(InquiryTicket::getCreateTime, from)
.le(InquiryTicket::getCreateTime, to);
InquiryDataScopeSupport.applySelfOnly(qw);
InquiryDataScopeSupport.applyRoleBizLines(qw);
List<InquiryTicket> tickets = ticketMapper.selectList(qw);
long total = tickets.size();
@@ -20,6 +20,7 @@ import org.jeecg.modules.inquiry.mapper.InquiryQuoteTemplateMapper;
import org.jeecg.modules.inquiry.mapper.InquiryQuoteTemplateVersionMapper;
import org.jeecg.modules.inquiry.mapper.InquiryStaffRoleMapper;
import org.jeecg.modules.inquiry.mapper.InquiryTicketMapper;
import org.jeecg.modules.inquiry.support.InquiryQuoteTemplateBizScope;
import org.jeecg.modules.inquiry.support.InquiryTicketDisplaySupport;
import org.jeecg.modules.inquiry.service.IInquiryQuoteTemplateService;
import org.jeecg.modules.inquiry.template.QuoteTemplateAiParseClient;
@@ -98,12 +99,28 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
@Value("${inquiry.quoteTemplate.contractPath:}")
private String contractPath;
/**
* 后台列表按当前登录人的角色收业务线。智能体匹配不走这个方法。
* 角色是「陆运中港」时,海运、空运模板不会出现在这一页。
*/
@Override
public IPage<Map<String, Object>> pageWithVersion(String keyword, String bizType, String status,
int pageNo, int pageSize) {
QueryWrapper<InquiryQuoteTemplate> qw = new QueryWrapper<>();
if (StringUtils.isNotBlank(bizType)) {
qw.eq("biz_type", bizType);
Set<String> allowed = InquiryQuoteTemplateBizScope.currentAllowedOrNull();
String asked = StringUtils.trimToEmpty(bizType);
if (allowed != null) {
if (StringUtils.isNotBlank(asked) && !allowed.contains(asked)) {
qw.apply("1 = 0");
} else if (StringUtils.isNotBlank(asked)) {
qw.eq("biz_type", asked);
} else if (allowed.isEmpty()) {
qw.apply("1 = 0");
} else {
qw.in("biz_type", allowed);
}
} else if (StringUtils.isNotBlank(asked)) {
qw.eq("biz_type", asked);
}
if (StringUtils.isNotBlank(status)) {
qw.eq("status", status);
@@ -179,7 +196,10 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
if (creating) {
template = new InquiryQuoteTemplate();
template.setName(StringUtils.defaultIfBlank(name, stripExt(original)));
template.setBizType(StringUtils.defaultIfBlank(bizType, "海运"));
// 没选业务时历史默认是海运;陆运账号不能因此建出一张海运模板。
String biz = StringUtils.defaultIfBlank(bizType, "海运");
InquiryQuoteTemplateBizScope.assertBizAllowed(biz);
template.setBizType(biz);
template.setKeywordsJson(StringUtils.defaultIfBlank(keywordsJson, "[]"));
template.setMode(StringUtils.defaultIfBlank(mode, "auto"));
template.setStatus("disabled");
@@ -194,10 +214,12 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
if (template == null) {
throw new IllegalArgumentException("模板不存在: " + templateId);
}
InquiryQuoteTemplateBizScope.assertBizAllowed(template.getBizType());
if (StringUtils.isNotBlank(name)) {
template.setName(name);
}
if (StringUtils.isNotBlank(bizType)) {
InquiryQuoteTemplateBizScope.assertBizAllowed(bizType);
template.setBizType(bizType);
}
if (StringUtils.isNotBlank(keywordsJson)) {
@@ -395,13 +417,54 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
return "sea";
}
/**
* 后台查看或修改某张模板前,核对它是不是当前角色能碰的业务。
* 模板已经不存在时不在这里报错,留给原方法返回「模板不存在」。
*/
private void requireTemplateBiz(String templateId) {
if (StringUtils.isBlank(templateId)) {
return;
}
InquiryQuoteTemplate template = getById(templateId);
if (template == null) {
return;
}
InquiryQuoteTemplateBizScope.assertBizAllowed(template.getBizType());
}
/** 按版本找到所属模板,再套同一套业务范围。版本不存在时留给原方法处理。 */
private void requireVersionBiz(String versionId) {
if (StringUtils.isBlank(versionId)) {
return;
}
InquiryQuoteTemplateVersion ver = versionMapper.selectById(versionId);
if (ver == null) {
return;
}
requireTemplateBiz(ver.getTemplateId());
}
/** 纠偏任务跳过越权模板时用;模板没了就当成空业务,有范围的账号不会去改它。 */
private String bizTypeOfTemplate(String templateId) {
if (StringUtils.isBlank(templateId)) {
return "";
}
InquiryQuoteTemplate template = getById(templateId);
return template == null ? "" : StringUtils.defaultString(template.getBizType());
}
@Override
public InquiryQuoteTemplateVersion getVersion(String versionId) {
return versionMapper.selectById(versionId);
InquiryQuoteTemplateVersion ver = versionMapper.selectById(versionId);
if (ver != null) {
requireTemplateBiz(ver.getTemplateId());
}
return ver;
}
@Override
public List<InquiryQuoteTemplateVersion> listVersions(String templateId) {
requireTemplateBiz(templateId);
return versionMapper.selectList(new QueryWrapper<InquiryQuoteTemplateVersion>()
.eq("template_id", templateId)
.orderByDesc("version_no"));
@@ -410,6 +473,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
@Override
@Transactional(rollbackFor = Exception.class)
public void saveMapping(String versionId, String mappingJson, String operator) {
requireVersionBiz(versionId);
InquiryQuoteTemplateVersion ver = versionMapper.selectById(versionId);
if (ver == null) {
throw new IllegalArgumentException("版本不存在");
@@ -451,6 +515,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
@Override
@Transactional(rollbackFor = Exception.class)
public void enableTemplate(String templateId, String versionId, String operator) {
requireTemplateBiz(templateId);
InquiryQuoteTemplate template = getById(templateId);
if (template == null) {
throw new IllegalArgumentException("模板不存在");
@@ -492,6 +557,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
@Override
@Transactional(rollbackFor = Exception.class)
public void disableTemplate(String templateId, String operator) {
requireTemplateBiz(templateId);
InquiryQuoteTemplate template = getById(templateId);
if (template == null) {
throw new IllegalArgumentException("模板不存在");
@@ -511,6 +577,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteTemplate(String templateId, String operator) {
requireTemplateBiz(templateId);
InquiryQuoteTemplate template = getById(templateId);
if (template == null) {
throw new IllegalArgumentException("模板不存在");
@@ -533,6 +600,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
@Override
@Transactional(rollbackFor = Exception.class)
public void setDefault(String templateId, boolean isDefault, String operator) {
requireTemplateBiz(templateId);
InquiryQuoteTemplate template = getById(templateId);
if (template == null) {
throw new IllegalArgumentException("模板不存在");
@@ -559,6 +627,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
@Override
public Path buildSamplePreview(String versionId) throws Exception {
requireVersionBiz(versionId);
InquiryQuoteTemplateVersion ver = versionMapper.selectById(versionId);
if (ver == null) {
throw new IllegalArgumentException("版本不存在");
@@ -594,6 +663,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
*/
@Override
public Map<String, Object> buildSheetPreview(String versionId) throws Exception {
requireVersionBiz(versionId);
InquiryQuoteTemplateVersion ver = versionMapper.selectById(versionId);
if (ver == null) {
throw new IllegalArgumentException("版本不存在");
@@ -625,6 +695,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
*/
@Override
public Path buildSnapshotPreview(String versionId) throws Exception {
requireVersionBiz(versionId);
InquiryQuoteTemplateVersion ver = versionMapper.selectById(versionId);
if (ver == null) {
throw new IllegalArgumentException("版本不存在");
@@ -855,6 +926,11 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
}
src.put("customer_name", customer);
src.put("客户名称", customer);
// 报价单「报价日期」格用工单创建日,不拿询价/TMS 报价日期。
if (ticket != null && ticket.getCreateTime() != null) {
src.put("ticket_create_date",
new java.text.SimpleDateFormat("yyyy-MM-dd").format(ticket.getCreateTime()));
}
return src;
}
@@ -960,9 +1036,32 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
if (StringUtils.isNotBlank(vol)) {
out.put("volume_cbm", vol.replaceAll("(?i)\\s*(CBM|立方米|方)\\s*$", "").trim());
}
// 询价/走货/发货/出货/提货的日期或时间,都是报价日期
out.put("quote_date", first(src, "报价日期", "quote_date", "询价日期", "走货日期", "询价时间",
"走货时间", "发货时间", "发货日期", "出货日期", "出货时间", "提货时间", "提货日期"));
// 报价单「报价日期」格:优先工单创建日(产品口径)。
// 询价事实里的报价日期只给 TMS 查价/锁舱,不填进这张表的日期格。
String created = first(src, "ticket_create_date", "createTime", "create_time");
if (StringUtils.isNotBlank(created)) {
out.put("quote_date", created.length() >= 10 ? created.substring(0, 10) : created);
// #region agent log
try {
java.nio.file.Path dbg = java.nio.file.Paths.get(
"C:/Users/ytd_extdev_0903/incoming/debug-717349.log");
String line = "{\"sessionId\":\"717349\",\"runId\":\"post-fix\",\"hypothesisId\":\"T1\","
+ "\"location\":\"InquiryQuoteTemplateServiceImpl.buildFillFacts\","
+ "\"message\":\"template quote_date from create\","
+ "\"data\":{\"wo\":\"" + StringUtils.defaultString(workOrderNo)
+ "\",\"quote_date\":\"" + out.getString("quote_date")
+ "\",\"fact_quote\":\"" + StringUtils.defaultString(first(src, "报价日期", "quote_date"))
+ "\"},\"timestamp\":" + System.currentTimeMillis() + "}\n";
java.nio.file.Files.write(dbg, line.getBytes(java.nio.charset.StandardCharsets.UTF_8),
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.APPEND);
} catch (Exception ignore) {
// 调试埋点失败不影响出单
}
// #endregion
} else {
out.put("quote_date", first(src, "报价日期", "quote_date", "询价日期", "走货日期", "询价时间",
"走货时间", "发货时间", "发货日期", "出货日期", "出货时间", "提货时间", "提货日期"));
}
out.put("trade_terms", first(src, "贸易条款", "trade_terms"));
out.put("运输分类", first(src, "运输分类"));
out.put("cargo_ready_date", first(src, "货好时间", "cargo_ready_date"));
@@ -1002,6 +1101,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
out.put("oil_elec_mag", oem);
out.put("是否含油,电,磁", oem);
}
// 没有创建日、也没有询价日期时,才退到上海当天,避免格子全空。
if (StringUtils.isBlank(out.getString("quote_date"))) {
out.put("quote_date", new java.text.SimpleDateFormat("yyyy-MM-dd").format(new Date()));
}
@@ -1591,6 +1691,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
*/
@Override
public void reparseLatestIfFailed(String templateId) {
requireTemplateBiz(templateId);
InquiryQuoteTemplateVersion ver = latestExcelVersion(templateId);
if (ver == null || !"failed".equals(ver.getParseStatus())) {
return;
@@ -1603,6 +1704,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
*/
@Override
public String reparseLatest(String templateId) {
requireTemplateBiz(templateId);
InquiryQuoteTemplate template = getById(templateId);
if (template == null) {
throw new IllegalArgumentException("模板不存在");
@@ -1942,6 +2044,9 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
.eq("parse_status", "mapping_incomplete"));
int n = 0;
for (InquiryQuoteTemplateVersion ver : list) {
if (!InquiryQuoteTemplateBizScope.isBizAllowed(bizTypeOfTemplate(ver.getTemplateId()))) {
continue;
}
if (ver.getMappingPointCount() != null && ver.getMappingPointCount() > 0) {
ver.setParseStatus("parsed");
ver.setParseMessage("已解析 " + ver.getMappingPointCount() + " 个映射点");
@@ -148,8 +148,9 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
if (StringUtils.isNotBlank(dateTo)) {
qw.le(InquiryTicket::getCreateTime, dateTo + " 23:59:59");
}
// 与看板同源:账号勾「只看自己」时收紧列表(非超管)
// 与看板同源:只看自己,再按角色收海陆空。陆运中港看不到空运、海运工单。
InquiryDataScopeSupport.applySelfOnly(qw);
InquiryDataScopeSupport.applyRoleBizLines(qw);
qw.orderByDesc(InquiryTicket::getCreateTime);
return qw;
}
@@ -160,6 +161,7 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
if (ticket == null) {
throw new IllegalArgumentException("工单不存在: " + workOrderNo);
}
InquiryDataScopeSupport.assertTicketBizAllowed(ticket);
enrichDisplayFields(Collections.singletonList(ticket));
List<InquiryQuote> quotes = quoteMapper.selectList(new LambdaQueryWrapper<InquiryQuote>()
.eq(InquiryQuote::getWorkOrderNo, workOrderNo)
@@ -210,19 +212,77 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
ticket.getSalesName(), ticket.getSalesWecomId(), staffName));
InquiryTicketDisplaySupport.applyRequirementSummary(ticket);
List<InquiryQuote> quoteRows = quotesByNo.get(ticket.getWorkOrderNo());
InquiryTicketPriceSupport.apply(ticket, quoteRows);
if (quoteRows != null) {
for (InquiryQuote q : quoteRows) {
// 空运未选舱的 TMS 备选:不进后台报价列,也不参与销售价汇总。
List<InquiryQuote> displayRows = filterOutPendingAirCabinQuotes(quoteRows);
InquiryTicketPriceSupport.apply(ticket, displayRows);
if (displayRows != null) {
for (InquiryQuote q : displayRows) {
if (q == null) {
continue;
}
q.setSourceLabel(InquiryQuoteDisplaySupport.displayLabel(q.getSource(), q.getSourceLabel()));
}
}
ticket.setQuotes(quoteRows);
ticket.setQuotes(displayRows);
}
}
/**
* 列表/详情:丢掉空运「待选舱」TMS 行,避免未选舱却显示费用。
* 判定与 InquiryTmsQueryService.isAirCabinPendingSelection 对齐。
*/
static List<InquiryQuote> filterOutPendingAirCabinQuotes(List<InquiryQuote> rows) {
if (rows == null || rows.isEmpty()) {
return rows;
}
List<InquiryQuote> out = new ArrayList<>();
for (InquiryQuote q : rows) {
if (q == null) {
continue;
}
if (isPendingAirCabinQuoteRow(q)) {
continue;
}
out.add(q);
}
return out;
}
static boolean isPendingAirCabinQuoteRow(InquiryQuote q) {
if (q == null || StringUtils.isBlank(q.getPayloadJson())) {
return false;
}
try {
JSONObject obj = JSON.parseObject(q.getPayloadJson());
if (obj == null) {
return false;
}
if ("1".equals(StringUtils.trimToEmpty(obj.getString("pending_cabin_pick")))) {
return true;
}
if (obj.get("selected_air_option") != null || obj.get("selectedAirOption") != null) {
return false;
}
Object raw = obj.get("airOptions");
if (raw == null) {
raw = obj.get("air_options");
}
if (raw instanceof List) {
return !((List<?>) raw).isEmpty();
}
if (raw instanceof String) {
String s = ((String) raw).trim();
if (s.startsWith("[")) {
List<?> parsed = JSON.parseArray(s);
return parsed != null && !parsed.isEmpty();
}
}
} catch (Exception ignored) {
return false;
}
return false;
}
/** 列表一次取出本页工单的报价记录,避免按单 N+1。 */
private Map<String, List<InquiryQuote>> loadQuotesByWorkOrder(List<InquiryTicket> tickets) {
List<String> nos = new ArrayList<>();
@@ -300,6 +360,7 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
if (ticket == null) {
throw new IllegalArgumentException("工单不存在: " + req.getWorkOrderNo());
}
InquiryDataScopeSupport.assertTicketBizAllowed(ticket);
String from = ticket.getStatus();
String to = req.getToStatus().trim();
if (!TicketStatuses.canTransit(from, to)) {
@@ -529,16 +590,18 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
throw new IllegalArgumentException("salesWecomId 与 businessLine 不能为空");
}
// 每次出询价确认都新开一单,相同需求不复用未关闭工单。
Map<String, String> facts = req.getFacts() == null ? new HashMap<>() : req.getFacts();
Map<String, String> facts = scrubPlaceholderFacts(
req.getFacts() == null ? new HashMap<>() : new HashMap<>(req.getFacts()));
InquiryTicket ticket = new InquiryTicket();
ticket.setWorkOrderNo(nextWorkOrderNo());
ticket.setSalesWecomId(req.getSalesWecomId());
ticket.setSalesName(resolveStaffDisplayName(req.getSalesWecomId()));
ticket.setBusinessLine(req.getBusinessLine().trim().toUpperCase());
ticket.setBizTypeLabel(bizLabel(ticket.getBusinessLine()));
ticket.setOrigin(facts.get("起运港"));
ticket.setDestination(facts.get("目的港"));
ticket.setGoodsName(facts.get("品名"));
// 摘要占位「-」不落库:空运不起运地应空串出站,横杠会骗 TMS 回 1003。
ticket.setOrigin(placeOrNull(facts.get("起运港")));
ticket.setDestination(placeOrNull(facts.get("目的港")));
ticket.setGoodsName(placeOrNull(facts.get("品名")));
ticket.setPieces(parseInt(facts.get("件数")));
ticket.setWeightKg(parseDecimal(firstFact(facts, "重量", "毛重")));
// 体积常写尺寸 1.1*1.2*3,必须相乘;旧解析剥 * 会变成 1.11.23 直接 NumberFormat 炸建单。
@@ -548,7 +611,9 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
ticket.setFclLcl(StringUtils.trimToNull(facts.get("整柜或拼柜")));
ticket.setContainerSpec(StringUtils.trimToNull(facts.get("箱型箱量")));
ticket.setFactsJson(JSON.toJSONString(facts));
ticket.setDemandTitle(ticket.getOrigin() + " → " + ticket.getDestination());
String originTitle = StringUtils.defaultString(ticket.getOrigin());
String destTitle = StringUtils.defaultString(ticket.getDestination());
ticket.setDemandTitle(originTitle + " → " + destTitle);
ticket.setDemandDetail("FP:" + fingerprint(req));
ticket.setStatus(TicketStatuses.INQUIRING);
ticket.setVersion(1);
@@ -651,7 +716,9 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
Object raw = body == null ? null : body.get("quote");
@SuppressWarnings("unchecked")
Map<String, Object> quote = raw instanceof Map ? (Map<String, Object>) raw : new LinkedHashMap<String, Object>();
return tmsQueryService.replaceTmsQuote(no, quote);
// 智能体可显式传 toStatus=询价中:空运未选舱只落备选,不迁已报价。
String toStatus = text(body, "toStatus", "to_status");
return tmsQueryService.replaceTmsQuote(no, quote, toStatus);
}
private String nextWorkOrderNo() {
@@ -698,13 +765,49 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
}
for (String key : keys) {
String val = facts.get(key);
if (StringUtils.isNotBlank(val)) {
if (StringUtils.isNotBlank(val) && !isPlaceholderDash(val)) {
return val;
}
}
return "";
}
/**
* 摘要占位横杠不当地名落库。
*/
private static boolean isPlaceholderDash(String raw) {
if (StringUtils.isBlank(raw)) {
return false;
}
String t = raw.trim();
return "-".equals(t) || "—".equals(t) || "-".equals(t);
}
private static String placeOrNull(String raw) {
if (StringUtils.isBlank(raw) || isPlaceholderDash(raw)) {
return null;
}
return raw.trim();
}
/**
* 建单前清掉 facts 里的占位横杠,避免 factsJson / 起运港列写进「-」。
*/
static Map<String, String> scrubPlaceholderFacts(Map<String, String> facts) {
if (facts == null || facts.isEmpty()) {
return facts == null ? new HashMap<>() : facts;
}
for (String key : new String[] {
"起运港", "起运地", "目的港", "目的地", "品名", "包装方式", "包装类型", "报价日期"
}) {
String val = facts.get(key);
if (val != null && isPlaceholderDash(val)) {
facts.remove(key);
}
}
return facts;
}
private static Integer parseInt(String raw) {
if (StringUtils.isBlank(raw)) {
return null;
@@ -755,6 +858,7 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
if (ticket == null) {
throw new IllegalArgumentException("工单不存在: " + req.getWorkOrderNo());
}
InquiryDataScopeSupport.assertTicketBizAllowed(ticket);
return ticket;
}
@@ -3,15 +3,21 @@ 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.exception.JeecgBootException;
import org.jeecg.common.system.vo.LoginUser;
import org.jeecg.modules.inquiry.entity.InquiryTicket;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
/**
* 后台数据范围:账号「只看自己数据」时收紧工单查询。
* 后台数据范围:账号「只看自己数据」,以及角色业务范围。
* <p>
* 口径:非超管且 {@code dashboardSelfOnly=true} →
* 只看自己:非超管且 {@code dashboardSelfOnly=true} →
* {@code salesWecomId = 工号} 或 {@code createBy = 登录名}。
* 看板与工单列表共用,避免只看板生效、列表漏看全站。
* 业务范围:角色是陆运中港,或数据权限只有陆运线路时,看板和工单只留陆运。
* 看板与工单列表共用,避免只收一边。没有登录上下文时不限制(智能体内部调用)。
* 线程:只读登录态,无全局锁。
*/
public final class InquiryDataScopeSupport {
@@ -32,13 +38,15 @@ public final class InquiryDataScopeSupport {
if (login == null) {
return;
}
if (isAdministrator(login)) {
return;
}
if (!Boolean.TRUE.equals(login.getDashboardSelfOnly())) {
return;
}
boolean admin = isAdministrator(login);
Boolean selfOnly = login.getDashboardSelfOnly();
String username = StringUtils.trimToEmpty(login.getUsername());
if (admin) {
return;
}
if (!Boolean.TRUE.equals(selfOnly)) {
return;
}
String wecom = wecomId(login);
qw.and(w -> {
boolean any = false;
@@ -60,6 +68,85 @@ public final class InquiryDataScopeSupport {
});
}
/**
* 按角色把工单收成空运 / 海运 / 陆运。不限制时原样返回。
* 有业务线码时只认码;业务线空着才用中文标签兜底,避免海运单被写成「陆运」后漏出来。
*/
public static void applyRoleBizLines(LambdaQueryWrapper<InquiryTicket> qw) {
if (qw == null) {
return;
}
Set<String> allowed = InquiryQuoteTemplateBizScope.currentAllowedOrNull();
if (allowed == null) {
return;
}
List<String> codes = new ArrayList<String>();
List<String> marks = new ArrayList<String>();
if (allowed.contains(InquiryRoleBizScope.LAND)) {
codes.add("LAND");
marks.add("陆");
}
if (allowed.contains(InquiryRoleBizScope.AIR)) {
codes.add("AIR");
marks.add("空");
}
if (allowed.contains(InquiryRoleBizScope.SEA)) {
codes.add("SEA");
marks.add("海");
}
if (codes.isEmpty()) {
qw.apply("1 = 0");
return;
}
StringBuilder sql = new StringBuilder();
sql.append("(UPPER(business_line) IN (");
for (int i = 0; i < codes.size(); i++) {
if (i > 0) {
sql.append(',');
}
sql.append('\'').append(codes.get(i)).append('\'');
}
sql.append(") OR ((business_line IS NULL OR business_line = '') AND (");
for (int i = 0; i < marks.size(); i++) {
if (i > 0) {
sql.append(" OR ");
}
sql.append("biz_type_label LIKE '%").append(marks.get(i)).append("%'");
}
sql.append(")))");
qw.apply(sql.toString());
}
/**
* 详情、流转、异常处理:列表里不该出现的工单,按编号打开也不给看。
*/
public static void assertTicketBizAllowed(InquiryTicket ticket) {
if (ticket == null) {
return;
}
Set<String> allowed = InquiryQuoteTemplateBizScope.currentAllowedOrNull();
if (InquiryRoleBizScope.ticketInScope(ticket.getBusinessLine(), ticket.getBizTypeLabel(), allowed)) {
return;
}
String scope = InquiryRoleBizScope.joinLabels(allowed);
if (StringUtils.isBlank(scope)) {
scope = "已授权业务";
}
throw new JeecgBootException("当前账号只能查看" + scope + "的询价工单和数据看板");
}
/**
* 工单业务下拉。不限制时含多段联运;陆运角色只有陆运。
*/
public static List<String> ticketBizOptionsForCurrentUser() {
Set<String> allowed = InquiryQuoteTemplateBizScope.currentAllowedOrNull();
List<String> labels = new ArrayList<String>(InquiryRoleBizScope.orderedLabels(allowed));
if (allowed == null) {
labels.add("多段联运");
}
return labels;
}
private static LoginUser currentLoginUser() {
try {
Object p = SecurityUtils.getSubject().getPrincipal();
@@ -0,0 +1,153 @@
package org.jeecg.modules.inquiry.support;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.SecurityUtils;
import org.jeecg.common.exception.JeecgBootException;
import org.jeecg.common.system.vo.LoginUser;
import org.jeecg.common.util.SpringContextUtils;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
/**
* 后台报价模板的业务范围:按当前登录人的角色收成空运 / 海运 / 陆运。
* <p>
* 菜单权限 {@code inquiry:template:view} 只决定能不能进「报价模板」页。
* 进了页面之后,陆运中港这类角色只应看到陆运模板,不能把海运、空运模板一并列出来。
* 智能体出单走服务账号的 {@code /agent/listEnabled},不经过这里,避免按某个销售的角色把别的业务模板藏掉。
* <p>
* 线程:只读当前请求的登录态和角色表,无锁。没有登录上下文时不限制(内部任务、单测)。
*/
public final class InquiryQuoteTemplateBizScope {
private InquiryQuoteTemplateBizScope() {
}
/**
* 当前登录人允许的业务线。
*
* @return {@code null} 表示不限制(未登录、超管、角色未标明业务)
*/
public static Set<String> currentAllowedOrNull() {
LoginUser login = currentLoginUser();
if (login == null) {
return null;
}
String username = StringUtils.trimToEmpty(login.getUsername());
List<InquiryRoleBizScope.RoleSnap> roles = loadRoles(username);
return InquiryRoleBizScope.resolve(isAdministrator(login, roles), roles);
}
/**
* 给报价模板页下拉用。不限制时返回空运、海运、陆运三项。
*/
public static List<String> allowedLabelsForCurrentUser() {
return InquiryRoleBizScope.orderedLabels(currentAllowedOrNull());
}
/**
* 当前账号能否操作这条业务的模板。未登录或不限制时放行。
*/
public static boolean isBizAllowed(String bizType) {
Set<String> allowed = currentAllowedOrNull();
if (allowed == null) {
return true;
}
return allowed.contains(StringUtils.trimToEmpty(bizType));
}
/**
* 越权时直接拒绝,避免只藏列表、预览和下载仍能按编号打开海运/空运模板。
*/
public static void assertBizAllowed(String bizType) {
if (isBizAllowed(bizType)) {
return;
}
Set<String> allowed = currentAllowedOrNull();
String scope = InquiryRoleBizScope.joinLabels(allowed);
if (StringUtils.isBlank(scope)) {
scope = "已授权业务";
}
throw new JeecgBootException("当前账号只能查看和维护" + scope + "报价模板");
}
private static LoginUser currentLoginUser() {
try {
Object principal = SecurityUtils.getSubject().getPrincipal();
if (principal instanceof LoginUser) {
return (LoginUser) principal;
}
} catch (Exception ignored) {
// 无 Shiro 上下文:内部调用不按人过滤
}
return null;
}
/**
* 超管看全部模板。只认登录名 admin、角色码 admin、角色名「管理员 / 超级管理员」。
* 「陆运管理员」这种带业务的名字不放行。
*/
private static boolean isAdministrator(LoginUser login, List<InquiryRoleBizScope.RoleSnap> roles) {
if (login == null) {
return false;
}
if ("admin".equalsIgnoreCase(StringUtils.trimToEmpty(login.getUsername()))) {
return true;
}
if (roles != null) {
for (InquiryRoleBizScope.RoleSnap role : roles) {
if (role == null) {
continue;
}
String code = StringUtils.trimToEmpty(role.getRoleCode());
String name = StringUtils.trimToEmpty(role.getRoleName());
if ("admin".equalsIgnoreCase(code) || "管理员".equals(name) || "超级管理员".equals(name)) {
return true;
}
}
}
try {
return SecurityUtils.getSubject().hasRole("admin")
|| SecurityUtils.getSubject().hasRole("管理员");
} catch (Exception e) {
return false;
}
}
/**
* 读账号绑定角色的名称和数据权限。失败时当没有角色,不把异常抛到列表上。
*/
private static List<InquiryRoleBizScope.RoleSnap> loadRoles(String username) {
List<InquiryRoleBizScope.RoleSnap> roles = new ArrayList<>();
if (StringUtils.isBlank(username)) {
return roles;
}
String sql = "SELECT r.role_name, r.role_code, r.description FROM sys_role r "
+ "INNER JOIN sys_user_role ur ON ur.role_id = r.id "
+ "INNER JOIN sys_user u ON u.id = ur.user_id WHERE u.username = ?";
try {
DataSource ds = SpringContextUtils.getBean(DataSource.class);
try (Connection conn = ds.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, username);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
roles.add(new InquiryRoleBizScope.RoleSnap(
rs.getString("role_name"),
rs.getString("role_code"),
rs.getString("description")));
}
}
}
} catch (Exception ignored) {
// 角色表暂时读不到时返回空,列表不因此 500。
// 上层会当成「没配业务范围」,暂不收窄;下次请求再读。
}
return roles;
}
}
@@ -0,0 +1,274 @@
package org.jeecg.modules.inquiry.support;
import org.apache.commons.lang3.StringUtils;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Pattern;
/**
* 把角色名称和角色数据权限,收成报价模板能用的业务线:空运 / 海运 / 陆运。
* <p>
* 数据权限写在 {@code sys_role.description} 的 {@code [dataPerms:…]},目录与后台
* {@code dataPermCatalog.ts} 一致。报价模板只有业务线,没有「中港 / 国内整车」这一层,
* 所以陆运线路(含中港)都归到「陆运」。
* <p>
* 纯函数,不读库、不碰登录态。超管是否放行由调用方传入。
*/
public final class InquiryRoleBizScope {
public static final String AIR = "空运";
public static final String SEA = "海运";
public static final String LAND = "陆运";
/** 角色描述里数据权限的包裹格式,与后台角色保存一致。 */
private static final String PERM_OPEN = "[dataPerms:";
/** 陆运线路叶子(写入时不带「陆运·」前缀)。 */
private static final Set<String> LAND_ROUTE_KEYS = new LinkedHashSet<String>(Arrays.asList(
"陆运全线",
"中港/中亚/中欧",
"国内长途/零担",
"国内城配/拖车/打包",
"东南亚"
));
private static final List<String> BIZ_ORDER = Arrays.asList(AIR, SEA, LAND);
/** 空运线路码,如 CGO-KUL。中港角色名不含这种码。 */
private static final Pattern AIRPORT_PAIR = Pattern.compile(".*[A-Z]{3}-[A-Z]{3}.*");
private InquiryRoleBizScope() {
}
/**
* 一个角色的名称、编码、描述。编码用于识别超管角色,描述用于读数据权限。
*/
public static final class RoleSnap {
private final String roleName;
private final String roleCode;
private final String description;
public RoleSnap(String roleName, String roleCode, String description) {
this.roleName = roleName;
this.roleCode = roleCode;
this.description = description;
}
public String getRoleName() {
return roleName;
}
public String getRoleCode() {
return roleCode;
}
public String getDescription() {
return description;
}
}
/**
* 当前账号能看的报价模板业务。
* <p>
* {@code null} 表示不限制:超管,或所有角色都看不出业务线(老账号不被误锁)。
* 非空则列表、预览、改删都只能落在这些业务里。多个角色取并集。
* 角色名已点明单一业务(如「陆运中港」)时,以角色名为准,避免功能权限开通后仍看到海陆空全部模板。
*
* @param administrator 登录名 admin、角色码 admin、或角色名「管理员」
* @param roles 该账号绑定的角色;可空
*/
public static Set<String> resolve(boolean administrator, List<RoleSnap> roles) {
if (administrator) {
return null;
}
LinkedHashSet<String> allowed = new LinkedHashSet<>();
boolean anyScoped = false;
if (roles != null) {
for (RoleSnap role : roles) {
if (role == null) {
continue;
}
Set<String> fromName = bizFromRoleName(role.getRoleName());
Set<String> fromPerms = bizFromDataPerms(role.getDescription());
// 角色名只指向一条业务线时,数据权限不再把别的业务线加进来。
// 「陆运中港」即使描述里误勾了海运/空运,报价模板也只出陆运。
if (fromName.size() == 1) {
anyScoped = true;
allowed.addAll(fromName);
continue;
}
if (!fromPerms.isEmpty()) {
anyScoped = true;
allowed.addAll(fromPerms);
continue;
}
if (!fromName.isEmpty()) {
anyScoped = true;
allowed.addAll(fromName);
}
}
}
if (!anyScoped) {
return null;
}
return allowed;
}
/**
* 界面下拉用的稳定顺序:空运、海运、陆运。{@code allowed == null} 时三项都返回。
*/
public static List<String> orderedLabels(Set<String> allowed) {
List<String> out = new ArrayList<String>(3);
for (String biz : BIZ_ORDER) {
if (allowed == null || allowed.contains(biz)) {
out.add(biz);
}
}
return out;
}
/** 给销售看的业务名,顿号拼接。空集合返回空串。 */
public static String joinLabels(Collection<String> allowed) {
Collection<String> source = allowed == null ? Collections.<String>emptySet() : allowed;
return String.join("、", orderedLabels(new LinkedHashSet<String>(source)));
}
/**
* 从角色描述取出数据权限稳定码。没有这段标记时返回空列表。
*/
public static List<String> parseDataPermKeys(String description) {
List<String> keys = new ArrayList<>();
if (StringUtils.isBlank(description)) {
return keys;
}
int open = description.indexOf(PERM_OPEN);
if (open < 0) {
return keys;
}
int close = description.indexOf(']', open);
if (close < 0) {
return keys;
}
String body = description.substring(open + PERM_OPEN.length(), close);
for (String part : body.split("\\|")) {
String key = StringUtils.trimToEmpty(part);
if (StringUtils.isNotBlank(key)) {
keys.add(key);
}
}
return keys;
}
/**
* 一条数据权限对应的业务线。系统异常通知、认不出的码返回 null,不扩大可见范围。
*/
public static String bizOfDataPermKey(String raw) {
String key = StringUtils.trimToEmpty(raw);
if (key.isEmpty() || key.contains("系统异常")) {
return null;
}
if (key.contains("空运")) {
return AIR;
}
if (key.contains("海运")) {
return SEA;
}
if (key.contains("陆运") || LAND_ROUTE_KEYS.contains(key)) {
return LAND;
}
// 剥掉「空运· / 陆运·」之后再认一次叶子(旧数据可能带前缀,上面 contains 已覆盖带前缀的)。
String leaf = key;
int dot = Math.max(key.lastIndexOf('·'), key.lastIndexOf('•'));
if (dot >= 0 && dot + 1 < key.length()) {
leaf = key.substring(dot + 1).trim();
}
if (LAND_ROUTE_KEYS.contains(leaf)) {
return LAND;
}
if (AIRPORT_PAIR.matcher(key).matches() || key.contains("外采") || key.contains("曼谷")) {
return AIR;
}
return null;
}
/**
* 角色名称里点名的业务。中港归陆运。名称同时含多条业务时都算上。
*/
public static Set<String> bizFromRoleName(String roleName) {
LinkedHashSet<String> out = new LinkedHashSet<>();
String name = StringUtils.trimToEmpty(roleName);
if (name.isEmpty()) {
return out;
}
if (name.contains("空运")) {
out.add(AIR);
}
if (name.contains("海运")) {
out.add(SEA);
}
if (name.contains("陆运") || name.contains("中港")) {
out.add(LAND);
}
return out;
}
/**
* 工单业务线归到空运 / 海运 / 陆运。
* 已写 SEA/AIR/LAND 时以它为准;空着才看中文标签。多段联运不归进任何一条。
*/
public static String classifyTicketBiz(String businessLine, String bizTypeLabel) {
String line = StringUtils.trimToEmpty(businessLine).toUpperCase(Locale.ROOT);
if ("LAND".equals(line) || line.contains("陆")) {
return LAND;
}
if ("AIR".equals(line) || line.contains("空")) {
return AIR;
}
if ("SEA".equals(line) || line.contains("海")) {
return SEA;
}
if (StringUtils.isNotBlank(line)) {
return "";
}
String label = StringUtils.trimToEmpty(bizTypeLabel);
if (label.contains("陆")) {
return LAND;
}
if (label.contains("空")) {
return AIR;
}
if (label.contains("海")) {
return SEA;
}
return "";
}
/**
* {@code allowed == null} 表示不限制。多段、认不出的工单对有范围的账号不可见。
*/
public static boolean ticketInScope(String businessLine, String bizTypeLabel, Set<String> allowed) {
if (allowed == null) {
return true;
}
String biz = classifyTicketBiz(businessLine, bizTypeLabel);
return StringUtils.isNotBlank(biz) && allowed.contains(biz);
}
private static Set<String> bizFromDataPerms(String description) {
LinkedHashSet<String> out = new LinkedHashSet<>();
for (String key : parseDataPermKeys(description)) {
String biz = bizOfDataPermKey(key);
if (biz != null) {
out.add(biz);
}
}
return out;
}
}
@@ -39,19 +39,20 @@ public final class InquiryTicketFactsSupport {
continue;
}
String val = e.getValue().trim();
if (val.isEmpty()) {
// 摘要占位「-」等于没写,禁止盖进工单起运港。
if (val.isEmpty() || isPlaceholderDash(val)) {
continue;
}
merged.put(e.getKey(), val);
}
ticket.setFactsJson(JSON.toJSONString(merged));
if (StringUtils.isNotBlank(merged.get("起运港"))) {
if (StringUtils.isNotBlank(merged.get("起运港")) && !isPlaceholderDash(merged.get("起运港"))) {
ticket.setOrigin(merged.get("起运港"));
}
if (StringUtils.isNotBlank(merged.get("目的港"))) {
if (StringUtils.isNotBlank(merged.get("目的港")) && !isPlaceholderDash(merged.get("目的港"))) {
ticket.setDestination(merged.get("目的港"));
}
if (StringUtils.isNotBlank(merged.get("品名"))) {
if (StringUtils.isNotBlank(merged.get("品名")) && !isPlaceholderDash(merged.get("品名"))) {
ticket.setGoodsName(merged.get("品名"));
}
Integer pieces = parseInt(merged.get("件数"));
@@ -114,6 +115,15 @@ public final class InquiryTicketFactsSupport {
return null;
}
/** 销售摘要占位横杠,不当真实起运/目的。 */
private static boolean isPlaceholderDash(String raw) {
if (StringUtils.isBlank(raw)) {
return false;
}
String t = raw.trim();
return "-".equals(t) || "—".equals(t) || "-".equals(t);
}
private static Integer parseInt(String raw) {
if (StringUtils.isBlank(raw)) {
return null;
@@ -178,6 +178,11 @@ public class InquiryTmsCabinService {
if (!properties.lockConfigured()) {
return fail("lock_disabled", "主账未开放锁舱");
}
// 工单报价日空/脏(如事实只有「:」)时,先拦再出站。
// 禁止用舱位起飞日顶过去后打 TMS,再回「选项不匹配」(WO202610140047)。
if (isTicketQuoteDateMissing(ticket)) {
return fail("lock_incomplete", "报价日期为空,请补充报价日期后重新锁舱");
}
Map<String, Object> req = new LinkedHashMap<>();
req.put("workOrderNo", no);
if (StringUtils.isNotBlank(quoteId)) {
@@ -187,7 +192,7 @@ public class InquiryTmsCabinService {
req.put("flightDate", firstNonBlank(
text(option, "flightDate"),
flightDateFromDeparture(text(option, "departureTime")),
quoteDate(ticket)));
effectiveTicketQuoteDate(ticket)));
req.put("origin", firstNonBlank(text(option, "originAirportCode"),
fact(ticket, "originCode"),
text(option, "origin"), ticket.getOrigin(),
@@ -241,18 +246,15 @@ public class InquiryTmsCabinService {
// 缺必填仍出站会被 TMS 1001;在此拦住并回清原因。
String incomplete = validateManualLockOutbound(req);
// 现拉仍无舱位时,改成业务补问口径(禁止堆「缺少 optionNo」技术字段名)。
// 报价日期空 → flightDate/validUntil 落不下来:优先提示补报价日期,
// 禁止被「运价库无匹配→补舱位」话术盖掉(运行证据 WO202610140005)。
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 + ")";
}
incomplete = rewriteManualLockIncompleteReason(
incomplete,
ticket,
req,
text(quote, "_lockRefreshClass"),
text(quote, "_lockRefreshMsg"));
}
if (StringUtils.isNotBlank(incomplete)) {
log.info("tms.lock incomplete wo={} optionNo={} missing={}",
@@ -893,6 +895,39 @@ public class InquiryTmsCabinService {
return "";
}
/**
* 手工锁舱校验失败话术改写。
* <p>
* 报价日期空导致 flightDate/validUntil 落不下来时,优先提示补报价日期;
* 否则现拉无舱位时改成「补舱位三项」业务口径。
*/
static String rewriteManualLockIncompleteReason(
String incomplete,
InquiryTicket ticket,
Map<String, Object> req,
String refreshClass,
String refreshMsg) {
String why = StringUtils.trimToEmpty(incomplete);
if (why.isEmpty()) {
return "";
}
// 列空、事实只有「:」等脏值,都算报价日缺失(WO047)。
if (isTicketQuoteDateMissing(ticket)) {
return "报价日期为空,请补充报价日期后重新锁舱";
}
boolean noMatch = "TMS_NO_QUOTE_1002".equals(StringUtils.trimToEmpty(refreshClass))
|| (StringUtils.isNotBlank(refreshMsg) && refreshMsg.contains("无匹配"));
if (noMatch) {
return "本单是手工报价,且运价库无匹配舱位,无法自动带出航班。"
+ "请按「AIR-OPT01 CZ 航班CZ327」补舱位、航司和航班号后再锁。"
+ "(现拉TMS:" + firstNonBlank(refreshMsg, "标准运价库无匹配报价") + ")";
}
if (StringUtils.isNotBlank(refreshMsg)) {
return why + "(现拉TMS:" + refreshMsg + ")";
}
return why;
}
/**
* MANUAL 出站必填校验。缺字段直接失败,禁止 HMAC 空壳打 TMS(会 1001)。
* 返回中文原因;齐了返回空串。
@@ -1382,6 +1417,27 @@ public class InquiryTmsCabinService {
return new SimpleDateFormat("yyyy-MM-dd").format(ticket.getQuoteDate());
}
/**
* 锁舱用的工单报价日:列优先,其次事实里能规范成 yyyy-MM-dd 的值。
* 「:」、空串、乱码不算日期。
*/
static String effectiveTicketQuoteDate(InquiryTicket ticket) {
String fromCol = quoteDate(ticket);
if (StringUtils.isNotBlank(fromCol)) {
return fromCol;
}
String fromFact = fact(ticket, "报价日期", "quoteDate", "quote_date");
String normalized = TmsV2RequestMapper.normalizeQuoteDate(fromFact);
return normalized == null ? "" : normalized;
}
/**
* 工单是否缺少可用报价日(列空且事实无合法日期)。
*/
static boolean isTicketQuoteDateMissing(InquiryTicket ticket) {
return StringUtils.isBlank(effectiveTicketQuoteDate(ticket));
}
private static String fact(InquiryTicket ticket, String... keys) {
if (ticket == null || StringUtils.isBlank(ticket.getFactsJson())) {
return "";
@@ -70,6 +70,8 @@ public class InquiryTmsQueryService {
if (ticket != null) {
mergeTicketFacts(facts, ticket);
}
// 摘要占位「-」清成空,禁止当起运地出站(运行证据 WO202610150086 → TMS 1003 originCode)。
scrubPlaceholderDashes(facts);
String line = resolveTransportLine(
body,
facts,
@@ -82,9 +84,9 @@ public class InquiryTmsQueryService {
Map<String, Object> segment = new LinkedHashMap<>();
segment.put("sequence", 1);
segment.put("transportMode", line);
String origin = text(facts.get("起运港"));
String destination = text(facts.get("目的港"));
segment.put("cargoName", text(facts.get("品名")));
String origin = placeText(facts.get("起运港"));
String destination = placeText(facts.get("目的港"));
segment.put("cargoName", placeText(facts.get("品名")));
if ("SEA".equals(line)) {
// 海运不拦空运件数/重量/体积/包装;港口只带名称,没有港口字典也不跳过。
segment.put("origin", origin);
@@ -107,9 +109,10 @@ public class InquiryTmsQueryService {
segment.put("destination", destination);
segment.putAll(TmsLandSegmentMapper.build(facts));
} else {
// 空运:地名栏留空,三字码只放 originCode / destinationCode。
segment.put("origin", "");
segment.put("destination", "");
// 空运:地名传销售原文(广西/克拉克),能投影再带 originCode/destinationCode。
// 对不上三字码不拦出站、不编假码;起运地非必填可空串。
segment.put("origin", origin);
segment.put("destination", destination);
segment.put("quantity", TmsV2RequestMapper.asInteger(facts.get("件数")));
segment.put("weightKg", TmsV2RequestMapper.asPositiveDecimal(facts.get("毛重")));
segment.put("volumeCbm", TmsV2RequestMapper.asVolumeCbm(facts.get("体积")));
@@ -173,7 +176,8 @@ public class InquiryTmsQueryService {
}
try {
Map<String, Object> tms = gateway.query(internal, cacheKey);
return classifyAndPersist(workOrderNo, tms);
Map<String, Object> classified = classifyAndPersist(workOrderNo, tms);
return classified;
} catch (RuntimeException ex) {
log.warn("tms.query tech wo={} err={}", workOrderNo, ex.getMessage());
return tech(ex.getMessage());
@@ -242,20 +246,33 @@ public class InquiryTmsQueryService {
log.info("tms.quote skip frozen wo={} status={}", workOrderNo, ticket.getStatus());
return;
}
boolean airPending = isAirCabinPendingSelection(quote);
InquiryQuote row = new InquiryQuote();
row.setTicketId(ticket.getId());
row.setWorkOrderNo(workOrderNo);
row.setQuoteVersion(1);
applySnapshotToQuoteRow(row, quote);
if (airPending) {
// 未选舱:保留 airOptions 供恢复,清空展示金额,禁止后台当成已报价。
sanitizePendingAirCabinRow(row, quote);
}
row.setCreateTime(new Date());
quoteMapper.insert(row);
List<InquiryQuote> quotes = quoteMapper.selectList(new LambdaQueryWrapper<InquiryQuote>()
.eq(InquiryQuote::getWorkOrderNo, workOrderNo)
.orderByAsc(InquiryQuote::getCreateTime));
InquiryTicketPriceSupport.apply(ticket, quotes);
if (airPending) {
ticket.setSalePrice("");
ticket.setCostPrice("");
ticket.setProfitRate("");
}
ticket.setTmsHit(1);
workOrderService.updateById(ticket);
if (TicketStatuses.canTransit(ticket.getStatus(), TicketStatuses.QUOTED)) {
// 空运备选舱位未选:保持询价中,不迁已报价(合同:选定后才出报价)。
boolean willTransit = !airPending
&& TicketStatuses.canTransit(ticket.getStatus(), TicketStatuses.QUOTED);
if (willTransit) {
TicketTransitionRequest tr = new TicketTransitionRequest();
tr.setWorkOrderNo(workOrderNo);
tr.setToStatus(TicketStatuses.QUOTED);
@@ -263,6 +280,87 @@ public class InquiryTmsQueryService {
}
}
/**
* 空运尚在选舱/选线:有 airOptions 且没有 selected_air_option。
* 此时禁止迁已报价、禁止后台展示最终费用。
*/
static boolean isAirCabinPendingSelection(Map<String, String> quote) {
if (quote == null || quote.isEmpty()) {
return false;
}
if (hasSelectedAirOption(quote)) {
return false;
}
if ("1".equals(StringUtils.trimToEmpty(quote.get("pending_cabin_pick")))) {
return true;
}
return countAirOptions(quote) >= 1;
}
static boolean hasSelectedAirOption(Map<String, String> quote) {
if (quote == null) {
return false;
}
return StringUtils.isNotBlank(quote.get("selected_air_option"))
|| StringUtils.isNotBlank(quote.get("selectedAirOption"));
}
static int countAirOptions(Map<String, String> quote) {
if (quote == null) {
return 0;
}
String airRaw = firstText(quote.get("airOptions"), quote.get("air_options"));
if (StringUtils.isBlank(airRaw)) {
return 0;
}
try {
Object parsed = airRaw.trim().startsWith("[") ? JSON.parse(airRaw) : null;
if (parsed instanceof List) {
return ((List<?>) parsed).size();
}
} catch (Exception ignored) {
return 0;
}
return 0;
}
static int countFeeLines(Map<String, String> quote) {
if (quote == null) {
return 0;
}
String fees = firstText(quote.get("fee_lines"), quote.get("fee_rows"));
if (StringUtils.isBlank(fees) || !fees.trim().startsWith("[")) {
return 0;
}
try {
List<?> fl = JSON.parseArray(fees);
return fl == null ? 0 : fl.size();
} catch (Exception ignored) {
return 0;
}
}
/**
* 未选舱落库:payload 保留 airOptions,清空 total/fee_lines/priceText。
*/
static void sanitizePendingAirCabinRow(InquiryQuote row, Map<String, String> quote) {
if (row == null) {
return;
}
row.setPriceText("");
Map<String, String> sanitized = new LinkedHashMap<>();
if (quote != null) {
sanitized.putAll(quote);
}
sanitized.put("pending_cabin_pick", "1");
sanitized.put("fee_lines", "[]");
sanitized.put("total", "");
sanitized.remove("selected_air_option");
sanitized.remove("selectedAirOption");
row.setPayloadJson(JSON.toJSONString(sanitized));
}
/**
* 把 TMS 规范化回包投影成智能体 quote。单测可直接调。
* 金额保持数字原文;币种单独落 currency / fee_lines.currency,禁止默认 CNY。
@@ -619,6 +717,14 @@ public class InquiryTmsQueryService {
* 不新插 TMS 行,避免后台出现两条「TMS标准报价」。已冻结工单拒绝。
*/
public Map<String, Object> replaceTmsQuote(String workOrderNo, Map<String, Object> incoming) {
return replaceTmsQuote(workOrderNo, incoming, null);
}
/**
* 覆盖 TMS 快照。toStatus=询价中 或空运未选舱时,只落备选、不迁已报价。
* 选定后(有 selected_air_option / 投影完成)才迁已报价。
*/
public Map<String, Object> replaceTmsQuote(String workOrderNo, Map<String, Object> incoming, String toStatus) {
Map<String, Object> out = new LinkedHashMap<>();
if (StringUtils.isBlank(workOrderNo) || incoming == null || incoming.isEmpty()) {
out.put("ok", false);
@@ -642,6 +748,8 @@ public class InquiryTmsQueryService {
}
Map<String, String> quote = stringifyQuote(incoming);
applyLandSegmentTotals(quote);
boolean airPending = isAirCabinPendingSelection(quote)
|| TicketStatuses.INQUIRING.equals(StringUtils.trimToEmpty(toStatus));
InquiryQuote row = quoteMapper.selectOne(new LambdaQueryWrapper<InquiryQuote>()
.eq(InquiryQuote::getWorkOrderNo, workOrderNo)
.eq(InquiryQuote::getSource, "TMS")
@@ -656,6 +764,9 @@ public class InquiryTmsQueryService {
row.setCreateTime(new Date());
}
applySnapshotToQuoteRow(row, quote);
if (airPending) {
sanitizePendingAirCabinRow(row, quote);
}
row.setUpdateTime(new Date());
if (insert) {
quoteMapper.insert(row);
@@ -666,13 +777,21 @@ public class InquiryTmsQueryService {
.eq(InquiryQuote::getWorkOrderNo, workOrderNo)
.orderByAsc(InquiryQuote::getCreateTime));
InquiryTicketPriceSupport.apply(ticket, quotes);
if (airPending) {
ticket.setSalePrice("");
ticket.setCostPrice("");
ticket.setProfitRate("");
}
ticket.setTmsHit(1);
workOrderService.updateById(ticket);
if (TicketStatuses.canTransit(ticket.getStatus(), TicketStatuses.QUOTED)) {
boolean wantQuoted = !airPending
&& (StringUtils.isBlank(toStatus) || TicketStatuses.QUOTED.equals(toStatus.trim()));
if (wantQuoted && TicketStatuses.canTransit(ticket.getStatus(), TicketStatuses.QUOTED)) {
TicketTransitionRequest tr = new TicketTransitionRequest();
tr.setWorkOrderNo(workOrderNo);
tr.setToStatus(TicketStatuses.QUOTED);
workOrderService.transition(tr, "agent");
ticket.setStatus(TicketStatuses.QUOTED);
}
out.put("ok", true);
out.put("quote_version", row.getQuoteVersion() == null ? 1 : row.getQuoteVersion());
@@ -704,8 +823,39 @@ public class InquiryTmsQueryService {
return quote;
}
private static boolean blankOrDash(String raw) {
return StringUtils.isBlank(raw) || "-".equals(raw.trim());
/**
* 空串或摘要占位横杠(- / — / -)。
* 横杠只用于销售展示「没写」,不能当起运地/目的地原文出站。
*/
static boolean blankOrDash(String raw) {
if (StringUtils.isBlank(raw)) {
return true;
}
String t = raw.trim();
return "-".equals(t) || "—".equals(t) || "-".equals(t);
}
/**
* 查价 facts 里的占位横杠清掉,避免 merge 工单列后又把「-」打给 TMS。
*/
static void scrubPlaceholderDashes(Map<String, Object> facts) {
if (facts == null || facts.isEmpty()) {
return;
}
for (String key : new String[] {
"起运港", "起运地", "目的港", "目的地", "品名", "包装方式", "包装类型", "报价日期"
}) {
Object raw = facts.get(key);
if (raw != null && blankOrDash(String.valueOf(raw))) {
facts.remove(key);
}
}
}
/** 地名/品名出站:横杠当空串。 */
static String placeText(Object value) {
String t = text(value);
return blankOrDash(t) ? "" : t;
}
/**
@@ -1408,7 +1558,7 @@ public class InquiryTmsQueryService {
}
/**
* 空运出站齐套检查。地名栏允许空串,禁止再拿 destination 拦查价。
* 空运出站齐套检查。起运地/目的地原文可空,禁止拿地名栏拦查价。
* 报价日期非必填,空着也算出站齐套。
*/
static List<String> missingAirQuery(String workOrderNo, String quoteDate, Map<String, Object> segment) {
@@ -1542,7 +1692,8 @@ public class InquiryTmsQueryService {
}
private static void putIfBlank(Map<String, Object> facts, String key, String val) {
if (blank(facts.get(key)) && StringUtils.isNotBlank(val)) {
// 工单列若是「-」,禁止再补进查价 facts。
if (blank(facts.get(key)) && StringUtils.isNotBlank(val) && !blankOrDash(val)) {
facts.put(key, val);
}
}
@@ -79,16 +79,19 @@ public final class TmsV2RequestMapper {
mode = parentMode;
}
put(target, "transportMode", mode);
// 空运/海运/陆运:地名传销售原文;空运另带能投影出的三字码。
// 空运起运地非必填,原文空时仍留空串给 TMS,禁止当「没值」删掉。
Object originRaw = first(source, "origin");
Object destRaw = first(source, "destination");
if ("AIR".equalsIgnoreCase(String.valueOf(mode == null ? "" : mode))) {
// 空运地名栏必须空串,三字码只放 originCode / destinationCode。
target.put("origin", "");
target.put("destination", "");
target.put("origin", originRaw == null ? "" : originRaw);
target.put("destination", destRaw == null ? "" : destRaw);
put(target, "originCode", first(source, "originCode"));
put(target, "destinationCode", first(source, "destinationCode"));
} else {
put(target, "origin", first(source, "origin"));
put(target, "origin", originRaw);
put(target, "originCode", first(source, "originCode"));
put(target, "destination", first(source, "destination"));
put(target, "destination", destRaw);
put(target, "destinationCode", first(source, "destinationCode"));
}
put(target, "cargoName", first(source, "cargoName", "goodsName"));
@@ -330,7 +333,7 @@ public final class TmsV2RequestMapper {
}
private static Map<String, Object> compact(Map<String, Object> value) {
// 空运地名栏要留空串给 TMS,禁止把 origin / destination 当成「没值」删掉。
// 空运起运地可空:空串仍留给 TMS,禁止把 origin / destination 当成「没值」删掉。
value.entrySet().removeIf(e -> {
Object current = e.getValue();
if (current == null) {
@@ -0,0 +1,106 @@
package org.jeecg.modules.inquiry.support;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 报价模板业务范围:陆运中港角色只落到「陆运」,不连库。
*/
public class InquiryRoleBizScopeTest {
@Test
public void landHongKongRoleNameShowsOnlyLandTemplates() {
Set<String> allowed = InquiryRoleBizScope.resolve(false, Collections.singletonList(
new InquiryRoleBizScope.RoleSnap("陆运中港", "land_hk", "")));
assertEquals(Collections.singleton(InquiryRoleBizScope.LAND), allowed);
assertEquals(Collections.singletonList("陆运"), InquiryRoleBizScope.orderedLabels(allowed));
}
@Test
public void landRouteDataPermMapsToLandEvenIfRoleNameIsGeneric() {
Set<String> allowed = InquiryRoleBizScope.resolve(false, Collections.singletonList(
new InquiryRoleBizScope.RoleSnap("销售", "sales",
"[dataPerms:中港/中亚/中欧|东南亚]")));
assertEquals(Collections.singleton(InquiryRoleBizScope.LAND), allowed);
}
@Test
public void namedLandRoleIgnoresExtraSeaAndAirDataPerms() {
Set<String> allowed = InquiryRoleBizScope.resolve(false, Collections.singletonList(
new InquiryRoleBizScope.RoleSnap("陆运中港", "land_hk",
"[dataPerms:中港/中亚/中欧|海运全线|空运全线]")));
assertEquals(Collections.singleton(InquiryRoleBizScope.LAND), allowed);
}
@Test
public void airAndSeaPermsStaySplitWhenRoleNameHasNoBusiness() {
Set<String> allowed = InquiryRoleBizScope.resolve(false, Collections.singletonList(
new InquiryRoleBizScope.RoleSnap("线路专员", "route",
"[dataPerms:CGO-KUL|海运全线]")));
assertEquals(new HashSet<String>(Arrays.asList(
InquiryRoleBizScope.AIR, InquiryRoleBizScope.SEA)), allowed);
assertEquals(Arrays.asList("空运", "海运"), InquiryRoleBizScope.orderedLabels(allowed));
}
@Test
public void twoRolesUnionTheirBusinessLines() {
Set<String> allowed = InquiryRoleBizScope.resolve(false, Arrays.asList(
new InquiryRoleBizScope.RoleSnap("陆运中港", "land_hk", ""),
new InquiryRoleBizScope.RoleSnap("空运专员", "air", "[dataPerms:空运全线]")));
assertEquals(new HashSet<String>(Arrays.asList(
InquiryRoleBizScope.LAND, InquiryRoleBizScope.AIR)), allowed);
}
@Test
public void adminAndUnscopedRoleAreNotFiltered() {
assertNull(InquiryRoleBizScope.resolve(true, Collections.singletonList(
new InquiryRoleBizScope.RoleSnap("陆运中港", "land_hk", ""))));
assertNull(InquiryRoleBizScope.resolve(false, Collections.singletonList(
new InquiryRoleBizScope.RoleSnap("运营", "ops", ""))));
assertEquals(Arrays.asList("空运", "海运", "陆运"), InquiryRoleBizScope.orderedLabels(null));
}
@Test
public void systemNoticeAloneDoesNotOpenAllLines() {
Set<String> allowed = InquiryRoleBizScope.resolve(false, Collections.singletonList(
new InquiryRoleBizScope.RoleSnap("陆运中港", "land_hk",
"[dataPerms:系统异常·消息通知]")));
assertEquals(Collections.singleton(InquiryRoleBizScope.LAND), allowed);
assertNull(InquiryRoleBizScope.resolve(false, Collections.singletonList(
new InquiryRoleBizScope.RoleSnap("运维", "ops",
"[dataPerms:系统异常·消息通知]"))));
}
@Test
public void airportAndBangkokKeysAreAir() {
assertEquals(InquiryRoleBizScope.AIR, InquiryRoleBizScope.bizOfDataPermKey("HKG-KUL"));
assertEquals(InquiryRoleBizScope.AIR, InquiryRoleBizScope.bizOfDataPermKey("国内-曼谷(BKK 外采)"));
assertEquals(InquiryRoleBizScope.SEA, InquiryRoleBizScope.bizOfDataPermKey("海运全线"));
assertEquals(InquiryRoleBizScope.LAND, InquiryRoleBizScope.bizOfDataPermKey("国内长途/零担"));
List<String> keys = InquiryRoleBizScope.parseDataPermKeys("[dataPerms:中港/中亚/中欧|空运全线]");
assertTrue(keys.contains("中港/中亚/中欧"));
}
@Test
public void landRoleCannotSeeAirOrSeaTickets() {
Set<String> land = Collections.singleton(InquiryRoleBizScope.LAND);
assertTrue(InquiryRoleBizScope.ticketInScope("LAND", "陆运", land));
assertTrue(InquiryRoleBizScope.ticketInScope("", "陆运", land));
assertFalse(InquiryRoleBizScope.ticketInScope("AIR", "空运", land));
assertFalse(InquiryRoleBizScope.ticketInScope("SEA", "海运", land));
assertFalse(InquiryRoleBizScope.ticketInScope("MULTI", "多段联运", land));
assertTrue(InquiryRoleBizScope.ticketInScope("AIR", "空运", null));
assertEquals(InquiryRoleBizScope.LAND, InquiryRoleBizScope.classifyTicketBiz("land", ""));
assertEquals("", InquiryRoleBizScope.classifyTicketBiz("MULTI", "陆运"));
}
}
@@ -2683,6 +2683,21 @@ public class QuoteTemplateFillTest {
}
}
/**
* 报价单「报价日期」格用工单创建日,不拿询价事实里的报价日期(TMS 用)。
*/
@Test
public void buildFillFactsQuoteDatePrefersTicketCreateDate() {
Map<String, Object> facts = new HashMap<>();
facts.put("报价日期", "2026-10-18");
facts.put("ticket_create_date", "2026-10-15");
facts.put("起运港", "广西");
facts.put("目的港", "肇庆");
JSONObject out = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl
.buildFillFacts("WO202610150031", facts, new HashMap<>(), "WuJiLin", "AIR");
assertEquals("2026-10-15", out.getString("quote_date"));
}
/**
* 业务员写中文名,不写企微账号。客户名称只留销售填的,账号不能占这一格。
*/
@@ -350,6 +350,33 @@ public class InquiryTmsCabinServiceTest {
assertEquals("", InquiryTmsCabinService.validateManualLockOutbound(ok));
}
@Test
public void rewriteIncompletePrefersEmptyQuoteDateOverNoMatchCabinTip() {
org.jeecg.modules.inquiry.entity.InquiryTicket ticket =
new org.jeecg.modules.inquiry.entity.InquiryTicket();
ticket.setFactsJson("{}");
Map<String, Object> req = new HashMap<>();
req.put("flightDate", "");
req.put("validUntil", "");
String raw = "锁舱参数不完整,缺少:有效期validUntil。请按「AIR-OPT01 CZ 航班CZ327」补舱位、航司和航班号后再锁。";
String why = InquiryTmsCabinService.rewriteManualLockIncompleteReason(
raw, ticket, req, "TMS_NO_QUOTE_1002", "标准运价库无匹配报价");
assertEquals("报价日期为空,请补充报价日期后重新锁舱", why);
}
@Test
public void colonOnlyFactQuoteDateCountsAsMissing() {
org.jeecg.modules.inquiry.entity.InquiryTicket ticket =
new org.jeecg.modules.inquiry.entity.InquiryTicket();
ticket.setFactsJson("{\"报价日期\":\":\"}");
org.junit.jupiter.api.Assertions.assertTrue(InquiryTmsCabinService.isTicketQuoteDateMissing(ticket));
assertEquals("", InquiryTmsCabinService.effectiveTicketQuoteDate(ticket));
ticket.setFactsJson("{\"报价日期\":\"2026-10-14\"}");
org.junit.jupiter.api.Assertions.assertFalse(InquiryTmsCabinService.isTicketQuoteDateMissing(ticket));
assertEquals("2026-10-14", InquiryTmsCabinService.effectiveTicketQuoteDate(ticket));
}
@Test
public void airlineCodeOrBlankRejectsChineseName() {
assertEquals("CZ", InquiryTmsCabinService.airlineCodeOrBlank("CZ"));
@@ -77,7 +77,7 @@ public class InquiryTmsQueryServiceTest {
@Test
public void airQueryAllowsEmptyPlaceNames() {
// 地名栏空串不能拦出站;三字码和包装原文齐了就要打 TMS。
// 起运地可空不能拦出站;包装与货量齐了就要打 TMS。
java.util.Map<String, Object> segment = new java.util.HashMap<String, Object>();
segment.put("origin", "");
segment.put("destination", "");
@@ -93,6 +93,38 @@ public class InquiryTmsQueryServiceTest {
org.junit.jupiter.api.Assertions.assertTrue(missing.isEmpty(), String.valueOf(missing));
}
@Test
public void scrubPlaceholderDashesClearsOriginDash() {
// 摘要「-」必须清掉,否则 TMS 当起飞机场去匹配会 1003。
java.util.Map<String, Object> facts = new java.util.HashMap<String, Object>();
facts.put("起运港", "-");
facts.put("目的港", "KUL");
facts.put("品名", "书籍");
InquiryTmsQueryService.scrubPlaceholderDashes(facts);
org.junit.jupiter.api.Assertions.assertFalse(facts.containsKey("起运港"));
org.junit.jupiter.api.Assertions.assertEquals("KUL", facts.get("目的港"));
org.junit.jupiter.api.Assertions.assertEquals("", InquiryTmsQueryService.placeText("-"));
org.junit.jupiter.api.Assertions.assertEquals("上海", InquiryTmsQueryService.placeText("上海"));
org.junit.jupiter.api.Assertions.assertTrue(InquiryTmsQueryService.blankOrDash("—"));
}
@Test
public void airQueryAllowsGuangxiOriginalWithoutOriginCode() {
// 销售写广西:原文出站,对不上三字码也不拦。
java.util.Map<String, Object> segment = new java.util.HashMap<String, Object>();
segment.put("origin", "广西");
segment.put("destination", "克拉克");
segment.put("destinationCode", "CRK");
segment.put("cargoName", "衣服");
segment.put("quantity", 3);
segment.put("weightKg", "1.5");
segment.put("volumeCbm", "100");
segment.put("packageType", "托盘");
java.util.List<String> missing = InquiryTmsQueryService.missingAirQuery(
"WO202610140054", null, segment);
org.junit.jupiter.api.Assertions.assertTrue(missing.isEmpty(), String.valueOf(missing));
}
@Test
public void airQueryAllowsBlankQuoteDate() {
// 空运没填报价日期也算出站齐套,不能再补成当天才放行。
@@ -187,7 +187,7 @@ public class TmsV2RequestMapperTest {
assertEquals("东莞仓库", out.get("placeOfReceipt"));
assertEquals("马尼拉", out.get("deliveryAddress"));
assertEquals("", out.get("origin"));
assertEquals("", out.get("destination"));
assertEquals("克拉克", out.get("destination"));
assertEquals("CRK", out.get("destinationCode"));
assertFalse(out.containsKey("transportTerms"));
assertFalse(out.containsKey("tradeTerms"));
@@ -221,12 +221,38 @@ public class TmsV2RequestMapperTest {
}
@Test
public void airPlaceNamesStayEmptyCodesOnlyAndRawPackage() {
// 地名栏必须空串;三字码只放 *Code。包装原文原样,禁止编成 2。
public void airPlaceNamesKeepOriginalAndCodesWithRawPackage() {
// 地名传销售原文;三字码另放 *Code。包装原文原样,禁止编成 2。
Map<String, Object> seg = new HashMap<>();
seg.put("origin", "ZUH");
seg.put("origin", "广西");
seg.put("destination", "克拉克");
seg.put("destinationCode", "CRK");
seg.put("cargoName", "衣服");
seg.put("quantity", "3");
seg.put("weightKg", "1.5");
seg.put("volumeCbm", "100");
seg.put("packageType", "托盘");
Map<String, Object> internal = new HashMap<>();
internal.put("transportMode", "AIR");
internal.put("quoteDate", "2026-09-25");
internal.put("segments", Collections.singletonList(seg));
Map<String, Object> body = TmsV2RequestMapper.quote(internal);
@SuppressWarnings("unchecked")
List<Map<String, Object>> segs = (List<Map<String, Object>>) body.get("segments");
Map<String, Object> out = segs.get(0);
assertEquals("广西", out.get("origin"));
assertNull(out.get("originCode"));
assertEquals("克拉克", out.get("destination"));
assertEquals("CRK", out.get("destinationCode"));
assertEquals("托盘", out.get("packageType"));
}
@Test
public void airCodesStillPassWithOriginalNames() {
Map<String, Object> seg = new HashMap<>();
seg.put("origin", "珠海");
seg.put("originCode", "ZUH");
seg.put("destination", "CRK");
seg.put("destination", "克拉克");
seg.put("destinationCode", "CRK");
seg.put("cargoName", "茶叶");
seg.put("quantity", "1");
@@ -241,9 +267,9 @@ public class TmsV2RequestMapperTest {
@SuppressWarnings("unchecked")
List<Map<String, Object>> segs = (List<Map<String, Object>>) body.get("segments");
Map<String, Object> out = segs.get(0);
assertEquals("", out.get("origin"));
assertEquals("珠海", out.get("origin"));
assertEquals("ZUH", out.get("originCode"));
assertEquals("", out.get("destination"));
assertEquals("克拉克", out.get("destination"));
assertEquals("CRK", out.get("destinationCode"));
assertEquals("托盘", out.get("packageType"));
}
@@ -5,6 +5,7 @@
* - 版式由上传 Excel 决定;列表展示「映射点数」而非假占位符
* - 预览:原表截图(LibreOffice 转图)
* - 解析状态列可点:弹窗看已解析 / 未解析清单
* - 业务下拉和列表按主账返回的 allowedBizTypes 收窄:陆运角色不出现海运、空运
*/
import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
import { Modal, message } from "ant-design-vue";
@@ -55,6 +56,29 @@ const keyword = ref("");
const bizFilter = ref("");
const statusFilter = ref("");
/** 报价模板只有这三条业务。主账按角色收窄后,下拉只留账号能看的。 */
const ALL_BIZ = ["空运", "海运", "陆运"] as const;
type BizName = (typeof ALL_BIZ)[number];
const bizOptions = ref<BizName[]>([...ALL_BIZ]);
function defaultBiz(): TemplateRow["bizType"] {
return bizOptions.value[0] || "海运";
}
/** 主账没带回范围时保持三项,避免旧接口把下拉清空。 */
function applyAllowedBiz(raw: unknown) {
const incoming = Array.isArray(raw) ? raw.map((item) => String(item)) : [];
const next = ALL_BIZ.filter((biz) => incoming.includes(biz));
if (!next.length) return;
bizOptions.value = [...next];
if (bizFilter.value && !bizOptions.value.includes(bizFilter.value as BizName)) {
bizFilter.value = "";
}
if (!bizOptions.value.includes(form.bizType as BizName)) {
form.bizType = defaultBiz();
}
}
const formOpen = ref(false);
const editing = ref<TemplateRow | null>(null);
const previewOpen = ref(false);
@@ -364,6 +388,7 @@ async function loadList() {
pageSize: 200,
});
const records = res?.records || res?.result?.records || [];
applyAllowedBiz(res?.allowedBizTypes ?? res?.result?.allowedBizTypes);
rows.value = (records as any[]).map(mapApiRow);
scheduleParsePoll();
} catch (e: any) {
@@ -397,6 +422,7 @@ function scheduleParsePoll() {
pageSize: 200,
});
const records = res?.records || res?.result?.records || [];
applyAllowedBiz(res?.allowedBizTypes ?? res?.result?.allowedBizTypes);
rows.value = (records as any[]).map(mapApiRow);
} catch {
/* 轮询失败不打断页面 */
@@ -420,7 +446,7 @@ onUnmounted(() => {
function resetForm() {
Object.assign(form, {
name: "",
bizType: "海运" as TemplateRow["bizType"],
bizType: defaultBiz(),
mode: "auto" as const,
keywordItems: [""],
isDefault: false,
@@ -708,9 +734,7 @@ async function downloadPreviewOrOriginal() {
</div>
<select v-model="bizFilter" @change="loadList">
<option value="">全部业务</option>
<option value="空运">空运</option>
<option value="海运">海运</option>
<option value="陆运">陆运</option>
<option v-for="biz in bizOptions" :key="biz" :value="biz">{{ biz }}</option>
</select>
<select v-model="statusFilter" @change="loadList">
<option value="">全部状态</option>
@@ -874,9 +898,7 @@ async function downloadPreviewOrOriginal() {
<div class="qt-create-row">
<label>业务</label>
<select v-model="form.bizType" class="field">
<option value="海运">海运</option>
<option value="空运">空运</option>
<option value="陆运">陆运</option>
<option v-for="biz in bizOptions" :key="biz" :value="biz">{{ biz }}</option>
</select>
</div>
<div class="qt-create-row qt-create-row--keywords">
@@ -2,6 +2,7 @@
/**
* 询价工单列表:对接主账 /inquiry/workOrder。
* 六态关单/重试必须调主账;禁止本地改 status 冒充成功。
* 业务下拉按主账 allowedBizTypes 收窄:陆运角色不出现海运、空运。
* 详情/关单弹窗必须用 AdminModal:本工程 ant-design-vue 3 的 a-modal 只认 visible,
* v-model:open 不会弹出(账号/角色页已统一走 AdminModal)。
*/
@@ -112,6 +113,20 @@ const filters = reactive({
dateTo: "",
});
/** 工单业务下拉。主账按角色收窄后,陆运账号不再出现海运、空运、多段。 */
const ALL_TICKET_BIZ = ["空运", "海运", "陆运", "多段联运"] as const;
const ticketBizOptions = ref<string[]>([...ALL_TICKET_BIZ]);
function applyTicketBizOptions(raw: unknown) {
const incoming = Array.isArray(raw) ? raw.map((item) => String(item)) : [];
const next = ALL_TICKET_BIZ.filter((biz) => incoming.includes(biz));
if (!next.length) return;
ticketBizOptions.value = [...next];
if (filters.bizType && !ticketBizOptions.value.includes(filters.bizType)) {
filters.bizType = "";
}
}
const detailOpen = ref(false);
const closeOpen = ref(false);
const current = ref<TicketRow | null>(null);
@@ -460,6 +475,20 @@ function requirementSummaryLines(t: any): string[] {
return lines;
}
/**
* 空运尚未选定舱位/线路:payload 有 airOptions 且无 selected,或 pending_cabin_pick。
* 此时后台不应展示 TMS 费用,避免「未选舱却已报价」。
*/
function isPendingAirCabinQuote(q: any): boolean {
const payload = asObject(q?.payloadJson) || asObject(q?.payload) || {};
if (String(payload.pending_cabin_pick || "") === "1") return true;
const selected =
asObject(payload.selected_air_option) || asObject(payload.selectedAirOption);
if (selected) return false;
const opts = asFeeArray(payload.airOptions || payload.air_options);
return opts.length > 0;
}
function mapTicket(t: any, quotes: any[] = [], attachments: any[] = [], timeline: any[] = []): TicketRow {
const attachRows: TicketAttach[] = (attachments || [])
.map((a) => ({
@@ -479,7 +508,9 @@ function mapTicket(t: any, quotes: any[] = [], attachments: any[] = [], timeline
initiator,
demandTitle: reqLines[0] || "—",
demandDetail: reqLines.slice(1).join("\n"),
quotes: (quotes || []).map((q) => ({
quotes: (quotes || [])
.filter((q) => !isPendingAirCabinQuote(q))
.map((q) => ({
source: quoteSourceLabel(q),
price: q.priceText || "-",
time: fmtTime(q.createTime),
@@ -587,6 +618,7 @@ async function loadList() {
});
const page = res?.records ? res : res?.result || {};
const records = page.records || [];
applyTicketBizOptions(page.allowedBizTypes);
total.value = Number(page.total || records.length || 0);
rows.value = (records as any[]).map((t) => mapTicket(t, t.quotes || []));
// 删减后当前页可能越界,回退到最后一页
@@ -766,10 +798,7 @@ async function exportTickets() {
</select>
<select v-model="filters.bizType">
<option value="">业务类型 · 全部</option>
<option value="空运">空运</option>
<option value="海运">海运</option>
<option value="陆运">陆运</option>
<option value="多段联运">多段联运</option>
<option v-for="biz in ticketBizOptions" :key="biz" :value="biz">{{ biz }}</option>
</select>
<div class="toolbar-date-range" title="按创建时间筛选">
<a-range-picker
+4
View File
@@ -105,6 +105,10 @@ NormalizedInbound
实现与 `inquiry-required-fields-v1.json` 等价的条件激活(JSON 放在新仓库)。SEA 箱型箱量保持单原始字段 + B 模型投影 `containerItems`。协同点击用不可变询价原话重跑 B,去重补问。
## 违禁品(建单前)
企微私聊里,海运、空运、陆运、多段都一样:工单还没建成时,当前这句话、还没改口的原话,以及已经认定的品名、起运地、备注(含表格、图片、文件认出来的字)里出现枪支、弹药、爆炸物、毒品,只回「涉及危险品,无法发起报价。」,不建工单,后台看不到这票,这一轮也不追问缺项。同一条私聊里直接把品名改成普通货后,前面没落进字段的那句不再算;这句里仍写着违禁词,或起运地、备注里还留着,继续拦。只补重量清不掉。本句已无违禁词且是另开一票(如「查一下目的地吉隆坡的费用」),上一句违禁原话作废,可正常询价。水枪、玩具枪、仿真枪、气枪、烟花、礼炮、鞭炮可以报价。化学品、电池、油品以及「是否为危险品:是」仍按原询价走。群聊不拦。工单已经建成之后不拦。英文、拼音、谐音这一轮不拦。
## 出站
- 主账:登录或 Token,所有写操作要幂等键(message_id / request_id / quoteVersion)。