完善空运锁舱与海运报价单单选回填,并增强模板预览与协同字段盖写。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-10-02 22:57:04 +08:00
co-authored by Cursor
parent 5402f94d30
commit d989154485
51 changed files with 4748 additions and 188 deletions
+137 -3
View File
@@ -226,6 +226,8 @@ def send_air_brief_and_sheet(
空运群再发一版摘要 + 匹配到的空运模板。对齐海运 send_brief_and_sheet。
必须先发出工单信息,模板等摘要发出后再发,禁止后台线程抢先发文件。
填表报价必须带上 TMS 总销售价:群票上的 latest quote 常只有增点费/拆板费明细,
若直接拿去出单,预估费用会落成明细加总(如 200)而不是 salePrice(如 7918.25)。
"""
ticket = hydrate_ticket(flow, ticket)
view = ticket_view(ticket)
@@ -247,6 +249,8 @@ def send_air_brief_and_sheet(
if not manual and copy.quote_is_product(override or view_q):
manual = override or view_q
quote = dict(override or view_q or tms or manual)
# 出单专用:把 TMS salePrice 盖回薄报价,避免预估费用=明细 SUM。
sheet_quote = _sheet_quote_with_tms_sale_total(quote, tms=tms, facts=facts)
sales_name, airline_names, mention_ids = resolve_air_mentions(flow, view)
send_group_text(
group_client_of(flow),
@@ -264,10 +268,133 @@ def send_air_brief_and_sheet(
),
mention_userids=mention_ids,
)
_send_air_activate_sheet(flow=flow, ticket=view, chat_id=room, quote=quote)
_send_air_activate_sheet(flow=flow, ticket=view, chat_id=room, quote=sheet_quote)
return "brief_and_sheet"
def _air_usable_sale_total(quote: Optional[dict[str, Any]]) -> str:
"""
空运出单可用的总销售价:顶层 total / salePrice,或选定/首条线路 salePrice。
「-」与空串不算。
"""
src = dict(quote or {})
for key in ("total", "total_amount", "salePrice", "totalSalePrice"):
val = str(src.get(key) or "").strip()
if val and val != "-":
return val
selected = src.get("selected_air_option") or src.get("selectedAirOption")
if isinstance(selected, dict):
val = str(selected.get("salePrice") or selected.get("sale_price") or "").strip()
if val and val != "-":
return val
raw_opts = src.get("airOptions") or src.get("air_options") or []
if isinstance(raw_opts, list) and raw_opts and isinstance(raw_opts[0], dict):
val = str(raw_opts[0].get("salePrice") or raw_opts[0].get("sale_price") or "").strip()
if val and val != "-":
return val
return ""
def _money_plain_number(text: str) -> Optional[float]:
"""从「CNY 7918.25」类文本抽出数字;解不开返回 None。"""
raw = str(text or "").strip()
if not raw or raw == "-":
return None
digits = "".join(ch if (ch.isdigit() or ch in ".-") else " " for ch in raw).split()
if not digits:
return None
try:
return float(digits[-1].replace(",", ""))
except ValueError:
return None
def _total_matches_fee_row_sum(quote: dict[str, Any], total_text: str) -> bool:
"""
顶层 total 是否只是明细金额相加(如增点费100+拆板费100=200)。
是则说明缺了 TMS 总销售价,不能当真预估费用。
"""
total_num = _money_plain_number(total_text)
if total_num is None:
return False
rows = quote.get("fee_rows") or quote.get("fee_lines") or quote.get("feeItems") or []
if isinstance(rows, str) and rows.strip():
try:
rows = json.loads(rows)
except json.JSONDecodeError:
rows = []
if not isinstance(rows, list) or not rows:
return False
summed = 0.0
hit = 0
for item in rows:
if not isinstance(item, dict):
continue
amt = _money_plain_number(
str(
item.get("amount")
or item.get("value")
or item.get("salePrice")
or item.get("price")
or ""
)
)
if amt is None:
continue
summed += amt
hit += 1
if hit <= 0:
return False
return abs(total_num - summed) < 0.01
def _sheet_quote_with_tms_sale_total(
quote: dict[str, Any],
*,
tms: Optional[dict[str, Any]] = None,
facts: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
"""
群填表用的报价包:保留产品/航线改过的明细,但预估费用必须能落到 TMS 总销售价。
协同群 latest quote 常只有 departureCharges 投影出的附加费行,total 空或被加成 200;
销售 1V1 会话里带了 salePrice,所以同一工单两边会不一致。这里出单前对齐。
"""
from agent.schema.air_route_options import project_group_air_quote
out = dict(quote or {})
source = dict(tms or {}) or dict(out)
if not source:
return out
projected = project_group_air_quote(source, dict(facts or {}))
tms_total = _air_usable_sale_total(projected)
cur_total = _air_usable_sale_total(out)
# 补齐线路对象 / 计费重,便于主账从 airOptions.salePrice 再兜底一次。
for key in (
"airOptions",
"air_options",
"selected_air_option",
"selectedAirOption",
"chargeableWeightKg",
"chargeable_weight_kg",
"currency",
"valid_until",
):
cur = out.get(key)
empty = cur in (None, "", "-", [], {})
proj_val = projected.get(key)
if empty and proj_val not in (None, "", "-", [], {}):
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)
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():
out["currency"] = projected.get("currency")
return out
def resolve_air_mentions(flow: Any, ticket: object) -> tuple[str, list[str], list[str]]:
"""
空运群摘要必须 @ 到本单销售和航线·空运,对上海运 @ 销售/@ 产品。
@@ -581,16 +708,21 @@ def apply_air_confirm_tms(*, flow: Any, ticket: object, sender_id: str, chat_id:
quote["source_label"] = "航线确认TMS"
no = ticket_no(ticket)
_clear_air_pending(flow, no, sender_id)
saved = flow.ledger.upsert_product_quote(work_order_no=no, quote=quote, to_status="已报价") or {}
# 确认 TMS 出单:同样用总销售价,避免只落附加费明细时预估费用=200。
sheet_quote = _sheet_quote_with_tms_sale_total(
quote, tms=quote, facts=dict(view.get("facts") or {})
)
saved = flow.ledger.upsert_product_quote(work_order_no=no, quote=sheet_quote, to_status="已报价") or {}
if not saved.get("ok"):
send_group_text(group_client_of(flow), chat_id, "主账写入报价失败。")
return "ledger_fail"
view["collab_chat_id"] = chat_id
view["quote"] = sheet_quote
_persist_quote_sheet(
flow=flow,
ticket=view,
sess=None,
quote=quote,
quote=sheet_quote,
sender_id=sender_id,
business_line="AIR",
)
@@ -658,6 +790,7 @@ def apply_air_lock(
chat_id: str,
instruction: str,
option_no: str = "",
airline: str = "",
) -> str:
if not is_airline(flow, sender_id):
send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin())
@@ -705,6 +838,7 @@ def apply_air_lock(
sender_id=sender_id,
operator_name=name,
instruction_text=instruction,
airline=(airline or "").strip(),
)
or {},
is_ok=lambda x: bool((x or {}).get("ok")),
+7 -6
View File
@@ -2239,13 +2239,14 @@ def adjust_only_sales() -> str:
def adjust_group_body(quote: dict[str, Any] | None, business_line: str = "") -> str:
"""群里调后全量明细,利润点不写给销售。"""
"""
群里调后全量明细,利润点不写给销售。
海运必须带三段标题与小计(始发港/海运/目的港),禁止只摊平 fee_lines 把小计弄丢。
"""
src = dict(quote or {})
lines = [f"{name}:{amount}" for name, amount in quote_fee_lines(src)]
header = str(src.get("currency") or "")
total = money_text(str(src.get("total") or "-").strip() or "-", header)
lines.append(f"{_total_label(src, business_line)}:{total}")
return "调价后价格:\n" + "\n".join(lines)
text = quote_area_fee_text(src, business_line=business_line)
return "调价后价格:\n" + text
def adopt_excel_ack() -> str:
+130 -6
View File
@@ -3,11 +3,16 @@
主账填表只认英文码,货好时间 / 海关编码 / 含油电磁常空着。
本文件只改 Excel 字节,不打主账、不占回调线程。
注意:openpyxl 保存会弄坏海运顶部 Form Control(drawing/VML/sheet1 锚点)。
因此只用来「读」标签位置;真正写入走 zip 内定点改格,不动控件骨架。
无待写字段、或目标格已是目标值时原样返回。
"""
from __future__ import annotations
import logging
import re
from pathlib import Path
from typing import Any, Mapping
@@ -30,6 +35,18 @@ _COLLAB_LABEL_MARKERS: tuple[tuple[str, tuple[str, ...]], ...] = (
("包装方式", ("包装方式", "包装类型")),
)
# 旧路径兜底:若仍误走 openpyxl 保存,至少盖回这些部件
_PRESERVE_ZIP_PARTS = (
"xl/drawings/drawing1.xml",
"xl/drawings/vmlDrawing1.vml",
"xl/styles.xml",
"xl/ctrlProps/ctrlProp1.xml",
"xl/ctrlProps/ctrlProp2.xml",
"xl/worksheets/sheet1.xml",
"xl/worksheets/_rels/sheet1.xml.rels",
"[Content_Types].xml",
)
def _fact_for_markers(facts: Mapping[str, Any], keys: tuple[str, ...]) -> str:
for key in keys:
@@ -39,6 +56,102 @@ def _fact_for_markers(facts: Mapping[str, Any], keys: tuple[str, ...]) -> str:
return ""
def _restore_sea_draw_parts(original: bytes, rewritten: bytes) -> bytes:
"""把原件里的 drawing/VML/ctrl/styles/sheet1 控件骨架盖回 openpyxl 重写后的 xlsx。"""
import io
import zipfile
try:
with zipfile.ZipFile(io.BytesIO(original)) as zin:
keep = {
name: zin.read(name)
for name in _PRESERVE_ZIP_PARTS
if name in zin.namelist()
}
if not keep:
return rewritten
out = io.BytesIO()
with zipfile.ZipFile(io.BytesIO(rewritten)) as zin, zipfile.ZipFile(
out, "w", compression=zipfile.ZIP_DEFLATED
) as zout:
for info in zin.infolist():
data = keep.get(info.filename) or zin.read(info.filename)
zout.writestr(info, data)
for name, data in keep.items():
if name not in zin.namelist():
zout.writestr(name, data)
return out.getvalue()
except Exception:
logger.exception("overlay 盖回海运 drawing 失败,退回 openpyxl 结果")
return rewritten
def _col_letter(col: int) -> str:
"""1-based 列号 → Excel 列字母。"""
n = int(col)
letters: list[str] = []
while n > 0:
n, rem = divmod(n - 1, 26)
letters.append(chr(65 + rem))
return "".join(reversed(letters)) or "A"
def _xml_escape(text: str) -> str:
return (
str(text)
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
)
def _patch_cells_keep_structure(data: bytes, cells: list[tuple[int, int, str]]) -> bytes:
"""
只改 sheet1 里指定格的字符串,保留 AlternateContent / 控件关系。
禁止整表经 openpyxl 重存:否则拉群协同 Excel 顶部 Port/其它单选会空白,
而「直接生成」不经本函数所以看起来正常(WO031 vs WO033)。
"""
import io
import zipfile
if not cells:
return data
with zipfile.ZipFile(io.BytesIO(data)) as zin:
if "xl/worksheets/sheet1.xml" not in zin.namelist():
return data
sheet = zin.read("xl/worksheets/sheet1.xml").decode("utf-8", "replace")
others = {name: zin.read(name) for name in zin.namelist() if name != "xl/worksheets/sheet1.xml"}
for row, col, val in cells:
ref = f"{_col_letter(col)}{int(row)}"
cell_xml = (
f'<c r="{ref}" t="inlineStr"><is><t>{_xml_escape(val)}</t></is></c>'
)
pat = re.compile(rf'<c\b[^>]*\br="{re.escape(ref)}"[^>]*(?:/>|>[\s\S]*?</c>)')
if pat.search(sheet):
sheet = pat.sub(cell_xml, sheet, count=1)
continue
row_pat = re.compile(rf'(<row\b[^>]*\br="{int(row)}"[^>]*>)([\s\S]*?)(</row>)')
m = row_pat.search(sheet)
if m:
sheet = sheet[: m.start(3)] + cell_xml + sheet[m.start(3) :]
continue
sheet = sheet.replace(
"</sheetData>",
f'<row r="{int(row)}">{cell_xml}</row></sheetData>',
1,
)
out = io.BytesIO()
with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as zout:
for name, blob in others.items():
zout.writestr(name, blob)
zout.writestr("xl/worksheets/sheet1.xml", sheet.encode("utf-8"))
return out.getvalue()
def overlay_labeled_facts(
*,
facts: Mapping[str, Any],
@@ -48,7 +161,8 @@ def overlay_labeled_facts(
"""
按模板标签再写协同格。失败则原样交回。
副作用:可能写临时 xlsx。
副作用:可能写临时 xlsx。无待写字段、或格子已是目标值时不改字节,
避免毁掉海运顶部运输分类单选。
"""
import base64
import tempfile
@@ -91,20 +205,30 @@ def overlay_labeled_facts(
if not val:
continue
dest = _label_value_cell(ws, cell)
# 主账已填过同值(如贸易条款):再写会经 openpyxl 毁掉单选
if str(dest.value or "").strip() == val:
used_rows.add(cell.row)
break
pending.append((dest.row, dest.column, val))
used_rows.add(cell.row)
break
for r, c, val in pending:
ws.cell(row=r, column=c, value=val)
written += 1
wb.save(tmp)
wb.close()
if not pending:
# 无增量可写:原样返回,禁止重存
logger.info("overlay 协同字段 written=0 keep_original")
return {
"filepath": filepath or str(tmp),
"file_b64": file_b64 or base64.b64encode(raw).decode("ascii"),
}
patched = _patch_cells_keep_structure(raw, pending)
tmp.write_bytes(patched)
written = len(pending)
except Exception:
logger.exception("overlay 协同字段失败")
return {"filepath": filepath, "file_b64": file_b64}
out_b64 = base64.b64encode(tmp.read_bytes()).decode("ascii")
logger.info("overlay 协同字段 written=%s", written)
logger.info("overlay 协同字段 written=%s keep_structure=1", written)
return {"filepath": str(tmp), "file_b64": out_b64}
+91 -7
View File
@@ -401,17 +401,21 @@ def hydrate_ticket(flow: Any, ticket: object) -> object:
)
)
quote = dict(view.get("quote") or {})
has_quote = bool(quote.get("total") or _quote_fee_rows(quote))
# total="-" 在 Python 里也是 truthy,不能当成已有总价;只有附加费明细也不算完整。
has_sale_total = _quote_has_usable_sale_total(quote)
has_fee_rows = bool(_quote_fee_rows(quote))
has_quote = has_sale_total or has_fee_rows
has_status = bool(ticket_status(ticket))
has_tms = bool(dict(view.get("tms_quote") or view.get("tmsQuote") or {}))
# 调价后 latest quote 仍有价,但不能因此跳过补 TMS 原价。
if has_facts and has_quote and has_status and has_tms:
return ticket
# 仅有 fee_rows、没有可用总价时仍要往下补全,否则空运出单预估费用会落成明细 SUM。
if has_facts and has_sale_total and has_status and has_tms:
return _soft_fill_quote_sale_fields(ticket)
no = ticket_no(ticket)
sales = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "").strip()
book = getattr(flow, "ledger", None)
if book is None or not no:
return ticket
return _soft_fill_quote_sale_fields(ticket)
full: Any = None
getter = getattr(book, "get_for_agent", None)
if callable(getter) and sales:
@@ -423,13 +427,33 @@ def hydrate_ticket(flow: Any, ticket: object) -> object:
if callable(getter):
full = getter(work_order_no=no)
if full is None:
return ticket
return _soft_fill_quote_sale_fields(ticket)
merged = dict(view)
full_view = ticket_view(full)
if (not has_facts) and full_view.get("facts"):
merged["facts"] = dict(full_view.get("facts") or {})
if (not has_quote) and full_view.get("quote"):
merged["quote"] = dict(full_view.get("quote") or {})
if (not has_sale_total) and full_view.get("quote"):
# 薄报价保留已有明细,补上完整快照里的总价/线路。
rich = dict(full_view.get("quote") or {})
cur = dict(quote)
for key in (
"total",
"salePrice",
"totalSalePrice",
"airOptions",
"air_options",
"selected_air_option",
"selectedAirOption",
"chargeableWeightKg",
"chargeable_weight_kg",
"currency",
"valid_until",
):
if cur.get(key) in (None, "", "-", [], {}) and rich.get(key) not in (None, "", "-", [], {}):
cur[key] = rich[key]
if not has_fee_rows and (rich.get("fee_lines") or rich.get("fee_rows")):
cur["fee_lines"] = rich.get("fee_lines") or rich.get("fee_rows")
merged["quote"] = cur
if full_view.get("tms_quote") or full_view.get("tmsQuote"):
merged["tms_quote"] = dict(full_view.get("tms_quote") or full_view.get("tmsQuote") or {})
if (not has_status) and full_view.get("status"):
@@ -449,6 +473,66 @@ def hydrate_ticket(flow: Any, ticket: object) -> object:
):
if full_view.get(key) and not merged.get(key):
merged[key] = full_view.get(key)
return _soft_fill_quote_sale_fields(merged)
def _quote_has_usable_sale_total(quote: dict[str, Any] | None) -> bool:
"""
是否已有可用于出单预估费用的总价。
total="-" 不算;仅有 fee_rows 也不算。
"""
src = dict(quote or {})
total = str(src.get("total") or src.get("total_amount") or src.get("salePrice") or "").strip()
if total and total != "-":
return True
selected = src.get("selected_air_option") or src.get("selectedAirOption")
if isinstance(selected, dict):
sale = str(selected.get("salePrice") or selected.get("sale_price") or "").strip()
if sale and sale != "-":
return True
opts = src.get("airOptions") or src.get("air_options") or []
if isinstance(opts, list) and opts and isinstance(opts[0], dict):
sale = str(opts[0].get("salePrice") or opts[0].get("sale_price") or "").strip()
if sale and sale != "-":
return True
return False
def _soft_fill_quote_sale_fields(ticket: object) -> object:
"""
从 tms_quote 补齐当前 quote 缺失的总销售价 / 线路对象。
群 findByCollabChat 常只有薄 latest quote,出单前必须能落到 salePrice。
"""
view = ticket_view(ticket)
cur = dict(view.get("quote") or {})
rich = dict(view.get("tms_quote") or view.get("tmsQuote") or {})
if not rich:
return ticket if isinstance(ticket, dict) else view
changed = False
for key in (
"total",
"salePrice",
"totalSalePrice",
"airOptions",
"air_options",
"selected_air_option",
"selectedAirOption",
"chargeableWeightKg",
"chargeable_weight_kg",
"currency",
"valid_until",
):
if cur.get(key) in (None, "", "-", [], {}) and rich.get(key) not in (None, "", "-", [], {}):
cur[key] = rich[key]
changed = True
if not changed:
return ticket if isinstance(ticket, dict) else view
if isinstance(ticket, dict):
out = dict(ticket)
out["quote"] = cur
return out
merged = dict(view)
merged["quote"] = cur
return merged
+1 -1
View File
@@ -190,7 +190,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
from agent.schema.sea_options import drop_invented_door_trade_terms, harvest_sea_class
facts = harvest_sea_class(text, facts)
# 旧陆运/补问草稿可能把「门到门」塞进贸易条款;原话没写条款则丢掉。
# 贸易条款标签行原样优先(Port to Port 等);再丢掉无标签的冒充「门到门」
facts = drop_invented_door_trade_terms(text, facts)
check = validate_required_fields(facts=facts, business_line="SEA")
facts = dict(check["facts"])