完善报价单填表与币种回填,并补齐账号末次登录、角色按钮权限与陆运全线匹配。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-09-19 22:03:45 +08:00
co-authored by Cursor
parent 1a2e1d6cb2
commit e4743541f7
25 changed files with 2262 additions and 163 deletions
@@ -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")
+14 -4
View File
@@ -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:
+197 -6
View File
@@ -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]] = []
+42 -3
View File
@@ -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] = []