diff --git a/inquiry-agent/agent/policy/air_group_ops.py b/inquiry-agent/agent/policy/air_group_ops.py index 7d6256b..cc788b9 100644 --- a/inquiry-agent/agent/policy/air_group_ops.py +++ b/inquiry-agent/agent/policy/air_group_ops.py @@ -69,6 +69,42 @@ def _ticket_flight_date(ticket: object) -> str: ).strip() +def _lock_receipt_fields( + ticket: object, + quote: dict[str, Any], + option_no: str, + airline: str, +) -> tuple[str, str, str, str]: + """ + 锁舱成功回执的起运地、目的地、航司、航班日期。 + + 优先级与主账出站一致:舱位三字码/航班日优先,没有再用工单事实。 + 航司用本次实际锁出去的两位码。不编造。 + """ + from agent.schema.air_route_options import airport_code_for_display, option_flight_date + + row = _option_row(quote, option_no) or _selected_cabin_row(quote) or {} + facts = dict(ticket_view(ticket).get("facts") or {}) + origin = airport_code_for_display( + str(row.get("originAirportCode") or row.get("origin") or facts.get("originCode") or ""), + str(facts.get("起运地") or facts.get("起运港") or ""), + ) + destination = airport_code_for_display( + str( + row.get("destinationAirportCode") + or row.get("destination") + or facts.get("destinationCode") + or "" + ), + str(facts.get("目的地") or facts.get("目的港") or ""), + ) + airline_text = (airline or "").strip().upper() + if not airline_text: + airline_text = str(row.get("airlineCode") or row.get("airline") or "").strip().upper() + flight_date = option_flight_date(row) or _ticket_flight_date(ticket) + return origin, destination, airline_text, flight_date + + def is_airline(flow: Any, sender_id: str) -> bool: """员工管理岗位=航线·空运且启用。""" uid = (sender_id or "").strip() @@ -1001,6 +1037,9 @@ def apply_air_lock( flight_no=flight_no, instruction=instruction, ) + origin, destination, airline_label, flight_date = _lock_receipt_fields( + ticket, quote, picked, air_code + ) missing = _missing_lock_labels(picked, air_code, flight) if missing and _quote_ready_for_lock(quote): _remember_partial_lock( @@ -1119,12 +1158,14 @@ def apply_air_lock( conversation_target=chat_id, extra={ "option_no": picked, - "airline": air_code, + "airline": airline_label or air_code, "flight_no": flight, "instruction": instruction, "sender_id": sender_id, "operator_name": name, - "flight_date": _ticket_flight_date(ticket), + "flight_date": flight_date, + "origin": origin, + "destination": destination, }, ), ledger=flow.ledger, @@ -1155,6 +1196,10 @@ def apply_air_lock( option_no=picked, exec_no=exec_no, quote_no=quote_no, + origin=origin, + destination=destination, + airline=airline_label, + flight_date=flight_date, ), ) from agent.policy.system_exception import clear_system_exception, ticket_is_paused diff --git a/inquiry-agent/agent/policy/air_quote_copy.py b/inquiry-agent/agent/policy/air_quote_copy.py new file mode 100644 index 0000000..5b6b581 --- /dev/null +++ b/inquiry-agent/agent/policy/air_quote_copy.py @@ -0,0 +1,362 @@ +""" +空运 TMS 报价正文:私聊价格明细,以及协同群有价时的货物信息和 TMS 段。 + +本文件职责:只排销售和群里看得见的文字。费用名、单价、单位、备注、合计、有效期、航司、计费重、货型都取 TMS 原文。 +起运地、品名、件数等仍用工单上销售已填的内容。 +禁止:改按钮、改卡片、改海运陆运、没查到价时另写一套、本地编备注或货型。 +""" + +from __future__ import annotations + +import json +from typing import Any + +# 货型没有单独合同字段。TMS 若带回下列键,原样展示;都没有就「-」。 +_CARGO_TYPE_KEYS = ( + "cargoType", + "goodsType", + "cargoRatio", + "weightVolumeRatio", + "volumeWeightRatio", + "heavyBubbleRatio", + "bubbleRatio", + "densityRatio", + "cargoShape", +) + +_REMARK_KEYS = ( + "remark", + "remarks", + "note", + "specialRemarks", + "special_remarks", + "quoteRemark", + "description", +) + + +def air_private_quote_text( + *, + work_order_no: str, + facts: dict[str, str] | None, + quote: dict[str, Any] | None, +) -> str: + """ + 私聊查到一条空运 TMS 报价后的完整文字。 + + 先货物信息,再「价格方案如下」。协同说明和按钮不在这里,仍由后面的卡片发。 + 副作用:无。线程:调用方线程。 + """ + lines = air_cargo_lines(work_order_no=work_order_no, facts=facts, quote=quote) + lines.append("") + lines.append("价格方案如下:") + lines.extend(air_price_lines(quote, facts)) + return "\n".join(lines) + + +def air_cargo_lines( + *, + work_order_no: str, + facts: dict[str, str] | None, + quote: dict[str, Any] | None, +) -> list[str]: + """ + 货物信息。工单字段与现在确认工单同一套单位;航司、计费重、货型只认 TMS。 + + 报价日期没填写成「-」,不写「非必填」。增值服务有内容才多一行。 + """ + from agent.policy.inquiry_copy import _with_unit + + src = dict(facts or {}) + origin = (src.get("起运港") or src.get("起运地") or "-").strip() or "-" + dest = (src.get("目的港") or src.get("目的地") or "-").strip() or "-" + cargo = (src.get("品名") or "-").strip() or "-" + pack = (src.get("包装方式") or src.get("包装类型") or "-").strip() or "-" + quote_date = (src.get("报价日期") or "").strip() + if quote_date in {"", "-", "—", "-"}: + quote_date = "-" + lines = [ + f"工单号:{(work_order_no or '').strip()}", + "运输方式:空运", + f"起运地:{origin}", + f"目的地:{dest}", + f"航司:{_airline_display(quote, src)}", + f"品名:{cargo}", + f"件数:{_with_unit(str(src.get('件数') or ''), '件')}", + f"重量(KG):{_with_unit(str(src.get('毛重') or ''), 'KGS')}", + f"体积(CBM):{_with_unit(str(src.get('体积') or ''), 'CBM')}", + f"包装类型:{pack}", + ] + added = (src.get("added_services") or src.get("增值服务") or "").strip() + if added and added not in {"-", "—", "-"}: + lines.append(f"增值服务:{added}") + lines.append(f"计费重:{_chargeable_display(quote, src)}") + lines.append(f"货型:{_cargo_type_display(quote, src)}") + lines.append(f"报价日期:{quote_date}") + return lines + + +def air_price_lines( + quote: dict[str, Any] | None, + facts: dict[str, str] | None = None, +) -> list[str]: + """ + TMS 价格段:费用行、备注、费用合计、报价有效期。 + + 有单价且有计价单位:单价CNY11/KG。按票:CNY300/票。只有金额:CNY 100.0。 + 备注每次都有,TMS 没带回就「-」。不写时效。 + """ + from agent.policy.inquiry_copy import money_text, tms_valid_until_display + + src = dict(quote or {}) + header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip() + lines = [_format_fee(row, header_ccy) for row in _fee_rows(src, facts)] + lines = [line for line in lines if line] + lines.append(f"备注:{_remark_display(src, facts)}") + total = money_text(str(src.get("total") or "-").strip() or "-", header_ccy) + lines.append(f"费用合计:{total}") + lines.append(f"报价有效期:{tms_valid_until_display(src)}") + return lines + + +def _airline_display(quote: dict[str, Any] | None, facts: dict[str, str]) -> str: + """航司优先代码(TH)。只有中文名时用中文名。没有就「-」。""" + option = _option(quote, facts) + blobs = [option, dict(quote or {})] + for blob in blobs: + for key in ("airlineCode", "carrierCode"): + text = str(blob.get(key) or "").strip() + if text: + return text + for blob in blobs: + raw = str(blob.get("airline") or blob.get("carrier") or "").strip() + if raw and _looks_like_code(raw): + return raw + for blob in blobs: + name = str(blob.get("airlineName") or "").strip() + if name: + return name + raw = str(blob.get("airline") or blob.get("carrier") or "").strip() + if raw: + return raw + return "-" + + +def _looks_like_code(raw: str) -> bool: + """两到四位字母数字才当航司代码,避免把中文名收成代码。""" + token = raw.replace(" ", "") + return token.isascii() and token.isalnum() and 2 <= len(token) <= 4 + + +def _chargeable_display(quote: dict[str, Any] | None, facts: dict[str, str]) -> str: + """计费重。纯数字补 KG;TMS 已带单位则原样。没有就「-」。""" + option = _option(quote, facts) + for blob in (option, dict(quote or {})): + raw = blob.get("chargeableWeightKg") + if raw in (None, "", "-"): + raw = blob.get("chargeable_weight_kg") or blob.get("chargeableWeight") + text = str(raw or "").strip() + if not text or text in {"-", "—", "-"}: + continue + upper = text.upper() + if any(upper.endswith(unit) for unit in ("KG", "KGS", "公斤", "千克")): + return text + return f"{_compact_number(text)}KG" + return "-" + + +def _cargo_type_display(quote: dict[str, Any] | None, facts: dict[str, str]) -> str: + """货型只展示 TMS 带回的原文,例如 1:212。没有就「-」。""" + option = _option(quote, facts) + for blob in (option, dict(quote or {})): + for key in _CARGO_TYPE_KEYS: + text = str(blob.get(key) or "").strip() + if text and text not in {"-", "—", "-"}: + return text + return "-" + + +def _remark_display(quote: dict[str, Any] | None, facts: dict[str, str] | None) -> str: + """报价备注。整单没有时,把费用行上不重复的备注拼起来。仍没有就「-」。""" + src = dict(quote or {}) + option = _option(src, facts or {}) + for blob in (src, option): + for key in _REMARK_KEYS: + text = str(blob.get(key) or "").strip() + if text and text not in {"-", "—", "-"}: + return text + notes: list[str] = [] + for row in _fee_rows(src, facts): + note = str(row.get("remark") or row.get("note") or row.get("description") or "").strip() + if note and note not in notes and note not in {"-", "—", "-"}: + notes.append(note) + return ";".join(notes) if notes else "-" + + +def _option(quote: dict[str, Any] | None, facts: dict[str, str] | None) -> dict[str, Any]: + """群摘要和私聊共用:已选定的那条,或仅一条,或起运目的对得上的那条。""" + from agent.schema.air_route_options import resolve_display_air_option + + found = resolve_display_air_option(quote, facts) + return dict(found or {}) + + +def _fee_rows(quote: dict[str, Any] | None, facts: dict[str, str] | None) -> list[dict[str, Any]]: + """ + 该条线路的原始费用。 + + 先读舱位上的空运段、起运港、目的地。多条线路对不上时不借用整单费用。 + 已经有其它分项时,去掉「空运费=总价」这一行兜底。 + """ + from agent.schema.air_route_options import ( + drop_air_freight_total_alias, + parse_air_options, + resolve_display_air_option, + ) + + src = dict(quote or {}) + option = resolve_display_air_option(src, facts) + rows: list[dict[str, Any]] = [] + if option: + rows.extend(_named_rows(option.get("feeItems") or option.get("fee_items"))) + rows.extend( + _named_rows( + option.get("departureCharges") + or option.get("departure_charges") + or option.get("charges") + ) + ) + rows.extend( + _named_rows( + option.get("destinationCharges") + or option.get("destination_charges") + or option.get("arrivalCharges") + ) + ) + rows = _dedupe(rows) + if not rows and len(parse_air_options(src)) > 1: + return [] + if not rows: + rows = _named_rows(src.get("feeItems") or src.get("fee_items")) + if not rows: + rows = _named_rows(src.get("fee_lines") or src.get("fee_rows")) + if not rows: + return [] + projected = drop_air_freight_total_alias({"total": src.get("total"), "fee_lines": rows}) + kept = projected.get("fee_lines") + return [row for row in kept if isinstance(row, dict)] if isinstance(kept, list) else rows + + +def _named_rows(raw: Any) -> list[dict[str, Any]]: + """费用数组收成字典。字符串 JSON 也认。没有名称的跳过。""" + parsed = _parse_list(raw) + out: list[dict[str, Any]] = [] + for item in parsed: + row = dict(item) + name = str( + row.get("name") or row.get("chargeName") or row.get("feeName") or row.get("label") or "" + ).strip() + if not name: + continue + row["name"] = name + out.append(row) + return out + + +def _parse_list(raw: Any) -> list[dict[str, Any]]: + if isinstance(raw, str) and raw.strip(): + try: + raw = json.loads(raw) + except json.JSONDecodeError: + return [] + if not isinstance(raw, list): + return [] + return [dict(item) for item in raw if isinstance(item, dict)] + + +def _dedupe(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + """同名费用保留先出现的桶,和主账投影一致。""" + seen: set[str] = set() + out: list[dict[str, Any]] = [] + for row in rows: + key = str(row.get("name") or "").strip().lower() + if not key or key in seen: + continue + seen.add(key) + out.append(row) + return out + + +def _format_fee(row: dict[str, Any], header_ccy: str) -> str: + """一条费用。没有可展示的金额就不出现。""" + from agent.policy.inquiry_copy import money_text + + name = str(row.get("name") or "").strip() + if not name: + return "" + ccy = _currency(str(row.get("currency") or row.get("currencyCode") or header_ccy or "")) + unit = _bill_unit(str(row.get("unit") or row.get("unitName") or row.get("chargeUnit") or "")) + unit_price = _first_text(row.get("unitPrice"), row.get("unit_price")) + amount = _first_text(row.get("amount"), row.get("value")) + if "%" in amount or name.endswith("费率") or name in {"税率", "燃油附加费率"}: + shown = amount if "%" in amount else f"{amount}%" + return f"{name}:{shown}" + if unit and unit_price and unit != "票": + price = _compact_number(unit_price) + prefix = f"单价{ccy}{price}" if ccy else f"单价{price}" + return f"{name}:{prefix}/{unit}" + if unit == "票": + num = unit_price or amount + if num: + body = f"{ccy}{_compact_number(num)}" if ccy else _compact_number(num) + return f"{name}:{body}/票" + shown = amount or unit_price + if not shown: + return "" + return f"{name}:{money_text(shown, ccy)}" + + +def _bill_unit(raw: str) -> str: + """计价单位收成 KG / 票 / CBM。空串表示 TMS 没给单位。""" + text = (raw or "").strip() + if not text: + return "" + token = text.upper().replace("/", "").replace("每", "").replace(" ", "") + if token in {"票", "SHIPMENT", "BL"} or text in {"票", "/票", "每票"}: + return "票" + if token in {"KG", "KGS", "公斤", "千克"}: + return "KG" + if token in {"CBM", "M3", "立方米", "立方"}: + return "CBM" + return text.lstrip("/") + + +def _currency(raw: str) -> str: + code = (raw or "").strip().upper() + if code == "RMB": + return "CNY" + return code + + +def _first_text(*values: Any) -> str: + for value in values: + text = str(value if value is not None else "").strip() + if text and text not in {"-", "—", "-", "None"}: + return text + return "" + + +def _compact_number(raw: str) -> str: + """单价和按票金额去掉多余的尾零:11.00 写成 11。解析不了就原样。""" + text = (raw or "").strip().replace(",", "") + for prefix in ("CNY", "USD", "EUR", "HKD", "RMB"): + if text.upper().startswith(prefix): + text = text[len(prefix):].strip() + break + try: + number = float(text) + except ValueError: + return text + if number == int(number): + return str(int(number)) + return f"{number:.4f}".rstrip("0").rstrip(".") diff --git a/inquiry-agent/agent/policy/air_text_flow.py b/inquiry-agent/agent/policy/air_text_flow.py index 9a75deb..abd7e40 100644 --- a/inquiry-agent/agent/policy/air_text_flow.py +++ b/inquiry-agent/agent/policy/air_text_flow.py @@ -870,6 +870,7 @@ class AirTextInquiryFlow: reply, work_order_no=sess.work_order_no, quote=sess.quote, + facts=sess.facts, first_or_same=sess.first_or_same, history_work_order_no=sess.history_work_order_no, status=sess.status or "已报价", @@ -1355,6 +1356,7 @@ class AirTextInquiryFlow: reply, work_order_no=no, quote=quote, + facts=sess.facts, first_or_same=first, history_work_order_no=history_wo, status="已报价", @@ -1479,6 +1481,7 @@ class AirTextInquiryFlow: reply, work_order_no=sess.work_order_no, quote=quote, + facts=facts, first_or_same=sess.first_or_same, history_work_order_no=sess.history_work_order_no, status="已报价", @@ -1491,6 +1494,7 @@ class AirTextInquiryFlow: *, work_order_no: str, quote: dict[str, Any], + facts: dict[str, str] | None = None, first_or_same: str = "first", history_work_order_no: str = "", status: str = "已报价", @@ -1506,6 +1510,7 @@ class AirTextInquiryFlow: copy.sea_quote_detail_text( work_order_no=work_order_no, quote=src, + facts=facts, first_or_same=first_or_same, history_work_order_no=history_work_order_no, status=status, diff --git a/inquiry-agent/agent/policy/inquiry_copy.py b/inquiry-agent/agent/policy/inquiry_copy.py index b63520e..51c3261 100644 --- a/inquiry-agent/agent/policy/inquiry_copy.py +++ b/inquiry-agent/agent/policy/inquiry_copy.py @@ -465,10 +465,10 @@ def ask_land_fields( 陆运货物缺项补问。不出核对卡、不出现确定句。 已识别和待补充都标明必填/非必填。跨境/中港空着的通关口岸也列进待补充。 - 报价日期不进待补充。 + 报价日期不进待补充。货物品名只在前面的选项清单里问,这里不再放进待填框。 """ src = dict(facts or {}) - skip = {"报价日期"} + skip = {"报价日期", "品名"} missing = [k for k in missing_keys if k and k not in skip] land_type = (land_subtype or src.get("运输类型") or "").strip() optional = _land_optional_keys(land_type) @@ -1220,26 +1220,29 @@ def sea_quote_detail_text( history_work_order_no: str = "", status: str = "已报价", business_line: str = "SEA", + facts: dict[str, str] | None = None, ) -> str: """ - 完整价格明细文本:来源、工单号、费用行、总价。海运/陆运/空运共用。 + 完整价格明细文本。海运/陆运仍是来源、工单号、费用行、总价。 + 空运改成货物信息 + 价格方案。航司、计费重、货型和费用来自 TMS,其余用工单字段。 陆运分段时效跟在各段费用后面,用 TMS 原文;报价有效期写在总成本价下面。 海运时效、报价有效期仍只在按钮卡副标题。 - 空运:业务确认没有时效,总销售价下面只留报价有效期。 不受卡片引用条字数限制。first_or_same 只兼容调用方。 """ _ = first_or_same, history_work_order_no, status src = dict(quote or {}) - source = str(src.get("source_label") or "TMS标准报价").replace(" ", "") - lines = [source, f"{work_order_no}价格明细"] line = (business_line or "").upper() if line == "AIR": - from agent.schema.air_route_options import route_codes + from agent.policy.air_quote_copy import air_private_quote_text - route = route_codes(None, {}, src) - if route: - lines.append(route) + return air_private_quote_text( + work_order_no=work_order_no, + facts=facts, + quote=src, + ) + source = str(src.get("source_label") or "TMS标准报价").replace(" ", "") + lines = [source, f"{work_order_no}价格明细"] if line == "LAND": from agent.schema.land_route_options import land_leg_fee_blocks @@ -1250,8 +1253,8 @@ def sea_quote_detail_text( if land_line and len(blocks) != 1: lines.append(land_line) lines.append(quote_area_fee_text(src, business_line=business_line or "SEA")) - # 空运、陆运的价格明细正文都要带有效期。海运仍放在按钮卡副标题,避免和原口径打架。 - if line in {"AIR", "LAND"}: + # 陆运价格明细正文带有效期。海运仍放在按钮卡副标题。空运已在上面单独成文。 + if line == "LAND": lines.append(f"报价有效期:{tms_valid_until_display(src)}") return "\n".join(lines) @@ -3011,12 +3014,12 @@ def air_group_brief( airline_names: list[str] | None = None, ) -> str: """ - 空运群激活摘要:与海运进群同一套结构,字段用空运。 + 空运群激活摘要。 - 先 @ 销售和航线,再工单号 + 询价字段 + 协同补充(含电/含磁)+ TMS报价。 - TMS 段跟单聊同一套线路分项,总销售价只一行。 - 已有运输费等分项时,不再叠一行「空运费=总价」;只有空运费这一项时要写出来。 - 不写「当前工单/询价要点/现在等到哪一步」。 + 有 TMS 报价:货物信息(含航司、计费重、货型)+ 协同补充 + 新价格段。 + 没有 TMS 报价:仍是「询价字段」+「TMS 暂无匹配报价」。 + 品名正好是普货且没人补过时,含电含磁仍写「否」。 + 人工报价、销售调价有则接在后面,写法不改。 """ no = (work_order_no or "").strip() at_bits: list[str] = [] @@ -3027,10 +3030,28 @@ def air_group_brief( token = f"@{(name or '').strip()}" if (name or "").strip() and token not in at_bits: at_bits.append(token) - lines = ([(" ".join(at_bits))] if at_bits else []) + [f"工单号:{no}", "询价字段:"] - lines.extend( - inquiry_field_lines(work_order_no=no, facts=facts, transport_mode="空运")[1:] + tms, manual, adjust = _split_brief_quotes( + quote=quote, + tms_quote=tms_quote, + product_quote=product_quote, + has_price=has_price, ) + if tms: + from agent.schema.air_route_options import project_group_air_quote + + tms = project_group_air_quote(tms, facts) + has_tms_price = bool(tms and (tms.get("total") or quote_fee_lines(tms))) + if has_tms_price: + # 有 TMS 报价才换成货物信息。没报价仍走下面的「询价字段」。 + from agent.policy.air_quote_copy import air_cargo_lines + + lines = ([(" ".join(at_bits))] if at_bits else []) + lines.extend(air_cargo_lines(work_order_no=no, facts=facts, quote=tms)) + else: + lines = ([(" ".join(at_bits))] if at_bits else []) + [f"工单号:{no}", "询价字段:"] + lines.extend( + inquiry_field_lines(work_order_no=no, facts=facts, transport_mode="空运")[1:] + ) lines.append("") lines.append("协同补充字段:") collab = dict(collab_facts or {}) @@ -3046,20 +3067,12 @@ def air_group_brief( if val in {"", "-", "—", "-"}: val = "-" lines.append(f"{key}:{val}") - tms, manual, adjust = _split_brief_quotes( - quote=quote, - tms_quote=tms_quote, - product_quote=product_quote, - has_price=has_price, - ) - if tms: - from agent.schema.air_route_options import project_group_air_quote - - tms = project_group_air_quote(tms, facts) lines.append("") lines.append("TMS报价:") - if tms and (tms.get("total") or quote_fee_lines(tms)): - lines.extend(quote_detail_lines(tms, business_line="AIR")) + if has_tms_price: + from agent.policy.air_quote_copy import air_price_lines + + lines.extend(air_price_lines(tms, facts)) lines.append(group_tms_confirm_hint(business_line="AIR")) else: lines.append("TMS 暂无匹配报价") @@ -3235,16 +3248,37 @@ def air_lock_ok( option_no: str, exec_no: str, quote_no: str, + origin: str = "", + destination: str = "", + airline: str = "", + flight_date: str = "", ) -> str: - """空运锁舱成功。后台 inquiring.air_lock_ok。""" + """ + 空运锁舱成功。后台 inquiring.air_lock_ok。 + + 回执在报价编号下补起运地、目的地、航司、航班日期。 + 后台旧模板没有这四行时,这里补上,避免被旧句盖掉。 + 不编造空值;没取到就留空。 + """ + origin_text = (origin or "").strip() + destination_text = (destination or "").strip() + airline_text = (airline or "").strip() + flight_text = (flight_date or "").strip() + route = ( + f"起运地:{origin_text}\n" + f"目的地:{destination_text}\n" + f"航司:{airline_text}\n" + f"航班日期:{flight_text}" + ) default = ( - f"工单{work_order_no} 已通知TMS执行空运锁舱,执行成功。\n" + "TMS空运锁舱成功。\n" f"操作人:{operator}\n" f"舱位选项:{option_no}\n" f"执行编号:{exec_no}\n" - f"报价编号:{quote_no}" + f"报价编号:{quote_no}\n" + f"{route}" ) - return _r( + text = _r( "inquiring.air_lock_ok", default, ticket_no=work_order_no, @@ -3252,7 +3286,15 @@ def air_lock_ok( option_no=option_no, exec_no=exec_no, quote_no=quote_no, + origin=origin_text, + destination=destination_text, + airline=airline_text, + flight_date=flight_text, ) + body = (text or "").rstrip() + if "起运地:" in body and "航班日期:" in body: + return body + return f"{body}\n{route}" if body else route # 多段里有空运段时,航线锁舱的失败原因。产品暂不支持,不当 TMS 故障。 diff --git a/inquiry-agent/agent/policy/land_text_flow.py b/inquiry-agent/agent/policy/land_text_flow.py index fd37821..579f88a 100644 --- a/inquiry-agent/agent/policy/land_text_flow.py +++ b/inquiry-agent/agent/policy/land_text_flow.py @@ -1,7 +1,7 @@ """ 陆运文字询价确定性流程。 -本文件职责:三项选项各自必填(不联动)→ 按类型补货物 → 无工单号核对卡 →(确定后)建单查 TMS。 +本文件职责:三项选项和货物品名同一张清单各自必填(不联动)→ 按类型补始发站等货物 → 无工单号核对卡 →(确定后)建单查 TMS。 查价后:1 条完整线路直接出价格明细;多于 1 条发入口卡,H5 选定后再出该条明细。 查价后半段复用海运拉群/报价单/成交,拉人按线路类别,禁止陆运岗全员。 禁止:字段齐了就建单;直连 TMS;把中转段当成另一条线路。海运私聊同样是核对后确定再查价。 @@ -27,7 +27,10 @@ from agent.policy.air_text_flow import ( from agent.policy.sea_text_flow import SeaTextInquiryFlow from agent.schema.field_validate import harvest_oral_measures, validate_required_fields from agent.schema.land_options import ( + drop_blank_paste_options, + drop_land_prompt_echo, harvest_land_options, + land_cargo_name, looks_like_confirm, option_prompt, sample_pair_allowed, @@ -170,6 +173,8 @@ class LandTextInquiryFlow(SeaTextInquiryFlow): ) if b_opt: facts = harvest_land_options(b_opt, facts) + # 复制清单冒号空着的项,菜单文字和模型猜测都不能当成已填。 + facts = drop_blank_paste_options(facts, source) if not mode: mode = "LAND" if (sess and (sess.business_line or "").upper() == "LAND") else "" @@ -313,7 +318,7 @@ class LandTextInquiryFlow(SeaTextInquiryFlow): created = self._ledger.create_ticket( sender_id=sender_id, business_line="LAND", - facts=facts, + facts=drop_land_prompt_echo(facts), idempotency_key=f"create:{sender_id}:{uuid.uuid4().hex}", ) no = str(created.get("work_order_no") or "").strip() @@ -582,7 +587,8 @@ class LandTextInquiryFlow(SeaTextInquiryFlow): merged.update(opt.get("normalized") or {}) if opt.get("errors"): return {"ready": False, "opt": opt, "facts": merged, "stage": "options_error"} - if not opt.get("ok"): + # 货物品名和三项同一张清单。没填上继续发这张,不挪到始发站补问。 + if not opt.get("ok") or not land_cargo_name(merged): return {"ready": False, "opt": opt, "facts": merged, "stage": "options_missing"} land_type = (merged.get("运输类型") or "").strip() check = validate_required_fields( @@ -658,6 +664,8 @@ class LandTextInquiryFlow(SeaTextInquiryFlow): return "need_land_options" if state["stage"] == "options_missing": keys = list((state.get("opt") or {}).get("missing") or []) + if not land_cargo_name(facts): + keys.append("品名") logger.info("land.options_missing missing=%s", keys) new_sess = FlowSession( sender_id=sender_id, diff --git a/inquiry-agent/agent/policy/multi_segments.py b/inquiry-agent/agent/policy/multi_segments.py index d0335ca..7fb168e 100644 --- a/inquiry-agent/agent/policy/multi_segments.py +++ b/inquiry-agent/agent/policy/multi_segments.py @@ -20,7 +20,10 @@ from agent.schema.field_validate import ( ) from agent.llm.extract_text import parse_labeled_facts from agent.schema.land_options import ( + drop_blank_paste_options, + drop_land_prompt_echo, harvest_land_options, + land_cargo_name, option_prompt, scrub_land_placeholder_ports, validate_land_options, @@ -220,15 +223,26 @@ def _segment_required_check(seg: Segment) -> tuple[dict[str, Any], dict[str, str ) ) option_missing = [str(x) for x in (opt.get("missing") or []) if str(x).strip()] - cargo_missing = [str(x) for x in (check.get("missing") or []) if str(x).strip()] - missing = option_missing + [k for k in cargo_missing if k not in option_missing] + cargo_missing = [ + str(x) + for x in (check.get("missing") or []) + if str(x).strip() and str(x) not in option_missing and str(x) != "品名" + ] + # 货物品名没填时仍算这张选项清单没完成,不进始发站待填。 + cargo_name = land_cargo_name(facts) + missing = list(option_missing) + cargo_missing + if not cargo_name: + missing.append("品名") merged = dict(check.get("facts") or facts) merged.update({k: facts[k] for k in ("运输类型", "线路类别", "运输分类") if facts.get(k)}) + for key, val in facts.items(): + if str(key).startswith("_land_show_") and str(val).strip(): + merged[str(key)] = str(val).strip() check["facts"] = merged check["missing"] = missing check["ok"] = not missing check["need_land_subtype"] = False - check["need_land_options"] = bool(option_missing) + check["need_land_options"] = bool(option_missing) or not cargo_name return check, merged check = validate_required_fields(facts=dict(seg.facts), business_line=seg.mode) facts = dict(seg.facts) @@ -342,7 +356,10 @@ def _overlay_segments( continue blob = (neu.source or "").strip() if target.mode == "LAND": - overlay = harvest_land_options(blob, dict(neu.facts)) + overlay = drop_blank_paste_options( + harvest_land_options(blob, dict(neu.facts)), + blob, + ) elif target.mode == "SEA": overlay = harvest_sea_class(blob, dict(neu.facts)) else: @@ -412,16 +429,23 @@ def group_brief( return "\n".join(lines) -def segments_to_payload(segments: list[Segment]) -> list[dict[str, Any]]: - """书签和工单里保存的段。只保留字符串,避免书签序列化掉类型。""" - return [ - { - "index": seg.index, - "mode": seg.mode, - "facts": {str(k): str(v) for k, v in (seg.facts or {}).items() if str(v).strip()}, - } - for seg in segments - ] +def segments_to_payload( + segments: list[Segment], + *, + keep_prompt_echo: bool = True, +) -> list[dict[str, Any]]: + """ + 书签和工单里保存的段。只保留字符串,避免书签序列化掉类型。 + + 建单时 keep_prompt_echo=False,回显用的内部键不进工单。 + """ + rows: list[dict[str, Any]] = [] + for seg in segments: + facts = {str(k): str(v) for k, v in (seg.facts or {}).items() if str(v).strip()} + if not keep_prompt_echo: + facts = drop_land_prompt_echo(facts) + rows.append({"index": seg.index, "mode": seg.mode, "facts": facts}) + return rows def segments_from_payload(raw: Any) -> list[Segment]: @@ -462,16 +486,13 @@ def _segment_clarify(seg: Segment, check: dict[str, Any]) -> str: if check.get("ok") and not check.get("need_land_subtype"): return "" missing = [str(x) for x in (check.get("missing") or []) if str(x).strip()] - if check.get("need_land_subtype"): - return ( - copy.CLARIFY_PASTE_HINT - + "\n1.运输类型(必填):\n(如:国内运输拼车)" - ) + if seg.mode == "LAND" and ( + check.get("need_land_subtype") or check.get("need_land_options") + ): + # 和单聊同一张清单:类型、线路、分类、货物品名。已填的留在复制框上面。 + return option_prompt(current=seg.facts) if seg.mode == "LAND": - if check.get("need_land_options"): - # 和单聊一样先列出线路类别、运输分类,选完再问询价字段。 - return option_prompt(current=seg.facts) - cargo_missing = [k for k in missing if k not in _LAND_OPTION_KEYS] + cargo_missing = [k for k in missing if k not in _LAND_OPTION_KEYS and k != "品名"] raw = copy.ask_land_fields( facts=seg.facts, missing_keys=cargo_missing, @@ -594,7 +615,7 @@ def _facts_from_body(body: str, mode: str) -> dict[str, str]: if val and not str(facts.get(key) or "").strip(): facts[key] = val if mode == "LAND": - facts = harvest_land_options(body, facts) + facts = drop_blank_paste_options(harvest_land_options(body, facts), body) if mode == "SEA": facts = harvest_sea_class(body, facts) return facts @@ -624,7 +645,7 @@ def _split_by_segment_mark(text: str) -> dict[int, dict[str, str]]: end = marks[i + 1].start() if i + 1 < len(marks) else len(raw) body = raw[mark.end() : end] facts = parse_labeled_facts(body) - facts = harvest_land_options(body, facts) + facts = drop_blank_paste_options(harvest_land_options(body, facts), body) facts = harvest_sea_class(body, facts) facts = harvest_oral_measures(body, facts) if facts: diff --git a/inquiry-agent/agent/policy/multi_text_flow.py b/inquiry-agent/agent/policy/multi_text_flow.py index 1586d73..d63e7e3 100644 --- a/inquiry-agent/agent/policy/multi_text_flow.py +++ b/inquiry-agent/agent/policy/multi_text_flow.py @@ -414,7 +414,7 @@ class MultiTextInquiryFlow: self._put_segments(sess, updated) self._save(sess) return "clarify" - payload = segments_to_payload(updated) + payload = segments_to_payload(updated, keep_prompt_echo=False) created = self._ledger.create_ticket( sender_id=sess.sender_id, business_line="MULTI", diff --git a/inquiry-agent/agent/policy/system_exception.py b/inquiry-agent/agent/policy/system_exception.py index 8e5fc61..db87e44 100644 --- a/inquiry-agent/agent/policy/system_exception.py +++ b/inquiry-agent/agent/policy/system_exception.py @@ -954,7 +954,19 @@ def _resume_cabin_step( facts={"__air_lock_id": exec_no, "__air_lock_option": option_no, "__pending_air_lock": ""}, sender_id=sender, ) - reply(copy.air_lock_ok(work_order_no=no, operator="同事", option_no=option_no, exec_no=exec_no, quote_no="")) + reply( + copy.air_lock_ok( + work_order_no=no, + operator="同事", + option_no=option_no, + exec_no=exec_no, + quote_no="", + origin=str(extra.get("origin") or ""), + destination=str(extra.get("destination") or ""), + airline=str(extra.get("airline") or ""), + flight_date=str(extra.get("flight_date") or ""), + ) + ) else: reply(copy.air_release_ok(work_order_no=no, operator="同事", option_no="", exec_no=str((out or {}).get("releaseId") or lock_id), quote_no="")) return {"ok": True, "step": cabin_step} diff --git a/inquiry-agent/agent/schema/air_route_options.py b/inquiry-agent/agent/schema/air_route_options.py index 3640309..2838819 100644 --- a/inquiry-agent/agent/schema/air_route_options.py +++ b/inquiry-agent/agent/schema/air_route_options.py @@ -136,6 +136,18 @@ def _iata_code(raw: str, fallback: str = "") -> str: return (mapped or "").upper() +def airport_code_for_display(raw: str, fallback: str = "") -> str: + """ + 锁舱成功回执上的起运地/目的地。 + + 能收成三字码就用三字码(NKG、KUL)。对不上就留原文,不编城市。 + """ + code = _iata_code(raw, fallback) + if code: + return code + return (raw or fallback or "").strip() + + def route_codes( option: dict[str, Any] | None, facts: dict[str, str] | None = None, diff --git a/inquiry-agent/agent/schema/land_options.py b/inquiry-agent/agent/schema/land_options.py index 275226e..7bb1f69 100644 --- a/inquiry-agent/agent/schema/land_options.py +++ b/inquiry-agent/agent/schema/land_options.py @@ -1,8 +1,10 @@ """ -陆运选项规则:运输类型、线路类别、运输分类。 +陆运选项规则:运输类型、线路类别、运输分类,以及和它们同一张清单里的货物品名。 -本文件职责:从销售原话或复制填空清单收取标准项、校验是否在名单内、生成补问清单、判断「确定查价」意图。 -三项各自必填,组合不拦截查价。拉群必须对上填写样例。运输分类句里的「中港/东南亚」不得冒充类型或线路。 +本文件职责:从销售原话或复制填空清单收取标准项、校验三项是否在名单内、生成补问清单、判断「确定查价」意图。 +三项各自必填,组合不拦截查价。货物品名六个运输类型都要,和三项同一张复制清单里填;没填完不进始发站补问。 +已填项回显销售刚写的数字或文字,并附上完整选项,方便对着编号填线路类别。 +拉群必须对上填写样例。运输分类句里的「中港/东南亚」不得冒充类型或线路。 禁止:改六态;发明枚举;把选项当货物字段;再要单独的中港装载方式。 线程:纯函数,无共享可变状态,可在 Worker 槽调用。 """ @@ -243,20 +245,26 @@ def _decode_option_token(key: str, raw: str) -> str: return "" -def _parse_land_option_labels(text: str) -> dict[str, str]: - """复制清单里「字段(必填):值」只取冒号同行,空着的不当已填。""" +def _parse_land_option_labels(text: str) -> tuple[dict[str, str], dict[str, str]]: + """ + 复制清单里「字段(必填):值」只取冒号同行,空着的不当已填。 + + 返回标准项,以及销售冒号后原文,供下一张清单原样回显。 + """ found: dict[str, str] = {} + shown: dict[str, str] = {} inferred_type = "" for match in _PASTE_LABEL.finditer(text or ""): key = match.group(1) token = (match.group(2) or "").strip() - if not token: + if not token or _CARGO_PLACEHOLDER.match(token): continue if key == "线路类别": hit = "" for code, typ, route in ROUTE_CODES: if token == code or token.startswith(code): found["线路类别"] = route + shown["线路类别"] = token inferred_type = typ hit = code break @@ -265,9 +273,12 @@ def _parse_land_option_labels(text: str) -> dict[str, str]: decoded = _decode_option_token(key, token) if decoded: found[key] = decoded + shown[key] = token if inferred_type and not found.get("运输类型"): + # 编号带出的类型不是销售本行写下的字。已有回显时不要改成标准名称。 found["运输类型"] = inferred_type - return found + shown.setdefault("运输类型", "") + return found, shown def _strip_land_option_prompt(text: str) -> str: @@ -304,7 +315,9 @@ def harvest_land_options(text: str, current: dict[str, str] | None = None) -> di 未填的补问清单本身不能当成已选。 先收运输分类并把命中句从原文挖掉,再收类型/线路,避免分类里的东南亚、中港冒充选项。 已有非空项默认保留;本句明确写出另一标准项时才覆盖。 - 已有值对不上标准枚举则丢掉。不改货物字段。返回新 dict,不修改 current。 + 已有值对不上标准枚举则丢掉。不改货物字段。 + 回显键只在本句真正改了该项时更新:复制清单留冒号后原文,口语留标准名称。 + 返回新 dict,不修改 current。 """ merged = { k: str(v).strip() @@ -312,15 +325,26 @@ def harvest_land_options(text: str, current: dict[str, str] | None = None) -> di if str(v).strip() } _keep_canonical_options(merged) - labeled = _parse_land_option_labels(text or "") + labeled, shown = _parse_land_option_labels(text or "") for key in ("运输类型", "线路类别", "运输分类"): - if labeled.get(key): - merged[key] = labeled[key] + if not labeled.get(key): + continue + previous = str(merged.get(key) or "").strip() + merged[key] = labeled[key] + token = str(shown.get(key) or "").strip() + if token: + _remember_show(merged, key, token, labeled[key]) + elif previous != labeled[key] or not str(merged.get(land_show_key(key)) or "").strip(): + _remember_show(merged, key, labeled[key], labeled[key]) blob = _strip_land_option_prompt(text or "") compact = blob.replace(" ", "") if compact in _ROUTE_CODE_MAP: - _apply_route_code(merged, compact, fill_type=not bool(merged.get("运输类型"))) + had_type = bool(merged.get("运输类型")) + _apply_route_code(merged, compact, fill_type=not had_type) + _remember_show(merged, "线路类别", compact, str(merged.get("线路类别") or "")) + if not had_type and merged.get("运输类型"): + _remember_show(merged, "运输类型", merged["运输类型"], merged["运输类型"]) merged.pop("中港装载方式", None) return merged if compact.isdigit() and len(compact) == 1: @@ -329,8 +353,10 @@ def harvest_land_options(text: str, current: dict[str, str] | None = None) -> di missing_class = not merged.get("运输分类") if missing_type and not missing_class and 1 <= idx <= len(TRANSPORT_TYPES): merged["运输类型"] = TRANSPORT_TYPES[idx - 1] + _remember_show(merged, "运输类型", compact, merged["运输类型"]) elif missing_class and not missing_type and 1 <= idx <= len(LAND_CLASSES): merged["运输分类"] = LAND_CLASSES[idx - 1] + _remember_show(merged, "运输分类", compact, merged["运输分类"]) merged.pop("中港装载方式", None) return merged @@ -338,16 +364,105 @@ def harvest_land_options(text: str, current: dict[str, str] | None = None) -> di remainder = blob.replace(found_class, " ") if found_class else blob found_type = canonicalize_transport_type(remainder) found_route = canonicalize_route(remainder) - if found_type: - merged["运输类型"] = found_type - if found_route: - merged["线路类别"] = found_route - if found_class: - merged["运输分类"] = found_class + _assign_spoken_option(merged, "运输类型", found_type) + _assign_spoken_option(merged, "线路类别", found_route) + _assign_spoken_option(merged, "运输分类", found_class) merged.pop("中港装载方式", None) return merged +def _assign_spoken_option(merged: dict[str, str], key: str, canonical: str) -> None: + """口语命中才覆盖。标准名没变时保留销售上次写的数字回显。""" + if not canonical: + return + previous = str(merged.get(key) or "").strip() + merged[key] = canonical + if previous != canonical or not str(merged.get(land_show_key(key)) or "").strip(): + _remember_show(merged, key, canonical, canonical) + + +# 回显用:销售写「5」就留 5,写「中港整车」就留这句。只进会话,建单前丢掉。 +LAND_SHOW_PREFIX = "_land_show_" +_CARGO_PLACEHOLDER = re.compile(r"^[((]\s*如\s*[::]") + + +def land_show_key(field: str) -> str: + """选项回显键。不进 TMS,也不出现在核对卡。""" + return LAND_SHOW_PREFIX + field + + +def drop_land_prompt_echo(facts: dict[str, Any] | None) -> dict[str, str]: + """建单前去掉回显键。返回新 dict,不改入参。""" + out: dict[str, str] = {} + for key, raw in dict(facts or {}).items(): + name = str(key) + if name.startswith(LAND_SHOW_PREFIX): + continue + val = str(raw).strip() + if val: + out[name] = val + return out + + +def blank_paste_option_fields(text: str) -> set[str]: + """ + 复制清单里出现了选项标签、但冒号后没写值的那些项。 + + 编号菜单和模型不能把「1.普件…」抄成已填。不是复制清单时返回空,口语「急件」仍可收。 + """ + raw = text or "" + if "请复制以下内容" not in raw and "Step1" not in raw: + return set() + seen: set[str] = set() + filled: set[str] = set() + for match in _PASTE_LABEL.finditer(raw): + key = match.group(1) + token = (match.group(2) or "").strip() + seen.add(key) + if token and not _CARGO_PLACEHOLDER.match(token): + filled.add(key) + return seen - filled + + +def drop_blank_paste_options(facts: dict[str, str] | None, text: str) -> dict[str, str]: + """ + 复制清单里空着的运输类型、线路类别、运输分类,从本句结果里拿掉。 + + 模型若按菜单猜了一项,也不留。返回新 dict,不改入参。 + """ + out = { + str(k): str(v).strip() + for k, v in dict(facts or {}).items() + if str(v).strip() + } + for key in blank_paste_option_fields(text): + out.pop(key, None) + out.pop(land_show_key(key), None) + return out + + +def land_cargo_name(facts: dict[str, Any] | None) -> str: + """ + 陆运货物品名。六个运输类型都要。 + + 示例行「(如:衣服)」原样贴回不算已填。没有则空串。 + """ + src = facts or {} + name = str(src.get("品名") or src.get("货物品名") or "").strip() + if not name or _CARGO_PLACEHOLDER.match(name): + return "" + return name + + +def _remember_show(merged: dict[str, str], key: str, shown: str, canonical: str) -> None: + """记下销售刚写的数字或文字。超长句子改回标准名称,避免把整段需求贴进回显。""" + token = (shown or "").strip() + if not token or "\n" in token or len(token) > 80: + token = canonical + if token: + merged[land_show_key(key)] = token + + def _keep_canonical_options(merged: dict[str, str]) -> None: """只留能对上枚举的选项;模型胡写的「陆运」不能冒充运输类型。旧中港两项作废。""" t = canonicalize_transport_type(merged.get("运输类型") or "") @@ -355,16 +470,19 @@ def _keep_canonical_options(merged: dict[str, str]) -> None: merged["运输类型"] = t else: merged.pop("运输类型", None) + merged.pop(land_show_key("运输类型"), None) r = canonicalize_route(merged.get("线路类别") or "") if r: merged["线路类别"] = r else: merged.pop("线路类别", None) + merged.pop(land_show_key("线路类别"), None) c = canonicalize_land_class(merged.get("运输分类") or "") if c: merged["运输分类"] = c else: merged.pop("运输分类", None) + merged.pop(land_show_key("运输分类"), None) merged.pop("中港装载方式", None) @@ -412,25 +530,39 @@ _OPTION_PASTE_HEAD = ( ) +def _type_menu_lines() -> list[str]: + return [f"{i}.{name}" for i, name in enumerate(TRANSPORT_TYPES, start=1)] + + +def _route_menu_lines() -> list[str]: + return [f"{code}{route}" for code, _typ, route in ROUTE_CODES] + + +def _class_menu_lines() -> list[str]: + return [f"{i}.{name}" for i, name in enumerate(LAND_CLASSES, start=1)] + + def _type_prompt_lines() -> list[str]: - lines = ["运输类型(必填):"] - for i, name in enumerate(TRANSPORT_TYPES, start=1): - lines.append(f"{i}.{name}") - return lines + return ["运输类型(必填):", *_type_menu_lines()] def _route_prompt_lines() -> list[str]: - lines = ["线路类别(必填):"] - for code, _typ, route in ROUTE_CODES: - lines.append(f"{code}{route}") - return lines + return ["线路类别(必填):", *_route_menu_lines()] def _class_prompt_lines() -> list[str]: - lines = ["运输分类(必填):"] - for i, name in enumerate(LAND_CLASSES, start=1): - lines.append(f"{i}.{name}") - return lines + return ["运输分类(必填):", *_class_menu_lines()] + + +def _cargo_prompt_lines() -> list[str]: + """货物品名没有编号。示例单独一行,原样贴回不算已填。""" + return ["货物品名(必填):", "(如:衣服)"] + + +def _shown_option(current: dict[str, str], key: str, canonical: str) -> str: + """回显销售上次写的数字或文字;没有则用标准名称。""" + raw = str(current.get(land_show_key(key)) or "").strip() + return raw or canonical # 编号清单已覆盖类型+线路,补问不再发填写样例图。验收脚本认这个开关。 @@ -473,27 +605,51 @@ def option_prompt(*, current: dict[str, str]) -> str: """ 按已识别项生成复制填空清单。 - 缺哪项列哪项。销售把数字或文字填在字段冒号后面。 + 运输类型、线路类别、运输分类、货物品名同一张清单。缺的留在复制框里。 + 已经填过的排在 Step1 上面:保留销售写的数字或文字,并附上完整选项。 + 货物品名没有选项,填了只回显一行。四个都齐了返回空串。 填写样例图不再另发。不修改 current。 """ src = {k: str(v).strip() for k, v in dict(current or {}).items() if str(v).strip()} t = canonicalize_transport_type(src.get("运输类型") or "") r = canonicalize_route(src.get("线路类别") or "") c = canonicalize_land_class(src.get("运输分类") or "") - need_type = t not in TRANSPORT_TYPES - need_route = r not in ROUTES - need_class = c not in LAND_CLASSES - if not (need_type or need_route or need_class): + cargo = land_cargo_name(src) + filled: list[list[str]] = [] + missing: list[list[str]] = [] + if t in TRANSPORT_TYPES: + filled.append( + [f"运输类型(必填):{_shown_option(src, '运输类型', t)}", *_type_menu_lines()] + ) + else: + missing.append(_type_prompt_lines()) + if r in ROUTES: + filled.append( + [f"线路类别(必填):{_shown_option(src, '线路类别', r)}", *_route_menu_lines()] + ) + else: + missing.append(_route_prompt_lines()) + if c in LAND_CLASSES: + filled.append( + [f"运输分类(必填):{_shown_option(src, '运输分类', c)}", *_class_menu_lines()] + ) + else: + missing.append(_class_prompt_lines()) + if cargo: + filled.append([f"货物品名(必填):{cargo}"]) + else: + missing.append(_cargo_prompt_lines()) + if not missing: return "" - chunks: list[list[str]] = [] - if need_type: - chunks.append(_type_prompt_lines()) - if need_route: - chunks.append(_route_prompt_lines()) - if need_class: - chunks.append(_class_prompt_lines()) - body: list[str] = list(_OPTION_PASTE_HEAD) - for i, block in enumerate(chunks): + body: list[str] = [] + for i, block in enumerate(filled): + if i: + body.append("") + body.extend(block) + if filled: + body.append("") + body.extend(_OPTION_PASTE_HEAD) + for i, block in enumerate(missing): if i: body.append("") body.extend(block) diff --git a/inquiry-agent/tests/test_air_group_collab.py b/inquiry-agent/tests/test_air_group_collab.py index fa39acf..2b35b23 100644 --- a/inquiry-agent/tests/test_air_group_collab.py +++ b/inquiry-agent/tests/test_air_group_collab.py @@ -341,6 +341,59 @@ class AirGroupCollabTests(unittest.TestCase): self.assertIn("报价有效期:2026-09-30", tms_block) self.assertIn("请航线人员回复“确认报价”确定采用TMS报价。", text) self.assertIn("亦可通过“纯文字”告知新报价,或直接将新报价excel表发给智能体。", text) + self.assertNotIn("询价字段:", text) + self.assertIn("备注:-", text) + + def test_air_group_brief_with_tms_uses_cargo_and_price_shape(self) -> None: + """有 TMS 报价时,群摘要换成货物信息和三种费用写法。普货含电含磁仍是「否」。""" + tms = { + "source": "TMS", + "total": "87150.0", + "currency": "CNY", + "valid_until": "2026-10-31", + "remark": "其他杂费如有实报实销", + "airlineCode": "TH", + "chargeableWeightKg": "8300", + "cargoType": "1:212", + "fee_lines": [ + {"name": "空运费", "currency": "CNY", "unit": "KG", "unitPrice": 11, "amount": "91300"}, + {"name": "文件费", "currency": "CNY", "unit": "票", "amount": "300"}, + ], + } + text = copy.air_group_brief( + work_order_no="WO202610090040", + facts={ + "起运地": "NKG", + "目的地": "KUL", + "品名": "普货", + "件数": "800箱", + "毛重": "8300KG", + "体积": "39CBM", + "包装类型": "散货", + "报价日期": "2026-10-09", + }, + quote=tms, + collab_facts={}, + has_price=True, + tms_quote=tms, + sales_name="吴季林", + airline_names=["高芸"], + ) + self.assertTrue(text.startswith("@吴季林 @高芸")) + self.assertNotIn("询价字段:", text) + self.assertIn("航司:TH", text) + self.assertIn("计费重:8300KG", text) + self.assertIn("货型:1:212", text) + self.assertIn("是否含电:否", text) + self.assertIn("是否含磁:否", text) + tms_block = text.split("TMS报价:", 1)[1] + self.assertIn("空运费:单价CNY11/KG", tms_block) + self.assertIn("文件费:CNY300/票", tms_block) + self.assertIn("备注:其他杂费如有实报实销", tms_block) + self.assertIn("费用合计:CNY 87150.0", tms_block) + self.assertIn("报价有效期:2026-10-31", tms_block) + self.assertIn("请航线人员回复“确认报价”确定采用TMS报价。", text) + self.assertLess(tms_block.find("报价有效期:"), tms_block.find("请航线人员")) self.assertIn("附件报价单仅作为文件存储于后台,智能体不读取且不将报价内容写进系统", text) self.assertNotIn("请产品人员回复", text) @@ -1111,7 +1164,7 @@ class AirGroupCollabTests(unittest.TestCase): injected_intent="air_pick_option", ) self.assertEqual(phase, "lock_ok") - self.assertTrue(any("执行成功" in t for t in self._texts())) + self.assertTrue(any("TMS空运锁舱成功" in t for t in self._texts())) def test_tms_quote_with_selected_cabin_locks_without_asking_params(self) -> None: """TMS 已有 selected_air_option:直接锁,不追问舱位/航司/航班(WO014)。""" @@ -1162,6 +1215,40 @@ class AirGroupCollabTests(unittest.TestCase): self.assertEqual(calls[0].get("flight_no"), "JG2719") self.assertFalse(any("锁舱参数不完整" in t for t in self._texts())) + def test_lock_ok_reply_includes_route(self) -> None: + """锁舱成功回执带出本次舱位的起运地、目的地、航司、航班日期。""" + wo = self._create_air( + quote={ + "total": "1800", + "source": "TMS", + "source_label": "航线确认TMS", + "quoteId": "AIR-2099695579169554433-2099695579182137346", + "selected_air_option": { + "optionNo": "AIR-OPT01", + "airlineCode": "TH", + "flightNo": "TH123", + "originAirportCode": "NKG", + "destinationAirportCode": "KUL", + "flightDate": "2026-10-15", + }, + }, + status="已成交", + ) + phase = handle_air_group( + self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_route", mid="route1"), + flow=self.flow, + ledger=self.ledger, + injected_intent="air_lock", + ) + self.assertEqual(phase, "lock_ok") + text = self._texts()[-1] + self.assertIn("TMS空运锁舱成功。", text) + self.assertIn("舱位选项:AIR-OPT01", text) + self.assertIn("起运地:NKG", text) + self.assertIn("目的地:KUL", text) + self.assertIn("航司:TH", text) + self.assertIn("航班日期:2026-10-15", text) + def test_lock_missing_params_asks_and_does_not_call_tms(self) -> None: """无舱位明细时缺三项才请补,不锁舱、不报 TMS 失败、不通知 IT。""" wo = self._create_air( @@ -1318,7 +1405,7 @@ class AirGroupCollabTests(unittest.TestCase): injected_intent="air_lock", ) self.assertEqual(phase, "lock_ok") - self.assertTrue(any("执行成功" in t for t in self._texts())) + self.assertTrue(any("TMS空运锁舱成功" in t for t in self._texts())) self.assertFalse(any("请确认成交状态" in t for t in self._texts())) phase = handle_air_group( self._msg(sender="air1", text=f"{wo}取消舱位", chat="wr_air_1", mid="l5"), diff --git a/inquiry-agent/tests/test_air_route_options.py b/inquiry-agent/tests/test_air_route_options.py index 1c8952e..c5472e7 100644 --- a/inquiry-agent/tests/test_air_route_options.py +++ b/inquiry-agent/tests/test_air_route_options.py @@ -382,17 +382,89 @@ class AirRouteOptionsTests(unittest.TestCase): text, "\n".join( [ - "TMS标准报价", - "WO202609220013价格明细", - "NKG-KUL", + "工单号:WO202609220013", + "运输方式:空运", + "起运地:-", + "目的地:-", + "航司:-", + "品名:-", + "件数:-", + "重量(KG):-", + "体积(CBM):-", + "包装类型:-", + "计费重:-", + "货型:-", + "报价日期:-", + "", + "价格方案如下:", "增点费:CNY 100.0", "拆板费:USD 100.0", + "备注:-", "费用合计:CNY 5001.0", "报价有效期:2026-09-30", ] ), ) self.assertNotIn("时效", text) + self.assertNotIn("TMS标准报价", text) + self.assertNotIn("NKG-KUL", text) + + def test_air_detail_text_uses_ticket_facts_and_tms_price_shape(self) -> None: + """货物信息用工单,价格方案按单价/按票/只有金额三种写法。""" + text = copy.sea_quote_detail_text( + work_order_no="WO202610090040", + facts={ + "起运地": "NKG", + "目的地": "KUL", + "品名": "普货", + "件数": "800箱", + "毛重": "8300KG", + "体积": "39CBM", + "包装类型": "散货", + "报价日期": "2026-10-09", + }, + quote={ + "currency": "CNY", + "total": "87150.0", + "valid_until": "2026-10-31", + "remark": "其他杂费如有实报实销,不含续页;续页50/票", + "airlineCode": "TH", + "chargeableWeightKg": "8300", + "cargoType": "1:212", + "fee_lines": [ + { + "name": "空运费", + "currency": "CNY", + "unit": "KG", + "unitPrice": 11, + "amount": "91300", + }, + {"name": "文件费", "currency": "CNY", "unit": "票", "amount": "300"}, + {"name": "报关费", "currency": "CNY", "unit": "/票", "unitPrice": "250"}, + {"name": "增点费", "currency": "CNY", "amount": "100.0"}, + ], + }, + business_line="AIR", + ) + self.assertIn("工单号:WO202610090040", text) + self.assertIn("起运地:NKG", text) + self.assertIn("目的地:KUL", text) + self.assertIn("航司:TH", text) + self.assertIn("件数:800箱", text) + self.assertIn("重量(KG):8300KG", text) + self.assertIn("体积(CBM):39CBM", text) + self.assertIn("计费重:8300KG", text) + self.assertIn("货型:1:212", text) + self.assertIn("报价日期:2026-10-09", text) + self.assertIn("价格方案如下:", text) + self.assertIn("空运费:单价CNY11/KG", text) + self.assertIn("文件费:CNY300/票", text) + self.assertIn("报关费:CNY250/票", text) + self.assertIn("增点费:CNY 100.0", text) + self.assertIn("备注:其他杂费如有实报实销,不含续页;续页50/票", text) + self.assertIn("费用合计:CNY 87150.0", text) + self.assertIn("报价有效期:2026-10-31", text) + self.assertNotIn("询价字段", text) def test_h5_page_lists_route_and_fees(self) -> None: from agent.channel.h5.air_route_page import render_air_route_page diff --git a/inquiry-agent/tests/test_air_text_inquiry.py b/inquiry-agent/tests/test_air_text_inquiry.py index eda6b85..095d726 100644 --- a/inquiry-agent/tests/test_air_text_inquiry.py +++ b/inquiry-agent/tests/test_air_text_inquiry.py @@ -934,10 +934,12 @@ class AirTextInquiryTests(unittest.TestCase): quote=long_quote, business_line="AIR", ) - self.assertIn("TMS标准报价", text) - self.assertIn("WO202609220005价格明细", text) + self.assertIn("价格方案如下:", text) + self.assertNotIn("TMS标准报价", text) + self.assertNotIn("价格明细", text) self.assertIn("操作费:CNY 1220.00", text) self.assertIn("空运费:USD 1250.00", text) + self.assertIn("备注:-", text) self.assertIn("费用合计:CNY 142998.53", text) self.assertNotIn("时效", text) self.assertIn("报价有效期:2026-09-30", text) @@ -987,7 +989,8 @@ class AirTextInquiryTests(unittest.TestCase): quote=quote, business_line="AIR", ) - self.assertIn("ZUH-CAN-CRK", detail) + self.assertNotIn("ZUH-CAN-CRK", detail) + self.assertIn("价格方案如下:", detail) self.assertIn("运输费:CNY 11.0", detail) self.assertIn("费用合计:CNY 183370", detail) rows = { diff --git a/inquiry-agent/tests/test_land_copy.py b/inquiry-agent/tests/test_land_copy.py index 6057dbc..b37a546 100644 --- a/inquiry-agent/tests/test_land_copy.py +++ b/inquiry-agent/tests/test_land_copy.py @@ -124,6 +124,9 @@ class LandCopyTest(unittest.TestCase): self.assertIn(copy.CLARIFY_PASTE_HINT, text) self.assertIn("1.体积(CBM)(必填):\n(如:100CBM)", text) self.assertIn("2.通关口岸(非必填):\n(如:友谊关)", text) + self.assertIn("货物品名(必填):衣服", text.split(copy.CLARIFY_PASTE_HINT, 1)[0]) + pending = text.split(copy.CLARIFY_PASTE_HINT, 1)[1] + self.assertNotIn("货物品名", pending) self.assertNotIn("体积(必填):(如:", text) self.assertNotIn(copy.LAND_CONFIRM_TAIL, text) diff --git a/inquiry-agent/tests/test_land_options.py b/inquiry-agent/tests/test_land_options.py index 7d8dd9b..60502e0 100644 --- a/inquiry-agent/tests/test_land_options.py +++ b/inquiry-agent/tests/test_land_options.py @@ -16,6 +16,7 @@ if ROOT not in sys.path: from agent.schema.land_options import ( harvest_land_options, + land_cargo_name, looks_like_confirm, option_prompt, option_prompt_needs_sample, @@ -118,6 +119,12 @@ class LandOptionsTest(unittest.TestCase): self.assertIn("运输分类(必填):\n1.普件(常用线路/常规车型)", text) self.assertIn("2.普件(东南亚、中港常用线路/常规车型)", text) self.assertIn("5.急件", text) + self.assertIn("货物品名(必填):\n(如:衣服)", text) + self.assertTrue(text.startswith("Step1: 请复制以下内容,粘贴到输入框;")) + self.assertLess( + text.index("5.急件"), + text.index("货物品名(必填)"), + ) self.assertNotIn("请一次选择:运输类型", text) self.assertNotIn("可选择回复", text) self.assertNotIn("填写样例见图", text) @@ -134,16 +141,26 @@ class LandOptionsTest(unittest.TestCase): self.assertNotIn("请补充陆运类型:国内运输拼车", text) def test_sample_table_when_route_missing(self): - text = option_prompt(current={"运输类型": "国内运输拼车"}) - self.assertIn("Step1: 请复制以下内容,粘贴到输入框;", text) - self.assertIn("---请复制以下内容---\n线路类别(必填):", text) - self.assertIn("线路类别(必填):\n1.1国内长途/零担", text) - self.assertIn("运输分类(必填):\n1.普件", text) + text = option_prompt( + current={ + "运输类型": "国内运输拼车", + "_land_show_运输类型": "1", + "品名": "衣服", + } + ) + head, _, tail = text.partition("---请复制以下内容---") + self.assertIn("运输类型(必填):1\n1.国内运输拼车", head) + self.assertIn("6.中港零担/集拼", head) + self.assertIn("货物品名(必填):衣服", head) + self.assertIn("Step1: 请复制以下内容,粘贴到输入框;", head) + self.assertIn("线路类别(必填):\n1.1国内长途/零担", tail) + self.assertIn("运输分类(必填):\n1.普件", tail) + self.assertNotIn("运输类型(必填)", tail) + self.assertNotIn("货物品名", tail) self.assertFalse(option_prompt_needs_sample(current={"运输类型": "国内运输拼车"})) self.assertFalse(option_prompt_needs_sample(current={})) self.assertNotIn("国内运输拼车 + 国内长途/零担", text) self.assertNotIn("请一次选择:运输类型", text) - self.assertNotIn("运输类型(必填)", text) def test_sample_pairs_go_to_image_not_text(self): text = option_prompt(current={}) @@ -218,6 +235,46 @@ class LandOptionsTest(unittest.TestCase): self.assertFalse(got.get("线路类别")) self.assertFalse(got.get("运输分类")) + def test_paste_number_is_echoed_and_kept(self): + """冒号后写 5,下一张清单回显 5,不改成标准名称。""" + got = harvest_land_options("运输类型(必填):5") + self.assertEqual(got.get("运输类型"), "中港整车") + self.assertEqual(got.get("_land_show_运输类型"), "5") + nxt = harvest_land_options("线路类别(必填):5.1", current=got) + self.assertEqual(nxt.get("_land_show_运输类型"), "5") + self.assertEqual(nxt.get("_land_show_线路类别"), "5.1") + self.assertEqual(nxt.get("线路类别"), "中港/中亚/中欧") + text = option_prompt(current={**nxt, "品名": ""}) + head, _, tail = text.partition("---请复制以下内容---") + self.assertIn("运输类型(必填):5\n1.国内运输拼车", head) + self.assertIn("线路类别(必填):5.1\n1.1国内长途/零担", head) + self.assertIn("货物品名(必填):\n(如:衣服)", tail) + self.assertNotIn("运输类型(必填)", tail) + self.assertNotIn("线路类别(必填)", tail) + + def test_prose_type_echoes_words(self): + got = harvest_land_options("中港整车,衣服,广州到香港") + self.assertEqual(got.get("运输类型"), "中港整车") + self.assertEqual(got.get("_land_show_运输类型"), "中港整车") + + def test_placeholder_cargo_name_is_still_empty(self): + self.assertEqual(land_cargo_name({"品名": "衣服"}), "衣服") + self.assertEqual(land_cargo_name({"货物品名": "零件"}), "零件") + self.assertEqual(land_cargo_name({"品名": "(如:衣服)"}), "") + text = option_prompt( + current={ + "运输类型": "中港整车", + "线路类别": "中港/中亚/中欧", + "运输分类": "急件", + "品名": "(如:衣服)", + } + ) + head, _, tail = text.partition("---请复制以下内容---") + self.assertIn("运输类型(必填):中港整车", head) + self.assertNotIn("货物品名(必填):", head) + self.assertIn("货物品名(必填):\n(如:衣服)", tail) + self.assertNotIn("运输类型(必填)", tail) + def test_confirm_intent(self): self.assertTrue(looks_like_confirm("确认")) self.assertTrue(looks_like_confirm("确定")) diff --git a/inquiry-agent/tests/test_land_text_inquiry.py b/inquiry-agent/tests/test_land_text_inquiry.py index 28ecc1d..4158173 100644 --- a/inquiry-agent/tests/test_land_text_inquiry.py +++ b/inquiry-agent/tests/test_land_text_inquiry.py @@ -28,6 +28,7 @@ from agent.ledger.memory_ledger import MemoryLedger from agent.policy import inquiry_copy as copy from agent.policy.air_text_flow import FlowSession from agent.policy.land_text_flow import LandTextInquiryFlow +from agent.schema.land_options import option_prompt class CountingLedger(MemoryLedger): @@ -75,6 +76,9 @@ class LandTextInquiryFrontTests(unittest.TestCase): self.assertIn("中港整车", text) self.assertIn("Step1: 请复制以下内容,粘贴到输入框;", text) self.assertIn("---请复制以下内容---", text) + head, _, tail = text.partition("---请复制以下内容---") + self.assertIn("货物品名(必填):衣服", head) + self.assertNotIn("货物品名", tail) self.assertNotIn("请补充陆运类型:", text) self.assertFalse(any(x.get("msgtype") == "image" for x in self.extras)) @@ -232,6 +236,80 @@ class LandTextInquiryFrontTests(unittest.TestCase): self.assertEqual(phase, "wait_confirm") self.assertEqual(self.flow.session_of("u1").facts.get("通关口岸"), "皇岗") + def test_missing_cargo_name_stays_on_option_sheet(self) -> None: + """三项齐了但没品名,仍停在第一张复制清单,不进始发站补问。""" + phase = self.flow.on_text( + sender_id="u1", + text="国内运输拼车 国内长途/零担 急件", + reply=self._reply, + injected_facts={ + "起运港": "广州", + "目的港": "深圳", + "体积": "100CBM", + "毛重": "50kg", + }, + injected_mode="LAND", + ) + self.assertEqual(phase, "need_land_options") + text = self._joined() + head, _, tail = text.partition("---请复制以下内容---") + self.assertIn("运输类型(必填):国内运输拼车", head) + self.assertIn("货物品名(必填):\n(如:衣服)", tail) + self.assertNotIn("始发站", text) + self.assertNotIn(copy.LAND_CONFIRM_TAIL, text) + + def test_typed_number_is_shown_on_next_sheet(self) -> None: + self.flow.on_text( + sender_id="u1", + text="陆运", + reply=self._reply, + injected_facts={"起运港": "广州", "目的港": "深圳"}, + injected_mode="LAND", + ) + self.replies.clear() + phase = self.flow.on_text( + sender_id="u1", + text="运输类型(必填):5", + reply=self._reply, + injected_mode="LAND", + ) + self.assertEqual(phase, "need_land_options") + text = self._joined() + head, _, tail = text.partition("---请复制以下内容---") + self.assertIn("运输类型(必填):5\n1.国内运输拼车", head) + self.assertNotIn("运输类型(必填)", tail) + self.assertIn("货物品名(必填):", tail) + self.assertEqual(self.flow.session_of("u1").facts.get("运输类型"), "中港整车") + + def test_blank_class_on_paste_is_not_taken_from_menu(self) -> None: + """复制清单里运输分类冒号是空的,不能把菜单第一项或模型抄来的分类当成已填。""" + sheet = option_prompt(current={}) + sheet = sheet.replace("运输类型(必填):\n", "运输类型(必填):1\n", 1) + sheet = sheet.replace("货物品名(必填):\n", "货物品名(必填):茶叶\n", 1) + phase = self.flow.on_text( + sender_id="u1", + text=sheet, + reply=self._reply, + injected_facts={ + "运输类型": "国内运输拼车", + "运输分类": "普件(常用线路/常规车型)", + "品名": "茶叶", + }, + injected_mode="LAND", + ) + self.assertEqual(phase, "need_land_options") + facts = self.flow.session_of("u1").facts + self.assertEqual(facts.get("运输类型"), "国内运输拼车") + self.assertEqual(facts.get("品名"), "茶叶") + self.assertFalse(facts.get("运输分类")) + self.assertFalse(facts.get("线路类别")) + head, _, tail = self._joined().partition("---请复制以下内容---") + self.assertIn("运输类型(必填):1", head) + self.assertIn("货物品名(必填):茶叶", head) + self.assertNotIn("运输分类(必填):普件", head) + self.assertIn("运输分类(必填):", tail) + self.assertIn("线路类别(必填):", tail) + def test_missing_volume_no_confirm(self) -> None: phase = self.flow.on_text( sender_id="u1", diff --git a/inquiry-agent/tests/test_multi_segments.py b/inquiry-agent/tests/test_multi_segments.py index 6a16508..594adf5 100644 --- a/inquiry-agent/tests/test_multi_segments.py +++ b/inquiry-agent/tests/test_multi_segments.py @@ -545,7 +545,8 @@ class FlowTests(unittest.TestCase): self.assertEqual(phase, "clarify") card = self.last_prompt() self.assertNotIn("起运港:(如:上海)", card) - self.assertIn("始发站", card) + self.assertIn("货物品名(必填)", card) + self.assertNotIn("始发站", card) sess = self.flow.session_of("sales") segs = self.flow._segments(sess) sea = next(seg for seg in segs if seg.mode == "SEA") 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 e90f9ed..ae1b360 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 @@ -359,6 +359,18 @@ public class InquiryTmsQueryService { if (StringUtils.isNotBlank(chargeable)) { quote.put("chargeableWeightKg", chargeable); } + // 销售侧报价正文要原样带出 TMS 备注和货型。没有就不写,禁止本地编。 + String remark = firstText(data.get("remark"), data.get("specialRemarks"), data.get("note")); + if (StringUtils.isNotBlank(remark)) { + quote.put("remark", remark); + } + String cargoType = firstText( + data.get("cargoType"), data.get("goodsType"), data.get("cargoRatio"), + data.get("weightVolumeRatio"), data.get("volumeWeightRatio"), + data.get("heavyBubbleRatio"), data.get("bubbleRatio")); + if (StringUtils.isNotBlank(cargoType)) { + quote.put("cargoType", cargoType); + } return quote; } diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_inq_bot_reply_air_lock_ok_route_v1.sql b/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_inq_bot_reply_air_lock_ok_route_v1.sql new file mode 100644 index 0000000..381987f --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_inq_bot_reply_air_lock_ok_route_v1.sql @@ -0,0 +1,19 @@ +-- 空运锁舱成功回执补起运地、目的地、航司、航班日期。 +-- 仅改 inquiry_robot 的机器人回复语;幂等。已发出的历史消息不改。 + +SET NAMES utf8mb4; + +UPDATE `inq_bot_reply` +SET `reply_text` = 'TMS空运锁舱成功。 +操作人:{{operator}} +舱位选项:{{option_no}} +执行编号:{{exec_no}} +报价编号:{{quote_no}} +起运地:{{origin}} +目的地:{{destination}} +航司:{{airline}} +航班日期:{{flight_date}}', + `update_by` = 'system', + `update_time` = NOW() +WHERE `reply_code` = 'inquiring.air_lock_ok' + OR `id` = 'brv10000000000000000000000000027'; 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 ca3da2e..ca500af 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 @@ -41,11 +41,15 @@ VALUES ('brv10000000000000000000000000024','inquiring','询价中','inquiring.air_lock_need_wo','空运锁舱/释放未带工单号','锁舱或释放舱位请带上工单号,例如 WO202609160001锁舱。',1,24,'admin',NOW()), ('brv10000000000000000000000000025','inquiring','询价中','inquiring.air_lock_need_quote','没有报价不能锁舱','工单{{ticket_no}}还没有报价,不能锁舱。',1,25,'admin',NOW()), ('brv10000000000000000000000000026','inquiring','询价中','inquiring.air_airline_only_cabin','只有航线能锁舱或释放','只有岗位为「航线·空运」的人能锁舱或释放舱位。',1,26,'admin',NOW()), -('brv10000000000000000000000000027','inquiring','询价中','inquiring.air_lock_ok','空运锁舱成功','工单{{ticket_no}} 已通知TMS执行空运锁舱,执行成功。 +('brv10000000000000000000000000027','inquiring','询价中','inquiring.air_lock_ok','空运锁舱成功','TMS空运锁舱成功。 操作人:{{operator}} 舱位选项:{{option_no}} 执行编号:{{exec_no}} -报价编号:{{quote_no}}',1,27,'admin',NOW()), +报价编号:{{quote_no}} +起运地:{{origin}} +目的地:{{destination}} +航司:{{airline}} +航班日期:{{flight_date}}',1,27,'admin',NOW()), ('brv10000000000000000000000000028','inquiring','询价中','inquiring.air_lock_fail','空运锁舱失败','工单{{ticket_no}} 空运锁舱失败。 操作人:{{operator}} 锁舱失败的原因:{{reason}} diff --git a/inquiry-backend/src/views/inquiry/mock/botReplies.ts b/inquiry-backend/src/views/inquiry/mock/botReplies.ts index b4e09cf..9d951b8 100644 --- a/inquiry-backend/src/views/inquiry/mock/botReplies.ts +++ b/inquiry-backend/src/views/inquiry/mock/botReplies.ts @@ -212,7 +212,7 @@ export const mockReplyCategories: ReplyCategory[] = [ id: "brv10000000000000000000000000027", replyCode: "inquiring.air_lock_ok", trigger: "空运锁舱成功", - text: "工单{{ticket_no}} 已通知TMS执行空运锁舱,执行成功。\n操作人:{{operator}}\n舱位选项:{{option_no}}\n执行编号:{{exec_no}}\n报价编号:{{quote_no}}", + text: "TMS空运锁舱成功。\n操作人:{{operator}}\n舱位选项:{{option_no}}\n执行编号:{{exec_no}}\n报价编号:{{quote_no}}\n起运地:{{origin}}\n目的地:{{destination}}\n航司:{{airline}}\n航班日期:{{flight_date}}", enabled: true, }, {