diff --git a/inquiry-agent/agent/ledger/http_ledger.py b/inquiry-agent/agent/ledger/http_ledger.py index 5a59331..1c6dcaf 100644 --- a/inquiry-agent/agent/ledger/http_ledger.py +++ b/inquiry-agent/agent/ledger/http_ledger.py @@ -40,8 +40,18 @@ class HttpLedger: 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) if not resp.ok: - logger.warning("http_ledger fail path=%s status=%s err=%s", path, resp.status_code, resp.error) - return {"ok": False, "error": resp.error or f"http_{resp.status_code}"} + # 500 时 Jeecg 仍可能带回 message(如六态非法);勿只留 http_500, + # 否则成交补「已报价」重试认不出「询价中」(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) def find_similar_open( @@ -166,8 +176,12 @@ class HttpLedger: """ no = (work_order_no or "").strip() src = dict(quote or {}) - if not no or not src: - return {"ok": True, "quote_version": 1, "status": to_status or "已报价"} + if not no: + return {"ok": False, "error": "work_order_blank"} + # 空报价不打主账(避免抹掉已有查价),但禁止伪装成「已写入已报价」—— + # 否则会话以为已报价、主账仍停询价中,成交三态会失败(WO022)。 + if not src: + return {"ok": False, "error": "quote_blank", "skipped": True} out = self._post( "/inquiry/agent/ticket/replaceTmsQuote", {"workOrderNo": no, "quote": src, "toStatus": to_status}, @@ -423,6 +437,7 @@ class HttpLedger: operator_name: str = "", instruction_text: str = "", airline: str = "", + flight_no: str = "", ) -> dict[str, Any]: body: dict[str, Any] = { "workOrderNo": work_order_no, @@ -433,6 +448,8 @@ class HttpLedger: } if (airline or "").strip(): body["airline"] = airline.strip().upper() + if (flight_no or "").strip(): + body["flightNo"] = flight_no.strip().upper() return self._post( "/inquiry/agent/tms/lockCabin", body, diff --git a/inquiry-agent/agent/ledger/memory_ledger.py b/inquiry-agent/agent/ledger/memory_ledger.py index 0e415e1..0bb49b3 100644 --- a/inquiry-agent/agent/ledger/memory_ledger.py +++ b/inquiry-agent/agent/ledger/memory_ledger.py @@ -827,9 +827,14 @@ class MemoryLedger: operator_name: str = "", instruction_text: str = "", airline: str = "", + flight_no: str = "", ) -> dict[str, Any]: - """单测可注入 _cabin_lock_result;默认成功。""" - _ = sender_id, operator_name, instruction_text, airline + """单测可注入 _cabin_lock_result;默认成功。 + + 纯人工且视图无 TMS 舱位时,缺 option/航司/航班 → lock_incomplete, + 对齐主账「现拉仍缺才回」的口径(内存账不会真查 TMS)。 + """ + _ = sender_id, operator_name, instruction_text forced = getattr(self, "_cabin_lock_result", None) if isinstance(forced, dict): return dict(forced) @@ -845,6 +850,27 @@ class MemoryLedger: quote_id = str(quote.get("quoteId") or "").strip() if quote_id.upper().startswith("QT-WO"): 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 { "ok": True, "status": "LOCKED", diff --git a/inquiry-agent/agent/llm/extract_text.py b/inquiry-agent/agent/llm/extract_text.py index 342a55c..b3fcfba 100644 --- a/inquiry-agent/agent/llm/extract_text.py +++ b/inquiry-agent/agent/llm/extract_text.py @@ -757,6 +757,30 @@ _RE_MONEY = re.compile( r"|(?:USD|CNY|RMB|美金|美元)\s*(\d+(?:\.\d+)?)", re.I, ) +# 陆运集拼:「2-500KG 单价CNY990」是分档单价,不是「燃油附加费=2」。 +_RE_WEIGHT_BAND_PRICE = re.compile( + r"(?P\d+)\s*[-~~—–到至]\s*(?P\d+)\s*K\s*G" + r"(?:\s*以上)?" + r"\s*(?:单价)?" + r"\s*(?:(?PUSD|CNY|RMB|美金|美元|人民币)\s*)?" + r"(?P\d+(?:\.\d+)?)", + re.I, +) +_RE_WEIGHT_BAND_OPEN = re.compile( + r"(?P\d+)\s*K\s*G\s*以上" + r"\s*(?:单价)?" + r"\s*(?:(?PUSD|CNY|RMB|美金|美元|人民币)\s*)?" + r"(?P\d+(?:\.\d+)?)", + re.I, +) +_RE_FUEL_SURCHARGE_RATE = re.compile( + r"燃油附加费率\s*[::=]?\s*(?P\d+(?:\.\d+)?)\s*%?", + re.I, +) +_RE_TAX_RATE = re.compile( + r"(?\d+(?:\.\d+)?)\s*%?", + re.I, +) _RE_FEE_LINE = re.compile( r"(?P[\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费|[A-Za-z]{2,10})" r"\s*(?:改为|改成|调整为|调整成)?" @@ -867,6 +891,8 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non 认不出价格返回空 dict,调用方回「没有识别到报价」。 群里常见「码头操作费100CNY / 报关费改为300CNY」,不能只把第一笔数当成海运费。 「空运费 单价…金额…」必须保留空运费,金额用「金额」后面的数,不用单价。 + 陆运集拼:「2-500KG 单价CNY990,燃油附加费率1%,税率6%」走分档单价+费率, + 禁止把公斤档下限「2」当成燃油附加费金额(WO021)。 """ if isinstance(injected, dict) and (injected.get("total") or injected.get("fee_rows")): out = dict(injected) @@ -879,16 +905,22 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non compact = stripped.replace(" ", "").replace(" ", "") if re.fullmatch(r"WO\d{12}", compact, re.I): 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: ccy = rows[0].get("currency") or _currency_of(raw) - total = rows[0]["amount"] - if len({x.get("currency") for x in rows}) == 1: + money_rows = [x for x in rows if "%" not in str(x.get("amount") or "")] + 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: - total = str(sum(float(x["amount"]) for x in rows)) + total = str(sum(float(x["amount"]) for x in money_rows)) except ValueError: - total = rows[0]["amount"] - return { + total = money_rows[0]["amount"] + out = { "total": total, "currency": ccy, "source_label": "产品报价", @@ -896,9 +928,12 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non "fee_rows": 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: - return {} + rates = _extract_rate_fields(raw) + return rates if rates else {} amount = _ascii_amount(hit.group(1) or hit.group(2) or "") if not amount: return {} @@ -906,12 +941,13 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non # 句里已经点了费用名(空运费)时,不能退回默认海运费。 named = re.search( 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 "海运费" - if fee_name in {"费用"}: - fee_name = "海运费" - return { + if fee_name in {"费用", "燃油附加费"}: + # 「燃油附加费率」里的「燃油附加费」不是费用行名;无其它费名时用运费。 + fee_name = "海运费" if fee_name == "费用" else "陆运费" + out = { "total": amount, "currency": ccy, "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_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 diff --git a/inquiry-agent/agent/policy/air_group_ops.py b/inquiry-agent/agent/policy/air_group_ops.py index cd0388b..d9f160c 100644 --- a/inquiry-agent/agent/policy/air_group_ops.py +++ b/inquiry-agent/agent/policy/air_group_ops.py @@ -3,7 +3,8 @@ 本文件职责:群里副作用(主账 + BOT 回话)。海运应用群仍走 sea_group_ops。 禁止:自动建群/拉人/解散;拿当前工单去猜锁舱工单号。 -锁舱按 TMS /air/cabin/lock 拼字段出站;没有 TMS 报价编号也要锁,用工单字段补 quoteId/optionNo。 +锁舱按 TMS /air/cabin/lock 拼字段出站;没有 TMS 报价编号也可走 MANUAL。 +禁止编造 optionNo;缺舱位编号/航司/计费重时先群里补问,禁止空壳打 TMS。 """ from __future__ import annotations @@ -776,22 +777,46 @@ def apply_air_deal( if stopped: return stopped 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) + 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 "" result = finish_deal_outcome( ledger=flow.ledger, - work_order_no=ticket_no(ticket), + work_order_no=no, outcome=outcome, facts=facts_of_group(view), - quote=dict(view.get("quote") or {}), + quote=quote, business_line="AIR", sales_name=str(view.get("sales_name") or ""), lost_reason=inline, ) 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" phase = str(result.get("phase") or "done") ack_reason = inline if phase == "done" and outcome == "未成交" else "" @@ -800,7 +825,7 @@ def apply_air_deal( chat_id, copy.deal_ack( outcome, - ticket_no(ticket), + no, str(result.get("next_followup_at") or ""), lost_reason=ack_reason, ), @@ -818,6 +843,13 @@ def apply_air_lock( option_no: str = "", airline: str = "", ) -> str: + """ + 空运群锁舱。 + + 副作用:调主账 lockCabin(HMAC 出站 TMS);成功写协同 facts、推成交跟进。 + 舱位/航司/航班交给主账现拉 TMS 补全;仅计费重全空时群里补问。 + 主账仍缺则回 lock_incomplete,禁止空壳出站。 + """ if not is_airline(flow, sender_id): send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin()) return "not_airline" @@ -825,14 +857,21 @@ def apply_air_lock( # 不能只回「TMS接口异常,IT运维排查中」。 no = ticket_no(ticket) 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 {}) quote = air_quote or view_quote - options = _air_options(quote) - quote_id = _quote_id(quote) + # 最新价常是人工覆盖:舱位/航司/航班要从本单 TMS 原价自动补,不能逼航线手打。 + 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() 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": flow.ledger.patch_collab_facts( work_order_no=no, facts={_PENDING_LOCK: "1"}, sender_id=sender_id @@ -841,6 +880,42 @@ def apply_air_lock( return "wait_lock_option" if not picked and len(options) == 1: 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) if not callable(locker): send_group_text( @@ -857,6 +932,14 @@ def apply_air_lock( 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( lambda: locker( work_order_no=no, @@ -864,15 +947,30 @@ def apply_air_lock( sender_id=sender_id, operator_name=name, instruction_text=instruction, - airline=(airline or "").strip(), + airline=airline_code, + flight_no=flight_no, ) or {}, - is_ok=lambda x: bool((x or {}).get("ok")), + is_ok=_lock_terminal, ) if not out.get("ok"): if str(out.get("error") or "") == "no_quote": send_group_text(group_client_of(flow), chat_id, copy.air_lock_need_quote(no)) 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( tms_reason=str(out.get("failReason") or out.get("error") or ""), flight_date=_ticket_flight_date(ticket), @@ -895,6 +993,7 @@ def apply_air_lock( conversation_target=chat_id, extra={ "option_no": picked, + "airline": airline_code, "instruction": instruction, "sender_id": sender_id, "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() +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]]: raw = quote.get("airOptions") or quote.get("air_options") if isinstance(raw, str) and raw.strip().startswith("["): diff --git a/inquiry-agent/agent/policy/air_text_flow.py b/inquiry-agent/agent/policy/air_text_flow.py index e491cb5..7f3c282 100644 --- a/inquiry-agent/agent/policy/air_text_flow.py +++ b/inquiry-agent/agent/policy/air_text_flow.py @@ -1780,8 +1780,12 @@ class AirTextInquiryFlow: extra, ) 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) + # 文件已出就必须让主账离开「询价中」,否则成交三态会被六态拒绝。 + ensure_ticket_quoted(self._ledger, work_order_no=sess.work_order_no) + sess.status = sess.status or "已报价" sess.phase = "wait_deal" sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE) sess.wait_version += 1 @@ -1926,20 +1930,37 @@ class AirTextInquiryFlow: """点成交/未成交/协商中:先整卡置灰,再入主账。未成交同一句带原因则一次记完。""" 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.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 "" result = finish_deal_outcome( ledger=self._ledger, work_order_no=sess.work_order_no, outcome=status, facts=sess.facts, - quote=sess.quote, + quote=quote, business_line=sess.business_line, sales_name=sess.invited_sales_name, lost_reason=inline, ) 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" phase = str(result.get("phase") or "done") sess.status = status @@ -1976,7 +1997,12 @@ class AirTextInquiryFlow: sess.phase = "done" sess.allowed = () 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" def mark_wait_deal(self, sender_id: str, work_order_no: str) -> None: @@ -1990,8 +2016,10 @@ class AirTextInquiryFlow: if not no: return 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) + ensure_ticket_quoted(self._ledger, work_order_no=no) if not sender_id: return with self._lock: diff --git a/inquiry-agent/agent/policy/deal_outcome.py b/inquiry-agent/agent/policy/deal_outcome.py index c93d255..18fc2df 100644 --- a/inquiry-agent/agent/policy/deal_outcome.py +++ b/inquiry-agent/agent/policy/deal_outcome.py @@ -210,7 +210,11 @@ def finish_deal_outcome( writer = getattr(ledger, "transition", None) if not callable(writer): 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"): 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 @@ -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]: """把销售下一句写进未成交原因。@询价小助手 不是原因。""" text = strip_bot_mentions(reason or "") diff --git a/inquiry-agent/agent/policy/inquiry_copy.py b/inquiry-agent/agent/policy/inquiry_copy.py index 471731d..fc2df8f 100644 --- a/inquiry-agent/agent/policy/inquiry_copy.py +++ b/inquiry-agent/agent/policy/inquiry_copy.py @@ -1023,7 +1023,12 @@ def quote_fee_lines(quote: dict[str, Any] | None) -> list[tuple[str, str]]: amount = str(item.get("amount") or item.get("value") or "").strip() line_ccy = str(item.get("currency") or item.get("currencyCode") or header_ccy).strip() 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: return lines freight = str(src.get("freight") or "").strip() @@ -1219,10 +1224,21 @@ def sea_quote_detail_text( 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, ) @@ -2661,8 +2677,24 @@ def group_deal_stale(*, work_order_no: str) -> str: return f"这张成交确认已过期。工单{no}已有更新的报价,请点击群里最新一条成交跟进。" -def group_deal_need_quote() -> str: - return "还没有可确认的报价,请先完成报价再确认成交状态。" +def group_deal_need_quote(*, work_order_no: str = "") -> str: + """ + 群里确认成交/未成交时,主账还没有可确认报价。 + + 空运 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: @@ -2717,7 +2749,7 @@ def deal_ack( if outcome == "未成交": reason = (lost_reason or "").strip() if reason: - return f"已记录为「未成交」。{lost_reason_ack(reason)}" + return f"已记录为「未成交」。{lost_reason_ack(reason, work_order_no=no)}" return _r("lost.ask_reason", "已记录为「未成交」。请补充未成交原因。", ticket_no=no) when = _followup_month_day(next_followup_at) or "第四个工作日" return _r( @@ -2744,14 +2776,19 @@ def _followup_month_day(raw: str) -> str: return "" -def lost_reason_ack(reason: str = "") -> str: - """收下未成交原因后回显原句。后台 lost.reason_ack。""" +def lost_reason_ack(reason: str = "", work_order_no: str = "") -> str: + """ + 收下未成交原因后回显。后台 lost.reason_ack。 + + 测服文案含 {{ticket_no}} / {{lost_reason}};缺 ticket_no 会原样打出占位符。 + """ text = (reason or "").strip() + no = (work_order_no or "").strip() if text.endswith(("。", ".", "!", "!", "?", "?")): default = f"已记下未成交原因:{text}" else: default = f"已记下未成交原因:{text}。" if text else "已记下未成交原因。" - return _r("lost.reason_ack", default, lost_reason=text) + return _r("lost.reason_ack", default, lost_reason=text, ticket_no=no) def deal_notify_card( @@ -3044,6 +3081,53 @@ def air_ask_lock_option(options: list) -> str: 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( *, work_order_no: str, @@ -3072,7 +3156,7 @@ def air_lock_ok( def air_lock_fail(*, work_order_no: str, operator: str, reason: str) -> str: - """空运锁舱失败。后台 inquiring.air_lock_fail。""" + """空运锁舱失败(接口/系统类)。后台 inquiring.air_lock_fail。""" why = (reason or "").strip() or "TMS未返回原因" default = ( f"工单{work_order_no} 空运锁舱失败。\n" @@ -3089,6 +3173,25 @@ def air_lock_fail(*, work_order_no: str, operator: str, reason: str) -> str: ) +def air_lock_incomplete(*, work_order_no: str, operator: str, reason: str = "") -> str: + """ + 锁舱参数不齐(纯手工价、运价库无舱位等):业务补问,不算系统异常,不惊动 IT。 + + 航线按示例回舱位+航司+航班后即可再锁。 + """ + why = (reason or "").strip() + lines = [ + f"工单{work_order_no} 还不能锁舱。", + f"操作人:{operator}", + ] + if why: + lines.append(f"原因:{why}") + else: + lines.append("原因:本单没有可引用的 TMS 舱位(手工价或运价库无匹配)。") + lines.append("请直接回复「AIR-OPT01 CZ 航班CZ327」(换成实际舱位、航司、航班号)后再锁。") + return "\n".join(lines) + + def air_cabin_fail_reason(*, tms_reason: str = "", flight_date: str = "", today: str = "") -> str: """ 锁舱/释放失败原因:原样回 TMS 的 failReason。 diff --git a/inquiry-agent/agent/policy/sea_group_ops.py b/inquiry-agent/agent/policy/sea_group_ops.py index e71b376..fda9640 100644 --- a/inquiry-agent/agent/policy/sea_group_ops.py +++ b/inquiry-agent/agent/policy/sea_group_ops.py @@ -906,12 +906,14 @@ def apply_product_quote( logger.info("产品报价未识别 wo=%s text=%s", no, (text or "")[:200]) send_group_text(client, chat_id, copy.group_quote_not_recognized()) return "quote_not_recognized" + quote = _attach_land_weight_band_segment(quote, view) base = _current_quote(view, sess) if _has_tms_quote(base): _park_pending_quote(no, quote, sess, flow) send_group_text(client, chat_id, copy.group_ask_tms_or_product()) 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) _save_product_quote(flow, no, quote, sess) ticket2 = _reload(flow, chat_id, no) or ticket @@ -1231,8 +1233,11 @@ def apply_group_deal( return "not_sales" quote = _current_quote(view, sess) 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 {"已报价", "协商中"}): - send_group_text(client, chat_id, copy.group_deal_need_quote()) + 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(client, chat_id, copy.group_deal_need_quote(work_order_no=no)) return "deal_need_quote" 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.allowed = () 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" @@ -1634,6 +1646,53 @@ def merge_quote_fees(base: dict[str, Any] | None, overlay: dict[str, Any] | None 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( flow: Any, no: str, diff --git a/inquiry-agent/agent/policy/system_exception.py b/inquiry-agent/agent/policy/system_exception.py index 4a53cf2..735a257 100644 --- a/inquiry-agent/agent/policy/system_exception.py +++ b/inquiry-agent/agent/policy/system_exception.py @@ -850,6 +850,7 @@ def _resume_cabin_step( work_order_no=no, lock_id=lock_id, sender_id=sender, + operator_name=str(extra.get("operator_name") or "").strip(), instruction_text=instruction, ) or {} @@ -860,13 +861,20 @@ def _resume_cabin_step( service = "空运舱位" reason = "空运释放舱位接口调用失败" else: + # 与首次群锁舱对齐:重试必须带 operatorName / airline,否则 MANUAL 出站缺姓名。 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( lambda: locker( work_order_no=no, option_no=option_no, sender_id=sender, + operator_name=op_name, instruction_text=instruction, + airline=airline, + flight_no=flight_no, ) or {} if callable(locker) diff --git a/inquiry-agent/agent/routing/air_group_intent.py b/inquiry-agent/agent/routing/air_group_intent.py index 8db5c8e..cf28d75 100644 --- a/inquiry-agent/agent/routing/air_group_intent.py +++ b/inquiry-agent/agent/routing/air_group_intent.py @@ -154,10 +154,17 @@ def _hint_lock_or_release(text: str) -> str: def _only_work_order(text: str) -> bool: - """去掉工单号和 @询价小助手 后几乎没字,当作激活/切单。""" + """ + 去掉工单号和 @询价小助手 后几乎没字,当作激活/切单。 + + 销售常按话术复制「工单编号WO…@询价小助手」;残留「工单编号」不算业务意图。 + 运行证据:only_wo=false 后走 extract_product_quote→product_quote→航线岗拒绝。 + """ no = copy.extract_work_order_no(text) rest = (text or "").replace(no, "").replace(no.lower(), "") 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") return not rest.strip() diff --git a/inquiry-agent/tests/test_air_group_collab.py b/inquiry-agent/tests/test_air_group_collab.py index 6625c9e..51ec3a7 100644 --- a/inquiry-agent/tests/test_air_group_collab.py +++ b/inquiry-agent/tests/test_air_group_collab.py @@ -421,6 +421,21 @@ class AirGroupCollabTests(unittest.TestCase): classify_air_group_text("@询价小助手 WO202609160002", allow_network=False), 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 with patch( @@ -434,6 +449,13 @@ class AirGroupCollabTests(unittest.TestCase): ), INTENT_ACTIVATE, ) + self.assertEqual( + classify_air_group_text( + "工单编号WO202610070026@询价小助手", + allow_network=True, + ), + INTENT_ACTIVATE, + ) def test_dispatch_aibot_goes_air(self) -> None: 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("执行成功" 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( quote={ "total": "100", @@ -921,6 +944,33 @@ class AirGroupCollabTests(unittest.TestCase): ledger=self.ledger, 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.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: """TMS 拒绝锁舱时原样回失败原因,不要改写成选项不匹配。""" - wo = self._create_air(quote={}, status="询价中") + wo = self._create_air() self.ledger._cabin_lock_result = { "ok": False, "error": "lock_fail", diff --git a/inquiry-agent/tests/test_deal_outcome.py b/inquiry-agent/tests/test_deal_outcome.py index 18fbd8d..d601b9c 100644 --- a/inquiry-agent/tests/test_deal_outcome.py +++ b/inquiry-agent/tests/test_deal_outcome.py @@ -144,10 +144,75 @@ class TestDealOutcome(unittest.TestCase): view = self.ledger.get_for_agent(work_order_no=self.wo, sender_id="sales1") 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: 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: self.assertEqual(harvest_inline_lost_reason("未成交,原因客户不想要了"), "客户不想要了") self.assertEqual( diff --git a/inquiry-agent/tests/test_sea_text_inquiry.py b/inquiry-agent/tests/test_sea_text_inquiry.py index b1a6a4f..6fd1282 100644 --- a/inquiry-agent/tests/test_sea_text_inquiry.py +++ b/inquiry-agent/tests/test_sea_text_inquiry.py @@ -1341,6 +1341,28 @@ class SeaGroupCollabFieldTests(unittest.TestCase): self.assertEqual(str(rows["海运费"].get("amount") or ""), "100.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: """空运费 单价/数量/金额:费用名是空运费,金额用「金额」不是单价。""" from agent.llm.extract_text import extract_product_quote diff --git a/inquiry-agent/tests/test_system_exception.py b/inquiry-agent/tests/test_system_exception.py index be14d8a..54bda3a 100644 --- a/inquiry-agent/tests/test_system_exception.py +++ b/inquiry-agent/tests/test_system_exception.py @@ -477,22 +477,45 @@ class TestConfirmSystemException(unittest.TestCase): self.assertTrue("USD" in joined or "拉产品" in joined or "已报价" in joined) 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( - work_order_no=self.wo, + work_order_no=air_wo, system_exception=TYPE_TMS, system_exception_reason="空运锁舱接口调用失败", step=STEP_TMS_CABIN, payload={ "conversation_kind": "group", "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 def counted(**kwargs): hits["n"] += 1 + hits["kwargs"] = dict(kwargs) return orig_lock(**kwargs) self.ledger.lock_cabin = counted # type: ignore[method-assign] @@ -504,12 +527,14 @@ class TestConfirmSystemException(unittest.TestCase): orig = se._send_to_conversation se._send_to_conversation = lambda **kwargs: talks.append(str(kwargs.get("text") or "")) try: - out = run_admin_retry(self.wo, ledger=self.ledger) + out = run_admin_retry(air_wo, ledger=self.ledger) finally: se._send_to_conversation = orig self.assertTrue(out.get("ok")) 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.assertTrue(any("空运舱位接口已恢复" in x for x in talks)) diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java index 12f53b7..d6649a7 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java @@ -1171,6 +1171,27 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl 0 + && abs.compareTo(java.math.BigDecimal.ONE) <= 0) { + n = n.multiply(new java.math.BigDecimal("100")).stripTrailingZeros(); + } + return n.stripTrailingZeros().toPlainString() + "%"; + } catch (RuntimeException ex) { + return text; + } + } + + /** rateFlag:1=含税,0=不含税。 */ + static String formatLandTaxIncludedDisplay(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 writeFeeRows(Workbook wb, JSONObject mapping, JSONObject facts, Set yellow) { JSONObject feeRows = mapping.getJSONObject("fee_rows"); if (feeRows == null) { diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java index d902bc3..60be325 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java @@ -75,11 +75,16 @@ public class InquiryTmsCabinService { Map quote = payloadOf(rows.get(0)); // 「AIR-OPT01 TH」被误落成航线报价不算真正人工价 → 仍走仅 TMS(SYSTEM)。 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); } String tmsQuoteId = realTmsQuoteId(rows, no); - if (!latestManual && StringUtils.isBlank(tmsQuoteId)) { + // 现拉到的 TMS 编号也算:库里无历史行时仍可 MANUAL+quoteId。 + if (StringUtils.isBlank(tmsQuoteId)) { tmsQuoteId = resolveQuoteId(quote, no); } boolean hasTms = StringUtils.isNotBlank(tmsQuoteId); @@ -140,26 +145,68 @@ public class InquiryTmsCabinService { text(option, "destination"), ticket.getDestination(), fact(ticket, "目的地", "目的港"))); // 航司:句中两位码(如「AIR-OPT01 TH」)> 舱位/报价字段 > 航班号前两位。 - req.put("airline", firstNonBlank( + // airlineName 中文名不能当航司码出站,只取两位码或航班前缀。 + String airlineCode = firstNonBlank( text(body, "airline", "airlineCode"), airlineFromInstruction(text(body, "instructionText", "instruction_text")), text(option, "airlineCode", "airline"), - text(option, "airlineName"), - text(quote, "airlineCode", "airline", "airlineName"), - fact(ticket, "航司", "航司代码"), - airlineFromFlight(firstNonBlank(text(option, "flightNo"), text(quote, "flightNo"))))); + text(quote, "airlineCode", "airline"), + fact(ticket, "航司代码"), + 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("volumeCbm", ticket.getVolumeCbm()); req.put("pieceQty", ticket.getPieces()); + // 计费重:舱位 > 报价 > 工单计费重事实 > 毛重(避免 MANUAL 空壳 1001)。 req.put("chargeableWeightKg", firstNonBlank( 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("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")); if ("MANUAL".equals(lockSource)) { // 有 TMS 时保留 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 { // 仅 TMS:source=SYSTEM + quoteId。禁止再塞 validUntil/feeItems(会 1001)。 req.put("source", "SYSTEM"); @@ -315,14 +362,26 @@ public class InquiryTmsCabinService { } Map body = new LinkedHashMap<>(); body.put("workOrderNo", ticket.getWorkOrderNo()); + // 锁舱现拉必须用「今天」报价日:历史询价日常已无运价(1002), + // 不能把销售初询那天的日期带出去导致永远补不到舱位。 + JSONObject facts = new JSONObject(); if (StringUtils.isNotBlank(ticket.getFactsJson())) { - JSONObject facts = JSON.parseObject(ticket.getFactsJson()); - if (facts != null) { - body.put("facts", facts); + JSONObject stored = JSON.parseObject(ticket.getFactsJson()); + if (stored != null) { + 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 { Map 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"); if (!(raw instanceof Map)) { return current; @@ -335,14 +394,19 @@ public class InquiryTmsCabinService { if (airOptions(current).isEmpty() && fetched.get("airOptions") != null) { 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")) && StringUtils.isNotBlank(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(), StringUtils.isNotBlank(text(current, "quoteId", "quote_id")), - airOptions(current).size()); + airOptions(current).size(), + live == null ? "" : live.get("classification"), + today); } catch (RuntimeException ex) { log.warn("tms.lock refresh fail wo={} err={}", ticket.getWorkOrderNo(), ex.getMessage()); } @@ -416,7 +480,11 @@ public class InquiryTmsCabinService { if (StringUtils.isNotBlank(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)) { req.put("flightNo", flightNo); } @@ -463,11 +531,41 @@ public class InquiryTmsCabinService { List> departure = feeRows(quote, option, "departureCharges", "departure_charges"); List> fees = feeRows(quote, option, "feeItems", "fee_items"); splitFeeRows(quote, departure, fees); - if (!departure.isEmpty()) { - req.put("departureCharges", toLockFees(departure, true)); - } + // 运行证据(2026-10-07):只要出站带 departureCharges,TMS 一律 1001「参数格式错误」; + // 去掉后变为业务码 2008。feeItems 可保留。故 MANUAL 锁舱不再出站 departureCharges。 if (!fees.isEmpty()) { req.put("feeItems", toLockFees(fees, false)); + } else if (!departure.isEmpty()) { + // 仅有起运港费、无 feeItems 时,折进 feeItems,避免再走 departureCharges。 + List> asFees = new ArrayList<>(); + for (Map row : toLockFees(departure, true)) { + Map 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; } + /** + * 本单真实 TMS quoteId:优先非人工报价行;人工覆盖后再锁仍要带原编号。 + * 跳过 QT-WO 假编号。 + */ static String realTmsQuoteId(List rows, String workOrderNo) { if (rows == null) { return ""; } + for (InquiryQuote row : rows) { + Map payload = payloadOf(row); + if (isManualQuote(payload) && !isFakeManualLockQuote(payload)) { + continue; + } + String id = resolveQuoteId(payload, workOrderNo); + if (StringUtils.isNotBlank(id)) { + return id; + } + } for (InquiryQuote row : rows) { String id = resolveQuoteId(payloadOf(row), workOrderNo); if (StringUtils.isNotBlank(id)) { @@ -704,6 +816,51 @@ public class InquiryTmsCabinService { return ""; } + /** + * MANUAL 出站必填校验。缺字段直接失败,禁止 HMAC 空壳打 TMS(会 1001)。 + * 返回中文原因;齐了返回空串。 + */ + static String validateManualLockOutbound(Map req) { + if (req == null) { + return "锁舱参数为空"; + } + java.util.List 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 quoteRows(String workOrderNo) { if (StringUtils.isBlank(workOrderNo)) { return new ArrayList<>(); diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java index f8a7cfb..e86dc70 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java @@ -484,6 +484,21 @@ public class InquiryTmsQueryService { putExtra(row, pair[1], firstText(seg.get(pair[0]), seg.get(pair[1]))); 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")) || StringUtils.isNotBlank(row.get("amount"))) { out.add(row); @@ -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> lines, String name, String amount, String currency, Map extras) { TmsQuoteCurrency.Money money = TmsQuoteCurrency.split(amount, currency); diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_inq_bot_reply_replace_v1.sql b/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_inq_bot_reply_replace_v1.sql index dd4ccd5..e110b09 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_inq_bot_reply_replace_v1.sql +++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_inq_bot_reply_replace_v1.sql @@ -69,7 +69,7 @@ VALUES ('brv10000000000000000000000000038','deal','已成交','deal.recorded_won','销售选择「成交」','已记录为「已成交」。工单 {{ticket_no}} 已关闭智能体跟进。',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()), -('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()), ('brv10000000000000000000000000043','sys-ex','系统异常','sys.llm_fail','大模型调用失败(销售侧)','大模型服务异常,IT排查中...',1,1,'admin',NOW()), ('brv10000000000000000000000000044','sys-ex','系统异常','sys.tms_fail','TMS 接口失败(销售侧)','TMS接口异常,IT运维排查中...',1,2,'admin',NOW()), diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateFillTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateFillTest.java index 465cb7c..9121b62 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateFillTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateFillTest.java @@ -2231,4 +2231,58 @@ public class QuoteTemplateFillTest { } 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")); + } } diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinServiceTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinServiceTest.java index b55c21c..5c4adce 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinServiceTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinServiceTest.java @@ -178,10 +178,8 @@ public class InquiryTmsCabinServiceTest { InquiryTmsCabinService.fillLockQuoteExtras(req, quote, option); assertEquals("2026-09-30T23:59:59+08:00", req.get("validUntil")); - @SuppressWarnings("unchecked") - java.util.List> dep = - (java.util.List>) req.get("departureCharges"); - assertEquals("运输费", dep.get(0).get("chargeName")); + // departureCharges 出站会 TMS 1001,只保留 feeItems。 + org.junit.jupiter.api.Assertions.assertNull(req.get("departureCharges")); @SuppressWarnings("unchecked") java.util.List> fees = (java.util.List>) req.get("feeItems"); @@ -261,4 +259,40 @@ public class InquiryTmsCabinServiceTest { InquiryTmsCabinService.fillManualLock(req, quote, new HashMap<>(), new HashMap<>(), false); org.junit.jupiter.api.Assertions.assertNull(req.get("quoteId")); } + + @Test + public void validateManualLockOutboundReportsMissingFields() { + Map 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 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); + } } diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsQuoteCurrencyTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsQuoteCurrencyTest.java index 0b232f5..730d308 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsQuoteCurrencyTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsQuoteCurrencyTest.java @@ -184,6 +184,42 @@ public class TmsQuoteCurrencyTest { assertTrue(quote.get("fee_lines").contains("2800")); } + /** + * 陆运 TMS:fuelSurchargeRate=燃油附加费率;rate=税率;rateFlag=是否含税。 + * 禁止用 fxRates.rate(汇率)冒充税率。 + */ + @Test + public void toAgentQuoteProjectsLandFuelAndTaxRate() { + Map 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 data = new HashMap<>(); + data.put("quoteFound", true); + data.put("currency", "CNY"); + data.put("landSegments", Collections.singletonList(seg)); + Map tms = new HashMap<>(); + tms.put("code", 1); + tms.put("tmsData", data); + Map 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 public void toAgentQuoteKeepsSeaBucketTotals() { Map pol = new HashMap<>(); diff --git a/inquiry-api/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/LoginController.java b/inquiry-api/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/LoginController.java index 274507e..e6683a5 100644 --- a/inquiry-api/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/LoginController.java +++ b/inquiry-api/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/LoginController.java @@ -568,13 +568,22 @@ public class LoginController { } /** - * 切换菜单表为vue3的表 + * 切换菜单表为vue3的表。 + *

