完善报价单填表与币种回填,并补齐账号末次登录、角色按钮权限与陆运全线匹配。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -37,7 +37,7 @@ class QuoteTemplateParseBody(BaseModel):
|
||||
yellow_count: int = Field(default=0, alias="yellowCount")
|
||||
truncated: bool = False
|
||||
request_id: str = Field(default="", alias="requestId")
|
||||
# 未换文件重新解析:true + existingMapping,回写时旧字段优先补缺
|
||||
# 未换文件重新解析:true + existingMapping,回写时智能合并(对齐保留、对不上改坐标)
|
||||
merge_existing: bool = Field(default=False, alias="mergeExisting")
|
||||
existing_mapping: Optional[dict[str, Any]] = Field(default=None, alias="existingMapping")
|
||||
|
||||
|
||||
@@ -114,8 +114,8 @@ def _process_quote_template_parse(payload: dict[str, Any], *, allow_network: boo
|
||||
"""
|
||||
跑 sheet_map 并回写主账;失败也必须回写 failed+原因。
|
||||
|
||||
mergeExisting=true(未换文件重新解析)时:与 existingMapping 补缺合并后再回写,
|
||||
旧字段优先保留;换文件/首传不传该标记,整份覆盖。
|
||||
mergeExisting=true(未换文件重新解析)时:与 existingMapping 智能合并后再回写,
|
||||
仍对齐标签的旧坐标保留,对不上的改用新坐标;换文件/首传不传该标记,整份覆盖。
|
||||
"""
|
||||
from agent.config import get_settings
|
||||
from agent.llm.mode_sheet_map import (
|
||||
@@ -130,11 +130,14 @@ def _process_quote_template_parse(payload: dict[str, Any], *, allow_network: boo
|
||||
result = invoke_sheet_map(payload, allow_network=net, timeout_seconds=240.0, max_rounds=3)
|
||||
merge_existing = bool(payload.get("mergeExisting") or payload.get("merge_existing"))
|
||||
existing = payload.get("existingMapping") or payload.get("existing_mapping")
|
||||
inventory = _inventory_lines_from_payload(payload)
|
||||
body: dict[str, Any]
|
||||
if result.get("ok") and isinstance(result.get("mapping"), dict):
|
||||
mapping = dict(result["mapping"])
|
||||
if merge_existing and isinstance(existing, dict):
|
||||
mapping = merge_mapping_prefer_existing(existing, mapping)
|
||||
mapping = merge_mapping_prefer_existing(
|
||||
existing, mapping, cell_inventory=inventory
|
||||
)
|
||||
# 合并后再对照清单复评缺口,避免角标沿用合并前数字
|
||||
remaining = assess_mapping_gaps(payload, mapping)
|
||||
mapping["mappingGaps"] = [str(g) for g in remaining if g]
|
||||
@@ -153,7 +156,7 @@ def _process_quote_template_parse(payload: dict[str, Any], *, allow_network: boo
|
||||
points = n_fields + n_fee + n_route
|
||||
parse_message = f"已解析 {points} 个映射点"
|
||||
if merge_existing:
|
||||
parse_message += "(保留原映射并补缺)"
|
||||
parse_message += "(对齐的保留,对不上的改用新坐标)"
|
||||
if mapping.get("mappingGaps"):
|
||||
preview = ";".join(mapping["mappingGaps"][:8])
|
||||
more = len(mapping["mappingGaps"]) - 8
|
||||
@@ -188,6 +191,13 @@ def _process_quote_template_parse(payload: dict[str, Any], *, allow_network: boo
|
||||
return {"ok": bool(body.get("ok")), "posted": posted, "kind": "quote_template_parse"}
|
||||
|
||||
|
||||
def _inventory_lines_from_payload(payload: dict[str, Any]) -> list[str]:
|
||||
"""从解析入队载荷取出单元格清单行。"""
|
||||
raw = payload.get("cellInventory") or payload.get("cell_inventory") or []
|
||||
if isinstance(raw, list):
|
||||
return [str(x) for x in raw]
|
||||
return []
|
||||
|
||||
def _post_parse_result(body: dict[str, Any]) -> dict[str, Any]:
|
||||
"""PUT /inquiry/agent/quoteTemplate/parseResult。"""
|
||||
try:
|
||||
|
||||
@@ -288,11 +288,14 @@ def assess_mapping_gaps(payload: dict[str, Any], mapping: dict[str, Any] | None)
|
||||
def merge_mapping_prefer_existing(
|
||||
old_mapping: dict[str, Any] | None,
|
||||
new_mapping: dict[str, Any] | None,
|
||||
*,
|
||||
cell_inventory: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
补缺合并:旧 fields / 区域优先,新结果只补充旧里没有的 code/id。
|
||||
重新解析合并:费用/线路区按 id 旧优先;fields 对照单元格清单智能取舍。
|
||||
|
||||
用于「未换文件重新解析」:避免已对上的字段被新一轮 AI 冲掉。
|
||||
规则:旧无→用新;新无→留旧;相同→留旧;旧坐标仍贴标签→留旧;
|
||||
旧坐标对不上(清单里能看到该标签)→改用新坐标。无清单时保守旧优先。
|
||||
"""
|
||||
new_mapping = dict(new_mapping or {})
|
||||
old_mapping = old_mapping if isinstance(old_mapping, dict) else {}
|
||||
@@ -300,11 +303,41 @@ def merge_mapping_prefer_existing(
|
||||
|
||||
new_fields = new_mapping.get("fields") if isinstance(new_mapping.get("fields"), dict) else {}
|
||||
old_fields = old_mapping.get("fields") if isinstance(old_mapping.get("fields"), dict) else {}
|
||||
fields: dict[str, Any] = dict(new_fields)
|
||||
for code, cell in old_fields.items():
|
||||
if cell is not None and str(cell).strip():
|
||||
fields[str(code)] = cell
|
||||
new_meta = new_mapping.get("fieldMeta") if isinstance(new_mapping.get("fieldMeta"), dict) else {}
|
||||
old_meta = old_mapping.get("fieldMeta") if isinstance(old_mapping.get("fieldMeta"), dict) else {}
|
||||
meta_out: dict[str, Any] = dict(new_meta)
|
||||
|
||||
codes = set(str(k) for k in old_fields.keys()) | set(str(k) for k in new_fields.keys())
|
||||
fields: dict[str, Any] = {}
|
||||
inv = list(cell_inventory or [])
|
||||
for code in codes:
|
||||
old_cell = str(old_fields.get(code) or "").strip()
|
||||
new_cell = str(new_fields.get(code) or "").strip()
|
||||
kept_old = False
|
||||
if not old_cell:
|
||||
chosen = new_cell
|
||||
elif not new_cell:
|
||||
chosen = old_cell
|
||||
kept_old = True
|
||||
elif old_cell.lower() == new_cell.lower():
|
||||
chosen = old_cell
|
||||
kept_old = True
|
||||
elif inv and _field_mapping_still_aligned(code, old_cell, inv):
|
||||
chosen = old_cell
|
||||
kept_old = True
|
||||
elif inv:
|
||||
chosen = new_cell
|
||||
logger.info("映射坐标纠偏 code=%s old=%s -> new=%s", code, old_cell, new_cell)
|
||||
else:
|
||||
chosen = old_cell
|
||||
kept_old = True
|
||||
if chosen:
|
||||
fields[code] = chosen
|
||||
if kept_old and isinstance(old_meta.get(code), dict):
|
||||
meta_out[code] = dict(old_meta[code])
|
||||
out["fields"] = fields
|
||||
if meta_out:
|
||||
out["fieldMeta"] = meta_out
|
||||
|
||||
out["feeRegions"] = _merge_regions_by_id(
|
||||
old_mapping.get("feeRegions"), new_mapping.get("feeRegions")
|
||||
@@ -315,6 +348,164 @@ def merge_mapping_prefer_existing(
|
||||
return out
|
||||
|
||||
|
||||
# 与主账 FILL_BY_LABEL 对齐的常用码→标签(智能合并校验用)
|
||||
_FIELD_LABELS: dict[str, tuple[str, ...]] = {
|
||||
"quote_no": ("RFQ No", "报价单编号", "报价编号"),
|
||||
"quote_date": ("询价日期", "报价日期", "Inquiry Date"),
|
||||
"pol": ("起运港", "POL"),
|
||||
"pod": ("目的港", "POD"),
|
||||
"commodity": ("品名", "货物品名", "Commodity"),
|
||||
"quantity": ("货物数量", "货量"),
|
||||
"fcl_or_lcl": ("整柜或拼柜",),
|
||||
"container_type_qty": ("箱型箱量",),
|
||||
"valid_until": ("报价有效期", "有效期至"),
|
||||
"total_amount": ("费用总计", "报价合计"),
|
||||
"origin": ("始发地", "始发站", "起运地"),
|
||||
"destination": ("目的地",),
|
||||
}
|
||||
|
||||
|
||||
def _parse_inventory_line(line: str) -> tuple[str, str, str] | None:
|
||||
"""地址 | 原文 | 合并 | 色 → (full_addr, text, merge)。"""
|
||||
parts = [p.strip() for p in str(line or "").split("|")]
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
return parts[0], parts[1], parts[2] if len(parts) > 2 else "-"
|
||||
|
||||
|
||||
def _col_index(letter: str) -> int:
|
||||
n = 0
|
||||
for ch in (letter or "").upper():
|
||||
if "A" <= ch <= "Z":
|
||||
n = n * 26 + (ord(ch) - ord("A") + 1)
|
||||
return max(n - 1, 0)
|
||||
|
||||
|
||||
def _split_addr(full: str) -> tuple[str, int, int] | None:
|
||||
"""Sheet!B3 → (sheet, row0, col0)。"""
|
||||
text = (full or "").strip()
|
||||
if "!" not in text:
|
||||
return None
|
||||
sheet, addr = text.split("!", 1)
|
||||
m = re.match(r"^\$?([A-Za-z]+)\$?(\d+)$", addr.strip())
|
||||
if not m:
|
||||
return None
|
||||
return sheet, int(m.group(2)) - 1, _col_index(m.group(1))
|
||||
|
||||
|
||||
def _merge_end_col(merge: str, fallback_col: int) -> int:
|
||||
"""A3:C3 → 末列 index;无合并返回 fallback。"""
|
||||
m = (merge or "").strip()
|
||||
if not m or m == "-":
|
||||
return fallback_col
|
||||
# 可能是 A3:C3 或 Sheet!A3:C3
|
||||
if "!" in m:
|
||||
m = m.split("!", 1)[1]
|
||||
if ":" not in m:
|
||||
return fallback_col
|
||||
end = m.split(":", 1)[1].strip()
|
||||
mm = re.match(r"^\$?([A-Za-z]+)\$?(\d+)$", end)
|
||||
if not mm:
|
||||
return fallback_col
|
||||
return _col_index(mm.group(1))
|
||||
|
||||
|
||||
def _text_looks_like_label(text: str, labels: tuple[str, ...]) -> bool:
|
||||
norm = (text or "").replace(":", ":").strip()
|
||||
if not norm:
|
||||
return False
|
||||
primary = norm.split("/")[0].strip()
|
||||
upper = norm.upper()
|
||||
for lb in labels:
|
||||
if not lb:
|
||||
continue
|
||||
if norm.lower().startswith(lb.lower()) or primary.lower().startswith(lb.lower()):
|
||||
return True
|
||||
if len(lb) >= 3 and lb.upper() in upper:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _field_mapping_still_aligned(code: str, cell_ref: str, inventory: list[str]) -> bool:
|
||||
"""
|
||||
旧坐标是否仍可贴标签。
|
||||
|
||||
quote_no:贴着 RFQ 的 B3 常是 Port to Port / 工作天单选,不算对齐。
|
||||
"""
|
||||
labels = _FIELD_LABELS.get(str(code), ())
|
||||
if not labels:
|
||||
return True
|
||||
parsed = _split_addr(cell_ref)
|
||||
if not parsed:
|
||||
return False
|
||||
sheet, row, col = parsed
|
||||
rows: list[tuple[str, int, int, str, str]] = []
|
||||
mapped_text = ""
|
||||
for line in inventory:
|
||||
one = _parse_inventory_line(line)
|
||||
if not one:
|
||||
continue
|
||||
addr, text, merge = one
|
||||
sp = _split_addr(addr)
|
||||
if not sp or sp[0] != sheet:
|
||||
continue
|
||||
rows.append((addr, sp[1], sp[2], text, merge))
|
||||
if sp[1] == row and sp[2] == col:
|
||||
mapped_text = text
|
||||
if _text_looks_like_label(text, labels):
|
||||
return True
|
||||
if _is_unsuitable_value_cell(mapped_text):
|
||||
return False
|
||||
if str(code) == "quote_no" and mapped_text.strip() and not _looks_like_quote_no(mapped_text):
|
||||
return False
|
||||
saw = False
|
||||
for _addr, r, c, text, merge in rows:
|
||||
if not _text_looks_like_label(text, labels):
|
||||
continue
|
||||
saw = True
|
||||
if r == row and c == col:
|
||||
return True
|
||||
end_col = _merge_end_col(merge, c)
|
||||
if r == row:
|
||||
gap = col - end_col
|
||||
if 1 <= gap <= 3 and (not mapped_text.strip() or _looks_like_quote_no(mapped_text)):
|
||||
return True
|
||||
if str(code) == "quote_no" and gap > 3 and (
|
||||
not mapped_text.strip() or _looks_like_quote_no(mapped_text)
|
||||
):
|
||||
return True
|
||||
return not saw
|
||||
|
||||
|
||||
def _is_unsuitable_value_cell(text: str) -> bool:
|
||||
t = (text or "").replace(":", ":").strip()
|
||||
if not t:
|
||||
return False
|
||||
lower = t.lower()
|
||||
if any(x in t for x in ("○", "●", "⊙", "□", "☑")):
|
||||
return True
|
||||
if "port to port" in lower or "door to door" in lower:
|
||||
return True
|
||||
if "工作天" in t or "工作日" in t or "回复" in t:
|
||||
return True
|
||||
if "其它" in t and ("天" in t or "回复" in t or "Port" in t):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _looks_like_quote_no(text: str) -> bool:
|
||||
t = (text or "").strip()
|
||||
if not t or len(t) > 40:
|
||||
return False
|
||||
import re as _re
|
||||
|
||||
if _re.match(r"(?i)WO\d{6,}", t) or _re.match(r"(?i)W0\d{6,}", t):
|
||||
return True
|
||||
if _re.match(r"(?i)RFQ[-_]?[A-Z0-9]+", t):
|
||||
return True
|
||||
return bool(_re.match(r"[A-Z]{1,5}[-_]?\d{8,}", t))
|
||||
|
||||
|
||||
def _merge_regions_by_id(old_arr: Any, new_arr: Any) -> list[dict[str, Any]]:
|
||||
"""区域按 id 合并:旧整段保留,新 id 追加。"""
|
||||
out: list[dict[str, Any]] = []
|
||||
|
||||
@@ -179,15 +179,54 @@ class TestSheetMapParse(unittest.TestCase):
|
||||
"routeRegions": [],
|
||||
"mappingGaps": [],
|
||||
}
|
||||
# 无清单:保守旧优先
|
||||
merged = merge_mapping_prefer_existing(old, new)
|
||||
self.assertEqual(merged["fields"]["quote_no"], "S!B3") # 旧优先
|
||||
self.assertEqual(merged["fields"]["pol"], "S!B5") # 旧保留
|
||||
self.assertEqual(merged["fields"]["pod"], "S!B6") # 新补缺
|
||||
self.assertEqual(merged["fields"]["quote_no"], "S!B3")
|
||||
self.assertEqual(merged["fields"]["pol"], "S!B5")
|
||||
self.assertEqual(merged["fields"]["pod"], "S!B6")
|
||||
ids = [r["id"] for r in merged["feeRegions"]]
|
||||
self.assertEqual(ids[0], "origin_charges")
|
||||
self.assertEqual(merged["feeRegions"][0]["title"], "旧始发港")
|
||||
self.assertIn("ocean_freight", ids)
|
||||
|
||||
def test_merge_replaces_misaligned_quote_no(self) -> None:
|
||||
"""旧 quote_no 落在单选 B3 → 改用新坐标 F3。"""
|
||||
from agent.llm.mode_sheet_map import merge_mapping_prefer_existing
|
||||
|
||||
inventory = [
|
||||
"S!A3 | RFQ No: | A3:A3 | -",
|
||||
"S!B3 | ○ Port to Port / 其它,3工作天回复 | B3:E3 | -",
|
||||
"S!F3 | | F3:G3 | -",
|
||||
]
|
||||
old = {"fields": {"quote_no": "S!B3"}, "feeRegions": [], "routeRegions": []}
|
||||
new = {"fields": {"quote_no": "S!F3"}, "feeRegions": [], "routeRegions": []}
|
||||
merged = merge_mapping_prefer_existing(old, new, cell_inventory=inventory)
|
||||
self.assertEqual(merged["fields"]["quote_no"], "S!F3")
|
||||
|
||||
def test_merge_keeps_aligned_quote_no_when_ai_drifts(self) -> None:
|
||||
"""旧已在 RFQ 旁空格、新一轮漂到远处 → 仍保留旧坐标。"""
|
||||
from agent.llm.mode_sheet_map import merge_mapping_prefer_existing
|
||||
|
||||
inventory = [
|
||||
"S!A3 | RFQ No: | - | -",
|
||||
"S!B3 | | - | -",
|
||||
]
|
||||
old = {"fields": {"quote_no": "S!B3"}, "feeRegions": [], "routeRegions": []}
|
||||
new = {"fields": {"quote_no": "S!Z99"}, "feeRegions": [], "routeRegions": []}
|
||||
merged = merge_mapping_prefer_existing(old, new, cell_inventory=inventory)
|
||||
self.assertEqual(merged["fields"]["quote_no"], "S!B3")
|
||||
|
||||
def test_b3_radio_not_aligned(self) -> None:
|
||||
from agent.llm.mode_sheet_map import _field_mapping_still_aligned
|
||||
|
||||
inventory = [
|
||||
"S!A3 | RFQ No: | - | -",
|
||||
"S!B3 | ○ Port to Port | - | -",
|
||||
"S!F3 | WO202609190002 | - | -",
|
||||
]
|
||||
self.assertFalse(_field_mapping_still_aligned("quote_no", "S!B3", inventory))
|
||||
self.assertTrue(_field_mapping_still_aligned("quote_no", "S!F3", inventory))
|
||||
|
||||
def test_invoke_retries_when_gaps(self) -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user