员工岗位可重新启用;调价认空运航线;会话存档跳号补拉;陆运报价按车型列回填
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -546,6 +546,7 @@ class WeComAppClient:
|
||||
return {}
|
||||
info = data.get("chat_info") or data.get("chatinfo") or {}
|
||||
return {
|
||||
"name": str(info.get("name") or "").strip(),
|
||||
"owner": str(info.get("owner") or "").strip(),
|
||||
"userlist": [
|
||||
str(x).strip()
|
||||
|
||||
@@ -278,6 +278,83 @@ def _poll_by_scan_users(
|
||||
return merged
|
||||
|
||||
|
||||
def _merge_records_by_seq(
|
||||
*batches: Sequence[dict[str, Any]],
|
||||
min_seq: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""按 seq 去重合并多页;同 seq 后写覆盖。只保留 seq > min_seq。"""
|
||||
by_seq: dict[int, dict[str, Any]] = {}
|
||||
for batch in batches:
|
||||
for rec in batch or ():
|
||||
seq = _record_seq(rec)
|
||||
if seq <= min_seq:
|
||||
continue
|
||||
by_seq[seq] = dict(rec)
|
||||
return [by_seq[s] for s in sorted(by_seq)]
|
||||
|
||||
|
||||
def _backfill_room_seq_gap(
|
||||
api: WshotoOpenClient,
|
||||
*,
|
||||
room: str,
|
||||
last_seq: int,
|
||||
rows: list[dict[str, Any]],
|
||||
page_size: int,
|
||||
lookback: int,
|
||||
clock: Callable[[], float],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
增量结果跳号时,用时间窗补中间漏掉的消息。
|
||||
|
||||
现网:游标 9892,管家增量只回撤回 9899,把「解散群」9898 永久跨过。
|
||||
若本页最小 seq > last_seq+1,再打一枪时间窗,按 seq 合并后返回。
|
||||
补洞失败则原样返回(调用方仍按本页 max 推进,故补洞要尽量成功)。
|
||||
"""
|
||||
if last_seq <= 0 or not rows:
|
||||
return rows
|
||||
page_seqs = [_record_seq(r) for r in rows if _record_seq(r) > 0]
|
||||
if not page_seqs:
|
||||
return rows
|
||||
min_page = min(page_seqs)
|
||||
if min_page <= last_seq + 1:
|
||||
return rows
|
||||
now_i = int(clock())
|
||||
window = min(max(60, int(lookback or 0)), _NEW_ROOM_LOOKBACK_SEC)
|
||||
logger.warning(
|
||||
"微盛房间增量跳号 chat=%s last=%s min_page=%s,时间窗补洞 window=%ss",
|
||||
room,
|
||||
last_seq,
|
||||
min_page,
|
||||
window,
|
||||
)
|
||||
try:
|
||||
fill = api.list_group_messages(
|
||||
userid="",
|
||||
target_id=room,
|
||||
last_seq=0,
|
||||
page_size=page_size,
|
||||
msg_time_start=now_i - window,
|
||||
msg_time_end=now_i,
|
||||
)
|
||||
except WshotoError as exc:
|
||||
logger.warning(
|
||||
"微盛房间补洞失败 chat=%s code=%s msg=%s",
|
||||
room,
|
||||
exc.code,
|
||||
exc.msg,
|
||||
)
|
||||
return rows
|
||||
merged = _merge_records_by_seq(rows, fill or [], min_seq=last_seq)
|
||||
if len(merged) > len(rows):
|
||||
logger.info(
|
||||
"微盛房间补洞成功 chat=%s added=%s total=%s",
|
||||
room,
|
||||
len(merged) - len(rows),
|
||||
len(merged),
|
||||
)
|
||||
return merged
|
||||
|
||||
|
||||
def _poll_by_rooms(
|
||||
api: WshotoOpenClient,
|
||||
*,
|
||||
@@ -341,6 +418,17 @@ def _poll_by_rooms(
|
||||
polled += 1
|
||||
if not is_room_hot(room):
|
||||
cold_polled += 1
|
||||
# 增量若跳号:管家有时先吐出撤回等高 seq,游标一步跨过中间正文
|
||||
# (现网:解散群 seq=9898 被撤回 9899 推过去后永远听不见)。跳号则时间窗补洞。
|
||||
rows = _backfill_room_seq_gap(
|
||||
api,
|
||||
room=room,
|
||||
last_seq=last,
|
||||
rows=list(rows or []),
|
||||
page_size=page_size,
|
||||
lookback=lookback,
|
||||
clock=clock,
|
||||
)
|
||||
max_seq = last
|
||||
for rec in rows:
|
||||
seq = _record_seq(rec)
|
||||
|
||||
@@ -117,11 +117,14 @@ def handle_card_action(
|
||||
wo = copy.work_order_from_card_meta(payload)
|
||||
code = str((payload or {}).get("response_code") or extra.get("response_code") or "")[:24]
|
||||
msgtype = str(extra.get("msgtype") or "text")
|
||||
# 文本 ack 按工单+动作去重;置灰更新仍带 code(每次 ResponseCode 只能用一次)
|
||||
# 文本/方案卡按工单+动作+waitVersion 去重。
|
||||
# 调价后再点「生成」时正文前缀相同,若不带 waitVersion,第二次方案卡会被 PG 唯一键吃掉,
|
||||
# 价格卡已置灰成「已提交」但销售收不到生成 Excel/PDF 的方案卡。
|
||||
wait_ver = int(getattr(sess, "wait_version", 0) or 0) if sess is not None else 0
|
||||
if msgtype == "update_template_card":
|
||||
dedupe_key = f"card-grey:{sender_id}:{wo}:{code}:{action}"
|
||||
else:
|
||||
dedupe_key = f"card-ack:{sender_id}:{wo}:{action}:{body[:24]}"
|
||||
dedupe_key = f"card-ack:{sender_id}:{wo}:{action}:v{wait_ver}:{body[:24]}"
|
||||
ok, info = msg_store.enqueue_outbound(
|
||||
touser=sender_id,
|
||||
content=body,
|
||||
|
||||
@@ -19,6 +19,7 @@ from agent.policy.sea_group_ops import (
|
||||
apply_tms_quote_choice,
|
||||
capture_group_lost_reason,
|
||||
dissolve_group,
|
||||
dissolve_orphan_group,
|
||||
group_client_of,
|
||||
handle_group_file,
|
||||
hydrate_ticket,
|
||||
@@ -82,6 +83,24 @@ def handle_group_inbound(
|
||||
ticket = _find_ticket(book, chat_id)
|
||||
if ticket is None:
|
||||
logger.info("group_inbound 对不上工单 chat=%s", chat_id)
|
||||
# 老群/迁库丢绑定:对不上工单时,「解散群」仍按企微当前成员清人。
|
||||
from agent.routing.group_intent import INTENT_DISSOLVE, classify_group_text
|
||||
|
||||
kind_orphan = (message.msg_type or "text").lower()
|
||||
if kind_orphan in {"", "text"} and (
|
||||
classify_group_text(message.content or "", injected=injected_intent)
|
||||
== INTENT_DISSOLVE
|
||||
):
|
||||
logger.info(
|
||||
"group_inbound 无工单仍解散 chat=%s sender=%s",
|
||||
chat_id,
|
||||
message.sender_id,
|
||||
)
|
||||
return dissolve_orphan_group(
|
||||
flow=engine,
|
||||
chat_id=chat_id,
|
||||
sender_id=message.sender_id or "",
|
||||
)
|
||||
return "group_ignore"
|
||||
if flow is None:
|
||||
view0 = ticket_view(ticket)
|
||||
|
||||
@@ -156,6 +156,7 @@ def _drop_other_mode_drafts(*, owner: str, sender_id: str) -> None:
|
||||
|
||||
def _flow_waiting_lost_reason(sender_id: str, sea, air, land):
|
||||
"""谁在等未成交原因就交给谁。刚记下未成交的那条优先。"""
|
||||
from agent.policy.deal_outcome import ticket_missing_in_ledger
|
||||
from agent.policy.handoff_human import is_handoff_frozen
|
||||
|
||||
waiting = []
|
||||
@@ -165,6 +166,20 @@ def _flow_waiting_lost_reason(sender_id: str, sea, air, land):
|
||||
continue
|
||||
if (sess.phase or "").strip() != "wait_lost_reason":
|
||||
continue
|
||||
# 清数后主账无单:丢掉孤儿书签,不要继续抢走新询价。
|
||||
if ticket_missing_in_ledger(
|
||||
getattr(flow, "_ledger", None),
|
||||
work_order_no=str(getattr(sess, "work_order_no", "") or ""),
|
||||
sender_id=sender_id,
|
||||
):
|
||||
abandon = getattr(flow, "abandon_orphan_lost_wait", None)
|
||||
if callable(abandon):
|
||||
abandon(sess)
|
||||
else:
|
||||
drop = getattr(flow, "drop_current_bookmark", None)
|
||||
if callable(drop):
|
||||
drop(sender_id)
|
||||
continue
|
||||
waiting.append((int(getattr(sess, "wait_version", 0) or 0), flow))
|
||||
if not waiting:
|
||||
return None
|
||||
|
||||
@@ -202,10 +202,12 @@ def _handle_one(
|
||||
watchers = [dict(x) for x in (row.get("watchers") or []) if isinstance(x, dict)]
|
||||
sales = _ticket_sales(row, watchers)
|
||||
people = pick_mentions(wait_kind=kind, sales=sales, members=watchers, business_line=line)
|
||||
# 空运群往往只绑了群号,成员列表是空的,群里就找不到航线岗,话术会落成「相关人员」。
|
||||
# 只补航线超时。销售超时不能改去 @ 航线,只 @ 本单销售。
|
||||
if kind == WAIT_AIR_QUOTE and not people:
|
||||
people = _airline_staff_mentions(book)
|
||||
# 空运群往往只绑了群号;且航线超时必须按本单「负责线路」点名,禁止整岗广播。
|
||||
# 与激活摘要 resolve_air_mentions 同口径。销售超时不能改去 @ 航线。
|
||||
if kind == WAIT_AIR_QUOTE:
|
||||
route_people = _airline_staff_mentions(book, facts=facts)
|
||||
if route_people:
|
||||
people = route_people
|
||||
names = [str(p.get("name") or "").strip() for p in people if str(p.get("name") or "").strip()]
|
||||
chat_id = str(row.get("collab_chat_id") or row.get("collabChatId") or "").strip()
|
||||
where = conversation_of(wait_kind=kind, collab_chat_id=chat_id)
|
||||
@@ -295,22 +297,42 @@ def _ticket_sales(row: dict[str, Any], watchers: list[dict[str, Any]]) -> dict[s
|
||||
return matched
|
||||
|
||||
|
||||
def _airline_staff_mentions(book: Any) -> list[dict[str, Any]]:
|
||||
def _airline_staff_mentions(
|
||||
book: Any,
|
||||
*,
|
||||
facts: dict[str, Any] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
主账按岗位列出启用的航线同事。
|
||||
主账列出启用航线·空运,再按本单负责线路过滤。
|
||||
|
||||
只在群成员里没有航线岗时调用。主账失败或没有这个接口时返回空,话术仍用「相关人员」。
|
||||
不写工单。可与巡检线程并发,每次只读这一单的员工名单。
|
||||
与空运群激活摘要同口径:对上起运/目的(或空运全线);对不上则只留全线。
|
||||
主账失败或没有接口时返回空,话术仍用「相关人员」。不写工单。
|
||||
"""
|
||||
from agent.policy.deal_followup import air_staff_route_matches
|
||||
|
||||
lister = getattr(book, "list_staff_by_role", None)
|
||||
if not callable(lister):
|
||||
return []
|
||||
try:
|
||||
rows = list(lister(role_code="air") or [])
|
||||
rows = [dict(x) for x in (lister(role_code="air") or []) if isinstance(x, dict)]
|
||||
except Exception:
|
||||
logger.exception("事件异常列航线岗失败")
|
||||
return []
|
||||
return mentions_for_role(rows, role_code="air")
|
||||
matched = [
|
||||
r
|
||||
for r in rows
|
||||
if air_staff_route_matches(routes=str(r.get("routes") or ""), facts=facts)
|
||||
]
|
||||
if not matched:
|
||||
matched = [
|
||||
r
|
||||
for r in rows
|
||||
if any(
|
||||
t.strip() in {"空运全线", "全线"}
|
||||
for t in str(r.get("routes") or "").replace(",", ",").replace("、", ",").split(",")
|
||||
)
|
||||
]
|
||||
return mentions_for_role(matched, role_code="air")
|
||||
|
||||
|
||||
def _send_app_cards(
|
||||
|
||||
@@ -186,6 +186,20 @@ _TIGHTEN_LABELS = tuple(
|
||||
)
|
||||
)
|
||||
_FEE_NAME = re.compile(r"^\*?[\u4e00-\u9fffA-Za-z0-9]{1,12}费$")
|
||||
# 识图/标签错位:报价编号、工单号、长数字进了起运地/目的地。三字码航线不动。
|
||||
_QUOTE_LIKE_PLACE = re.compile(
|
||||
r"^(?:"
|
||||
r"(?:WO|VT)\d{10,}"
|
||||
r"|QT[-_]?\d{6,}"
|
||||
r"|\d{8,}"
|
||||
r"|[A-Za-z]{1,6}\d{8,}"
|
||||
r")$",
|
||||
re.I,
|
||||
)
|
||||
_QUOTE_NO_IN_TEXT = re.compile(
|
||||
r"(?:报价编号|报价单编号|RFQ\s*No\.?|Quote\s*No\.?)\s*[::]?\s*([A-Za-z0-9\-_]{6,})",
|
||||
re.I,
|
||||
)
|
||||
_SHEET_STOP = (
|
||||
"报价明细",
|
||||
"报价条件",
|
||||
@@ -455,6 +469,64 @@ def _drop_fee_place_values(facts: dict[str, str]) -> dict[str, str]:
|
||||
return out
|
||||
|
||||
|
||||
def looks_like_quote_id_place(raw: str | None) -> bool:
|
||||
"""
|
||||
是否像报价编号/工单号,而不是起运地或目的地。
|
||||
|
||||
识图常把「报价编号:QT…」错贴到起运地。SZX、南京、SZX-CGK 不算。
|
||||
"""
|
||||
text = str(raw or "").strip()
|
||||
if not text:
|
||||
return False
|
||||
if "报价编号" in text or "报价单编号" in text:
|
||||
return True
|
||||
# 去掉常见装饰后再判
|
||||
cleaned = re.sub(r"[((]\s*(?:必填|非必填)\s*[))]", "", text).strip(" \t::,,。;;")
|
||||
if not cleaned:
|
||||
return False
|
||||
if _QUOTE_LIKE_PLACE.match(cleaned):
|
||||
return True
|
||||
# 纯数字超过 7 位多半是系统编号,不是港口名
|
||||
if re.fullmatch(r"\d{8,}", cleaned.replace(" ", "")):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _quote_nos_mentioned(text: str) -> set[str]:
|
||||
"""原话/识图正文里出现的报价编号值。"""
|
||||
return {
|
||||
m.group(1).strip()
|
||||
for m in _QUOTE_NO_IN_TEXT.finditer(text or "")
|
||||
if (m.group(1) or "").strip()
|
||||
}
|
||||
|
||||
|
||||
def _drop_quote_like_place_values(
|
||||
facts: dict[str, str],
|
||||
*,
|
||||
source_text: str = "",
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
起运地/目的地若是报价编号、工单号或原话「报价编号:」同一值,丢掉。
|
||||
|
||||
副作用:无写库;只清洗本轮 facts。
|
||||
"""
|
||||
out = dict(facts or {})
|
||||
mentioned = _quote_nos_mentioned(source_text)
|
||||
for key in ("起运港", "起运地", "目的港", "目的地"):
|
||||
val = str(out.get(key) or "").strip()
|
||||
if not val:
|
||||
continue
|
||||
if not (looks_like_quote_id_place(val) or val in mentioned):
|
||||
continue
|
||||
out.pop(key, None)
|
||||
if key == "起运港":
|
||||
out.pop("originCode", None)
|
||||
if key == "目的港":
|
||||
out.pop("destinationCode", None)
|
||||
return out
|
||||
|
||||
|
||||
def extract_inquiry_snapshot(
|
||||
text: str,
|
||||
*,
|
||||
@@ -483,6 +555,7 @@ def extract_inquiry_snapshot(
|
||||
if str(facts.get(key) or "").strip():
|
||||
facts[key] = clip_inquiry_value(str(facts[key]))
|
||||
facts = _drop_fee_place_values(_clip_sheet_inquiry_facts(facts))
|
||||
facts = _drop_quote_like_place_values(facts, source_text=text)
|
||||
facts = _apply_air_added_services(text, facts, mode)
|
||||
subtype = str(facts.get("运输类型") or "").strip()
|
||||
return {
|
||||
@@ -539,6 +612,7 @@ def extract_inquiry_snapshot(
|
||||
if str(facts.get(key) or "").strip():
|
||||
facts[key] = clip_inquiry_value(str(facts[key]))
|
||||
facts = _drop_fee_place_values(_clip_sheet_inquiry_facts(facts))
|
||||
facts = _drop_quote_like_place_values(facts, source_text=text)
|
||||
facts = _apply_air_added_services(text, facts, mode)
|
||||
land_subtype = str((b_payload or {}).get("land_subtype") or facts.get("运输类型") or "").strip()
|
||||
if land_subtype and not str(facts.get("运输类型") or "").strip():
|
||||
@@ -1013,8 +1087,18 @@ _SKIP_FEE_NAMES = frozenset(
|
||||
# 航线常写「空运费 单价CNY99,数量100,金额CNY199」。单价和金额不是同一个数,必须认金额。
|
||||
# WO202610130003:协同句常见「空运费, KG, 单价CNY99.00, 数量100, 金额CNY9900」——费名与单价之间夹单位;
|
||||
# WO202610140008:航线写「增值费_板,单价CNY10…」——下划线单位;漏认会落到金额误抽,出单默认单位「票」。
|
||||
# WO202610190081:打板费,次,单价…——「次/人」等中文单位不在旧名单里时整句匹配失败,数量落成 1、金额=单价。
|
||||
# 数量/金额前也可能只有空格无逗号。漏匹配会落到 _RE_FEE_LINE,把单价 99 当成行金额、数量被补成 1。
|
||||
_FEE_UOM = r"KG|KGS|公斤|千克|TON|CBM|柜|票|板数|板|点|[A-Za-z]{1,8}"
|
||||
# 业务单位表(长词/带斜杠优先,避免「次/人」被拆成「次」):
|
||||
# 板数/点/人/天/货值/个/页数/柜/次/份/张/台/平方/件/计重/箱数/数量/千克/立方/卷/
|
||||
# 单数/票数/次/人/车/小时/人/小时/公里数/月/趟/周/批/项 + 英文重量体积码。
|
||||
# 末尾短中文通配:表外单位夹在费名与单价之间也不挡抽取。
|
||||
_FEE_UOM = (
|
||||
r"次/人|小时/人|板数|页数|箱数|单数|票数|公里数|货值|计重|平方|立方|千克|小时|"
|
||||
r"数量|KG|KGS|公斤|TON|CBM|柜|票|板|点|次|人|天|个|份|张|台|件|卷|车|月|趟|周|批|项|"
|
||||
r"托|套|袋|包|箱|"
|
||||
r"[A-Za-z]{1,8}|[\u4e00-\u9fff]{1,3}"
|
||||
)
|
||||
_RE_FEE_PRICED = re.compile(
|
||||
r"(?P<name>[\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费)"
|
||||
rf"(?:(?:\s*[,,、/]\s*|\s+|_)(?P<uom>{_FEE_UOM}))?"
|
||||
@@ -1251,7 +1335,9 @@ def _parse_fee_rows(text: str, baseline_currency: str = "") -> list[dict[str, st
|
||||
row[key] = val
|
||||
rows.append(row)
|
||||
|
||||
priced_hits = 0
|
||||
for hit in _RE_FEE_PRICED.finditer(raw):
|
||||
priced_hits += 1
|
||||
# 有「金额」用金额;只有单价时才用单价。禁止把单价误当成这一行的总金额。
|
||||
amount = (hit.group("amount") or "").strip() or (hit.group("unit") or "").strip()
|
||||
ccy = hit.group("amt_ccy") or hit.group("unit_ccy") or ""
|
||||
|
||||
@@ -117,6 +117,11 @@ def build_adjust_messages(text: str, fee_names: list[str]) -> list[dict[str, str
|
||||
"只动费用合计(销售也可能说总销售价、总成本价、合计、总价、汇总价):wants_total=true,items 仍要一条(fee_name 可空),带 kind/direction/value。"
|
||||
"kind 只能 percent 或 target;direction 只能 up/down/set。"
|
||||
"value 必须来自原话数字;一成=10。禁止编造费用名和金额。"
|
||||
"语义:上调/加/涨 X% → kind=percent direction=up;"
|
||||
"下调/减/降 X% → kind=percent direction=down;"
|
||||
"增加到/提升到/调整到/涨到/升到/增至/变为/上浮到/调到/改为 X% "
|
||||
"→ kind=percent direction=set(改到原价的 X%,不是再上调 X%);"
|
||||
"提升到/改为/变为 具体金额(无百分号)→ kind=target direction=set。"
|
||||
"不是调价(问候、新询价、谢谢)时 is_adjust=false。"
|
||||
f"当前可调费用名:{names}"
|
||||
),
|
||||
|
||||
@@ -125,7 +125,8 @@ def build_extract_messages(text: str) -> list[dict[str, str]]:
|
||||
"你是询价字段抽取器。只根据销售原话抽取字段,调用 extract_inquiry_fields。"
|
||||
"未出现的字段必须空串,禁止编造港口、重量、日期。"
|
||||
"空运件数/毛重/体积/包装方式;海运货量与箱型箱量;陆运按二级类型。"
|
||||
"陆运整车/中港零担的车型写入车型/数量:45HQ(13.5厢车)、9米6/1、3T;"
|
||||
"国内整车/跨境整车的车型写入车型/数量:45HQ(13.5厢车)、9米6/1、3T;"
|
||||
"中港零担/集拼不要抽车型/数量;中港整车的车型写入车型/数量;"
|
||||
"陆运不要把 45HQ/厢车 写成箱型箱量。"
|
||||
"海运时 15件/20吨/3箱 必须写入货量,不要只写到件数把货量留空。"
|
||||
"原话已带单位必须抽取,不要因为没写「毛重:」就留空:"
|
||||
@@ -144,6 +145,7 @@ def build_extract_messages(text: str) -> list[dict[str, str]]:
|
||||
"陆运「A到B」必须写入起运港和目的港。"
|
||||
"原话是机场三字码或航线就原样记,禁止翻译成城市名:"
|
||||
"SZX-CGK / SZX/CGK / SZX到CGK → 起运港=SZX、目的港=CGK,不要写成深圳、雅加达。"
|
||||
"报价编号、报价单编号、工单号(WO/VT…)、长数字编号不是起运港或目的港,必须空串。"
|
||||
"换城市必须覆盖旧值,不要沿用上一句的港口。"
|
||||
"运输方式仅在原话能判断时填 AIR/SEA/LAND,否则 UNKNOWN。"
|
||||
),
|
||||
|
||||
@@ -13,7 +13,6 @@ import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.llm.extract_text import extract_air_collab_fields, extract_product_quote
|
||||
@@ -71,6 +70,60 @@ def _ticket_flight_date(ticket: object) -> str:
|
||||
).strip()
|
||||
|
||||
|
||||
def _quote_date_from_lock_instruction(instruction: str) -> str:
|
||||
"""
|
||||
锁舱补参句里的报价日期。
|
||||
|
||||
先认「报价日期:…」标签,再机读句末 yyyy-MM-dd(产品示例:
|
||||
AIR-OPT01,南航,航班CZ327,2026-10-18)。不改主账。
|
||||
"""
|
||||
from agent.llm.extract_text import labeled_quote_date
|
||||
from agent.schema.field_validate import _harvest_quote_date
|
||||
|
||||
labeled = labeled_quote_date(instruction or "")
|
||||
if labeled:
|
||||
return labeled
|
||||
return _harvest_quote_date(instruction or "")
|
||||
|
||||
|
||||
def _absorb_lock_quote_date(
|
||||
*,
|
||||
flow: Any,
|
||||
ticket: object,
|
||||
instruction: str,
|
||||
work_order_no: str,
|
||||
) -> tuple[object, str, str]:
|
||||
"""
|
||||
工单报价日空、锁舱句里又写了日期时,先写入主账再锁。
|
||||
|
||||
返回:(最新 ticket, 生效报价日, 本句抽出的日期)。
|
||||
写失败不挡缺项提示;生效日仍可用本句日期做本地齐套判断。
|
||||
"""
|
||||
from agent.schema.field_validate import is_blank_quote_date
|
||||
|
||||
current = _ticket_flight_date(ticket)
|
||||
said = _quote_date_from_lock_instruction(instruction)
|
||||
if not said:
|
||||
return ticket, current, ""
|
||||
if not is_blank_quote_date(current):
|
||||
return ticket, current, said
|
||||
writer = getattr(flow.ledger, "update_facts", None)
|
||||
write_ok = False
|
||||
if callable(writer):
|
||||
try:
|
||||
out = writer(work_order_no=work_order_no, facts={"报价日期": said}) or {}
|
||||
write_ok = bool(out.get("ok", True))
|
||||
except Exception:
|
||||
logger.exception("锁舱句写报价日期失败 wo=%s", work_order_no)
|
||||
write_ok = False
|
||||
if write_ok:
|
||||
getter = getattr(flow.ledger, "get_ticket", None)
|
||||
reloaded = getter(work_order_no=work_order_no) if callable(getter) else None
|
||||
ticket2 = hydrate_ticket(flow, reloaded or ticket)
|
||||
return ticket2, _ticket_flight_date(ticket2) or said, said
|
||||
return ticket, said, said
|
||||
|
||||
|
||||
def _lock_receipt_fields(
|
||||
ticket: object,
|
||||
quote: dict[str, Any],
|
||||
@@ -451,6 +504,20 @@ def _total_matches_fee_row_sum(quote: dict[str, Any], total_text: str) -> bool:
|
||||
return abs(total_num - summed) < 0.01
|
||||
|
||||
|
||||
def _total_is_thin_vs_tms(cur_total: str, tms_total: str) -> bool:
|
||||
"""
|
||||
薄报价:明细加总明显小于 TMS 销售价(WO040:200 << 7918)才盖成销售价。
|
||||
|
||||
航线在舱位价上再加增值费/增点费时,total=明细之和会大于 salePrice(如 4966 > 3966),
|
||||
此时必须保留航线总价,禁止再盖回 TMS。
|
||||
"""
|
||||
cur = _money_plain_number(cur_total)
|
||||
tms = _money_plain_number(tms_total)
|
||||
if cur is None or tms is None:
|
||||
return False
|
||||
return cur + 0.01 < tms
|
||||
|
||||
|
||||
def _sheet_quote_with_tms_sale_total(
|
||||
quote: dict[str, Any],
|
||||
*,
|
||||
@@ -490,7 +557,10 @@ def _sheet_quote_with_tms_sale_total(
|
||||
out[key] = proj_val
|
||||
if not tms_total:
|
||||
return out
|
||||
need_tms_total = (not cur_total) or cur_total == "-" or _total_matches_fee_row_sum(out, cur_total)
|
||||
# 仅「空/薄报价加总」才盖 TMS;航线加出增值费后 total>salePrice 必须保留。
|
||||
matches_sum = bool(cur_total) and cur_total != "-" and _total_matches_fee_row_sum(out, cur_total)
|
||||
thin_vs_tms = matches_sum and _total_is_thin_vs_tms(cur_total, tms_total)
|
||||
need_tms_total = (not cur_total) or cur_total == "-" or thin_vs_tms
|
||||
if need_tms_total:
|
||||
out["total"] = str(projected.get("total") or tms_total).strip()
|
||||
if projected.get("currency") and not str(out.get("currency") or "").strip():
|
||||
@@ -500,9 +570,13 @@ def _sheet_quote_with_tms_sale_total(
|
||||
|
||||
def resolve_air_mentions(flow: Any, ticket: object) -> tuple[str, list[str], list[str]]:
|
||||
"""
|
||||
空运群摘要必须 @ 到本单销售和航线·空运,对上海运 @ 销售/@ 产品。
|
||||
空运群摘要 @ 本单销售 + 负责本单线路的航线·空运(按后台 routes 过滤)。
|
||||
对上海运 @ 销售/@ 产品;禁止再按岗位整表广播。
|
||||
"""
|
||||
from agent.policy.deal_followup import air_staff_route_matches
|
||||
|
||||
view = ticket_view(ticket)
|
||||
facts = dict(view.get("facts") or {})
|
||||
sales_id = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "").strip()
|
||||
sales_name = str(view.get("sales_name") or view.get("salesName") or "").strip()
|
||||
if not sales_name or sales_name == "销售":
|
||||
@@ -519,7 +593,25 @@ def resolve_air_mentions(flow: Any, ticket: object) -> tuple[str, list[str], lis
|
||||
rows = list(lister(role_code="air") or [])
|
||||
except Exception:
|
||||
logger.exception("列航线岗失败")
|
||||
matched_rows: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
if air_staff_route_matches(routes=str(row.get("routes") or ""), facts=facts):
|
||||
matched_rows.append(row)
|
||||
# 对不上具体线路时,只保留「空运全线」,避免再 @ 全岗
|
||||
if not matched_rows:
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
toks = [
|
||||
t.strip()
|
||||
for t in str(row.get("routes") or "").replace(",", ",").replace("、", ",").split(",")
|
||||
if t.strip()
|
||||
]
|
||||
if any(t in {"空运全线", "全线"} for t in toks):
|
||||
matched_rows.append(row)
|
||||
for row in matched_rows:
|
||||
uid = str(row.get("wecomId") or row.get("wecom_id") or "").strip()
|
||||
name = str(row.get("name") or "").strip() or _staff_display_name(flow, uid)
|
||||
if not uid:
|
||||
@@ -1062,6 +1154,13 @@ def apply_air_lock(
|
||||
if blocked:
|
||||
return blocked
|
||||
no = ticket_no(ticket)
|
||||
# 补参句带日期时先写入,避免舱位三项齐了仍反复要报价日期。
|
||||
ticket, quote_date, _said_quote_date = _absorb_lock_quote_date(
|
||||
flow=flow,
|
||||
ticket=ticket,
|
||||
instruction=instruction,
|
||||
work_order_no=no,
|
||||
)
|
||||
name = operator_name(flow, sender_id)
|
||||
view_quote = dict(ticket_view(ticket).get("quote") or {})
|
||||
air_quote = dict((view_quote.get("segment_quotes") or {}).get("AIR") or {})
|
||||
@@ -1079,7 +1178,9 @@ def apply_air_lock(
|
||||
origin, destination, airline_label, flight_date = _lock_receipt_fields(
|
||||
ticket, quote, picked, air_code
|
||||
)
|
||||
missing = _missing_lock_labels(picked, air_code, flight)
|
||||
if not quote_date:
|
||||
quote_date = _ticket_flight_date(ticket)
|
||||
missing = _missing_lock_labels(picked, air_code, flight, quote_date=quote_date)
|
||||
if missing and _quote_ready_for_lock(quote):
|
||||
_remember_partial_lock(
|
||||
flow,
|
||||
@@ -1812,8 +1913,20 @@ def _option_row(quote: dict[str, Any], option_no: str) -> Optional[dict[str, Any
|
||||
return None
|
||||
|
||||
|
||||
def _missing_lock_labels(option_no: str, airline: str, flight_no: str) -> list[str]:
|
||||
"""缺的中文名,顺序固定:舱位编号、航司、航班号。"""
|
||||
def _missing_lock_labels(
|
||||
option_no: str,
|
||||
airline: str,
|
||||
flight_no: str,
|
||||
*,
|
||||
quote_date: str = "",
|
||||
) -> list[str]:
|
||||
"""
|
||||
缺的中文名,顺序固定:舱位编号、航司、航班号、报价日期。
|
||||
|
||||
报价日期空也要进缺项,避免先只补舱位三项、再被主账单独拦一次。
|
||||
"""
|
||||
from agent.schema.field_validate import is_blank_quote_date
|
||||
|
||||
missing: list[str] = []
|
||||
if not (option_no or "").strip():
|
||||
missing.append("舱位编号")
|
||||
@@ -1821,12 +1934,18 @@ def _missing_lock_labels(option_no: str, airline: str, flight_no: str) -> list[s
|
||||
missing.append("航司")
|
||||
if not _FLIGHT_CODE_RE.match((flight_no or "").strip().upper()):
|
||||
missing.append("航班号")
|
||||
if is_blank_quote_date(quote_date):
|
||||
missing.append("报价日期")
|
||||
return missing
|
||||
|
||||
|
||||
def _labels_from_incomplete_reason(reason: str) -> list[str]:
|
||||
"""主账回了「缺少:…」时,按中文名收回缺项,丢掉英文字段名。"""
|
||||
found = [label for label in ("舱位编号", "航司", "航班号") if label in (reason or "")]
|
||||
found = [
|
||||
label
|
||||
for label in ("舱位编号", "航司", "航班号", "报价日期")
|
||||
if label in (reason or "")
|
||||
]
|
||||
return found or ["舱位编号", "航司", "航班号"]
|
||||
|
||||
|
||||
|
||||
@@ -216,15 +216,22 @@ def _fee_rows(quote: dict[str, Any] | None, facts: dict[str, str] | None) -> lis
|
||||
)
|
||||
|
||||
src = dict(quote or {})
|
||||
option = resolve_display_air_option(src, facts)
|
||||
# 销售调价后扁平 fee_lines 是真相;舱位 feeItems 常仍是 TMS 原价/被合计盖住的单价。
|
||||
source = str(src.get("source") or "").strip().upper()
|
||||
label = str(src.get("source_label") or src.get("sourceLabel") or "").replace(" ", "")
|
||||
prefer_flat = source == "SALES_ADJUST" or "销售调价" in label
|
||||
rows: list[dict[str, Any]] = []
|
||||
if option:
|
||||
from agent.schema.air_route_options import option_fee_dicts
|
||||
if prefer_flat:
|
||||
rows = _named_rows(src.get("fee_lines") or src.get("fee_rows"))
|
||||
if not rows:
|
||||
option = resolve_display_air_option(src, facts)
|
||||
if option:
|
||||
from agent.schema.air_route_options import option_fee_dicts
|
||||
|
||||
# 与投影同一套:舱位缺 feeItems 时补整单顶层空运费,再拼起运/目的附加费。
|
||||
rows = option_fee_dicts(option, src)
|
||||
if not rows and len(parse_air_options(src)) > 1:
|
||||
return []
|
||||
# 与投影同一套:舱位缺 feeItems 时补整单顶层空运费,再拼起运/目的附加费。
|
||||
rows = option_fee_dicts(option, src)
|
||||
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:
|
||||
|
||||
@@ -2264,6 +2264,26 @@ class AirTextInquiryFlow:
|
||||
)
|
||||
return phase
|
||||
|
||||
def abandon_orphan_lost_wait(self, sess: FlowSession) -> None:
|
||||
"""
|
||||
主账已无此单,但仍停在「等未成交原因」:丢掉书签与进程内索引。
|
||||
|
||||
清数后常见:Redis 还挂着 WO,下一句永远 patch 失败,新询价也被抢走。
|
||||
"""
|
||||
sid = (sess.sender_id or "").strip()
|
||||
no = (sess.work_order_no or "").strip()
|
||||
if sid:
|
||||
with self._lock:
|
||||
self._by_sender.pop(sid, None)
|
||||
self._delete_bookmark(sid)
|
||||
if no:
|
||||
with self._lock:
|
||||
self._by_ticket.pop(no, None)
|
||||
upper = no.upper()
|
||||
for key in list(self._by_ticket.keys()):
|
||||
if (key or "").upper() == upper:
|
||||
self._by_ticket.pop(key, None)
|
||||
|
||||
def _capture_lost_reason(self, sess: FlowSession, text: str, reply: ReplyFn) -> str:
|
||||
"""私聊未成交后收下原因,不再追问。"""
|
||||
from agent.policy.deal_outcome import capture_lost_reason
|
||||
@@ -2274,6 +2294,16 @@ class AirTextInquiryFlow:
|
||||
reason=text,
|
||||
)
|
||||
if not result.get("ok"):
|
||||
err = str(result.get("error") or "").strip()
|
||||
# 清数后主账无单:不能再死循环「请再发一句」,否则挡死新询价。
|
||||
if err in {"ticket_not_found", "not_found"}:
|
||||
no = (sess.work_order_no or "").strip()
|
||||
self.abandon_orphan_lost_wait(sess)
|
||||
reply(
|
||||
f"工单 {no or ''} 已不在系统中(可能已清数),无需再补未成交原因。"
|
||||
"请直接发起新询价。"
|
||||
)
|
||||
return "orphan_cleared"
|
||||
reply("未成交原因没有记下,请再发一句。")
|
||||
return "wait_lost_reason"
|
||||
sess.phase = "done"
|
||||
|
||||
@@ -181,11 +181,33 @@ def _split_routes(raw: str) -> list[str]:
|
||||
return out
|
||||
|
||||
|
||||
def _air_od_codes(facts: dict[str, str] | None) -> tuple[str, str]:
|
||||
"""从工单 facts 取空运起运/目的三字码(兼容港/地/投影字段)。"""
|
||||
src = dict(facts or {})
|
||||
origin = resolve_airport_code(
|
||||
str(
|
||||
src.get("起运港")
|
||||
or src.get("起运地")
|
||||
or src.get("originCode")
|
||||
or src.get("origin_code")
|
||||
or ""
|
||||
)
|
||||
)
|
||||
dest = resolve_airport_code(
|
||||
str(
|
||||
src.get("目的港")
|
||||
or src.get("目的地")
|
||||
or src.get("destinationCode")
|
||||
or src.get("destination_code")
|
||||
or ""
|
||||
)
|
||||
)
|
||||
return origin, dest
|
||||
|
||||
|
||||
def air_route_keys(facts: dict[str, str] | None) -> set[str]:
|
||||
"""本单可对上的空运线路名:三字码对 + 目的港。"""
|
||||
src = dict(facts or {})
|
||||
origin = resolve_airport_code(str(src.get("起运港") or src.get("originCode") or ""))
|
||||
dest = resolve_airport_code(str(src.get("目的港") or src.get("destinationCode") or ""))
|
||||
origin, dest = _air_od_codes(facts)
|
||||
keys: set[str] = set()
|
||||
if dest:
|
||||
keys.add(dest)
|
||||
@@ -194,6 +216,32 @@ def air_route_keys(facts: dict[str, str] | None) -> set[str]:
|
||||
return keys
|
||||
|
||||
|
||||
def air_staff_route_matches(*, routes: str, facts: dict[str, str] | None) -> bool:
|
||||
"""
|
||||
空运群 @ 航线:按员工「负责线路」是否对上本单。
|
||||
|
||||
与成交负责人推送同口径(全线 / 外采 / 三字码对),但不要求 isLeader。
|
||||
仅有目的港、无起运时:后台常配 CGO-KUL,允许 token 以 -目的 结尾命中。
|
||||
"""
|
||||
tokens = _split_routes(routes)
|
||||
if not tokens:
|
||||
return False
|
||||
if _has_all_line(tokens, line="AIR"):
|
||||
return True
|
||||
if any("外采-空运" in t or t == "外采-空运" for t in tokens) and _is_waicai(facts):
|
||||
return True
|
||||
origin, dest = _air_od_codes(facts)
|
||||
keys = air_route_keys(facts)
|
||||
if any(t in keys for t in tokens):
|
||||
return True
|
||||
# 只有目的港:KUL 应对上 CGO-KUL / SZX-KUL 等配线
|
||||
if dest and not origin:
|
||||
suffix = f"-{dest}"
|
||||
if any(t == dest or t.endswith(suffix) for t in tokens):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_waicai(facts: dict[str, str] | None) -> bool:
|
||||
src = dict(facts or {})
|
||||
for key in ("外采", "是否外采", "采购方式"):
|
||||
@@ -251,12 +299,7 @@ def leader_matches(
|
||||
if line == "AIR":
|
||||
if code != "air":
|
||||
return False
|
||||
if _has_all_line(tokens, line="AIR"):
|
||||
return True
|
||||
if "外采-空运" in tokens and _is_waicai(facts):
|
||||
return True
|
||||
keys = air_route_keys(facts)
|
||||
return any(t in keys for t in tokens)
|
||||
return air_staff_route_matches(routes=routes, facts=facts)
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -7,8 +7,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from agent.config import get_settings
|
||||
@@ -16,12 +19,14 @@ from agent.policy import inquiry_copy as copy
|
||||
from agent.policy.deal_followup import (
|
||||
DEFAULT_NEGOTIATE_HOURS,
|
||||
TIMEOUT_CODE_DEAL_NEGOTIATE,
|
||||
air_route_keys,
|
||||
next_followup_at,
|
||||
pick_leaders,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
EnqueueFn = Callable[..., tuple[bool, str]]
|
||||
|
||||
# 成交跟进里先认未成交,避免被「成交」误伤。
|
||||
@@ -204,6 +209,7 @@ def finish_deal_outcome(
|
||||
business_line=business_line,
|
||||
)
|
||||
enqueued = 0
|
||||
enqueue_fails: list[str] = []
|
||||
for row in leaders:
|
||||
uid = str(row.get("wecomId") or "").strip()
|
||||
if not uid:
|
||||
@@ -218,9 +224,12 @@ def finish_deal_outcome(
|
||||
if ok:
|
||||
enqueued += 1
|
||||
else:
|
||||
enqueue_fails.append(f"{uid}:{info}")
|
||||
logger.warning("成交通知入队失败 wo=%s uid=%s info=%s", no, uid, info)
|
||||
except Exception:
|
||||
except Exception as exc: # noqa: BLE001
|
||||
enqueue_fails.append(f"{uid}:exc")
|
||||
logger.exception("成交通知入队异常 wo=%s uid=%s", no, uid)
|
||||
_ = exc
|
||||
except Exception:
|
||||
logger.exception("成交通知组装失败,仍继续入账 wo=%s", no)
|
||||
summary = "通知未发出"
|
||||
@@ -383,3 +392,23 @@ def capture_lost_reason(*, ledger: Any, work_order_no: str, reason: str) -> dict
|
||||
|
||||
clear_wait(ledger, work_order_no=no)
|
||||
return out
|
||||
|
||||
|
||||
def ticket_missing_in_ledger(ledger: Any, *, work_order_no: str, sender_id: str = "") -> bool:
|
||||
"""
|
||||
主账已无此工单(清数/删单后)。
|
||||
|
||||
用于丢掉「等原因」孤儿书签,避免死循环「请再发一句」并挡住新询价。
|
||||
"""
|
||||
no = (work_order_no or "").strip()
|
||||
if not no:
|
||||
return True
|
||||
getter = getattr(ledger, "get_for_agent", None)
|
||||
if not callable(getter):
|
||||
return False
|
||||
try:
|
||||
view = getter(work_order_no=no, sender_id=sender_id or "") or {}
|
||||
except Exception:
|
||||
logger.exception("查工单是否存在失败 wo=%s", no)
|
||||
return False
|
||||
return not bool(view.get("found"))
|
||||
|
||||
@@ -215,7 +215,7 @@ def mentions_for_role(
|
||||
从员工行里取出启用、有企微、有姓名的指定岗位。
|
||||
|
||||
空运群激活只记群号,协同成员经常是空的。超时巡检在群成员里找不到航线岗时,
|
||||
用本函数点名员工管理里的航线·空运,和进群摘要 @ 的是同一批人。
|
||||
用本函数点名员工管理里已按本单线路筛过的航线·空运(与进群摘要同口径)。
|
||||
不发消息、不写主账。
|
||||
"""
|
||||
want = (role_code or "").strip().lower()
|
||||
|
||||
@@ -323,8 +323,10 @@ LAND_TMS_RETRYING = "正在重新查询TMS报价。工单:{wo}"
|
||||
|
||||
_LAND_LCL_KEYS = ("起运港", "目的港", "品名", "体积", "毛重", "报价日期")
|
||||
_LAND_FTL_KEYS = ("起运港", "目的港", "品名", "车型/数量", "报价日期")
|
||||
_LAND_HK_FULL_KEYS = ("起运港", "目的港", "品名", "报价日期")
|
||||
_LAND_FTL_TYPES = frozenset({"国内运输整车", "跨境整车", "中港零担/集拼"})
|
||||
# 中港零担/集拼:核对卡/方案卡不展示车型/数量(中港整车要采车型/数量)
|
||||
_LAND_HK_LCL_KEYS = ("起运港", "目的港", "品名", "报价日期")
|
||||
_LAND_FTL_TYPES = frozenset({"国内运输整车", "跨境整车", "中港整车"})
|
||||
_LAND_HK_NO_VEHICLE_TYPES = frozenset({"中港零担/集拼"})
|
||||
_LAND_CUSTOMS_TYPES = frozenset({"跨境集拼", "跨境整车", "中港整车", "中港零担/集拼"})
|
||||
_LAND_EXAMPLES = {
|
||||
"起运港": "广州",
|
||||
@@ -341,10 +343,10 @@ _LAND_EXAMPLES = {
|
||||
|
||||
|
||||
def _land_cargo_keys(land_type: str, facts: dict[str, str] | None = None) -> tuple[str, ...]:
|
||||
"""当前运输类型要展示的货物内部键。中港整车不要车型/数量。"""
|
||||
"""当前运输类型要展示的货物内部键。仅中港零担/集拼不要车型/数量。"""
|
||||
_ = facts
|
||||
if land_type == "中港整车":
|
||||
return _LAND_HK_FULL_KEYS
|
||||
if land_type in _LAND_HK_NO_VEHICLE_TYPES:
|
||||
return _LAND_HK_LCL_KEYS
|
||||
if land_type in _LAND_FTL_TYPES:
|
||||
return _LAND_FTL_KEYS
|
||||
return _LAND_LCL_KEYS
|
||||
@@ -629,7 +631,8 @@ def ask_clarify(
|
||||
|
||||
空运报价日期:说了就写成「报价日期:日期」,没说写成「报价日期(非必填):」,不进待补充。
|
||||
海运报价日期写进已识别(销售说了用原话,没说用当天),不出现在待补充。
|
||||
空运非必填一并列出,没填进待补充,不挡查价。
|
||||
空运:仅当仍有必填缺口时,才把空着的非必填一并放进待补充;
|
||||
必填已齐则返回空串,由核对卡引导回复「确定」,禁止只甩一堆非必填逼回复。
|
||||
"""
|
||||
src = dict(facts or {})
|
||||
skip = {"报价日期"}
|
||||
@@ -660,45 +663,59 @@ def ask_clarify(
|
||||
val = known(src, key)
|
||||
if val:
|
||||
lines.append(f"{label}:{val}")
|
||||
lines.append("")
|
||||
lines.append(CLARIFY_PASTE_HINT)
|
||||
any_missing = False
|
||||
seq = 0
|
||||
# 先判断有没有必填缺口。仅非必填空着时不发「请复制粘贴」,交给核对卡让销售直接回复确定。
|
||||
required_items: list[tuple[str, str, str, str]] = []
|
||||
if "运输方式" in missing_set:
|
||||
any_missing = True
|
||||
seq += 1
|
||||
lines.append(_pending_field_block(seq, "运输方式", "空运"))
|
||||
if not sea and not known(src, "起运港"):
|
||||
any_missing = True
|
||||
seq += 1
|
||||
lines.append(_pending_field_block(seq, "起运地", _AIR_EXAMPLES["起运港"], optional=True))
|
||||
required_items.append(("运输方式", "运输方式", "空运", ""))
|
||||
for key, label, example in rows:
|
||||
if key in missing_set:
|
||||
any_missing = True
|
||||
seq += 1
|
||||
if sea and key == "运输分类":
|
||||
lines.append(_pending_field_block(seq, "运输分类", "Port to Port、其他"))
|
||||
required_items.append((key, "运输分类", "Port to Port、其他", ""))
|
||||
else:
|
||||
lines.append(
|
||||
_pending_field_block(
|
||||
seq,
|
||||
label,
|
||||
example,
|
||||
unit=_PENDING_UNITS.get(key, ""),
|
||||
)
|
||||
required_items.append(
|
||||
(key, label, example, _PENDING_UNITS.get(key, ""))
|
||||
)
|
||||
if not sea:
|
||||
optional_pending = [
|
||||
optional_pending: list[tuple[str, str, str]] = []
|
||||
if not sea and required_items:
|
||||
# 已有必填要补时,空运非必填一并列出;起运地排在目的地前面。
|
||||
if not known(src, "起运港"):
|
||||
optional_pending.append(
|
||||
("起运港", "起运地", _AIR_EXAMPLES["起运港"])
|
||||
)
|
||||
optional_pending.extend(
|
||||
(key, label, example)
|
||||
for key, label, example in _AIR_OPTIONAL_CLARIFY
|
||||
if key != "起运港" and not known(src, key)
|
||||
]
|
||||
for key, label, example in optional_pending:
|
||||
any_missing = True
|
||||
seq += 1
|
||||
lines.append(_pending_field_block(seq, label, example, optional=True))
|
||||
if not any_missing:
|
||||
)
|
||||
if not required_items:
|
||||
return ""
|
||||
lines.append("")
|
||||
lines.append(CLARIFY_PASTE_HINT)
|
||||
seq = 0
|
||||
origin_block = ""
|
||||
other_optional_blocks: list[str] = []
|
||||
for key, label, example in optional_pending:
|
||||
if key == "起运港":
|
||||
seq += 1
|
||||
origin_block = _pending_field_block(
|
||||
seq, label, example, optional=True
|
||||
)
|
||||
# 起运地(非必填)紧挨目的地上面:有则先写起运地,再写必填缺口。
|
||||
if origin_block:
|
||||
lines.append(origin_block)
|
||||
for _key, label, example, unit in required_items:
|
||||
seq += 1
|
||||
lines.append(
|
||||
_pending_field_block(seq, label, example, unit=unit)
|
||||
)
|
||||
for key, label, example in optional_pending:
|
||||
if key == "起运港":
|
||||
continue
|
||||
seq += 1
|
||||
other_optional_blocks.append(
|
||||
_pending_field_block(seq, label, example, optional=True)
|
||||
)
|
||||
lines.extend(other_optional_blocks)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -3179,19 +3196,21 @@ def air_no_tms_quote_to_lock(work_order_no: str) -> str:
|
||||
|
||||
def air_lock_need_params(missing=None) -> str:
|
||||
"""
|
||||
舱位编号、航司、航班号没齐时,只请航线补全。
|
||||
舱位编号、航司、航班号(及报价日期空时)没齐时,只请航线补全。
|
||||
|
||||
这时还没锁舱,不能说 TMS 锁舱失败,也不能通知 IT。
|
||||
缺哪项就点哪项;三项都缺时是产品指定的那一句。
|
||||
缺哪项就点哪项;报价日期也空时示例末尾带日期。
|
||||
"""
|
||||
labels = [str(x).strip() for x in (missing or []) if str(x).strip()]
|
||||
if not labels:
|
||||
labels = ["舱位编号", "航司", "航班号"]
|
||||
joined = "、".join(labels)
|
||||
return (
|
||||
f"锁舱参数不完整,缺少:{joined}。"
|
||||
"请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。"
|
||||
)
|
||||
# 报价日期空:产品指定示例带日期,避免航线补完舱位三项后再被单独拦一次。
|
||||
if "报价日期" in labels:
|
||||
example = "AIR-OPT01,南航,航班CZ327,2026-10-18"
|
||||
else:
|
||||
example = "AIR-OPT01,南航,航班CZ327"
|
||||
return f"锁舱参数不完整,缺少:{joined}。请补充后再锁,例如:{example}。"
|
||||
|
||||
|
||||
def air_lock_need_quote(work_order_no: str) -> str:
|
||||
|
||||
@@ -603,10 +603,27 @@ def _staff_rows(book: Any, seg: Segment) -> list[dict[str, Any]]:
|
||||
return []
|
||||
return [dict(x) for x in (lister(role_code="land") or []) if isinstance(x, dict)]
|
||||
if seg.mode == "AIR":
|
||||
from agent.policy.deal_followup import air_staff_route_matches
|
||||
|
||||
lister = getattr(book, "list_staff_by_role", None)
|
||||
if not callable(lister):
|
||||
return []
|
||||
return [dict(x) for x in (lister(role_code="air") or []) if isinstance(x, dict)]
|
||||
rows = [dict(x) for x in (lister(role_code="air") or []) if isinstance(x, dict)]
|
||||
hits = [
|
||||
r
|
||||
for r in rows
|
||||
if air_staff_route_matches(routes=str(r.get("routes") or ""), facts=seg.facts)
|
||||
]
|
||||
if not hits:
|
||||
hits = [
|
||||
r
|
||||
for r in rows
|
||||
if any(
|
||||
t.strip() in {"空运全线", "全线"}
|
||||
for t in str(r.get("routes") or "").replace(",", ",").split(",")
|
||||
)
|
||||
]
|
||||
return hits
|
||||
except Exception:
|
||||
logger.exception("多段激活匹配产品失败 mode=%s", seg.mode)
|
||||
return []
|
||||
|
||||
@@ -87,7 +87,10 @@ def _reply_apply_messages(result, reply) -> None:
|
||||
|
||||
|
||||
def _emit_private_card(flow: Any, sess: Any, reply) -> None:
|
||||
"""新价格明细卡:海运/陆运带拉群,空运只带跳过协同。"""
|
||||
"""新价格明细卡:海运/陆运带拉群,空运只带跳过协同。
|
||||
|
||||
空运货物信息用工单 facts(起运地/品名等);不传会整段变成「-」。
|
||||
"""
|
||||
line = (sess.business_line or "").upper()
|
||||
no = sess.work_order_no
|
||||
quote = dict(sess.quote or {})
|
||||
@@ -95,6 +98,7 @@ def _emit_private_card(flow: Any, sess: Any, reply) -> None:
|
||||
quote["business_line"] = line
|
||||
first = getattr(sess, "first_or_same", "first") or "first"
|
||||
history = getattr(sess, "history_work_order_no", "") or ""
|
||||
facts = {str(k): str(v) for k, v in dict(getattr(sess, "facts", None) or {}).items()}
|
||||
if line in {"SEA", "LAND"} and hasattr(flow, "_emit_sea_hit"):
|
||||
sess.allowed = (copy.BTN_PULL_COLLAB, copy.BTN_SKIP_COLLAB)
|
||||
flow._emit_sea_hit(
|
||||
@@ -114,6 +118,7 @@ def _emit_private_card(flow: Any, sess: Any, reply) -> None:
|
||||
reply,
|
||||
work_order_no=no,
|
||||
quote=quote,
|
||||
facts=facts,
|
||||
first_or_same=first,
|
||||
history_work_order_no=history,
|
||||
status="已报价",
|
||||
@@ -210,6 +215,13 @@ def try_private_adjust(
|
||||
sess.quote = persist
|
||||
sess.status = "已报价"
|
||||
sess.phase = "wait_collab"
|
||||
# 必须先写回允许点的按钮再存书签。若等 emit 后再改 allowed,
|
||||
# Worker 重启后会读到空 allowed,销售点「生成报价单」会被静默丢掉。
|
||||
line = (str(getattr(sess, "business_line", "") or "")).upper()
|
||||
if line in {"SEA", "LAND"}:
|
||||
sess.allowed = (copy.BTN_PULL_COLLAB, copy.BTN_SKIP_COLLAB)
|
||||
else:
|
||||
sess.allowed = (copy.BTN_SKIP_COLLAB,)
|
||||
sess.wait_version += 1
|
||||
flow._save(sess)
|
||||
_emit_private_card(flow, sess, reply)
|
||||
|
||||
@@ -1200,11 +1200,132 @@ def handle_group_file(
|
||||
return "file_archived"
|
||||
|
||||
|
||||
def _live_appchat_member_ids(client: Any, chat_id: str) -> list[str]:
|
||||
"""
|
||||
读企微应用群当前成员。主账名单过期时必须靠这个清人。
|
||||
|
||||
失败回空列表,不抛。不写主账。
|
||||
"""
|
||||
raw = (chat_id or "").strip()
|
||||
if not raw:
|
||||
return []
|
||||
info_fn = getattr(client, "_appchat_info", None)
|
||||
if not callable(info_fn):
|
||||
return []
|
||||
try:
|
||||
info = info_fn(chat_id=raw) or {}
|
||||
except Exception:
|
||||
logger.exception("读应用群成员失败 chat=%s", raw)
|
||||
return []
|
||||
return [
|
||||
str(x).strip()
|
||||
for x in (info.get("userlist") or [])
|
||||
if str(x).strip()
|
||||
]
|
||||
|
||||
|
||||
def _merge_dismiss_userids(*groups: list[str]) -> list[str]:
|
||||
"""合并待移出名单,去重保序。"""
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for group in groups:
|
||||
for uid in group or []:
|
||||
u = str(uid or "").strip()
|
||||
if not u or u in seen:
|
||||
continue
|
||||
seen.add(u)
|
||||
out.append(u)
|
||||
return out
|
||||
|
||||
|
||||
def dissolve_orphan_group(
|
||||
*,
|
||||
flow: Any,
|
||||
chat_id: str,
|
||||
sender_id: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
主账对不上工单时仍解散应用群。
|
||||
|
||||
老群、迁库丢绑定、虚拟转人工未写主账都会走到这里。
|
||||
以企微当前成员为准移人;群名像工单号则尽量标协同结束。
|
||||
不改六态。先回话再移人。
|
||||
"""
|
||||
room = (chat_id or "").strip()
|
||||
if not room:
|
||||
return "group_ignore"
|
||||
client = group_client_of(flow)
|
||||
info_fn = getattr(client, "_appchat_info", None)
|
||||
info: dict[str, Any] = {}
|
||||
if callable(info_fn):
|
||||
try:
|
||||
info = info_fn(chat_id=room) or {}
|
||||
except Exception:
|
||||
logger.exception("孤儿群读成员失败 chat=%s", room)
|
||||
info = {}
|
||||
members = _merge_dismiss_userids(
|
||||
list(info.get("userlist") or []),
|
||||
[sender_id] if (sender_id or "").strip() else [],
|
||||
)
|
||||
name = str(info.get("name") or "").strip()
|
||||
no = ""
|
||||
try:
|
||||
from agent.policy import inquiry_copy as _copy
|
||||
|
||||
hit = str(_copy.extract_work_order_no(name) or "").strip()
|
||||
if hit:
|
||||
no = hit
|
||||
elif name.upper().startswith(("WO", "VT")):
|
||||
no = name.strip()
|
||||
except Exception:
|
||||
no = name if name.upper().startswith(("WO", "VT")) else ""
|
||||
display_no = no or name or "-"
|
||||
send_group_text(client, room, copy.group_dissolved(work_order_no=display_no))
|
||||
dismiss = getattr(client, "dismiss_group", None)
|
||||
dismiss_ok = False
|
||||
if callable(dismiss) and members:
|
||||
out = dismiss(chat_id=room, userids=members) or {}
|
||||
dismiss_ok = bool(out.get("ok"))
|
||||
if not dismiss_ok:
|
||||
logger.warning(
|
||||
"孤儿群移人失败 chat=%s err=%s members=%s",
|
||||
room,
|
||||
out.get("error"),
|
||||
len(members),
|
||||
)
|
||||
elif not members:
|
||||
logger.warning("孤儿群无成员可移 chat=%s name=%s", room, name or "-")
|
||||
try:
|
||||
from agent.channel.wshoto.rooms import unwatch_collab_room
|
||||
|
||||
unwatch_collab_room(room)
|
||||
except Exception:
|
||||
logger.exception("孤儿群取消存档监听失败 chat=%s", room)
|
||||
if no:
|
||||
ender = getattr(getattr(flow, "ledger", None), "end_collab_group", None)
|
||||
if callable(ender):
|
||||
try:
|
||||
ender(work_order_no=no)
|
||||
except Exception:
|
||||
logger.exception("孤儿群标协同结束失败 wo=%s", no)
|
||||
logger.info(
|
||||
"orphan_dissolve chat=%s name=%s wo=%s members=%s dismiss_ok=%s by=%s",
|
||||
room,
|
||||
name or "-",
|
||||
no or "-",
|
||||
len(members),
|
||||
dismiss_ok,
|
||||
(sender_id or "")[:32],
|
||||
)
|
||||
return "group_dissolved"
|
||||
|
||||
|
||||
def dissolve_group(*, flow: Any, ticket: object, sess: Any = None) -> str:
|
||||
"""
|
||||
解散应用群,这单协同结束,不改六态。
|
||||
|
||||
先发「群已解散」再移人:移完人销售可能已不在群,收不到确认。
|
||||
移人名单 = 主账/书签成员 ∪ 企微当前成员,避免名单过期清不干净。
|
||||
"""
|
||||
view = ticket_view(ticket)
|
||||
no = ticket_no(ticket)
|
||||
@@ -1213,11 +1334,22 @@ def dissolve_group(*, flow: Any, ticket: object, sess: Any = None) -> str:
|
||||
members = list(view.get("collab_member_ids") or view.get("memberIds") or [])
|
||||
if sess is not None:
|
||||
members = list(sess.invited_userids or members)
|
||||
live = _live_appchat_member_ids(client, chat_id)
|
||||
members = _merge_dismiss_userids(members, live)
|
||||
# 先回话再移人,避免销售被移出后看不到确认。
|
||||
send_group_text(client, chat_id, copy.group_dissolved(work_order_no=no))
|
||||
dismiss = getattr(client, "dismiss_group", None)
|
||||
if callable(dismiss) and chat_id:
|
||||
dismiss(chat_id=chat_id, userids=members)
|
||||
if callable(dismiss) and chat_id and members:
|
||||
out = dismiss(chat_id=chat_id, userids=members) or {}
|
||||
if not out.get("ok"):
|
||||
logger.warning(
|
||||
"解散群移人失败 chat=%s wo=%s err=%s",
|
||||
chat_id,
|
||||
no,
|
||||
out.get("error"),
|
||||
)
|
||||
elif chat_id and not members:
|
||||
logger.warning("解散群无成员可移 chat=%s wo=%s", chat_id, no)
|
||||
if chat_id:
|
||||
try:
|
||||
from agent.channel.wshoto.rooms import unwatch_collab_room
|
||||
|
||||
@@ -96,6 +96,18 @@ _CONFIRM_CHOICE = {
|
||||
"沿用tms",
|
||||
"沿用tms报价",
|
||||
}
|
||||
# 解散相近说法。禁止只认整句「解散群」,模型失败时也要能清群。
|
||||
_DISSOLVE_HINTS = (
|
||||
"解散群",
|
||||
"解散这个群",
|
||||
"解散该群",
|
||||
"把群解散",
|
||||
"这个群解散",
|
||||
"群解散吧",
|
||||
"解散吧",
|
||||
"清群",
|
||||
"散群",
|
||||
)
|
||||
_CONFIRM_HINTS = (
|
||||
"确认报价",
|
||||
"确认该报价",
|
||||
@@ -195,6 +207,10 @@ def classify_group_text(
|
||||
exact = _exact_tms_choice(raw)
|
||||
if exact:
|
||||
return exact
|
||||
# 先认解散口令:避免模型关/失败时「解散群」掉进 other 静默无反应。
|
||||
hinted_dissolve = _hint_dissolve(raw)
|
||||
if hinted_dissolve:
|
||||
return hinted_dissolve
|
||||
try:
|
||||
from agent.config import get_settings
|
||||
|
||||
@@ -303,6 +319,22 @@ def _hint_deal_outcome(text: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _hint_dissolve(text: str) -> str:
|
||||
"""
|
||||
要把协同群解散。口令不写死整句「解散群」。
|
||||
|
||||
含金额的报价句不走这里(上层已先抽价)。整句很短且像清群时认。
|
||||
"""
|
||||
blob = _norm_intent_text(text)
|
||||
if not blob:
|
||||
return ""
|
||||
if any(x in blob for x in _DISSOLVE_HINTS):
|
||||
return INTENT_DISSOLVE
|
||||
if "解散" in blob and "群" in blob:
|
||||
return INTENT_DISSOLVE
|
||||
return ""
|
||||
|
||||
|
||||
def _classify_with_model(text: str, settings) -> str:
|
||||
"""DeepSeek 只回一个意图词。失败空串。"""
|
||||
from agent.llm.http_client import LlmHttpClient
|
||||
|
||||
@@ -146,7 +146,6 @@ _LAND_COLLAB_BY_PAIR: dict[tuple[str, str], tuple[str, ...]] = {
|
||||
("中港整车", "中港/中亚/中欧"): (
|
||||
"客户名称",
|
||||
"HS编码",
|
||||
"车型/数量",
|
||||
"贸易条款",
|
||||
"货值",
|
||||
"包装方式",
|
||||
@@ -769,21 +768,29 @@ def _pricing_required_keys(
|
||||
land_subtype: str,
|
||||
facts: dict[str, str] | None = None,
|
||||
) -> list[str]:
|
||||
"""当前激活的首次查价内部键列表。中港整车/零担是两个运输类型。"""
|
||||
"""
|
||||
当前激活的首次查价内部键列表。中港整车/零担是两个运输类型。
|
||||
|
||||
合同里偶有「车型数量」等别名键,这里折成 normalize_facts 后的内部键,
|
||||
避免销售已填「车型/数量」仍被判缺。
|
||||
"""
|
||||
_ = facts
|
||||
line = (business_line or "").strip().upper()
|
||||
pricing = contract.get("pricing_required") or {}
|
||||
raw: list[str] = []
|
||||
if line == "SEA":
|
||||
return list((pricing.get("SEA") or {}).get("always") or [])
|
||||
if line == "AIR":
|
||||
return list((pricing.get("AIR") or {}).get("always") or [])
|
||||
if line == "LAND":
|
||||
raw = list((pricing.get("SEA") or {}).get("always") or [])
|
||||
elif line == "AIR":
|
||||
raw = list((pricing.get("AIR") or {}).get("always") or [])
|
||||
# 空运选填键不得进首次查价必填:合同与 AIR_OPTIONAL_FIELDS 对齐,避免再逼销售补起运地。
|
||||
optional_air = {key for key, _label in AIR_OPTIONAL_FIELDS} | {"报价日期"}
|
||||
raw = [k for k in raw if k not in optional_air]
|
||||
elif line == "LAND":
|
||||
land = pricing.get("LAND") or {}
|
||||
block = land.get(land_subtype) or {}
|
||||
if not block:
|
||||
return []
|
||||
return list(block.get("always") or [])
|
||||
return []
|
||||
if block:
|
||||
raw = list(block.get("always") or [])
|
||||
return [_ALIASES.get(k, k) for k in raw]
|
||||
|
||||
|
||||
_PLACE_KEYS = ("起运港", "目的港")
|
||||
|
||||
@@ -23,9 +23,15 @@ _PERCENT_UP = re.compile(
|
||||
r"(上调|上涨|上升|上浮|提高|提升|加|涨|升)\s*(\d+(?:\.\d+)?)\s*%"
|
||||
)
|
||||
_PERCENT_DOWN = re.compile(r"(下调|下降|降低|减|降)\s*(\d+(?:\.\d+)?)\s*%")
|
||||
_TARGET = re.compile(
|
||||
r"(提升到|减少到|上调到|下调到|加到|减到|调到|改为|改成)\s*(\d+(?:\.\d+)?)"
|
||||
# 「增加到/调整到/涨到 110%」= 改到原价的 110%,不是上调 110%。必须先于绝对金额与相对上调。
|
||||
_TO_VERBS = (
|
||||
"增加到|提高到|提升到|减少到|上调到|下调到|调整到|上浮到|上涨到|上升到|"
|
||||
"涨到|升到|加到|减到|调到|增至|变为|变成|改为|改成|调整为|调整成"
|
||||
)
|
||||
_PERCENT_TO = re.compile(rf"({_TO_VERBS})\s*(\d+(?:\.\d+)?)\s*%")
|
||||
# (?!\d) 防止「增加到110%」回退成「加到11」;(?!\s*%) 把带百分号的留给 _PERCENT_TO。
|
||||
_TARGET = re.compile(rf"({_TO_VERBS})\s*(\d+(?:\.\d+)?)(?!\d)(?!\s*%)")
|
||||
_DAO_PERCENT = re.compile(rf"(?:{_TO_VERBS})\s*(\d+(?:\.\d+)?)\s*%")
|
||||
_SPLIT = re.compile(r"[,,;;。!!??\n]|和(?=\S)")
|
||||
_FEE_TOKEN = re.compile(r"([\u4e00-\u9fffA-Za-z0-9]{1,16}费)")
|
||||
_TOTAL_WORDS = ("费用合计", "合计", "总价", "汇总价", "总销售价", "总成本价")
|
||||
@@ -432,9 +438,74 @@ def row_currency(row: dict[str, Any] | None, fallback: str = "") -> str:
|
||||
return prefix or (fallback or "").strip().upper().replace("RMB", "CNY")
|
||||
|
||||
|
||||
def _parse_qty(raw: Any) -> Optional[float]:
|
||||
"""数量/计费重转浮点;读不了返回 None。"""
|
||||
if raw in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return float(str(raw).replace(",", "").strip())
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _row_or_quote_qty(row: dict[str, Any], quote: dict[str, Any] | None = None) -> Optional[float]:
|
||||
"""
|
||||
费用行数量;行上没有时用工单/舱位计费重兜底。
|
||||
|
||||
空运调价常只有 unitPrice+amount,quantity 在 chargeableWeightKg。
|
||||
"""
|
||||
qty = _parse_qty(row.get("quantity"))
|
||||
if qty is not None:
|
||||
return qty
|
||||
qty = _parse_qty(row.get("chargeQuantity"))
|
||||
if qty is not None:
|
||||
return qty
|
||||
src = dict(quote or {})
|
||||
for key in ("chargeableWeightKg", "chargeable_weight_kg", "chargeableWeight"):
|
||||
qty = _parse_qty(src.get(key))
|
||||
if qty is not None:
|
||||
return qty
|
||||
opt = src.get("selected_air_option") or src.get("selectedAirOption")
|
||||
if isinstance(opt, str) and opt.strip():
|
||||
try:
|
||||
opt = json.loads(opt)
|
||||
except json.JSONDecodeError:
|
||||
opt = None
|
||||
if isinstance(opt, dict):
|
||||
for key in ("chargeableWeightKg", "chargeable_weight_kg", "chargeableWeight"):
|
||||
qty = _parse_qty(opt.get(key))
|
||||
if qty is not None:
|
||||
return qty
|
||||
return None
|
||||
|
||||
|
||||
def _apply_amount(row: dict[str, Any], amount: str) -> None:
|
||||
"""
|
||||
写入行金额;有数量时单价=金额÷数量,禁止把行合计原样塞进单价。
|
||||
|
||||
空运 KG:调价后金额是单价×计费重,展示读 unit_price;无数量(按票等)才单价=金额。
|
||||
"""
|
||||
row["amount"] = amount
|
||||
row["unit_price"] = amount
|
||||
qty_raw = row.get("quantity")
|
||||
if qty_raw in (None, ""):
|
||||
qty_raw = row.get("chargeQuantity")
|
||||
qty_num: Optional[float] = None
|
||||
if qty_raw not in (None, ""):
|
||||
try:
|
||||
qty_num = float(str(qty_raw).replace(",", "").strip())
|
||||
except ValueError:
|
||||
qty_num = None
|
||||
amt_num, _ = parse_money(amount)
|
||||
if qty_num is not None and abs(qty_num) > 1e-9 and amt_num is not None:
|
||||
# 有计费数量:单价从行金额反推,避免 3385.8 被当成「每KG」
|
||||
unit_plain = format_money(amt_num / qty_num, sample=str(row.get("unit_price") or row.get("unitPrice") or ""))
|
||||
unit_plain = _plain_amount(unit_plain)
|
||||
row["unit_price"] = unit_plain
|
||||
row["unitPrice"] = unit_plain
|
||||
else:
|
||||
row["unit_price"] = amount
|
||||
if row.get("unitPrice") not in (None, ""):
|
||||
row["unitPrice"] = amount
|
||||
for key in ("salePrice", "price", "value"):
|
||||
if row.get(key) not in (None, ""):
|
||||
row[key] = amount
|
||||
@@ -518,7 +589,12 @@ def text_has_total_only_adjust(text: str) -> bool:
|
||||
「总销售价改为180」仍是销售调价。点了「空运费」「报关费」则不是。不打模型。
|
||||
"""
|
||||
raw = text or ""
|
||||
if not (_TARGET.search(raw) or _PERCENT_UP.search(raw) or _PERCENT_DOWN.search(raw)):
|
||||
if not (
|
||||
_TARGET.search(raw)
|
||||
or _PERCENT_TO.search(raw)
|
||||
or _PERCENT_UP.search(raw)
|
||||
or _PERCENT_DOWN.search(raw)
|
||||
):
|
||||
return False
|
||||
if not any(word in raw for word in _TOTAL_WORDS) and "报价" not in raw:
|
||||
return False
|
||||
@@ -546,27 +622,30 @@ def extract_adjust(
|
||||
本地回退只给无网/模型失败用,不是正式识别。
|
||||
allow_model=False 时禁止打模型。
|
||||
"""
|
||||
raw = (text or "").strip()
|
||||
if injected is not None:
|
||||
if isinstance(injected, AdjustExtract):
|
||||
return injected
|
||||
return _coerce_dao_percent(raw, injected)
|
||||
items = []
|
||||
for raw in injected.get("items") or []:
|
||||
if not isinstance(raw, dict):
|
||||
for row in injected.get("items") or []:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
items.append(
|
||||
AdjustItem(
|
||||
fee_name=str(raw.get("fee_name") or "").strip(),
|
||||
kind=str(raw.get("kind") or "percent"),
|
||||
direction=str(raw.get("direction") or "up"),
|
||||
value=float(raw.get("value") or 0),
|
||||
fee_name=str(row.get("fee_name") or "").strip(),
|
||||
kind=str(row.get("kind") or "percent"),
|
||||
direction=str(row.get("direction") or "up"),
|
||||
value=float(row.get("value") or 0),
|
||||
)
|
||||
)
|
||||
return AdjustExtract(
|
||||
is_adjust=bool(injected.get("is_adjust")),
|
||||
items=items,
|
||||
wants_total=bool(injected.get("wants_total")),
|
||||
return _coerce_dao_percent(
|
||||
raw,
|
||||
AdjustExtract(
|
||||
is_adjust=bool(injected.get("is_adjust")),
|
||||
items=items,
|
||||
wants_total=bool(injected.get("wants_total")),
|
||||
),
|
||||
)
|
||||
raw = (text or "").strip()
|
||||
if not raw:
|
||||
return AdjustExtract(is_adjust=False)
|
||||
names = [str(x).strip() for x in (fee_names or []) if str(x).strip()]
|
||||
@@ -617,11 +696,16 @@ def extract_adjust(
|
||||
items=merged,
|
||||
wants_total=bool(modeled.wants_total),
|
||||
)
|
||||
return modeled
|
||||
return _coerce_dao_percent(raw, modeled)
|
||||
except Exception:
|
||||
logger.exception("调价模型抽取失败,回退本地")
|
||||
total_hit = any(w in raw for w in _TOTAL_WORDS)
|
||||
has_op = bool(_PERCENT_UP.search(raw) or _PERCENT_DOWN.search(raw) or _TARGET.search(raw))
|
||||
has_op = bool(
|
||||
_PERCENT_UP.search(raw)
|
||||
or _PERCENT_DOWN.search(raw)
|
||||
or _PERCENT_TO.search(raw)
|
||||
or _TARGET.search(raw)
|
||||
)
|
||||
fee_in_text = [n for n in names_sorted if n and n in raw]
|
||||
if total_hit and has_op and not fee_in_text:
|
||||
op = _item_from_chunk(raw, "")
|
||||
@@ -634,7 +718,9 @@ def extract_adjust(
|
||||
item = _item_from_chunk(raw, "")
|
||||
if item:
|
||||
items.append(item)
|
||||
return AdjustExtract(is_adjust=bool(items or has_op), items=items)
|
||||
return _coerce_dao_percent(
|
||||
raw, AdjustExtract(is_adjust=bool(items or has_op), items=items)
|
||||
)
|
||||
|
||||
|
||||
def _local_fee_items(raw: str, names_sorted: list[str]) -> list[AdjustItem]:
|
||||
@@ -664,10 +750,40 @@ def _local_fee_items(raw: str, names_sorted: list[str]) -> list[AdjustItem]:
|
||||
return items
|
||||
|
||||
|
||||
def _coerce_dao_percent(text: str, extract: AdjustExtract) -> AdjustExtract:
|
||||
"""
|
||||
「增加到110%」纠正为 percent/set。
|
||||
|
||||
模型常抽成 percent/up;本地旧正则会抽成绝对金额 target。两种都不能当上调。
|
||||
"""
|
||||
hit = _DAO_PERCENT.search(text or "")
|
||||
if not hit or not extract.items:
|
||||
return extract
|
||||
val = float(hit.group(1))
|
||||
fixed: list[AdjustItem] = []
|
||||
changed = False
|
||||
for item in extract.items:
|
||||
same_num = abs(float(item.value) - val) < 1e-9
|
||||
if item.kind == "percent" and item.direction in {"up", "down"} and same_num:
|
||||
fixed.append(AdjustItem(item.fee_name, "percent", "set", val))
|
||||
changed = True
|
||||
elif item.kind == "target" and item.direction == "set" and same_num:
|
||||
fixed.append(AdjustItem(item.fee_name, "percent", "set", val))
|
||||
changed = True
|
||||
else:
|
||||
fixed.append(item)
|
||||
if not changed:
|
||||
return extract
|
||||
return AdjustExtract(is_adjust=extract.is_adjust, items=fixed, wants_total=extract.wants_total)
|
||||
|
||||
|
||||
def _item_from_chunk(chunk: str, fee_name: str) -> Optional[AdjustItem]:
|
||||
to_pct = _PERCENT_TO.search(chunk)
|
||||
up = _PERCENT_UP.search(chunk)
|
||||
down = _PERCENT_DOWN.search(chunk)
|
||||
target = _TARGET.search(chunk)
|
||||
if to_pct:
|
||||
return AdjustItem(fee_name=fee_name, kind="percent", direction="set", value=float(to_pct.group(2)))
|
||||
if target:
|
||||
return AdjustItem(fee_name=fee_name, kind="target", direction="set", value=float(target.group(2)))
|
||||
if up:
|
||||
@@ -1167,6 +1283,10 @@ def _apply_total_only(
|
||||
if item.kind == "target":
|
||||
new_amt = float(item.value)
|
||||
how = f"改到 {item.value:g}"
|
||||
elif item.kind == "percent" and item.direction == "set":
|
||||
# 「费用合计增加到110%」= 改到原合计的 110%,不是上调 110%
|
||||
new_amt = old_total * (float(item.value) / 100.0)
|
||||
how = f"调到 {item.value:g}%"
|
||||
elif item.direction == "down":
|
||||
new_amt = old_total * (1 - float(item.value) / 100.0)
|
||||
how = f"下调 {item.value:g}%"
|
||||
@@ -1311,15 +1431,45 @@ def apply_adjust(
|
||||
if old_amt is None:
|
||||
continue
|
||||
line_ccy = row_currency(row, header) or prefix
|
||||
if item.kind == "target":
|
||||
old_up, _ = parse_money(row.get("unit_price") or row.get("unitPrice"))
|
||||
qty_num = _row_or_quote_qty(row, src)
|
||||
# 有单价且有计费重:比例调价按「单价」缩放,再算行金额=单价×数量。
|
||||
# 否则会把行合计×110% 后整笔写进单价(15→42633.53)。
|
||||
scale_by_unit = (
|
||||
item.kind == "percent"
|
||||
and old_up is not None
|
||||
and qty_num is not None
|
||||
and abs(qty_num) > 1e-9
|
||||
and abs(old_up - old_amt) > 1e-6
|
||||
)
|
||||
if scale_by_unit:
|
||||
if item.direction == "set":
|
||||
new_up = old_up * (float(item.value) / 100.0)
|
||||
how = f"调到 {item.value:g}%"
|
||||
elif item.direction == "down":
|
||||
new_up = old_up * (1 - float(item.value) / 100.0)
|
||||
how = f"下调 {item.value:g}%"
|
||||
else:
|
||||
new_up = old_up * (1 + float(item.value) / 100.0)
|
||||
how = f"上调 {item.value:g}%"
|
||||
new_amt = new_up * qty_num
|
||||
if row.get("quantity") in (None, ""):
|
||||
row["quantity"] = f"{qty_num:.10g}"
|
||||
elif item.kind == "target":
|
||||
new_amt = float(item.value)
|
||||
how = f"改到 {item.value:g}"
|
||||
elif item.kind == "percent" and item.direction == "set":
|
||||
# 「运输费增加到110%」= 原价 × 110%,不是上调 110%(×210%)
|
||||
new_amt = old_amt * (float(item.value) / 100.0)
|
||||
how = f"调到 {item.value:g}%"
|
||||
elif item.direction == "down":
|
||||
new_amt = old_amt * (1 - float(item.value) / 100.0)
|
||||
how = f"下调 {item.value:g}%"
|
||||
else:
|
||||
new_amt = old_amt * (1 + float(item.value) / 100.0)
|
||||
how = f"上调 {item.value:g}%"
|
||||
if qty_num is not None and row.get("quantity") in (None, ""):
|
||||
row["quantity"] = f"{qty_num:.10g}"
|
||||
_apply_amount(row, format_money(new_amt, sample=str(row.get("amount") or ""), currency=line_ccy or prefix))
|
||||
rows[idx] = row
|
||||
if not any_applied:
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
本文件职责:核对卡通过后、建单查价前,确认当前运输类型的货物字段能拼出查价正文。
|
||||
带去:运输类型、线路类别、始发/目的/品名/报价日期,以及该类型的体积重量或车型/数量;通关口岸有值才带。
|
||||
出站类型/线路译成 TMS 用的值,线路键名 routeCategory。工单与卡片仍用销售中文。
|
||||
中港整车、中港零担/集拼出站类型都只写「中港」。中港整车不带车型/数量。
|
||||
中港整车、中港零担/集拼出站类型按细类原文传。中港整车带车型/数量;仅中港零担/集拼不带。
|
||||
禁止:把运输分类放进出站;直连 TMS;改六态。
|
||||
线程:纯函数。
|
||||
"""
|
||||
@@ -20,8 +20,10 @@ from agent.schema.field_validate import apply_quote_date_default, normalize_fact
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LCL_TYPES = frozenset({"国内运输拼车", "跨境集拼"})
|
||||
FTL_TYPES = frozenset({"国内运输整车", "跨境整车", "中港零担/集拼"})
|
||||
HK_FTL_TYPE = "中港整车"
|
||||
FTL_TYPES = frozenset({"国内运输整车", "跨境整车", "中港整车"})
|
||||
# 仅中港零担/集拼:询价不采车型/数量,出站也不带。
|
||||
HK_NO_VEHICLE_TYPES = frozenset({"中港零担/集拼"})
|
||||
HK_FTL_TYPE = "中港整车" # 兼容旧调用名;现与国内/跨境整车同属 FTL_TYPES
|
||||
CUSTOMS_TYPES = frozenset({"跨境集拼", "跨境整车", "中港整车", "中港零担/集拼"})
|
||||
|
||||
_TYPE_ENUM = {
|
||||
@@ -29,8 +31,8 @@ _TYPE_ENUM = {
|
||||
"国内运输整车": "DOMESTIC_FTL",
|
||||
"跨境集拼": "CROSS_BORDER_LTL",
|
||||
"跨境整车": "CROSS_BORDER_FTL",
|
||||
"中港整车": "中港",
|
||||
"中港零担/集拼": "中港",
|
||||
"中港整车": "中港整车",
|
||||
"中港零担/集拼": "中港零担/集拼",
|
||||
}
|
||||
_ROUTE_ENUM = {
|
||||
"国内长途/零担": "DOMESTIC_LONG_LTL",
|
||||
@@ -45,7 +47,7 @@ _TAIL_QTY = re.compile(r"^(.*?)[\s,,]+(\d+)\s*(台|辆)?$")
|
||||
|
||||
|
||||
def tms_land_type_code(raw: str) -> str:
|
||||
"""销售中文运输类型 → 查价 landTransportType。中港两类都是「中港」。对不上空串。"""
|
||||
"""销售中文运输类型 → 查价 landTransportType。中港两类按细类原文。对不上空串。"""
|
||||
text = (raw or "").strip()
|
||||
if text in _TYPE_ENUM:
|
||||
return _TYPE_ENUM[text]
|
||||
@@ -140,7 +142,8 @@ def assemble_land_query(facts: dict[str, Any] | None) -> dict[str, Any]:
|
||||
missing.append("毛重")
|
||||
else:
|
||||
outbound["weightKg"] = wgt
|
||||
elif land_type == HK_FTL_TYPE:
|
||||
elif land_type in HK_NO_VEHICLE_TYPES:
|
||||
# 中港零担/集拼:不要车型/数量,也不改要体积毛重
|
||||
pass
|
||||
elif land_type in FTL_TYPES:
|
||||
combo = (
|
||||
|
||||
@@ -12,6 +12,7 @@ from agent.policy.deal_followup import (
|
||||
SHANGHAI,
|
||||
add_workday_hours,
|
||||
air_route_keys,
|
||||
air_staff_route_matches,
|
||||
is_workday,
|
||||
leader_matches,
|
||||
next_followup_at,
|
||||
@@ -193,5 +194,37 @@ class TestLeaderMatch(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestAirStaffRouteMatches(unittest.TestCase):
|
||||
"""群 @ 航线:按负责线路过滤,不要求 isLeader。"""
|
||||
|
||||
def test_ehu_ord_pair_and_all_line(self) -> None:
|
||||
facts = {"起运港": "EHU", "目的港": "ORD"}
|
||||
self.assertTrue(air_staff_route_matches(routes="EHU-ORD", facts=facts))
|
||||
self.assertTrue(air_staff_route_matches(routes="空运全线", facts=facts))
|
||||
self.assertFalse(air_staff_route_matches(routes="CGO-KUL,SZX-KUL", facts=facts))
|
||||
self.assertFalse(air_staff_route_matches(routes="HKG-KUL,NKG-KUL", facts=facts))
|
||||
|
||||
def test_dest_only_matches_pair_suffix(self) -> None:
|
||||
facts = {"目的港": "KUL"}
|
||||
self.assertTrue(air_staff_route_matches(routes="CGO-KUL,SZX-KUL", facts=facts))
|
||||
self.assertTrue(air_staff_route_matches(routes="HKG-KUL,NKG-KUL", facts=facts))
|
||||
self.assertFalse(air_staff_route_matches(routes="EHU-ORD", facts=facts))
|
||||
self.assertFalse(air_staff_route_matches(routes="CGO-BLR,CGO-MAA", facts=facts))
|
||||
|
||||
def test_non_leader_still_matches(self) -> None:
|
||||
# 群 @ 与成交推负责人不同:非负责人只要线路对也要 @
|
||||
facts = {"起运港": "EHU", "目的港": "ORD"}
|
||||
self.assertTrue(air_staff_route_matches(routes="EHU-ORD", facts=facts))
|
||||
self.assertFalse(
|
||||
leader_matches(
|
||||
role_code="air",
|
||||
routes="EHU-ORD",
|
||||
is_leader=False,
|
||||
business_line="AIR",
|
||||
facts=facts,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -364,6 +364,53 @@ class TestDealOutcome(unittest.TestCase):
|
||||
self.assertEqual(self.ledger.get_ticket(work_order_no=self.wo).lost_reason, "价格高")
|
||||
self.assertTrue(any(t == "已记下未成交原因:价格高。" for t in replies))
|
||||
|
||||
def test_orphan_lost_wait_clears_when_ticket_missing(self) -> None:
|
||||
"""清数后主账无单:等原因书签必须丢掉,不能死循环「请再发一句」。"""
|
||||
from agent.handlers.text_inquiry import _flow_waiting_lost_reason
|
||||
from agent.policy.air_text_flow import AirTextInquiryFlow
|
||||
from agent.policy.land_text_flow import LandTextInquiryFlow
|
||||
|
||||
book = MemoryLedger()
|
||||
air = AirTextInquiryFlow(book)
|
||||
sea = SeaTextInquiryFlow(book)
|
||||
land = LandTextInquiryFlow(book)
|
||||
ghost = "WO202610090041"
|
||||
land._save(
|
||||
FlowSession(
|
||||
sender_id="YangHuiMing",
|
||||
thread_id="YangHuiMing",
|
||||
phase="wait_lost_reason",
|
||||
business_line="LAND",
|
||||
work_order_no=ghost,
|
||||
status="未成交",
|
||||
)
|
||||
)
|
||||
self.assertIsNotNone(land.session_of("YangHuiMing"))
|
||||
# 路由前应 scrub 孤儿,不再交给陆运等原因
|
||||
self.assertIsNone(_flow_waiting_lost_reason("YangHuiMing", sea, air, land))
|
||||
self.assertIsNone(land.session_of("YangHuiMing"))
|
||||
|
||||
# 若仍走到 capture:也应清书签并提示可新询价
|
||||
land._save(
|
||||
FlowSession(
|
||||
sender_id="YangHuiMing",
|
||||
thread_id="YangHuiMing",
|
||||
phase="wait_lost_reason",
|
||||
business_line="LAND",
|
||||
work_order_no=ghost,
|
||||
status="未成交",
|
||||
)
|
||||
)
|
||||
replies: list[str] = []
|
||||
phase = land._capture_lost_reason(
|
||||
land.session_of("YangHuiMing"),
|
||||
"客户取消订单",
|
||||
lambda t, extra=None: replies.append(t),
|
||||
)
|
||||
self.assertEqual(phase, "orphan_cleared")
|
||||
self.assertTrue(any("已不在系统中" in t for t in replies))
|
||||
self.assertIsNone(land.session_of("YangHuiMing"))
|
||||
|
||||
def test_sea_private_next_text_is_lost_reason(self) -> None:
|
||||
flow = SeaTextInquiryFlow(self.ledger)
|
||||
flow._save(
|
||||
|
||||
@@ -300,10 +300,22 @@ class TestPoll(unittest.TestCase):
|
||||
"name": "吴季林",
|
||||
"roleCode": "air",
|
||||
"status": "active",
|
||||
"routes": "空运全线",
|
||||
},
|
||||
{
|
||||
"wecomId": "air2",
|
||||
"name": "KUL专员",
|
||||
"roleCode": "air",
|
||||
"status": "active",
|
||||
"routes": "CGO-KUL,SZX-KUL",
|
||||
},
|
||||
]
|
||||
)
|
||||
created = book.create_ticket(sender_id="sales1", business_line="AIR", facts={"起运港": "南京"})
|
||||
created = book.create_ticket(
|
||||
sender_id="sales1",
|
||||
business_line="AIR",
|
||||
facts={"起运港": "EHU", "目的港": "ORD"},
|
||||
)
|
||||
wo = created["work_order_no"]
|
||||
now = datetime(2026, 10, 9, 18, 0, 0, tzinfo=SHANGHAI)
|
||||
book.start_event_wait(work_order_no=wo, wait_kind="air_quote")
|
||||
@@ -331,6 +343,7 @@ class TestPoll(unittest.TestCase):
|
||||
f"航线响应超时\n关联询价工单 {wo}。@吴季林已超过 0.25 工作小时 无响应,事件异常已记录工单,请及时处理。",
|
||||
)
|
||||
self.assertNotIn("相关人员", groups[0]["content"])
|
||||
self.assertNotIn("KUL专员", groups[0]["content"])
|
||||
|
||||
def test_sales_timeout_mentions_sales_not_airline(self) -> None:
|
||||
"""销售超时只 @ 本单销售。私聊也带 @,不点航线,不写相关人员。"""
|
||||
|
||||
@@ -721,5 +721,56 @@ class AirQuoteSheetInquiryOnlyTests(unittest.TestCase):
|
||||
self.assertNotIn("友通达", blob)
|
||||
|
||||
|
||||
class QuoteIdNotOriginTests(unittest.TestCase):
|
||||
"""识图把报价编号错贴到起运地时必须丢掉,不能当港口查价。"""
|
||||
|
||||
def test_quote_no_labeled_as_origin_dropped(self) -> None:
|
||||
text = (
|
||||
"报价编号:QT202610220088\n"
|
||||
"起运地:QT202610220088\n"
|
||||
"目的地:吉隆坡\n"
|
||||
"品名:茶叶\n"
|
||||
"件数:21件\n"
|
||||
"毛重:11KG\n"
|
||||
"体积:1.1*2.1*2.2\n"
|
||||
"包装类型:托盘\n"
|
||||
"运输方式:空运"
|
||||
)
|
||||
facts = extract_inquiry_snapshot(text, allow_b=False)["facts"]
|
||||
self.assertNotIn("起运港", facts)
|
||||
self.assertEqual(facts.get("目的港"), "吉隆坡")
|
||||
|
||||
def test_work_order_as_origin_dropped(self) -> None:
|
||||
text = (
|
||||
"起运地:WO202610220001\n"
|
||||
"目的地:吉隆坡\n"
|
||||
"品名:茶叶\n"
|
||||
"件数:10件\n"
|
||||
"毛重:11KG\n"
|
||||
"体积:1CBM\n"
|
||||
"包装方式:托盘\n"
|
||||
"空运"
|
||||
)
|
||||
facts = extract_inquiry_snapshot(text, allow_b=False)["facts"]
|
||||
self.assertNotIn("起运港", facts)
|
||||
self.assertEqual(facts.get("目的港"), "吉隆坡")
|
||||
|
||||
def test_real_city_origin_kept(self) -> None:
|
||||
text = (
|
||||
"报价编号:QT202610220088\n"
|
||||
"起运地:南京\n"
|
||||
"目的地:吉隆坡\n"
|
||||
"品名:茶叶\n"
|
||||
"件数:10件\n"
|
||||
"毛重:11KG\n"
|
||||
"体积:1CBM\n"
|
||||
"包装方式:托盘\n"
|
||||
"空运"
|
||||
)
|
||||
facts = extract_inquiry_snapshot(text, allow_b=False)["facts"]
|
||||
self.assertEqual(facts.get("起运港"), "南京")
|
||||
self.assertEqual(facts.get("目的港"), "吉隆坡")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -41,10 +41,12 @@ class LandCollabFieldsTest(unittest.TestCase):
|
||||
self.assertIn("HS编码", keys)
|
||||
self.assertNotIn("车型/数量", keys)
|
||||
|
||||
def test_hk_ftl_has_vehicle(self):
|
||||
def test_hk_ftl_vehicle_is_inquiry_not_collab(self):
|
||||
"""中港整车车型/数量已改到询价必填,协同不再要。"""
|
||||
keys = land_collab_fields("中港整车", "中港/中亚/中欧")
|
||||
self.assertIn("车型/数量", keys)
|
||||
self.assertNotIn("车型/数量", keys)
|
||||
self.assertEqual(keys[0], "客户名称")
|
||||
self.assertIn("HS编码", keys)
|
||||
|
||||
def test_unlisted_pair_empty(self):
|
||||
self.assertEqual(land_collab_fields("国内运输拼车", "东南亚"), ())
|
||||
|
||||
@@ -53,11 +53,11 @@ class LandCopyTest(unittest.TestCase):
|
||||
)
|
||||
self.assertIn("通关口岸(非必填):", text)
|
||||
|
||||
def test_hk_lcl_shows_vehicle_not_load_field(self):
|
||||
def test_hk_lcl_hides_vehicle_not_load_field(self):
|
||||
text = copy.land_confirm_card(
|
||||
{
|
||||
"运输类型": "中港零担/集拼",
|
||||
"线路类别": "东南亚",
|
||||
"线路类别": "中港/中亚/中欧",
|
||||
"运输分类": "急件",
|
||||
"起运港": "广州",
|
||||
"目的港": "香港",
|
||||
@@ -67,24 +67,25 @@ class LandCopyTest(unittest.TestCase):
|
||||
}
|
||||
)
|
||||
self.assertIn("运输类型(必填):中港零担/集拼", text)
|
||||
self.assertIn("车型/数量(必填):9米6/1", text)
|
||||
self.assertNotIn("车型/数量", text)
|
||||
self.assertNotIn("中港装载方式", text)
|
||||
self.assertIn("通关口岸(非必填)", text)
|
||||
|
||||
def test_hk_full_truck_hides_vehicle(self):
|
||||
def test_hk_full_truck_shows_vehicle(self):
|
||||
text = copy.land_confirm_card(
|
||||
{
|
||||
"运输类型": "中港整车",
|
||||
"线路类别": "东南亚",
|
||||
"线路类别": "中港/中亚/中欧",
|
||||
"运输分类": "急件",
|
||||
"起运港": "广州",
|
||||
"目的港": "香港",
|
||||
"品名": "衣服",
|
||||
"车型/数量": "9米6/1",
|
||||
"报价日期": "2026-09-17",
|
||||
}
|
||||
)
|
||||
self.assertIn("运输类型(必填):中港整车", text)
|
||||
self.assertNotIn("车型/数量", text)
|
||||
self.assertIn("车型/数量(必填):9米6/1", text)
|
||||
self.assertNotIn("中港装载方式", text)
|
||||
|
||||
def test_domestic_ftl_one_field(self):
|
||||
|
||||
@@ -31,24 +31,25 @@ class LandFieldValidateTest(unittest.TestCase):
|
||||
self.assertFalse(got["ok"])
|
||||
self.assertIn("体积", got["missing"])
|
||||
|
||||
def test_hk_lcl_still_needs_vehicle(self):
|
||||
def test_hk_lcl_skips_vehicle(self):
|
||||
"""中港零担/集拼:询价不要车型/数量。"""
|
||||
got = validate_required_fields(
|
||||
facts={
|
||||
"起运港": "广州",
|
||||
"目的港": "香港",
|
||||
"品名": "衣服",
|
||||
"体积": "10CBM",
|
||||
"毛重": "50kg",
|
||||
},
|
||||
business_line="LAND",
|
||||
land_subtype="中港零担/集拼",
|
||||
)
|
||||
self.assertIn("车型/数量", got["missing"])
|
||||
self.assertNotIn("数量", got["missing"])
|
||||
self.assertNotIn("车型", got["missing"])
|
||||
self.assertTrue(got["ok"])
|
||||
self.assertNotIn("车型/数量", got["missing"])
|
||||
self.assertNotIn("体积", got["missing"])
|
||||
self.assertNotIn("毛重", got["missing"])
|
||||
|
||||
def test_hk_full_truck_skips_vehicle(self):
|
||||
got = validate_required_fields(
|
||||
def test_hk_full_truck_requires_vehicle(self):
|
||||
"""中港整车:询价必填车型/数量。"""
|
||||
missing = validate_required_fields(
|
||||
facts={
|
||||
"起运港": "广州",
|
||||
"目的港": "香港",
|
||||
@@ -57,8 +58,20 @@ class LandFieldValidateTest(unittest.TestCase):
|
||||
business_line="LAND",
|
||||
land_subtype="中港整车",
|
||||
)
|
||||
self.assertFalse(missing["ok"])
|
||||
self.assertIn("车型/数量", missing["missing"])
|
||||
got = validate_required_fields(
|
||||
facts={
|
||||
"起运港": "广州",
|
||||
"目的港": "香港",
|
||||
"品名": "衣服",
|
||||
"车型/数量": "9米6/1",
|
||||
},
|
||||
business_line="LAND",
|
||||
land_subtype="中港整车",
|
||||
)
|
||||
self.assertTrue(got["ok"])
|
||||
self.assertNotIn("车型/数量", got["missing"])
|
||||
self.assertEqual(got["facts"]["车型/数量"], "9米6/1")
|
||||
|
||||
def test_domestic_ftl_one_field(self):
|
||||
got = validate_required_fields(
|
||||
|
||||
@@ -112,25 +112,22 @@ class LandTextInquiryFrontTests(unittest.TestCase):
|
||||
self.assertEqual(sess.facts.get("运输类型"), "国内运输拼车")
|
||||
self.assertEqual(sess.facts.get("线路类别"), "东南亚")
|
||||
|
||||
def test_hk_lcl_asks_vehicle(self) -> None:
|
||||
def test_hk_lcl_skips_vehicle(self) -> None:
|
||||
"""中港零担/集拼:不补问车型/数量,齐字段后直接出核对卡。"""
|
||||
phase = self.flow.on_text(
|
||||
sender_id="u1",
|
||||
text="中港零担/集拼 东南亚 急件",
|
||||
text="中港零担/集拼 中港/中亚/中欧 急件",
|
||||
reply=self._reply,
|
||||
injected_facts={
|
||||
"起运港": "广州",
|
||||
"目的港": "香港",
|
||||
"品名": "衣服",
|
||||
"体积": "10CBM",
|
||||
"毛重": "50kg",
|
||||
},
|
||||
injected_mode="LAND",
|
||||
)
|
||||
self.assertEqual(phase, "clarify")
|
||||
self.assertIn("车型/数量(必填)", self._joined())
|
||||
self.assertEqual(phase, "wait_confirm")
|
||||
self.assertNotIn("车型/数量", self._joined())
|
||||
self.assertIn("通关口岸(非必填)", self._joined())
|
||||
self.assertNotIn("\n数量:", self._joined())
|
||||
self.assertNotIn(copy.LAND_CONFIRM_TAIL, self._joined())
|
||||
self.assertEqual(self.ledger.create_calls, 0)
|
||||
|
||||
def test_hk_full_truck_skips_vehicle(self) -> None:
|
||||
|
||||
@@ -22,6 +22,7 @@ from agent.schema.quote_adjust import (
|
||||
apply_adjust,
|
||||
effective_quote,
|
||||
extract_adjust,
|
||||
parse_money,
|
||||
private_session_can_adjust,
|
||||
total_price_label,
|
||||
)
|
||||
@@ -48,6 +49,151 @@ class QuoteAdjustTests(unittest.TestCase):
|
||||
self.assertEqual(str(got.quote["total"]), "1300.00")
|
||||
self.assertFalse(got.currency_hint)
|
||||
|
||||
def test_increase_to_percent_is_set_not_up(self) -> None:
|
||||
"""「运输费用增加到110%」= 原价×1.1,不是上调 110%(×2.1)。"""
|
||||
src = _quote(("运输费", "CNY 7500.00", "CNY"), total="CNY 7500.00")
|
||||
ext = extract_adjust(
|
||||
"运输费用增加到110%",
|
||||
fee_names=["运输费"],
|
||||
allow_model=False,
|
||||
)
|
||||
self.assertTrue(ext.is_adjust)
|
||||
self.assertEqual(len(ext.items), 1)
|
||||
self.assertEqual(ext.items[0].kind, "percent")
|
||||
self.assertEqual(ext.items[0].direction, "set")
|
||||
self.assertEqual(ext.items[0].value, 110.0)
|
||||
got = apply_adjust(src, ext, business_line="LAND", tms_cost="7500")
|
||||
self.assertTrue(got.ok)
|
||||
self.assertEqual(got.quote["fee_rows"][0]["amount"], "CNY 8250.00")
|
||||
self.assertEqual(str(got.quote["total"]), "CNY 8250.00")
|
||||
self.assertIn("调到 110%", got.applied[0]["how"])
|
||||
|
||||
def test_air_unit_price_percent_keeps_per_kg_not_line_total(self) -> None:
|
||||
"""空运「单价增加到110%」:单价 19→20.9,行金额=20.9×162;禁止把合计写进单价。"""
|
||||
src = {
|
||||
"currency": "CNY",
|
||||
"total": "3438",
|
||||
"fee_rows": [
|
||||
{
|
||||
"name": "空运费",
|
||||
"amount": "3078",
|
||||
"currency": "CNY",
|
||||
"unit": "KG",
|
||||
"unit_price": "19",
|
||||
"quantity": "162",
|
||||
},
|
||||
{
|
||||
"name": "人工费",
|
||||
"amount": "200",
|
||||
"currency": "CNY",
|
||||
"unit": "人",
|
||||
"unit_price": "200",
|
||||
"quantity": "1",
|
||||
},
|
||||
{
|
||||
"name": "打板费",
|
||||
"amount": "160",
|
||||
"currency": "CNY",
|
||||
"unit": "个",
|
||||
"unit_price": "160",
|
||||
"quantity": "1",
|
||||
},
|
||||
],
|
||||
}
|
||||
ext = extract_adjust(
|
||||
"空运费单价增加到110%/KG",
|
||||
fee_names=["空运费", "人工费", "打板费"],
|
||||
allow_model=False,
|
||||
)
|
||||
got = apply_adjust(src, ext, business_line="AIR")
|
||||
self.assertTrue(got.ok)
|
||||
air = next(r for r in got.quote["fee_rows"] if r["name"] == "空运费")
|
||||
self.assertEqual(air["amount"], "3385.80")
|
||||
self.assertEqual(str(air.get("unit_price") or ""), "20.90")
|
||||
self.assertNotEqual(str(air.get("unit_price") or ""), "3385.80")
|
||||
|
||||
def test_air_unit_percent_uses_chargeable_weight_when_qty_missing(self) -> None:
|
||||
"""费用行无 quantity 时,用工单计费重:单价15→16.5,禁止把行合计写进单价。"""
|
||||
src = {
|
||||
"currency": "CNY",
|
||||
"total": "38757.75",
|
||||
"chargeableWeightKg": "2583.85",
|
||||
"fee_rows": [
|
||||
{
|
||||
"name": "空运费",
|
||||
"amount": "38757.75",
|
||||
"currency": "CNY",
|
||||
"unit": "KG",
|
||||
"unit_price": "15",
|
||||
"unitPrice": "15",
|
||||
}
|
||||
],
|
||||
"selected_air_option": {
|
||||
"chargeableWeightKg": 2583.85,
|
||||
"airlineCode": "TH",
|
||||
"feeItems": [
|
||||
{
|
||||
"chargeName": "空运费",
|
||||
"unitPrice": 15,
|
||||
"amount": 38757.75,
|
||||
"unit": "KG",
|
||||
"currency": "CNY",
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
ext = extract_adjust(
|
||||
"空运费单价增加到110%/KG",
|
||||
fee_names=["空运费"],
|
||||
allow_model=False,
|
||||
)
|
||||
got = apply_adjust(src, ext, business_line="AIR")
|
||||
self.assertTrue(got.ok)
|
||||
air = next(r for r in got.quote["fee_rows"] if r["name"] == "空运费")
|
||||
self.assertEqual(str(air.get("unit_price") or ""), "16.50")
|
||||
self.assertNotEqual(str(air.get("unit_price") or ""), "42633.53")
|
||||
amt, _ = parse_money(air.get("amount"))
|
||||
self.assertAlmostEqual(float(amt or 0), 42633.525, places=2)
|
||||
|
||||
def test_model_up_coerced_when_text_says_to_percent(self) -> None:
|
||||
"""模型误抽成 up 时,按原话「增加到X%」纠正为 set。"""
|
||||
src = _quote(("运输费", "7500", "CNY"), total="7500")
|
||||
injected = {
|
||||
"is_adjust": True,
|
||||
"wants_total": False,
|
||||
"items": [{"fee_name": "运输费", "kind": "percent", "direction": "up", "value": 110}],
|
||||
}
|
||||
ext = extract_adjust(
|
||||
"运输费用增加到110%",
|
||||
fee_names=["运输费"],
|
||||
injected=injected,
|
||||
allow_model=False,
|
||||
)
|
||||
self.assertEqual(ext.items[0].direction, "set")
|
||||
got = apply_adjust(src, ext, business_line="LAND")
|
||||
self.assertEqual(got.quote["fee_rows"][0]["amount"], "8250.00")
|
||||
|
||||
def test_to_percent_phrase_variants(self) -> None:
|
||||
"""调整到/涨到/升到/增至/变为/上浮到 X% 一律按改到原价比例。"""
|
||||
src = _quote(("运输费", "CNY 7500.00", "CNY"), total="CNY 7500.00")
|
||||
phrases = (
|
||||
"运输费调整到110%",
|
||||
"运输费涨到110%",
|
||||
"运输费升到110%",
|
||||
"运输费增至110%",
|
||||
"运输费变为110%",
|
||||
"运输费上浮到110%",
|
||||
)
|
||||
for text in phrases:
|
||||
with self.subTest(text=text):
|
||||
ext = extract_adjust(text, fee_names=["运输费"], allow_model=False)
|
||||
self.assertTrue(ext.is_adjust, text)
|
||||
self.assertEqual(ext.items[0].kind, "percent", text)
|
||||
self.assertEqual(ext.items[0].direction, "set", text)
|
||||
got = apply_adjust(src, ext, business_line="LAND")
|
||||
self.assertTrue(got.ok, text)
|
||||
self.assertEqual(got.quote["fee_rows"][0]["amount"], "CNY 8250.00", text)
|
||||
|
||||
def test_percent_down_and_target_in_one_sentence(self) -> None:
|
||||
src = _quote(("提货费", "1000", "CNY"), ("送货费", "200", "CNY"), total="1200")
|
||||
ext = extract_adjust("提货费减 5%,送货费提升到 180 元", fee_names=["提货费", "送货费"])
|
||||
@@ -573,6 +719,87 @@ class QuoteAdjustTests(unittest.TestCase):
|
||||
self.assertTrue(copy.quote_is_tms(sess.tms_quote))
|
||||
self.assertFalse(copy.quote_is_tms(sess.quote))
|
||||
|
||||
def test_private_air_adjust_emits_facts_so_cargo_not_blank(self) -> None:
|
||||
"""空运私聊调价出卡必须带 sess.facts,否则起运地/品名会整段变「-」。"""
|
||||
from agent.policy.quote_adjust_ops import try_private_adjust
|
||||
|
||||
class _Sess:
|
||||
status = "已报价"
|
||||
phase = "wait_collab"
|
||||
work_order_no = "WO202610280072"
|
||||
business_line = "AIR"
|
||||
collab_chat_id = ""
|
||||
wait_version = 1
|
||||
first_or_same = "first"
|
||||
history_work_order_no = ""
|
||||
facts = {
|
||||
"起运地": "南京",
|
||||
"目的地": "吉隆坡",
|
||||
"品名": "普货",
|
||||
"件数": "15",
|
||||
"毛重": "2403",
|
||||
"体积": "15.5",
|
||||
"包装类型": "托盘",
|
||||
}
|
||||
quote = {
|
||||
"currency": "CNY",
|
||||
"total": "38757.75",
|
||||
"chargeableWeightKg": "2583.85",
|
||||
"fee_rows": [
|
||||
{
|
||||
"name": "空运费",
|
||||
"amount": "38757.75",
|
||||
"currency": "CNY",
|
||||
"unit": "KG",
|
||||
"unit_price": "15",
|
||||
}
|
||||
],
|
||||
}
|
||||
tms_quote = {}
|
||||
|
||||
class _Flow:
|
||||
def __init__(self) -> None:
|
||||
self.emitted = None
|
||||
|
||||
class _Ledger:
|
||||
def upsert_product_quote(self, **kwargs):
|
||||
return {"ok": True}
|
||||
|
||||
self.ledger = _Ledger()
|
||||
|
||||
def _save(self, sess) -> None:
|
||||
return None
|
||||
|
||||
def _emit_air_hit(self, reply, **kwargs) -> None:
|
||||
self.emitted = kwargs
|
||||
|
||||
sess = _Sess()
|
||||
flow = _Flow()
|
||||
out = try_private_adjust(
|
||||
flow,
|
||||
sess,
|
||||
"空运费单价增加到110%/KG",
|
||||
lambda *_a, **_k: None,
|
||||
injected_adjust={
|
||||
"is_adjust": True,
|
||||
"items": [
|
||||
{
|
||||
"fee_name": "空运费",
|
||||
"kind": "percent",
|
||||
"direction": "set",
|
||||
"value": 110,
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
self.assertEqual(out, "wait_collab")
|
||||
self.assertIsNotNone(flow.emitted)
|
||||
facts = dict(flow.emitted.get("facts") or {})
|
||||
self.assertEqual(facts.get("起运地"), "南京")
|
||||
self.assertEqual(facts.get("品名"), "普货")
|
||||
air = next(r for r in sess.quote["fee_rows"] if r["name"] == "空运费")
|
||||
self.assertEqual(str(air.get("unit_price") or ""), "16.50")
|
||||
|
||||
def test_group_brief_keeps_tms_when_current_is_adjust(self) -> None:
|
||||
"""拉群摘要:TMS 段必须是原价,调后价另写销售调价,禁止替换。"""
|
||||
tms = _quote(("运输费", "110.00", "CNY"), total="110.00")
|
||||
|
||||
@@ -1427,6 +1427,64 @@ class SeaGroupCollabFieldTests(unittest.TestCase):
|
||||
self.assertEqual(str(rows["增值费"].get("project") or ""), "起运港")
|
||||
self.assertEqual(str(rows["增值费"].get("amount") or ""), "1000")
|
||||
|
||||
def test_air_origin_fee_unit_ci_keeps_qty_and_amount(self) -> None:
|
||||
"""WO081:打板费单位「次」必须认出数量100/金额1000,不能落成数量1、金额=单价。"""
|
||||
from agent.llm.extract_text import extract_product_quote
|
||||
|
||||
quote = extract_product_quote(
|
||||
"@智价通AIQ 起运港,打板费,次,单价CNY10,数量100,金额CNY1000",
|
||||
business_line="AIR",
|
||||
)
|
||||
rows = {str(x.get("name") or ""): x for x in (quote.get("fee_rows") or [])}
|
||||
self.assertIn("打板费", rows)
|
||||
self.assertEqual(str(rows["打板费"].get("unit") or ""), "次")
|
||||
self.assertEqual(str(rows["打板费"].get("unit_price") or ""), "10")
|
||||
self.assertEqual(str(rows["打板费"].get("quantity") or ""), "100")
|
||||
self.assertEqual(str(rows["打板费"].get("amount") or ""), "1000")
|
||||
self.assertEqual(str(rows["打板费"].get("project") or ""), "起运港")
|
||||
|
||||
def test_air_origin_fee_unit_ren_keeps_qty_and_amount(self) -> None:
|
||||
"""单位「人」同样要能抽出数量与金额。"""
|
||||
from agent.llm.extract_text import extract_product_quote
|
||||
|
||||
quote = extract_product_quote(
|
||||
"起运港,人工费,人,单价CNY50,数量2,金额CNY100",
|
||||
business_line="AIR",
|
||||
)
|
||||
rows = {str(x.get("name") or ""): x for x in (quote.get("fee_rows") or [])}
|
||||
self.assertIn("人工费", rows)
|
||||
self.assertEqual(str(rows["人工费"].get("unit") or ""), "人")
|
||||
self.assertEqual(str(rows["人工费"].get("quantity") or ""), "2")
|
||||
self.assertEqual(str(rows["人工费"].get("amount") or ""), "100")
|
||||
|
||||
def test_air_origin_fee_unit_slash_ci_ren(self) -> None:
|
||||
"""单位表「次/人」整段保留,不能拆成只认「次」。"""
|
||||
from agent.llm.extract_text import extract_product_quote
|
||||
|
||||
quote = extract_product_quote(
|
||||
"起运港,操作费,次/人,单价CNY20,数量3,金额CNY60",
|
||||
business_line="AIR",
|
||||
)
|
||||
rows = {str(x.get("name") or ""): x for x in (quote.get("fee_rows") or [])}
|
||||
self.assertIn("操作费", rows)
|
||||
self.assertEqual(str(rows["操作费"].get("unit") or ""), "次/人")
|
||||
self.assertEqual(str(rows["操作费"].get("quantity") or ""), "3")
|
||||
self.assertEqual(str(rows["操作费"].get("amount") or ""), "60")
|
||||
|
||||
def test_air_origin_fee_unit_hour_per_person(self) -> None:
|
||||
"""单位表「小时/人」整段保留。"""
|
||||
from agent.llm.extract_text import extract_product_quote
|
||||
|
||||
quote = extract_product_quote(
|
||||
"起运港,仓储费,小时/人,单价CNY30,数量4,金额CNY120",
|
||||
business_line="AIR",
|
||||
)
|
||||
rows = {str(x.get("name") or ""): x for x in (quote.get("fee_rows") or [])}
|
||||
self.assertIn("仓储费", rows)
|
||||
self.assertEqual(str(rows["仓储费"].get("unit") or ""), "小时/人")
|
||||
self.assertEqual(str(rows["仓储费"].get("quantity") or ""), "4")
|
||||
self.assertEqual(str(rows["仓储费"].get("amount") or ""), "120")
|
||||
|
||||
def test_fee_line_text_is_a_product_quote(self) -> None:
|
||||
"""产品按行改价:码头操作费100CNY / 报关费改为300CNY,必须抽出费用名,不能只当海运费合计。"""
|
||||
from agent.llm.extract_text import extract_product_quote
|
||||
|
||||
@@ -226,7 +226,8 @@ class WshotoPollTests(unittest.TestCase):
|
||||
self.assertTrue(out["ok"])
|
||||
call = api.calls[0]
|
||||
self.assertEqual(call.get("msg_time_end"), 1_800_000_000)
|
||||
self.assertEqual(call.get("msg_time_start"), 1_800_000_000 - 180)
|
||||
# 新群回看上限见 poll._NEW_ROOM_LOOKBACK_SEC(3600),须盖过存档延迟。
|
||||
self.assertEqual(call.get("msg_time_start"), 1_800_000_000 - 3600)
|
||||
|
||||
def test_poll_enqueues_member_skips_seat_and_advances_seq(self) -> None:
|
||||
store = MemoryMessageStore()
|
||||
@@ -317,6 +318,82 @@ class WshotoPollTests(unittest.TestCase):
|
||||
self.assertEqual(b, 0)
|
||||
self.assertEqual(skipped, 1)
|
||||
|
||||
def test_room_seq_gap_backfill_keeps_dissolve(self) -> None:
|
||||
"""
|
||||
现网事故:增量只先到「撤回」高 seq,游标一步跳到 9899,
|
||||
中间的「解散群」再也拉不回来。跳号时必须时间窗补洞再推进游标。
|
||||
"""
|
||||
store = MemoryMessageStore()
|
||||
redis = _redis()
|
||||
room = "wr_gap_dissolve"
|
||||
watch_collab_room(room, redis=redis)
|
||||
save_last_seq(f"room:{room}", 9892, redis=redis)
|
||||
|
||||
all_rows = [
|
||||
{
|
||||
"msgid": "m9897",
|
||||
"seq": 9897,
|
||||
"from": "ShiChangChang",
|
||||
"room_id": room,
|
||||
"msgtype": "text",
|
||||
"content": "随便说一句",
|
||||
"msgtime": 1_792_546_051_000,
|
||||
},
|
||||
{
|
||||
"msgid": "m9898",
|
||||
"seq": 9898,
|
||||
"from": "ShiChangChang",
|
||||
"room_id": room,
|
||||
"msgtype": "text",
|
||||
"content": "解散群",
|
||||
"msgtime": 1_792_546_055_000,
|
||||
},
|
||||
{
|
||||
"msgid": "m9899",
|
||||
"seq": 9899,
|
||||
"from": "ShiChangChang",
|
||||
"room_id": room,
|
||||
"msgtype": "revoke",
|
||||
"content": "[撤回消息]",
|
||||
"msgtime": 1_792_546_059_000,
|
||||
},
|
||||
]
|
||||
|
||||
class _GapApi:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict] = []
|
||||
|
||||
def list_group_messages(self, **kwargs):
|
||||
self.calls.append(dict(kwargs))
|
||||
last = int(kwargs.get("last_seq") or 0)
|
||||
time_start = int(kwargs.get("msg_time_start") or 0)
|
||||
# 模拟管家增量晚到:先只吐撤回;时间窗才能看到中间「解散群」。
|
||||
if time_start > 0:
|
||||
return [r for r in all_rows if int(r["seq"]) > last]
|
||||
if last > 0:
|
||||
return [r for r in all_rows if int(r["seq"]) == 9899 and int(r["seq"]) > last]
|
||||
return list(all_rows)
|
||||
|
||||
cfg = _Cfg()
|
||||
cfg.wshoto_room_gap_ms = 0
|
||||
out = poll_once(
|
||||
client=_GapApi(),
|
||||
settings=cfg,
|
||||
redis=redis,
|
||||
store=store,
|
||||
now=lambda: 1_792_546_100,
|
||||
)
|
||||
self.assertTrue(out["ok"])
|
||||
self.assertGreaterEqual(out["enqueued"], 1)
|
||||
self.assertEqual(load_last_seq(f"room:{room}", redis=redis), 9899)
|
||||
claimed = []
|
||||
while True:
|
||||
item = store.claim_inbound()
|
||||
if item is None:
|
||||
break
|
||||
claimed.append(item.message.content)
|
||||
self.assertIn("解散群", claimed)
|
||||
|
||||
def test_watched_rooms_preferred_without_seat_userid(self) -> None:
|
||||
store = MemoryMessageStore()
|
||||
redis = _redis()
|
||||
|
||||
+13
@@ -145,6 +145,19 @@ public class InquiryStaffRoleController extends JeecgController<InquiryStaffRole
|
||||
return Result.OK("已停用");
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新启用。权限与停用相同(inquiry:staff:disable),避免再扩一套权限码。
|
||||
* 查询参数 id,前端非 GET 须 joinParamsToUrl。
|
||||
*/
|
||||
@AutoLog(value = "员工岗位-启用")
|
||||
@ApiOperation("启用")
|
||||
@RequiresPermissions("inquiry:staff:disable")
|
||||
@PostMapping("/enable")
|
||||
public Result<String> enable(@RequestParam String id) {
|
||||
staffRoleService.enable(id);
|
||||
return Result.OK("已启用");
|
||||
}
|
||||
|
||||
@ApiOperation("按 id 查询")
|
||||
@RequiresPermissions("inquiry:staff:list")
|
||||
@GetMapping("/queryById")
|
||||
|
||||
+5
@@ -21,6 +21,11 @@ public interface IInquiryStaffRoleService extends IService<InquiryStaffRole> {
|
||||
*/
|
||||
void disable(String id);
|
||||
|
||||
/**
|
||||
* 重新启用员工(status=active)。
|
||||
*/
|
||||
void enable(String id);
|
||||
|
||||
/**
|
||||
* 从企微通讯录全量 upsert:按 wecom_id 更新姓名/手机/部门,缺失则新建(默认销售岗)。
|
||||
* <p>
|
||||
|
||||
+79
-6
@@ -32,6 +32,7 @@ import org.jeecg.modules.inquiry.template.QuoteSheetFileName;
|
||||
import org.jeecg.modules.inquiry.template.QuoteTemplateMatcher;
|
||||
import org.jeecg.modules.inquiry.template.QuoteTemplateObjectStore;
|
||||
import org.jeecg.modules.inquiry.template.QuoteTemplateSnapshotRenderer;
|
||||
import org.jeecg.modules.inquiry.tms.TmsLandSegmentMapper;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -42,6 +43,7 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
@@ -1172,9 +1174,9 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
out.put("quote_date", new java.text.SimpleDateFormat("yyyy-MM-dd").format(new Date()));
|
||||
}
|
||||
out.put("valid_until", first(q, "valid_until"));
|
||||
// 预估费用必须用 TMS 总销售价。会话 quote 偶发只有 selected_air_option.salePrice、
|
||||
// 顶层 total 空时,若仍走模板 SUM,会把增点费+拆板费加成 200(WO040/WO026)。
|
||||
// 群出单还可能把明细之和误写成 total=200,此时仍以线路 salePrice 为准。
|
||||
// 预估费用:顶层 total 空时用舱位 salePrice。
|
||||
// 薄报价把附加费之和误写成 total=200(<< salePrice)时盖成销售价(WO040/WO026)。
|
||||
// 航线在舱位价上再加增值费/增点费时 total=明细之和会大于 salePrice,必须保留,禁止盖回。
|
||||
String fromOpt = airOptionSalePrice(q);
|
||||
String totalAmount = first(q, "total", "total_amount", "salePrice", "totalSalePrice");
|
||||
if (StringUtils.isBlank(totalAmount) || "-".equals(totalAmount)) {
|
||||
@@ -1182,6 +1184,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
} else if (StringUtils.isNotBlank(fromOpt)
|
||||
&& looksAirInquiry(src, q, businessLine)
|
||||
&& totalLooksLikeFeeRowSum(q, totalAmount)
|
||||
&& totalIsThinVsSalePrice(totalAmount, fromOpt)
|
||||
&& !sameMoneyAmount(totalAmount, fromOpt)) {
|
||||
totalAmount = fromOpt;
|
||||
}
|
||||
@@ -1261,12 +1264,18 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
}
|
||||
// 空运:完整明细在 airOptions 分桶 + 顶层 feeItems(空运段)。
|
||||
// 行数相同也优先分桶解析(fee_lines 常把单价误当金额,缺单位/项目列)。
|
||||
// 有 selected_air_option 时必须跟选定舱位,禁止写列表首条单价(WO202610270003)。
|
||||
Object quoteFeeItems = q.get("feeItems");
|
||||
if (quoteFeeItems == null) {
|
||||
quoteFeeItems = q.get("fee_items");
|
||||
}
|
||||
Object selectedAir = q.get("selected_air_option");
|
||||
if (selectedAir == null) {
|
||||
selectedAir = q.get("selectedAirOption");
|
||||
}
|
||||
JSONArray fromAir = QuoteTemplateExcelSupport.parseAirDepartureChargeRows(
|
||||
q.get("airOptions"), q.get("air_options"), quoteFeeItems, chargeable, currency);
|
||||
q.get("airOptions"), q.get("air_options"), quoteFeeItems, selectedAir,
|
||||
chargeable, currency);
|
||||
if (!fromAir.isEmpty()) {
|
||||
// 分桶优先,但 flat fee_lines 里多出来的人工调价(如「增值费」)必须并进模板,
|
||||
// 禁止整表替换后把群摘要已展示的费用丢掉(WO202610140006)。
|
||||
@@ -1378,6 +1387,8 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
out.put("selected_land_route", selectedLand);
|
||||
}
|
||||
if (!landSegs.isEmpty()) {
|
||||
// 产品文字价/分档壳偶发无 vehicleType:用询价「车型/数量」补上,否则整车矩阵车型列空、金额只进合计。
|
||||
enrichLandRouteVehicleFromInquiry(landSegs, out, src);
|
||||
out.put("landSegments", landSegs);
|
||||
out.put("route_rows", landSegs);
|
||||
// 首段时效/燃油/税率抬到顶层,供侧栏与标签回填(WO006 燃油列不能空着有数却写不上)
|
||||
@@ -1480,9 +1491,11 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
one.put("amount", landAmt);
|
||||
one.put("salePrice", landAmt);
|
||||
}
|
||||
// WO047:人工「陆运费」无 TMS 段时也必须带上车型,否则境外 10T 列空、金额只进合计。
|
||||
JSONArray fallbackRows = new JSONArray();
|
||||
fallbackRows.add(one);
|
||||
enrichLandRouteVehicleFromInquiry(fallbackRows, out, src);
|
||||
if (StringUtils.isNotBlank(one.getString("origin")) || StringUtils.isNotBlank(one.getString("destination"))) {
|
||||
JSONArray fallbackRows = new JSONArray();
|
||||
fallbackRows.add(one);
|
||||
out.put("route_rows", fallbackRows);
|
||||
}
|
||||
}
|
||||
@@ -1509,6 +1522,53 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 线路行缺 vehicleType 时,从询价「车型/数量」拆出车型补上。
|
||||
* <p>
|
||||
* 为何:TMS 无匹配、产品只报「陆运费」时 buildFillFacts 会拼 OD+金额回退行,
|
||||
* 若不带车型,整车矩阵只能写「金额合计」、10T 等车型列会空(WO202610210047)。
|
||||
* 副作用:只改传入的 JSONArray 行对象;不写库。线程:出单请求线程。
|
||||
*/
|
||||
private static void enrichLandRouteVehicleFromInquiry(JSONArray landSegs, JSONObject out,
|
||||
Map<String, Object> src) {
|
||||
if (landSegs == null || landSegs.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
String combo = firstNonBlankLocal(
|
||||
out == null ? null : out.getString("vehicle_qty"),
|
||||
first(src, "车型/数量"),
|
||||
first(src, "车型数量"),
|
||||
first(src, "车型"),
|
||||
out == null ? null : out.getString("vehicleType"),
|
||||
out == null ? null : out.getString("vehicle_type"));
|
||||
if (StringUtils.isBlank(combo)) {
|
||||
return;
|
||||
}
|
||||
String[] parts = TmsLandSegmentMapper.splitVehicleQty(combo);
|
||||
String vehicle = parts == null || parts.length < 1 ? "" : StringUtils.trimToEmpty(parts[0]);
|
||||
String qty = parts == null || parts.length < 2 ? "1" : StringUtils.trimToEmpty(parts[1]);
|
||||
if (StringUtils.isBlank(vehicle)) {
|
||||
return;
|
||||
}
|
||||
int patched = 0;
|
||||
for (int i = 0; i < landSegs.size(); i++) {
|
||||
JSONObject seg = landSegs.getJSONObject(i);
|
||||
if (seg == null) {
|
||||
continue;
|
||||
}
|
||||
String existing = firstNonBlankLocal(seg.getString("vehicleType"), seg.getString("vehicle_type"));
|
||||
if (StringUtils.isNotBlank(existing)) {
|
||||
continue;
|
||||
}
|
||||
seg.put("vehicleType", vehicle);
|
||||
seg.put("vehicle_type", vehicle);
|
||||
if (seg.get("quantity") == null && StringUtils.isNotBlank(qty)) {
|
||||
seg.put("quantity", qty);
|
||||
}
|
||||
patched++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 空运选定线路 / 首条 airOptions 的 salePrice。
|
||||
* 会话 quote 有时只投影了线路对象,顶层 total 空,出单预估费用必须能落到这个数。
|
||||
@@ -1569,6 +1629,19 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
opt.getString("totalSalePrice"), opt.getString("total"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 薄报价:明细加总明显小于舱位销售价(如 200 < 7918)才允许盖成 salePrice。
|
||||
* 航线加增值费后 total>salePrice 时返回 false,保留航线总价。
|
||||
*/
|
||||
private static boolean totalIsThinVsSalePrice(String totalText, String salePriceText) {
|
||||
Double totalNum = parseMoneyPlain(totalText);
|
||||
Double saleNum = parseMoneyPlain(salePriceText);
|
||||
if (totalNum == null || saleNum == null) {
|
||||
return false;
|
||||
}
|
||||
return totalNum + 0.01 < saleNum;
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶层 total 是否只是 fee_lines 金额相加(如 100+100=200)。
|
||||
* 空运群薄报价常把附加费之和误当总价,真总价在 airOptions.salePrice。
|
||||
|
||||
+15
@@ -64,6 +64,21 @@ public class InquiryStaffRoleServiceImpl extends ServiceImpl<InquiryStaffRoleMap
|
||||
updateById(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新启用:停用后列表可点「启用」恢复 active,智能体匹配只认启用员工。
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void enable(String id) {
|
||||
InquiryStaffRole row = getById(id);
|
||||
if (row == null) {
|
||||
throw new IllegalArgumentException("员工不存在: " + id);
|
||||
}
|
||||
row.setStatus("active");
|
||||
row.setUpdateTime(new Date());
|
||||
updateById(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 wecom_id 查一条(智能体/后台可复用)。
|
||||
*/
|
||||
|
||||
+14
@@ -45,6 +45,11 @@ import com.alibaba.fastjson.JSONObject;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
@@ -931,6 +936,7 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
return org.jeecg.modules.inquiry.tms.TmsV2RequestMapper.asVolumeCbm(raw);
|
||||
}
|
||||
|
||||
|
||||
private static Date parseDate(String raw) {
|
||||
if (StringUtils.isBlank(raw)) {
|
||||
return null;
|
||||
@@ -1006,9 +1012,13 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
if (StringUtils.isNotBlank(ticket.getContainerSpec())) {
|
||||
facts.put("箱型箱量", ticket.getContainerSpec());
|
||||
}
|
||||
String storedVolumeBeforeMerge = "";
|
||||
if (StringUtils.isNotBlank(ticket.getFactsJson())) {
|
||||
JSONObject stored = JSON.parseObject(ticket.getFactsJson());
|
||||
if (stored != null) {
|
||||
if (stored.get("体积") != null) {
|
||||
storedVolumeBeforeMerge = String.valueOf(stored.get("体积"));
|
||||
}
|
||||
for (String key : stored.keySet()) {
|
||||
if (StringUtils.isBlank(facts.get(key)) && stored.get(key) != null) {
|
||||
facts.put(key, String.valueOf(stored.get(key)));
|
||||
@@ -1016,6 +1026,10 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
}
|
||||
}
|
||||
}
|
||||
// 体积列是 TMS 用的相乘 CBM;销售侧展示/群卡必须保留 factsJson 尺寸原文(如 1.1*2.1*2.2)。
|
||||
if (StringUtils.isNotBlank(storedVolumeBeforeMerge)) {
|
||||
facts.put("体积", storedVolumeBeforeMerge.trim());
|
||||
}
|
||||
// 最新一条=当前生效价;TMS 原价单独留,拉群摘要禁止用调后价冒充 TMS。
|
||||
Map<String, Object> quote = new LinkedHashMap<>();
|
||||
Map<String, Object> tmsQuote = new LinkedHashMap<>();
|
||||
|
||||
+41
-4
@@ -1,9 +1,16 @@
|
||||
package org.jeecg.modules.inquiry.support;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryTicket;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
@@ -54,19 +61,19 @@ public final class InquiryTicketDisplaySupport {
|
||||
if ("AIR".equals(businessLine) || containsChinese(lineLabel, "空运")) {
|
||||
addLabel(quantities, "件数", ticket.getPieces());
|
||||
addUnit(quantities, "重量(KG)", ticket.getWeightKg(), "kg");
|
||||
addUnit(quantities, "体积(CBM)", ticket.getVolumeCbm(), "CBM");
|
||||
addVolumeDisplay(quantities, ticket);
|
||||
} else if ("SEA".equals(businessLine) || containsChinese(lineLabel, "海运")) {
|
||||
addLabel(quantities, "件数", ticket.getPieces());
|
||||
addUnit(quantities, "重量(KG)", ticket.getWeightKg(), "kg");
|
||||
addUnit(quantities, "体积(CBM)", ticket.getVolumeCbm(), "CBM");
|
||||
addVolumeDisplay(quantities, ticket);
|
||||
} else if ("LAND".equals(businessLine) || containsChinese(lineLabel, "陆运")) {
|
||||
addUnit(quantities, "体积(CBM)", ticket.getVolumeCbm(), "CBM");
|
||||
addVolumeDisplay(quantities, ticket);
|
||||
addUnit(quantities, "重量(KG)", ticket.getWeightKg(), "kg");
|
||||
addLabel(quantities, "件数", ticket.getPieces());
|
||||
} else {
|
||||
addLabel(quantities, "件数", ticket.getPieces());
|
||||
addUnit(quantities, "重量(KG)", ticket.getWeightKg(), "kg");
|
||||
addUnit(quantities, "体积(CBM)", ticket.getVolumeCbm(), "CBM");
|
||||
addVolumeDisplay(quantities, ticket);
|
||||
}
|
||||
addLine(lines, join(quantities, " · "));
|
||||
|
||||
@@ -191,6 +198,36 @@ public final class InquiryTicketDisplaySupport {
|
||||
return String.valueOf(value).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台摘要体积:优先 factsJson 销售原文(尺寸式),没有再退回相乘后的 CBM 列。
|
||||
* 原文不加「CBM」后缀,避免 1.1*2.1*2.2CBM。
|
||||
*/
|
||||
private static void addVolumeDisplay(List<String> quantities, InquiryTicket ticket) {
|
||||
String raw = volumeFromFactsJson(ticket);
|
||||
if (StringUtils.isNotBlank(raw)) {
|
||||
addLabel(quantities, "体积(CBM)", raw);
|
||||
return;
|
||||
}
|
||||
addUnit(quantities, "体积(CBM)", ticket.getVolumeCbm(), "CBM");
|
||||
}
|
||||
|
||||
/** 从 factsJson 取体积原文;坏 JSON 或没有则空。 */
|
||||
private static String volumeFromFactsJson(InquiryTicket ticket) {
|
||||
if (ticket == null || StringUtils.isBlank(ticket.getFactsJson())) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
JSONObject o = JSON.parseObject(ticket.getFactsJson());
|
||||
if (o == null || o.get("体积") == null) {
|
||||
return "";
|
||||
}
|
||||
return StringUtils.trimToEmpty(String.valueOf(o.get("体积")));
|
||||
} catch (Exception ignore) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static String join(List<String> values, String sep) {
|
||||
if (values == null || values.isEmpty()) {
|
||||
return null;
|
||||
|
||||
+7
@@ -51,6 +51,13 @@ public final class LandQuoteTemplateFields {
|
||||
add("vehicle_10t_fee", "9.4*2.4*2.55M/10T费用", "vehicle10TFee");
|
||||
add("vehicle_40hq_fee", "11.8*2.32*2.7M/40'HQ费用", "vehicle40HQFee");
|
||||
add("vehicle_45hq_fee", "12.8*2.32*2.7M/45'HQ费用", "vehicle45HQFee");
|
||||
// 跨境整车矩阵短表头:询价写 40HQ,旧模板曾写 40'HQ,两种都认
|
||||
LABEL_TO_CODE.put("40'HQ", "vehicle_40hq_fee");
|
||||
LABEL_TO_CODE.put("40HQ", "vehicle_40hq_fee");
|
||||
LABEL_TO_CODE.put("45'HQ", "vehicle_45hq_fee");
|
||||
LABEL_TO_CODE.put("45HQ", "vehicle_45hq_fee");
|
||||
LABEL_TO_CODE.put("11.8*2.32*2.7M/40HQ费用", "vehicle_40hq_fee");
|
||||
LABEL_TO_CODE.put("12.8*2.32*2.7M/45HQ费用", "vehicle_45hq_fee");
|
||||
add("vehicle_48rh_fee", "14.6*2.28*2.5M/48RH冷柜费用", "vehicle48RHFee");
|
||||
add("vehicle_53r_fee", "16.3*2.7*2.8M/53尺费用", "vehicle53RFee");
|
||||
add("vehicle_1375_fee", "13.5*3M/13.75平板费用", "vehicle13.75TFee");
|
||||
|
||||
+670
-90
File diff suppressed because it is too large
Load Diff
+34
-12
@@ -178,11 +178,8 @@ public class InquiryTmsCabinService {
|
||||
if (!properties.lockConfigured()) {
|
||||
return fail("lock_disabled", "主账未开放锁舱");
|
||||
}
|
||||
// 工单报价日空/脏(如事实只有「:」)时,先拦再出站。
|
||||
// 禁止用舱位起飞日顶过去后打 TMS,再回「选项不匹配」(WO202610140047)。
|
||||
if (isTicketQuoteDateMissing(ticket)) {
|
||||
return fail("lock_incomplete", "报价日期为空,请补充报价日期后重新锁舱");
|
||||
}
|
||||
// 报价日空/脏:并入下方 missingLockParamReason(可与舱位三项同句补问),
|
||||
// 禁止用舱位起飞日顶过去后打 TMS(WO202610140047)。
|
||||
Map<String, Object> req = new LinkedHashMap<>();
|
||||
req.put("workOrderNo", no);
|
||||
if (StringUtils.isNotBlank(quoteId)) {
|
||||
@@ -301,10 +298,14 @@ public class InquiryTmsCabinService {
|
||||
}
|
||||
}
|
||||
String incomplete = missingLockParamReason(
|
||||
text(req, "optionNo"), text(req, "airline"), text(req, "flightNo"));
|
||||
text(req, "optionNo"),
|
||||
text(req, "airline"),
|
||||
text(req, "flightNo"),
|
||||
isTicketQuoteDateMissing(ticket));
|
||||
if (StringUtils.isNotBlank(incomplete)) {
|
||||
log.info("tms.lock skip incomplete wo={} optionNo={} airline={} flightNo={}",
|
||||
no, text(req, "optionNo"), text(req, "airline"), text(req, "flightNo"));
|
||||
log.info("tms.lock skip incomplete wo={} optionNo={} airline={} flightNo={} quoteDateMissing={}",
|
||||
no, text(req, "optionNo"), text(req, "airline"), text(req, "flightNo"),
|
||||
isTicketQuoteDateMissing(ticket));
|
||||
return fail("lock_params_incomplete", incomplete);
|
||||
}
|
||||
return invokeLock(ticket, req, quoteId, resolvedOption);
|
||||
@@ -912,8 +913,13 @@ public class InquiryTmsCabinService {
|
||||
return "";
|
||||
}
|
||||
// 列空、事实只有「:」等脏值,都算报价日缺失(WO047)。
|
||||
// 与舱位缺项同一句,避免航线先补三项再被单独拦报价日。
|
||||
if (isTicketQuoteDateMissing(ticket)) {
|
||||
return "报价日期为空,请补充报价日期后重新锁舱";
|
||||
return missingLockParamReason(
|
||||
text(req, "optionNo"),
|
||||
text(req, "airline"),
|
||||
text(req, "flightNo"),
|
||||
true);
|
||||
}
|
||||
boolean noMatch = "TMS_NO_QUOTE_1002".equals(StringUtils.trimToEmpty(refreshClass))
|
||||
|| (StringUtils.isNotBlank(refreshMsg) && refreshMsg.contains("无匹配"));
|
||||
@@ -1302,10 +1308,20 @@ public class InquiryTmsCabinService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 锁舱三项没齐时的群回复。不叫锁舱失败,不提 TMS 查价结果。
|
||||
* 三项都缺时是:锁舱参数不完整,缺少:舱位编号、航司、航班号。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。
|
||||
* 锁舱参数没齐时的群回复。不叫锁舱失败,不提 TMS 查价结果。
|
||||
* <p>
|
||||
* 舱位三项都缺且报价日有值:缺少:舱位编号、航司、航班号。…例如:AIR-OPT01,南航,航班CZ327。
|
||||
* 报价日也空:缺项末尾加「报价日期」,示例末尾加「,2026-10-18」。
|
||||
*/
|
||||
static String missingLockParamReason(String optionNo, String airline, String flightNo) {
|
||||
return missingLockParamReason(optionNo, airline, flightNo, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param quoteDateMissing 工单报价日空/脏时为 true,并入缺项列表
|
||||
*/
|
||||
static String missingLockParamReason(
|
||||
String optionNo, String airline, String flightNo, boolean quoteDateMissing) {
|
||||
java.util.List<String> missing = new ArrayList<>();
|
||||
if (StringUtils.isBlank(optionNo)) {
|
||||
missing.add("舱位编号");
|
||||
@@ -1316,11 +1332,17 @@ public class InquiryTmsCabinService {
|
||||
if (StringUtils.isBlank(flightNo)) {
|
||||
missing.add("航班号");
|
||||
}
|
||||
if (quoteDateMissing) {
|
||||
missing.add("报价日期");
|
||||
}
|
||||
if (missing.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
String example = quoteDateMissing
|
||||
? "AIR-OPT01,南航,航班CZ327,2026-10-18"
|
||||
: "AIR-OPT01,南航,航班CZ327";
|
||||
return "锁舱参数不完整,缺少:" + String.join("、", missing)
|
||||
+ "。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。";
|
||||
+ "。请补充后再锁,例如:" + example + "。";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+3
-8
@@ -4,7 +4,7 @@ package org.jeecg.modules.inquiry.tms;
|
||||
* 陆运运输类型 / 线路类别出站:产品要求原文直传,不再翻成 TMS 英文枚举。
|
||||
* <p>
|
||||
* 职责:统一 trim;空串表示未填。工单/卡片/拉人本来就是中文,出站与之一致。
|
||||
* 中港整车、中港零担/集拼查价只传「中港」,工单仍留细类。
|
||||
* 中港整车、中港零担/集拼也按细类原文传 landTransportType,不再收成「中港」。
|
||||
* 禁止:再把「跨境整车」写成 CROSS_BORDER_FTL;把运输分类、中港装载方式塞进出站。
|
||||
* 历史英文码若仍传入,原样放过(兼容旧快照),不做双向翻译。
|
||||
*/
|
||||
@@ -15,15 +15,10 @@ public final class TmsLandEnumMapper {
|
||||
|
||||
/**
|
||||
* @param raw 销售中文(或历史英文码)
|
||||
* @return 中港两类收成「中港」;其余 trim 后原文;空则 ""
|
||||
* @return trim 后原文(含中港整车 / 中港零担/集拼);空则 ""
|
||||
*/
|
||||
public static String transportType(String raw) {
|
||||
String text = trimOrEmpty(raw);
|
||||
if ("中港整车".equals(text) || "中港零担/集拼".equals(text)) {
|
||||
// zhonggang: 两类细类查价都只传「中港」
|
||||
return "中港";
|
||||
}
|
||||
return text;
|
||||
return trimOrEmpty(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+23
-18
@@ -16,10 +16,10 @@ import java.util.regex.Pattern;
|
||||
* 陆运 TMS 查价段:按运输类型拼出站字段。
|
||||
* <p>
|
||||
* 职责:把工单 facts 里的中文键写成 TMS 段;运输分类只留在工单,不进出站。
|
||||
* 拼车/跨境集拼带体积重量;国内整车/跨境整车/中港零担带「车型/数量」一项再拆成出站车型与台数;
|
||||
* 中港整车不带车型/数量。通关口岸有值才放。
|
||||
* 中港整车、中港零担/集拼:出站 landTransportType 只写「中港」,工单事实仍留细类。
|
||||
* 类型/线路键名仍是 landTransportType / routeCategory。除中港两类外,值用中文原文。
|
||||
* 拼车/跨境集拼带体积重量;国内整车/跨境整车/中港整车带「车型/数量」一项再拆成出站车型与台数;
|
||||
* 仅中港零担/集拼不带车型/数量。通关口岸有值才放。
|
||||
* 中港整车、中港零担/集拼:出站 landTransportType 按细类原文(不再收成「中港」)。
|
||||
* 类型/线路键名仍是 landTransportType / routeCategory;值用中文原文。
|
||||
* 禁止:按空运要包装件数;把分类当查价条件;直连 TMS;再把中文翻成 CROSS_BORDER_*。
|
||||
*/
|
||||
public final class TmsLandSegmentMapper {
|
||||
@@ -27,9 +27,12 @@ public final class TmsLandSegmentMapper {
|
||||
/** 体积+重量那一套。 */
|
||||
private static final Set<String> LCL = new HashSet<String>(
|
||||
Arrays.asList("国内运输拼车", "跨境集拼"));
|
||||
/** 车型/数量那一套。中港零担走这里;中港整车另判。 */
|
||||
/** 车型/数量那一套:国内整车、跨境整车、中港整车。 */
|
||||
private static final Set<String> FTL = new HashSet<String>(
|
||||
Arrays.asList("国内运输整车", "跨境整车", "中港零担/集拼"));
|
||||
Arrays.asList("国内运输整车", "跨境整车", "中港整车"));
|
||||
/** 仅中港零担/集拼:询价不采车型/数量,出站也不带。 */
|
||||
private static final Set<String> HK_NO_VEHICLE = new HashSet<String>(
|
||||
Arrays.asList("中港零担/集拼"));
|
||||
|
||||
private static final Pattern VEHICLE_SPLIT = Pattern.compile("\\s*[/xX×**]\\s*");
|
||||
private static final Pattern TAIL_QTY = Pattern.compile("^(.*?)[\\s,,]+(\\d+)\\s*(台|辆)?$");
|
||||
@@ -54,8 +57,8 @@ public final class TmsLandSegmentMapper {
|
||||
if (LCL.contains(type)) {
|
||||
put(seg, "volumeCbm", TmsV2RequestMapper.asVolumeCbm(source.get("体积")));
|
||||
put(seg, "weightKg", TmsV2RequestMapper.asPositiveDecimal(source.get("毛重")));
|
||||
} else if ("中港整车".equals(type)) {
|
||||
// 中港整车:询价不要车型/数量,出站也不带
|
||||
} else if (HK_NO_VEHICLE.contains(type)) {
|
||||
// 中港零担/集拼:询价不要车型/数量,出站也不带
|
||||
} else if (FTL.contains(type)) {
|
||||
putVehicleQty(source, seg);
|
||||
}
|
||||
@@ -68,7 +71,7 @@ public final class TmsLandSegmentMapper {
|
||||
|
||||
/**
|
||||
* 陆运出站拦网:工单号、报价日期、起运目的品名、类型、线路,再加当前类型货物。
|
||||
* 不要包装、不要运输分类。中港整车不要车型/数量。
|
||||
* 不要包装、不要运输分类。仅中港零担/集拼不要车型/数量。
|
||||
*
|
||||
* @param facts 用于读运输类型;禁止把分类写进 segment
|
||||
*/
|
||||
@@ -98,13 +101,13 @@ public final class TmsLandSegmentMapper {
|
||||
|| LCL.contains(typeOnSeg)
|
||||
|| "DOMESTIC_LTL".equals(typeOnSeg)
|
||||
|| "CROSS_BORDER_LTL".equals(typeOnSeg);
|
||||
boolean hkFull = "中港整车".equals(cnType) || "中港整车".equals(typeOnSeg);
|
||||
// 中港细类出站已原文直传;仍同时认 facts 与段上的类型,兼容旧「中港」快照。
|
||||
boolean hkNoVehicle = HK_NO_VEHICLE.contains(cnType)
|
||||
|| HK_NO_VEHICLE.contains(typeOnSeg);
|
||||
boolean ftl = FTL.contains(cnType)
|
||||
|| FTL.contains(typeOnSeg)
|
||||
|| "DOMESTIC_FTL".equals(typeOnSeg)
|
||||
|| "CROSS_BORDER_FTL".equals(typeOnSeg)
|
||||
|| "CHINA_HK".equals(typeOnSeg)
|
||||
|| "中港零担/集拼".equals(cnType);
|
||||
|| "CROSS_BORDER_FTL".equals(typeOnSeg);
|
||||
if (lcl) {
|
||||
if (seg.get("volumeCbm") == null) {
|
||||
missing.add("volumeCbm");
|
||||
@@ -112,8 +115,8 @@ public final class TmsLandSegmentMapper {
|
||||
if (seg.get("weightKg") == null) {
|
||||
missing.add("weightKg");
|
||||
}
|
||||
} else if (hkFull) {
|
||||
// 中港整车不拦车型/数量
|
||||
} else if (hkNoVehicle) {
|
||||
// 中港零担/集拼:不拦车型/数量
|
||||
} else if (ftl) {
|
||||
if (blank(seg.get("vehicleType"))) {
|
||||
missing.add("vehicleType");
|
||||
@@ -126,7 +129,7 @@ public final class TmsLandSegmentMapper {
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容旧调用:没有 facts 时按段上的类型拦;中港整车必须走四参才能放过车型。
|
||||
* 兼容旧调用:没有 facts 时按段上的类型拦;中港零担须走四参才能放过车型。
|
||||
*/
|
||||
public static List<String> missingQuery(String workOrderNo, String quoteDate,
|
||||
Map<String, Object> segment) {
|
||||
@@ -163,9 +166,11 @@ public final class TmsLandSegmentMapper {
|
||||
}
|
||||
|
||||
/**
|
||||
* 例:9米6/1、9米6*2、9米6 1台。拆不出台数时默认 1。
|
||||
* 例:9米6/1、9米6*2、9米6 1台、10T车/1。拆不出台数时默认 1。
|
||||
* <p>
|
||||
* 出单回填无 TMS 段时,也要按同一规则从询价「车型/数量」拆出 vehicleType。
|
||||
*/
|
||||
static String[] splitVehicleQty(String raw) {
|
||||
public static String[] splitVehicleQty(String raw) {
|
||||
String text = text(raw);
|
||||
if (text.isEmpty()) {
|
||||
return new String[] {"", "1"};
|
||||
|
||||
+1
-1
@@ -160,7 +160,7 @@ public final class TmsV2RequestMapper {
|
||||
}
|
||||
|
||||
/**
|
||||
* 陆运出站:类型+线路用销售中文;中港整车、中港零担/集拼收成「中港」。线路字段名是 routeCategory。
|
||||
* 陆运出站:类型+线路用销售中文(含中港整车 / 中港零担/集拼细类)。线路字段名是 routeCategory。
|
||||
* 分类和中港装载即使误放在 source 也不抄走。
|
||||
*/
|
||||
private static void applyLandFields(Map<String, Object> source, Map<String, Object> target) {
|
||||
|
||||
+3
@@ -166,6 +166,9 @@
|
||||
"体积": "volume_cbm",
|
||||
"体积(CBM)": "volume_cbm",
|
||||
"体 积": "volume_cbm",
|
||||
"计费重": "chargeable_weight_kg",
|
||||
"计费重量": "chargeable_weight_kg",
|
||||
"Chargeable Weight": "chargeable_weight_kg",
|
||||
"费用总计": "total_amount",
|
||||
"报价合计": "total_amount",
|
||||
"RFQ No": "quote_no",
|
||||
|
||||
+8
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": "tms-domestic-vehicle-mapping-v1",
|
||||
"description": "TMS车型/别名 → 国内整车报价单表头车型;来源 tms国内车型映射表 -ai.xlsx",
|
||||
"description": "TMS车型/别名 → 国内整车报价单表头车型;来源 tms国内车型映射表 -ai.xlsx。domesticHeaderToHkHeader 为国内表头与中港表头对等(填表时双向)。",
|
||||
"templateToDomesticAliases": {
|
||||
"4.2米/3T": [
|
||||
"3T危险品",
|
||||
@@ -99,6 +99,13 @@
|
||||
"3.5T"
|
||||
]
|
||||
},
|
||||
"domesticHeaderToHkHeader": {
|
||||
"6.8/5T": "5米/5T",
|
||||
"6.8米/5T": "5米/5T",
|
||||
"7.6米/8T": "6.5米/8T",
|
||||
"9.6米/10T": "7.8米/10T",
|
||||
"12.5米/40尺": "12米/40尺"
|
||||
},
|
||||
"domesticAliasToTemplate": {
|
||||
"3T危险品": "4.2米/3T",
|
||||
"4.2厢车": "4.2米/3T",
|
||||
|
||||
+338
@@ -17,6 +17,7 @@ import org.junit.jupiter.api.Test;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
@@ -25,6 +26,7 @@ import org.apache.poi.ss.usermodel.CellType;
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
@@ -119,6 +121,97 @@ public class QuoteTemplateFillTest {
|
||||
assertEquals("CNY", ((JSONArray) fee).getJSONObject(0).getString("currency"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 调试 2672ec / 25b094:体积=1.1*2.1*2.2 时,填表事实里计费重不能原样等于体积。
|
||||
* 同时验证标签回填是否把体积写进「计费重」右侧格。
|
||||
* WO007:竖排「体积 / 计费重 / 派送地址」+ 映射写计费重 + 两次 writeFactsByLabel,
|
||||
* 不得把尺寸式盖进计费重、再把 847 挤进派送地址。
|
||||
*/
|
||||
@Test
|
||||
public void airDimensionVolumeMustNotCopyIntoChargeableWeight() throws Exception {
|
||||
Map<String, Object> facts = new HashMap<>();
|
||||
facts.put("运输方式", "空运");
|
||||
facts.put("品名", "茶叶");
|
||||
facts.put("件数", "21件");
|
||||
facts.put("毛重", "11 KG");
|
||||
facts.put("体积", "1.1*2.1*2.2");
|
||||
Map<String, Object> quote = new HashMap<>();
|
||||
quote.put("currency", "CNY");
|
||||
quote.put("total", "100");
|
||||
quote.put("chargeableWeightKg", "847.1694");
|
||||
JSONObject fill = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl
|
||||
.buildFillFacts("WO202610180046", facts, quote, "WuJiLin", "AIR");
|
||||
assertEquals("1.1*2.1*2.2", fill.getString("volume_cbm"));
|
||||
assertEquals("847.1694", fill.getString("chargeable_weight_kg"));
|
||||
assertFalse(QuoteTemplateExcelSupport.looksLikeVehicleDimensionText("1.1*2.1*2.2"),
|
||||
"空运体积尺寸式不得被当成陆运车型尺寸");
|
||||
assertTrue(QuoteTemplateExcelSupport.looksLikeVehicleDimensionText("4.1*2.1*2.3M"),
|
||||
"陆运车型尺寸必须仍识别");
|
||||
|
||||
// 空运常见:标签横排「体积 | 计费重」,中间没有独立空值格。
|
||||
Path dir = Files.createTempDirectory("air-cw-vol");
|
||||
Path src = dir.resolve("tpl.xlsx");
|
||||
Path outXlsx = dir.resolve("out.xlsx");
|
||||
try (XSSFWorkbook wb = new XSSFWorkbook()) {
|
||||
Sheet sheet = wb.createSheet("报价单");
|
||||
Row labels = sheet.createRow(5);
|
||||
labels.createCell(0).setCellValue("体积");
|
||||
labels.createCell(1).setCellValue("计费重");
|
||||
Row values = sheet.createRow(6);
|
||||
values.createCell(0).setCellValue("");
|
||||
values.createCell(1).setCellValue("");
|
||||
try (java.io.OutputStream os = Files.newOutputStream(src)) {
|
||||
wb.write(os);
|
||||
}
|
||||
}
|
||||
JSONObject mapping = new JSONObject(true);
|
||||
mapping.put("fields", new JSONObject(true));
|
||||
QuoteTemplateExcelSupport.fillWorkbook(src, outXlsx, mapping, fill);
|
||||
try (Workbook got = org.apache.poi.ss.usermodel.WorkbookFactory.create(Files.newInputStream(outXlsx))) {
|
||||
Sheet sheet = got.getSheetAt(0);
|
||||
DataFormatter fmt = new DataFormatter();
|
||||
String cwLabelOrVal = fmt.formatCellValue(sheet.getRow(5).getCell(1));
|
||||
assertFalse("1.1*2.1*2.2".equals(cwLabelOrVal),
|
||||
"体积标签回填不得把尺寸式写入计费重格,实际=" + cwLabelOrVal);
|
||||
}
|
||||
|
||||
// 竖排货物信息(空运正式模板 Sheet2):体积 / 计费重 / 派送地址
|
||||
Path src2 = dir.resolve("tpl-vertical.xlsx");
|
||||
Path out2 = dir.resolve("out-vertical.xlsx");
|
||||
try (XSSFWorkbook wb = new XSSFWorkbook()) {
|
||||
Sheet sheet = wb.createSheet("Sheet2");
|
||||
Row r11 = sheet.createRow(10);
|
||||
r11.createCell(0).setCellValue("体 积:");
|
||||
r11.createCell(1).setCellValue("");
|
||||
r11.createCell(2).setCellValue("CBM");
|
||||
Row r12 = sheet.createRow(11);
|
||||
r12.createCell(0).setCellValue(" 计 费 重:");
|
||||
r12.createCell(1).setCellValue("");
|
||||
r12.createCell(2).setCellValue("KG");
|
||||
Row r13 = sheet.createRow(12);
|
||||
r13.createCell(0).setCellValue(" 派送地址:");
|
||||
r13.createCell(1).setCellValue("");
|
||||
try (java.io.OutputStream os = Files.newOutputStream(src2)) {
|
||||
wb.write(os);
|
||||
}
|
||||
}
|
||||
JSONObject mapping2 = new JSONObject(true);
|
||||
JSONObject fields2 = new JSONObject(true);
|
||||
fields2.put("chargeable_weight_kg", "Sheet2!B12");
|
||||
mapping2.put("fields", fields2);
|
||||
QuoteTemplateExcelSupport.fillWorkbook(src2, out2, mapping2, fill);
|
||||
try (Workbook got = org.apache.poi.ss.usermodel.WorkbookFactory.create(Files.newInputStream(out2))) {
|
||||
Sheet sheet = got.getSheet("Sheet2");
|
||||
DataFormatter fmt = new DataFormatter();
|
||||
assertEquals("1.1*2.1*2.2", fmt.formatCellValue(sheet.getRow(10).getCell(1)));
|
||||
assertEquals("847.1694", fmt.formatCellValue(sheet.getRow(11).getCell(1)));
|
||||
assertFalse("847.1694".equals(fmt.formatCellValue(sheet.getRow(12).getCell(1))),
|
||||
"计费重不得被挤进派送地址");
|
||||
assertFalse("1.1*2.1*2.2".equals(fmt.formatCellValue(sheet.getRow(11).getCell(1))),
|
||||
"计费重格不得被体积尺寸式覆盖");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 空运没有时效:即便 TMS 回了 eta,出单也不填 transit_* / 时效。
|
||||
* 报价有效期仍要留下。
|
||||
@@ -887,6 +980,52 @@ public class QuoteTemplateFillTest {
|
||||
assertEquals("7918.25", out.getString("total_amount"));
|
||||
}
|
||||
|
||||
/**
|
||||
* WO036:航线在舱位价上再加增点费,total=明细之和(4966.40)大于 salePrice(3966.40)。
|
||||
* 预估费用必须保留航线总价,禁止再盖回 TMS。
|
||||
*/
|
||||
@Test
|
||||
public void buildFillFactsAirKeepsAirlineTotalWhenAboveSalePrice() {
|
||||
Map<String, Object> facts = new HashMap<>();
|
||||
facts.put("运输方式", "空运");
|
||||
facts.put("品名", "普货");
|
||||
Map<String, Object> option = new HashMap<>();
|
||||
option.put("optionNo", "AIR-OPT01");
|
||||
option.put("salePrice", 3966.40);
|
||||
option.put("currency", "CNY");
|
||||
Map<String, Object> quote = new HashMap<>();
|
||||
quote.put("total", "4966.40");
|
||||
quote.put("currency", "CNY");
|
||||
quote.put("airOptions", java.util.Collections.singletonList(option));
|
||||
quote.put("selected_air_option", option);
|
||||
quote.put("fee_lines", java.util.Arrays.asList(
|
||||
new HashMap<String, Object>() {{
|
||||
put("name", "空运费");
|
||||
put("amount", "3667.40");
|
||||
put("currency", "CNY");
|
||||
}},
|
||||
new HashMap<String, Object>() {{
|
||||
put("name", "人工费");
|
||||
put("amount", "100");
|
||||
put("currency", "CNY");
|
||||
}},
|
||||
new HashMap<String, Object>() {{
|
||||
put("name", "打板费");
|
||||
put("amount", "199");
|
||||
put("currency", "CNY");
|
||||
}},
|
||||
new HashMap<String, Object>() {{
|
||||
put("name", "增点费");
|
||||
put("amount", "1000");
|
||||
put("currency", "CNY");
|
||||
}}
|
||||
));
|
||||
quote.put("chargeableWeightKg", "333.4");
|
||||
JSONObject out = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl
|
||||
.buildFillFacts("WO202610180036", facts, quote, "WuJiLin", "AIR");
|
||||
assertEquals("4966.40", out.getString("total_amount"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void feeQuantityDefaultsToOneWhenBlank() {
|
||||
assertEquals("1", QuoteTemplateExcelSupport.feeQuantityOrDefault(null, "提送货费"));
|
||||
@@ -1008,6 +1147,70 @@ public class QuoteTemplateFillTest {
|
||||
|| "16670.0".equals(rows.getJSONObject(1).getString("quantity")));
|
||||
}
|
||||
|
||||
/**
|
||||
* WO202610270003:TMS 多航班时顶层 feeItems 含 14.5/15/15;
|
||||
* 出单必须跟 selected_air_option(TH739 / AIR-OPT03 / 15),禁止写列表首条 14.5。
|
||||
*/
|
||||
@Test
|
||||
public void parseAirChargesPrefersSelectedOptionUnitPrice() {
|
||||
String airOptions = "["
|
||||
+ "{\"optionNo\":\"AIR-OPT01\",\"flightNo\":\"TH641\",\"originAirportCode\":\"HKG\","
|
||||
+ "\"salePrice\":37465.83,\"currency\":\"CNY\",\"chargeableWeightKg\":2583.85,"
|
||||
+ "\"departureCharges\":[]},"
|
||||
+ "{\"optionNo\":\"AIR-OPT02\",\"flightNo\":\"TH6187\",\"originAirportCode\":\"SZX\","
|
||||
+ "\"salePrice\":38757.75,\"currency\":\"CNY\",\"chargeableWeightKg\":2583.85,"
|
||||
+ "\"departureCharges\":[]},"
|
||||
+ "{\"optionNo\":\"AIR-OPT03\",\"flightNo\":\"TH739\",\"originAirportCode\":\"NKG\","
|
||||
+ "\"salePrice\":38757.75,\"currency\":\"CNY\",\"chargeableWeightKg\":2583.85,"
|
||||
+ "\"departureCharges\":[]}"
|
||||
+ "]";
|
||||
String topFees = "["
|
||||
+ "{\"feeCode\":\"AIR_FREIGHT\",\"feeName\":\"空运费\",\"airOptionNo\":\"AIR-OPT01\","
|
||||
+ "\"unitPrice\":14.5,\"quantity\":2583.85,\"amount\":37465.83,\"unit\":\"KG\",\"currency\":\"CNY\"},"
|
||||
+ "{\"feeCode\":\"AIR_FREIGHT\",\"feeName\":\"空运费\",\"airOptionNo\":\"AIR-OPT02\","
|
||||
+ "\"unitPrice\":15.0,\"quantity\":2583.85,\"amount\":38757.75,\"unit\":\"KG\",\"currency\":\"CNY\"},"
|
||||
+ "{\"feeCode\":\"AIR_FREIGHT\",\"feeName\":\"空运费\",\"airOptionNo\":\"AIR-OPT03\","
|
||||
+ "\"unitPrice\":15.0,\"quantity\":2583.85,\"amount\":38757.75,\"unit\":\"KG\",\"currency\":\"CNY\"}"
|
||||
+ "]";
|
||||
String selected = "{\"optionNo\":\"AIR-OPT03\",\"flightNo\":\"TH739\",\"originAirportCode\":\"NKG\","
|
||||
+ "\"salePrice\":38757.75,\"currency\":\"CNY\",\"chargeableWeightKg\":2583.85,"
|
||||
+ "\"departureCharges\":[]}";
|
||||
JSONArray rows = QuoteTemplateExcelSupport.parseAirDepartureChargeRows(
|
||||
airOptions, null, topFees, selected, "2583.85", "CNY");
|
||||
assertFalse(rows.isEmpty());
|
||||
assertEquals("空运费", rows.getJSONObject(0).getString("name"));
|
||||
assertEquals("空运段", rows.getJSONObject(0).getString("project"));
|
||||
String unitPrice = rows.getJSONObject(0).getString("unit_price");
|
||||
assertTrue(unitPrice.startsWith("15"), "选定舱位单价应为 15,实际=" + unitPrice);
|
||||
String amount = rows.getJSONObject(0).getString("amount");
|
||||
assertTrue(amount.startsWith("38757.75"), "选定舱位金额应为 38757.75,实际=" + amount);
|
||||
|
||||
// buildFillFacts 同样带上 selected,报价单事实不得落到 14.5
|
||||
Map<String, Object> facts = new LinkedHashMap<>();
|
||||
facts.put("起运港", "南京");
|
||||
facts.put("目的港", "吉隆坡");
|
||||
facts.put("计费重", "2583.85");
|
||||
Map<String, Object> quote = new LinkedHashMap<>();
|
||||
quote.put("source", "TMS");
|
||||
quote.put("total", "38757.75");
|
||||
quote.put("currency", "CNY");
|
||||
quote.put("chargeableWeightKg", "2583.8500");
|
||||
quote.put("airOptions", JSON.parseArray(airOptions));
|
||||
quote.put("feeItems", JSON.parseArray(topFees));
|
||||
quote.put("selected_air_option", JSON.parseObject(selected));
|
||||
quote.put("fee_lines", JSON.parseArray(
|
||||
"[{\"name\":\"空运费\",\"amount\":\"CNY 38757.75\",\"currency\":\"CNY\","
|
||||
+ "\"unit\":\"KG\",\"unit_price\":15.0,\"quantity\":2583.85}]"));
|
||||
JSONObject out = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl
|
||||
.buildFillFacts("WO202610270003", facts, quote, "Tester", "AIR");
|
||||
JSONArray feeRows = out.getJSONArray("fee_rows");
|
||||
assertNotNull(feeRows);
|
||||
assertFalse(feeRows.isEmpty());
|
||||
String fillPrice = feeRows.getJSONObject(0).getString("unit_price");
|
||||
assertTrue(fillPrice != null && fillPrice.startsWith("15"),
|
||||
"出单事实单价应为 15,实际=" + fillPrice);
|
||||
}
|
||||
|
||||
/**
|
||||
* 人工只报起运港费:必须落到模板「起运港」段,禁止占掉「空运段」首行并把骨架改成起运港。
|
||||
*/
|
||||
@@ -3208,6 +3411,27 @@ public class QuoteTemplateFillTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 中港:TMS 别名经国内映射后再对等中港表头;跨境:吨位列与 3T 族境外 3.5T;禁止 5T 误命中 3.5T。
|
||||
*/
|
||||
@Test
|
||||
public void hkAndCrossBorderVehicleMappingPeers() {
|
||||
assertEquals("12米/40尺", QuoteTemplateExcelSupport.hkPeerHeaderOf("12.5米/40尺"));
|
||||
assertEquals("12.5米/40尺", QuoteTemplateExcelSupport.hkPeerHeaderOf("12米/40尺"));
|
||||
assertEquals("5T", QuoteTemplateExcelSupport.extractVehicleTonnage("6.8/5T"));
|
||||
assertTrue(QuoteTemplateExcelSupport.vehicleTypeMatchesSection("40HQ", "12米/40尺", ""));
|
||||
assertTrue(QuoteTemplateExcelSupport.vehicleTypeMatchesSection("6.8厢车", "5米/5T", ""));
|
||||
assertTrue(QuoteTemplateExcelSupport.vehicleTypeMatchesSection("9.6厢车", "7.8米/10T", ""));
|
||||
assertTrue(QuoteTemplateExcelSupport.vehicleTypeMatchesSection("6.8厢车", "5T", "domestic"));
|
||||
assertTrue(QuoteTemplateExcelSupport.vehicleTypeMatchesSection("4.2厢车", "3.5T", "overseas"));
|
||||
assertEquals("3.5T", QuoteTemplateExcelSupport.mapDomesticVehicleToOverseas("4.2厢车"));
|
||||
assertFalse(QuoteTemplateExcelSupport.vehicleTypeMatchesSection("5T", "3.5T", "overseas"));
|
||||
assertTrue(QuoteTemplateExcelSupport.vehicleTypeMatchesSection("5T", "5T", "overseas"));
|
||||
// 国内整车原路径不回归
|
||||
assertTrue(QuoteTemplateExcelSupport.vehicleTypeMatchesSection("40HQ", "12.5米/40尺", ""));
|
||||
assertTrue(QuoteTemplateExcelSupport.vehicleTypeMatchesSection("6.8厢车", "6.8/5T", ""));
|
||||
}
|
||||
|
||||
/**
|
||||
* 国内整车:车型格「4.2米/3T」是浅蓝表头,出单后不能变成业务员那种深蓝。
|
||||
*/
|
||||
@@ -3343,4 +3567,118 @@ public class QuoteTemplateFillTest {
|
||||
assertEquals("8%", QuoteTemplateExcelSupport.formatLandPercentDisplay("8.0"));
|
||||
assertEquals("含税", QuoteTemplateExcelSupport.formatLandTaxIncludedDisplay("1"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 空运起运港费用多于模板预留行时:必须在「起运港」段内(「其他」之前)增行,
|
||||
* 禁止把多余费目挤到「其他」之后。
|
||||
*/
|
||||
@Test
|
||||
public void airManyOriginFeesGrowInsideOriginBandBeforeOther() throws Exception {
|
||||
Path src = Files.createTempFile("quote-air-grow-src", ".xlsx");
|
||||
Path out = Files.createTempFile("quote-air-grow-out", ".xlsx");
|
||||
try (Workbook wb = new XSSFWorkbook()) {
|
||||
Sheet sheet = wb.createSheet("Sheet2");
|
||||
org.apache.poi.ss.usermodel.Row head = sheet.createRow(15);
|
||||
head.createCell(0).setCellValue("项目");
|
||||
head.createCell(1).setCellValue("费用名称");
|
||||
head.createCell(2).setCellValue("单位");
|
||||
head.createCell(4).setCellValue("单价");
|
||||
head.createCell(5).setCellValue("数量");
|
||||
head.createCell(6).setCellValue("金额");
|
||||
// 空运段 1 行
|
||||
org.apache.poi.ss.usermodel.Row rAir = sheet.createRow(16);
|
||||
rAir.createCell(0).setCellValue("空运段");
|
||||
rAir.createCell(1).setCellValue("样例空运费");
|
||||
// 起运港仅预留 2 行(合并),远少于 5 条起运港费
|
||||
org.apache.poi.ss.usermodel.Row rPol1 = sheet.createRow(17);
|
||||
rPol1.createCell(0).setCellValue("起运港");
|
||||
rPol1.createCell(1).setCellValue("样例1");
|
||||
org.apache.poi.ss.usermodel.Row rPol2 = sheet.createRow(18);
|
||||
rPol2.createCell(0).setCellValue("起运港");
|
||||
rPol2.createCell(1).setCellValue("样例2");
|
||||
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(17, 18, 0, 0));
|
||||
org.apache.poi.ss.usermodel.Row rOther = sheet.createRow(19);
|
||||
rOther.createCell(0).setCellValue("其他");
|
||||
org.apache.poi.ss.usermodel.Row total = sheet.createRow(22);
|
||||
total.createCell(0).setCellValue("预估费用");
|
||||
try (java.io.OutputStream os = Files.newOutputStream(src)) {
|
||||
wb.write(os);
|
||||
}
|
||||
}
|
||||
JSONObject mapping = new JSONObject();
|
||||
mapping.put("fields", new JSONObject());
|
||||
JSONObject region = new JSONObject(true);
|
||||
region.put("id", "air_fee_detail");
|
||||
region.put("anchor", "Sheet2!A17");
|
||||
region.put("stopAtText", java.util.Collections.singletonList("预估费用"));
|
||||
region.put("grow", "insert_before_stop");
|
||||
JSONObject cols = new JSONObject(true);
|
||||
cols.put("project", "A");
|
||||
cols.put("name", "B");
|
||||
cols.put("unit", "C");
|
||||
cols.put("unit_price", "E");
|
||||
cols.put("quantity", "F");
|
||||
cols.put("amount", "G");
|
||||
region.put("columns", cols);
|
||||
mapping.put("feeRegions", new JSONArray(java.util.Collections.singletonList(region)));
|
||||
|
||||
JSONObject facts = new JSONObject(true);
|
||||
facts.put("total_amount", "5000");
|
||||
facts.put("currency", "CNY");
|
||||
JSONArray feeRows = new JSONArray();
|
||||
JSONObject air = new JSONObject(true);
|
||||
air.put("project", "空运段");
|
||||
air.put("name", "空运费");
|
||||
air.put("unit", "KG");
|
||||
air.put("unit_price", "10");
|
||||
air.put("quantity", "100");
|
||||
air.put("amount", "1000");
|
||||
feeRows.add(air);
|
||||
String[] polNames = {"人工费", "打板费", "增点费", "电放费", "文件费", "报关费", "单证费"};
|
||||
for (String n : polNames) {
|
||||
JSONObject one = new JSONObject(true);
|
||||
one.put("project", "起运港");
|
||||
one.put("name", n);
|
||||
one.put("unit", "次");
|
||||
one.put("unit_price", "10");
|
||||
one.put("quantity", "100");
|
||||
one.put("amount", "1000");
|
||||
feeRows.add(one);
|
||||
}
|
||||
facts.put("fee_rows", feeRows);
|
||||
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
|
||||
try (Workbook got = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||
Sheet sheet = got.getSheet("Sheet2");
|
||||
DataFormatter fmt = new DataFormatter();
|
||||
int otherRow = -1;
|
||||
int lastPolFeeRow = -1;
|
||||
java.util.List<String> polFees = new java.util.ArrayList<>();
|
||||
for (int r = 16; r <= sheet.getLastRowNum(); r++) {
|
||||
org.apache.poi.ss.usermodel.Row row = sheet.getRow(r);
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
String proj = fmt.formatCellValue(row.getCell(0));
|
||||
String name = fmt.formatCellValue(row.getCell(1));
|
||||
if ("其他".equals(proj.trim()) || "其它".equals(proj.trim())) {
|
||||
otherRow = r;
|
||||
}
|
||||
if (java.util.Arrays.asList(polNames).contains(name)) {
|
||||
polFees.add(name);
|
||||
lastPolFeeRow = r;
|
||||
// 合并格读原点:物理行可能空白,用骨架工具读
|
||||
String band = QuoteTemplateExcelSupport.normalizeAirProjectLabel(
|
||||
QuoteTemplateExcelSupport.projectLabelAt(sheet, r, 0));
|
||||
assertEquals("起运港", band, "fee " + name + " must stay in 起运港 band at row " + r);
|
||||
}
|
||||
}
|
||||
assertEquals(7, polFees.size(), "all 7 origin fees written");
|
||||
assertTrue(otherRow > 0, "其他 row present");
|
||||
assertTrue(lastPolFeeRow < otherRow,
|
||||
"last origin fee row " + lastPolFeeRow + " must be before 其他 row " + otherRow);
|
||||
} finally {
|
||||
Files.deleteIfExists(src);
|
||||
Files.deleteIfExists(out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
-1
@@ -248,6 +248,12 @@ public class InquiryTmsCabinServiceTest {
|
||||
"锁舱参数不完整,缺少:航班号。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。",
|
||||
InquiryTmsCabinService.missingLockParamReason("AIR-OPT01", "CZ", ""));
|
||||
assertEquals("", InquiryTmsCabinService.missingLockParamReason("AIR-OPT01", "CZ", "CZ327"));
|
||||
assertEquals(
|
||||
"锁舱参数不完整,缺少:舱位编号、航司、航班号、报价日期。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327,2026-10-18。",
|
||||
InquiryTmsCabinService.missingLockParamReason("", "", "", true));
|
||||
assertEquals(
|
||||
"锁舱参数不完整,缺少:报价日期。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327,2026-10-18。",
|
||||
InquiryTmsCabinService.missingLockParamReason("AIR-OPT01", "CZ", "CZ327", true));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -361,7 +367,9 @@ public class InquiryTmsCabinServiceTest {
|
||||
String raw = "锁舱参数不完整,缺少:有效期validUntil。请按「AIR-OPT01 CZ 航班CZ327」补舱位、航司和航班号后再锁。";
|
||||
String why = InquiryTmsCabinService.rewriteManualLockIncompleteReason(
|
||||
raw, ticket, req, "TMS_NO_QUOTE_1002", "标准运价库无匹配报价");
|
||||
assertEquals("报价日期为空,请补充报价日期后重新锁舱", why);
|
||||
assertEquals(
|
||||
"锁舱参数不完整,缺少:舱位编号、航司、航班号、报价日期。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327,2026-10-18。",
|
||||
why);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+3
-3
@@ -28,9 +28,9 @@ public class TmsLandEnumMapperTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void hkTypesCollapseToZhonggang() {
|
||||
assertEquals("中港", TmsLandEnumMapper.transportType("中港整车"));
|
||||
assertEquals("中港", TmsLandEnumMapper.transportType("中港零担/集拼"));
|
||||
public void hkTypesPassThroughFineClass() {
|
||||
assertEquals("中港整车", TmsLandEnumMapper.transportType("中港整车"));
|
||||
assertEquals("中港零担/集拼", TmsLandEnumMapper.transportType("中港零担/集拼"));
|
||||
assertEquals("中港集拼", TmsLandEnumMapper.transportType("中港集拼"));
|
||||
}
|
||||
|
||||
|
||||
+39
-17
@@ -37,6 +37,42 @@ public class TmsLandSegmentMapperTest {
|
||||
|
||||
@Test
|
||||
public void landFtlUsesVehicleNotVolume() {
|
||||
Map<String, Object> facts = new HashMap<String, Object>();
|
||||
facts.put("运输类型", "国内运输整车");
|
||||
facts.put("线路类别", "国内长途/零担");
|
||||
facts.put("起运港", "广州");
|
||||
facts.put("目的港", "深圳");
|
||||
facts.put("品名", "衣服");
|
||||
facts.put("车型/数量", "9米6/1");
|
||||
facts.put("体积", "99");
|
||||
Map<String, Object> seg = TmsLandSegmentMapper.build(facts);
|
||||
assertEquals("国内运输整车", seg.get("landTransportType"));
|
||||
assertEquals("9米6", seg.get("vehicleType"));
|
||||
assertNull(seg.get("volumeCbm"));
|
||||
assertFalse(seg.containsKey("hkLoadType"));
|
||||
assertEquals(Integer.valueOf(1), seg.get("quantity"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void hkFullTruckRequiresVehicle() {
|
||||
Map<String, Object> facts = new HashMap<String, Object>();
|
||||
facts.put("运输类型", "中港整车");
|
||||
facts.put("线路类别", "中港/中亚/中欧");
|
||||
facts.put("起运港", "广州");
|
||||
facts.put("目的港", "香港");
|
||||
facts.put("品名", "衣服");
|
||||
facts.put("车型/数量", "9米6/1");
|
||||
Map<String, Object> seg = TmsLandSegmentMapper.build(facts);
|
||||
assertEquals("中港整车", seg.get("landTransportType"));
|
||||
assertEquals("9米6", seg.get("vehicleType"));
|
||||
assertEquals(Integer.valueOf(1), seg.get("quantity"));
|
||||
java.util.List<String> missing = TmsLandSegmentMapper.missingQuery(
|
||||
"WO202609170005", "2026-09-17", seg, facts);
|
||||
assertEquals(java.util.Collections.emptyList(), missing);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void hkLclOmitsVehicle() {
|
||||
Map<String, Object> facts = new HashMap<String, Object>();
|
||||
facts.put("运输类型", "中港零担/集拼");
|
||||
facts.put("线路类别", "中港/中亚/中欧");
|
||||
@@ -46,25 +82,11 @@ public class TmsLandSegmentMapperTest {
|
||||
facts.put("车型/数量", "9米6/1");
|
||||
facts.put("体积", "99");
|
||||
Map<String, Object> seg = TmsLandSegmentMapper.build(facts);
|
||||
assertEquals("中港", seg.get("landTransportType"));
|
||||
assertEquals("9米6", seg.get("vehicleType"));
|
||||
assertNull(seg.get("volumeCbm"));
|
||||
assertFalse(seg.containsKey("hkLoadType"));
|
||||
assertEquals(Integer.valueOf(1), seg.get("quantity"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void hkFullTruckOmitsVehicle() {
|
||||
Map<String, Object> facts = new HashMap<String, Object>();
|
||||
facts.put("运输类型", "中港整车");
|
||||
facts.put("线路类别", "中港/中亚/中欧");
|
||||
facts.put("起运港", "广州");
|
||||
facts.put("目的港", "香港");
|
||||
facts.put("品名", "衣服");
|
||||
Map<String, Object> seg = TmsLandSegmentMapper.build(facts);
|
||||
assertEquals("中港", seg.get("landTransportType"));
|
||||
assertEquals("中港零担/集拼", seg.get("landTransportType"));
|
||||
assertNull(seg.get("vehicleType"));
|
||||
assertNull(seg.get("quantity"));
|
||||
assertNull(seg.get("volumeCbm"));
|
||||
assertFalse(seg.containsKey("hkLoadType"));
|
||||
java.util.List<String> missing = TmsLandSegmentMapper.missingQuery(
|
||||
"WO202609170005", "2026-09-17", seg, facts);
|
||||
assertEquals(java.util.Collections.emptyList(), missing);
|
||||
|
||||
@@ -129,31 +129,6 @@ export function createPermissionGuard(router: Router) {
|
||||
}
|
||||
|
||||
if (permissionStore.getIsDynamicAddedRoute) {
|
||||
// #region agent log
|
||||
fetch('http://127.0.0.1:7800/ingest/a69bf0d0-32e3-486e-80eb-ae1824ba17a1', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': 'a0b47d' },
|
||||
body: JSON.stringify({
|
||||
sessionId: 'a0b47d',
|
||||
runId: 'pre-fix',
|
||||
hypothesisId: 'H4-H5',
|
||||
location: 'permissionGuard.ts:alreadyAdded',
|
||||
message: '动态路由已添加,直接放行',
|
||||
data: {
|
||||
toPath: to.path,
|
||||
toName: String(to.name || ''),
|
||||
matched: (to.matched || []).map((m) => ({ path: m.path, name: String(m.name || '') })),
|
||||
hasDashRoute: router.getRoutes().some((r) => String(r.path).includes('inquiry/dashboard') || String(r.name) === 'inquiry-dashboard'),
|
||||
routeNamesSample: router
|
||||
.getRoutes()
|
||||
.map((r) => String(r.name || ''))
|
||||
.filter((n) => n.includes('inquiry'))
|
||||
.slice(0, 20),
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
}).catch(() => {});
|
||||
// #endregion
|
||||
next();
|
||||
return;
|
||||
}
|
||||
@@ -168,32 +143,6 @@ export function createPermissionGuard(router: Router) {
|
||||
|
||||
permissionStore.setDynamicAddedRoute(true);
|
||||
|
||||
// #region agent log
|
||||
fetch('http://127.0.0.1:7800/ingest/a69bf0d0-32e3-486e-80eb-ae1824ba17a1', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': 'a0b47d' },
|
||||
body: JSON.stringify({
|
||||
sessionId: 'a0b47d',
|
||||
runId: 'pre-fix',
|
||||
hypothesisId: 'H3-H5',
|
||||
location: 'permissionGuard.ts:afterAddRoute',
|
||||
message: '刚动态加路由后的匹配情况',
|
||||
data: {
|
||||
toPath: to.path,
|
||||
toName: String(to.name || ''),
|
||||
toIs404: to.name === PAGE_NOT_FOUND_NAME,
|
||||
hasDashRoute: router.getRoutes().some((r) => String(r.path).includes('inquiry/dashboard') || String(r.name) === 'inquiry-dashboard'),
|
||||
inquiryRoutes: router
|
||||
.getRoutes()
|
||||
.filter((r) => String(r.path).includes('inquiry') || String(r.name || '').includes('inquiry'))
|
||||
.map((r) => ({ path: r.path, name: String(r.name || '') }))
|
||||
.slice(0, 30),
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
}).catch(() => {});
|
||||
// #endregion
|
||||
|
||||
if (to.name === PAGE_NOT_FOUND_NAME) {
|
||||
// 动态添加路由后,此处应当重定向到fullPath,否则会加载404页面内容
|
||||
next({ path: to.fullPath, replace: true, query: to.query });
|
||||
|
||||
@@ -461,79 +461,8 @@ export const usePermissionStore = defineStore({
|
||||
}
|
||||
// 只纠正/补挂「已有菜单或已有查看权限」的手写页,禁止灌入未授权路由
|
||||
const permCodes = this.getPermCodeList || [];
|
||||
// #region agent log
|
||||
const _dbgCollectPaths = (list: AppRouteRecordRaw[] = [], out: string[] = []): string[] => {
|
||||
for (const item of list || []) {
|
||||
out.push(String(item.path || ''));
|
||||
if (item.children?.length) _dbgCollectPaths(item.children as AppRouteRecordRaw[], out);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const _dbgPathsBefore = _dbgCollectPaths(routeList || []);
|
||||
const _dbgHasDashPerm = (permCodes as any[]).map(String).includes('inquiry:dashboard:view');
|
||||
fetch('http://127.0.0.1:7800/ingest/a69bf0d0-32e3-486e-80eb-ae1824ba17a1', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': 'a0b47d' },
|
||||
body: JSON.stringify({
|
||||
sessionId: 'a0b47d',
|
||||
runId: 'pre-fix',
|
||||
hypothesisId: 'H1-H2',
|
||||
location: 'permission.ts:buildRoutesAction:beforeEnsure',
|
||||
message: '菜单拉取后、ensure/prune 前',
|
||||
data: {
|
||||
menuCount: (routeList || []).length,
|
||||
pathCount: _dbgPathsBefore.length,
|
||||
hasDashboardPath: _dbgPathsBefore.some((p) => p.includes('inquiry/dashboard')),
|
||||
hasDashPerm: _dbgHasDashPerm,
|
||||
permCount: (permCodes as any[]).length,
|
||||
samplePerms: (permCodes as any[]).map(String).filter((c) => c.includes('inquiry')).slice(0, 20),
|
||||
samplePaths: _dbgPathsBefore.filter((p) => p.includes('inquiry')).slice(0, 20),
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
}).catch(() => {});
|
||||
// #endregion
|
||||
routeList = ensureInquiryHandwrittenRoutes(routeList || [], permCodes);
|
||||
// #region agent log
|
||||
const _dbgAfterEnsure = _dbgCollectPaths(routeList || []);
|
||||
fetch('http://127.0.0.1:7800/ingest/a69bf0d0-32e3-486e-80eb-ae1824ba17a1', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': 'a0b47d' },
|
||||
body: JSON.stringify({
|
||||
sessionId: 'a0b47d',
|
||||
runId: 'pre-fix',
|
||||
hypothesisId: 'H2',
|
||||
location: 'permission.ts:buildRoutesAction:afterEnsure',
|
||||
message: 'ensureInquiryHandwrittenRoutes 后',
|
||||
data: {
|
||||
hasDashboardPath: _dbgAfterEnsure.some((p) => p.includes('inquiry/dashboard')),
|
||||
inquiryPaths: _dbgAfterEnsure.filter((p) => p.includes('inquiry')).slice(0, 30),
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
}).catch(() => {});
|
||||
// #endregion
|
||||
routeList = pruneInquiryLeavesWithoutViewPerm(routeList, permCodes);
|
||||
// #region agent log
|
||||
const _dbgAfterPrune = _dbgCollectPaths(routeList || []);
|
||||
fetch('http://127.0.0.1:7800/ingest/a69bf0d0-32e3-486e-80eb-ae1824ba17a1', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': 'a0b47d' },
|
||||
body: JSON.stringify({
|
||||
sessionId: 'a0b47d',
|
||||
runId: 'pre-fix',
|
||||
hypothesisId: 'H1',
|
||||
location: 'permission.ts:buildRoutesAction:afterPrune',
|
||||
message: 'pruneInquiryLeavesWithoutViewPerm 后',
|
||||
data: {
|
||||
hasDashboardPath: _dbgAfterPrune.some((p) => p.includes('inquiry/dashboard')),
|
||||
inquiryPaths: _dbgAfterPrune.filter((p) => p.includes('inquiry')).slice(0, 30),
|
||||
prunedAway: _dbgAfterEnsure.includes('/inquiry/dashboard') && !_dbgAfterPrune.some((p) => p.includes('inquiry/dashboard')),
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
}).catch(() => {});
|
||||
// #endregion
|
||||
// 补挂后再标一次(ensure 新建的父级也要带 Vue3 标记)
|
||||
routeList = normalizeVue3MenuMarkers(routeList);
|
||||
// 组件地址前加斜杠处理 author: lsq date:2021-09-08
|
||||
@@ -550,33 +479,6 @@ export const usePermissionStore = defineStore({
|
||||
routeList = routeList.filter(routeRemoveIgnoreFilter);
|
||||
|
||||
routeList = flatMultiLevelRoutes(routeList);
|
||||
// #region agent log
|
||||
const _dbgFinal = _dbgCollectPaths(routeList || []);
|
||||
const _dbgTopComp = (routeList || []).slice(0, 5).map((r) => ({
|
||||
path: r.path,
|
||||
name: String(r.name || ''),
|
||||
compType: typeof r.component,
|
||||
childN: r.children?.length || 0,
|
||||
childPaths: (r.children || []).slice(0, 8).map((c) => c.path),
|
||||
}));
|
||||
fetch('http://127.0.0.1:7800/ingest/a69bf0d0-32e3-486e-80eb-ae1824ba17a1', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': 'a0b47d' },
|
||||
body: JSON.stringify({
|
||||
sessionId: 'a0b47d',
|
||||
runId: 'pre-fix',
|
||||
hypothesisId: 'H3',
|
||||
location: 'permission.ts:buildRoutesAction:afterFlat',
|
||||
message: 'transform+flat 后最终路由树',
|
||||
data: {
|
||||
hasDashboardPath: _dbgFinal.some((p) => p.includes('inquiry/dashboard')),
|
||||
inquiryPaths: _dbgFinal.filter((p) => p.includes('inquiry')).slice(0, 40),
|
||||
top: _dbgTopComp,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
}).catch(() => {});
|
||||
// #endregion
|
||||
routes = [PAGE_NOT_FOUND_ROUTE, ...routeList];
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 分析看板:对接主账 /inquiry/dashboard/metrics。
|
||||
* 指标 / 询价趋势 / 人员排名 / 成本趋势均来自主账聚合;禁止用 Mock 假线冒充生产。
|
||||
*/
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||
import { message } from "ant-design-vue";
|
||||
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
|
||||
import { getDashboardMetrics } from "/@/views/inquiry/dashboard/dashboard.api";
|
||||
@@ -100,28 +100,6 @@ async function loadMetrics() {
|
||||
const res: any = await getDashboardMetrics(req);
|
||||
const data = res?.result || res || {};
|
||||
todayMetrics.value = (data.metrics || []) as MetricCard[];
|
||||
// #region agent log
|
||||
fetch("http://127.0.0.1:7800/ingest/a69bf0d0-32e3-486e-80eb-ae1824ba17a1", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-Debug-Session-Id": "7b2ee5" },
|
||||
body: JSON.stringify({
|
||||
sessionId: "7b2ee5",
|
||||
runId: "post-fix",
|
||||
hypothesisId: "THOUSAND_SEP",
|
||||
location: "dashboard/index.vue:loadMetrics",
|
||||
message: "指标千分位展示抽样",
|
||||
data: {
|
||||
samples: todayMetrics.value.slice(0, 9).map((m) => ({
|
||||
key: m.key,
|
||||
raw: m.value,
|
||||
display: formatMetricDisplay(m),
|
||||
isTime: !!m.isTime,
|
||||
})),
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
}).catch(() => {});
|
||||
// #endregion
|
||||
inquiryTrend.value = ((data.trend || []) as any[]).map((p) => ({
|
||||
date: String(p.date || ""),
|
||||
value: Number(p.value || 0),
|
||||
@@ -383,92 +361,6 @@ function onDocClick(e: MouseEvent) {
|
||||
if (!t.closest(".ref-route-filter")) routeFilterOpen.value = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 调试:打开线路筛选时采集弹层/chips/祖先裁切与可滚尺寸。
|
||||
*/
|
||||
function probeRouteFilterMenu(runId: string) {
|
||||
// #region agent log
|
||||
const menu = document.querySelector(".ref-route-filter__menu") as HTMLElement | null;
|
||||
const chips = document.querySelector(".ref-route-filter__chips") as HTMLElement | null;
|
||||
const filter = document.querySelector(".ref-route-filter") as HTMLElement | null;
|
||||
if (!menu || !chips) {
|
||||
fetch("http://127.0.0.1:7800/ingest/a69bf0d0-32e3-486e-80eb-ae1824ba17a1", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-Debug-Session-Id": "7b2ee5" },
|
||||
body: JSON.stringify({
|
||||
sessionId: "7b2ee5",
|
||||
runId,
|
||||
hypothesisId: "H2",
|
||||
location: "dashboard/index.vue:probeRouteFilterMenu",
|
||||
message: "弹层或 chips 未找到",
|
||||
data: { hasMenu: !!menu, hasChips: !!chips },
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
}).catch(() => {});
|
||||
return;
|
||||
}
|
||||
const menuCs = getComputedStyle(menu);
|
||||
const chipsCs = getComputedStyle(chips);
|
||||
const menuRect = menu.getBoundingClientRect();
|
||||
const chipsRect = chips.getBoundingClientRect();
|
||||
const clipAncestors: Array<{ tag: string; cls: string; overflow: string; overflowY: string; rectBottom: number }> = [];
|
||||
let node: HTMLElement | null = filter;
|
||||
while (node && node !== document.body) {
|
||||
const cs = getComputedStyle(node);
|
||||
if (cs.overflow !== "visible" || cs.overflowY !== "visible" || cs.overflowX !== "visible") {
|
||||
clipAncestors.push({
|
||||
tag: node.tagName.toLowerCase(),
|
||||
cls: (node.className || "").toString().slice(0, 80),
|
||||
overflow: cs.overflow,
|
||||
overflowY: cs.overflowY,
|
||||
rectBottom: Math.round(node.getBoundingClientRect().bottom),
|
||||
});
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
const spaceBelow = Math.round(window.innerHeight - menuRect.top);
|
||||
fetch("http://127.0.0.1:7800/ingest/a69bf0d0-32e3-486e-80eb-ae1824ba17a1", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-Debug-Session-Id": "7b2ee5" },
|
||||
body: JSON.stringify({
|
||||
sessionId: "7b2ee5",
|
||||
runId,
|
||||
hypothesisId: "H1-H5",
|
||||
location: "dashboard/index.vue:probeRouteFilterMenu",
|
||||
message: "线路筛选弹层尺寸与溢出",
|
||||
data: {
|
||||
routeCount: costRoutes.value.length,
|
||||
menu: {
|
||||
h: Math.round(menuRect.height),
|
||||
bottom: Math.round(menuRect.bottom),
|
||||
overflow: menuCs.overflow,
|
||||
overflowY: menuCs.overflowY,
|
||||
maxHeight: menuCs.maxHeight,
|
||||
},
|
||||
chips: {
|
||||
h: Math.round(chipsRect.height),
|
||||
clientH: chips.clientHeight,
|
||||
scrollH: chips.scrollHeight,
|
||||
canScroll: chips.scrollHeight > chips.clientHeight + 1,
|
||||
overflow: chipsCs.overflow,
|
||||
overflowY: chipsCs.overflowY,
|
||||
maxHeight: chipsCs.maxHeight,
|
||||
},
|
||||
viewport: { innerH: window.innerHeight, spaceBelow, menuClippedByViewport: menuRect.bottom > window.innerHeight + 1 },
|
||||
clipAncestors,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
}).catch(() => {});
|
||||
// #endregion
|
||||
}
|
||||
|
||||
watch(routeFilterOpen, async (open) => {
|
||||
if (!open) return;
|
||||
await nextTick();
|
||||
requestAnimationFrame(() => probeRouteFilterMenu("post-fix"));
|
||||
});
|
||||
|
||||
const inquiryWrap = ref<HTMLElement | null>(null);
|
||||
const costWrap = ref<HTMLElement | null>(null);
|
||||
|
||||
|
||||
@@ -119,29 +119,6 @@ export async function downloadAuthGet(
|
||||
const cd = res.headers.get('content-disposition') || '';
|
||||
const name = parseDownloadFilename(cd, fallbackName);
|
||||
|
||||
// #region agent log
|
||||
fetch('http://127.0.0.1:7800/ingest/a69bf0d0-32e3-486e-80eb-ae1824ba17a1', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '489366' },
|
||||
body: JSON.stringify({
|
||||
sessionId: '489366',
|
||||
runId: 'post-fix',
|
||||
hypothesisId: 'FN1',
|
||||
location: 'inquiryAuthDownload.ts:downloadAuthGet',
|
||||
message: '导出文件名解析',
|
||||
data: {
|
||||
path,
|
||||
cd,
|
||||
fallbackName,
|
||||
resolvedName: name,
|
||||
hasStar: /filename\*=/i.test(cd),
|
||||
hasCjk: /[\u4e00-\u9fff]/.test(name),
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
}).catch(() => {});
|
||||
// #endregion
|
||||
|
||||
const href = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = href;
|
||||
|
||||
@@ -9,6 +9,7 @@ import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { message, Modal } from "ant-design-vue";
|
||||
import {
|
||||
disableStaff,
|
||||
enableStaff,
|
||||
getStaffCounts,
|
||||
getStaffDepartments,
|
||||
getStaffList,
|
||||
@@ -465,6 +466,25 @@ function removeStaff(row: StaffRow) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表「启用」:停用行恢复 active,权限与停用相同。
|
||||
*/
|
||||
function activateStaff(row: StaffRow) {
|
||||
Modal.confirm({
|
||||
title: `启用员工 ${row.name}?`,
|
||||
content: "确认后状态设为启用,可重新参与询价匹配。",
|
||||
async onOk() {
|
||||
try {
|
||||
await enableStaff(row.id);
|
||||
message.success("已启用");
|
||||
await loadList();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || "启用失败");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function syncWecom() {
|
||||
if (syncing.value) return;
|
||||
syncing.value = true;
|
||||
@@ -615,6 +635,14 @@ function filterRouteOption(input: string, option: any) {
|
||||
>
|
||||
停用
|
||||
</button>
|
||||
<button
|
||||
v-if="canDisableStaff() && row.status === 'disabled'"
|
||||
class="link-action"
|
||||
type="button"
|
||||
@click="activateStaff(row)"
|
||||
>
|
||||
启用
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -11,6 +11,7 @@ enum Api {
|
||||
add = '/inquiry/staffRole/add',
|
||||
edit = '/inquiry/staffRole/edit',
|
||||
disable = '/inquiry/staffRole/disable',
|
||||
enable = '/inquiry/staffRole/enable',
|
||||
queryById = '/inquiry/staffRole/queryById',
|
||||
syncWecom = '/inquiry/staffRole/syncWecom',
|
||||
/** 旧项目同名接口:部门短名全量 */
|
||||
@@ -22,7 +23,17 @@ export const getStaffList = (params) => defHttp.get({ url: Api.list, params });
|
||||
export const getStaffCounts = () => defHttp.get({ url: Api.counts });
|
||||
export const saveStaff = (data, isUpdate: boolean) =>
|
||||
isUpdate ? defHttp.put({ url: Api.edit, data }) : defHttp.post({ url: Api.add, data });
|
||||
export const disableStaff = (id: string) => defHttp.post({ url: Api.disable, params: { id } });
|
||||
/**
|
||||
* 停用员工。主账 @RequestParam id。
|
||||
* 非 GET 时 axios 会把 params 挪进 JSON 体,必须 joinParamsToUrl 拼到查询串。
|
||||
*/
|
||||
export const disableStaff = (id: string) =>
|
||||
defHttp.post({ url: Api.disable, params: { id } }, { joinParamsToUrl: true });
|
||||
/**
|
||||
* 重新启用员工。与停用同权限、同参数拼法。
|
||||
*/
|
||||
export const enableStaff = (id: string) =>
|
||||
defHttp.post({ url: Api.enable, params: { id } }, { joinParamsToUrl: true });
|
||||
export const getStaffById = (id: string) => defHttp.get({ url: Api.queryById, params: { id } });
|
||||
/** 主账拉企微通讯录 upsert;返回 { fetched, created, updated, skipped, message } */
|
||||
export const syncWecomStaff = () => defHttp.post({ url: Api.syncWecom });
|
||||
|
||||
@@ -203,29 +203,6 @@ async function openPreview(row: TemplateRow) {
|
||||
previewState.isImage = contentType.includes("image/") || blob.type.includes("image/");
|
||||
previewState.objectUrl = URL.createObjectURL(blob);
|
||||
previewState.loading = false;
|
||||
// #region agent log
|
||||
const payload = {
|
||||
sessionId: "b4fa63",
|
||||
runId: "post-fix",
|
||||
hypothesisId: "H10",
|
||||
location: "index.vue:openPreview",
|
||||
message: "snapshot preview loaded",
|
||||
data: {
|
||||
versionId: row.versionId,
|
||||
name: row.name,
|
||||
isImage: previewState.isImage,
|
||||
blobSize: blob.size,
|
||||
contentType,
|
||||
mappedCount: previewState.mappedCount,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
fetch("http://127.0.0.1:7800/ingest/a69bf0d0-32e3-486e-80eb-ae1824ba17a1", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-Debug-Session-Id": "b4fa63" },
|
||||
body: JSON.stringify(payload),
|
||||
}).catch(() => {});
|
||||
// #endregion
|
||||
} catch (e: any) {
|
||||
previewState.loading = false;
|
||||
previewState.error = e?.message || "原表截图尚未就绪,请稍后重试或下载原模板";
|
||||
|
||||
@@ -48,28 +48,6 @@
|
||||
const statusMapRef = ref(new Map<string | number, MapValue>());
|
||||
|
||||
const { query } = useRoute();
|
||||
const route = useRoute();
|
||||
// #region agent log
|
||||
fetch('http://127.0.0.1:7800/ingest/a69bf0d0-32e3-486e-80eb-ae1824ba17a1', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': 'a0b47d' },
|
||||
body: JSON.stringify({
|
||||
sessionId: 'a0b47d',
|
||||
runId: 'pre-fix',
|
||||
hypothesisId: 'H5',
|
||||
location: 'Exception.vue:setup',
|
||||
message: '进入 ErrorPage(前端路由未匹配)',
|
||||
data: {
|
||||
fullPath: route.fullPath,
|
||||
path: route.path,
|
||||
name: String(route.name || ''),
|
||||
statusProp: props.status,
|
||||
queryStatus: query?.status,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
}).catch(() => {});
|
||||
// #endregion
|
||||
const go = useGo();
|
||||
const redo = useRedo();
|
||||
const { t } = useI18n();
|
||||
|
||||
+55
-63
@@ -1,93 +1,85 @@
|
||||
# 提示词:测试环境部署(同机并存)
|
||||
# 提示词:测试环境部署(独立测服机)
|
||||
|
||||
你只部署 **测试环境**。现网继续跑。不要按现网累计发布器去切 `C:\yutongda`。现网密钥对照 `03`,进程形态见 `01`/`11`,环境对照见 `13`。
|
||||
你只部署 **测试环境**。正服继续跑。不要按现网累计发布器去切 `C:\yutongda` / `E:\wwwroot`。现网密钥对照 `03`,进程形态见 `01`/`11`,环境对照见 `13`。运维细则见 `deploy/TEST-ENV.md`。
|
||||
|
||||
机器与现网同一台:公网 `129.211.181.80`,主机名 `10_206_0_15`,SSH `ytd_extdev_0903`,指纹见 `03`。私钥优先 `~/.ssh/id_ed25519_ytd`。出口白名单不变。不得输出私钥和 Secret。
|
||||
|
||||
## 现网禁止触碰
|
||||
|
||||
- 目录:`C:\yutongda`、`releases\`、`ops\active-release*.json`
|
||||
- 端口:旧 8080/8809/8810/8811/8813 已停。新主账占用 **8080**,不要再起回 8180。企微回调 URL 不要改。
|
||||
- 站点:`https://ai.ytd-scm.com` 及 `/wecom/callback`、`/inquiry-form`(Nginx 反代目标切流前不准改)
|
||||
- 库:`jeecg-boot`、`ytd_runtime`;Redis `ytd:prod:node01-07`;桶 `ytd-inquiry`
|
||||
- 企微询价应用 `1000010` 的后台设置(不要重置 Secret、不要改回调 URL)
|
||||
- 脚本:禁止对现网执行 `Deploy-YtdCumulativeRelease.ps1`
|
||||
- 禁止改 `C:\yutongda\secure\`、现网 `.env`、现网 `ai.conf` 已有 location
|
||||
|
||||
## 测试环境落点
|
||||
## 测服机器(产品决策:域名即开关)
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 运行根 | `E:\wwwroot`(`admin\current` / `agent\current`) |
|
||||
| 公网 | `183.11.229.69` |
|
||||
| 主机名 | `PC-20260706MVVZ` |
|
||||
| SSH | `Administrator`,私钥 `~/.ssh/id_ed25519` |
|
||||
| 运行根 | **`C:\inquiry-robot`** |
|
||||
| 域名 | **`ai-test.ytd-scm.com`**(HTTPS;证书用 win-acme / Let’s Encrypt) |
|
||||
| 开关 | 打开 `ai.ytd-scm.com`=正服;打开 `ai-test.ytd-scm.com`=测服。不做后台页面切库 |
|
||||
|
||||
正服 `129.211.181.80` / `E:\wwwroot` / `ai.ytd-scm.com`:**禁止**在测服发版流程里改 Nginx、`.env`、企微回调。
|
||||
|
||||
## 正服禁止触碰
|
||||
|
||||
- 目录:`C:\yutongda`、`E:\wwwroot`、`releases\`、`ops\active-release*.json`
|
||||
- 站点:`https://ai.ytd-scm.com` 及 `/wecom/callback`、`/inquiry-form`
|
||||
- 库:正服 `inquiry_robot` / 旧 `jeecg-boot`;Redis 正服前缀;桶正服
|
||||
- 企微询价应用 `1000010` 后台设置(不要重置 Secret、**不要改回调 URL**)
|
||||
- 脚本:禁止对正服执行累计切流发布器
|
||||
|
||||
## 测服落点
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 运行根 | `C:\inquiry-robot`(`admin\current` / `agent\current` / `inquiry-new`) |
|
||||
| 主账 | **8080** |
|
||||
| 智能体 HTTP | **8910**(H5 + 企微回调 + 主账唤醒,一个进程) |
|
||||
| AIBOT | **8913** 仅 127.0.0.1 |
|
||||
| 智能体 HTTP | **8910**(H5 + 回调入口 + 主账唤醒,一个进程) |
|
||||
| AIBOT | **8913** 仅 127.0.0.1;默认 **关** |
|
||||
| Worker | 无对外端口;LibreOffice 1 槽,HTTP 类 2~3 槽 |
|
||||
| MySQL | 库/用户 `inquiry_robot`(数据机 `10.206.0.14:3306`,口令新开) |
|
||||
| PostgreSQL | 库/用户 `inquiry_robot_runtime` |
|
||||
| Redis | db1,前缀 `inquiry_robot:` |
|
||||
| MinIO | 桶 `inquiry_robot`(实例仍 9000) |
|
||||
| MySQL | 本机 `inquiry_robot` |
|
||||
| PostgreSQL | 本机 `inquiry_robot_runtime` |
|
||||
| Redis | 本机 db1,前缀 `inquiry_robot:` |
|
||||
| MinIO | 本机桶 `inquiry-robot` |
|
||||
| Nginx | `C:\nginx-1.18.0\myconf\ai-test.conf`;证书目录 `cert\` |
|
||||
|
||||
不要开 8809/8811/8812。不要 Redis 全局单例锁。Nginx 80/443、SSH 22、3306/5432/6379、9000 不要关。
|
||||
不要开 8809/8811/8812。不要 Redis 全局单例锁。
|
||||
|
||||
`MYSQL_*` / `PG_*` / `REDIS_URL`(**db1**)/ `REDIS_KEY_PREFIX=inquiry_robot:` / `OBJECT_STORAGE_BUCKET=inquiry_robot` 完整对照见 `13`。口令新开,勿把现网 `MYSQL_PASSWORD`/`PG_PASSWORD` 写进新系统 `.env`。
|
||||
## 域名与企微
|
||||
|
||||
## 域名与企微(与正式相同)
|
||||
|
||||
测试 **不另开域名、不另申请证书、不新建企微应用、不新建智能机器人**。
|
||||
|
||||
从 `03` 原样抄入测试 `.env`:`WECOM_AGENT_ID=1000010`、Secret、Callback Token、EncodingAESKey、`WECOM_CALLBACK_URL=https://ai.ytd-scm.com/wecom/callback`、`INQUIRY_FORM_PUBLIC_BASE_URL=https://ai.ytd-scm.com/inquiry-form`、AIBOT BotId/Secret、运维 `1000014`。可信域名已是 `ai.ytd-scm.com`,不用再配。
|
||||
|
||||
**并存期:** 不要改 `ai.conf`,公网回调和 H5 仍进现网。测试用本机 `8910` 联调。`WECOM_AIBOT_ENABLED` 默认关,避免和现网 8813 抢长连接。要用企微真收消息验收新系统 = 切流(改 Nginx 反代 + 停现网 AIBOT 再起 8913),现网会暂停收询价。
|
||||
- 测服公网 URL 用 `https://ai-test.ytd-scm.com/...`(`.env` 自洽)。
|
||||
- **企微后台回调仍保持** `https://ai.ytd-scm.com/wecom/callback`(本阶段不改;真回调进测服须另批)。
|
||||
- 同一企微应用 `1000010` / 运维 `1000014`;`WECOM_AIBOT_ENABLED=false`,避免与正服抢长连接。
|
||||
- 前端构建:`VITE_GLOB_API_URL=/jeecgboot`,`VITE_GLOB_DOMAIN_URL=https://ai-test.ytd-scm.com`。
|
||||
|
||||
```text
|
||||
YTD_ENV=test
|
||||
WECOM_CALLBACK_URL=https://ai.ytd-scm.com/wecom/callback
|
||||
INQUIRY_FORM_PUBLIC_BASE_URL=https://ai.ytd-scm.com/inquiry-form
|
||||
YTD_DEPLOY_ROOT=C:\inquiry-robot
|
||||
PUBLIC_BASE_URL=https://ai-test.ytd-scm.com
|
||||
WECOM_CALLBACK_URL=https://ai-test.ytd-scm.com/wecom/callback
|
||||
INQUIRY_FORM_PUBLIC_BASE_URL=https://ai-test.ytd-scm.com/inquiry-form
|
||||
BIZ_API_BASE_URL=http://127.0.0.1:8080/jeecgboot
|
||||
AGENT_CALLBACK_BASE_URL=http://127.0.0.1:8910
|
||||
WECOM_AIBOT_BRIDGE_URL=http://127.0.0.1:8913
|
||||
WECOM_AIBOT_INGRESS_URL=http://127.0.0.1:8910/internal/aibot/inbound
|
||||
# 空运群发文件后马上回话:先停现网 8813,再 WECOM_AIBOT_ENABLED=true,并填 BOT_ID/SECRET。该群须先 @ 过小助手。
|
||||
WECOM_AIBOT_ENABLED=false
|
||||
```
|
||||
|
||||
前端 `VITE_GLOB_API_URL=/jeecgboot`,`VITE_GLOB_DOMAIN_URL=https://ai.ytd-scm.com`。不要加 `ai-test.conf`。
|
||||
## 证书
|
||||
|
||||
- 测服本机申请 Let’s Encrypt(win-acme)。**必须**在云安全组放行公网 80/443。
|
||||
- 未放行前可用自签;外网可临时 `https://ai-test.ytd-scm.com:8081`(浏览器不信任属预期)。
|
||||
|
||||
## TMS
|
||||
|
||||
现网 BaseURL + Client 三件套(`03`)。QUERY / 详情 / 字典照旧。空运群锁舱/释放由新主账出站打真实 TMS。智能体只打新主账 8080,不直连 TMS。
|
||||
现网 BaseURL + Client 三件套(`03`)。QUERY / 详情 / 字典可用。测服默认关真实 LOCK/RELEASE。智能体只打本机主账 8080。
|
||||
|
||||
## 库表(测试库可直接改)
|
||||
## 库表
|
||||
|
||||
开发需要加表、加字段、改字段时:**直接对 `inquiry_robot` / `inquiry_robot_runtime` 做版本化迁移**,不必先等 Task 批准。
|
||||
|
||||
仍须批准的:改现网库、改现网 Secret、对非测试人员发企微。测服 LOCK/RELEASE 已纳入空运群定稿,但必须现网锁舱已停。
|
||||
开发加表加字段:对测服 `inquiry_robot` / `inquiry_robot_runtime` 做版本化迁移。禁止写正服库。
|
||||
|
||||
## 发布原则
|
||||
|
||||
1. 新版本写入 `E:\wwwroot\` 的 `agent\releases` / `admin\releases`,再切 `current` 联接;禁止覆盖正在跑的 current 目标目录内容(先发 release 再切联接)。
|
||||
2. 用测试自己的指针;失败回指上一测试版本。
|
||||
3. 先停 **测试** 侧进程再起新的;禁止停现网服务。
|
||||
4. 禁止 scp 几个文件当正式发布。
|
||||
5. 构建不含 `.env`、密钥、日志、用户附件、dump。
|
||||
6. 健康检查 200 ≠ 业务验收。
|
||||
|
||||
## 部署前必须同时成立
|
||||
|
||||
- 新主账在 **8080**,智能体在 8910,AIBOT 在 8913
|
||||
- 不要把主账起回 8180
|
||||
- 未改 `C:\yutongda\ops\` 活动指针
|
||||
|
||||
误停现网端口或改了现网指针:立即停手,先恢复现网,再报告。
|
||||
|
||||
## 未经 Task 批准不得
|
||||
|
||||
- 现网与测服同时 LOCK/RELEASE(测服锁舱须现网已停;QUERY 按任务批准)
|
||||
- 给测试可见范围以外的人或生产客户群发企微
|
||||
- 写现网库
|
||||
- 改现网 Secret、防火墙、`1000010` 回调
|
||||
- Git 强推
|
||||
1. 新版本写入 `C:\inquiry-robot\` 的 `agent\releases` / `admin\releases`,再切 `current` junction。
|
||||
2. 先停测服计划任务再起;禁止停正服服务。
|
||||
3. 构建不含 `.env`、密钥、日志、用户附件、dump。
|
||||
4. 健康检查 200 ≠ 业务验收。
|
||||
|
||||
## 汇报
|
||||
|
||||
Task、分支 commit、测试目录与端口、是否误碰 `C:\yutongda`(必须为否)、现网端口仍监听、TMS/企微次数、回滚点、尚未完成的企微 fresh 验收。
|
||||
测服目录与端口、是否误碰正服(必须为否)、证书状态(LE / 自签 / 待放行)、尚未完成的企微 fresh 验收。
|
||||
|
||||
+51
-94
@@ -1,123 +1,80 @@
|
||||
# 现网 vs 新系统 inquiry_robot(切换只改配置,不改代码)
|
||||
# 正服 vs 测服 inquiry_robot(切换只改配置,不改代码)
|
||||
|
||||
同一套制品。用 `YTD_ENV=prod` 或 `YTD_ENV=test` 区分阶段(变量名仍叫 YTD_*,与 `03` 对齐)。
|
||||
**域名与企微与现网相同**(`ai.ytd-scm.com`、应用 `1000010`)。
|
||||
**产品决策(域名即开关)**:`ai.ytd-scm.com`=正服;`ai-test.ytd-scm.com`=测服。不做后台页面切库。
|
||||
**新系统资源名一律 `inquiry_robot*`**,禁止再用 `ytd_test` / `ytd:test:` / `ytd-test`。
|
||||
禁止在代码里写死域名/库名。
|
||||
|
||||
切流目标(方案 B):`E:\wwwroot` 取代 `C:\yutongda`。清单见 `deploy/CUTOVER-TO-PROD.md`。
|
||||
|
||||
对照表是切换清单。现网发布仍走甲方原流程与 `C:\yutongda` 指针;新系统只写 `E:\wwwroot`。
|
||||
禁止在代码里写死域名/库名。运维见 `deploy/TEST-ENV.md`、`deploy/CUTOVER-TO-PROD.md`。
|
||||
|
||||
## 对照
|
||||
|
||||
| 项 | 现网(仍在跑) | 新系统 `inquiry_robot` |
|
||||
| 项 | 正服 | 测服(独立机) |
|
||||
|---|---|---|
|
||||
| `YTD_ENV` | `prod`(现网进程) | 切流前可用 `test`;切流日改 `prod` |
|
||||
| 公网域名 | `ai.ytd-scm.com` | **同一域名** |
|
||||
| DNS / 证书 / Nginx | 已有 `ai.conf` | **不要另开域名/证书**;反代按切流改 |
|
||||
| 站点 / H5 / 企微回调 | 同左 | 配置字符串相同;企微回调 URL **不用改** |
|
||||
| 后台 API 前缀 | `/jeecgboot` → 旧 Jeecg 8080 | `/jeecgboot` → **新主账 8080**(旧进程已停) |
|
||||
| 运行根 | `C:\yutongda` | **`E:\wwwroot`**(`admin\current` / `agent\current`) |
|
||||
| 主账端口 | 8080(旧 Jeecg,已停) | **8080**(新 Java 主账) |
|
||||
| 智能体 HTTP | 8810(另 8809/8811) | **8910**(合并) |
|
||||
| AIBOT | 8813 | **8913**(切流前勿双连) |
|
||||
| MySQL | `jeecg-boot` / `ytd_jeecg` | **`inquiry_robot` / `inquiry_robot`** |
|
||||
| PostgreSQL | `ytd_runtime` | **`inquiry_robot_runtime`** |
|
||||
| Redis | db0 `ytd:prod:node01-07` | db1 **`inquiry_robot:`** |
|
||||
| MinIO 桶 | `ytd-inquiry` | **`inquiry-robot`**(S3 桶名不能用下划线) |
|
||||
| 询价企微应用 | `1000010` | **同一套** |
|
||||
| 运维企微 | `1000014` | **同一套**;切流前告警可加 `[test]` |
|
||||
| AIBOT Bot | 现网 Bot | **同一套**;切流先停 8813 再开 8913 |
|
||||
| TMS | `03` 现网 Client | 同一套;切流前默认关 lock/release |
|
||||
| 发布脚本 | 现网累计发布器 | 只写 `E:\wwwroot` |
|
||||
| 告警前缀 | 无 | 切流前 `[test]`;切流后去掉 |
|
||||
| `YTD_ENV` | `prod` | `test` |
|
||||
| 公网 IP | `129.211.181.80` | `183.11.229.69` |
|
||||
| 公网域名 | `ai.ytd-scm.com` | **`ai-test.ytd-scm.com`** |
|
||||
| DNS / 证书 / Nginx | `ai.conf` + 正服证书 | **`ai-test.conf` + 测服证书(win-acme)** |
|
||||
| 站点 / H5 | 正服域名 | 测服域名 |
|
||||
| 企微回调 URL(企微后台) | `https://ai.ytd-scm.com/wecom/callback` | **暂不改后台**;测服 `.env` 可自洽写 ai-test |
|
||||
| 后台 API 前缀 | `/jeecgboot` → 主账 8080 | 同左(本机 8080) |
|
||||
| 运行根 | **`E:\wwwroot`** | **`C:\inquiry-robot`** |
|
||||
| 主账端口 | 8080 | 8080 |
|
||||
| 智能体 HTTP | 8910 | 8910 |
|
||||
| AIBOT | 8913(正服可启) | 8913;**默认关** |
|
||||
| MySQL | 正服 `inquiry_robot` | **本机** `inquiry_robot` |
|
||||
| PostgreSQL | 正服 `inquiry_robot_runtime` | **本机** `inquiry_robot_runtime` |
|
||||
| Redis | 正服 db1 `inquiry_robot:` | **本机** db1 `inquiry_robot:` |
|
||||
| MinIO 桶 | `inquiry-robot` | **本机** `inquiry-robot` |
|
||||
| 询价企微应用 | `1000010` | 同一套密钥,不抢长连接 |
|
||||
| 运维企微 | `1000014` | 同一套;告警加 `[test]` |
|
||||
| TMS | `03` | 同一套;默认关 lock/release |
|
||||
| 发布 | 只写 `E:\wwwroot` | 只写 `C:\inquiry-robot` |
|
||||
| 告警前缀 | 无 | `[test]` |
|
||||
|
||||
数据机 IP、3306/5432/6379、MinIO `127.0.0.1:9000` **两边相同**。只换库名、用户、Redis db/前缀、桶。
|
||||
**切换环境 = 换域名 / 换对应机器上的 `.env` 取值,不改代码。**
|
||||
测服与正服库主机不同(测服本机),禁止测服进程连正服库。
|
||||
|
||||
## 数据库与存储(同一套变量名)
|
||||
|
||||
代码、YAML 只读变量名,**禁止**写死库名。现网明文见 `03`;新系统口令新开,写入 `E:\wwwroot\.env`。
|
||||
代码、YAML 只读变量名,**禁止**写死库名。正服明文见 `03`;测服口令写在测服本机 `C:\inquiry-robot\.env`(勿进 Git)。
|
||||
|
||||
| 变量 | 现网 | 新系统 |
|
||||
| 变量 | 正服 | 测服 |
|
||||
|---|---|---|
|
||||
| `MYSQL_HOST` | `10.206.0.14` | 同左 |
|
||||
| `MYSQL_PORT` | `3306` | 同左 |
|
||||
| `MYSQL_DB` | `jeecg-boot` | **`inquiry_robot`** |
|
||||
| `MYSQL_USER` | `ytd_jeecg` | **`inquiry_robot`** |
|
||||
| `MYSQL_PASSWORD` | 见 `03` | **新开,勿抄现网口令** |
|
||||
| `YTD_MYSQL_URL` / JDBC | `.../jeecg-boot?...` | `.../inquiry_robot?...` |
|
||||
| `PG_HOST` | `10.206.0.14` | 同左 |
|
||||
| `PG_PORT` | `5432` | 同左 |
|
||||
| `PG_DATABASE` | `ytd_runtime` | **`inquiry_robot_runtime`** |
|
||||
| `PG_USER` | `ytd_runtime` | **`inquiry_robot_runtime`** |
|
||||
| `PG_PASSWORD` | 见 `03` | **新开** |
|
||||
| `PG_SSLMODE` | `prefer` | `prefer` |
|
||||
| `CHECKPOINT_BACKEND` | `postgres` | `postgres` |
|
||||
| `YTD_REDIS_HOST` | `10.206.0.14` | 同左 |
|
||||
| `YTD_REDIS_PORT` | `6379` | 同左 |
|
||||
| `YTD_REDIS_USERNAME` | `ytd_app` | 可同账号或新账号 |
|
||||
| `REDIS_URL` | `...6379/0` | **`...6379/1`** |
|
||||
| `REDIS_KEY_PREFIX` | `ytd:prod:node01-07` | **`inquiry_robot:`** |
|
||||
| `OBJECT_STORAGE_BUCKET` | `ytd-inquiry` | **`inquiry-robot`** |
|
||||
| `MYSQL_HOST` | 正服数据机 | **`127.0.0.1`** |
|
||||
| `MYSQL_PORT` | `3306` | `3306` |
|
||||
| `MYSQL_DB` / `MYSQL_USER` | `inquiry_robot` | `inquiry_robot` |
|
||||
| `PG_HOST` | 正服数据机 | **`127.0.0.1`** |
|
||||
| `PG_DATABASE` / `PG_USER` | `inquiry_robot_runtime` | 同左 |
|
||||
| `YTD_REDIS_HOST` | 正服数据机 | **`127.0.0.1`** |
|
||||
| `REDIS_URL` | `.../1` | **本机 `.../1`** |
|
||||
| `REDIS_KEY_PREFIX` | `inquiry_robot:` | `inquiry_robot:` |
|
||||
| `OBJECT_STORAGE_ENDPOINT_URL` | 正服 MinIO | **`http://127.0.0.1:9000`** |
|
||||
| `OBJECT_STORAGE_BUCKET` | `inquiry-robot` | `inquiry-robot` |
|
||||
|
||||
新系统 `.env` 片段(口令留空):
|
||||
测服 `.env` 片段(口令留空):
|
||||
|
||||
```text
|
||||
YTD_ENV=test
|
||||
YTD_DEPLOY_ROOT=E:\wwwroot
|
||||
YTD_DEPLOY_ROOT=C:\inquiry-robot
|
||||
PUBLIC_BASE_URL=https://ai-test.ytd-scm.com
|
||||
WECOM_CALLBACK_URL=https://ai-test.ytd-scm.com/wecom/callback
|
||||
INQUIRY_FORM_PUBLIC_BASE_URL=https://ai-test.ytd-scm.com/inquiry-form
|
||||
BIZ_API_BASE_URL=http://127.0.0.1:8080/jeecgboot
|
||||
MYSQL_HOST=127.0.0.1
|
||||
MYSQL_DB=inquiry_robot
|
||||
MYSQL_USER=inquiry_robot
|
||||
YTD_MYSQL_URL=jdbc:mysql://10.206.0.14:3306/inquiry_robot?characterEncoding=UTF-8&useUnicode=true&useSSL=true&requireSSL=true&verifyServerCertificate=false&tinyInt1isBit=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai
|
||||
PG_HOST=127.0.0.1
|
||||
PG_DATABASE=inquiry_robot_runtime
|
||||
PG_USER=inquiry_robot_runtime
|
||||
REDIS_URL=redis://ytd_app:<密码>@10.206.0.14:6379/1
|
||||
REDIS_URL=redis://:<密码>@127.0.0.1:6379/1
|
||||
REDIS_KEY_PREFIX=inquiry_robot:
|
||||
OBJECT_STORAGE_BUCKET=inquiry-robot
|
||||
```
|
||||
|
||||
**切换环境 = 换 `.env` 取值,不改代码、不在迁移里写死库名。**
|
||||
|
||||
切流前新进程不得连 `jeecg-boot` / `ytd_runtime` / Redis `/0` 且前缀 `ytd:prod:` / 桶 `ytd-inquiry`。
|
||||
|
||||
## 代码约定
|
||||
|
||||
所有环境差异只进 `.env` / 前端构建参数 / Nginx,例如:
|
||||
|
||||
```text
|
||||
YTD_ENV=test
|
||||
PUBLIC_BASE_URL=https://ai.ytd-scm.com
|
||||
WECOM_CALLBACK_URL=https://ai.ytd-scm.com/wecom/callback
|
||||
INQUIRY_FORM_PUBLIC_BASE_URL=https://ai.ytd-scm.com/inquiry-form
|
||||
BIZ_API_BASE_URL=http://127.0.0.1:8080
|
||||
AGENT_CALLBACK_BASE_URL=http://127.0.0.1:8910
|
||||
WECOM_AIBOT_BRIDGE_URL=http://127.0.0.1:8913
|
||||
WECOM_AIBOT_INGRESS_URL=http://127.0.0.1:8910/internal/aibot/inbound
|
||||
WECOM_AIBOT_ENABLED=false
|
||||
WECOM_AIBOT_BOT_ID=
|
||||
WECOM_AIBOT_SECRET=
|
||||
WECOM_AIBOT_WS_URL=wss://openws.work.weixin.qq.com
|
||||
REDIS_KEY_PREFIX=inquiry_robot:
|
||||
PG_DATABASE=inquiry_robot_runtime
|
||||
MYSQL_DB=inquiry_robot
|
||||
OBJECT_STORAGE_BUCKET=inquiry_robot
|
||||
```
|
||||
|
||||
两套 `.env` 分开放:现网仍在 `C:\yutongda\...`,新系统在 `E:\wwwroot\.env`。不要互相覆盖。
|
||||
|
||||
## 以后切到正式(新系统接管)
|
||||
|
||||
见 `deploy/CUTOVER-TO-PROD.md`。顺序摘要:
|
||||
|
||||
1. 新进程在隔离库 `inquiry_robot*` 验收通过。
|
||||
2. `.env`:`YTD_ENV=prod`;库继续 `inquiry_robot*`(或甲方最终名)。
|
||||
3. Nginx 反代切到新主账/智能体;企微回调 URL 不用改。
|
||||
4. 停现网 8813,再起 8913。
|
||||
5. 去掉告警 `[test]`。
|
||||
**切换环境 = 换域名 / 换对应机器 `.env`,不改代码。**
|
||||
|
||||
## 禁止
|
||||
|
||||
- 新进程连 `jeecg-boot` / `ytd:prod:` / 桶 `ytd-inquiry`
|
||||
- 测服连正服库 / 正服 Redis / 正服桶
|
||||
- 新系统再使用 `ytd_test` / `ytd:test:` / `ytd-test` 命名
|
||||
- 并存期与现网 **同时** 连接同一 AIBOT 长连接
|
||||
- 与正服 **同时** 连接同一 AIBOT 长连接
|
||||
- 一份 `.env` 混用两套库
|
||||
- 测服发版改正服 Nginx / 企微回调 URL
|
||||
|
||||
Reference in New Issue
Block a user