+ * 询价新库若已无 {@code sys_permission_v3},服务层会拒绝切换并返回错误,避免把现用菜单表 rename 掉。 + *

*/ @RequiresRoles({"admin"}) @GetMapping(value = "/switchVue3Menu") public Result switchVue3Menu(HttpServletResponse response) { Result res = new Result(); - 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; } diff --git a/inquiry-api/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/SysPermissionMapper.java b/inquiry-api/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/SysPermissionMapper.java index d68abf6..237242c 100644 --- a/inquiry-api/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/SysPermissionMapper.java +++ b/inquiry-api/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/SysPermissionMapper.java @@ -49,6 +49,18 @@ public interface SysPermissionMapper extends BaseMapper { public void backupVue2Menu(); @Update("alter table sys_permission_v3 rename to sys_permission") public void changeVue3Menu(); + + /** + * 是否存在 Jeecg 官方 Vue3 菜单备份表 {@code sys_permission_v3}。 + *

+ * 询价新库 {@code inquiry_robot} 的菜单种子本身已是 Vue3 形态,通常没有 v3 表; + * 若此时仍执行 rename 切换,会把正在使用的 {@code sys_permission} 改名后找不到替代表,侧栏全挂。 + *

+ * + * @return 存在则 > 0 + */ + @Select("SELECT COUNT(1) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'sys_permission_v3'") + Long countVue3MenuTable(); /** * 获取模糊匹配规则的数据权限URL diff --git a/inquiry-api/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysPermissionServiceImpl.java b/inquiry-api/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysPermissionServiceImpl.java index 6c818a7..fe46766 100644 --- a/inquiry-api/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysPermissionServiceImpl.java +++ b/inquiry-api/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysPermissionServiceImpl.java @@ -55,8 +55,22 @@ public class SysPermissionServiceImpl extends ServiceImpl + * 副作用:{@code sys_permission} rename 为 {@code sys_permission_v2},再把 {@code sys_permission_v3} + * rename 为 {@code sys_permission}。线程:请求线程,需 admin 角色。 + *

+ *

+ * 为何加护栏:询价测服库菜单已是 Vue3,且没有 {@code sys_permission_v3};盲目切换会弄挂后台菜单。 + *

+ */ @Override public void switchVue3Menu() { + Long vue3TableCnt = sysPermissionMapper.countVue3MenuTable(); + if (vue3TableCnt == null || vue3TableCnt.longValue() <= 0L) { + throw new JeecgBootException("当前库没有 sys_permission_v3,菜单已是 Vue3 或不可自动切换,已拒绝执行"); + } sysPermissionMapper.backupVue2Menu(); sysPermissionMapper.changeVue3Menu(); } diff --git a/inquiry-backend/src/store/modules/permission.ts b/inquiry-backend/src/store/modules/permission.ts index 2c3d5d7..a8655cc 100644 --- a/inquiry-backend/src/store/modules/permission.ts +++ b/inquiry-backend/src/store/modules/permission.ts @@ -2,7 +2,6 @@ import type { AppRouteRecordRaw, Menu } from '/@/router/types'; import { defineStore } from 'pinia'; import { store } from '/@/store'; -import { useI18n } from '/@/hooks/web/useI18n'; import { useUserStore } from './user'; import { useAppStoreWithOut } from './app'; 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 { getMenuList,switchVue3Menu } from '/@/api/sys/menu'; +import { getMenuList } from '/@/api/sys/menu'; import { getPermCode } from '/@/api/sys/user'; -import { useMessage } from '/@/hooks/web/useMessage'; import { PageEnum } from '/@/enums/pageEnum'; /** @@ -246,7 +244,6 @@ export const usePermissionStore = defineStore({ this.setAuthData(systemPermission); }, async buildRoutesAction(): Promise { - const { t } = useI18n(); const userStore = useUserStore(); const appStore = useAppStoreWithOut(); @@ -322,54 +319,18 @@ export const usePermissionStore = defineStore({ // 后台菜单构建 case PermissionModeEnum.BACK: - const { createMessage, createWarningModal } = useMessage(); - // 菜单加载提示 - // createMessage.loading({ - // content: t('sys.app.menuLoading'), - // duration: 1, - // }); - // 从后台获取权限码, // 这个函数可能只需要执行一次,并且实际的项目可以在正确的时间被放置 let routeList: AppRouteRecordRaw[] = []; try { this.changePermissionCode(); routeList = (await getMenuList()) as AppRouteRecordRaw[]; - // update-begin----author:sunjianlei---date:20220315------for: 判断是否是 vue3 版本的菜单 --- - let hasIndex: boolean = false; - let hasIcon: boolean = false; - for (let menuItem of routeList) { - // 条件1:判断组件是否是 layouts/default/index - if (!hasIndex) { - hasIndex = menuItem.component === 'layouts/default/index'; - } - // 条件2:判断图标是否带有 冒号 - if (!hasIcon) { - hasIcon = !!menuItem.meta?.icon?.includes(':'); - } - // 满足任何一个条件都直接跳出循环 - if (hasIcon || hasIndex) { - break; - } - } - // 两个条件都不满足,就弹出提示框 - if (!hasIcon && !hasIndex) { - // 延迟1.5秒之后再出现提示,否则提示框出不来 - setTimeout( - () => - createWarningModal({ - title: '检测提示', - content: - '当前菜单表是 Vue2版本,导致菜单加载异常!
点击确认,切换到Vue3版菜单!', - onOk:function () { - switchVue3Menu(); - location.reload(); - } - }), - 100 - ); - } - // update-end----author:sunjianlei---date:20220315------for: 判断是否是 vue3 版本的菜单 --- + // Jeecg 原逻辑:顶层没有 layouts/default/index 且图标无冒号 → 弹窗并调用 /sys/switchVue3Menu + // (把 sys_permission rename 成 v2,再把 sys_permission_v3 rename 成正式表)。 + // 询价库 inquiry_robot 菜单种子已是 Vue3,且测服没有 sys_permission_v3;点确认会弄挂侧栏。 + // 因此禁用自动切表弹窗;后面仍会 ensureInquiryHandwrittenRoutes 补齐询价页。 + // update-begin----author:ytd---date:20261007------for: 禁用危险的 Vue2→Vue3 菜单切表提示 --- + // update-end----author:ytd---date:20261007------for: 禁用危险的 Vue2→Vue3 菜单切表提示 --- } catch (error) { console.error(error); // 菜单接口失败时仍补挂询价手写页,避免侧栏点进去全是 404 diff --git a/inquiry-backend/src/views/inquiry/account/index.vue b/inquiry-backend/src/views/inquiry/account/index.vue index 63afe51..daf0fb1 100644 --- a/inquiry-backend/src/views/inquiry/account/index.vue +++ b/inquiry-backend/src/views/inquiry/account/index.vue @@ -325,6 +325,7 @@ function removeAccount(row: AccountRow) { 账号 姓名 绑定角色 + 数据范围 手机号 最后登录 状态 @@ -333,10 +334,10 @@ function removeAccount(row: AccountRow) { - 加载中… + 加载中… - 暂无账号 + 暂无账号 {{ row.username }} @@ -344,6 +345,20 @@ function removeAccount(row: AccountRow) { {{ row.roleLabel }} + + + + {{ row.dashboardSelfOnly ? "只看自己数据" : "全部数据" }} + + {{ row.phoneMasked }} {{ row.lastLogin }} diff --git a/inquiry-backend/src/views/inquiry/login/index.vue b/inquiry-backend/src/views/inquiry/login/index.vue index 7a85070..5c81a3d 100644 --- a/inquiry-backend/src/views/inquiry/login/index.vue +++ b/inquiry-backend/src/views/inquiry/login/index.vue @@ -2,6 +2,9 @@ /** * 询价后台登录页:视觉 1:1 对齐 admin/LoginView(居中白卡片 + 品牌区,无验证码)。 * 鉴权走 Jeecg loginApi;主账 inquiry 配置关闭图形验证码(jeecg.loginCaptchaEnabled=false)。 + * + * 禁止写死 admin/123456:退出后再进登录页会误显示测试账号。 + * 仅记住上次成功登录的账号(不存密码),方便换人登录时改账号。 */ import { reactive, ref, toRaw } from 'vue'; import { ConfigProvider, message } from 'ant-design-vue'; @@ -10,11 +13,29 @@ import '/@/views/inquiry/styles/admin-prototype.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 loading = ref(false); const form = reactive({ - username: 'admin', - password: '123456', + username: readLastUsername(), + password: '', }); const themeToken = { @@ -36,9 +57,10 @@ } loading.value = true; try { + const username = form.username.trim(); const { userInfo } = await userStore.login( toRaw({ - username: form.username.trim(), + username, password: form.password, // 主账已关验证码;仍传空字段以兼容 LoginParams 类型 captcha: '', @@ -48,6 +70,9 @@ }) ); if (userInfo) { + rememberUsername(username); + // 成功后清空密码,避免浏览器后退/再进登录页时残留明文 + form.password = ''; message.success(`登录成功,欢迎 ${userInfo.realname || userInfo.username}`); } } catch (e: any) { diff --git a/inquiry-backend/src/views/inquiry/mock/botReplies.ts b/inquiry-backend/src/views/inquiry/mock/botReplies.ts index 36b64d9..ba44e79 100644 --- a/inquiry-backend/src/views/inquiry/mock/botReplies.ts +++ b/inquiry-backend/src/views/inquiry/mock/botReplies.ts @@ -342,7 +342,7 @@ export const mockReplyCategories: ReplyCategory[] = [ id: "brv10000000000000000000000000041", replyCode: "lost.reason_ack", trigger: "销售回复未成交原因", - text: "已记下未成交原因:{{lost_reason}}。", + text: "已将未成交原因“{{lost_reason}}”记录在后台,工单 {{ticket_no}}已关闭,智能体不再跟进该工单,请悉知。", enabled: true, } ], diff --git a/inquiry-backend/src/views/inquiry/styles/admin-fidelity.css b/inquiry-backend/src/views/inquiry/styles/admin-fidelity.css index 4de51f6..39c5cfb 100644 --- a/inquiry-backend/src/views/inquiry/styles/admin-fidelity.css +++ b/inquiry-backend/src/views/inquiry/styles/admin-fidelity.css @@ -23,13 +23,16 @@ body { grid-template-columns: 220px minmax(0, 1fr) !important; 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 { background: #f7f8fb !important; min-width: 0 !important; - overflow-x: hidden; /* 主区锁视口高度:列表页表格区内滚;看板等非列表页在 .admin-content 内滚 */ height: 100vh !important; max-height: 100vh !important; @@ -43,10 +46,16 @@ body { padding: 16px 16px 40px !important; gap: 12px !important; min-width: 0 !important; - overflow-x: hidden; - flex: 1 1 auto !important; + /* + * flex-basis:0:先按剩余视口占高,再在区内滚动。 + * 若用 1 1 auto,内容区会先被看板撑满,再被 .main-stage 裁掉 → 右侧无滚动条、滚轮无效。 + */ + flex: 1 1 0 !important; min-height: 0 !important; - overflow: auto !important; + height: 0 !important; + overflow-x: hidden !important; + overflow-y: auto !important; + -webkit-overflow-scrolling: touch; } /* @@ -1070,6 +1079,9 @@ h6 { .admin-content:has(.ref-surface--metrics) { padding: 14px 14px 36px !important; gap: 10px !important; + /* 分析看板:明确在内容区竖滚,避免被列表页 overflow:hidden 规则误伤 */ + overflow-x: hidden !important; + overflow-y: auto !important; } /* ---- 话术 ---- */ diff --git a/inquiry-backend/src/views/inquiry/workOrder/index.vue b/inquiry-backend/src/views/inquiry/workOrder/index.vue index f717df2..4595315 100644 --- a/inquiry-backend/src/views/inquiry/workOrder/index.vue +++ b/inquiry-backend/src/views/inquiry/workOrder/index.vue @@ -253,9 +253,88 @@ function parseQuoteFees(q: any): TicketQuoteFee[] { 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; } +/** + * 陆运线路:fuelSurchargeRate=燃油附加费率;rate=税率;rateFlag=是否含税(1是0否)。 + * 只读 payload,不写库。海运/空运没有这些键则不出行。 + */ +function parseLandRateFees(payload: Record): TicketQuoteFee[] { + const rows = asFeeArray(payload.route_rows || payload.landSegments || payload.land_segments); + const out: TicketQuoteFee[] = []; + const seen = new Set(); + 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 是内部码,禁止当备注展示。 * transition 与「已报价」等 toStatus 重复,无用户备注时不再跟一段英文。