Compare commits

...
57 changed files with 4085 additions and 403 deletions
+1 -1
View File
@@ -118,7 +118,7 @@ async def _accept_upload_files(
return HTMLResponse(expired_upload_page(), status_code=410) return HTMLResponse(expired_upload_page(), status_code=410)
if phase in {"reject_type", "need_file"}: if phase in {"reject_type", "need_file"}:
return HTMLResponse(render_upload_message("无法上传", hint), status_code=400) return HTMLResponse(render_upload_message("无法上传", hint), status_code=400)
return HTMLResponse(render_upload_message("上传成功", copy.ATTACH_H5_OK)) return HTMLResponse(render_upload_message("上传成功", copy.attach_h5_ok()))
@router.get("/upload/{token}", response_class=HTMLResponse) @router.get("/upload/{token}", response_class=HTMLResponse)
@@ -319,4 +319,4 @@ def render_upload_message(title: str, body: str, *, expired: bool = False) -> st
def expired_upload_page() -> str: def expired_upload_page() -> str:
return render_upload_message("链接已过期", copy.ATTACH_H5_EXPIRED, expired=True) return render_upload_message("链接已过期", copy.attach_h5_expired(), expired=True)
@@ -207,7 +207,7 @@ def join_attachment_image(
if not wave.ack_sent: if not wave.ack_sent:
msg_store.enqueue_outbound( msg_store.enqueue_outbound(
touser=message.sender_id, touser=message.sender_id,
content=copy.IMAGE_RECOGNIZING, content=copy.image_recognizing(),
dedupe_key=f"attach-ack:{wave.wave_id}", dedupe_key=f"attach-ack:{wave.wave_id}",
) )
book.mark_ack(message.sender_id, "image") book.mark_ack(message.sender_id, "image")
@@ -258,7 +258,7 @@ def accept_h5_uploads(
book = get_attach_wave_book() book = get_attach_wave_book()
wave = book.active(sid, now=stamp) wave = book.active(sid, now=stamp)
if wave is None: if wave is None:
return "expired", copy.ATTACH_H5_EXPIRED return "expired", copy.attach_h5_expired()
incoming = [(str(n or "").strip(), raw or b"") for n, raw in files if str(n or "").strip()] incoming = [(str(n or "").strip(), raw or b"") for n, raw in files if str(n or "").strip()]
if not incoming: if not incoming:
return "need_file", copy.ATTACH_H5_NEED_FILE return "need_file", copy.ATTACH_H5_NEED_FILE
@@ -268,12 +268,12 @@ def accept_h5_uploads(
start_index = len(wave.materials) start_index = len(wave.materials)
wave = book.add_files(sid, [n for n, _r in incoming], now=stamp) wave = book.add_files(sid, [n for n, _r in incoming], now=stamp)
if wave is None: if wave is None:
return "expired", copy.ATTACH_H5_EXPIRED return "expired", copy.attach_h5_expired()
msg_store = store or get_message_store() msg_store = store or get_message_store()
if not wave.ack_sent: if not wave.ack_sent:
msg_store.enqueue_outbound( msg_store.enqueue_outbound(
touser=sid, touser=sid,
content=copy.ATTACH_RECOGNIZING, content=copy.attach_recognizing(),
dedupe_key=f"attach-ack:{wave.wave_id}", dedupe_key=f"attach-ack:{wave.wave_id}",
) )
book.mark_ack(sid, "file") book.mark_ack(sid, "file")
@@ -304,8 +304,8 @@ def accept_h5_uploads(
wave = book.active(sid, now=stamp) wave = book.active(sid, now=stamp)
if process_now and wave is not None and book.pending(wave) <= 0: if process_now and wave is not None and book.pending(wave) <= 0:
finish_attach_wave(sid, store=msg_store) finish_attach_wave(sid, store=msg_store)
return "done", copy.ATTACH_H5_OK return "done", copy.attach_h5_ok()
return "recognizing", copy.ATTACH_H5_OK return "recognizing", copy.attach_h5_ok()
def finish_queued_attachment(payload: dict[str, Any]) -> dict[str, Any]: def finish_queued_attachment(payload: dict[str, Any]) -> dict[str, Any]:
@@ -403,7 +403,7 @@ def finish_attach_wave(
oral = book.compose_oral(sender_id) oral = book.compose_oral(sender_id)
reason = "" reason = ""
if not oral.strip(): if not oral.strip():
reason = copy.ATTACH_TECH_FAIL if book.has_errors(sender_id) else copy.ATTACH_UNREADABLE reason = copy.attach_tech_fail() if book.has_errors(sender_id) else copy.ATTACH_UNREADABLE
msg_store = store or get_message_store() msg_store = store or get_message_store()
prefix_used = {"v": False} prefix_used = {"v": False}
@@ -441,7 +441,7 @@ def finish_attach_wave(
if reason and not prefix_used["v"]: if reason and not prefix_used["v"]:
msg_store.enqueue_outbound( msg_store.enqueue_outbound(
touser=sender_id, touser=sender_id,
content=copy.image_fail_then_clarify(reason, copy.ASK_TRANSPORT), content=copy.image_fail_then_clarify(reason, copy.ask_transport()),
dedupe_key=f"attach-empty:{(wave.wave_id if wave else inbound.message_id)}", dedupe_key=f"attach-empty:{(wave.wave_id if wave else inbound.message_id)}",
) )
phase = "need_mode" phase = "need_mode"
@@ -79,7 +79,7 @@ def handle_image_inquiry(
drop_all_private_bookmarks(message.sender_id) drop_all_private_bookmarks(message.sender_id)
msg_store.enqueue_outbound( msg_store.enqueue_outbound(
touser=message.sender_id, touser=message.sender_id,
content=copy.IMAGE_RECOGNIZING, content=copy.image_recognizing(),
dedupe_key=f"img-ack:{wave.wave_id}", dedupe_key=f"img-ack:{wave.wave_id}",
) )
book.mark_ack(message.sender_id) book.mark_ack(message.sender_id)
@@ -341,7 +341,7 @@ def finish_wave(
oral = book.compose_oral(sender_id) oral = book.compose_oral(sender_id)
reason = "" reason = ""
if not oral.strip(): if not oral.strip():
reason = copy.IMAGE_TECH_FAIL if (wave and wave.errors) else copy.IMAGE_UNREADABLE reason = copy.image_tech_fail() if (wave and wave.errors) else copy.IMAGE_UNREADABLE
msg_store = store or get_message_store() msg_store = store or get_message_store()
prefix_used = {"v": False} prefix_used = {"v": False}
@@ -373,7 +373,7 @@ def finish_wave(
if reason and not prefix_used["v"]: if reason and not prefix_used["v"]:
msg_store.enqueue_outbound( msg_store.enqueue_outbound(
touser=sender_id, touser=sender_id,
content=copy.image_fail_then_clarify(reason, copy.ASK_TRANSPORT), content=copy.image_fail_then_clarify(reason, copy.ask_transport()),
dedupe_key=f"img-empty:{inbound.message_id}", dedupe_key=f"img-empty:{inbound.message_id}",
) )
phase = "need_mode" phase = "need_mode"
+1 -12
View File
@@ -478,18 +478,7 @@ def handle_text_other(message: InboundMessage, *, store=None) -> str:
logger.info("text_other 陆运确定/重查,改走询价流程 sender=%s", message.sender_id) logger.info("text_other 陆运确定/重查,改走询价流程 sender=%s", message.sender_id)
return handle_text_inquiry(message, store=msg_store) return handle_text_inquiry(message, store=msg_store)
from agent.schema.land_options import looks_like_confirm body = copy.first_chat()
if looks_like_confirm(message.content or ""):
body = copy.LAND_CONFIRM_EXPIRED
msg_store.enqueue_outbound(
touser=message.sender_id,
content=body,
dedupe_key=f"land-confirm-expired:{message.message_id}",
)
return "land_confirm_expired"
body = copy.FIRST_CHAT
msg_store.enqueue_outbound( msg_store.enqueue_outbound(
touser=message.sender_id, touser=message.sender_id,
content=body, content=body,
+9
View File
@@ -103,6 +103,15 @@ class LedgerApiShell:
params={"enabledOnly": str(bool(enabled_only)).lower()}, params={"enabledOnly": str(bool(enabled_only)).lower()},
) )
def list_bot_replies(self) -> LedgerResponse:
"""
已启用机器人回复语。
正式路径:GET /inquiry/agent/botReply/listEnabled
只读;停用项不在列表里。
"""
return self.client.request_json("GET", "/inquiry/agent/botReply/listEnabled")
def wake_agent_ack( def wake_agent_ack(
self, self,
*, *,
+21 -4
View File
@@ -40,8 +40,18 @@ class HttpLedger:
def _post(self, path: str, body: dict[str, Any], *, idem: str = "") -> dict[str, Any]: def _post(self, path: str, body: dict[str, Any], *, idem: str = "") -> dict[str, Any]:
resp = self._client.request_json("POST", path, json_body=body, idempotency_key=idem) resp = self._client.request_json("POST", path, json_body=body, idempotency_key=idem)
if not resp.ok: if not resp.ok:
logger.warning("http_ledger fail path=%s status=%s err=%s", path, resp.status_code, resp.error) # 500 时 Jeecg 仍可能带回 message(如六态非法);勿只留 http_500,
return {"ok": False, "error": resp.error or f"http_{resp.status_code}"} # 否则成交补「已报价」重试认不出「询价中」(WO023)。
data = resp.data if isinstance(resp.data, dict) else {}
msg = ""
if isinstance(data, dict):
msg = str(data.get("message") or "").strip()
unwrapped = _unwrap(data)
if unwrapped.get("error"):
msg = str(unwrapped.get("error") or msg)
err = msg or resp.error or f"http_{resp.status_code}"
logger.warning("http_ledger fail path=%s status=%s err=%s", path, resp.status_code, err)
return {"ok": False, "error": err}
return _unwrap(resp.data) return _unwrap(resp.data)
def find_similar_open( def find_similar_open(
@@ -166,8 +176,12 @@ class HttpLedger:
""" """
no = (work_order_no or "").strip() no = (work_order_no or "").strip()
src = dict(quote or {}) src = dict(quote or {})
if not no or not src: if not no:
return {"ok": True, "quote_version": 1, "status": to_status or "已报价"} return {"ok": False, "error": "work_order_blank"}
# 空报价不打主账(避免抹掉已有查价),但禁止伪装成「已写入已报价」——
# 否则会话以为已报价、主账仍停询价中,成交三态会失败(WO022)。
if not src:
return {"ok": False, "error": "quote_blank", "skipped": True}
out = self._post( out = self._post(
"/inquiry/agent/ticket/replaceTmsQuote", "/inquiry/agent/ticket/replaceTmsQuote",
{"workOrderNo": no, "quote": src, "toStatus": to_status}, {"workOrderNo": no, "quote": src, "toStatus": to_status},
@@ -423,6 +437,7 @@ class HttpLedger:
operator_name: str = "", operator_name: str = "",
instruction_text: str = "", instruction_text: str = "",
airline: str = "", airline: str = "",
flight_no: str = "",
) -> dict[str, Any]: ) -> dict[str, Any]:
body: dict[str, Any] = { body: dict[str, Any] = {
"workOrderNo": work_order_no, "workOrderNo": work_order_no,
@@ -433,6 +448,8 @@ class HttpLedger:
} }
if (airline or "").strip(): if (airline or "").strip():
body["airline"] = airline.strip().upper() body["airline"] = airline.strip().upper()
if (flight_no or "").strip():
body["flightNo"] = flight_no.strip().upper()
return self._post( return self._post(
"/inquiry/agent/tms/lockCabin", "/inquiry/agent/tms/lockCabin",
body, body,
+28 -2
View File
@@ -827,9 +827,14 @@ class MemoryLedger:
operator_name: str = "", operator_name: str = "",
instruction_text: str = "", instruction_text: str = "",
airline: str = "", airline: str = "",
flight_no: str = "",
) -> dict[str, Any]: ) -> dict[str, Any]:
"""单测可注入 _cabin_lock_result;默认成功。""" """单测可注入 _cabin_lock_result;默认成功。
_ = sender_id, operator_name, instruction_text, airline
纯人工且视图无 TMS 舱位时,缺 option/航司/航班 → lock_incomplete,
对齐主账「现拉仍缺才回」的口径(内存账不会真查 TMS)。
"""
_ = sender_id, operator_name, instruction_text
forced = getattr(self, "_cabin_lock_result", None) forced = getattr(self, "_cabin_lock_result", None)
if isinstance(forced, dict): if isinstance(forced, dict):
return dict(forced) return dict(forced)
@@ -845,6 +850,27 @@ class MemoryLedger:
quote_id = str(quote.get("quoteId") or "").strip() quote_id = str(quote.get("quoteId") or "").strip()
if quote_id.upper().startswith("QT-WO"): if quote_id.upper().startswith("QT-WO"):
quote_id = "" quote_id = ""
air_opts = quote.get("airOptions") or quote.get("air_options") or []
has_tms_cabin = bool(quote_id) or bool(air_opts)
opt = (option_no or "").strip()
air = (airline or "").strip()
fn = (flight_no or "").strip()
if not has_tms_cabin:
missing: list[str] = []
if not opt:
missing.append("舱位编号optionNo")
if not air:
missing.append("航司airline")
if not fn:
missing.append("航班号flightNo")
if missing:
return {
"ok": False,
"error": "lock_incomplete",
"failReason": "锁舱参数不完整,缺少:"
+ "、".join(missing)
+ "。请按「AIR-OPT01 CZ 航班CZ327」补舱位、航司和航班号后再锁。",
}
return { return {
"ok": True, "ok": True,
"status": "LOCKED", "status": "LOCKED",
+158 -12
View File
@@ -757,6 +757,30 @@ _RE_MONEY = re.compile(
r"|(?:USD|CNY|RMB|美金|美元)\s*(\d+(?:\.\d+)?)", r"|(?:USD|CNY|RMB|美金|美元)\s*(\d+(?:\.\d+)?)",
re.I, re.I,
) )
# 陆运集拼:「2-500KG 单价CNY990」是分档单价,不是「燃油附加费=2」。
_RE_WEIGHT_BAND_PRICE = re.compile(
r"(?P<min>\d+)\s*[-~~—–到至]\s*(?P<max>\d+)\s*K\s*G"
r"(?:\s*以上)?"
r"\s*(?:单价)?"
r"\s*(?:(?P<ccy>USD|CNY|RMB|美金|美元|人民币)\s*)?"
r"(?P<price>\d+(?:\.\d+)?)",
re.I,
)
_RE_WEIGHT_BAND_OPEN = re.compile(
r"(?P<min>\d+)\s*K\s*G\s*以上"
r"\s*(?:单价)?"
r"\s*(?:(?P<ccy>USD|CNY|RMB|美金|美元|人民币)\s*)?"
r"(?P<price>\d+(?:\.\d+)?)",
re.I,
)
_RE_FUEL_SURCHARGE_RATE = re.compile(
r"燃油附加费率\s*[::=]?\s*(?P<rate>\d+(?:\.\d+)?)\s*%?",
re.I,
)
_RE_TAX_RATE = re.compile(
r"(?<![A-Za-z\u4e00-\u9fff])税率\s*[::=]?\s*(?P<rate>\d+(?:\.\d+)?)\s*%?",
re.I,
)
_RE_FEE_LINE = re.compile( _RE_FEE_LINE = re.compile(
r"(?P<name>[\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费|[A-Za-z]{2,10})" r"(?P<name>[\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费|[A-Za-z]{2,10})"
r"\s*(?:改为|改成|调整为|调整成)?" r"\s*(?:改为|改成|调整为|调整成)?"
@@ -867,6 +891,8 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
认不出价格返回空 dict,调用方回「没有识别到报价」。 认不出价格返回空 dict,调用方回「没有识别到报价」。
群里常见「码头操作费100CNY / 报关费改为300CNY」,不能只把第一笔数当成海运费。 群里常见「码头操作费100CNY / 报关费改为300CNY」,不能只把第一笔数当成海运费。
「空运费 单价…金额…」必须保留空运费,金额用「金额」后面的数,不用单价。 「空运费 单价…金额…」必须保留空运费,金额用「金额」后面的数,不用单价。
陆运集拼:「2-500KG 单价CNY990,燃油附加费率1%,税率6%」走分档单价+费率,
禁止把公斤档下限「2」当成燃油附加费金额(WO021)。
""" """
if isinstance(injected, dict) and (injected.get("total") or injected.get("fee_rows")): if isinstance(injected, dict) and (injected.get("total") or injected.get("fee_rows")):
out = dict(injected) out = dict(injected)
@@ -879,16 +905,22 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
compact = stripped.replace(" ", "").replace(" ", "") compact = stripped.replace(" ", "").replace(" ", "")
if re.fullmatch(r"WO\d{12}", compact, re.I): if re.fullmatch(r"WO\d{12}", compact, re.I):
return {} return {}
rows = _parse_fee_rows(raw) band = _parse_weight_band_quote(raw)
if band:
return band
# 费率句若仍落到普通费用行,会把「燃油附加费」配上句首公斤数。先剥掉再抽。
scrubbed = _strip_rate_and_band_noise(raw)
rows = _parse_fee_rows(scrubbed) if scrubbed.strip() else []
if rows: if rows:
ccy = rows[0].get("currency") or _currency_of(raw) ccy = rows[0].get("currency") or _currency_of(raw)
total = rows[0]["amount"] money_rows = [x for x in rows if "%" not in str(x.get("amount") or "")]
if len({x.get("currency") for x in rows}) == 1: total = (money_rows[0] if money_rows else rows[0])["amount"]
if money_rows and len({x.get("currency") for x in money_rows}) == 1:
try: try:
total = str(sum(float(x["amount"]) for x in rows)) total = str(sum(float(x["amount"]) for x in money_rows))
except ValueError: except ValueError:
total = rows[0]["amount"] total = money_rows[0]["amount"]
return { out = {
"total": total, "total": total,
"currency": ccy, "currency": ccy,
"source_label": "产品报价", "source_label": "产品报价",
@@ -896,9 +928,12 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
"fee_rows": rows, "fee_rows": rows,
"fee_lines": list(rows), "fee_lines": list(rows),
} }
hit = _RE_MONEY.search(_ascii_amount(raw)) out.update(_extract_rate_fields(raw))
return out
hit = _RE_MONEY.search(_ascii_amount(scrubbed or raw))
if not hit: if not hit:
return {} rates = _extract_rate_fields(raw)
return rates if rates else {}
amount = _ascii_amount(hit.group(1) or hit.group(2) or "") amount = _ascii_amount(hit.group(1) or hit.group(2) or "")
if not amount: if not amount:
return {} return {}
@@ -906,12 +941,13 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
# 句里已经点了费用名(空运费)时,不能退回默认海运费。 # 句里已经点了费用名(空运费)时,不能退回默认海运费。
named = re.search( named = re.search(
r"([\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费)", r"([\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费)",
raw, scrubbed or raw,
) )
fee_name = (named.group(1) if named else "") or "海运费" fee_name = (named.group(1) if named else "") or "海运费"
if fee_name in {"费用"}: if fee_name in {"费用", "燃油附加费"}:
fee_name = "海运费" # 「燃油附加费率」里的「燃油附加费」不是费用行名;无其它费名时用运费。
return { fee_name = "海运费" if fee_name == "费用" else "陆运费"
out = {
"total": amount, "total": amount,
"currency": ccy, "currency": ccy,
"source_label": "产品报价", "source_label": "产品报价",
@@ -919,3 +955,113 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
"fee_rows": [{"name": fee_name, "amount": amount, "currency": ccy}], "fee_rows": [{"name": fee_name, "amount": amount, "currency": ccy}],
"fee_lines": [{"name": fee_name, "amount": amount, "currency": ccy}], "fee_lines": [{"name": fee_name, "amount": amount, "currency": ccy}],
} }
out.update(_extract_rate_fields(raw))
return out
def _format_percent_rate(raw: str) -> str:
"""费率展示统一带 %;已有 % 保留。"""
text = (raw or "").strip()
if not text:
return ""
if text.endswith("%"):
return text
return f"{text}%"
def _extract_rate_fields(text: str) -> dict[str, str]:
"""抽出燃油附加费率 / 税率;只返回出现过的键。"""
out: dict[str, str] = {}
fuel = _RE_FUEL_SURCHARGE_RATE.search(text or "")
if fuel:
out["fuel_surcharge_rate"] = _format_percent_rate(fuel.group("rate") or "")
out["fuelSurchargeRate"] = out["fuel_surcharge_rate"]
tax = _RE_TAX_RATE.search(text or "")
if tax:
out["tax_rate"] = _format_percent_rate(tax.group("rate") or "")
out["taxRate"] = out["tax_rate"]
return out
def _strip_rate_and_band_noise(text: str) -> str:
"""去掉分档价与费率短语,避免后续费用行/金额回退误伤。"""
cleaned = _ascii_amount(text or "")
cleaned = _RE_WEIGHT_BAND_PRICE.sub(" ", cleaned)
cleaned = _RE_WEIGHT_BAND_OPEN.sub(" ", cleaned)
cleaned = _RE_FUEL_SURCHARGE_RATE.sub(" ", cleaned)
cleaned = _RE_TAX_RATE.sub(" ", cleaned)
return cleaned
def _parse_weight_band_quote(text: str) -> dict[str, Any]:
"""
陆运集拼分档单价:2-500KG 单价CNY990 + 可选燃油附加费率/税率。
产出 weightPrices,供主账出单按「2-500KG」表头对档;费率进顶层字段填 Excel。
"""
raw = _ascii_amount(text or "")
tiers: list[dict[str, Any]] = []
rows: list[dict[str, str]] = []
ccy = ""
for hit in _RE_WEIGHT_BAND_PRICE.finditer(raw):
lo = int(hit.group("min") or 0)
hi = int(hit.group("max") or 0)
price = _ascii_amount(hit.group("price") or "")
if not price:
continue
line_ccy = _currency_of(raw, hit.group("ccy") or "")
ccy = ccy or line_ccy
label = f"{lo}-{hi}KG"
tiers.append({"min": lo, "max": hi, "price": price})
rows.append(
{
"name": label,
"amount": price,
"currency": line_ccy,
"unit_price": price,
}
)
for hit in _RE_WEIGHT_BAND_OPEN.finditer(raw):
lo = int(hit.group("min") or 0)
price = _ascii_amount(hit.group("price") or "")
if not price:
continue
line_ccy = _currency_of(raw, hit.group("ccy") or "")
ccy = ccy or line_ccy
label = f"{lo}KG以上"
tiers.append({"min": lo, "max": None, "price": price})
rows.append(
{
"name": label,
"amount": price,
"currency": line_ccy,
"unit_price": price,
}
)
if not tiers:
return {}
rates = _extract_rate_fields(raw)
# 费率行进明细方便群摘要展示;金额带 %,合计时只加数字价。
if rates.get("fuel_surcharge_rate"):
rows.append(
{
"name": "燃油附加费率",
"amount": rates["fuel_surcharge_rate"],
"currency": "",
}
)
if rates.get("tax_rate"):
rows.append({"name": "税率", "amount": rates["tax_rate"], "currency": ""})
# 分档是单价,不能把多档相加冒充费用合计。
out: dict[str, Any] = {
"total": tiers[0]["price"],
"currency": ccy or "CNY",
"source_label": "产品报价",
"raw_text": (text or "").strip(),
"fee_rows": rows,
"fee_lines": list(rows),
"weightPrices": tiers,
"price_per_kg": tiers[0]["price"],
}
out.update(rates)
return out
+246 -13
View File
@@ -3,7 +3,8 @@
本文件职责:群里副作用(主账 + BOT 回话)。海运应用群仍走 sea_group_ops。 本文件职责:群里副作用(主账 + BOT 回话)。海运应用群仍走 sea_group_ops。
禁止:自动建群/拉人/解散;拿当前工单去猜锁舱工单号。 禁止:自动建群/拉人/解散;拿当前工单去猜锁舱工单号。
锁舱按 TMS /air/cabin/lock 拼字段出站;没有 TMS 报价编号也要锁,用工单字段补 quoteId/optionNo。 锁舱按 TMS /air/cabin/lock 拼字段出站;没有 TMS 报价编号也可走 MANUAL。
禁止编造 optionNo;缺舱位编号/航司/计费重时先群里补问,禁止空壳打 TMS。
""" """
from __future__ import annotations from __future__ import annotations
@@ -484,7 +485,7 @@ def _send_air_activate_sheet(
return return
err = str(painted.get("error") or "") err = str(painted.get("error") or "")
if err == "NO_TEMPLATE": if err == "NO_TEMPLATE":
send_group_text(client, chat_id, painted.get("message") or copy.QUOTE_TEMPLATE_MISSING) send_group_text(client, chat_id, painted.get("message") or copy.quote_template_missing())
return return
if err in {"no_renderer_or_chat", "no_file_client"}: if err in {"no_renderer_or_chat", "no_file_client"}:
send_group_text(client, chat_id, copy.group_sheet_send_fail()) send_group_text(client, chat_id, copy.group_sheet_send_fail())
@@ -776,22 +777,46 @@ def apply_air_deal(
if stopped: if stopped:
return stopped return stopped
from agent.policy.deal_outcome import finish_deal_outcome, harvest_inline_lost_reason from agent.policy.deal_outcome import finish_deal_outcome, harvest_inline_lost_reason
from agent.policy.sea_group_ops import facts_of_group from agent.policy.sea_group_ops import _quote_fee_rows, facts_of_group
view = ticket_view(ticket) view = ticket_view(ticket)
no = ticket_no(ticket)
quote = dict(view.get("quote") or {})
status_now = ticket_status(ticket)
usable_total = str(quote.get("total") or "").strip()
if usable_total in {"", "-"}:
usable_total = ""
# 与海运一致:询价中且无报价时先说清原因,禁止笼统「主账状态更新失败」。
if not (usable_total or _quote_fee_rows(quote) or status_now in {"已报价", "协商中"}):
send_group_text(
group_client_of(flow), chat_id, copy.deal_fail_no_quote(work_order_no=no)
)
return "deal_need_quote"
inline = harvest_inline_lost_reason(text) if outcome == "未成交" else "" inline = harvest_inline_lost_reason(text) if outcome == "未成交" else ""
result = finish_deal_outcome( result = finish_deal_outcome(
ledger=flow.ledger, ledger=flow.ledger,
work_order_no=ticket_no(ticket), work_order_no=no,
outcome=outcome, outcome=outcome,
facts=facts_of_group(view), facts=facts_of_group(view),
quote=dict(view.get("quote") or {}), quote=quote,
business_line="AIR", business_line="AIR",
sales_name=str(view.get("sales_name") or ""), sales_name=str(view.get("sales_name") or ""),
lost_reason=inline, lost_reason=inline,
) )
if not result.get("ok"): if not result.get("ok"):
send_group_text(group_client_of(flow), chat_id, "主账状态更新失败,成交结果未入账。") # 迁态失败且仍无报价时,同样用「没有报价」说明,避免旧笼统句。
err = str(result.get("error") or "")
still_no_quote = not (
usable_total or _quote_fee_rows(quote) or status_now in {"已报价", "协商中"}
)
if still_no_quote or "询价中" in err:
send_group_text(
group_client_of(flow), chat_id, copy.deal_fail_no_quote(work_order_no=no)
)
else:
send_group_text(
group_client_of(flow), chat_id, "主账状态更新失败,成交结果未入账。"
)
return "ledger_fail" return "ledger_fail"
phase = str(result.get("phase") or "done") phase = str(result.get("phase") or "done")
ack_reason = inline if phase == "done" and outcome == "未成交" else "" ack_reason = inline if phase == "done" and outcome == "未成交" else ""
@@ -800,7 +825,7 @@ def apply_air_deal(
chat_id, chat_id,
copy.deal_ack( copy.deal_ack(
outcome, outcome,
ticket_no(ticket), no,
str(result.get("next_followup_at") or ""), str(result.get("next_followup_at") or ""),
lost_reason=ack_reason, lost_reason=ack_reason,
), ),
@@ -818,6 +843,13 @@ def apply_air_lock(
option_no: str = "", option_no: str = "",
airline: str = "", airline: str = "",
) -> str: ) -> str:
"""
空运群锁舱。
副作用:调主账 lockCabin(HMAC 出站 TMS);成功写协同 facts、推成交跟进。
舱位/航司/航班交给主账现拉 TMS 补全;仅计费重全空时群里补问。
主账仍缺则回 lock_incomplete,禁止空壳出站。
"""
if not is_airline(flow, sender_id): if not is_airline(flow, sender_id):
send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin()) send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin())
return "not_airline" return "not_airline"
@@ -825,14 +857,21 @@ def apply_air_lock(
# 不能只回「TMS接口异常,IT运维排查中」。 # 不能只回「TMS接口异常,IT运维排查中」。
no = ticket_no(ticket) no = ticket_no(ticket)
name = operator_name(flow, sender_id) name = operator_name(flow, sender_id)
view_quote = dict(ticket_view(ticket).get("quote") or {}) view = ticket_view(ticket)
view_quote = dict(view.get("quote") or {})
air_quote = dict((view_quote.get("segment_quotes") or {}).get("AIR") or {}) air_quote = dict((view_quote.get("segment_quotes") or {}).get("AIR") or {})
quote = air_quote or view_quote quote = air_quote or view_quote
options = _air_options(quote) # 最新价常是人工覆盖:舱位/航司/航班要从本单 TMS 原价自动补,不能逼航线手打。
quote_id = _quote_id(quote) tms_quote = copy.resolve_tms_quote(None, view)
if not tms_quote:
tms_quote = dict(view.get("tms_quote") or view.get("tmsQuote") or {})
quote_options = _air_options(quote)
tms_options = _air_options(tms_quote)
options = quote_options or tms_options
quote_id = _quote_id(quote) or _quote_id(tms_quote)
picked = (option_no or "").strip() picked = (option_no or "").strip()
if not picked and len(options) > 1: if not picked and len(options) > 1:
facts = dict(ticket_view(ticket).get("collab_facts") or {}) facts = dict(view.get("collab_facts") or {})
if str(facts.get(_PENDING_LOCK) or "") != "1": if str(facts.get(_PENDING_LOCK) or "") != "1":
flow.ledger.patch_collab_facts( flow.ledger.patch_collab_facts(
work_order_no=no, facts={_PENDING_LOCK: "1"}, sender_id=sender_id work_order_no=no, facts={_PENDING_LOCK: "1"}, sender_id=sender_id
@@ -841,6 +880,42 @@ def apply_air_lock(
return "wait_lock_option" return "wait_lock_option"
if not picked and len(options) == 1: if not picked and len(options) == 1:
picked = str(options[0].get("optionNo") or options[0].get("option_no") or "").strip() picked = str(options[0].get("optionNo") or options[0].get("option_no") or "").strip()
# 没有任何可锁报价时先拒绝,不要把「无报价」误说成缺舱位。
if not _quote_ready_for_lock(quote) and not _quote_ready_for_lock(tms_quote):
send_group_text(group_client_of(flow), chat_id, copy.air_lock_need_quote(no))
return "no_quote"
airline_code = (airline or "").strip().upper()
if not airline_code:
airline_code = _airline_from_quote_or_option(quote, quote_options, picked)
if not airline_code:
airline_code = _airline_from_quote_or_option(tms_quote, tms_options, picked)
chargeable = _chargeable_weight_kg(quote, quote_options, picked, ticket)
if not chargeable:
chargeable = _chargeable_weight_kg(tms_quote, tms_options, picked, ticket)
flight_no = _flight_no_from_quote_or_option(quote, quote_options, picked, instruction)
if not flight_no:
flight_no = _flight_no_from_quote_or_option(tms_quote, tms_options, picked, instruction)
# 舱位/航司/航班交给主账:库里无 TMS 时会现拉补全;仍缺才回 lock_incomplete。
# 这里只拦计费重全空(工单毛重也没有)——否则会逼航线手打航班。
need_chargeable = not chargeable
if need_chargeable:
facts = dict(ticket_view(ticket).get("collab_facts") or {})
if str(facts.get(_PENDING_LOCK) or "") != "1":
flow.ledger.patch_collab_facts(
work_order_no=no, facts={_PENDING_LOCK: "1"}, sender_id=sender_id
)
send_group_text(
group_client_of(flow),
chat_id,
copy.air_ask_manual_lock_fields(
need_option=False,
need_airline=False,
need_chargeable=True,
need_flight=False,
options=options,
),
)
return "wait_lock_fields"
locker = getattr(flow.ledger, "lock_cabin", None) locker = getattr(flow.ledger, "lock_cabin", None)
if not callable(locker): if not callable(locker):
send_group_text( send_group_text(
@@ -857,6 +932,14 @@ def apply_air_lock(
confirm_system_exception, confirm_system_exception,
) )
def _lock_terminal(x: object) -> bool:
"""成功,或业务上不必再重试(缺舱位/无报价)。"""
row = x if isinstance(x, dict) else {}
if bool(row.get("ok")):
return True
err = str(row.get("error") or "")
return err in {"lock_incomplete", "no_quote"}
out = call_with_retries( out = call_with_retries(
lambda: locker( lambda: locker(
work_order_no=no, work_order_no=no,
@@ -864,15 +947,30 @@ def apply_air_lock(
sender_id=sender_id, sender_id=sender_id,
operator_name=name, operator_name=name,
instruction_text=instruction, instruction_text=instruction,
airline=(airline or "").strip(), airline=airline_code,
flight_no=flight_no,
) )
or {}, or {},
is_ok=lambda x: bool((x or {}).get("ok")), is_ok=_lock_terminal,
) )
if not out.get("ok"): if not out.get("ok"):
if str(out.get("error") or "") == "no_quote": if str(out.get("error") or "") == "no_quote":
send_group_text(group_client_of(flow), chat_id, copy.air_lock_need_quote(no)) send_group_text(group_client_of(flow), chat_id, copy.air_lock_need_quote(no))
return "no_quote" return "no_quote"
if str(out.get("error") or "") == "lock_incomplete":
# 主账现拉仍无舱位:业务补问,不算系统异常,不惊动 IT。
why = str(out.get("failReason") or out.get("error") or "锁舱参数不完整")
facts = dict(ticket_view(ticket).get("collab_facts") or {})
if str(facts.get(_PENDING_LOCK) or "") != "1":
flow.ledger.patch_collab_facts(
work_order_no=no, facts={_PENDING_LOCK: "1"}, sender_id=sender_id
)
send_group_text(
group_client_of(flow),
chat_id,
copy.air_lock_incomplete(work_order_no=no, operator=name, reason=why),
)
return "lock_incomplete"
why = copy.air_cabin_fail_reason( why = copy.air_cabin_fail_reason(
tms_reason=str(out.get("failReason") or out.get("error") or ""), tms_reason=str(out.get("failReason") or out.get("error") or ""),
flight_date=_ticket_flight_date(ticket), flight_date=_ticket_flight_date(ticket),
@@ -895,6 +993,7 @@ def apply_air_lock(
conversation_target=chat_id, conversation_target=chat_id,
extra={ extra={
"option_no": picked, "option_no": picked,
"airline": airline_code,
"instruction": instruction, "instruction": instruction,
"sender_id": sender_id, "sender_id": sender_id,
"operator_name": name, "operator_name": name,
@@ -1164,6 +1263,140 @@ def _quote_id(quote: dict[str, Any]) -> str:
return str(quote.get("quoteId") or quote.get("quote_id") or "").strip() return str(quote.get("quoteId") or quote.get("quote_id") or "").strip()
def _quote_ready_for_lock(quote: dict[str, Any]) -> bool:
"""
有没有可锁的报价:真实 TMS 编号,或人工总价/费用明细。
与内存账本口径一致,禁止把空报价当成「缺舱位」去补问。
"""
src = dict(quote or {})
label = str(src.get("source_label") or src.get("sourceLabel") or "")
if "确认" in label and "TMS" in label.upper():
qid = str(src.get("quoteId") or "").strip()
return bool(qid) and not qid.upper().startswith("QT-")
if str(src.get("total") or "").strip() not in {"", "-"}:
return True
if src.get("fee_rows") or src.get("fee_lines") or src.get("feeItems") or src.get("departureCharges"):
return True
if _air_options(src):
return True
qid = str(src.get("quoteId") or src.get("quote_id") or "").strip()
return bool(qid) and not qid.upper().startswith("QT-WO")
def _picked_option(
options: list[dict[str, Any]], option_no: str
) -> dict[str, Any]:
"""按 optionNo 取舱位;对不上返回空 dict。"""
want = (option_no or "").strip().upper()
if not want:
return {}
for row in options:
no = str(row.get("optionNo") or row.get("option_no") or "").strip().upper()
if no == want:
return row
return {}
def _airline_from_quote_or_option(
quote: dict[str, Any],
options: list[dict[str, Any]],
option_no: str,
) -> str:
"""
锁舱航司:舱位 airlineCode > 报价顶层 > 航班号前两位。
只回两位大写字母;猜不到就空,交给群里补问。
"""
opt = _picked_option(options, option_no)
raw = str(
opt.get("airlineCode")
or opt.get("airline")
or quote.get("airlineCode")
or quote.get("airline")
or ""
).strip().upper()
if len(raw) >= 2 and raw[:2].isalpha():
return raw[:2]
flight = str(
opt.get("flightNo") or opt.get("flight_no") or quote.get("flightNo") or ""
).strip().upper()
if len(flight) >= 2 and flight[0].isalpha() and flight[1].isalpha():
return flight[:2]
name = str(opt.get("airlineName") or quote.get("airlineName") or "").strip()
# 名称里不硬猜二字码,避免「南航」编成乱码。
_ = name
return ""
def _flight_no_from_quote_or_option(
quote: dict[str, Any],
options: list[dict[str, Any]],
option_no: str,
instruction: str = "",
) -> str:
"""航班号:舱位/报价字段,或句中「航班CZ327」「CZ327」形态。"""
import re
opt = _picked_option(options, option_no)
raw = str(opt.get("flightNo") or opt.get("flight_no") or quote.get("flightNo") or "").strip()
if raw:
return raw.upper()
blob = (instruction or "").upper()
m = re.search(r"(?:航班|FLIGHT)\s*([A-Z0-9]{2,8})", blob)
if m:
return m.group(1)
m = re.search(r"\b([A-Z]{2}\d{2,4})\b", blob)
if m:
return m.group(1)
return ""
def _chargeable_weight_kg(
quote: dict[str, Any],
options: list[dict[str, Any]],
option_no: str,
ticket: object,
) -> str:
"""
计费重:舱位 > 报价 > 工单事实「计费重」> 毛重。
有正数才返回;否则空串,门禁会补问。
"""
opt = _picked_option(options, option_no)
candidates = [
opt.get("chargeableWeightKg") or opt.get("chargeable_weight_kg"),
quote.get("chargeableWeightKg") or quote.get("chargeable_weight_kg"),
]
view = ticket_view(ticket)
facts = dict(view.get("facts") or {})
candidates.extend(
[
facts.get("计费重"),
facts.get("计费重量"),
view.get("chargeableWeightKg"),
facts.get("毛重"),
view.get("weightKg") or view.get("weight_kg"),
]
)
for raw in candidates:
text = str(raw or "").strip().replace(",", "")
if not text or text in {"-", "null", "None"}:
continue
# 去掉「公斤」「KGS」等后缀,只留数字。
num = ""
for ch in text:
if ch.isdigit() or ch == ".":
num += ch
elif num:
break
if not num:
continue
try:
if float(num) > 0:
return num
except ValueError:
continue
return ""
def _air_options(quote: dict[str, Any]) -> list[dict[str, Any]]: def _air_options(quote: dict[str, Any]) -> list[dict[str, Any]]:
raw = quote.get("airOptions") or quote.get("air_options") raw = quote.get("airOptions") or quote.get("air_options")
if isinstance(raw, str) and raw.strip().startswith("["): if isinstance(raw, str) and raw.strip().startswith("["):
+45 -18
View File
@@ -418,21 +418,21 @@ class AirTextInquiryFlow:
hit.business_line = str(view.get("business_line") or "").strip().upper() hit.business_line = str(view.get("business_line") or "").strip().upper()
if hit is None: if hit is None:
if not callable(getter): if not callable(getter):
reply(f"找不到工单 {no},请核对工单号后再试。") reply(copy.continue_ticket_not_found(no))
return "ticket_missing" return "ticket_missing"
if not view.get("found"): if not view.get("found"):
reply(f"找不到工单 {no},请核对工单号后再试。") reply(copy.continue_ticket_not_found(no))
return "ticket_missing" return "ticket_missing"
line = str(view.get("business_line") or "").strip().upper() line = str(view.get("business_line") or "").strip().upper()
owner = self._handoff_owner_for_line(line) owner = self._handoff_owner_for_line(line)
if owner is None: if owner is None:
reply(copy.ASK_TRANSPORT) reply(copy.ask_transport())
return "need_mode" return "need_mode"
hit = owner._session_from_ledger(sender_id=sender_id, view=view) hit = owner._session_from_ledger(sender_id=sender_id, view=view)
line = (hit.business_line or "").strip().upper() line = (hit.business_line or "").strip().upper()
owner = self._handoff_owner_for_line(line) owner = self._handoff_owner_for_line(line)
if owner is None: if owner is None:
reply(copy.ASK_TRANSPORT) reply(copy.ask_transport())
return "need_mode" return "need_mode"
logger.info( logger.info(
"handoff.named wo=%s line=%s phase=%s chat=%s", "handoff.named wo=%s line=%s phase=%s chat=%s",
@@ -474,7 +474,7 @@ class AirTextInquiryFlow:
mode = (sess.business_line if sess else "") or detect_transport_mode(text) or "" mode = (sess.business_line if sess else "") or detect_transport_mode(text) or ""
mode = (mode or "").upper() mode = (mode or "").upper()
if not mode: if not mode:
reply(copy.ASK_TRANSPORT) reply(copy.ask_transport())
self._save( self._save(
FlowSession( FlowSession(
sender_id=sender_id, sender_id=sender_id,
@@ -538,7 +538,7 @@ class AirTextInquiryFlow:
sess.business_line = "SEA" sess.business_line = "SEA"
line = (sess.business_line or "").upper() line = (sess.business_line or "").upper()
if has_collab_group(sess): if has_collab_group(sess):
reply(copy.HANDOFF_ALREADY_COLLAB) reply(copy.already_collab())
return "handoff_already" return "handoff_already"
why = copy.HANDOFF_CLARIFY_REASON if reason == "clarify" else copy.HANDOFF_INTENT_REASON why = copy.HANDOFF_CLARIFY_REASON if reason == "clarify" else copy.HANDOFF_INTENT_REASON
if line == "AIR": if line == "AIR":
@@ -711,7 +711,7 @@ class AirTextInquiryFlow:
""" """
no = (work_order_no or "").strip().upper() no = (work_order_no or "").strip().upper()
if not no: if not no:
reply("请告诉我要继续的工单号,例如 WO202609140034。") reply(copy.continue_need_wo())
return "need_work_order" return "need_work_order"
with self._lock: with self._lock:
sess = self._by_ticket.get(no) sess = self._by_ticket.get(no)
@@ -730,11 +730,11 @@ class AirTextInquiryFlow:
return self._replay_wait(keep, reply, source="session") return self._replay_wait(keep, reply, source="session")
getter = getattr(self._ledger, "get_for_agent", None) getter = getattr(self._ledger, "get_for_agent", None)
if not callable(getter): if not callable(getter):
reply(f"找不到工单 {no},请核对工单号后再试。") reply(copy.continue_ticket_not_found(no))
return "ticket_missing" return "ticket_missing"
view = getter(work_order_no=no, sender_id=sender_id) or {} view = getter(work_order_no=no, sender_id=sender_id) or {}
if not view.get("found"): if not view.get("found"):
reply(f"找不到工单 {no},请核对工单号后再试。") reply(copy.continue_ticket_not_found(no))
return "ticket_missing" return "ticket_missing"
if view.get("owned") is False: if view.get("owned") is False:
reply("这张工单不属于当前账号,不能继续。") reply("这张工单不属于当前账号,不能继续。")
@@ -879,7 +879,7 @@ class AirTextInquiryFlow:
if sess.phase == "wait_file": if sess.phase == "wait_file":
self._refresh_wait_file(sess) self._refresh_wait_file(sess)
if sess.phase == "wait_file": if sess.phase == "wait_file":
reply("报价单正在生成,请稍候。") reply(copy.quote_generating())
return "wait_file" return "wait_file"
if sess.phase == "wait_deal": if sess.phase == "wait_deal":
# 成交卡已经绑在报价文件那条出站上。只续办、没说成交结果时不再另发一张卡。 # 成交卡已经绑在报价文件那条出站上。只续办、没说成交结果时不再另发一张卡。
@@ -964,9 +964,8 @@ class AirTextInquiryFlow:
reply=reply, reply=reply,
sess=sess, sess=sess,
) )
if (not sess or sess.phase != "wait_confirm") and looks_like_confirm(text) and not sess: # 核对卡不再设时效。没有待确定会话时不当成「已过期」,
reply(copy.AIR_CONFIRM_EXPIRED) # 让本句按新询价走,避免正文里的「确认」误伤。
return "confirm_expired"
sess = self._detach_ticketed_for_new_inquiry(sess) sess = self._detach_ticketed_for_new_inquiry(sess)
snap = extract_inquiry_snapshot( snap = extract_inquiry_snapshot(
@@ -1006,7 +1005,7 @@ class AirTextInquiryFlow:
) )
if not mode: if not mode:
reply(copy.ASK_TRANSPORT) reply(copy.ask_transport())
self._save( self._save(
FlowSession( FlowSession(
sender_id=sender_id, sender_id=sender_id,
@@ -1055,7 +1054,7 @@ class AirTextInquiryFlow:
chat_fn=chat_fn, chat_fn=chat_fn,
inbound_raw=inbound_raw, inbound_raw=inbound_raw,
) )
reply(copy.ASK_TRANSPORT) reply(copy.ask_transport())
return "need_mode" return "need_mode"
if is_waiting_mode_session(sess): if is_waiting_mode_session(sess):
@@ -1781,8 +1780,12 @@ class AirTextInquiryFlow:
extra, extra,
) )
from agent.redis_coord.quote_file import mark_quote_file_ready from agent.redis_coord.quote_file import mark_quote_file_ready
from agent.policy.deal_outcome import ensure_ticket_quoted
mark_quote_file_ready(sess.work_order_no) mark_quote_file_ready(sess.work_order_no)
# 文件已出就必须让主账离开「询价中」,否则成交三态会被六态拒绝。
ensure_ticket_quoted(self._ledger, work_order_no=sess.work_order_no)
sess.status = sess.status or "已报价"
sess.phase = "wait_deal" sess.phase = "wait_deal"
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE) sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
sess.wait_version += 1 sess.wait_version += 1
@@ -1927,20 +1930,37 @@ class AirTextInquiryFlow:
"""点成交/未成交/协商中:先整卡置灰,再入主账。未成交同一句带原因则一次记完。""" """点成交/未成交/协商中:先整卡置灰,再入主账。未成交同一句带原因则一次记完。"""
self._grey_clicked_card(reply, card_meta, replace_name=status) self._grey_clicked_card(reply, card_meta, replace_name=status)
from agent.policy.deal_outcome import finish_deal_outcome, harvest_inline_lost_reason from agent.policy.deal_outcome import finish_deal_outcome, harvest_inline_lost_reason
from agent.policy.sea_group_ops import _quote_fee_rows
quote = dict(sess.quote or {})
status_now = str(sess.status or "").strip()
usable_total = str(quote.get("total") or "").strip()
if usable_total in {"", "-"}:
usable_total = ""
if not (
usable_total
or _quote_fee_rows(quote)
or status_now in {"已报价", "协商中"}
):
reply(copy.deal_fail_no_quote(work_order_no=sess.work_order_no))
return "deal_need_quote"
inline = harvest_inline_lost_reason(source_text) if status == "未成交" else "" inline = harvest_inline_lost_reason(source_text) if status == "未成交" else ""
result = finish_deal_outcome( result = finish_deal_outcome(
ledger=self._ledger, ledger=self._ledger,
work_order_no=sess.work_order_no, work_order_no=sess.work_order_no,
outcome=status, outcome=status,
facts=sess.facts, facts=sess.facts,
quote=sess.quote, quote=quote,
business_line=sess.business_line, business_line=sess.business_line,
sales_name=sess.invited_sales_name, sales_name=sess.invited_sales_name,
lost_reason=inline, lost_reason=inline,
) )
if not result.get("ok"): if not result.get("ok"):
reply("主账状态更新失败,成交结果未入账。") err = str(result.get("error") or "")
if (not usable_total and not _quote_fee_rows(quote)) or "询价中" in err:
reply(copy.deal_fail_no_quote(work_order_no=sess.work_order_no))
else:
reply("主账状态更新失败,成交结果未入账。")
return "ledger_fail" return "ledger_fail"
phase = str(result.get("phase") or "done") phase = str(result.get("phase") or "done")
sess.status = status sess.status = status
@@ -1977,7 +1997,12 @@ class AirTextInquiryFlow:
sess.phase = "done" sess.phase = "done"
sess.allowed = () sess.allowed = ()
self._save(sess) self._save(sess)
reply(copy.lost_reason_ack(str(result.get("lost_reason") or text))) reply(
copy.lost_reason_ack(
str(result.get("lost_reason") or text),
work_order_no=sess.work_order_no,
)
)
return "done" return "done"
def mark_wait_deal(self, sender_id: str, work_order_no: str) -> None: def mark_wait_deal(self, sender_id: str, work_order_no: str) -> None:
@@ -1991,8 +2016,10 @@ class AirTextInquiryFlow:
if not no: if not no:
return return
from agent.redis_coord.quote_file import mark_quote_file_ready from agent.redis_coord.quote_file import mark_quote_file_ready
from agent.policy.deal_outcome import ensure_ticket_quoted
mark_quote_file_ready(no) mark_quote_file_ready(no)
ensure_ticket_quoted(self._ledger, work_order_no=no)
if not sender_id: if not sender_id:
return return
with self._lock: with self._lock:
+156
View File
@@ -0,0 +1,156 @@
"""
后台机器人回复语:按 reply_code 取正文。
本文件职责:短缓存主账已启用列表,把 {{ticket_no}} 等变量填进模板。
禁止:当六态;禁止回调线程同步等完整 LLM;失败必须回退 inquiry_copy 默认句。
线程:进程内锁刷新;不同工单可读同一份缓存。
"""
from __future__ import annotations
import logging
import os
import re
import threading
import time
from typing import Any, Optional
logger = logging.getLogger(__name__)
# 后台改文案后最多这么久出现在销售侧;不当 checkpoint。
_TTL_SECONDS = 60.0
_PLACEHOLDER = re.compile(r"\{\{\s*([a-zA-Z0-9_]+)\s*\}\}")
_lock = threading.Lock()
_loaded_at = 0.0
_by_code: dict[str, str] = {}
# 单测注入;None 表示走主账/空缓存。
_override: Optional[dict[str, str]] = None
def override_catalog(mapping: Optional[dict[str, str]]) -> None:
"""
单测注入调用编码 → 正文。传 None 清掉注入。
副作用:清空加载时间,下次 render 重新取。不打主账。
"""
global _override, _loaded_at, _by_code
with _lock:
_override = dict(mapping) if mapping is not None else None
_loaded_at = 0.0
if mapping is None:
_by_code = {}
def fill(template: str, values: dict[str, Any] | None = None) -> str:
"""把 {{ticket_no}} 换成实值;未提供的占位原样保留。"""
src = dict(values or {})
def _repl(match: re.Match[str]) -> str:
key = match.group(1)
if key not in src:
return match.group(0)
raw = src.get(key)
if raw is None:
return ""
return str(raw)
return _PLACEHOLDER.sub(_repl, template or "")
def render(code: str, default: str, **kwargs: Any) -> str:
"""
取已启用回复语并填变量。
停用、库空、主账失败、编码对不上:返回 default(已是拼好的默认句)。
副作用:缓存过期时 GET 主账;超时/失败吞掉,不抛给销售。
"""
key = (code or "").strip()
fallback = default if default is not None else ""
if not key:
return fallback
tpl = _lookup(key)
if not (tpl or "").strip():
return fallback
return fill(tpl, kwargs)
def _lookup(code: str) -> str:
"""读缓存;过期再拉主账。override 优先。"""
global _loaded_at, _by_code
with _lock:
if _override is not None:
return str(_override.get(code) or "")
now = time.monotonic()
if _loaded_at > 0 and (now - _loaded_at) < _TTL_SECONDS:
return str(_by_code.get(code) or "")
fresh = _fetch()
with _lock:
if _override is not None:
return str(_override.get(code) or "")
if fresh is not None:
_by_code = fresh
_loaded_at = time.monotonic()
elif not _by_code:
_loaded_at = time.monotonic()
return str(_by_code.get(code) or "")
def _fetch() -> Optional[dict[str, str]]:
"""
GET /inquiry/agent/botReply/listEnabled。
未配主账地址视为空,返回 {{}} 以免每次空打。失败返回 None 以保留旧缓存。
"""
try:
from agent.config import get_settings
cfg = get_settings()
backend = str(
os.environ.get("LEDGER_BACKEND") or getattr(cfg, "ledger_backend", "") or ""
).strip().lower()
if backend in {"memory", "mem", "stub"}:
return {}
if not str(getattr(cfg, "biz_api_base_url", "") or "").strip():
return {}
from agent.ledger import LedgerClient
from agent.ledger.api_shell import LedgerApiShell
client = LedgerClient.from_settings()
client.timeout_seconds = min(float(client.timeout_seconds or 10), 3.0)
resp = LedgerApiShell(client=client).list_bot_replies()
if not resp.ok:
logger.warning("bot_reply listEnabled fail status=%s err=%s", resp.status_code, resp.error)
return None
rows = _unwrap_rows(resp.data)
out: dict[str, str] = {}
for row in rows:
if not isinstance(row, dict):
continue
code = str(row.get("replyCode") or row.get("reply_code") or "").strip()
text = str(row.get("replyText") or row.get("reply_text") or "")
if code and text.strip():
out[code] = text
return out
except Exception:
logger.warning("bot_reply listEnabled 异常,回退默认句", exc_info=True)
return None
def _unwrap_rows(data: Any) -> list[Any]:
"""解开 Jeecg Result 列表。"""
if isinstance(data, list):
return data
if not isinstance(data, dict):
return []
inner = data.get("result")
if isinstance(inner, list):
return inner
if isinstance(inner, dict):
rec = inner.get("records")
if isinstance(rec, list):
return rec
rec = data.get("records") or data.get("data")
if isinstance(rec, list):
return rec
return []
+78 -1
View File
@@ -210,7 +210,11 @@ def finish_deal_outcome(
writer = getattr(ledger, "transition", None) writer = getattr(ledger, "transition", None)
if not callable(writer): if not callable(writer):
return {"ok": False, "error": "no_transition", "phase": "ledger_fail"} return {"ok": False, "error": "no_transition", "phase": "ledger_fail"}
result = writer(work_order_no=no, to_status=status, **extra) or {} # 报价文件已出、会话已进入成交跟进,但主账仍停在「询价中」时,
# 直接迁「未成交/已成交/协商中」会被六态拒绝。先补「已报价」再迁目标态。
result = _transition_deal_status(
writer, work_order_no=no, to_status=status, extra=extra, ledger=ledger
)
if not result.get("ok"): if not result.get("ok"):
return {"ok": False, "error": result.get("error") or "transition_failed", "phase": "ledger_fail"} return {"ok": False, "error": result.get("error") or "transition_failed", "phase": "ledger_fail"}
from agent.policy.event_exception import WAIT_LOST_REASON, clear_wait, start_wait from agent.policy.event_exception import WAIT_LOST_REASON, clear_wait, start_wait
@@ -241,6 +245,79 @@ def finish_deal_outcome(
} }
def _transition_deal_status(
writer: Callable[..., dict[str, Any]],
*,
work_order_no: str,
to_status: str,
extra: dict[str, Any],
ledger: Any = None,
) -> dict[str, Any]:
"""
写入成交三态。若主账仍停在询价中,先迁已报价再迁目标态。
运行证据:
- WO022/WO023:文件已发仍停询价中,直迁未成交被六态拒绝;
- HTTP 500 时 http_ledger 常只回 http_500(正文里的「询价中」丢失),
不能只靠错误文案判断,要以工单当前状态为准。
"""
no = (work_order_no or "").strip()
status = (to_status or "").strip()
result = writer(work_order_no=no, to_status=status, **extra) or {}
if result.get("ok"):
return result
if status not in {"已成交", "未成交", "协商中"}:
return result
err = str(result.get("error") or result.get("message") or "")
cur = ""
getter = getattr(ledger, "get_for_agent", None) if ledger is not None else None
if callable(getter):
try:
snap = getter(work_order_no=no, sender_id="") or {}
cur = str(snap.get("status") or "").strip()
except Exception:
cur = ""
# 状态确认询价中,或错误文案带询价中,或仅有 http_5xx 时仍尝试补已报价。
should_promote = (
cur == "询价中"
or "询价中" in err
or err.startswith("http_5")
or err in {"transition_failed", "ledger_error"}
)
if not should_promote:
return result
promoted = writer(
work_order_no=no,
to_status="已报价",
remark="成交跟进前补已报价",
) or {}
if not promoted.get("ok"):
return result
return writer(work_order_no=no, to_status=status, **extra) or {}
def ensure_ticket_quoted(ledger: Any, *, work_order_no: str) -> dict[str, Any]:
"""
报价文件发出 / 进入成交跟进时,保证主账至少是「已报价」。
幂等:已离开「询价中」则不再迁态(已报价→已报价会被六态拒绝)。
"""
no = (work_order_no or "").strip()
writer = getattr(ledger, "transition", None)
if not no or not callable(writer):
return {"ok": False, "error": "no_transition"}
getter = getattr(ledger, "get_for_agent", None)
if callable(getter):
try:
snap = getter(work_order_no=no, sender_id="") or {}
except Exception:
snap = {}
st = str(snap.get("status") or "").strip()
if st and st != "询价中":
return {"ok": True, "status": st, "skipped": True}
return writer(work_order_no=no, to_status="已报价", remark="报价文件已出") or {}
def capture_lost_reason(*, ledger: Any, work_order_no: str, reason: str) -> dict[str, Any]: def capture_lost_reason(*, ledger: Any, work_order_no: str, reason: str) -> dict[str, Any]:
"""把销售下一句写进未成交原因。@询价小助手 不是原因。""" """把销售下一句写进未成交原因。@询价小助手 不是原因。"""
text = strip_bot_mentions(reason or "") text = strip_bot_mentions(reason or "")
+420 -83
View File
@@ -2,7 +2,8 @@
空运文字询价出站话术(销售可见)。 空运文字询价出站话术(销售可见)。
本文件职责:集中文案;按钮字面量必须与 handler 精确匹配。 本文件职责:集中文案;按钮字面量必须与 handler 精确匹配。
禁止:在本文件调主账/TMS;禁止把密钥写进话术。 销售可见固定句经 bot_reply 按调用编码读后台;停用/失败回退本文件默认句。
禁止:在本文件直接 request 主账;禁止把密钥写进话术。
""" """
from __future__ import annotations from __future__ import annotations
@@ -27,6 +28,17 @@ from agent.schema.field_validate import (
from agent.schema.land_options import option_prompt as _land_option_prompt from agent.schema.land_options import option_prompt as _land_option_prompt
from agent.schema.quote_adjust import total_price_label from agent.schema.quote_adjust import total_price_label
def _r(code: str, default: str, **kwargs: Any) -> str:
"""
按后台调用编码取回复语。
停用、库空、主账失败返回 default。本函数不打主账,只调 bot_reply 短缓存。
"""
from agent.policy.bot_reply import render
return render(code, default, **kwargs)
# 空运补问:必填 7 项 + 非必填。报价日期默认当天,不进待补充。 # 空运补问:必填 7 项 + 非必填。报价日期默认当天,不进待补充。
_AIR_EXAMPLES = { _AIR_EXAMPLES = {
"起运港": "南京", "起运港": "南京",
@@ -50,9 +62,11 @@ _AIR_OPTIONAL_CLARIFY = tuple(
(key, label, _AIR_EXAMPLES.get(key, "")) for key, label in AIR_OPTIONAL_FIELDS (key, label, _AIR_EXAMPLES.get(key, "")) for key, label in AIR_OPTIONAL_FIELDS
) )
AIR_CONFIRM_TAIL = "请回复“确定”进行TMS查价,内容有误时,请重新发内容。" AIR_CONFIRM_TAIL = "请回复“确定”进行TMS查价,内容有误时,请重新发内容。"
# 核对卡已取消时效,不再对销售发送此句;常量仅保留避免旧引用报错。
AIR_CONFIRM_EXPIRED = "刚才的空运核对已过期,请把空运需求再发一次,出核对卡后再回复确定。" AIR_CONFIRM_EXPIRED = "刚才的空运核对已过期,请把空运需求再发一次,出核对卡后再回复确定。"
# 海运私聊与空运同一句。核对卡没有工单号,回复确定后才建单查价。 # 海运私聊与空运同一句。核对卡没有工单号,回复确定后才建单查价。
SEA_CONFIRM_TAIL = "请回复“确定”进行TMS查价,内容有误时,请重新发内容。" SEA_CONFIRM_TAIL = "请回复“确定”进行TMS查价,内容有误时,请重新发内容。"
# 核对卡已取消时效,不再对销售发送此句。
SEA_CONFIRM_EXPIRED = "刚才的海运核对已过期,请把海运需求再发一次,出核对卡后再回复确定。" SEA_CONFIRM_EXPIRED = "刚才的海运核对已过期,请把海运需求再发一次,出核对卡后再回复确定。"
# 海运补问:与合同 10 项一致。报价日期默认当天,不进待补充。 # 海运补问:与合同 10 项一致。报价日期默认当天,不进待补充。
@@ -125,10 +139,79 @@ ASK_TRANSPORT = (
"2.海运\n" "2.海运\n"
"3.空运" "3.空运"
) )
def ask_transport() -> str:
"""未识别运输方式。后台 inquiring.ask_transport。"""
return _r("inquiring.ask_transport", ASK_TRANSPORT)
def first_chat() -> str:
"""销售首次单聊。后台 inquiring.first_chat。"""
return _r("inquiring.first_chat", FIRST_CHAT)
def image_recognizing() -> str:
"""正在识别图片。后台 inquiring.image_recognizing。"""
return _r("inquiring.image_recognizing", IMAGE_RECOGNIZING)
def image_tech_fail() -> str:
"""图片技术失败。后台 inquiring.image_tech_fail。"""
return _r("inquiring.image_tech_fail", IMAGE_TECH_FAIL)
def attach_recognizing() -> str:
"""正在识别附件。后台 inquiring.attach_recognizing。"""
return _r("inquiring.attach_recognizing", ATTACH_RECOGNIZING)
def attach_tech_fail() -> str:
"""附件技术失败。后台 inquiring.attach_tech_fail。"""
return _r("inquiring.attach_tech_fail", ATTACH_TECH_FAIL)
def attach_h5_expired() -> str:
"""H5 上传过期。后台 inquiring.attach_h5_expired。"""
return _r("inquiring.attach_h5_expired", ATTACH_H5_EXPIRED)
def attach_h5_ok() -> str:
"""H5 上传成功。后台 inquiring.attach_h5_ok。"""
return _r("inquiring.attach_h5_ok", ATTACH_H5_OK)
def continue_need_wo() -> str:
"""续办但未提供工单号。后台 inquiring.continue_need_wo。"""
return _r("inquiring.continue_need_wo", "请告诉我要继续的工单号,例如 WO202609140034。")
def continue_ticket_not_found(work_order_no: str) -> str:
"""续办找不到工单。后台 inquiring.continue_not_found(工单号后有空格)。"""
no = (work_order_no or "").strip()
return _r(
"inquiring.continue_not_found",
f"找不到工单 {no},请核对工单号后再试。",
ticket_no=no,
)
def quote_generating() -> str:
"""报价单生成中。后台 inquiring.quote_generating。"""
return _r("inquiring.quote_generating", "报价单正在生成,请稍候。")
# 待补充清单:请销售整段复制,只在冒号后填值;示例行不算已填。 # 待补充清单:请销售整段复制,只在冒号后填值;示例行不算已填。
CLARIFY_PASTE_HINT = "请复制粘贴以下内容,通过回复数字或文字方式,告知我以下字段值:" CLARIFY_PASTE_HINT = "请复制粘贴以下内容,通过回复数字或文字方式,告知我以下字段值:"
_PENDING_UNITS = {} _PENDING_UNITS = {}
HANDOFF_ALREADY_COLLAB = "该工单已拉产品进群协同。" HANDOFF_ALREADY_COLLAB = "该工单已拉产品进群协同。"
def already_collab() -> str:
"""该工单已拉过产品进群。后台 transfer.already_collab。"""
return _r("transfer.already_collab", HANDOFF_ALREADY_COLLAB)
HANDOFF_CLARIFY_REASON = "参数提取不全(连续 3 次反问仍不完整)" HANDOFF_CLARIFY_REASON = "参数提取不全(连续 3 次反问仍不完整)"
HANDOFF_INTENT_REASON = "已识别您希望转人工处理" HANDOFF_INTENT_REASON = "已识别您希望转人工处理"
CLARIFY_LIMIT_HINT = "连续 3 次反问仍不完整将触发转人工" CLARIFY_LIMIT_HINT = "连续 3 次反问仍不完整将触发转人工"
@@ -214,6 +297,7 @@ def land_sample_image_fallback() -> str:
FIRST_CHAT = "你好,我是询价机器人。请直接描述客户需求,例如「上海到洛杉矶空运,3 件 180kg,下周三前到」,我会解析需求并查询 TMS 标准报价。" FIRST_CHAT = "你好,我是询价机器人。请直接描述客户需求,例如「上海到洛杉矶空运,3 件 180kg,下周三前到」,我会解析需求并查询 TMS 标准报价。"
ROUTE_MODEL_FAILED = "刚才没听清,请再说一次:是新询价,还是要接着办哪张工单?" ROUTE_MODEL_FAILED = "刚才没听清,请再说一次:是新询价,还是要接着办哪张工单?"
LAND_CONFIRM_TAIL = "请回复“确定”进行TMS查价,内容有误时,请重新发内容。" LAND_CONFIRM_TAIL = "请回复“确定”进行TMS查价,内容有误时,请重新发内容。"
# 核对卡已取消时效。仅工单号丢失时内部兜底,不对销售当「过期」主路径。
LAND_CONFIRM_EXPIRED = "刚才的陆运核对已过期,请把陆运需求再发一次,出核对卡后再回复确定。" LAND_CONFIRM_EXPIRED = "刚才的陆运核对已过期,请把陆运需求再发一次,出核对卡后再回复确定。"
LAND_TMS_RETRY = "工单 {wo} 上次查价没成功。回复「确定」按原需求再查一次,不用重新填。" LAND_TMS_RETRY = "工单 {wo} 上次查价没成功。回复「确定」按原需求再查一次,不用重新填。"
LAND_TMS_RETRYING = "正在重新查询TMS报价。工单:{wo}" LAND_TMS_RETRYING = "正在重新查询TMS报价。工单:{wo}"
@@ -274,8 +358,11 @@ def land_confirm_card(facts: dict[str, str] | None) -> str:
def land_group_no_staff() -> str: def land_group_no_staff() -> str:
"""没有对上线路的陆运产品,不建空群。""" """没有对上线路的陆运产品,不建空群。后台 inquiring.land_no_staff。"""
return "没有可拉的产品人员。请先在后台「员工管理」维护本线路的陆运产品岗后再试。" return _r(
"inquiring.land_no_staff",
"没有可拉的产品人员。请先在后台「员工管理」维护本线路的陆运产品岗后再试。",
)
def land_group_pair_rejected() -> str: def land_group_pair_rejected() -> str:
@@ -936,7 +1023,12 @@ def quote_fee_lines(quote: dict[str, Any] | None) -> list[tuple[str, str]]:
amount = str(item.get("amount") or item.get("value") or "").strip() amount = str(item.get("amount") or item.get("value") or "").strip()
line_ccy = str(item.get("currency") or item.get("currencyCode") or header_ccy).strip() line_ccy = str(item.get("currency") or item.get("currencyCode") or header_ccy).strip()
if name and amount and amount != "-": if name and amount and amount != "-":
lines.append((name, money_text(amount, line_ccy))) # 燃油附加费率 / 税率是百分比,禁止套成「CNY 1%」。
if "%" in amount or name.endswith("费率") or name in {"税率", "燃油附加费率"}:
shown = amount if "%" in amount else f"{amount}%"
lines.append((name, shown))
else:
lines.append((name, money_text(amount, line_ccy)))
if lines: if lines:
return lines return lines
freight = str(src.get("freight") or "").strip() freight = str(src.get("freight") or "").strip()
@@ -1132,10 +1224,21 @@ def sea_quote_detail_text(
def collab_hint(work_order_no: str) -> str: def collab_hint(work_order_no: str) -> str:
"""价格卡协同说明:无「内部协同」前缀,紧挨生成报价单按钮上方。""" """
return ( 空运价格卡协同说明:紧挨「不需要协同,直接生成报价单」上方。
f"若需要咨询内部航线人员,请手动拉新人与智能体到原有群,"
f"并在群聊发送工单编号{work_order_no}。" 后台 inquiring.air_collab_hint;库内模板可用 {{ticket_no}}。
无后台模板时用本默认句(已带工单号)。销售侧示例:
…发送“ WO202610060030@询价小助手”,谢谢。
"""
no = (work_order_no or "").strip()
return _r(
"inquiring.air_collab_hint",
(
f"若需要咨询内部航线人员,请手动将相关方拉到空运原有协同群,"
f"并在空运协同群中发送“ {no}@询价小助手”,谢谢。"
),
ticket_no=no,
) )
@@ -1354,8 +1457,13 @@ def skip_collab_card_update(*, response_code: str, replace_name: str = "") -> di
def tms_miss_quote_text(*, work_order_no: str) -> str: def tms_miss_quote_text(*, work_order_no: str) -> str:
"""文字兜底里的拉群句,与现网图一致。""" """请手动拉人进群并发送工单号。后台 inquiring.pull_group_manual。"""
return f"请手动拉相关人员和智能体到原有群,并在群聊发送工单编号{work_order_no}" no = (work_order_no or "").strip()
return _r(
"inquiring.pull_group_manual",
f"请手动拉相关人员和智能体到原有群,并在群聊发送工单编号{no}",
ticket_no=no,
)
def tms_miss_hint() -> str: def tms_miss_hint() -> str:
@@ -1438,7 +1546,11 @@ def tms_need_more(*, work_order_no: str, hint: str = "") -> str:
def skip_collab_ack() -> str: def skip_collab_ack() -> str:
return "好的,已跳过内部协同。正在按 TMS 标准报价生成报价单草稿…" """销售跳过内部协同。后台 inquiring.skip_collab_ack。"""
return _r(
"inquiring.skip_collab_ack",
"好的,已跳过内部协同。正在按 TMS 标准报价生成报价单草稿…",
)
def sea_collab_ask() -> str: def sea_collab_ask() -> str:
@@ -1584,13 +1696,13 @@ def sea_tms_miss_wecom_payload(*, work_order_no: str, action_url: str = "") -> d
MULTI_CONFIRM_TAIL = "请回复「确定」创建工单,内容有误时,请重新发内容。" MULTI_CONFIRM_TAIL = "请回复「确定」创建工单,内容有误时,请重新发内容。"
MULTI_SAME_MODE = "同一运输方式出现了两段,请拆开再发。" MULTI_SAME_MODE = "同一运输方式出现了两段,请拆开再发。"
# 核对卡已取消时效,不再对销售发送此句。
MULTI_CONFIRM_EXPIRED = "刚才的多段核对已过期,请把多段需求再发一次,出核对后再回复确定。" MULTI_CONFIRM_EXPIRED = "刚才的多段核对已过期,请把多段需求再发一次,出核对后再回复确定。"
def multi_air_manual_hint(*, work_order_no: str) -> str: def multi_air_manual_hint(*, work_order_no: str) -> str:
"""含空运的多段:不自动拉群,请销售把人和智能体拉进原有群并发送工单号。""" """含空运的多段:不自动拉群。与 inquiring.pull_group_manual 同一条后台。"""
no = (work_order_no or "").strip() return tms_miss_quote_text(work_order_no=work_order_no)
return f"请手动拉相关人员和智能体到原有群,并在群聊发送工单编号{no}"
def multi_pull_text(*, work_order_no: str) -> str: def multi_pull_text(*, work_order_no: str) -> str:
@@ -1629,11 +1741,16 @@ def multi_pull_wecom_payload(*, work_order_no: str) -> dict[str, Any]:
def handoff_air_hint(*, work_order_no: str) -> str: def handoff_air_hint(*, work_order_no: str) -> str:
"""空运转人工:只发文字,不建群。""" """空运转人工:只发文字,不建群。后台 transfer.air_hint。"""
no = (work_order_no or "").strip() no = (work_order_no or "").strip()
if no: if no:
return f"触发转人工,请手动把航线和智能体拉进原有空运群,并发送工单号 {no}。" default = f"触发转人工,请手动把航线和智能体拉进原有空运群,并发送工单号 {no}。"
return "触发转人工,请手动把航线和智能体拉进原有空运群,并发送工单号。" return _r("transfer.air_hint", default, ticket_no=no)
return _r(
"transfer.air_hint",
"触发转人工,请手动把航线和智能体拉进原有空运群,并发送工单号。",
ticket_no="",
)
def handoff_offer_text(*, reason: str, transport_mode: str) -> str: def handoff_offer_text(*, reason: str, transport_mode: str) -> str:
@@ -1729,27 +1846,45 @@ def _role_label(name: str, role: str) -> str:
def sea_group_created(*, work_order_no: str, sales_name: str, product_names: list[str]) -> str: def sea_group_created(*, work_order_no: str, sales_name: str, product_names: list[str]) -> str:
"""拉群成功后的私聊。已邀请写成「销售某某、产品某某」,岗位不能省略。""" """拉群成功后的私聊。后台 inquiring.group_created。已邀请写成「销售某某、产品某某」。"""
no = (work_order_no or "").strip() no = (work_order_no or "").strip()
sales = _role_label(sales_name, "销售") sales_raw = (sales_name or "").strip()
products = "、".join( sales = _role_label(sales_raw, "销售")
_role_label(n, "产品") for n in (product_names or []) if (n or "").strip() name_bits = [n.strip() for n in (product_names or []) if (n or "").strip()]
) or "产品" products = "、".join(_role_label(n, "产品") for n in name_bits) or "产品"
return ( default = (
f"协同群已创建,群名与工单号一致:{no}。" f"协同群已创建,群名与工单号一致:{no}。"
f"已邀请:{sales}、{products}。请在群聊中继续确认报价。" f"已邀请:{sales}、{products}。请在群聊中继续确认报价。"
) )
sales_var = sales_raw[2:] if sales_raw.startswith("销售") else sales_raw
prod_var = "、".join(
(n[2:] if n.startswith("产品") else n) for n in name_bits
) or "产品"
return _r(
"inquiring.group_created",
default,
ticket_no=no,
sales_name=sales_var,
product_names=prod_var,
)
def sea_group_exists(*, work_order_no: str) -> str: def sea_group_exists(*, work_order_no: str) -> str:
"""同一工单已有群,不再新建。""" """同一工单已有群,不再新建。后台 inquiring.group_exists。"""
no = (work_order_no or "").strip() no = (work_order_no or "").strip()
return f"协同群已在,群名与工单号一致:{no}。请到该群继续确认报价。" return _r(
"inquiring.group_exists",
f"协同群已在,群名与工单号一致:{no}。请到该群继续确认报价。",
ticket_no=no,
)
def sea_group_no_staff() -> str: def sea_group_no_staff() -> str:
"""没有可拉的产品人员,不建空群。""" """没有可拉的产品人员,不建空群。后台 inquiring.sea_no_staff。"""
return "没有可拉的产品人员。请先在后台「员工管理」维护本线路的海运产品岗后再试。" return _r(
"inquiring.sea_no_staff",
"没有可拉的产品人员。请先在后台「员工管理」维护本线路的海运产品岗后再试。",
)
def group_sheet_send_fail() -> str: def group_sheet_send_fail() -> str:
@@ -1758,11 +1893,10 @@ def group_sheet_send_fail() -> str:
def sea_group_create_fail(*, reason: str = "") -> str: def sea_group_create_fail(*, reason: str = "") -> str:
"""建群失败,按钮保持可点。""" """建群失败,按钮保持可点。后台 inquiring.group_create_fail(无原因短句)。"""
extra = (reason or "").strip() extra = (reason or "").strip()
if extra: default = f"建群失败:{extra}。请稍后重试。" if extra else "建群失败,请稍后重试。"
return f"建群失败:{extra}。请稍后重试。" return _r("inquiring.group_create_fail", default, reason=extra)
return "建群失败,请稍后重试。"
def quote_is_product(quote: dict[str, Any] | None) -> bool: def quote_is_product(quote: dict[str, Any] | None) -> bool:
@@ -2212,7 +2346,11 @@ def scheme_wecom_payload(
def reject_ack() -> str: def reject_ack() -> str:
return "请问如何调整报价单,以满足您的需求?请说明要调哪一项费用,例如「提货费加 10%」。" """销售选择不采用。后台 quoted.reject_ack。"""
return _r(
"quoted.reject_ack",
"请问如何调整报价单,以满足您的需求?请说明要调哪一项费用,例如「提货费加 10%」。",
)
def adjust_confirm(applied: list[dict[str, Any]]) -> str: def adjust_confirm(applied: list[dict[str, Any]]) -> str:
@@ -2278,23 +2416,32 @@ def adjust_group_body(quote: dict[str, Any] | None, business_line: str = "") ->
def adopt_excel_ack() -> str: def adopt_excel_ack() -> str:
return "已确认采用该方案,正在生成 Excel 最终报价单…" """采用并生成 Excel。后台 quoted.adopt_excel。"""
return _r("quoted.adopt_excel", "已确认采用该方案,正在生成 Excel 最终报价单…")
def adopt_pdf_ack() -> str: def adopt_pdf_ack() -> str:
return "已确认采用该方案,正在生成PDF最终报价单…" """采用并生成 PDF。后台 quoted.adopt_pdf。"""
return _r("quoted.adopt_pdf", "已确认采用该方案,正在生成PDF最终报价单…")
QUOTE_TEMPLATE_MISSING = "生成报价单失败,请联系IT运维维护后台报价模板。" QUOTE_TEMPLATE_MISSING = "生成报价单失败,请联系IT运维维护后台报价模板。"
def quote_template_missing() -> str: def quote_template_missing() -> str:
"""关键词与默认模板都未维护时的销售提示(产品口径,勿改字)。""" """关键词与默认模板都未维护。后台 quoted.template_missing。"""
return QUOTE_TEMPLATE_MISSING return _r("quoted.template_missing", QUOTE_TEMPLATE_MISSING)
def file_ready(*, kind: str, work_order_no: str, filename: str) -> str: def file_ready(*, kind: str, work_order_no: str, filename: str) -> str:
return f"已生成{kind}最终报价单:{filename}(工单 {work_order_no})" """最终报价单生成完成。后台 quoted.file_ready。"""
return _r(
"quoted.file_ready",
f"已生成{kind}最终报价单:{filename}(工单 {work_order_no})",
kind=kind,
filename=filename,
ticket_no=work_order_no,
)
# 未成交原因说明跟在未成交后,不跟在协商中后。验收脚本认这个开关。 # 未成交原因说明跟在未成交后,不跟在协商中后。验收脚本认这个开关。
@@ -2429,7 +2576,11 @@ def group_only_excel_pdf() -> str:
def group_only_product_can_quote() -> str: def group_only_product_can_quote() -> str:
return "只有产品能报价格,请产品在群里发价格或 Excel/PDF" """非产品人员在群里报价。后台 inquiring.group_only_product。"""
return _r(
"inquiring.group_only_product",
"只有产品能报价格,请产品在群里发价格或 Excel/PDF",
)
_GROUP_QUOTE_TAIL = ( _GROUP_QUOTE_TAIL = (
@@ -2464,14 +2615,24 @@ def group_no_tms_quote_hint(*, business_line: str = "") -> str:
def quote_attachment_saved_app(work_order_no: str, count: int = 1) -> str: def quote_attachment_saved_app(work_order_no: str, count: int = 1) -> str:
"""群里告诉大家:模板或产品附件已进后台。""" """
群里告诉大家:模板或产品附件已进后台。
后台 inquiring.group_file_saved。默认句带工单号和份数;后台改成短句则原样用。
"""
no = (work_order_no or "").strip() or "WO" no = (work_order_no or "").strip() or "WO"
n = count if count > 0 else 1 n = count if count > 0 else 1
return f"工单{no}的{n}个附件已保存到后台工单" return _r(
"inquiring.group_file_saved",
f"工单{no}的{n}个附件已保存到后台工单",
ticket_no=no,
count=n,
)
def group_confirm_need_tms() -> str: def group_file_saved(*, work_order_no: str = "", count: int = 1) -> str:
return "本单还没有可沿用的 TMS 报价,请直接回复协同报价或上传报价附件。" """群附件已保存。与 quote_attachment_saved_app 同一条后台编码。"""
return quote_attachment_saved_app(work_order_no, count)
def group_ask_tms_or_product() -> str: def group_ask_tms_or_product() -> str:
@@ -2490,8 +2651,8 @@ def group_need_price_before_choice() -> str:
return "请产品先发价格,再说在TMS基础上改或不用TMS报价" return "请产品先发价格,再说在TMS基础上改或不用TMS报价"
def group_file_saved() -> str: def group_confirm_need_tms() -> str:
return "附件已保存后台" return "本单还没有可沿用的 TMS 报价,请直接回复协同报价或上传报价附件。"
def group_quote_not_recognized() -> str: def group_quote_not_recognized() -> str:
@@ -2516,8 +2677,24 @@ def group_deal_stale(*, work_order_no: str) -> str:
return f"这张成交确认已过期。工单{no}已有更新的报价,请点击群里最新一条成交跟进。" return f"这张成交确认已过期。工单{no}已有更新的报价,请点击群里最新一条成交跟进。"
def group_deal_need_quote() -> str: def group_deal_need_quote(*, work_order_no: str = "") -> str:
return "还没有可确认的报价,请先完成报价再确认成交状态。" """
群里确认成交/未成交时,主账还没有可确认报价。
空运 WO027:询价中且无 inq_quote,旧句「主账状态更新失败」不说明原因。
"""
no = (work_order_no or "").strip()
if no:
return (
f"工单{no}状态更新失败,原因:没有报价。\n"
f"请先报价,然后再更新工单状态。"
)
return "工单状态更新失败,原因:没有报价。\n请先报价,然后再更新工单状态。"
def deal_fail_no_quote(*, work_order_no: str = "") -> str:
"""成交三态写入失败且判定为没有报价时的销售可见说明。与群门禁同句。"""
return group_deal_need_quote(work_order_no=work_order_no)
def group_deal_sales_only() -> str: def group_deal_sales_only() -> str:
@@ -2561,18 +2738,26 @@ def deal_ack(
next_followup_at: str = "", next_followup_at: str = "",
lost_reason: str = "", lost_reason: str = "",
) -> str: ) -> str:
"""成交三态回执。协商中写明顺延三个工作日和下次再问的月日 9:00。""" """成交三态回执。后台 deal.recorded_won / lost.ask_reason / negotiating.recorded。"""
no = (work_order_no or "").strip()
if outcome == "已成交": if outcome == "已成交":
return f"已记录为「已成交」。工单 {work_order_no} 已关闭智能体跟进。" return _r(
"deal.recorded_won",
f"已记录为「已成交」。工单 {no} 已关闭智能体跟进。",
ticket_no=no,
)
if outcome == "未成交": if outcome == "未成交":
reason = (lost_reason or "").strip() reason = (lost_reason or "").strip()
if reason: if reason:
return f"已记录为「未成交」。{lost_reason_ack(reason)}" return f"已记录为「未成交」。{lost_reason_ack(reason, work_order_no=no)}"
return "已记录为「未成交」。请补充未成交原因。" return _r("lost.ask_reason", "已记录为「未成交」。请补充未成交原因。", ticket_no=no)
when = _followup_month_day(next_followup_at) when = _followup_month_day(next_followup_at) or "第四个工作日"
if when: return _r(
return f"已记录为「协商中」。已顺延三个工作日,{when}的9:00再次发起成交状态询问。" "negotiating.recorded",
return "已记录为「协商中」。已顺延三个工作日,第四个工作日早上9:00再次发起成交状态询问。" f"已记录为「协商中」。已顺延三个工作日,{when}的9:00再次发起成交状态询问。",
followup_when=when,
ticket_no=no,
)
def _followup_month_day(raw: str) -> str: def _followup_month_day(raw: str) -> str:
@@ -2591,12 +2776,19 @@ def _followup_month_day(raw: str) -> str:
return "" return ""
def lost_reason_ack(reason: str = "") -> str: def lost_reason_ack(reason: str = "", work_order_no: str = "") -> str:
"""收下未成交原因后回显原句,不再追问。""" """
收下未成交原因后回显。后台 lost.reason_ack。
测服文案含 {{ticket_no}} / {{lost_reason}};缺 ticket_no 会原样打出占位符。
"""
text = (reason or "").strip() text = (reason or "").strip()
no = (work_order_no or "").strip()
if text.endswith(("。", ".", "!", "!", "?", "?")): if text.endswith(("。", ".", "!", "!", "?", "?")):
return f"已记下未成交原因:{text}" default = f"已记下未成交原因:{text}"
return f"已记下未成交原因:{text}。" if text else "已记下未成交原因。" else:
default = f"已记下未成交原因:{text}。" if text else "已记下未成交原因。"
return _r("lost.reason_ack", default, lost_reason=text, ticket_no=no)
def deal_notify_card( def deal_notify_card(
@@ -2679,29 +2871,49 @@ def air_need_work_order() -> str:
def air_lock_need_work_order() -> str: def air_lock_need_work_order() -> str:
"""锁舱/释放必须自带工单号,不猜当前单。""" """锁舱/释放必须自带工单号。后台 inquiring.air_lock_need_wo。"""
return "锁舱或释放舱位请带上工单号,例如 WO202609160001锁舱。" return _r(
"inquiring.air_lock_need_wo",
"锁舱或释放舱位请带上工单号,例如 WO202609160001锁舱。",
)
def air_ticket_not_found(work_order_no: str) -> str: def air_ticket_not_found(work_order_no: str) -> str:
"""空运群找不到工单。后台 inquiring.air_ticket_not_found。"""
no = (work_order_no or "").strip() no = (work_order_no or "").strip()
return f"找不到工单{no},请核对工单号后再试。" return _r("inquiring.air_ticket_not_found", f"找不到工单{no},请核对工单号后再试。", ticket_no=no)
def air_ticket_not_air(work_order_no: str) -> str: def air_ticket_not_air(work_order_no: str) -> str:
"""工单不是空运单。后台 inquiring.air_ticket_not_air。"""
no = (work_order_no or "").strip() no = (work_order_no or "").strip()
return f"工单{no}不是空运单,不能在空运群里跟。" return _r(
"inquiring.air_ticket_not_air",
f"工单{no}不是空运单,不能在空运群里跟。",
ticket_no=no,
)
def air_ticket_closed(work_order_no: str, status: str) -> str: def air_ticket_closed(work_order_no: str, status: str) -> str:
"""空运工单已是终态仍要激活。后台 closed.air_cannot_activate。"""
no = (work_order_no or "").strip() no = (work_order_no or "").strip()
st = (status or "").strip() or "已关闭" st = (status or "").strip() or "已关闭"
return f"工单{no}已是「{st}」,不能再激活。" return _r(
"closed.air_cannot_activate",
f"工单{no}已是「{st}」,不能再激活。",
ticket_no=no,
status=st,
)
def air_ticket_occupied(work_order_no: str) -> str: def air_ticket_occupied(work_order_no: str) -> str:
"""空运工单已在另一个群。后台 inquiring.air_ticket_occupied。"""
no = (work_order_no or "").strip() no = (work_order_no or "").strip()
return f"工单{no}已经在另一个群跟了,请到那个群继续。" return _r(
"inquiring.air_ticket_occupied",
f"工单{no}已经在另一个群跟了,请到那个群继续。",
ticket_no=no,
)
def air_group_brief( def air_group_brief(
@@ -2820,7 +3032,11 @@ def air_airline_only_quote() -> str:
def air_airline_only_cabin() -> str: def air_airline_only_cabin() -> str:
return "只有岗位为「航线·空运」的人能锁舱或释放舱位。" """只有航线能锁舱或释放。后台 inquiring.air_airline_only_cabin。"""
return _r(
"inquiring.air_airline_only_cabin",
"只有岗位为「航线·空运」的人能锁舱或释放舱位。",
)
def air_sales_only_deal() -> str: def air_sales_only_deal() -> str:
@@ -2844,9 +3060,13 @@ def air_no_tms_quote_to_lock(work_order_no: str) -> str:
def air_lock_need_quote(work_order_no: str) -> str: def air_lock_need_quote(work_order_no: str) -> str:
"""没有任何报价时的锁舱拒绝。不提 TMS,避免把人工报价说成没报价。""" """没有任何报价时的锁舱拒绝。后台 inquiring.air_lock_need_quote。"""
no = (work_order_no or "").strip() no = (work_order_no or "").strip()
return f"工单{no}还没有报价,不能锁舱。" return _r(
"inquiring.air_lock_need_quote",
f"工单{no}还没有报价,不能锁舱。",
ticket_no=no,
)
def air_ask_lock_option(options: list) -> str: def air_ask_lock_option(options: list) -> str:
@@ -2861,6 +3081,53 @@ def air_ask_lock_option(options: list) -> str:
return "\n".join(lines) return "\n".join(lines)
def air_ask_manual_lock_fields(
*,
need_option: bool = False,
need_airline: bool = False,
need_chargeable: bool = False,
need_flight: bool = False,
options: list | None = None,
) -> str:
"""
手动报价锁舱缺 TMS MANUAL 必填项时,群里补问,禁止空壳出站。
例:请回复「AIR-OPT01 CZ 航班CZ327」。
"""
bits: list[str] = []
if need_option:
bits.append("舱位编号(如 AIR-OPT01)")
if need_airline:
bits.append("航司两位码(如 CZ)")
if need_flight:
bits.append("航班号(如 CZ327)")
if need_chargeable:
bits.append("计费重(公斤)")
need = "、".join(bits) if bits else "舱位编号、航司和航班号"
lines = [
f"锁舱还缺:{need}。",
"请按「AIR-OPT01 CZ 航班CZ327」回复;",
]
if need_chargeable:
lines.append("若还缺计费重,请再写「计费重xx公斤」。")
else:
lines.append("例如:AIR-OPT01 CZ 航班CZ327")
opts = options or []
shown = False
for item in opts:
if not isinstance(item, dict):
continue
no = str(item.get("optionNo") or item.get("option_no") or "").strip()
airline = str(item.get("airlineCode") or item.get("airlineName") or "").strip()
if not no:
continue
if not shown:
lines.append("可选舱位:")
shown = True
lines.append(f"{no} {airline}".strip() if airline else no)
return "\n".join(lines)
def air_lock_ok( def air_lock_ok(
*, *,
work_order_no: str, work_order_no: str,
@@ -2869,23 +3136,60 @@ def air_lock_ok(
exec_no: str, exec_no: str,
quote_no: str, quote_no: str,
) -> str: ) -> str:
return ( """空运锁舱成功。后台 inquiring.air_lock_ok。"""
default = (
f"工单{work_order_no} 已通知TMS执行空运锁舱,执行成功。\n" f"工单{work_order_no} 已通知TMS执行空运锁舱,执行成功。\n"
f"操作人:{operator}\n" f"操作人:{operator}\n"
f"舱位选项:{option_no}\n" f"舱位选项:{option_no}\n"
f"执行编号:{exec_no}\n" f"执行编号:{exec_no}\n"
f"报价编号:{quote_no}" f"报价编号:{quote_no}"
) )
return _r(
"inquiring.air_lock_ok",
default,
ticket_no=work_order_no,
operator=operator,
option_no=option_no,
exec_no=exec_no,
quote_no=quote_no,
)
def air_lock_fail(*, work_order_no: str, operator: str, reason: str) -> str: def air_lock_fail(*, work_order_no: str, operator: str, reason: str) -> str:
"""空运锁舱失败(接口/系统类)。后台 inquiring.air_lock_fail。"""
why = (reason or "").strip() or "TMS未返回原因" why = (reason or "").strip() or "TMS未返回原因"
return ( default = (
f"工单{work_order_no} 空运锁舱失败。\n" f"工单{work_order_no} 空运锁舱失败。\n"
f"操作人:{operator}\n" f"操作人:{operator}\n"
f"锁舱失败的原因:{why}\n" f"锁舱失败的原因:{why}\n"
"已通知IT运维排查,请稍等。" "已通知IT运维排查,请稍等。"
) )
return _r(
"inquiring.air_lock_fail",
default,
ticket_no=work_order_no,
operator=operator,
reason=why,
)
def air_lock_incomplete(*, work_order_no: str, operator: str, reason: str = "") -> str:
"""
锁舱参数不齐(纯手工价、运价库无舱位等):业务补问,不算系统异常,不惊动 IT。
航线按示例回舱位+航司+航班后即可再锁。
"""
why = (reason or "").strip()
lines = [
f"工单{work_order_no} 还不能锁舱。",
f"操作人:{operator}",
]
if why:
lines.append(f"原因:{why}")
else:
lines.append("原因:本单没有可引用的 TMS 舱位(手工价或运价库无匹配)。")
lines.append("请直接回复「AIR-OPT01 CZ 航班CZ327」(换成实际舱位、航司、航班号)后再锁。")
return "\n".join(lines)
def air_cabin_fail_reason(*, tms_reason: str = "", flight_date: str = "", today: str = "") -> str: def air_cabin_fail_reason(*, tms_reason: str = "", flight_date: str = "", today: str = "") -> str:
@@ -2907,33 +3211,61 @@ def air_release_ok(
exec_no: str, exec_no: str,
quote_no: str, quote_no: str,
) -> str: ) -> str:
return ( """空运释放成功。后台 inquiring.air_release_ok。"""
default = (
f"工单{work_order_no} 已通知TMS执行空运释放舱位成功\n" f"工单{work_order_no} 已通知TMS执行空运释放舱位成功\n"
f"操作人:{operator}\n" f"操作人:{operator}\n"
f"舱位选项:{option_no}\n" f"舱位选项:{option_no}\n"
f"执行编号:{exec_no}\n" f"执行编号:{exec_no}\n"
f"报价编号:{quote_no}" f"报价编号:{quote_no}"
) )
return _r(
"inquiring.air_release_ok",
default,
ticket_no=work_order_no,
operator=operator,
option_no=option_no,
exec_no=exec_no,
quote_no=quote_no,
)
def air_release_fail(*, work_order_no: str, operator: str, reason: str) -> str: def air_release_fail(*, work_order_no: str, operator: str, reason: str) -> str:
"""空运释放失败。后台 inquiring.air_release_fail。"""
why = (reason or "").strip() or "TMS未返回原因" why = (reason or "").strip() or "TMS未返回原因"
return ( default = (
f"工单{work_order_no} 空运释放舱位失败。\n" f"工单{work_order_no} 空运释放舱位失败。\n"
f"操作人:{operator}\n" f"操作人:{operator}\n"
f"释放舱位失败的原因:{why}\n" f"释放舱位失败的原因:{why}\n"
"已通知IT运维排查,请稍等。" "已通知IT运维排查,请稍等。"
) )
return _r(
"inquiring.air_release_fail",
default,
ticket_no=work_order_no,
operator=operator,
reason=why,
)
def air_release_deal_blocked(work_order_no: str) -> str: def air_release_deal_blocked(work_order_no: str) -> str:
"""已成交不能释放舱位。后台 inquiring.air_release_deal_blocked。"""
no = (work_order_no or "").strip() no = (work_order_no or "").strip()
return f"释放舱位失败,工单{no}已成交。" return _r(
"inquiring.air_release_deal_blocked",
f"释放舱位失败,工单{no}已成交。",
ticket_no=no,
)
def air_release_need_lock(work_order_no: str) -> str: def air_release_need_lock(work_order_no: str) -> str:
"""没有有效锁舱不能释放。后台 inquiring.air_release_need_lock。"""
no = (work_order_no or "").strip() no = (work_order_no or "").strip()
return f"工单{no}还没有有效锁舱,不能释放。" return _r(
"inquiring.air_release_need_lock",
f"工单{no}还没有有效锁舱,不能释放。",
ticket_no=no,
)
# —— 系统异常(UC024):销售/群兜底与恢复;告警卡只给有权限的人 —— # —— 系统异常(UC024):销售/群兜底与恢复;告警卡只给有权限的人 ——
@@ -2966,29 +3298,34 @@ IMPACT_GROUP = "当前建群/加人暂停,已建群不新建第二个群"
def system_exception_fallback(exception_type: str, *, names: list[str] | None = None) -> str: def system_exception_fallback(exception_type: str, *, names: list[str] | None = None) -> str:
"""判定系统异常后,给卡住的会话的兜底话术。""" """判定系统异常后,给卡住的会话的兜底话术。后台 sys.llm_fail / sys.tms_fail / sys.group_add_fail。"""
kind = (exception_type or "").strip() kind = (exception_type or "").strip()
if kind == TYPE_LLM: if kind == TYPE_LLM:
return SYS_EXC_LLM_FALLBACK return _r("sys.llm_fail", SYS_EXC_LLM_FALLBACK)
if kind == TYPE_GROUP: if kind == TYPE_GROUP:
people = [str(x).strip() for x in (names or []) if str(x).strip()] people = [str(x).strip() for x in (names or []) if str(x).strip()]
if not people:
return "无法添加同事,请联系管理员检查企业微信配置。"
joined = "、".join(people) joined = "、".join(people)
return f"无法添加 {joined} 同事,请联系管理员检查企业微信配置。" if joined:
return SYS_EXC_TMS_FALLBACK default = f"无法添加 {joined} 同事,请联系管理员检查企业微信配置。"
else:
default = "无法添加同事,请联系管理员检查企业微信配置。"
text = _r("sys.group_add_fail", default, staff_name=joined)
if not joined:
text = re.sub(r"无法添加\s+同事", "无法添加同事", text)
return text
return _r("sys.tms_fail", SYS_EXC_TMS_FALLBACK)
def system_exception_recovery(step: str) -> str: def system_exception_recovery(step: str) -> str:
"""后台点重试成功后,先发这一句,再补没做完的事。""" """后台点重试成功后,先发这一句,再补没做完的事。"""
key = (step or "").strip() key = (step or "").strip()
if key == STEP_LLM or key == STEP_LLM_NO_TICKET: if key == STEP_LLM or key == STEP_LLM_NO_TICKET:
return SYS_EXC_RECOVERY_LLM return _r("sys.llm_recovery", SYS_EXC_RECOVERY_LLM)
if key in {STEP_TMS_CABIN, STEP_TMS_LOCK, STEP_TMS_RELEASE}: if key in {STEP_TMS_CABIN, STEP_TMS_LOCK, STEP_TMS_RELEASE}:
return SYS_EXC_RECOVERY_CABIN return _r("sys.cabin_recovery", SYS_EXC_RECOVERY_CABIN)
if key == STEP_GROUP: if key == STEP_GROUP:
return SYS_EXC_RECOVERY_GROUP return _r("sys.group_recovery", SYS_EXC_RECOVERY_GROUP)
return SYS_EXC_RECOVERY_TMS return _r("sys.tms_recovery", SYS_EXC_RECOVERY_TMS)
def system_exception_impact(step: str) -> str: def system_exception_impact(step: str) -> str:
+4 -3
View File
@@ -174,7 +174,7 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
if not mode: if not mode:
mode = "LAND" if (sess and (sess.business_line or "").upper() == "LAND") else "" mode = "LAND" if (sess and (sess.business_line or "").upper() == "LAND") else ""
if not mode: if not mode:
reply(copy.ASK_TRANSPORT) reply(copy.ask_transport())
self._save( self._save(
FlowSession( FlowSession(
sender_id=sender_id, sender_id=sender_id,
@@ -364,8 +364,9 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
no = (sess.work_order_no or "").strip() no = (sess.work_order_no or "").strip()
facts = dict(sess.facts or {}) facts = dict(sess.facts or {})
if not no or not facts: if not no or not facts:
reply(copy.LAND_CONFIRM_EXPIRED) # 核对卡已取消时效,禁止再回「陆运核对已过期」。
return "land_confirm_expired" logger.warning("land.retry 缺工单或字段,不提示过期")
return "land_retry_missing"
reply(copy.LAND_TMS_RETRYING.format(wo=no)) reply(copy.LAND_TMS_RETRYING.format(wo=no))
from agent.policy.system_exception import query_tms_resilient from agent.policy.system_exception import query_tms_resilient
@@ -256,9 +256,7 @@ class MultiTextInquiryFlow:
allow_b=allow_b, allow_b=allow_b,
chat_fn=chat_fn, chat_fn=chat_fn,
) )
if sess and sess.phase == "wait_confirm": # 核对卡不再设时效。走到这里说明不是「确定」也不是补字段。
reply(copy.MULTI_CONFIRM_EXPIRED)
return "multi_confirm_expired"
reply(copy.MULTI_SAME_MODE if verdict == "same_mode" else "请按段发多段需求,例如段1海运、段2空运。") reply(copy.MULTI_SAME_MODE if verdict == "same_mode" else "请按段发多段需求,例如段1海运、段2空运。")
return "multi_need_text" return "multi_need_text"
+63 -4
View File
@@ -906,12 +906,14 @@ def apply_product_quote(
logger.info("产品报价未识别 wo=%s text=%s", no, (text or "")[:200]) logger.info("产品报价未识别 wo=%s text=%s", no, (text or "")[:200])
send_group_text(client, chat_id, copy.group_quote_not_recognized()) send_group_text(client, chat_id, copy.group_quote_not_recognized())
return "quote_not_recognized" return "quote_not_recognized"
quote = _attach_land_weight_band_segment(quote, view)
base = _current_quote(view, sess) base = _current_quote(view, sess)
if _has_tms_quote(base): if _has_tms_quote(base):
_park_pending_quote(no, quote, sess, flow) _park_pending_quote(no, quote, sess, flow)
send_group_text(client, chat_id, copy.group_ask_tms_or_product()) send_group_text(client, chat_id, copy.group_ask_tms_or_product())
return "wait_tms_choice" return "wait_tms_choice"
if _quote_fee_rows(base): # 分档单价是完整人工价(含费率),禁止和旧错误行(如燃油附加费=2)相加。
if _quote_fee_rows(base) and not (quote.get("weightPrices") or []):
quote = merge_quote_fees(base, quote) quote = merge_quote_fees(base, quote)
_save_product_quote(flow, no, quote, sess) _save_product_quote(flow, no, quote, sess)
ticket2 = _reload(flow, chat_id, no) or ticket ticket2 = _reload(flow, chat_id, no) or ticket
@@ -1231,8 +1233,11 @@ def apply_group_deal(
return "not_sales" return "not_sales"
quote = _current_quote(view, sess) quote = _current_quote(view, sess)
status_now = ticket_status(ticket) or (sess.status if sess else "") status_now = ticket_status(ticket) or (sess.status if sess else "")
if not (quote.get("total") or _quote_fee_rows(quote) or status_now in {"已报价", "协商中"}): usable_total = str(quote.get("total") or "").strip()
send_group_text(client, chat_id, copy.group_deal_need_quote()) if usable_total in {"", "-"}:
usable_total = ""
if not (usable_total or _quote_fee_rows(quote) or status_now in {"已报价", "协商中"}):
send_group_text(client, chat_id, copy.group_deal_need_quote(work_order_no=no))
return "deal_need_quote" return "deal_need_quote"
from agent.policy.deal_outcome import finish_deal_outcome, harvest_inline_lost_reason from agent.policy.deal_outcome import finish_deal_outcome, harvest_inline_lost_reason
@@ -1309,7 +1314,14 @@ def capture_group_lost_reason(
sess.phase = "done" sess.phase = "done"
sess.allowed = () sess.allowed = ()
flow._save(sess) flow._save(sess)
send_group_text(client, chat_id, copy.lost_reason_ack(str(result.get("lost_reason") or text))) send_group_text(
client,
chat_id,
copy.lost_reason_ack(
str(result.get("lost_reason") or text),
work_order_no=no,
),
)
return "done" return "done"
@@ -1634,6 +1646,53 @@ def merge_quote_fees(base: dict[str, Any] | None, overlay: dict[str, Any] | None
return stamp_prices_for_sheet(out) return stamp_prices_for_sheet(out)
def _attach_land_weight_band_segment(
quote: dict[str, Any], view: dict[str, Any]
) -> dict[str, Any]:
"""
陆运产品分档价:把 weightPrices / 燃油税率挂到 landSegments,出单才能填「2-500KG」列。
无分档价时原样返回。OD 取工单询价事实,不另猜城市。
"""
src = dict(quote or {})
tiers = src.get("weightPrices")
if not isinstance(tiers, list) or not tiers:
return src
facts = dict(view.get("facts") or {})
origin = str(
facts.get("始发地")
or facts.get("起运地")
or facts.get("origin")
or view.get("origin")
or ""
).strip()
dest = str(
facts.get("目的地")
or facts.get("destination")
or view.get("destination")
or ""
).strip()
seg: dict[str, Any] = {
"origin": origin,
"destination": dest,
"weightPrices": list(tiers),
"currency": str(src.get("currency") or "CNY"),
"salePrice": str(src.get("total") or tiers[0].get("price") or ""),
"price_per_kg": str(src.get("price_per_kg") or tiers[0].get("price") or ""),
}
fuel = str(src.get("fuel_surcharge_rate") or src.get("fuelSurchargeRate") or "").strip()
tax = str(src.get("tax_rate") or src.get("taxRate") or "").strip()
if fuel:
seg["fuel_surcharge_rate"] = fuel
seg["fuelSurchargeRate"] = fuel
if tax:
seg["tax_rate"] = tax
seg["taxRate"] = tax
src["landSegments"] = [seg]
src["route_rows"] = [seg]
return src
def _save_product_quote( def _save_product_quote(
flow: Any, flow: Any,
no: str, no: str,
+3 -5
View File
@@ -120,9 +120,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
reply=reply, reply=reply,
sess=sess, sess=sess,
) )
if not sess and looks_like_confirm(text): # 核对卡不再设时效。没有待确定会话时不当成「已过期」。
reply(copy.SEA_CONFIRM_EXPIRED)
return "confirm_expired"
# 上一单已出号:新一轮从本句重收,禁止把旧贸易条款/分类带进下一张工单。 # 上一单已出号:新一轮从本句重收,禁止把旧贸易条款/分类带进下一张工单。
sess = self._detach_ticketed_for_new_inquiry(sess) sess = self._detach_ticketed_for_new_inquiry(sess)
@@ -159,7 +157,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
mode = detect_transport_mode(f"{sess.immutable_text or ''}\n{text}") mode = detect_transport_mode(f"{sess.immutable_text or ''}\n{text}")
if not mode: if not mode:
reply(copy.ASK_TRANSPORT) reply(copy.ask_transport())
self._save( self._save(
FlowSession( FlowSession(
sender_id=sender_id, sender_id=sender_id,
@@ -888,7 +886,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
) )
if sess.collab_ended or (sess.collab_chat_id or "").strip(): if sess.collab_ended or (sess.collab_chat_id or "").strip():
reply(copy.HANDOFF_ALREADY_COLLAB) reply(copy.already_collab())
return "handoff_already" return "handoff_already"
display_no = ensure_virtual_no(sess) if not (sess.work_order_no or "").strip() else sess.work_order_no display_no = ensure_virtual_no(sess) if not (sess.work_order_no or "").strip() else sess.work_order_no
staff = self._match_handoff_staff(sess) staff = self._match_handoff_staff(sess)
@@ -850,6 +850,7 @@ def _resume_cabin_step(
work_order_no=no, work_order_no=no,
lock_id=lock_id, lock_id=lock_id,
sender_id=sender, sender_id=sender,
operator_name=str(extra.get("operator_name") or "").strip(),
instruction_text=instruction, instruction_text=instruction,
) )
or {} or {}
@@ -860,13 +861,20 @@ def _resume_cabin_step(
service = "空运舱位" service = "空运舱位"
reason = "空运释放舱位接口调用失败" reason = "空运释放舱位接口调用失败"
else: else:
# 与首次群锁舱对齐:重试必须带 operatorName / airline,否则 MANUAL 出站缺姓名。
locker = getattr(ledger, "lock_cabin", None) locker = getattr(ledger, "lock_cabin", None)
op_name = str(extra.get("operator_name") or "").strip()
airline = str(extra.get("airline") or "").strip()
flight_no = str(extra.get("flight_no") or "").strip()
out = call_with_retries( out = call_with_retries(
lambda: locker( lambda: locker(
work_order_no=no, work_order_no=no,
option_no=option_no, option_no=option_no,
sender_id=sender, sender_id=sender,
operator_name=op_name,
instruction_text=instruction, instruction_text=instruction,
airline=airline,
flight_no=flight_no,
) )
or {} or {}
if callable(locker) if callable(locker)
@@ -154,10 +154,17 @@ def _hint_lock_or_release(text: str) -> str:
def _only_work_order(text: str) -> bool: def _only_work_order(text: str) -> bool:
"""去掉工单号和 @询价小助手 后几乎没字,当作激活/切单。""" """
去掉工单号和 @询价小助手 后几乎没字,当作激活/切单。
销售常按话术复制「工单编号WO…@询价小助手」;残留「工单编号」不算业务意图。
运行证据:only_wo=false 后走 extract_product_quote→product_quote→航线岗拒绝。
"""
no = copy.extract_work_order_no(text) no = copy.extract_work_order_no(text)
rest = (text or "").replace(no, "").replace(no.lower(), "") rest = (text or "").replace(no, "").replace(no.lower(), "")
rest = rest.replace("@询价小助手", "").replace("询价小助手", "") rest = rest.replace("@询价小助手", "").replace("询价小助手", "")
for noise in ("工单编号", "工单号", "编号"):
rest = rest.replace(noise, "")
rest = "".join(ch for ch in rest if ch.isalnum() or "\u4e00" <= ch <= "\u9fff") rest = "".join(ch for ch in rest if ch.isalnum() or "\u4e00" <= ch <= "\u9fff")
return not rest.strip() return not rest.strip()
+11 -3
View File
@@ -504,12 +504,20 @@ def looks_like_confirm(text: str) -> bool:
""" """
判断是否在表示确定查价。 判断是否在表示确定查价。
「确定」「确认」「查价」「可以查了」及相近说法都算。 「确定」「确认」「查价」「可以查了」及相近短句才算。
同一句像在改始发/目的/体积/车型/「改成」则不当确定,避免改内容被当成查价。 长询价里「需确认/请确认」不算点核对卡,避免误回「核对已过期」。
同一句像在改始发/目的/体积/车型/「改成」则不当确定。
纯函数,无副作用。
""" """
raw = (text or "").strip() raw = (text or "").strip()
if not raw: if not raw:
return False return False
if any(x in raw for x in _CONFIRM_EXCLUDE): if any(x in raw for x in _CONFIRM_EXCLUDE):
return False return False
return any(p in raw for p in _CONFIRM_PHRASES) # 超过约 20 字一定是需求正文,不是「确定」短答。
if len(raw) > 20:
return False
compact = re.sub(r"[\s。!?!?,,、]+", "", raw)
if not compact:
return False
return any(p in compact for p in _CONFIRM_PHRASES)
+52 -2
View File
@@ -421,6 +421,21 @@ class AirGroupCollabTests(unittest.TestCase):
classify_air_group_text("@询价小助手 WO202609160002", allow_network=False), classify_air_group_text("@询价小助手 WO202609160002", allow_network=False),
INTENT_ACTIVATE, INTENT_ACTIVATE,
) )
# 话术前缀「工单编号」不能当成报价(WO026 复现:误走 product_quote)。
self.assertEqual(
classify_air_group_text(
"工单编号WO202610070026@询价小助手",
allow_network=False,
),
INTENT_ACTIVATE,
)
self.assertEqual(
classify_air_group_text(
"工单编号 WO202610060026@询价小助手",
allow_network=False,
),
INTENT_ACTIVATE,
)
from unittest.mock import patch from unittest.mock import patch
with patch( with patch(
@@ -434,6 +449,13 @@ class AirGroupCollabTests(unittest.TestCase):
), ),
INTENT_ACTIVATE, INTENT_ACTIVATE,
) )
self.assertEqual(
classify_air_group_text(
"工单编号WO202610070026@询价小助手",
allow_network=True,
),
INTENT_ACTIVATE,
)
def test_dispatch_aibot_goes_air(self) -> None: def test_dispatch_aibot_goes_air(self) -> None:
from agent.routing.dispatch import dispatch_inbound as _disp from agent.routing.dispatch import dispatch_inbound as _disp
@@ -906,7 +928,8 @@ class AirGroupCollabTests(unittest.TestCase):
self.assertFalse(any("AIR-OPT-01" in t for t in self._texts())) self.assertFalse(any("AIR-OPT-01" in t for t in self._texts()))
self.assertFalse(any("执行成功" in t for t in self._texts())) self.assertFalse(any("执行成功" in t for t in self._texts()))
def test_manual_quote_without_quote_id_can_lock(self) -> None: def test_manual_quote_without_option_asks_fields(self) -> None:
"""纯人工价且口令无 AIR-OPT:交主账后仍缺 → lock_incomplete(不空壳成功)。"""
wo = self._create_air( wo = self._create_air(
quote={ quote={
"total": "100", "total": "100",
@@ -921,6 +944,33 @@ class AirGroupCollabTests(unittest.TestCase):
ledger=self.ledger, ledger=self.ledger,
injected_intent="air_lock", injected_intent="air_lock",
) )
self.assertEqual(phase, "lock_incomplete")
joined = "\n".join(self._texts())
self.assertIn("舱位编号", joined)
self.assertFalse(any("执行成功" in t for t in self._texts()))
def test_manual_quote_with_option_airline_can_lock(self) -> None:
"""人工价补齐舱位+航司+航班后可锁;计费重用工单毛重兜底。"""
wo = self._create_air(
quote={
"total": "100",
"source": "manual",
"source_label": "产品报价",
"currency": "CNY",
},
status="已报价",
)
phase = handle_air_group(
self._msg(
sender="air1",
text=f"{wo}锁舱,锁AIR-OPT01 CZ 航班CZ327",
chat="wr_air_lock_manual2",
mid="lm2",
),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_lock",
)
self.assertEqual(phase, "lock_ok") self.assertEqual(phase, "lock_ok")
self.assertTrue(any("执行成功" in t for t in self._texts())) self.assertTrue(any("执行成功" in t for t in self._texts()))
@@ -1348,7 +1398,7 @@ class AirGroupCollabTests(unittest.TestCase):
def test_lock_without_quote_still_shows_tms_reason(self) -> None: def test_lock_without_quote_still_shows_tms_reason(self) -> None:
"""TMS 拒绝锁舱时原样回失败原因,不要改写成选项不匹配。""" """TMS 拒绝锁舱时原样回失败原因,不要改写成选项不匹配。"""
wo = self._create_air(quote={}, status="询价中") wo = self._create_air()
self.ledger._cabin_lock_result = { self.ledger._cabin_lock_result = {
"ok": False, "ok": False,
"error": "lock_fail", "error": "lock_fail",
+97
View File
@@ -0,0 +1,97 @@
"""后台回复语:按调用编码填句,失败回退默认。"""
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("LEDGER_BACKEND", "memory")
from agent.policy import inquiry_copy as copy
from agent.policy.bot_reply import fill, override_catalog, render
class BotReplyRenderTests(unittest.TestCase):
def tearDown(self) -> None:
override_catalog(None)
def test_fill_placeholder(self) -> None:
self.assertEqual(fill("工单{{ticket_no}}。", {"ticket_no": "WO1"}), "工单WO1。")
self.assertEqual(fill("工单{{ ticket_no }}。", {"ticket_no": "WO1"}), "工单WO1。")
self.assertIn("{{lost_reason}}", fill("因{{lost_reason}}", {}))
def test_default_when_empty(self) -> None:
override_catalog({})
self.assertEqual(copy.first_chat(), copy.FIRST_CHAT)
self.assertEqual(copy.ask_transport(), copy.ASK_TRANSPORT)
self.assertEqual(copy.deal_ack("未成交", "WO1"), "已记录为「未成交」。请补充未成交原因。")
def test_override_first_chat(self) -> None:
override_catalog({"inquiring.first_chat": "你好,测试问候。"})
self.assertEqual(copy.first_chat(), "你好,测试问候。")
self.assertEqual(copy.FIRST_CHAT, "你好,我是询价机器人。请直接描述客户需求,例如「上海到洛杉矶空运,3 件 180kg,下周三前到」,我会解析需求并查询 TMS 标准报价。")
def test_group_created_fills_names(self) -> None:
override_catalog(
{
"inquiring.group_created": (
"协同群已创建,群名与工单号一致:{{ticket_no}}。"
"已邀请:销售{{sales_name}}、产品{{product_names}}。请在群聊中继续确认报价。"
)
}
)
text = copy.sea_group_created(
work_order_no="WO202610060001",
sales_name="张三",
product_names=["李四"],
)
self.assertIn("WO202610060001", text)
self.assertIn("销售张三", text)
self.assertIn("产品李四", text)
self.assertNotIn("销售销售", text)
def test_disabled_falls_back(self) -> None:
override_catalog({"quoted.adopt_excel": ""})
self.assertEqual(copy.adopt_excel_ack(), "已确认采用该方案,正在生成 Excel 最终报价单…")
def test_group_file_saved_uses_admin(self) -> None:
override_catalog({"inquiring.group_file_saved": "附件已保存后台"})
self.assertEqual(
copy.quote_attachment_saved_app("WO202610060001", 2),
"附件已保存后台",
)
override_catalog({})
self.assertEqual(
copy.quote_attachment_saved_app("WO202610060001", 2),
"工单WO202610060001的2个附件已保存到后台工单",
)
def test_negotiate_without_date_still_reads_admin(self) -> None:
override_catalog(
{
"negotiating.recorded": (
"已记录为「协商中」。已顺延三个工作日,{{followup_when}}的9:00再次发起成交状态询问。"
)
}
)
text = copy.deal_ack("协商中", "WO1", next_followup_at="")
self.assertIn("第四个工作日", text)
self.assertIn("9:00", text)
def test_group_add_fail_empty_names_reads_admin(self) -> None:
override_catalog(
{"sys.group_add_fail": "无法添加 {{staff_name}} 同事,请联系管理员检查企业微信配置。"}
)
text = copy.system_exception_fallback(copy.TYPE_GROUP, names=[])
self.assertEqual(text, "无法添加同事,请联系管理员检查企业微信配置。")
named = copy.system_exception_fallback(copy.TYPE_GROUP, names=["张三"])
self.assertIn("张三", named)
if __name__ == "__main__":
unittest.main()
+65
View File
@@ -144,10 +144,75 @@ class TestDealOutcome(unittest.TestCase):
view = self.ledger.get_for_agent(work_order_no=self.wo, sender_id="sales1") view = self.ledger.get_for_agent(work_order_no=self.wo, sender_id="sales1")
self.assertEqual(view.get("wait_phase"), "done") self.assertEqual(view.get("wait_phase"), "done")
def test_lost_from_inquiring_promotes_quoted_first(self) -> None:
"""主账仍询价中时点未成交:先补已报价再入未成交,禁止直接失败。"""
class _StrictSix:
def __init__(self) -> None:
self.status = "询价中"
self.lost_reason = ""
self.event_wait_kind = ""
def transition(self, *, work_order_no, to_status, **kwargs):
_ = work_order_no
allowed = {
"询价中": {"已报价", "协商中", "已关闭"},
"已报价": {"协商中", "已成交", "未成交", "已关闭"},
}
if to_status not in allowed.get(self.status, set()):
# 模拟 http_ledger 在 500 时只回 http_500(丢失正文)
return {"ok": False, "error": "http_500"}
self.status = to_status
if to_status == "未成交" and kwargs.get("lost_reason"):
self.lost_reason = str(kwargs.get("lost_reason") or "")
return {"ok": True, "status": self.status}
def patch_lost_reason(self, *, work_order_no, lost_reason):
_ = work_order_no
self.lost_reason = lost_reason
return {"ok": True}
def get_for_agent(self, *, work_order_no, sender_id):
_ = (work_order_no, sender_id)
return {"status": self.status, "lost_reason": self.lost_reason}
ledger = _StrictSix()
out = finish_deal_outcome(
ledger=ledger,
work_order_no="WO202610070023",
outcome="未成交",
facts={},
quote={},
business_line="AIR",
)
self.assertTrue(out.get("ok"), out)
self.assertEqual(ledger.status, "未成交")
self.assertEqual(out.get("phase"), "wait_lost_reason")
def test_lost_reason_ack_repeats_user_text(self) -> None: def test_lost_reason_ack_repeats_user_text(self) -> None:
self.assertEqual(copy.lost_reason_ack("客户不要了"), "已记下未成交原因:客户不要了。") self.assertEqual(copy.lost_reason_ack("客户不要了"), "已记下未成交原因:客户不要了。")
self.assertEqual(copy.lost_reason_ack("价格高。"), "已记下未成交原因:价格高。") self.assertEqual(copy.lost_reason_ack("价格高。"), "已记下未成交原因:价格高。")
def test_lost_reason_ack_fills_ticket_no_from_bot_reply(self) -> None:
"""后台文案带 {{ticket_no}} 时必须填实值,禁止原样打出占位符。"""
from agent.policy import bot_reply
bot_reply.override_catalog(
{
"lost.reason_ack": (
"已将未成交原因“{{lost_reason}}”记录在后台,"
"工单{{ticket_no}}已关闭,智能体不再跟进该工单,请悉知。"
)
}
)
try:
text = copy.lost_reason_ack("换时间", work_order_no="WO202610070018")
self.assertIn("WO202610070018", text)
self.assertNotIn("{{ticket_no}}", text)
self.assertIn("换时间", text)
finally:
bot_reply.override_catalog(None)
def test_harvest_inline_lost_reason(self) -> None: def test_harvest_inline_lost_reason(self) -> None:
self.assertEqual(harvest_inline_lost_reason("未成交,原因客户不想要了"), "客户不想要了") self.assertEqual(harvest_inline_lost_reason("未成交,原因客户不想要了"), "客户不想要了")
self.assertEqual( self.assertEqual(
+7
View File
@@ -220,9 +220,16 @@ class LandOptionsTest(unittest.TestCase):
def test_confirm_intent(self): def test_confirm_intent(self):
self.assertTrue(looks_like_confirm("确认")) self.assertTrue(looks_like_confirm("确认"))
self.assertTrue(looks_like_confirm("确定"))
self.assertTrue(looks_like_confirm("可以查了")) self.assertTrue(looks_like_confirm("可以查了"))
self.assertTrue(looks_like_confirm("查价")) self.assertTrue(looks_like_confirm("查价"))
self.assertFalse(looks_like_confirm("广州到深圳衣服")) self.assertFalse(looks_like_confirm("广州到深圳衣服"))
# 询价正文里的「确认」不是点核对卡。
self.assertFalse(
looks_like_confirm(
"服务器、不带电,预计7月初货好,因货值较高 派送需确认安全运输方案,比如押运。"
)
)
if __name__ == "__main__": if __name__ == "__main__":
@@ -1341,6 +1341,28 @@ class SeaGroupCollabFieldTests(unittest.TestCase):
self.assertEqual(str(rows["海运费"].get("amount") or ""), "100.00") self.assertEqual(str(rows["海运费"].get("amount") or ""), "100.00")
self.assertEqual(str(rows["燃油附加费"].get("amount") or ""), "15.00") self.assertEqual(str(rows["燃油附加费"].get("amount") or ""), "15.00")
def test_land_weight_band_unit_price_and_rates(self) -> None:
"""陆运集拼:2-500KG 单价 + 燃油附加费率/税率,不能把档下限 2 当成燃油附加费。"""
from agent.llm.extract_text import extract_product_quote
quote = extract_product_quote(
"2-500KG 单价CNY990,燃油附加费率1%,税率6%"
)
self.assertEqual(str(quote.get("total") or ""), "990")
self.assertEqual(str(quote.get("fuel_surcharge_rate") or ""), "1%")
self.assertEqual(str(quote.get("tax_rate") or ""), "6%")
tiers = list(quote.get("weightPrices") or [])
self.assertEqual(len(tiers), 1)
self.assertEqual(int(tiers[0].get("min") or 0), 2)
self.assertEqual(int(tiers[0].get("max") or 0), 500)
self.assertEqual(str(tiers[0].get("price") or ""), "990")
rows = {str(x.get("name") or ""): x for x in (quote.get("fee_rows") or [])}
self.assertIn("2-500KG", rows)
self.assertEqual(str(rows["2-500KG"].get("amount") or ""), "990")
self.assertNotEqual(str(rows.get("燃油附加费", {}).get("amount") or ""), "2")
self.assertEqual(str(rows.get("燃油附加费率", {}).get("amount") or ""), "1%")
self.assertEqual(str(rows.get("税率", {}).get("amount") or ""), "6%")
def test_air_unit_qty_amount_keeps_air_freight_name(self) -> None: def test_air_unit_qty_amount_keeps_air_freight_name(self) -> None:
"""空运费 单价/数量/金额:费用名是空运费,金额用「金额」不是单价。""" """空运费 单价/数量/金额:费用名是空运费,金额用「金额」不是单价。"""
from agent.llm.extract_text import extract_product_quote from agent.llm.extract_text import extract_product_quote
+30 -5
View File
@@ -477,22 +477,45 @@ class TestConfirmSystemException(unittest.TestCase):
self.assertTrue("USD" in joined or "拉产品" in joined or "已报价" in joined) self.assertTrue("USD" in joined or "拉产品" in joined or "已报价" in joined)
def test_admin_retry_cabin_lock_runs_lock(self) -> None: def test_admin_retry_cabin_lock_runs_lock(self) -> None:
# 空运单 + 可锁报价;重试必须带上 operator_name / airline。
air = self.ledger.create_ticket(
sender_id="sales1",
business_line="AIR",
facts={"起运地": "上海", "目的地": "洛杉矶", "毛重": "100"},
)
air_wo = str(air.get("work_order_no") or "")
self.ledger.upsert_quote(
work_order_no=air_wo,
quote={
"total": "800",
"source": "TMS",
"quoteId": "QT-AIR-1",
"airOptions": [{"optionNo": "AIR-OPT-01", "airlineCode": "CZ"}],
},
to_status="已报价",
)
self.ledger.mark_system_exception( self.ledger.mark_system_exception(
work_order_no=self.wo, work_order_no=air_wo,
system_exception=TYPE_TMS, system_exception=TYPE_TMS,
system_exception_reason="空运锁舱接口调用失败", system_exception_reason="空运锁舱接口调用失败",
step=STEP_TMS_CABIN, step=STEP_TMS_CABIN,
payload={ payload={
"conversation_kind": "group", "conversation_kind": "group",
"conversation_target": "chat-air", "conversation_target": "chat-air",
"extra": {"option_no": "AIR-OPT-01", "instruction": "锁舱"}, "extra": {
"option_no": "AIR-OPT-01",
"instruction": "锁舱",
"operator_name": "HangXian",
"airline": "CZ",
},
}, },
) )
hits = {"n": 0} hits = {"n": 0, "kwargs": {}}
orig_lock = self.ledger.lock_cabin orig_lock = self.ledger.lock_cabin
def counted(**kwargs): def counted(**kwargs):
hits["n"] += 1 hits["n"] += 1
hits["kwargs"] = dict(kwargs)
return orig_lock(**kwargs) return orig_lock(**kwargs)
self.ledger.lock_cabin = counted # type: ignore[method-assign] self.ledger.lock_cabin = counted # type: ignore[method-assign]
@@ -504,12 +527,14 @@ class TestConfirmSystemException(unittest.TestCase):
orig = se._send_to_conversation orig = se._send_to_conversation
se._send_to_conversation = lambda **kwargs: talks.append(str(kwargs.get("text") or "")) se._send_to_conversation = lambda **kwargs: talks.append(str(kwargs.get("text") or ""))
try: try:
out = run_admin_retry(self.wo, ledger=self.ledger) out = run_admin_retry(air_wo, ledger=self.ledger)
finally: finally:
se._send_to_conversation = orig se._send_to_conversation = orig
self.assertTrue(out.get("ok")) self.assertTrue(out.get("ok"))
self.assertGreaterEqual(hits["n"], 1) self.assertGreaterEqual(hits["n"], 1)
ticket = self.ledger.get_ticket(work_order_no=self.wo) self.assertEqual(hits["kwargs"].get("operator_name"), "HangXian")
self.assertEqual(hits["kwargs"].get("airline"), "CZ")
ticket = self.ledger.get_ticket(work_order_no=air_wo)
self.assertFalse(ticket_is_paused(ticket)) self.assertFalse(ticket_is_paused(ticket))
self.assertTrue(any("空运舱位接口已恢复" in x for x in talks)) self.assertTrue(any("空运舱位接口已恢复" in x for x in talks))
@@ -44,12 +44,28 @@ public class InquiryAgentController {
@Autowired @Autowired
private org.jeecg.modules.inquiry.tms.InquiryTmsCabinService cabinService; private org.jeecg.modules.inquiry.tms.InquiryTmsCabinService cabinService;
@Autowired
private org.jeecg.modules.inquiry.service.IInquiryBotReplyService botReplyService;
@Value("${inquiry.agent.callback-token:${AGENT_CALLBACK_TOKEN:}}") @Value("${inquiry.agent.callback-token:${AGENT_CALLBACK_TOKEN:}}")
private String agentToken; private String agentToken;
@Value("${inquiry.agent.biz-token:${BIZ_API_TOKEN:}}") @Value("${inquiry.agent.biz-token:${BIZ_API_TOKEN:}}")
private String bizToken; private String bizToken;
/**
* 智能体只读回复语。停用或不在列表里的编码由智能体回退默认句。
* 鉴权同其它 /inquiry/agent/** ,禁止后台菜单权限码。
*/
@ApiOperation("智能体-已启用回复语")
@GetMapping("/botReply/listEnabled")
public Result<java.util.List<java.util.Map<String, Object>>> botReplyListEnabled(HttpServletRequest http) {
if (!authed(http)) {
return Result.error(401, "unauthorized");
}
return Result.OK(botReplyService.listEnabledForAgent());
}
@ApiOperation("智能体-回写模板 AI 映射结果") @ApiOperation("智能体-回写模板 AI 映射结果")
@PutMapping("/quoteTemplate/parseResult") @PutMapping("/quoteTemplate/parseResult")
public Result<String> quoteTemplateParseResult(@RequestBody Map<String, Object> body, public Result<String> quoteTemplateParseResult(@RequestBody Map<String, Object> body,
@@ -21,7 +21,7 @@ import java.util.List;
/** /**
* 机器人回复语 API。 * 机器人回复语 API。
* 支持按分类拉取全部条目(后台九分类页)与分页查询。 * 支持按分类拉取全部条目(后台九分类页)与分页查询。
* 调用编码 replyCode 给智能体日后按码取文案;本期只维护、不向智能体下发。 * 调用编码 replyCode 供智能体 GET /inquiry/agent/botReply/listEnabled 按码取文案。
*/ */
@Slf4j @Slf4j
@Api(tags = "询价-机器人回复语") @Api(tags = "询价-机器人回复语")
@@ -123,7 +123,7 @@ public class InquiryTicket implements Serializable {
private List<InquiryPriceLine> saleLines; private List<InquiryPriceLine> saleLines;
/** /**
* 利润点逐行。不入库。没调过价为空。最后一行是整单利润点。 * 利润点逐行。不入库。没调过价每项都是 0%。最后一行是整单利润点。
*/ */
@TableField(exist = false) @TableField(exist = false)
@ApiModelProperty("利润点逐行(展示)") @ApiModelProperty("利润点逐行(展示)")
@@ -22,4 +22,10 @@ public interface IInquiryBotReplyService extends IService<InquiryBotReply> {
* 编辑正文/启停。已有调用编码不可被请求覆盖。 * 编辑正文/启停。已有调用编码不可被请求覆盖。
*/ */
void updateReply(InquiryBotReply body); void updateReply(InquiryBotReply body);
/**
* 智能体只读:已启用且已有调用编码的正文。
* 停用项不返回,智能体回退默认句。
*/
java.util.List<java.util.Map<String, Object>> listEnabledForAgent();
} }
@@ -9,7 +9,13 @@ import org.jeecg.modules.inquiry.service.IInquiryBotReplyService;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import java.util.ArrayList;
import java.util.Date; import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern; import java.util.regex.Pattern;
/** /**
@@ -90,6 +96,35 @@ public class InquiryBotReplyServiceImpl extends ServiceImpl<InquiryBotReplyMappe
body.setReplyCode(code); body.setReplyCode(code);
} }
@Override
public List<Map<String, Object>> listEnabledForAgent() {
QueryWrapper<InquiryBotReply> qw = new QueryWrapper<>();
qw.eq("enabled", 1);
qw.isNotNull("reply_code");
qw.ne("reply_code", "");
qw.orderByAsc("sort_no").orderByAsc("id");
Page<InquiryBotReply> page = new Page<>(1, 2000);
page.setSearchCount(false);
page.setMaxLimit(2000L);
List<InquiryBotReply> rows = page(page, qw).getRecords();
List<Map<String, Object>> out = new ArrayList<>();
if (rows == null) {
return out;
}
for (InquiryBotReply row : rows) {
if (row == null || StringUtils.isBlank(row.getReplyCode())) {
continue;
}
Map<String, Object> item = new LinkedHashMap<>();
item.put("replyCode", row.getReplyCode());
item.put("replyText", row.getReplyText());
item.put("triggerScene", row.getTriggerScene());
item.put("categoryCode", row.getCategoryCode());
out.add(item);
}
return out;
}
private void assertCodeUnique(String replyCode, String excludeId) { private void assertCodeUnique(String replyCode, String excludeId) {
if (StringUtils.isBlank(replyCode)) { if (StringUtils.isBlank(replyCode)) {
return; return;
@@ -686,7 +686,7 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
return; return;
} }
String username = StringUtils.trimToEmpty(login.getUsername()); String username = StringUtils.trimToEmpty(login.getUsername());
String wecom = StringUtils.trimToEmpty(login.getWorkNo()); String wecom = dashboardWecomId(login);
qw.and(w -> { qw.and(w -> {
boolean any = false; boolean any = false;
if (StringUtils.isNotBlank(wecom)) { if (StringUtils.isNotBlank(wecom)) {
@@ -734,4 +734,20 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
return false; return false;
} }
} }
/**
* 看板「只看自己」用的企微工号。
* 不用直接调 getWorkNo:测服打包时基座 LoginUser 可能还没有这个方法。
*/
private static String dashboardWecomId(LoginUser login) {
if (login == null) {
return "";
}
try {
Object v = login.getClass().getMethod("getWorkNo").invoke(login);
return v == null ? "" : StringUtils.trimToEmpty(String.valueOf(v));
} catch (ReflectiveOperationException ignored) {
return "";
}
}
} }
@@ -1171,6 +1171,27 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
one.put("delivery_fee", out.getString("delivery_fee")); one.put("delivery_fee", out.getString("delivery_fee"));
one.put("price_per_cbm", out.getString("price_per_cbm")); one.put("price_per_cbm", out.getString("price_per_cbm"));
one.put("price_per_kg", out.getString("price_per_kg")); one.put("price_per_kg", out.getString("price_per_kg"));
// 产品文字报「2-500KG 单价…」时 weightPrices / 费率在 quote 顶层,无 TMS landSegments。
Object wp = q.get("weightPrices");
if (wp != null) {
one.put("weightPrices", wp);
}
String fuelRate = firstNonBlankLocal(
out.getString("fuel_surcharge_rate"),
first(q, "fuel_surcharge_rate"),
first(q, "fuelSurchargeRate"));
if (StringUtils.isNotBlank(fuelRate)) {
one.put("fuel_surcharge_rate", fuelRate);
one.put("fuelSurchargeRate", fuelRate);
}
String taxRate = firstNonBlankLocal(
out.getString("tax_rate"),
first(q, "tax_rate"),
first(q, "taxRate"));
if (StringUtils.isNotBlank(taxRate)) {
one.put("tax_rate", taxRate);
one.put("taxRate", taxRate);
}
String landAmt = firstNonBlankLocal(out.getString("land_freight"), out.getString("price_per_cbm")); String landAmt = firstNonBlankLocal(out.getString("land_freight"), out.getString("price_per_cbm"));
for (int i = 0; i < rows.size(); i++) { for (int i = 0; i < rows.size(); i++) {
JSONObject fee = rows.getJSONObject(i); JSONObject fee = rows.getJSONObject(i);
@@ -19,7 +19,9 @@ import java.util.List;
* <p> * <p>
* 职责:从已保存的报价明细铺开展示。不改调价算法,不把各行金额加总冒充费用合计。 * 职责:从已保存的报价明细铺开展示。不改调价算法,不把各行金额加总冒充费用合计。
* 海运/陆运:成本是调价前生效价(手工同名盖 TMS,多出来的补上);销售是最新调价。 * 海运/陆运:成本是调价前生效价(手工同名盖 TMS,多出来的补上);销售是最新调价。
* 空运:成本整列空;销售是当前生效价;利润点只在调过价之后,对调价前销售价。 * 空运:成本整列空;销售是当前生效价。
* 未调价:各项利润点与费用合计都是 0%,禁止拿金额去填利润点列。
* 已调价:利润点对调价前同名行。
* 副作用:改 ticket 的三列逐行;手工盖价算出新的官方总价时,同时改成本价和利润点字符串。不写库。 * 副作用:改 ticket 的三列逐行;手工盖价算出新的官方总价时,同时改成本价和利润点字符串。不写库。
* 调用:{@link InquiryTicketPriceSupport#apply} 末尾。列表 enrich 与保存报价后的 update 都走那里。 * 调用:{@link InquiryTicketPriceSupport#apply} 末尾。列表 enrich 与保存报价后的 update 都走那里。
*/ */
@@ -126,7 +128,7 @@ public final class InquiryQuoteLineSupport {
profitTotal = StringUtils.defaultString(InquiryTicketPriceSupport.profitText(saleTotal, baseTotal)); profitTotal = StringUtils.defaultString(InquiryTicketPriceSupport.profitText(saleTotal, baseTotal));
ticket.setProfitRate(profitTotal); ticket.setProfitRate(profitTotal);
} else if (!adjusted) { } else if (!adjusted) {
ticket.setProfitRate(""); ticket.setProfitRate("0%");
} }
if (air) { if (air) {
@@ -134,13 +136,13 @@ public final class InquiryQuoteLineSupport {
ticket.setSaleLines(moneyLines(saleFees, saleTotal)); ticket.setSaleLines(moneyLines(saleFees, saleTotal));
ticket.setProfitLines(adjusted && StringUtils.isNotBlank(profitTotal) ticket.setProfitLines(adjusted && StringUtils.isNotBlank(profitTotal)
? profitAgainst(baselineFees, saleFees, profitTotal) ? profitAgainst(baselineFees, saleFees, profitTotal)
: Collections.<InquiryPriceLine>emptyList()); : zeroPercentLines(saleFees));
return; return;
} }
if (!adjusted) { if (!adjusted) {
ticket.setCostLines(moneyLines(costFees, costTotal)); ticket.setCostLines(moneyLines(costFees, costTotal));
ticket.setSaleLines(Collections.<InquiryPriceLine>emptyList()); ticket.setSaleLines(Collections.<InquiryPriceLine>emptyList());
ticket.setProfitLines(Collections.<InquiryPriceLine>emptyList()); ticket.setProfitLines(zeroPercentLines(costFees));
return; return;
} }
List<Aligned> rows = align(costFees, saleFees); List<Aligned> rows = align(costFees, saleFees);
@@ -216,6 +218,21 @@ public final class InquiryQuoteLineSupport {
return formatMoney(base.add(delta), totalCcy); return formatMoney(base.add(delta), totalCcy);
} }
/**
* 未调价:每一项费用和费用合计都写 0%。
* 列表/详情都走这里,避免利润点列空白或误把金额填进去。
*/
private static List<InquiryPriceLine> zeroPercentLines(List<FeeRow> fees) {
List<InquiryPriceLine> lines = new ArrayList<InquiryPriceLine>();
if (fees != null) {
for (FeeRow fee : fees) {
lines.add(line(fee.name, "0%"));
}
}
lines.add(line(TOTAL_NAME, "0%"));
return lines;
}
private static List<InquiryPriceLine> moneyLines(List<FeeRow> fees, String totalText) { private static List<InquiryPriceLine> moneyLines(List<FeeRow> fees, String totalText) {
List<InquiryPriceLine> lines = new ArrayList<InquiryPriceLine>(); List<InquiryPriceLine> lines = new ArrayList<InquiryPriceLine>();
if (fees != null) { if (fees != null) {
@@ -14,7 +14,8 @@ import java.util.regex.Pattern;
* 后台成本价 / 销售价 / 利润点:按业务线从报价记录重算。 * 后台成本价 / 销售价 / 利润点:按业务线从报价记录重算。
* <p> * <p>
* 海运/陆运:TMS、产品报价是成本;销售调价后才有销售价,利润点=(销售-成本)/成本。 * 海运/陆运:TMS、产品报价是成本;销售调价后才有销售价,利润点=(销售-成本)/成本。
* 空运:成本价始终空;TMS/航线/调价都是销售侧;利润点只在销售调过价后算。 * 空运:成本价始终空;TMS/航线/调价都是销售侧。
* 未调价时利润点固定 0%(含各项),调过价才按差额算。
* 副作用:只改传入 ticket 的展示字段(三个总价字符串和三列逐行),不写库。调用方决定是否 updateById。 * 副作用:只改传入 ticket 的展示字段(三个总价字符串和三列逐行),不写库。调用方决定是否 updateById。
*/ */
public final class InquiryTicketPriceSupport { public final class InquiryTicketPriceSupport {
@@ -63,7 +64,7 @@ public final class InquiryTicketPriceSupport {
ticket.setProfitRate(profitText(adjustPrice, baseline)); ticket.setProfitRate(profitText(adjustPrice, baseline));
} else { } else {
ticket.setSalePrice(StringUtils.isNotBlank(manualPrice) ? manualPrice : tmsPrice); ticket.setSalePrice(StringUtils.isNotBlank(manualPrice) ? manualPrice : tmsPrice);
ticket.setProfitRate(""); ticket.setProfitRate("0%");
} }
} else { } else {
String cost = StringUtils.isNotBlank(manualPrice) ? manualPrice : tmsPrice; String cost = StringUtils.isNotBlank(manualPrice) ? manualPrice : tmsPrice;
@@ -73,7 +74,7 @@ public final class InquiryTicketPriceSupport {
ticket.setProfitRate(profitText(adjustPrice, cost)); ticket.setProfitRate(profitText(adjustPrice, cost));
} else { } else {
ticket.setSalePrice(""); ticket.setSalePrice("");
ticket.setProfitRate(""); ticket.setProfitRate("0%");
} }
} }
// 三个总价定完后,按同一批报价拆成逐行。有手工明细盖 TMS 时,这里会改写成本价和利润点。 // 三个总价定完后,按同一批报价拆成逐行。有手工明细盖 TMS 时,这里会改写成本价和利润点。
@@ -75,11 +75,16 @@ public class InquiryTmsCabinService {
Map<String, Object> quote = payloadOf(rows.get(0)); Map<String, Object> quote = payloadOf(rows.get(0));
// 「AIR-OPT01 TH」被误落成航线报价不算真正人工价 → 仍走仅 TMS(SYSTEM)。 // 「AIR-OPT01 TH」被误落成航线报价不算真正人工价 → 仍走仅 TMS(SYSTEM)。
boolean latestManual = isManualQuote(quote) && !isFakeManualLockQuote(quote); boolean latestManual = isManualQuote(quote) && !isFakeManualLockQuote(quote);
if (!latestManual && StringUtils.isBlank(resolveQuoteId(quote, no))) { // 库里只有人工价、没有舱位时也要现拉 TMS:补 quoteId/airOptions,
// 避免 MANUAL 空壳 1001,也避免逼航线手打航班号。
boolean cabinSparse = StringUtils.isBlank(resolveQuoteId(quote, no))
|| airOptions(quote).isEmpty();
if (cabinSparse) {
quote = refreshLockQuote(ticket, quote); quote = refreshLockQuote(ticket, quote);
} }
String tmsQuoteId = realTmsQuoteId(rows, no); String tmsQuoteId = realTmsQuoteId(rows, no);
if (!latestManual && StringUtils.isBlank(tmsQuoteId)) { // 现拉到的 TMS 编号也算:库里无历史行时仍可 MANUAL+quoteId。
if (StringUtils.isBlank(tmsQuoteId)) {
tmsQuoteId = resolveQuoteId(quote, no); tmsQuoteId = resolveQuoteId(quote, no);
} }
boolean hasTms = StringUtils.isNotBlank(tmsQuoteId); boolean hasTms = StringUtils.isNotBlank(tmsQuoteId);
@@ -140,26 +145,68 @@ public class InquiryTmsCabinService {
text(option, "destination"), ticket.getDestination(), text(option, "destination"), ticket.getDestination(),
fact(ticket, "目的地", "目的港"))); fact(ticket, "目的地", "目的港")));
// 航司:句中两位码(如「AIR-OPT01 TH」)> 舱位/报价字段 > 航班号前两位。 // 航司:句中两位码(如「AIR-OPT01 TH」)> 舱位/报价字段 > 航班号前两位。
req.put("airline", firstNonBlank( // airlineName 中文名不能当航司码出站,只取两位码或航班前缀。
String airlineCode = firstNonBlank(
text(body, "airline", "airlineCode"), text(body, "airline", "airlineCode"),
airlineFromInstruction(text(body, "instructionText", "instruction_text")), airlineFromInstruction(text(body, "instructionText", "instruction_text")),
text(option, "airlineCode", "airline"), text(option, "airlineCode", "airline"),
text(option, "airlineName"), text(quote, "airlineCode", "airline"),
text(quote, "airlineCode", "airline", "airlineName"), fact(ticket, "航司代码"),
fact(ticket, "航司", "航司代码"), airlineFromFlight(firstNonBlank(text(option, "flightNo"), text(quote, "flightNo"))),
airlineFromFlight(firstNonBlank(text(option, "flightNo"), text(quote, "flightNo"))))); airlineCodeOrBlank(text(option, "airlineName")),
airlineCodeOrBlank(text(quote, "airlineName")),
airlineCodeOrBlank(fact(ticket, "航司")));
req.put("airline", airlineCode);
req.put("weightKg", ticket.getWeightKg()); req.put("weightKg", ticket.getWeightKg());
req.put("volumeCbm", ticket.getVolumeCbm()); req.put("volumeCbm", ticket.getVolumeCbm());
req.put("pieceQty", ticket.getPieces()); req.put("pieceQty", ticket.getPieces());
// 计费重:舱位 > 报价 > 工单计费重事实 > 毛重(避免 MANUAL 空壳 1001)。
req.put("chargeableWeightKg", firstNonBlank( req.put("chargeableWeightKg", firstNonBlank(
text(option, "chargeableWeightKg", "chargeable_weight_kg"), text(option, "chargeableWeightKg", "chargeable_weight_kg"),
text(quote, "chargeableWeightKg", "chargeable_weight_kg"))); text(quote, "chargeableWeightKg", "chargeable_weight_kg"),
fact(ticket, "计费重", "计费重量"),
ticket.getWeightKg() == null ? "" : String.valueOf(ticket.getWeightKg()),
fact(ticket, "毛重")));
req.put("operatorUserId", text(body, "operatorUserId", "sender_id")); req.put("operatorUserId", text(body, "operatorUserId", "sender_id"));
req.put("operatorName", text(body, "operatorName", "operator_name")); // 重试漏传姓名时用 userid 兜底,仍要有 operatorName 键。
req.put("operatorName", firstNonBlank(
text(body, "operatorName", "operator_name"),
text(body, "operatorUserId", "sender_id")));
req.put("instructionText", text(body, "instructionText", "instruction_text")); req.put("instructionText", text(body, "instructionText", "instruction_text"));
if ("MANUAL".equals(lockSource)) { if ("MANUAL".equals(lockSource)) {
// 有 TMS 时保留 quoteId;纯人工才去掉。 // 有 TMS 时保留 quoteId;纯人工才去掉。
fillManualLock(req, quote, option, body, StringUtils.isNotBlank(quoteId)); fillManualLock(req, quote, option, body, StringUtils.isNotBlank(quoteId));
// 人工价常不写有效期:用航班日当天 23:59:59+08:00 兜底,避免空壳 1001。
if (StringUtils.isBlank(text(req, "validUntil"))) {
String isoUntil = normalizeValidUntilIso(text(req, "flightDate"));
if (StringUtils.isNotBlank(isoUntil)) {
req.put("validUntil", isoUntil);
if (StringUtils.isBlank(text(req, "requestedUntil"))) {
req.put("requestedUntil", isoUntil);
}
}
}
// 缺必填仍出站会被 TMS 1001;在此拦住并回清原因。
String incomplete = validateManualLockOutbound(req);
// 现拉仍无舱位时,改成业务补问口径(禁止堆「缺少 optionNo」技术字段名)。
if (StringUtils.isNotBlank(incomplete)) {
String refreshClass = text(quote, "_lockRefreshClass");
String refreshMsg = text(quote, "_lockRefreshMsg");
boolean noMatch = "TMS_NO_QUOTE_1002".equals(refreshClass)
|| (StringUtils.isNotBlank(refreshMsg) && refreshMsg.contains("无匹配"));
if (noMatch) {
incomplete = "本单是手工报价,且运价库无匹配舱位,无法自动带出航班。"
+ "请按「AIR-OPT01 CZ 航班CZ327」补舱位、航司和航班号后再锁。"
+ "(现拉TMS:" + firstNonBlank(refreshMsg, "标准运价库无匹配报价") + ")";
} else if (StringUtils.isNotBlank(refreshMsg)) {
incomplete = incomplete + "(现拉TMS:" + refreshMsg + ")";
}
}
if (StringUtils.isNotBlank(incomplete)) {
log.info("tms.lock incomplete wo={} optionNo={} missing={}",
no, resolvedOption, incomplete);
return fail("lock_incomplete", incomplete);
}
} else { } else {
// 仅 TMS:source=SYSTEM + quoteId。禁止再塞 validUntil/feeItems(会 1001)。 // 仅 TMS:source=SYSTEM + quoteId。禁止再塞 validUntil/feeItems(会 1001)。
req.put("source", "SYSTEM"); req.put("source", "SYSTEM");
@@ -315,14 +362,26 @@ public class InquiryTmsCabinService {
} }
Map<String, Object> body = new LinkedHashMap<>(); Map<String, Object> body = new LinkedHashMap<>();
body.put("workOrderNo", ticket.getWorkOrderNo()); body.put("workOrderNo", ticket.getWorkOrderNo());
// 锁舱现拉必须用「今天」报价日:历史询价日常已无运价(1002),
// 不能把销售初询那天的日期带出去导致永远补不到舱位。
JSONObject facts = new JSONObject();
if (StringUtils.isNotBlank(ticket.getFactsJson())) { if (StringUtils.isNotBlank(ticket.getFactsJson())) {
JSONObject facts = JSON.parseObject(ticket.getFactsJson()); JSONObject stored = JSON.parseObject(ticket.getFactsJson());
if (facts != null) { if (stored != null) {
body.put("facts", facts); facts.putAll(stored);
} }
} }
SimpleDateFormat dayFmt = new SimpleDateFormat("yyyy-MM-dd");
dayFmt.setTimeZone(java.util.TimeZone.getTimeZone("Asia/Shanghai"));
String today = dayFmt.format(new java.util.Date());
facts.put("报价日期", today);
body.put("facts", facts);
try { try {
Map<String, Object> live = queryService.queryFromAgent(body); Map<String, Object> live = queryService.queryFromAgent(body);
if (live != null && live.get("classification") != null) {
current.put("_lockRefreshClass", String.valueOf(live.get("classification")));
current.put("_lockRefreshMsg", String.valueOf(live.get("message") == null ? "" : live.get("message")));
}
Object raw = live == null ? null : live.get("quote"); Object raw = live == null ? null : live.get("quote");
if (!(raw instanceof Map)) { if (!(raw instanceof Map)) {
return current; return current;
@@ -335,14 +394,19 @@ public class InquiryTmsCabinService {
if (airOptions(current).isEmpty() && fetched.get("airOptions") != null) { if (airOptions(current).isEmpty() && fetched.get("airOptions") != null) {
current.put("airOptions", fetched.get("airOptions")); current.put("airOptions", fetched.get("airOptions"));
} }
if (airOptions(current).isEmpty() && fetched.get("air_options") != null) {
current.put("airOptions", fetched.get("air_options"));
}
if (StringUtils.isBlank(text(current, "chargeableWeightKg", "chargeable_weight_kg")) if (StringUtils.isBlank(text(current, "chargeableWeightKg", "chargeable_weight_kg"))
&& StringUtils.isNotBlank(text(fetched, "chargeableWeightKg", "chargeable_weight_kg"))) { && StringUtils.isNotBlank(text(fetched, "chargeableWeightKg", "chargeable_weight_kg"))) {
current.put("chargeableWeightKg", text(fetched, "chargeableWeightKg", "chargeable_weight_kg")); current.put("chargeableWeightKg", text(fetched, "chargeableWeightKg", "chargeable_weight_kg"));
} }
log.info("tms.lock refresh wo={} hasQuoteId={} airOpt={}", log.info("tms.lock refresh wo={} hasQuoteId={} airOpt={} class={} quoteDate={}",
ticket.getWorkOrderNo(), ticket.getWorkOrderNo(),
StringUtils.isNotBlank(text(current, "quoteId", "quote_id")), StringUtils.isNotBlank(text(current, "quoteId", "quote_id")),
airOptions(current).size()); airOptions(current).size(),
live == null ? "" : live.get("classification"),
today);
} catch (RuntimeException ex) { } catch (RuntimeException ex) {
log.warn("tms.lock refresh fail wo={} err={}", ticket.getWorkOrderNo(), ex.getMessage()); log.warn("tms.lock refresh fail wo={} err={}", ticket.getWorkOrderNo(), ex.getMessage());
} }
@@ -416,7 +480,11 @@ public class InquiryTmsCabinService {
if (StringUtils.isNotBlank(transfer)) { if (StringUtils.isNotBlank(transfer)) {
req.put("transferAirport", transfer); req.put("transferAirport", transfer);
} }
String flightNo = firstNonBlank(text(option, "flightNo"), text(quote, "flightNo")); // 句中/请求体点名的航班号优先,其次舱位与报价。
String flightNo = firstNonBlank(
text(body, "flightNo", "flight_no"),
text(option, "flightNo", "flight_no"),
text(quote, "flightNo", "flight_no"));
if (StringUtils.isNotBlank(flightNo)) { if (StringUtils.isNotBlank(flightNo)) {
req.put("flightNo", flightNo); req.put("flightNo", flightNo);
} }
@@ -463,11 +531,41 @@ public class InquiryTmsCabinService {
List<Map<String, Object>> departure = feeRows(quote, option, "departureCharges", "departure_charges"); List<Map<String, Object>> departure = feeRows(quote, option, "departureCharges", "departure_charges");
List<Map<String, Object>> fees = feeRows(quote, option, "feeItems", "fee_items"); List<Map<String, Object>> fees = feeRows(quote, option, "feeItems", "fee_items");
splitFeeRows(quote, departure, fees); splitFeeRows(quote, departure, fees);
if (!departure.isEmpty()) { // 运行证据(2026-10-07):只要出站带 departureCharges,TMS 一律 1001「参数格式错误」;
req.put("departureCharges", toLockFees(departure, true)); // 去掉后变为业务码 2008。feeItems 可保留。故 MANUAL 锁舱不再出站 departureCharges。
}
if (!fees.isEmpty()) { if (!fees.isEmpty()) {
req.put("feeItems", toLockFees(fees, false)); req.put("feeItems", toLockFees(fees, false));
} else if (!departure.isEmpty()) {
// 仅有起运港费、无 feeItems 时,折进 feeItems,避免再走 departureCharges。
List<Map<String, Object>> asFees = new ArrayList<>();
for (Map<String, Object> row : toLockFees(departure, true)) {
Map<String, Object> fee = new LinkedHashMap<>();
String name = text(row, "chargeName", "feeName");
if (StringUtils.isNotBlank(name)) {
fee.put("feeName", name);
}
if (row.get("unitPrice") != null) {
fee.put("unitPrice", row.get("unitPrice"));
}
if (row.get("quantity") != null) {
fee.put("quantity", row.get("quantity"));
}
if (row.get("amount") != null) {
fee.put("amount", row.get("amount"));
}
if (row.get("currency") != null) {
fee.put("currency", row.get("currency"));
}
if (row.get("unit") != null) {
fee.put("unit", row.get("unit"));
}
if (!fee.isEmpty()) {
asFees.add(fee);
}
}
if (!asFees.isEmpty()) {
req.put("feeItems", asFees);
}
} }
} }
@@ -691,10 +789,24 @@ public class InquiryTmsCabinService {
return true; return true;
} }
/**
* 本单真实 TMS quoteId:优先非人工报价行;人工覆盖后再锁仍要带原编号。
* 跳过 QT-WO 假编号。
*/
static String realTmsQuoteId(List<InquiryQuote> rows, String workOrderNo) { static String realTmsQuoteId(List<InquiryQuote> rows, String workOrderNo) {
if (rows == null) { if (rows == null) {
return ""; return "";
} }
for (InquiryQuote row : rows) {
Map<String, Object> payload = payloadOf(row);
if (isManualQuote(payload) && !isFakeManualLockQuote(payload)) {
continue;
}
String id = resolveQuoteId(payload, workOrderNo);
if (StringUtils.isNotBlank(id)) {
return id;
}
}
for (InquiryQuote row : rows) { for (InquiryQuote row : rows) {
String id = resolveQuoteId(payloadOf(row), workOrderNo); String id = resolveQuoteId(payloadOf(row), workOrderNo);
if (StringUtils.isNotBlank(id)) { if (StringUtils.isNotBlank(id)) {
@@ -704,6 +816,51 @@ public class InquiryTmsCabinService {
return ""; return "";
} }
/**
* MANUAL 出站必填校验。缺字段直接失败,禁止 HMAC 空壳打 TMS(会 1001)。
* 返回中文原因;齐了返回空串。
*/
static String validateManualLockOutbound(Map<String, Object> req) {
if (req == null) {
return "锁舱参数为空";
}
java.util.List<String> missing = new ArrayList<>();
if (StringUtils.isBlank(text(req, "optionNo", "option_no"))) {
missing.add("舱位编号optionNo");
}
if (StringUtils.isBlank(text(req, "airline", "airlineCode"))) {
missing.add("航司airline");
}
if (StringUtils.isBlank(text(req, "chargeableWeightKg", "chargeable_weight_kg"))) {
missing.add("计费重chargeableWeightKg");
}
if (StringUtils.isBlank(text(req, "flightNo", "flight_no"))) {
missing.add("航班号flightNo");
}
if (StringUtils.isBlank(text(req, "operatorName", "operator_name"))) {
missing.add("操作人operatorName");
}
if (StringUtils.isBlank(text(req, "validUntil", "valid_until"))) {
missing.add("有效期validUntil");
}
if (missing.isEmpty()) {
return "";
}
return "锁舱参数不完整,缺少:" + String.join("、", missing)
+ "。请按「AIR-OPT01 CZ 航班CZ327」补舱位、航司和航班号后再锁。";
}
/**
* 航司名只有恰好两位字母时才当代码;「南航」等中文名不当 airline 出站。
*/
static String airlineCodeOrBlank(String raw) {
String text = StringUtils.trimToEmpty(raw).toUpperCase();
if (text.length() == 2 && Character.isLetter(text.charAt(0)) && Character.isLetter(text.charAt(1))) {
return text;
}
return "";
}
private List<InquiryQuote> quoteRows(String workOrderNo) { private List<InquiryQuote> quoteRows(String workOrderNo) {
if (StringUtils.isBlank(workOrderNo)) { if (StringUtils.isBlank(workOrderNo)) {
return new ArrayList<>(); return new ArrayList<>();
@@ -484,6 +484,21 @@ public class InquiryTmsQueryService {
putExtra(row, pair[1], firstText(seg.get(pair[0]), seg.get(pair[1]))); putExtra(row, pair[1], firstText(seg.get(pair[0]), seg.get(pair[1])));
putExtra(row, pair[0], firstText(seg.get(pair[0]))); putExtra(row, pair[0], firstText(seg.get(pair[0])));
} }
// 陆运 TMS:fuelSurchargeRate=燃油附加费率;rate=税率;rateFlag=是否含税(1是0否)。
// 只投影 landSegments,禁止拿 fxRates.rate(汇率)冒充税率。
String fuelRate = formatLandPercentRate(firstText(
seg.get("fuelSurchargeRate"), seg.get("fuel_surcharge_rate")));
putExtra(row, "fuel_surcharge_rate", fuelRate);
putExtra(row, "fuelSurchargeRate", fuelRate);
String taxRate = formatLandTaxRate(firstText(
seg.get("rate"), seg.get("tax_rate"), seg.get("taxRate")));
putExtra(row, "tax_rate", taxRate);
putExtra(row, "taxRate", taxRate);
putExtra(row, "rate", firstText(seg.get("rate")));
String rateFlagRaw = firstText(seg.get("rateFlag"), seg.get("rate_flag"));
putExtra(row, "rateFlag", rateFlagRaw);
putExtra(row, "rate_flag", rateFlagRaw);
putExtra(row, "tax_included", formatLandTaxIncluded(rateFlagRaw));
if (StringUtils.isNotBlank(row.get("origin")) || StringUtils.isNotBlank(row.get("destination")) if (StringUtils.isNotBlank(row.get("origin")) || StringUtils.isNotBlank(row.get("destination"))
|| StringUtils.isNotBlank(row.get("amount"))) { || StringUtils.isNotBlank(row.get("amount"))) {
out.add(row); out.add(row);
@@ -689,7 +704,7 @@ public class InquiryTmsQueryService {
/** /**
* 把本次 TMS 报价写回工单主表,供后台列表/详情展示。 * 把本次 TMS 报价写回工单主表,供后台列表/详情展示。
* 海运/陆运进成本价,空运进销售价;利润点必须等销售调价后才算。 * 海运/陆运进成本价,空运进销售价;未调价利润点为 0%,调过价才按差额算。
*/ */
static void applyQuoteToTicket(InquiryTicket ticket, Map<String, String> quote) { static void applyQuoteToTicket(InquiryTicket ticket, Map<String, String> quote) {
if (ticket == null || quote == null) { if (ticket == null || quote == null) {
@@ -1004,6 +1019,72 @@ public class InquiryTmsQueryService {
} }
} }
/**
* 燃油附加费率展示:数字带 %;已带 % 保留。空值不编造。
*/
static String formatLandPercentRate(String raw) {
if (StringUtils.isBlank(raw) || "-".equals(raw) || "null".equalsIgnoreCase(raw.trim())) {
return "";
}
String text = raw.trim();
if (text.contains("%")) {
return text;
}
if (text.matches("-?\\d+(\\.\\d+)?")) {
if (text.contains(".")) {
text = text.replaceAll("0+$", "").replaceAll("\\.$", "");
}
return text + "%";
}
return text;
}
/**
* 税率 rate 展示:TMS 常回小数(0.09=9%);大于 1 的按百分点(10=10%)。
*/
static String formatLandTaxRate(String raw) {
if (StringUtils.isBlank(raw) || "-".equals(raw) || "null".equalsIgnoreCase(raw.trim())) {
return "";
}
String text = raw.trim();
if (text.contains("%")) {
return text;
}
if (!text.matches("-?\\d+(\\.\\d+)?")) {
return text;
}
try {
java.math.BigDecimal n = new java.math.BigDecimal(text);
java.math.BigDecimal abs = n.abs();
if (abs.compareTo(java.math.BigDecimal.ZERO) > 0
&& abs.compareTo(java.math.BigDecimal.ONE) <= 0) {
n = n.multiply(java.math.BigDecimal.TEN).multiply(java.math.BigDecimal.TEN)
.stripTrailingZeros();
}
String out = n.stripTrailingZeros().toPlainString();
return out + "%";
} catch (RuntimeException ex) {
return text;
}
}
/**
* rateFlag:是否含税。字典 1=是、0=否。
*/
static String formatLandTaxIncluded(String raw) {
if (StringUtils.isBlank(raw) || "-".equals(raw) || "null".equalsIgnoreCase(raw.trim())) {
return "";
}
String text = raw.trim();
if ("1".equals(text) || "true".equalsIgnoreCase(text) || "是".equals(text) || "含税".equals(text)) {
return "含税";
}
if ("0".equals(text) || "false".equalsIgnoreCase(text) || "否".equals(text) || "不含税".equals(text)) {
return "不含税";
}
return text;
}
private static void addFeeLine(List<Map<String, String>> lines, String name, String amount, private static void addFeeLine(List<Map<String, String>> lines, String name, String amount,
String currency, Map<String, String> extras) { String currency, Map<String, String> extras) {
TmsQuoteCurrency.Money money = TmsQuoteCurrency.split(amount, currency); TmsQuoteCurrency.Money money = TmsQuoteCurrency.split(amount, currency);
@@ -69,7 +69,7 @@ VALUES
('brv10000000000000000000000000038','deal','已成交','deal.recorded_won','销售选择「成交」','已记录为「已成交」。工单 {{ticket_no}} 已关闭智能体跟进。',1,1,'admin',NOW()), ('brv10000000000000000000000000038','deal','已成交','deal.recorded_won','销售选择「成交」','已记录为「已成交」。工单 {{ticket_no}} 已关闭智能体跟进。',1,1,'admin',NOW()),
('brv10000000000000000000000000039','negotiating','协商中','negotiating.recorded','成交跟进中选择「协商中」','已记录为「协商中」。已顺延三个工作日,{{followup_when}}的9:00再次发起成交状态询问。',1,1,'admin',NOW()), ('brv10000000000000000000000000039','negotiating','协商中','negotiating.recorded','成交跟进中选择「协商中」','已记录为「协商中」。已顺延三个工作日,{{followup_when}}的9:00再次发起成交状态询问。',1,1,'admin',NOW()),
('brv10000000000000000000000000040','lost','未成交','lost.ask_reason','成交跟进中选择「未成交」且未写原因','已记录为「未成交」。请补充未成交原因。',1,1,'admin',NOW()), ('brv10000000000000000000000000040','lost','未成交','lost.ask_reason','成交跟进中选择「未成交」且未写原因','已记录为「未成交」。请补充未成交原因。',1,1,'admin',NOW()),
('brv10000000000000000000000000041','lost','未成交','lost.reason_ack','销售回复未成交原因','已记下未成交原因:{{lost_reason}}。',1,2,'admin',NOW()), ('brv10000000000000000000000000041','lost','未成交','lost.reason_ack','销售回复未成交原因','已将未成交原因“{{lost_reason}}”记录在后台,工单 {{ticket_no}}已关闭,智能体不再跟进该工单,请悉知。',1,2,'admin',NOW()),
('brv10000000000000000000000000042','closed','已关闭','closed.air_cannot_activate','空运工单已是终态仍要激活','工单{{ticket_no}}已是「{{status}}」,不能再激活。',1,1,'admin',NOW()), ('brv10000000000000000000000000042','closed','已关闭','closed.air_cannot_activate','空运工单已是终态仍要激活','工单{{ticket_no}}已是「{{status}}」,不能再激活。',1,1,'admin',NOW()),
('brv10000000000000000000000000043','sys-ex','系统异常','sys.llm_fail','大模型调用失败(销售侧)','大模型服务异常,IT排查中...',1,1,'admin',NOW()), ('brv10000000000000000000000000043','sys-ex','系统异常','sys.llm_fail','大模型调用失败(销售侧)','大模型服务异常,IT排查中...',1,1,'admin',NOW()),
('brv10000000000000000000000000044','sys-ex','系统异常','sys.tms_fail','TMS 接口失败(销售侧)','TMS接口异常,IT运维排查中...',1,2,'admin',NOW()), ('brv10000000000000000000000000044','sys-ex','系统异常','sys.tms_fail','TMS 接口失败(销售侧)','TMS接口异常,IT运维排查中...',1,2,'admin',NOW()),
@@ -28,13 +28,16 @@ public class InquiryQuoteLineSupportTest {
+ "]}"))); + "]}")));
assertEquals("CNY 1200.00", ticket.getCostPrice()); assertEquals("CNY 1200.00", ticket.getCostPrice());
assertEquals("", ticket.getSalePrice()); assertEquals("", ticket.getSalePrice());
assertEquals("", ticket.getProfitRate()); assertEquals("0%", ticket.getProfitRate());
assertLines(ticket.getCostLines(), assertLines(ticket.getCostLines(),
"运输费", "CNY 1000", "运输费", "CNY 1000",
"卸货费", "CNY 200", "卸货费", "CNY 200",
"费用合计", "CNY 1200.00"); "费用合计", "CNY 1200.00");
assertTrue(ticket.getSaleLines() == null || ticket.getSaleLines().isEmpty()); assertTrue(ticket.getSaleLines() == null || ticket.getSaleLines().isEmpty());
assertTrue(ticket.getProfitLines() == null || ticket.getProfitLines().isEmpty()); assertLines(ticket.getProfitLines(),
"运输费", "0%",
"卸货费", "0%",
"费用合计", "0%");
} }
@Test @Test
@@ -113,7 +116,12 @@ public class InquiryQuoteLineSupportTest {
assertTrue(before.getCostLines() == null || before.getCostLines().isEmpty()); assertTrue(before.getCostLines() == null || before.getCostLines().isEmpty());
assertEquals("", before.getCostPrice()); assertEquals("", before.getCostPrice());
assertEquals("CNY 2000.00", before.getSalePrice()); assertEquals("CNY 2000.00", before.getSalePrice());
assertTrue(before.getProfitLines() == null || before.getProfitLines().isEmpty()); assertEquals("0%", before.getProfitRate());
assertLines(before.getProfitLines(),
"空运费", "0%",
"增点费", "0%",
"拆板费", "0%",
"费用合计", "0%");
assertLines(before.getSaleLines(), assertLines(before.getSaleLines(),
"空运费", "CNY 1800", "空运费", "CNY 1800",
"增点费", "CNY 100", "增点费", "CNY 100",
@@ -189,6 +197,11 @@ public class InquiryQuoteLineSupportTest {
"费用合计", "CNY 1320.00"); "费用合计", "CNY 1320.00");
assertEquals("CNY 1320.00", ticket.getCostPrice()); assertEquals("CNY 1320.00", ticket.getCostPrice());
assertTrue(ticket.getSaleLines() == null || ticket.getSaleLines().isEmpty()); assertTrue(ticket.getSaleLines() == null || ticket.getSaleLines().isEmpty());
assertLines(ticket.getProfitLines(),
"运输费", "0%",
"卸货费", "0%",
"配送费", "0%",
"费用合计", "0%");
} }
private static void assertLines(List<InquiryPriceLine> lines, String... nameText) { private static void assertLines(List<InquiryPriceLine> lines, String... nameText) {
@@ -21,7 +21,7 @@ public class InquiryTicketPriceSupportTest {
InquiryTicketPriceSupport.apply(ticket, Collections.singletonList(quote("TMS", "TMS标准报价", "CNY 121.00"))); InquiryTicketPriceSupport.apply(ticket, Collections.singletonList(quote("TMS", "TMS标准报价", "CNY 121.00")));
assertEquals("CNY 121.00", ticket.getCostPrice()); assertEquals("CNY 121.00", ticket.getCostPrice());
assertEquals("", ticket.getSalePrice()); assertEquals("", ticket.getSalePrice());
assertEquals("", ticket.getProfitRate()); assertEquals("0%", ticket.getProfitRate());
} }
@Test @Test
@@ -53,7 +53,7 @@ public class InquiryTicketPriceSupportTest {
InquiryTicketPriceSupport.apply(ticket, Collections.singletonList(quote("TMS", "TMS标准报价", "USD 1280"))); InquiryTicketPriceSupport.apply(ticket, Collections.singletonList(quote("TMS", "TMS标准报价", "USD 1280")));
assertEquals("", ticket.getCostPrice()); assertEquals("", ticket.getCostPrice());
assertEquals("USD 1280", ticket.getSalePrice()); assertEquals("USD 1280", ticket.getSalePrice());
assertEquals("", ticket.getProfitRate()); assertEquals("0%", ticket.getProfitRate());
} }
@Test @Test
@@ -1543,6 +1543,9 @@ public class QuoteTemplateFillTest {
matrix.getCell(15).setCellStyle(matrixBlue); matrix.getCell(15).setCellStyle(matrixBlue);
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(7, 8, 2, 2)); sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(7, 8, 2, 2));
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(7, 8, 9, 9)); sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(7, 8, 9, 9));
org.apache.poi.ss.usermodel.Row sub = row(sheet, 8);
sub.createCell(3).setCellValue("最低收费");
sub.createCell(4).setCellValue("元/KG");
org.apache.poi.ss.usermodel.Font red = wb.createFont(); org.apache.poi.ss.usermodel.Font red = wb.createFont();
red.setColor(org.apache.poi.ss.usermodel.IndexedColors.RED.getIndex()); red.setColor(org.apache.poi.ss.usermodel.IndexedColors.RED.getIndex());
org.apache.poi.ss.usermodel.CellStyle redOnSky = wb.createCellStyle(); org.apache.poi.ss.usermodel.CellStyle redOnSky = wb.createCellStyle();
@@ -1553,6 +1556,14 @@ public class QuoteTemplateFillTest {
currencyHead.setCellStyle(redOnSky); currencyHead.setCellStyle(redOnSky);
matrix.getCell(10).setCellStyle(redOnSky); matrix.getCell(10).setCellStyle(redOnSky);
org.apache.poi.ss.usermodel.Row data = row(sheet, 9); org.apache.poi.ss.usermodel.Row data = row(sheet, 9);
org.apache.poi.ss.usermodel.Font blackData = wb.createFont();
blackData.setFontName("宋体");
blackData.setFontHeightInPoints((short) 10);
blackData.setColor(org.apache.poi.ss.usermodel.IndexedColors.BLACK.getIndex());
org.apache.poi.ss.usermodel.CellStyle priceStyle = wb.createCellStyle();
priceStyle.setFont(blackData);
data.createCell(4).setCellValue(12);
data.getCell(4).setCellStyle(priceStyle);
org.apache.poi.ss.usermodel.Cell dest = data.createCell(2); org.apache.poi.ss.usermodel.Cell dest = data.createCell(2);
dest.setCellStyle(redOnSky); dest.setCellStyle(redOnSky);
org.apache.poi.ss.usermodel.Row delivery = row(sheet, 18); org.apache.poi.ss.usermodel.Row delivery = row(sheet, 18);
@@ -1610,6 +1621,9 @@ public class QuoteTemplateFillTest {
assertEquals(org.apache.poi.ss.usermodel.VerticalAlignment.CENTER, assertEquals(org.apache.poi.ss.usermodel.VerticalAlignment.CENTER,
sheet.getRow(9).getCell(2).getCellStyle().getVerticalAlignment()); sheet.getRow(9).getCell(2).getCellStyle().getVerticalAlignment());
assertFalse(QuoteTemplateExcelSupport.fontIsRed(sheet.getRow(9).getCell(2))); assertFalse(QuoteTemplateExcelSupport.fontIsRed(sheet.getRow(9).getCell(2)));
assertFalse(sheet.getRow(9).getCell(2).getCellStyle().getShrinkToFit());
assertEquals(QuoteTemplateExcelSupport.fontHeight(sheet.getRow(9).getCell(4)),
QuoteTemplateExcelSupport.fontHeight(sheet.getRow(9).getCell(2)));
assertEquals("始发地", read(sheet, 7, 1)); assertEquals("始发地", read(sheet, 7, 1));
assertEquals("币种", read(sheet, 7, 9)); assertEquals("币种", read(sheet, 7, 9));
byte[] originFill = QuoteTemplateExcelSupport.readFillRgb(sheet.getRow(7).getCell(1)); byte[] originFill = QuoteTemplateExcelSupport.readFillRgb(sheet.getRow(7).getCell(1));
@@ -1678,6 +1692,133 @@ public class QuoteTemplateFillTest {
} }
} }
/**
* 跨境整车:列头是「起始地」,城市必须写在下一行白格,不能盖掉列名。
*/
@Test
public void landCrossBorderFtlKeepsOriginHeader() throws Exception {
Path src = Files.createTempFile("quote-land-cb-ftl-src", ".xlsx");
Path out = Files.createTempFile("quote-land-cb-ftl-out", ".xlsx");
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("跨境整车");
org.apache.poi.ss.usermodel.Row top = row(sheet, 6);
top.createCell(1).setCellValue("路线");
top.createCell(3).setCellValue("车型");
org.apache.poi.ss.usermodel.Row head = row(sheet, 7);
head.createCell(1).setCellValue("起始地");
head.createCell(2).setCellValue("目的地");
head.createCell(3).setCellValue("3T");
head.createCell(4).setCellValue("5T");
head.createCell(5).setCellValue("8T");
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
}
JSONObject mapping = new JSONObject(true);
mapping.put("sheet", "跨境整车");
JSONObject fields = new JSONObject(true);
fields.put("origin", "跨境整车!B8");
fields.put("destination", "跨境整车!C8");
mapping.put("fields", fields);
JSONObject facts = new JSONObject(true);
facts.put("origin", "郑州");
facts.put("destination", "河内");
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = filled.getSheetAt(0);
assertEquals("起始地", read(sheet, 7, 1));
assertEquals("目的地", read(sheet, 7, 2));
assertEquals("郑州", read(sheet, 8, 1));
assertEquals("河内", read(sheet, 8, 2));
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
}
/**
* 跨境整车国内段只写境内起点,境外段只写境外终点,不能两段都写成郑州→吉隆坡。
*/
@Test
public void landCrossBorderFtlSplitsDomesticAndOverseasOd() throws Exception {
Path src = Files.createTempFile("quote-land-cb-split-src", ".xlsx");
Path out = Files.createTempFile("quote-land-cb-split-out", ".xlsx");
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("跨境整车");
row(sheet, 5).createCell(0).setCellValue("一、国内段报价");
org.apache.poi.ss.usermodel.Row head1 = row(sheet, 7);
head1.createCell(1).setCellValue("起始地");
head1.createCell(2).setCellValue("目的地");
head1.createCell(3).setCellValue("3T");
head1.createCell(4).setCellValue("5T");
head1.createCell(15).setCellValue("时效");
head1.createCell(16).setCellValue("币种");
head1.createCell(17).setCellValue("燃油附加费率");
head1.createCell(18).setCellValue("含税合计");
row(sheet, 24).createCell(0).setCellValue("二、境外段报价");
org.apache.poi.ss.usermodel.Row head2 = row(sheet, 26);
head2.createCell(1).setCellValue("起始地");
head2.createCell(2).setCellValue("目的地");
head2.createCell(3).setCellValue("1.25T");
head2.createCell(4).setCellValue("2.5T");
head2.createCell(15).setCellValue("时效");
head2.createCell(16).setCellValue("币种");
head2.createCell(17).setCellValue("燃油附加费率");
head2.createCell(18).setCellValue("含税合计");
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
}
JSONObject mapping = new JSONObject(true);
mapping.put("sheet", "跨境整车");
JSONArray regions = new JSONArray();
JSONObject inland = new JSONObject(true);
inland.put("title", "国内段报价");
inland.put("headerRow", 8);
inland.put("dataStartRow", 8);
JSONObject cols1 = new JSONObject(true);
cols1.put("origin", "B");
cols1.put("destination", "C");
inland.put("columns", cols1);
regions.add(inland);
JSONObject overseas = new JSONObject(true);
overseas.put("title", "境外段报价");
overseas.put("headerRow", 27);
overseas.put("dataStartRow", 27);
JSONObject cols2 = new JSONObject(true);
cols2.put("origin", "B");
cols2.put("destination", "C");
overseas.put("columns", cols2);
regions.add(overseas);
mapping.put("routeRegions", regions);
JSONObject facts = new JSONObject(true);
facts.put("origin", "郑州");
facts.put("destination", "吉隆坡");
facts.put("currency", "CNY");
facts.put("transit_days", "N+3");
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = filled.getSheetAt(0);
assertEquals("起始地", read(sheet, 7, 1));
assertEquals("郑州", read(sheet, 8, 1));
assertEquals("", read(sheet, 8, 2));
assertEquals("起始地", read(sheet, 26, 1));
assertEquals("", read(sheet, 27, 1));
assertEquals("吉隆坡", read(sheet, 27, 2));
assertEquals("N+3", read(sheet, 8, 15));
assertEquals("CNY", read(sheet, 8, 16));
assertEquals("", read(sheet, 8, 17));
assertEquals("", read(sheet, 8, 18));
assertEquals("N+3", read(sheet, 27, 15));
assertEquals("CNY", read(sheet, 27, 16));
assertEquals("", read(sheet, 27, 17));
assertEquals("", read(sheet, 27, 18));
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
}
/** /**
* 国内整车:4.2米/3T 这类车型费用也是标签右侧,不是海运费目表。 * 国内整车:4.2米/3T 这类车型费用也是标签右侧,不是海运费目表。
*/ */
@@ -1967,6 +2108,113 @@ public class QuoteTemplateFillTest {
} }
} }
/**
* 国内拼车:元/CBM、元/KG 必须跟「始发站」同一浅蓝,不能留成顶栏深蓝。
*/
@Test
public void landDomesticLtlUnitHeaderRowIsKept() throws Exception {
Path src = Files.createTempFile("quote-land-ltl-src", ".xlsx");
Path out = Files.createTempFile("quote-land-ltl-out", ".xlsx");
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("国内拼车");
org.apache.poi.ss.usermodel.CellStyle sky = wb.createCellStyle();
sky.setFillForegroundColor(org.apache.poi.ss.usermodel.IndexedColors.SKY_BLUE.getIndex());
sky.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND);
org.apache.poi.ss.usermodel.CellStyle navy = wb.createCellStyle();
navy.setFillForegroundColor(org.apache.poi.ss.usermodel.IndexedColors.ROYAL_BLUE.getIndex());
navy.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND);
org.apache.poi.ss.usermodel.Row head = row(sheet, 7);
head.createCell(1).setCellValue("始发站");
head.getCell(1).setCellStyle(sky);
head.createCell(2).setCellValue("目的地");
head.getCell(2).setCellStyle(sky);
head.createCell(5).setCellValue("运输费");
head.getCell(5).setCellStyle(sky);
org.apache.poi.ss.usermodel.Row units = row(sheet, 8);
units.createCell(5).setCellValue("元/CBM");
units.getCell(5).setCellStyle(navy);
units.createCell(6).setCellValue("元/KG");
units.getCell(6).setCellStyle(navy);
row(sheet, 9);
boolean[] keep = QuoteTemplateExcelSupport.markLandHeaderRows(sheet);
assertTrue(keep[7]);
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
}
JSONObject facts = new JSONObject(true);
facts.put("origin", "上海");
facts.put("destination", "江苏南京市");
facts.put("price_per_cbm", "100");
QuoteTemplateExcelSupport.fillWorkbook(src, out, new JSONObject(), facts);
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = filled.getSheetAt(0);
assertEquals("元/CBM", read(sheet, 8, 5));
assertEquals("元/KG", read(sheet, 8, 6));
byte[] unitRgb = QuoteTemplateExcelSupport.readFillRgb(sheet.getRow(8).getCell(5));
byte[] headRgb = QuoteTemplateExcelSupport.readFillRgb(sheet.getRow(7).getCell(1));
assertTrue(unitRgb != null && unitRgb.length >= 3);
assertTrue(headRgb != null && headRgb.length >= 3);
assertArrayEquals(headRgb, unitRgb);
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
}
/**
* 国内整车:车型格「4.2米/3T」是浅蓝表头,出单后不能变成业务员那种深蓝。
*/
@Test
public void landDomesticFtlVehicleHeaderFillSurvives() throws Exception {
Path src = Files.createTempFile("quote-land-ftl-src", ".xlsx");
Path out = Files.createTempFile("quote-land-ftl-out", ".xlsx");
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("国内整车");
org.apache.poi.ss.usermodel.CellStyle sky = wb.createCellStyle();
sky.setFillForegroundColor(org.apache.poi.ss.usermodel.IndexedColors.SKY_BLUE.getIndex());
sky.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND);
org.apache.poi.ss.usermodel.CellStyle navy = wb.createCellStyle();
navy.setFillForegroundColor(org.apache.poi.ss.usermodel.IndexedColors.ROYAL_BLUE.getIndex());
navy.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND);
org.apache.poi.ss.usermodel.Row head = row(sheet, 7);
head.createCell(1).setCellValue("始发站");
head.getCell(1).setCellStyle(sky);
head.createCell(2).setCellValue("目的地");
head.getCell(2).setCellStyle(sky);
head.createCell(3).setCellValue("车型/费用");
head.getCell(3).setCellStyle(sky);
head.createCell(8).setCellValue("时效");
head.getCell(8).setCellStyle(sky);
org.apache.poi.ss.usermodel.Row veh = row(sheet, 8);
veh.createCell(3).setCellValue("4.2米/3T");
veh.getCell(3).setCellStyle(navy);
veh.createCell(4).setCellValue("7.6米/8T");
veh.getCell(4).setCellStyle(navy);
row(sheet, 9);
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
}
JSONObject facts = new JSONObject(true);
facts.put("origin", "北京");
facts.put("destination", "上海");
QuoteTemplateExcelSupport.fillWorkbook(src, out, new JSONObject(), facts);
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = filled.getSheetAt(0);
assertEquals("4.2米/3T", read(sheet, 8, 3));
assertEquals("7.6米/8T", read(sheet, 8, 4));
byte[] vehRgb = QuoteTemplateExcelSupport.readFillRgb(sheet.getRow(8).getCell(3));
byte[] headRgb = QuoteTemplateExcelSupport.readFillRgb(sheet.getRow(7).getCell(1));
assertTrue(vehRgb != null && vehRgb.length >= 3);
assertTrue(headRgb != null && headRgb.length >= 3);
assertArrayEquals(headRgb, vehRgb);
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
}
private static org.apache.poi.ss.usermodel.Row row(Sheet sheet, int r) { private static org.apache.poi.ss.usermodel.Row row(Sheet sheet, int r) {
org.apache.poi.ss.usermodel.Row exist = sheet.getRow(r); org.apache.poi.ss.usermodel.Row exist = sheet.getRow(r);
return exist == null ? sheet.createRow(r) : exist; return exist == null ? sheet.createRow(r) : exist;
@@ -1983,4 +2231,58 @@ public class QuoteTemplateFillTest {
} }
return cell.getStringCellValue(); return cell.getStringCellValue();
} }
/**
* 陆运运价表:燃油附加费率、税率必须写到表头同列的数据行。
* rate=0.09→9%;rateFlag 是是否含税,不写进税率格。
*/
@Test
public void landGroupageFillsFuelSurchargeAndTaxRateOnMatrix() throws Exception {
Path src = Files.createTempFile("quote-land-fuel-tax-src", ".xlsx");
Path out = Files.createTempFile("quote-land-fuel-tax-out", ".xlsx");
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("中越集拼报价");
org.apache.poi.ss.usermodel.Row matrix = row(sheet, 7);
matrix.createCell(1).setCellValue("始发地");
matrix.createCell(2).setCellValue("目的地");
matrix.createCell(9).setCellValue("币种");
matrix.createCell(10).setCellValue("燃油附加费率");
matrix.createCell(11).setCellValue("税率");
row(sheet, 9);
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
}
JSONObject mapping = new JSONObject(true);
mapping.put("sheet", "中越集拼报价");
JSONArray regions = new JSONArray();
JSONObject region = new JSONObject(true);
region.put("headerRow", 8);
region.put("dataStartRow", 10);
JSONObject columns = new JSONObject(true);
columns.put("origin", "B");
columns.put("destination", "C");
region.put("columns", columns);
regions.add(region);
mapping.put("routeRegions", regions);
JSONObject facts = new JSONObject(true);
facts.put("route_rows", QuoteTemplateExcelSupport.parseFeeRows(
"[{\"origin\":\"广州\",\"destination\":\"河内\",\"fuelSurchargeRate\":\"8\",\"rate\":\"0.09\",\"rateFlag\":\"1\"}]"));
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = filled.getSheetAt(0);
assertEquals("广州", read(sheet, 9, 1));
assertEquals("河内", read(sheet, 9, 2));
assertEquals("8%", read(sheet, 9, 10));
assertEquals("9%", read(sheet, 9, 11));
assertEquals("燃油附加费率", read(sheet, 7, 10));
assertEquals("税率", read(sheet, 7, 11));
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
assertEquals("9%", QuoteTemplateExcelSupport.formatLandTaxRateDisplay("0.09"));
assertEquals("8%", QuoteTemplateExcelSupport.formatLandPercentDisplay("8.0"));
assertEquals("含税", QuoteTemplateExcelSupport.formatLandTaxIncludedDisplay("1"));
}
} }
@@ -178,10 +178,8 @@ public class InquiryTmsCabinServiceTest {
InquiryTmsCabinService.fillLockQuoteExtras(req, quote, option); InquiryTmsCabinService.fillLockQuoteExtras(req, quote, option);
assertEquals("2026-09-30T23:59:59+08:00", req.get("validUntil")); assertEquals("2026-09-30T23:59:59+08:00", req.get("validUntil"));
@SuppressWarnings("unchecked") // departureCharges 出站会 TMS 1001,只保留 feeItems。
java.util.List<Map<String, Object>> dep = org.junit.jupiter.api.Assertions.assertNull(req.get("departureCharges"));
(java.util.List<Map<String, Object>>) req.get("departureCharges");
assertEquals("运输费", dep.get(0).get("chargeName"));
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
java.util.List<Map<String, Object>> fees = java.util.List<Map<String, Object>> fees =
(java.util.List<Map<String, Object>>) req.get("feeItems"); (java.util.List<Map<String, Object>>) req.get("feeItems");
@@ -261,4 +259,40 @@ public class InquiryTmsCabinServiceTest {
InquiryTmsCabinService.fillManualLock(req, quote, new HashMap<>(), new HashMap<>(), false); InquiryTmsCabinService.fillManualLock(req, quote, new HashMap<>(), new HashMap<>(), false);
org.junit.jupiter.api.Assertions.assertNull(req.get("quoteId")); org.junit.jupiter.api.Assertions.assertNull(req.get("quoteId"));
} }
@Test
public void validateManualLockOutboundReportsMissingFields() {
Map<String, Object> empty = new HashMap<>();
String why = InquiryTmsCabinService.validateManualLockOutbound(empty);
org.junit.jupiter.api.Assertions.assertTrue(why.contains("optionNo"));
org.junit.jupiter.api.Assertions.assertTrue(why.contains("airline"));
org.junit.jupiter.api.Assertions.assertTrue(why.contains("chargeableWeightKg"));
Map<String, Object> ok = new HashMap<>();
ok.put("optionNo", "AIR-OPT01");
ok.put("airline", "CZ");
ok.put("chargeableWeightKg", "100");
ok.put("flightNo", "CZ327");
ok.put("operatorName", "航线");
ok.put("validUntil", "2026-10-01T23:59:59+08:00");
assertEquals("", InquiryTmsCabinService.validateManualLockOutbound(ok));
}
@Test
public void airlineCodeOrBlankRejectsChineseName() {
assertEquals("CZ", InquiryTmsCabinService.airlineCodeOrBlank("CZ"));
assertEquals("", InquiryTmsCabinService.airlineCodeOrBlank("南航"));
assertEquals("", InquiryTmsCabinService.airlineCodeOrBlank(""));
}
@Test
public void realTmsQuoteIdSkipsManualAndKeepsOlderTms() {
org.jeecg.modules.inquiry.entity.InquiryQuote manual = new org.jeecg.modules.inquiry.entity.InquiryQuote();
manual.setPayloadJson("{\"source\":\"PRODUCT\",\"source_label\":\"航线报价\",\"total\":\"200\"}");
org.jeecg.modules.inquiry.entity.InquiryQuote tms = new org.jeecg.modules.inquiry.entity.InquiryQuote();
tms.setPayloadJson("{\"source\":\"TMS\",\"quoteId\":\"AIR-REAL-1\",\"airOptions\":[{\"optionNo\":\"AIR-OPT01\"}]}");
String id = InquiryTmsCabinService.realTmsQuoteId(
java.util.Arrays.asList(manual, tms), "WO202610030009");
assertEquals("AIR-REAL-1", id);
}
} }
@@ -184,6 +184,42 @@ public class TmsQuoteCurrencyTest {
assertTrue(quote.get("fee_lines").contains("2800")); assertTrue(quote.get("fee_lines").contains("2800"));
} }
/**
* 陆运 TMS:fuelSurchargeRate=燃油附加费率;rate=税率;rateFlag=是否含税。
* 禁止用 fxRates.rate(汇率)冒充税率。
*/
@Test
public void toAgentQuoteProjectsLandFuelAndTaxRate() {
Map<String, Object> seg = new HashMap<>();
seg.put("segmentSequence", 1);
seg.put("origin", "广州市");
seg.put("destination", "深圳市");
seg.put("costPrice", "110");
seg.put("currency", "CNY");
seg.put("fuelSurchargeRate", 8);
seg.put("rate", 0.09);
seg.put("rateFlag", 1);
Map<String, Object> data = new HashMap<>();
data.put("quoteFound", true);
data.put("currency", "CNY");
data.put("landSegments", Collections.singletonList(seg));
Map<String, Object> tms = new HashMap<>();
tms.put("code", 1);
tms.put("tmsData", data);
Map<String, String> quote = InquiryTmsQueryService.toAgentQuote(tms);
assertTrue(quote.get("route_rows").contains("\"fuel_surcharge_rate\":\"8%\""));
assertTrue(quote.get("route_rows").contains("\"tax_rate\":\"9%\""));
assertTrue(quote.get("route_rows").contains("\"tax_included\":\"含税\""));
assertTrue(quote.get("route_rows").contains("\"rateFlag\":\"1\""));
assertFalse(quote.get("route_rows").contains("fxRates"));
assertEquals("8%", InquiryTmsQueryService.formatLandPercentRate("8"));
assertEquals("9%", InquiryTmsQueryService.formatLandTaxRate("0.09"));
assertEquals("10%", InquiryTmsQueryService.formatLandTaxRate("10"));
assertEquals("含税", InquiryTmsQueryService.formatLandTaxIncluded("1"));
assertEquals("不含税", InquiryTmsQueryService.formatLandTaxIncluded("0"));
assertEquals("", InquiryTmsQueryService.formatLandPercentRate(null));
}
@Test @Test
public void toAgentQuoteKeepsSeaBucketTotals() { public void toAgentQuoteKeepsSeaBucketTotals() {
Map<String, Object> pol = new HashMap<>(); Map<String, Object> pol = new HashMap<>();
@@ -568,13 +568,22 @@ public class LoginController {
} }
/** /**
* 切换菜单表为vue3的表 * 切换菜单表为vue3的表。
* <p>
* 询价新库若已无 {@code sys_permission_v3},服务层会拒绝切换并返回错误,避免把现用菜单表 rename 掉。
* </p>
*/ */
@RequiresRoles({"admin"}) @RequiresRoles({"admin"})
@GetMapping(value = "/switchVue3Menu") @GetMapping(value = "/switchVue3Menu")
public Result<String> switchVue3Menu(HttpServletResponse response) { public Result<String> switchVue3Menu(HttpServletResponse response) {
Result<String> res = new Result<String>(); Result<String> res = new Result<String>();
sysPermissionService.switchVue3Menu(); try {
sysPermissionService.switchVue3Menu();
res.success("切换成功");
} catch (Exception e) {
log.error("switchVue3Menu rejected or failed: {}", e.getMessage(), e);
res.error500(e.getMessage());
}
return res; return res;
} }
@@ -49,6 +49,18 @@ public interface SysPermissionMapper extends BaseMapper<SysPermission> {
public void backupVue2Menu(); public void backupVue2Menu();
@Update("alter table sys_permission_v3 rename to sys_permission") @Update("alter table sys_permission_v3 rename to sys_permission")
public void changeVue3Menu(); public void changeVue3Menu();
/**
* 是否存在 Jeecg 官方 Vue3 菜单备份表 {@code sys_permission_v3}。
* <p>
* 询价新库 {@code inquiry_robot} 的菜单种子本身已是 Vue3 形态,通常没有 v3 表;
* 若此时仍执行 rename 切换,会把正在使用的 {@code sys_permission} 改名后找不到替代表,侧栏全挂。
* </p>
*
* @return 存在则 &gt; 0
*/
@Select("SELECT COUNT(1) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'sys_permission_v3'")
Long countVue3MenuTable();
/** /**
* 获取模糊匹配规则的数据权限URL * 获取模糊匹配规则的数据权限URL
@@ -55,8 +55,22 @@ public class SysPermissionServiceImpl extends ServiceImpl<SysPermissionMapper, S
@Resource @Resource
private SysDepartRolePermissionMapper sysDepartRolePermissionMapper; private SysDepartRolePermissionMapper sysDepartRolePermissionMapper;
/**
* 将菜单表从 Vue2 备份切换为 Vue3 表(Jeecg 官方升级路径)。
* <p>
* 副作用:{@code sys_permission} rename 为 {@code sys_permission_v2},再把 {@code sys_permission_v3}
* rename 为 {@code sys_permission}。线程:请求线程,需 admin 角色。
* </p>
* <p>
* 为何加护栏:询价测服库菜单已是 Vue3,且没有 {@code sys_permission_v3};盲目切换会弄挂后台菜单。
* </p>
*/
@Override @Override
public void switchVue3Menu() { public void switchVue3Menu() {
Long vue3TableCnt = sysPermissionMapper.countVue3MenuTable();
if (vue3TableCnt == null || vue3TableCnt.longValue() <= 0L) {
throw new JeecgBootException("当前库没有 sys_permission_v3,菜单已是 Vue3 或不可自动切换,已拒绝执行");
}
sysPermissionMapper.backupVue2Menu(); sysPermissionMapper.backupVue2Menu();
sysPermissionMapper.changeVue3Menu(); sysPermissionMapper.changeVue3Menu();
} }
@@ -2,7 +2,6 @@ import type { AppRouteRecordRaw, Menu } from '/@/router/types';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { store } from '/@/store'; import { store } from '/@/store';
import { useI18n } from '/@/hooks/web/useI18n';
import { useUserStore } from './user'; import { useUserStore } from './user';
import { useAppStoreWithOut } from './app'; import { useAppStoreWithOut } from './app';
import { toRaw } from 'vue'; import { toRaw } from 'vue';
@@ -18,10 +17,9 @@ import { ERROR_LOG_ROUTE, PAGE_NOT_FOUND_ROUTE } from '/@/router/routes/basic';
import { filter } from '/@/utils/helper/treeHelper'; import { filter } from '/@/utils/helper/treeHelper';
import { getMenuList,switchVue3Menu } from '/@/api/sys/menu'; import { getMenuList } from '/@/api/sys/menu';
import { getPermCode } from '/@/api/sys/user'; import { getPermCode } from '/@/api/sys/user';
import { useMessage } from '/@/hooks/web/useMessage';
import { PageEnum } from '/@/enums/pageEnum'; import { PageEnum } from '/@/enums/pageEnum';
/** /**
@@ -246,7 +244,6 @@ export const usePermissionStore = defineStore({
this.setAuthData(systemPermission); this.setAuthData(systemPermission);
}, },
async buildRoutesAction(): Promise<AppRouteRecordRaw[]> { async buildRoutesAction(): Promise<AppRouteRecordRaw[]> {
const { t } = useI18n();
const userStore = useUserStore(); const userStore = useUserStore();
const appStore = useAppStoreWithOut(); const appStore = useAppStoreWithOut();
@@ -322,54 +319,18 @@ export const usePermissionStore = defineStore({
// 后台菜单构建 // 后台菜单构建
case PermissionModeEnum.BACK: case PermissionModeEnum.BACK:
const { createMessage, createWarningModal } = useMessage();
// 菜单加载提示
// createMessage.loading({
// content: t('sys.app.menuLoading'),
// duration: 1,
// });
// 从后台获取权限码, // 从后台获取权限码,
// 这个函数可能只需要执行一次,并且实际的项目可以在正确的时间被放置 // 这个函数可能只需要执行一次,并且实际的项目可以在正确的时间被放置
let routeList: AppRouteRecordRaw[] = []; let routeList: AppRouteRecordRaw[] = [];
try { try {
this.changePermissionCode(); this.changePermissionCode();
routeList = (await getMenuList()) as AppRouteRecordRaw[]; routeList = (await getMenuList()) as AppRouteRecordRaw[];
// update-begin----author:sunjianlei---date:20220315------for: 判断是否是 vue3 版本的菜单 --- // Jeecg 原逻辑:顶层没有 layouts/default/index 且图标无冒号 → 弹窗并调用 /sys/switchVue3Menu
let hasIndex: boolean = false; // (把 sys_permission rename 成 v2,再把 sys_permission_v3 rename 成正式表)。
let hasIcon: boolean = false; // 询价库 inquiry_robot 菜单种子已是 Vue3,且测服没有 sys_permission_v3;点确认会弄挂侧栏。
for (let menuItem of routeList) { // 因此禁用自动切表弹窗;后面仍会 ensureInquiryHandwrittenRoutes 补齐询价页。
// 条件1:判断组件是否是 layouts/default/index // update-begin----author:ytd---date:20261007------for: 禁用危险的 Vue2→Vue3 菜单切表提示 ---
if (!hasIndex) { // update-end----author:ytd---date:20261007------for: 禁用危险的 Vue2→Vue3 菜单切表提示 ---
hasIndex = menuItem.component === 'layouts/default/index';
}
// 条件2:判断图标是否带有 冒号
if (!hasIcon) {
hasIcon = !!menuItem.meta?.icon?.includes(':');
}
// 满足任何一个条件都直接跳出循环
if (hasIcon || hasIndex) {
break;
}
}
// 两个条件都不满足,就弹出提示框
if (!hasIcon && !hasIndex) {
// 延迟1.5秒之后再出现提示,否则提示框出不来
setTimeout(
() =>
createWarningModal({
title: '检测提示',
content:
'当前菜单表是 <b>Vue2版本</b>,导致菜单加载异常!<br>点击确认,切换到Vue3版菜单!',
onOk:function () {
switchVue3Menu();
location.reload();
}
}),
100
);
}
// update-end----author:sunjianlei---date:20220315------for: 判断是否是 vue3 版本的菜单 ---
} catch (error) { } catch (error) {
console.error(error); console.error(error);
// 菜单接口失败时仍补挂询价手写页,避免侧栏点进去全是 404 // 菜单接口失败时仍补挂询价手写页,避免侧栏点进去全是 404
@@ -1,4 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 账号管理:对接 Jeecg /sys/user(主账登录账号)。 * 账号管理:对接 Jeecg /sys/user(主账登录账号)。
* 与员工岗位页分离——本页管后台登录,员工页管企微业务角色。 * 与员工岗位页分离——本页管后台登录,员工页管企微业务角色。
@@ -305,7 +305,7 @@ function removeAccount(row: AccountRow) {
</div> </div>
</Teleport> </Teleport>
<div class="section-card"> <div class="section-card is-list-page">
<div class="toolbar"> <div class="toolbar">
<div class="search"> <div class="search">
<ProtoIcon class="search-icon" name="search" :size="16" /> <ProtoIcon class="search-icon" name="search" :size="16" />
@@ -325,6 +325,7 @@ function removeAccount(row: AccountRow) {
<th>账号</th> <th>账号</th>
<th>姓名</th> <th>姓名</th>
<th>绑定角色</th> <th>绑定角色</th>
<th>数据范围</th>
<th>手机号</th> <th>手机号</th>
<th>最后登录</th> <th>最后登录</th>
<th>状态</th> <th>状态</th>
@@ -333,10 +334,10 @@ function removeAccount(row: AccountRow) {
</thead> </thead>
<tbody> <tbody>
<tr v-if="loading"> <tr v-if="loading">
<td colspan="7" style="text-align: center; color: #94a3b8">加载中…</td> <td colspan="8" style="text-align: center; color: #94a3b8">加载中…</td>
</tr> </tr>
<tr v-else-if="!rows.length"> <tr v-else-if="!rows.length">
<td colspan="7" style="text-align: center; color: #94a3b8">暂无账号</td> <td colspan="8" style="text-align: center; color: #94a3b8">暂无账号</td>
</tr> </tr>
<tr v-for="row in rows" :key="row.id"> <tr v-for="row in rows" :key="row.id">
<td class="account-username">{{ row.username }}</td> <td class="account-username">{{ row.username }}</td>
@@ -344,6 +345,20 @@ function removeAccount(row: AccountRow) {
<td> <td>
<span class="tag" :class="row.roleTag">{{ row.roleLabel }}</span> <span class="tag" :class="row.roleTag">{{ row.roleLabel }}</span>
</td> </td>
<td>
<!-- 与编辑弹窗「数据范围」同源:dashboardSelfOnly -->
<span
class="tag"
:class="row.dashboardSelfOnly ? 'tag-amber' : 'tag-teal'"
:title="
row.dashboardSelfOnly
? '分析看板仅统计本账号企微归属的工单'
: '分析看板可看权限内全部数据'
"
>
{{ row.dashboardSelfOnly ? "只看自己数据" : "全部数据" }}
</span>
</td>
<td>{{ row.phoneMasked }}</td> <td>{{ row.phoneMasked }}</td>
<td>{{ row.lastLogin }}</td> <td>{{ row.lastLogin }}</td>
<td> <td>
@@ -2,6 +2,9 @@
/** /**
* 询价后台登录页:视觉 1:1 对齐 admin/LoginView(居中白卡片 + 品牌区,无验证码)。 * 询价后台登录页:视觉 1:1 对齐 admin/LoginView(居中白卡片 + 品牌区,无验证码)。
* 鉴权走 Jeecg loginApi;主账 inquiry 配置关闭图形验证码(jeecg.loginCaptchaEnabled=false)。 * 鉴权走 Jeecg loginApi;主账 inquiry 配置关闭图形验证码(jeecg.loginCaptchaEnabled=false)。
*
* 禁止写死 admin/123456:退出后再进登录页会误显示测试账号。
* 仅记住上次成功登录的账号(不存密码),方便换人登录时改账号。
*/ */
import { reactive, ref, toRaw } from 'vue'; import { reactive, ref, toRaw } from 'vue';
import { ConfigProvider, message } from 'ant-design-vue'; import { ConfigProvider, message } from 'ant-design-vue';
@@ -10,11 +13,29 @@
import '/@/views/inquiry/styles/admin-prototype.css'; import '/@/views/inquiry/styles/admin-prototype.css';
import '/@/views/inquiry/styles/admin-fidelity.css'; import '/@/views/inquiry/styles/admin-fidelity.css';
const LAST_USERNAME_KEY = 'inquiry_admin_last_username';
function readLastUsername(): string {
try {
return String(localStorage.getItem(LAST_USERNAME_KEY) || '').trim();
} catch {
return '';
}
}
function rememberUsername(username: string) {
try {
localStorage.setItem(LAST_USERNAME_KEY, username);
} catch {
/* 隐私模式等写失败时忽略 */
}
}
const userStore = useUserStore(); const userStore = useUserStore();
const loading = ref(false); const loading = ref(false);
const form = reactive({ const form = reactive({
username: 'admin', username: readLastUsername(),
password: '123456', password: '',
}); });
const themeToken = { const themeToken = {
@@ -36,9 +57,10 @@
} }
loading.value = true; loading.value = true;
try { try {
const username = form.username.trim();
const { userInfo } = await userStore.login( const { userInfo } = await userStore.login(
toRaw({ toRaw({
username: form.username.trim(), username,
password: form.password, password: form.password,
// 主账已关验证码;仍传空字段以兼容 LoginParams 类型 // 主账已关验证码;仍传空字段以兼容 LoginParams 类型
captcha: '', captcha: '',
@@ -48,6 +70,9 @@
}) })
); );
if (userInfo) { if (userInfo) {
rememberUsername(username);
// 成功后清空密码,避免浏览器后退/再进登录页时残留明文
form.password = '';
message.success(`登录成功,欢迎 ${userInfo.realname || userInfo.username}`); message.success(`登录成功,欢迎 ${userInfo.realname || userInfo.username}`);
} }
} catch (e: any) { } catch (e: any) {
@@ -342,7 +342,7 @@ export const mockReplyCategories: ReplyCategory[] = [
id: "brv10000000000000000000000000041", id: "brv10000000000000000000000000041",
replyCode: "lost.reason_ack", replyCode: "lost.reason_ack",
trigger: "销售回复未成交原因", trigger: "销售回复未成交原因",
text: "已记下未成交原因:{{lost_reason}}。", text: "已将未成交原因“{{lost_reason}}”记录在后台,工单 {{ticket_no}}已关闭,智能体不再跟进该工单,请悉知。",
enabled: true, enabled: true,
} }
], ],
@@ -1,4 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 角色管理:对接 Jeecg /sys/role 与菜单权限树。 * 角色管理:对接 Jeecg /sys/role 与菜单权限树。
* 数据权限按 Excel 线路目录树勾选,写入角色 description 的 [dataPerms:…],不另造 RBAC。 * 数据权限按 Excel 线路目录树勾选,写入角色 description 的 [dataPerms:…],不另造 RBAC。
@@ -255,7 +255,7 @@ function onDataPermCheck(checked: string[] | { checked: string[] }) {
</button> </button>
</Teleport> </Teleport>
<div class="section-card"> <div class="section-card is-list-page">
<div class="table-wrap"> <div class="table-wrap">
<table class="data-table"> <table class="data-table">
<thead> <thead>
@@ -494,7 +494,7 @@ function filterRouteOption(input: string, option: any) {
</div> </div>
</Teleport> </Teleport>
<div class="section-card"> <div class="section-card is-list-page">
<div class="tabs-line-bar"> <div class="tabs-line-bar">
<div class="tabs-line"> <div class="tabs-line">
<button type="button" :class="{ 'is-active': tab === 'all' }" @click="tab = 'all'"> <button type="button" :class="{ 'is-active': tab === 'all' }" @click="tab = 'all'">
@@ -23,20 +23,78 @@ body {
grid-template-columns: 220px minmax(0, 1fr) !important; grid-template-columns: 220px minmax(0, 1fr) !important;
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
overflow-x: hidden; /* 锁视口:避免 html overflow:hidden 裁切后整页既无文档滚、内容区又不内滚 */
height: 100vh !important;
max-height: 100vh !important;
min-height: 0 !important;
overflow: hidden !important;
} }
.main-stage { .main-stage {
background: #f7f8fb !important; background: #f7f8fb !important;
min-width: 0 !important; min-width: 0 !important;
overflow-x: hidden; /*
* 中间主区 = 滚动容器(看板/非列表页)。
* 实测仅改 .admin-content 在生产壳层仍会被裁切且无条;改由本列横竖自滚最稳。
*/
height: 100vh !important;
max-height: 100vh !important;
min-height: 0 !important;
overflow-x: auto !important;
overflow-y: scroll !important;
scrollbar-gutter: stable;
-webkit-overflow-scrolling: touch;
display: flex !important;
flex-direction: column !important;
}
.main-stage {
scrollbar-width: thin;
scrollbar-color: #cbd5e1 transparent;
}
.main-stage::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.main-stage::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 999px;
border: 1px solid transparent;
background-clip: padding-box;
}
.main-stage::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
background-clip: padding-box;
}
.main-stage::-webkit-scrollbar-track {
background: transparent;
}
.main-stage::-webkit-scrollbar-corner {
background: transparent;
} }
.admin-content { .admin-content {
padding: 16px 16px 40px !important; padding: 16px 16px 40px !important;
gap: 12px !important; gap: 12px !important;
min-width: 0 !important; min-width: 0 !important;
overflow-x: hidden; /* 随内容增高/增宽;滚动条在 .main-stage */
flex: 0 0 auto !important;
height: auto !important;
max-height: none !important;
min-height: 0 !important;
overflow: visible !important;
}
/* 列表页:主区不滚,仍由表格区内滚(工单/账号等) */
.main-stage:has(.is-list-page) {
overflow: hidden !important;
}
.main-stage:has(.is-list-page) .admin-content {
flex: 1 1 0 !important;
height: 0 !important;
min-height: 0 !important;
max-height: none !important;
overflow: hidden !important;
} }
/* /*
@@ -52,6 +110,12 @@ body {
display: flex !important; display: flex !important;
align-items: center !important; align-items: center !important;
border-bottom: 1px solid #e8edf4 !important; border-bottom: 1px solid #e8edf4 !important;
/* 中间主区滚动时顶栏吸顶,不跟着内容滚走 */
position: sticky !important;
top: 0 !important;
z-index: 40 !important;
flex-shrink: 0 !important;
background: rgba(255, 255, 255, 0.96) !important;
} }
.admin-brand { .admin-brand {
@@ -559,12 +623,11 @@ body {
gap: 12px 16px !important; gap: 12px 16px !important;
} }
/* 中间列横滑;左右 sticky 列固定 */ /* 中间列横滑;左右 sticky 列固定。竖滚改由 .is-list-page 表格区承接,勿再用 visible 把滚动甩给整页 */
.ticket-page .table-wrap, .ticket-page .table-wrap,
.ticket-table-scroll { .ticket-table-scroll {
border-radius: 10px !important; border-radius: 10px !important;
overflow-x: auto !important; overflow-x: auto !important;
overflow-y: visible;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
@@ -1061,6 +1124,18 @@ h6 {
.admin-content:has(.ref-surface--metrics) { .admin-content:has(.ref-surface--metrics) {
padding: 14px 14px 36px !important; padding: 14px 14px 36px !important;
gap: 10px !important; gap: 10px !important;
/* 分析看板:内容区不裁切,滚动交给 .main-stage */
overflow: visible !important;
max-height: none !important;
}
/* 看板缩窄时允许卡片/图表收缩,避免右侧被裁且无横滚 */
.admin-content .ref-surface,
.admin-content .ref-charts-section,
.admin-content .ref-metrics-grid,
.admin-content .ref-chart-panel {
min-width: 0 !important;
max-width: 100% !important;
} }
/* ---- 话术 ---- */ /* ---- 话术 ---- */
@@ -1415,3 +1490,102 @@ textarea.field.field-tall {
align-items: start !important; align-items: start !important;
} }
/**
* 列表页固定搜索区 / 表头 / 底部分页,表格区内双轴滚动。
* 根节点加 .is-list-page(工单、员工、账号、角色、报价模板)。
* 这样横向滚动条始终贴在表格可视区底部,不必滚到文档末尾才看到。
*/
.admin-content:has(> .is-list-page) {
overflow: hidden !important;
display: flex !important;
flex-direction: column !important;
padding-bottom: 16px !important;
}
.admin-content > .is-list-page {
flex: 1 1 auto !important;
min-height: 0 !important;
display: flex !important;
flex-direction: column !important;
overflow: hidden !important;
}
/* 工单页:外壳 is-list-page,内层 section-card 撑满 */
.admin-content > .is-list-page > .section-card {
flex: 1 1 auto !important;
min-height: 0 !important;
display: flex !important;
flex-direction: column !important;
overflow: hidden !important;
gap: 12px !important;
}
/* 账号/员工/角色/模板:section-card 自身即 is-list-page */
.admin-content > .section-card.is-list-page {
display: flex !important;
flex-direction: column !important;
gap: 12px !important;
}
.is-list-page .section-card-header,
.is-list-page .tabs-line-bar,
.is-list-page .toolbar,
.is-list-page .pagination {
flex-shrink: 0 !important;
}
.is-list-page .table-wrap,
.is-list-page .qt-template-table-wrap {
flex: 1 1 auto !important;
min-height: 0 !important;
overflow: auto !important;
overflow-x: auto !important;
overflow-y: auto !important;
-webkit-overflow-scrolling: touch;
}
/* 竖向滚动时表头钉在表格区顶部 */
.is-list-page .data-table thead th {
position: sticky !important;
top: 0 !important;
z-index: 4 !important;
background: #f8fafc !important;
box-shadow: 0 1px 0 var(--line, #d9e2ef);
}
/* 左/右冻结列与表头交叉处抬高层级,避免横滑时被盖住 */
.is-list-page .data-table thead th:first-child,
.is-list-page .data-table thead th.col-sticky-left {
left: 0 !important;
z-index: 6 !important;
}
.is-list-page .data-table thead th:last-child,
.is-list-page .data-table thead th.col-sticky-right {
right: 0 !important;
z-index: 6 !important;
}
.is-list-page .data-table tbody td:first-child,
.is-list-page .data-table tbody td.col-sticky-left {
position: sticky !important;
left: 0 !important;
z-index: 3 !important;
background: #fff !important;
}
.is-list-page .data-table tbody td:last-child,
.is-list-page .data-table tbody td.col-sticky-right {
position: sticky !important;
right: 0 !important;
z-index: 3 !important;
background: #fff !important;
}
.is-list-page .data-table tbody tr:hover td:first-child,
.is-list-page .data-table tbody tr:hover td:last-child,
.is-list-page .data-table tbody tr:hover td.col-sticky-left,
.is-list-page .data-table tbody tr:hover td.col-sticky-right {
background: #f8fbff !important;
}
@@ -688,7 +688,7 @@ async function downloadPreviewOrOriginal() {
</button> </button>
</Teleport> </Teleport>
<div class="section-card qt-template-page"> <div class="section-card qt-template-page is-list-page">
<div class="toolbar"> <div class="toolbar">
<div class="search"> <div class="search">
<ProtoIcon class="search-icon" name="search" :size="16" /> <ProtoIcon class="search-icon" name="search" :size="16" />
@@ -126,6 +126,17 @@ function asPriceLines(raw: any): PriceLine[] {
.filter((row) => row.name); .filter((row) => row.name);
} }
/**
* 未调价利润点必须是 0%。主账应已逐行给 0%;旧接口没给时,按成本/销售费用名补 0%。
* 禁止把金额拿来填利润点列。
*/
function displayProfitLines(row: TicketRow): PriceLine[] {
if (row.profitLines.length) return row.profitLines;
const src = row.saleLines.length ? row.saleLines : row.costLines;
if (!src.length) return [];
return src.map((line) => ({ name: line.name, text: "0%" }));
}
/** /**
* 后台报价来源:TMS标准报价 / 手工报价 / 销售调价。 * 后台报价来源:TMS标准报价 / 手工报价 / 销售调价。
* 禁止把产品报价或销售调价再显示成 TMS。 * 禁止把产品报价或销售调价再显示成 TMS。
@@ -242,9 +253,88 @@ function parseQuoteFees(q: any): TicketQuoteFee[] {
pushRows(asFeeArray(payload[key] || payload[bucket])); pushRows(asFeeArray(payload[key] || payload[bucket]));
} }
} }
// 陆运 TMS:燃油附加费率 / 税率挂在线路段上,不是金额费目行。
for (const extra of parseLandRateFees(payload)) {
if (!out.some((x) => x.name === extra.name && x.amount === extra.amount)) {
out.push(extra);
}
}
return out; return out;
} }
/**
* 陆运线路:fuelSurchargeRate=燃油附加费率;rate=税率;rateFlag=是否含税(1是0否)。
* 只读 payload,不写库。海运/空运没有这些键则不出行。
*/
function parseLandRateFees(payload: Record<string, any>): TicketQuoteFee[] {
const rows = asFeeArray(payload.route_rows || payload.landSegments || payload.land_segments);
const out: TicketQuoteFee[] = [];
const seen = new Set<string>();
for (const row of rows) {
if (!row || typeof row !== "object") continue;
const fuel = formatLandPercent(row.fuel_surcharge_rate ?? row.fuelSurchargeRate);
const tax = formatLandTaxRate(row.tax_rate ?? row.taxRate ?? row.rate);
const included = formatLandTaxIncluded(
row.tax_included ?? row.rateFlag ?? row.rate_flag
);
if (fuel) {
const key = `fuel:${fuel}`;
if (!seen.has(key)) {
seen.add(key);
out.push({ name: "燃油附加费率", amount: fuel });
}
}
if (tax) {
const key = `tax:${tax}`;
if (!seen.has(key)) {
seen.add(key);
out.push({ name: "税率", amount: tax });
}
}
if (included) {
const key = `inc:${included}`;
if (!seen.has(key)) {
seen.add(key);
out.push({ name: "是否含税", amount: included });
}
}
}
return out;
}
/** 燃油附加费率:数字带 %。 */
function formatLandPercent(raw: unknown): string {
const s = String(raw ?? "").trim();
if (!s || s === "-" || s.toLowerCase() === "null") return "";
if (s.includes("%")) return s;
if (/^-?\d+(\.\d+)?$/.test(s)) {
return `${s.replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, "")}%`;
}
return s;
}
/** 税率 rate:0.09→9%;大于 1 按百分点。 */
function formatLandTaxRate(raw: unknown): string {
const s = String(raw ?? "").trim();
if (!s || s === "-" || s.toLowerCase() === "null") return "";
if (s.includes("%")) return s;
if (!/^-?\d+(\.\d+)?$/.test(s)) return s;
const n = Number(s);
if (!Number.isFinite(n)) return s;
const abs = Math.abs(n);
const pct = abs > 0 && abs <= 1 ? n * 100 : n;
return `${String(pct).replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, "")}%`;
}
/** rateFlag:1=含税,0=不含税。 */
function formatLandTaxIncluded(raw: unknown): string {
const s = String(raw ?? "").trim();
if (!s || s === "-" || s.toLowerCase() === "null") return "";
if (s === "1" || s.toLowerCase() === "true" || s === "是" || s === "含税") return "含税";
if (s === "0" || s.toLowerCase() === "false" || s === "否" || s === "不含税") return "不含税";
return s;
}
/** /**
* 主账 inq_ticket_status_log.action 是内部码,禁止当备注展示。 * 主账 inq_ticket_status_log.action 是内部码,禁止当备注展示。
* transition 与「已报价」等 toStatus 重复,无用户备注时不再跟一段英文。 * transition 与「已报价」等 toStatus 重复,无用户备注时不再跟一段英文。
@@ -413,6 +503,44 @@ function quoteHasFees(row: TicketRow) {
return (row.quotes || []).some((q) => (q.fees || []).length > 0); return (row.quotes || []).some((q) => (q.fees || []).length > 0);
} }
/**
* 报价信息 / 成本价 / 销售价 / 利润点共用一把「展开」:
* 收起时只留摘要(报价来源+总额、费用合计行),展开才看逐费明细。
* 避免只收起报价列、成本/利润仍撑很高。
*/
const priceExpandedMap = reactive<Record<string, boolean>>({});
const detailPriceExpanded = ref(false);
function detailPriceNeedsToggle(row: TicketRow | null) {
if (!row) return false;
return priceBlockNeedsToggle(row);
}
function isPriceExpanded(id: string) {
return !!priceExpandedMap[id];
}
function togglePriceExpanded(id: string) {
priceExpandedMap[id] = !priceExpandedMap[id];
}
function priceBlockNeedsToggle(row: TicketRow) {
return (
quoteHasFees(row) ||
row.costLines.length > 1 ||
row.saleLines.length > 1 ||
displayProfitLines(row).length > 1
);
}
/** 收起:优先只留「费用合计」;没有合计则留首行,避免整列空白。 */
function visiblePriceLines(lines: PriceLine[], expanded: boolean): PriceLine[] {
if (!lines.length) return [];
if (expanded || lines.length <= 1) return lines;
const totals = lines.filter((l) => l.name === "费用合计");
return totals.length ? totals : lines.slice(0, 1);
}
/** 列表询价需求第二行起:与旧后台 summary-cell 多行展示一致。 */ /** 列表询价需求第二行起:与旧后台 summary-cell 多行展示一致。 */
function demandDetailLines(row: TicketRow) { function demandDetailLines(row: TicketRow) {
return String(row?.demandDetail || "") return String(row?.demandDetail || "")
@@ -472,6 +600,7 @@ onMounted(() => loadList());
async function openDetail(row: TicketRow) { async function openDetail(row: TicketRow) {
current.value = row; current.value = row;
detailPriceExpanded.value = false;
detailOpen.value = true; detailOpen.value = true;
try { try {
const res: any = await getWorkOrderDetail(row.id); const res: any = await getWorkOrderDetail(row.id);
@@ -540,7 +669,7 @@ function refresh() {
</div> </div>
</Teleport> </Teleport>
<div class="ticket-page"> <div class="ticket-page is-list-page">
<div class="section-card"> <div class="section-card">
<div class="section-card-header"> <div class="section-card-header">
<h2 class="section-card-title">询价工单列表</h2> <h2 class="section-card-title">询价工单列表</h2>
@@ -664,35 +793,41 @@ function refresh() {
</ExpandableClamp> </ExpandableClamp>
</td> </td>
<td class="align-top col-quote"> <td class="align-top col-quote">
<ExpandableClamp <div class="ticket-price-expand">
v-if="row.quotes.length" <template v-if="row.quotes.length">
v-slot="{ expanded }" <div
:content-key="row.id + '-quote-' + row.quotes.length" v-for="q in row.quotes"
:force-toggle="quoteHasFees(row)" :key="q.source + q.price"
:clip-collapsed="false" class="quote-row"
> style="margin-bottom: 8px"
<div >
v-for="q in row.quotes" <div class="quote-source">{{ q.source }}</div>
:key="q.source + q.price" <div class="quote-price" :style="q.highlight ? { color: '#1d4ed8' } : undefined">
class="quote-row" {{ q.price }}
style="margin-bottom: 8px"
>
<div class="quote-source">{{ q.source }}</div>
<div class="quote-price" :style="q.highlight ? { color: '#1d4ed8' } : undefined">
{{ q.price }}
</div>
<template v-if="expanded && q.fees.length">
<div
v-for="(fee, fi) in q.fees"
:key="q.source + '-fee-' + fi"
class="quote-fee-line"
>
{{ fee.name }}:{{ fee.amount }}
</div> </div>
</template> <template v-if="isPriceExpanded(row.id) && q.fees.length">
</div> <div
</ExpandableClamp> v-for="(fee, fi) in q.fees"
<span v-else style="color: #94a3b8">-</span> :key="q.source + '-fee-' + fi"
class="quote-fee-line"
>
{{ fee.name }}:{{ fee.amount }}
</div>
</template>
</div>
</template>
<span v-else style="color: #94a3b8">-</span>
<button
v-if="priceBlockNeedsToggle(row)"
type="button"
class="ticket-price-toggle"
:class="{ 'is-open': isPriceExpanded(row.id) }"
:title="isPriceExpanded(row.id) ? '收起费用明细' : '展开费用明细'"
@click.stop="togglePriceExpanded(row.id)"
>
<ProtoIcon name="chevron" :size="14" />
</button>
</div>
</td> </td>
<td class="align-top"> <td class="align-top">
<div v-if="row.attachments.length" class="attach-tags"> <div v-if="row.attachments.length" class="attach-tags">
@@ -702,9 +837,12 @@ function refresh() {
</td> </td>
<td class="align-top"><span class="ticket-status">{{ row.status }}</span></td> <td class="align-top"><span class="ticket-status">{{ row.status }}</span></td>
<td class="align-top"> <td class="align-top">
<div v-if="row.costLines.length" class="ticket-price-lines"> <div
v-if="visiblePriceLines(row.costLines, isPriceExpanded(row.id)).length"
class="ticket-price-lines"
>
<div <div
v-for="(line, i) in row.costLines" v-for="(line, i) in visiblePriceLines(row.costLines, isPriceExpanded(row.id))"
:key="row.id + '-cost-' + i" :key="row.id + '-cost-' + i"
class="ticket-price-line" class="ticket-price-line"
:class="{ 'is-total': line.name === '费用合计' }" :class="{ 'is-total': line.name === '费用合计' }"
@@ -715,9 +853,12 @@ function refresh() {
<span v-else class="ticket-price-cell">{{ row.costPrice }}</span> <span v-else class="ticket-price-cell">{{ row.costPrice }}</span>
</td> </td>
<td class="align-top"> <td class="align-top">
<div v-if="row.saleLines.length" class="ticket-price-lines"> <div
v-if="visiblePriceLines(row.saleLines, isPriceExpanded(row.id)).length"
class="ticket-price-lines"
>
<div <div
v-for="(line, i) in row.saleLines" v-for="(line, i) in visiblePriceLines(row.saleLines, isPriceExpanded(row.id))"
:key="row.id + '-sale-' + i" :key="row.id + '-sale-' + i"
class="ticket-price-line" class="ticket-price-line"
:class="{ 'is-total': line.name === '费用合计' }" :class="{ 'is-total': line.name === '费用合计' }"
@@ -728,9 +869,12 @@ function refresh() {
<span v-else class="ticket-price-cell" style="font-weight: 800">{{ row.salePrice }}</span> <span v-else class="ticket-price-cell" style="font-weight: 800">{{ row.salePrice }}</span>
</td> </td>
<td class="align-top"> <td class="align-top">
<div v-if="row.profitLines.length" class="ticket-price-lines"> <div
v-if="visiblePriceLines(displayProfitLines(row), isPriceExpanded(row.id)).length"
class="ticket-price-lines"
>
<div <div
v-for="(line, i) in row.profitLines" v-for="(line, i) in visiblePriceLines(displayProfitLines(row), isPriceExpanded(row.id))"
:key="row.id + '-profit-' + i" :key="row.id + '-profit-' + i"
class="ticket-price-line" class="ticket-price-line"
:class="{ 'is-total': line.name === '费用合计' }" :class="{ 'is-total': line.name === '费用合计' }"
@@ -738,7 +882,7 @@ function refresh() {
{{ line.name }}:{{ line.text || "-" }} {{ line.name }}:{{ line.text || "-" }}
</div> </div>
</div> </div>
<span v-else class="ticket-margin-cell">{{ row.profit }}</span> <span v-else class="ticket-margin-cell">{{ row.profit === "-" ? "0%" : row.profit }}</span>
</td> </td>
<td class="col-center"> <td class="col-center">
<span v-if="row.eventException !== '-'" class="tag tag-rose">{{ row.eventException }}</span> <span v-if="row.eventException !== '-'" class="tag tag-rose">{{ row.eventException }}</span>
@@ -843,56 +987,60 @@ function refresh() {
<div class="ticket-detail-title"> <div class="ticket-detail-title">
<ProtoIcon name="gauge" :size="14" />价格与利润 <ProtoIcon name="gauge" :size="14" />价格与利润
</div> </div>
<div class="ticket-detail-grid"> <div class="ticket-price-expand">
<div class="ticket-detail-item"> <div class="ticket-detail-grid">
<label>成本价</label> <div class="ticket-detail-item">
<div v-if="current.costLines.length" class="ticket-price-lines"> <label>成本价</label>
<div <div
v-for="(line, i) in current.costLines" v-if="visiblePriceLines(current.costLines, detailPriceExpanded).length"
:key="'detail-cost-' + i" class="ticket-price-lines"
class="ticket-price-line"
:class="{ 'is-total': line.name === '费用合计' }"
> >
{{ line.name }}:{{ line.text || "-" }} <div
v-for="(line, i) in visiblePriceLines(current.costLines, detailPriceExpanded)"
:key="'detail-cost-' + i"
class="ticket-price-line"
:class="{ 'is-total': line.name === '费用合计' }"
>
{{ line.name }}:{{ line.text || "-" }}
</div>
</div> </div>
<span v-else>{{ current.costPrice }}</span>
</div> </div>
<span v-else>{{ current.costPrice }}</span> <div class="ticket-detail-item">
</div> <label>销售价</label>
<div class="ticket-detail-item">
<label>销售价</label>
<div v-if="current.saleLines.length" class="ticket-price-lines">
<div <div
v-for="(line, i) in current.saleLines" v-if="visiblePriceLines(current.saleLines, detailPriceExpanded).length"
:key="'detail-sale-' + i" class="ticket-price-lines"
class="ticket-price-line"
:class="{ 'is-total': line.name === '费用合计' }"
> >
{{ line.name }}:{{ line.text || "-" }} <div
v-for="(line, i) in visiblePriceLines(current.saleLines, detailPriceExpanded)"
:key="'detail-sale-' + i"
class="ticket-price-line"
:class="{ 'is-total': line.name === '费用合计' }"
>
{{ line.name }}:{{ line.text || "-" }}
</div>
</div> </div>
<span v-else>{{ current.salePrice }}</span>
</div> </div>
<span v-else>{{ current.salePrice }}</span> <div class="ticket-detail-item">
</div> <label>利润点</label>
<div class="ticket-detail-item">
<label>利润点</label>
<div v-if="current.profitLines.length" class="ticket-price-lines">
<div <div
v-for="(line, i) in current.profitLines" v-if="visiblePriceLines(displayProfitLines(current), detailPriceExpanded).length"
:key="'detail-profit-' + i" class="ticket-price-lines"
class="ticket-price-line"
:class="{ 'is-total': line.name === '费用合计' }"
> >
{{ line.name }}:{{ line.text || "-" }} <div
v-for="(line, i) in visiblePriceLines(displayProfitLines(current), detailPriceExpanded)"
:key="'detail-profit-' + i"
class="ticket-price-line"
:class="{ 'is-total': line.name === '费用合计' }"
>
{{ line.name }}:{{ line.text || "-" }}
</div>
</div> </div>
<span v-else>{{ current.profit === "-" ? "0%" : current.profit }}</span>
</div> </div>
<span v-else>{{ current.profit }}</span>
</div> </div>
</div>
<ExpandableClamp
v-if="current.quotes.length"
class="ticket-detail-demand"
:content-key="current.id + '-detail-quote-' + current.quotes.length"
:max-height="220"
>
<div <div
v-for="q in current.quotes" v-for="q in current.quotes"
:key="q.source + q.price + (q.time || '')" :key="q.source + q.price + (q.time || '')"
@@ -901,7 +1049,7 @@ function refresh() {
<div class="quote-source"> <div class="quote-source">
{{ q.source }}<span v-if="q.time && q.time !== '-'"> · {{ q.time }}</span> {{ q.source }}<span v-if="q.time && q.time !== '-'"> · {{ q.time }}</span>
</div> </div>
<div v-if="q.fees.length" class="quote-fee-list"> <div v-if="detailPriceExpanded && q.fees.length" class="quote-fee-list">
<div <div
v-for="(fee, fi) in q.fees" v-for="(fee, fi) in q.fees"
:key="q.source + '-detail-fee-' + fi" :key="q.source + '-detail-fee-' + fi"
@@ -918,7 +1066,17 @@ function refresh() {
合计 {{ q.price }} 合计 {{ q.price }}
</div> </div>
</div> </div>
</ExpandableClamp> <button
v-if="detailPriceNeedsToggle(current)"
type="button"
class="ticket-price-toggle"
:class="{ 'is-open': detailPriceExpanded }"
:title="detailPriceExpanded ? '收起费用明细' : '展开费用明细'"
@click="detailPriceExpanded = !detailPriceExpanded"
>
<ProtoIcon name="chevron" :size="14" />
</button>
</div>
</section> </section>
<section class="ticket-detail-section"> <section class="ticket-detail-section">
@@ -1032,4 +1190,32 @@ function refresh() {
color: #708090; color: #708090;
font-size: 12px; font-size: 12px;
} }
/* 报价+成本+利润共用展开:箭头管整块费用明细 */
.ticket-price-expand {
position: relative;
min-width: 0;
}
.ticket-price-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
margin-top: 4px;
padding: 2px 0 0;
border: 0;
background: transparent;
color: #64748b;
cursor: pointer;
line-height: 1;
}
.ticket-price-toggle:hover {
color: #1d4ed8;
}
.ticket-price-toggle :deep(svg) {
transition: transform 0.15s ease;
}
.ticket-price-toggle.is-open :deep(svg) {
transform: rotate(180deg);
}
</style> </style>