完善空运锁舱与海运报价单单选回填,并增强模板预览与协同字段盖写。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -269,12 +269,15 @@ def _handle_cabin(
|
||||
chat_id=chat_id,
|
||||
instruction=message.content or "",
|
||||
)
|
||||
# 「WO…锁舱,锁AIR-OPT01 TH」要带上句中的舱位和航司,不能只靠报价里碰巧只有一条。
|
||||
return apply_air_lock(
|
||||
flow=engine,
|
||||
ticket=ticket,
|
||||
sender_id=message.sender_id,
|
||||
chat_id=chat_id,
|
||||
instruction=message.content or "",
|
||||
option_no=_option_from_text(message.content or ""),
|
||||
airline=_airline_from_text(message.content or ""),
|
||||
)
|
||||
|
||||
|
||||
@@ -308,6 +311,7 @@ def _handle_bound(
|
||||
chat_id=chat_id,
|
||||
instruction=message.content or "",
|
||||
option_no=option,
|
||||
airline=_airline_from_text(message.content or ""),
|
||||
)
|
||||
if intent in {INTENT_DEAL, INTENT_LOST, INTENT_NEGOTIATE}:
|
||||
outcome = {
|
||||
@@ -371,14 +375,25 @@ def _handle_bound(
|
||||
|
||||
|
||||
def _option_from_text(text: str) -> str:
|
||||
blob = (text or "").replace(" ", "").upper()
|
||||
if "AIR-OPT" in blob or "AIROPT" in blob:
|
||||
start = blob.find("AIR")
|
||||
token = []
|
||||
for ch in blob[start:]:
|
||||
if ch.isalnum() or ch == "-":
|
||||
token.append(ch)
|
||||
else:
|
||||
break
|
||||
return "".join(token)
|
||||
"""从「锁AIR-OPT01 TH」抽出舱位编号;不要把后面的航司码拼进 optionNo。"""
|
||||
import re
|
||||
|
||||
m = re.search(r"AIR-?OPT-?\d+", (text or "").upper())
|
||||
if not m:
|
||||
return ""
|
||||
raw = m.group(0)
|
||||
# 统一成 TMS 常见写法 AIR-OPT01(中间可保留已有的第二根横杠)
|
||||
if raw.startswith("AIROPT"):
|
||||
return "AIR-OPT" + raw[6:]
|
||||
return raw
|
||||
|
||||
|
||||
def _airline_from_text(text: str) -> str:
|
||||
"""从「AIR-OPT01 TH」抽出两位航司码;没有就不猜。"""
|
||||
import re
|
||||
|
||||
blob = (text or "").upper()
|
||||
m = re.search(r"AIR-?OPT-?\d+\s*([A-Z]{2})(?![A-Z0-9])", blob)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return ""
|
||||
|
||||
@@ -402,16 +402,20 @@ class HttpLedger:
|
||||
sender_id: str = "",
|
||||
operator_name: str = "",
|
||||
instruction_text: str = "",
|
||||
airline: str = "",
|
||||
) -> dict[str, Any]:
|
||||
body: dict[str, Any] = {
|
||||
"workOrderNo": work_order_no,
|
||||
"optionNo": option_no,
|
||||
"operatorUserId": sender_id,
|
||||
"operatorName": operator_name,
|
||||
"instructionText": instruction_text,
|
||||
}
|
||||
if (airline or "").strip():
|
||||
body["airline"] = airline.strip().upper()
|
||||
return self._post(
|
||||
"/inquiry/agent/tms/lockCabin",
|
||||
{
|
||||
"workOrderNo": work_order_no,
|
||||
"optionNo": option_no,
|
||||
"operatorUserId": sender_id,
|
||||
"operatorName": operator_name,
|
||||
"instructionText": instruction_text,
|
||||
},
|
||||
body,
|
||||
idem=f"air-lock:{work_order_no}:{option_no}",
|
||||
)
|
||||
|
||||
|
||||
@@ -823,9 +823,10 @@ class MemoryLedger:
|
||||
sender_id: str = "",
|
||||
operator_name: str = "",
|
||||
instruction_text: str = "",
|
||||
airline: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""单测可注入 _cabin_lock_result;默认成功。"""
|
||||
_ = sender_id, operator_name, instruction_text
|
||||
_ = sender_id, operator_name, instruction_text, airline
|
||||
forced = getattr(self, "_cabin_lock_result", None)
|
||||
if isinstance(forced, dict):
|
||||
return dict(forced)
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """)
|
||||
)
|
||||
|
||||
|
||||
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}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -20,7 +20,7 @@ _CLOSED = frozenset({"已成交", "未成交", "已关闭", "转人工"})
|
||||
_QUOTED = frozenset({"已报价", "协商中"})
|
||||
|
||||
_PERCENT_UP = re.compile(
|
||||
r"(上调|上涨|上升|上浮|提高|加|涨|升)\s*(\d+(?:\.\d+)?)\s*%"
|
||||
r"(上调|上涨|上升|上浮|提高|提升|加|涨|升)\s*(\d+(?:\.\d+)?)\s*%"
|
||||
)
|
||||
_PERCENT_DOWN = re.compile(r"(下调|下降|降低|减|降)\s*(\d+(?:\.\d+)?)\s*%")
|
||||
_TARGET = re.compile(
|
||||
@@ -531,6 +531,7 @@ def extract_adjust(
|
||||
if not raw:
|
||||
return AdjustExtract(is_adjust=False)
|
||||
names = [str(x).strip() for x in (fee_names or []) if str(x).strip()]
|
||||
names_sorted = sorted(names, key=len, reverse=True)
|
||||
use_model = allow_model
|
||||
if use_model is None:
|
||||
try:
|
||||
@@ -558,11 +559,28 @@ def extract_adjust(
|
||||
op = _item_from_chunk(raw, "")
|
||||
if op:
|
||||
modeled.items = [op]
|
||||
# 模型常只抽出「费用合计」:本地再扫点名小计/费目,避免「始发港费用小计提升10%」被丢掉
|
||||
local_items = _local_fee_items(raw, names_sorted)
|
||||
if local_items:
|
||||
seen = {
|
||||
str(x.fee_name or "").strip()
|
||||
for x in (modeled.items or [])
|
||||
if str(x.fee_name or "").strip()
|
||||
}
|
||||
merged = list(modeled.items or [])
|
||||
for it in local_items:
|
||||
key = str(it.fee_name or "").strip()
|
||||
if key and key not in seen:
|
||||
merged.append(it)
|
||||
seen.add(key)
|
||||
modeled = AdjustExtract(
|
||||
is_adjust=True,
|
||||
items=merged,
|
||||
wants_total=bool(modeled.wants_total),
|
||||
)
|
||||
return modeled
|
||||
except Exception:
|
||||
logger.exception("调价模型抽取失败,回退本地")
|
||||
names_sorted = sorted(names, key=len, reverse=True)
|
||||
blob = raw.replace(" ", "")
|
||||
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))
|
||||
fee_in_text = [n for n in names_sorted if n and n in raw]
|
||||
@@ -571,6 +589,17 @@ def extract_adjust(
|
||||
return AdjustExtract(is_adjust=True, wants_total=True, items=[op] if op else [])
|
||||
if _QUOTE_WORD in raw and has_op and not fee_in_text and not total_hit:
|
||||
return AdjustExtract(is_adjust=True, wants_total=False)
|
||||
items = _local_fee_items(raw, names_sorted)
|
||||
if not items and has_op:
|
||||
# 说了费用口语但和名单不完全一致:整句再扫一次
|
||||
item = _item_from_chunk(raw, "")
|
||||
if item:
|
||||
items.append(item)
|
||||
return AdjustExtract(is_adjust=bool(items or has_op), items=items)
|
||||
|
||||
|
||||
def _local_fee_items(raw: str, names_sorted: list[str]) -> list[AdjustItem]:
|
||||
"""按分句把点名费目/小计抽成调价项。提升/上调等口令都认。"""
|
||||
items: list[AdjustItem] = []
|
||||
for part in _SPLIT.split(raw) or [raw]:
|
||||
chunk = (part or "").strip()
|
||||
@@ -585,16 +614,15 @@ def extract_adjust(
|
||||
found = _FEE_TOKEN.findall(chunk)
|
||||
spoken = found[-1] if found else ""
|
||||
if not spoken:
|
||||
continue
|
||||
# 「费用合计提升10%」:名单里有费用合计时上面已命中;否则用合计词
|
||||
if any(w in chunk for w in _TOTAL_WORDS):
|
||||
spoken = "费用合计"
|
||||
else:
|
||||
continue
|
||||
item = _item_from_chunk(chunk, spoken)
|
||||
if item:
|
||||
items.append(item)
|
||||
if not items and has_op:
|
||||
# 说了费用口语但和名单不完全一致:整句再扫一次
|
||||
item = _item_from_chunk(raw, "")
|
||||
if item:
|
||||
items.append(item)
|
||||
return AdjustExtract(is_adjust=bool(items or has_op), items=items)
|
||||
return items
|
||||
|
||||
|
||||
def _item_from_chunk(chunk: str, fee_name: str) -> Optional[AdjustItem]:
|
||||
@@ -763,6 +791,42 @@ def _apply_total_only(
|
||||
)
|
||||
|
||||
|
||||
def _fold_total_into_items(
|
||||
extract: AdjustExtract,
|
||||
*,
|
||||
business_line: str = "",
|
||||
quote: dict[str, Any] | None = None,
|
||||
) -> AdjustExtract:
|
||||
"""
|
||||
合计与费目小计写在同一条指令里时:取消 wants_total 独占,把合计并进 items。
|
||||
|
||||
为何:wants_total 只改 total,海运「始发港费用小计」不会动,Excel 汇总仍是旧数。
|
||||
"""
|
||||
if not extract.wants_total:
|
||||
return extract
|
||||
named = [x for x in (extract.items or []) if str(x.fee_name or "").strip()]
|
||||
if not named:
|
||||
return extract
|
||||
label = total_price_label(business_line, quote=quote)
|
||||
has_total = any(fee_name_same(str(x.fee_name or ""), label) for x in named)
|
||||
items = list(named)
|
||||
if not has_total:
|
||||
seed = named[0]
|
||||
for raw in extract.items or []:
|
||||
if not str(raw.fee_name or "").strip():
|
||||
seed = raw
|
||||
break
|
||||
items.append(
|
||||
AdjustItem(
|
||||
fee_name=label,
|
||||
kind=seed.kind,
|
||||
direction=seed.direction,
|
||||
value=seed.value,
|
||||
)
|
||||
)
|
||||
return AdjustExtract(is_adjust=True, wants_total=False, items=items)
|
||||
|
||||
|
||||
def apply_adjust(
|
||||
quote: dict[str, Any] | None,
|
||||
extract: AdjustExtract,
|
||||
@@ -782,6 +846,8 @@ def apply_adjust(
|
||||
rows = adjustable_rows(src)
|
||||
if not extract.is_adjust:
|
||||
return ApplyResult(ok=False, code="not_adjust", quote=src)
|
||||
# 同时说了「始发港费用小计」和「费用合计」时:不能只走合计通道,否则小计不改、Excel 汇总也不动。
|
||||
extract = _fold_total_into_items(extract, business_line=business_line, quote=src)
|
||||
header = str(src.get("currency") or "").strip()
|
||||
total_ccy = row_currency({"currency": header, "amount": src.get("total")}, header)
|
||||
currencies = {row_currency(r, header) for r in rows if row_currency(r, header)}
|
||||
@@ -798,6 +864,7 @@ def apply_adjust(
|
||||
currency_hint = bool(multi)
|
||||
total_amt, _ = parse_money(src.get("total"))
|
||||
new_total = total_amt
|
||||
total_touched = False
|
||||
single_same_ccy = _single_line_same_currency(
|
||||
[row for row in rows if not row.get("_subtotal_key") and not row.get("_is_total")],
|
||||
total_ccy or header,
|
||||
@@ -845,8 +912,10 @@ def apply_adjust(
|
||||
bucket = str(row.get("_bucket") or "")
|
||||
if row.get("_is_total"):
|
||||
subtotal_only["__total__"] = new_amt
|
||||
total_touched = True
|
||||
continue
|
||||
if row.get("_subtotal_key"):
|
||||
# 只改小计数字写回 fee_total_*;明细是否另调看有没有点名费目
|
||||
subtotal_only[bucket] = new_amt - old_amt
|
||||
continue
|
||||
if bucket:
|
||||
@@ -861,10 +930,15 @@ def apply_adjust(
|
||||
|
||||
if (not multi) and new_total is not None:
|
||||
if "__total__" in subtotal_only:
|
||||
# 合计是整单改数;小计差额不再叠进合计,避免「合计×1.1 + 小计差额」重复
|
||||
new_total = subtotal_only.pop("__total__")
|
||||
for bucket, delta in subtotal_only.items():
|
||||
if bucket not in touched_buckets:
|
||||
new_total = new_total + delta
|
||||
else:
|
||||
for bucket, delta in subtotal_only.items():
|
||||
if bucket not in touched_buckets:
|
||||
new_total = new_total + delta
|
||||
elif total_touched and "__total__" in subtotal_only:
|
||||
# 多币种:点名「费用合计」仍改合计数字,不做汇率换算
|
||||
new_total = subtotal_only["__total__"]
|
||||
|
||||
out = dict(src)
|
||||
visible = []
|
||||
@@ -876,7 +950,7 @@ def apply_adjust(
|
||||
out["fee_rows"] = visible
|
||||
out["fee_lines"] = visible
|
||||
_write_adjusted_buckets(out, rows)
|
||||
if applied and (not multi) and new_total is not None and new_total != total_amt:
|
||||
if applied and new_total is not None and new_total != total_amt and ((not multi) or total_touched):
|
||||
out["total"] = format_money(new_total, sample=str(src.get("total") or ""), currency=total_ccy)
|
||||
if not applied:
|
||||
code = "ambiguous" if ambiguous else "none_matched"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
海运运输分类:只认 Port to Port、其他。
|
||||
|
||||
本文件职责:从原话收成标准词、生成补问清单。
|
||||
本文件职责:从原话收成标准词、生成补问清单;贸易条款标签行原样优先。
|
||||
禁止:发明第三项;把分类带去 TMS;改六态。
|
||||
线程:纯函数,可在 Worker 槽调用。
|
||||
"""
|
||||
@@ -15,6 +15,15 @@ SEA_CLASSES = ("Port to Port", "其他")
|
||||
# 补问清单下一行「(如:Port to Port、其他)」只是举例,不能当成销售已选。
|
||||
_CLARIFY_EXAMPLE_LINE = re.compile(r"^\s*[((]如\s*[::]")
|
||||
|
||||
# 标签行优先:避免「贸易条款:Port to Port」整句扫描时把运输分类也收成 Port to Port。
|
||||
_LABELED_SEA_CLASS = re.compile(
|
||||
r"(?im)^\s*运输分类(?:(必填)|\(必填\))?\s*[::]\s*(.+?)\s*$"
|
||||
)
|
||||
_LABELED_TRADE_TERMS = re.compile(
|
||||
r"(?im)^\s*贸易条款(?:(必填)|\(必填\)|(非必填)|\(非必填\))?\s*[::]\s*(.+?)\s*$"
|
||||
)
|
||||
_TRADE_TERMS_LINE = re.compile(r"(?im)^\s*贸易条款")
|
||||
|
||||
# 长词优先,避免 P2P 误伤其它词;门到门不在名单里。
|
||||
_ALIASES = (
|
||||
("port to port", "Port to Port"),
|
||||
@@ -58,16 +67,39 @@ def _without_clarify_examples(text: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _without_trade_terms_lines(text: str) -> str:
|
||||
"""整句扫运输分类时丢掉「贸易条款:…」行,避免条款里的 Port to Port 冒充分类。"""
|
||||
return "\n".join(
|
||||
line for line in (text or "").splitlines() if not _TRADE_TERMS_LINE.match(line.strip())
|
||||
)
|
||||
|
||||
|
||||
def _labeled_value(text: str, pattern: re.Pattern[str]) -> str:
|
||||
"""取原文最后一次标签行冒号后的值;没有则空串。"""
|
||||
last = ""
|
||||
for hit in pattern.finditer(_without_clarify_examples(text or "")):
|
||||
last = (hit.group(1) or "").strip()
|
||||
return last
|
||||
|
||||
|
||||
def harvest_sea_class(text: str, current: dict[str, str] | None = None) -> dict[str, str]:
|
||||
"""
|
||||
当前句能认则覆盖;认不出则保留已有标准值。
|
||||
|
||||
参数:本轮原文、已有 facts。返回合并后的 facts 副本。
|
||||
举例行「(如:Port to Port、其他)」先丢掉,避免示例里的 Port to Port 盖掉冒号后的「其它」。
|
||||
优先「运输分类:」标签行(WO202609280012:条款写 Port to Port、分类写其它)。
|
||||
整句扫描时排除贸易条款行,避免条款里的 Port to Port 盖掉「其它」。
|
||||
「其它」与「其他」都收成「其他」。非法旧值会丢掉,避免「门到门」一直占着必填位。
|
||||
"""
|
||||
merged = dict(current or {})
|
||||
found = canonicalize_sea_class(_without_clarify_examples(text or ""))
|
||||
cleaned = _without_clarify_examples(text or "")
|
||||
labeled = _labeled_value(cleaned, _LABELED_SEA_CLASS)
|
||||
if labeled:
|
||||
found_labeled = canonicalize_sea_class(labeled)
|
||||
if found_labeled:
|
||||
merged["运输分类"] = found_labeled
|
||||
return merged
|
||||
# 整句扫描:不含贸易条款行
|
||||
found = canonicalize_sea_class(_without_trade_terms_lines(cleaned))
|
||||
if found:
|
||||
merged["运输分类"] = found
|
||||
return merged
|
||||
@@ -98,6 +130,20 @@ _DOOR_TRADE_TERMS = frozenset(
|
||||
)
|
||||
|
||||
|
||||
def harvest_sea_trade_terms(text: str, facts: dict[str, str] | None = None) -> dict[str, str]:
|
||||
"""
|
||||
海运贸易条款:标签行冒号后原样优先。
|
||||
|
||||
WO202609280012:销售写「贸易条款:Port to Port」,模型却收成「门到门」。
|
||||
有标签行则强制用标签值;门到门是否丢掉仍交给 drop_invented_door_trade_terms。
|
||||
"""
|
||||
merged = dict(facts or {})
|
||||
labeled = _labeled_value(text or "", _LABELED_TRADE_TERMS)
|
||||
if labeled:
|
||||
merged["贸易条款"] = labeled
|
||||
return merged
|
||||
|
||||
|
||||
def drop_invented_door_trade_terms(text: str, facts: dict[str, str] | None = None) -> dict[str, str]:
|
||||
"""
|
||||
丢掉冒充贸易条款的「门到门」。
|
||||
@@ -105,11 +151,17 @@ def drop_invented_door_trade_terms(text: str, facts: dict[str, str] | None = Non
|
||||
测服 WO202609200013:原话没写贸易条款,确认卡却出现「贸易条款:门到门」。
|
||||
门到门只是运输分类说法,不是 FOB/CIF 这类条款;原话没有「贸易条款」四字时不准收下。
|
||||
销售明确写了「贸易条款:门到门」则保留原文,不改六态。
|
||||
若标签行写的是 Port to Port / 港到港等,禁止再用模型的「门到门」盖掉。
|
||||
"""
|
||||
merged = dict(facts or {})
|
||||
merged = harvest_sea_trade_terms(text, facts)
|
||||
trade = str(merged.get("贸易条款") or "").strip()
|
||||
if not trade:
|
||||
return merged
|
||||
labeled = _labeled_value(text or "", _LABELED_TRADE_TERMS)
|
||||
if labeled:
|
||||
# 标签已写死条款:以标签为准(含 Port to Port)
|
||||
merged["贸易条款"] = labeled.strip()
|
||||
return merged
|
||||
if "贸易条款" in (text or ""):
|
||||
return merged
|
||||
if trade.lower().replace(" ", "") in {x.replace(" ", "") for x in _DOOR_TRADE_TERMS} or trade in _DOOR_TRADE_TERMS:
|
||||
|
||||
@@ -78,6 +78,10 @@ _IATA_PAIR_BLOCK = frozenset(
|
||||
}
|
||||
)
|
||||
_NUMBER = re.compile(r"(\d+(?:\.\d+)?)")
|
||||
# 销售写尺寸当体积:1.1*1.2*3 / 1.1x1.2x3 / 1.1×1.2×3
|
||||
_DIM_PRODUCT = re.compile(
|
||||
r"(\d+(?:\.\d+)?)\s*[*xX×]\s*(\d+(?:\.\d+)?)(?:\s*[*xX×]\s*(\d+(?:\.\d+)?))?"
|
||||
)
|
||||
|
||||
|
||||
def _first_number(raw: str) -> str:
|
||||
@@ -85,6 +89,32 @@ def _first_number(raw: str) -> str:
|
||||
return hit.group(1) if hit else ""
|
||||
|
||||
|
||||
def volume_cbm_number(raw: str) -> str:
|
||||
"""
|
||||
体积出站用数字:尺寸式先相乘;否则取第一个数。
|
||||
|
||||
例:1.1*1.2*3 → 3.96;2CBM → 2。空串表示没有可用体积。
|
||||
"""
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
dims = _DIM_PRODUCT.search(text)
|
||||
if dims:
|
||||
try:
|
||||
a = float(dims.group(1))
|
||||
b = float(dims.group(2))
|
||||
c = float(dims.group(3) or "1")
|
||||
product = a * b * c
|
||||
if product <= 0:
|
||||
return ""
|
||||
# 去掉多余尾零,保持可读小数
|
||||
text_num = f"{product:.10f}".rstrip("0").rstrip(".")
|
||||
return text_num or ""
|
||||
except ValueError:
|
||||
return ""
|
||||
return _first_number(text)
|
||||
|
||||
|
||||
def resolve_airport_code(raw: str) -> str:
|
||||
"""销售原文是地名或三字码时,给出 TMS originCode/destinationCode。"""
|
||||
text = (raw or "").strip()
|
||||
@@ -161,7 +191,7 @@ def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]:
|
||||
cargo = str(src.get("品名") or "").strip()
|
||||
pieces = _first_number(str(src.get("件数") or ""))
|
||||
weight = _first_number(str(src.get("毛重") or ""))
|
||||
volume = _first_number(str(src.get("体积") or ""))
|
||||
volume = volume_cbm_number(str(src.get("体积") or ""))
|
||||
pack_raw = str(src.get("包装方式") or src.get("包装类型") or "").strip()
|
||||
pack_code = resolve_package_type(pack_raw)
|
||||
quote_date = str(src.get("报价日期") or "").strip()
|
||||
|
||||
Reference in New Issue
Block a user