账号支持只看本账号看板;模板重新解析改为保留原映射并多轮补缺。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-09-19 10:38:53 +08:00
co-authored by Cursor
parent 6cd6803147
commit 9bb69ae72e
19 changed files with 807 additions and 114 deletions
@@ -37,6 +37,9 @@ class QuoteTemplateParseBody(BaseModel):
yellow_count: int = Field(default=0, alias="yellowCount")
truncated: bool = False
request_id: str = Field(default="", alias="requestId")
# 未换文件重新解析:true + existingMapping,回写时旧字段优先补缺
merge_existing: bool = Field(default=False, alias="mergeExisting")
existing_mapping: Optional[dict[str, Any]] = Field(default=None, alias="existingMapping")
model_config = {"populate_by_name": True}
@@ -103,7 +106,10 @@ def enqueue_parse(
"cellCount": body.cell_count or len(inventory),
"yellowCount": body.yellow_count,
"truncated": bool(body.truncated),
"mergeExisting": bool(body.merge_existing),
}
if body.merge_existing and isinstance(body.existing_mapping, dict):
payload["existingMapping"] = body.existing_mapping
result = enqueue_recognition(
kind="quote_template_parse",
payload=payload,
+34 -9
View File
@@ -111,28 +111,50 @@ def process_recognition(data: dict[str, Any], *, allow_network: bool = True) ->
def _process_quote_template_parse(payload: dict[str, Any], *, allow_network: bool) -> dict[str, Any]:
"""跑 sheet_map 并回写主账;失败也必须回写 failed+原因。"""
"""
跑 sheet_map 并回写主账;失败也必须回写 failed+原因。
mergeExisting=true(未换文件重新解析)时:与 existingMapping 补缺合并后再回写,
旧字段优先保留;换文件/首传不传该标记,整份覆盖。
"""
from agent.config import get_settings
from agent.llm.mode_sheet_map import invoke_sheet_map
from agent.llm.mode_sheet_map import (
assess_mapping_gaps,
invoke_sheet_map,
merge_mapping_prefer_existing,
)
version_id = str(payload.get("versionId") or "").strip()
settings = get_settings()
net = bool(allow_network and settings.llm_allow_network)
result = invoke_sheet_map(payload, allow_network=net)
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")
body: dict[str, Any]
if result.get("ok") and isinstance(result.get("mapping"), dict):
mapping = dict(result["mapping"])
gaps = result.get("gaps_after")
if not isinstance(gaps, list):
gaps = mapping.get("mappingGaps") if isinstance(mapping.get("mappingGaps"), list) else []
mapping["mappingGaps"] = [str(g) for g in gaps if g]
if merge_existing and isinstance(existing, dict):
mapping = merge_mapping_prefer_existing(existing, mapping)
# 合并后再对照清单复评缺口,避免角标沿用合并前数字
remaining = assess_mapping_gaps(payload, mapping)
mapping["mappingGaps"] = [str(g) for g in remaining if g]
if not remaining:
mapping["unmappedRequired"] = []
mapping["unmappedLabels"] = []
else:
gaps = result.get("gaps_after")
if not isinstance(gaps, list):
gaps = mapping.get("mappingGaps") if isinstance(mapping.get("mappingGaps"), list) else []
mapping["mappingGaps"] = [str(g) for g in gaps if g]
# 运营可读摘要:点数之外固定带上未解析项
n_fields = len(mapping.get("fields") or {})
n_fee = len(mapping.get("feeRegions") or []) if isinstance(mapping.get("feeRegions"), list) else 0
n_route = len(mapping.get("routeRegions") or []) if isinstance(mapping.get("routeRegions"), list) else 0
points = n_fields + n_fee + n_route
parse_message = f"已解析 {points} 个映射点"
if mapping["mappingGaps"]:
if merge_existing:
parse_message += "(保留原映射并补缺)"
if mapping.get("mappingGaps"):
preview = ";".join(mapping["mappingGaps"][:8])
more = len(mapping["mappingGaps"]) - 8
parse_message += f";未解析到 {len(mapping['mappingGaps'])} 项:{preview}"
@@ -144,6 +166,7 @@ def _process_quote_template_parse(payload: dict[str, Any], *, allow_network: boo
"mappingJson": mapping,
"parseStatus": "parsed",
"parseMessage": parse_message,
"mergeExisting": merge_existing,
}
else:
err = str(result.get("error") or "AI 解析失败")
@@ -152,12 +175,14 @@ def _process_quote_template_parse(payload: dict[str, Any], *, allow_network: boo
"ok": False,
"parseStatus": "failed",
"parseMessage": err,
"mergeExisting": merge_existing,
}
posted = _post_parse_result(body)
logger.info(
"quote_template_parse done versionId=%s ok=%s posted=%s",
"quote_template_parse done versionId=%s ok=%s merge=%s posted=%s",
version_id,
body.get("ok"),
merge_existing,
posted.get("ok"),
)
return {"ok": bool(body.get("ok")), "posted": posted, "kind": "quote_template_parse"}
+188 -48
View File
@@ -2,7 +2,7 @@
报价工作表映射 mode:上传时用千问产出 mappingJson。
本文件职责:加载映射分析提示词、拼用户消息、调千问、剥 Markdown、校验 JSON、
对照单元格清单做缺项检测,必要时再补跑一轮。
对照单元格清单做缺项检测;有缺口则多轮补跑(默认最多 3 轮),目标一次流程内齐全。
禁止:发明运价;在企微回调线程同步调用;出单阶段再调本 mode。
"""
@@ -22,30 +22,58 @@ MODE = LlmMode.SHEET_MAP
_PROMPT_CACHE: str | None = None
# 清单原文片段 → 白名单 code(用于缺项检测;不要求全覆盖词典)
_LABEL_TO_CODE: list[tuple[tuple[str, ...], str]] = [
(("提货地址", "Place of Receipt", "place of receipt"), "pickup_address"),
(("送货地址", "派送地址", "Place of Delivery", "place of delivery"), "delivery_address"),
(("货值", "Cargo Value", "cargo value"), "cargo_value"),
(("海关编码", "HS Code", "HS编码", "hs code"), "hs_code"),
(("油、电、磁", "油,电,磁", "油电磁", "IL, ELEC", "L,ELEC", "oil"), "oil_elec_mag"),
# 清单原文片段 → 白名单 code(按运输方式过滤;关键词避免过短英文误报)
_LABEL_TO_CODE_COMMON: list[tuple[tuple[str, ...], str]] = [
(("报价单编号", "询价单号", "RFQ No", "Quote No"), "quote_no"),
(("报价日期", "询价日期", "Inquiry Date", "Quote Date"), "quote_date"),
(("有效期至", "有效期", "Validity"), "valid_until"),
(("客户名称", "Customer Name", "Customer"), "customer_name"),
(("联系人", "Contact Person", "Contact Name"), "contact_name"),
(("电话", "Tel", "Phone", "Mobile"), "contact_phone"),
(("币别", "Currency"), "currency"),
(("业务员", "Sales"), "sales_name"),
(("品名", "Commodity", "Goods Description"), "commodity"),
]
_LABEL_TO_CODE_SEA: list[tuple[tuple[str, ...], str]] = [
(("提货地址", "Place of Receipt"), "pickup_address"),
(("送货地址", "派送地址", "Place of Delivery"), "delivery_address"),
(("货值", "Cargo Value"), "cargo_value"),
(("海关编码", "HS Code", "HS编码"), "hs_code"),
(("油、电、磁", "油,电,磁", "油电磁"), "oil_elec_mag"),
(("特殊说明", "特别说明", "Special Remarks"), "special_remarks"),
(("要求货物到达", "Requested Arriv", "抵达时间"), "eta_request"),
(("运输时效", "Transit Time", "航程时间", "Navigation time"), "transit_time"),
(("船期", "Sailing", "Requested Sh"), "sailing_schedule"),
(("开船日", "On Board"), "on_board_date"),
(("船公司", "Shipping Line", "Shipping Co"), "shipping_line"),
(("要求货物到达", "Requested Arrival"), "eta_request"),
(("运输时效", "Transit Time", "航程时间"), "transit_time"),
(("船期", "Sailing Schedule"), "sailing_schedule"),
(("开船日", "On Board Date"), "on_board_date"),
(("船公司", "Shipping Line", "Shipping Company"), "shipping_line"),
(("货好时间", "Cargo Ready"), "cargo_ready_date"),
(("贸易条款", "Terms"), "trade_terms"),
(("贸易条款", "Trade Terms", "Incoterms"), "trade_terms"),
(("整柜或散货", "整柜或拼柜", "FCL or LCL"), "fcl_or_lcl"),
(("箱型", "柜量", "Type & Qty", "Cont."), "container_type_qty"),
(("起运港", "POL"), "pol"),
(("目的港", "POD"), "pod"),
(("品名", "Commodity"), "commodity"),
(("箱型箱量", "箱型", "柜量", "Type & Qty"), "container_type_qty"),
(("起运港", "Port of Loading", "POL"), "pol"),
(("目的港", "Port of Discharge", "POD"), "pod"),
(("货物数量", "Quantity"), "quantity"),
(("询价日期", "Inquiry Date", "报价日期"), "quote_date"),
(("RFQ", "询价单号", "报价单编号"), "quote_no"),
(("有效期", "period"), "valid_until"),
]
_LABEL_TO_CODE_AIR: list[tuple[tuple[str, ...], str]] = [
(("起运地", "起运港", "Departure", "Origin Airport"), "origin"),
(("目的地", "目的港", "Destination Airport"), "destination"),
(("件数", "Pieces", "Pkgs"), "pieces"),
(("毛重", "重量", "Gross Weight", "Weight"), "weight_kg"),
(("体积", "Volume", "CBM"), "volume_cbm"),
(("计费重", "Chargeable Weight"), "chargeable_weight_kg"),
(("航班", "Flight"), "flight_no"),
(("中转机场", "Transfer Airport"), "transfer_airport"),
(("贸易条款", "Trade Terms", "Incoterms"), "trade_terms"),
(("特殊说明", "特别说明", "Special Remarks"), "special_remarks"),
]
_LABEL_TO_CODE_LAND: list[tuple[tuple[str, ...], str]] = [
(("始发地", "起运地", "发站", "Origin"), "origin"),
(("目的地", "到站", "Destination"), "destination"),
(("运输时效", "时效", "Transit Time"), "transit_time"),
(("特殊说明", "特别说明"), "special_remarks"),
]
_SEA_FEE_MARKERS: list[tuple[tuple[str, ...], tuple[str, ...]]] = [
@@ -65,6 +93,33 @@ _SEA_FEE_MARKERS: list[tuple[tuple[str, ...], tuple[str, ...]]] = [
]
def _labels_for_mode(mode: str) -> list[tuple[tuple[str, ...], str]]:
m = (mode or "sea").strip().lower()
out = list(_LABEL_TO_CODE_COMMON)
if m in ("sea", "海运"):
out.extend(_LABEL_TO_CODE_SEA)
elif m in ("air", "空运"):
out.extend(_LABEL_TO_CODE_AIR)
elif m in ("land", "陆运"):
out.extend(_LABEL_TO_CODE_LAND)
else:
out.extend(_LABEL_TO_CODE_SEA)
return out
def _label_hit(blob: str, labels: tuple[str, ...]) -> bool:
"""清单命中:中文标签直接包含;英文至少 4 字符,降低误报。"""
for lab in labels:
if not lab:
continue
if any("\u4e00" <= ch <= "\u9fff" for ch in lab):
if lab in blob:
return True
elif len(lab) >= 4 and lab.lower() in blob.lower():
return True
return False
def mode_meta() -> dict[str, Any]:
return {
"mode": MODE.value,
@@ -178,7 +233,7 @@ def assess_mapping_gaps(payload: dict[str, Any], mapping: dict[str, Any] | None)
"""
对照单元格清单,列出 mapping 明显缺口(中文说明,供补跑用户消息)。
不打外网。海运:费用段标题出现则要求对应 feeRegion;常见业务标签出现则要求 fields code。
不打外网。按运输方式过滤字段词典;海运另检查三段费用区。
"""
blob = _inventory_blob(payload)
if not blob.strip():
@@ -196,24 +251,89 @@ def assess_mapping_gaps(payload: dict[str, Any], mapping: dict[str, Any] | None)
for r in fee_regions
if isinstance(r, dict)
).lower()
route_regions = mapping.get("routeRegions") if isinstance(mapping.get("routeRegions"), list) else []
gaps: list[str] = []
mode = str(payload.get("transportMode") or payload.get("transport_mode") or "sea").strip().lower()
# 字段匹配用:去掉费用段标题,避免「目的港费用」误报成 pod 等字段缺口
field_blob = blob
if mode in ("sea", "海运"):
for markers, matchers in _SEA_FEE_MARKERS:
if not any(m in blob for m in markers):
continue
if not any(x in fee_blob for x in matchers):
gaps.append(f"费用区缺失:清单有「{markers[0]}」,须建对应 feeRegion")
for m in markers:
field_blob = field_blob.replace(m, " ")
elif mode in ("land", "陆运"):
# 陆运主表常见项目/线路矩阵:清单有「项目」或车型价列但无 routeRegions
if (
("项目" in blob or "车型" in blob or "3T" in blob or "拼车" in blob)
and not route_regions
):
gaps.append("线路区缺失:陆运表须建 routeRegions 承接线路/车型报价")
for labels, code in _LABEL_TO_CODE:
if code in field_codes:
# 陆运已建 routeRegions 时,始发地/目的地/时效常在线路矩阵列头,不再强制 fields
land_route_covered = mode in ("land", "陆运") and bool(route_regions)
land_skip_codes = {"origin", "destination", "transit_time"} if land_route_covered else set()
for labels, code in _labels_for_mode(mode):
if code in field_codes or code in land_skip_codes:
continue
if any(lab in blob for lab in labels):
if _label_hit(field_blob, labels):
gaps.append(f"字段缺失:清单有「{labels[0]}」→ 应映射 {code}")
return gaps
def merge_mapping_prefer_existing(
old_mapping: dict[str, Any] | None,
new_mapping: dict[str, Any] | None,
) -> dict[str, Any]:
"""
补缺合并:旧 fields / 区域优先,新结果只补充旧里没有的 code/id。
用于「未换文件重新解析」:避免已对上的字段被新一轮 AI 冲掉。
"""
new_mapping = dict(new_mapping or {})
old_mapping = old_mapping if isinstance(old_mapping, dict) else {}
out = dict(new_mapping)
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
out["fields"] = fields
out["feeRegions"] = _merge_regions_by_id(
old_mapping.get("feeRegions"), new_mapping.get("feeRegions")
)
out["routeRegions"] = _merge_regions_by_id(
old_mapping.get("routeRegions"), new_mapping.get("routeRegions")
)
return out
def _merge_regions_by_id(old_arr: Any, new_arr: Any) -> list[dict[str, Any]]:
"""区域按 id 合并:旧整段保留,新 id 追加。"""
out: list[dict[str, Any]] = []
seen: set[str] = set()
for src in (old_arr, new_arr):
if not isinstance(src, list):
continue
for item in src:
if not isinstance(item, dict):
continue
rid = str(item.get("id") or "").strip()
if rid and rid in seen:
continue
if rid:
seen.add(rid)
out.append(dict(item))
return out
def invoke_shell(*, payload: dict[str, Any]) -> dict[str, Any]:
"""
无网/单测入口:若带 assistant_json 则直接解析;否则返回未实现。
@@ -273,10 +393,12 @@ def invoke_sheet_map(
*,
allow_network: bool = False,
chat_fn: Optional[Any] = None,
timeout_seconds: float = 180.0,
timeout_seconds: float = 240.0,
max_rounds: int = 3,
) -> dict[str, Any]:
"""
调用千问产出 mappingJson;若对照清单有明显缺口,再补跑一轮(最多 2 次 chat)。
调用千问产出 mappingJson;对照清单有缺口则补跑,最多 max_rounds 轮(默认 3),
目标一次流程内尽量齐全,缺口写进 mappingGaps。
allow_network=False 且无 chat_fn:不打外网。
副作用:可能出站 HTTPS;应在 Worker HTTP 槽内调用。
@@ -293,36 +415,54 @@ def invoke_sheet_map(
else:
chat = chat_fn
rounds_cap = max(1, min(int(max_rounds or 3), 4))
first = _chat_once(chat=chat, payload=payload, gaps=None)
if not first.get("ok"):
return first
mapping = first["mapping"]
gaps = assess_mapping_gaps(payload, mapping)
rounds = 1
if gaps:
logger.info(
"sheet_map gaps versionId=%s n=%s sample=%s",
payload.get("versionId"),
len(gaps),
gaps[:5],
)
second = _chat_once(chat=chat, payload=payload, gaps=gaps)
rounds = 2
if second.get("ok") and second.get("mapping"):
mapping = second["mapping"]
# 第二轮仍失败结构时保留第一轮
elif not second.get("ok"):
logger.warning(
"sheet_map 补跑失败,保留第一轮 versionId=%s err=%s",
payload.get("versionId"),
second.get("error"),
)
remaining = assess_mapping_gaps(payload, mapping)
# 缺口落进 mappingJson,主账/后台可直接展示「表上有、未映射上」
# 有缺口就补跑,直到齐、轮次用尽、或本轮未减少缺口
while remaining and rounds < rounds_cap:
logger.info(
"sheet_map gaps versionId=%s round=%s n=%s sample=%s",
payload.get("versionId"),
rounds,
len(remaining),
remaining[:5],
)
nxt = _chat_once(chat=chat, payload=payload, gaps=remaining)
rounds += 1
if not (nxt.get("ok") and nxt.get("mapping")):
logger.warning(
"sheet_map 补跑失败,保留上一轮 versionId=%s err=%s",
payload.get("versionId"),
nxt.get("error"),
)
break
mapping = nxt["mapping"]
after = assess_mapping_gaps(payload, mapping)
if len(after) >= len(remaining):
# 未继续收敛则停止,避免空转烧额度
logger.info(
"sheet_map gaps 未收敛 versionId=%s before=%s after=%s stop",
payload.get("versionId"),
len(remaining),
len(after),
)
remaining = after
break
remaining = after
# 缺口落进 mappingJson;齐全时清空 AI 自报未映射,避免列表角标被说明性标签抬高
if isinstance(mapping, dict):
mapping["mappingGaps"] = list(remaining)
if not remaining:
mapping["unmappedRequired"] = []
mapping["unmappedLabels"] = []
elif not isinstance(mapping.get("unmappedLabels"), list):
mapping["unmappedLabels"] = []
logger.info(
"sheet_map ok versionId=%s rounds=%s fields=%s feeRegions=%s routeRegions=%s remain_gaps=%s",
payload.get("versionId"),
@@ -360,6 +360,8 @@ transportMode:sea | air | land
5. 空运必须能承接 departureCharges + 空运费;陆运主表必须用 routeRegions 承接 landSegments。
6. 看不清 → unmappedRequired / unmappedLabels;表上有、能对上白名单却未映射 → 视为错误,必须补进 fields。
7. 用户未声明运输方式时推断 transportMode,并在 notes 说明。
8. **一次齐全**:第一轮就要尽量映射清单中全部可见业务标签;宁可多写 fields/feeRegions/routeRegions,也不要省略。补跑时输出完整 mappingJson(覆盖式),不要只回增量。
9. 说明性/公式性文字(如「1CBM=***KG」「重量体积取大」)无法落白名单字段时写入 unmappedLabels,不要假装成业务字段缺口。
【用户消息将提供】
运输方式:海运 | 空运 | 陆运
@@ -368,4 +370,4 @@ transportMode:sea | air | land
(可选)TMS 样例或出单投影说明——仅对齐字段
(补跑时)缺口清单:请据此补全整份 mappingJson,不要只回增量。
输出前自检 fields 与 feeRegions/routeRegions 是否覆盖清单中的可见业务标签,再只输出 mappingJson。
输出前自检:fields 与 feeRegions/routeRegions 是否覆盖清单中的可见业务标签;有缺口则先补齐再输出。只输出 mappingJson。
+145
View File
@@ -101,6 +101,93 @@ class TestSheetMapParse(unittest.TestCase):
}
self.assertEqual(assess_mapping_gaps(payload, mapping), [])
def test_assess_gaps_air_ignores_sea_labels(self) -> None:
"""空运不按海运词典报「提货地址/货值」缺口。"""
payload = {
"transportMode": "air",
"cellInventory": [
"A!B2 | 起运地/Origin Airport | - | -",
"A!B3 | 毛重/Gross Weight | - | -",
"A!Z99 | 提货地址/Place of Receipt | - | -",
],
}
mapping = {"fields": {"origin": "A!C2", "weight_kg": "A!C3"}, "feeRegions": []}
gaps = assess_mapping_gaps(payload, mapping)
self.assertFalse(any("pickup_address" in g for g in gaps))
self.assertEqual(gaps, [])
def test_assess_gaps_short_en_no_false_positive(self) -> None:
"""短英文(如 POL)单独出现不误报;须中文或 ≥4 字符英文标签。"""
payload = {
"transportMode": "sea",
"cellInventory": ["S!A1 | POL code table | - | -"],
}
mapping = {"fields": {}, "feeRegions": []}
gaps = assess_mapping_gaps(payload, mapping)
self.assertFalse(any("→ 应映射 pol" in g for g in gaps))
def test_assess_gaps_land_needs_route_regions(self) -> None:
payload = {
"transportMode": "land",
"cellInventory": [
"L!A1 | 项目 | - | -",
"L!B1 | 3T | - | -",
"L!C1 | 拼车 | - | -",
],
}
mapping = {"fields": {}, "feeRegions": [], "routeRegions": []}
gaps = assess_mapping_gaps(payload, mapping)
self.assertTrue(any("routeRegions" in g for g in gaps))
def test_assess_gaps_land_route_skips_matrix_fields(self) -> None:
"""陆运已有 routeRegions 时,不把矩阵列头始发地/目的地当 fields 缺口。"""
payload = {
"transportMode": "land",
"cellInventory": [
"L!A1 | 项目 | - | -",
"L!B1 | 始发地 | - | -",
"L!C1 | 目的地 | - | -",
"L!D1 | 运输时效 | - | -",
"L!E1 | 特殊说明 | - | -",
],
}
mapping = {
"fields": {},
"feeRegions": [],
"routeRegions": [{"id": "main", "title": "线路", "anchor": "L!A2"}],
}
gaps = assess_mapping_gaps(payload, mapping)
self.assertFalse(any("origin" in g for g in gaps))
self.assertFalse(any("destination" in g for g in gaps))
self.assertFalse(any("transit_time" in g for g in gaps))
self.assertTrue(any("special_remarks" in g for g in gaps))
def test_merge_prefer_existing_keeps_old_field(self) -> None:
from agent.llm.mode_sheet_map import merge_mapping_prefer_existing
old = {
"fields": {"quote_no": "S!B3", "pol": "S!B5"},
"feeRegions": [{"id": "origin_charges", "title": "旧始发港"}],
"routeRegions": [],
}
new = {
"fields": {"quote_no": "S!Z99", "pod": "S!B6"},
"feeRegions": [
{"id": "origin_charges", "title": "新始发港"},
{"id": "ocean_freight", "title": "海运费用"},
],
"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") # 新补缺
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_invoke_retries_when_gaps(self) -> None:
calls: list[str] = []
@@ -145,6 +232,64 @@ class TestSheetMapParse(unittest.TestCase):
self.assertEqual(out.get("gaps_after"), out["mapping"]["mappingGaps"])
self.assertEqual(out["mapping"]["mappingGaps"], [])
def test_invoke_multi_round_until_complete(self) -> None:
"""最多 3 轮:第 1 残缺 → 第 2 仍缺 → 第 3 齐。"""
calls: list[int] = []
def fake_chat(*, mode, messages, temperature=0.0): # noqa: ARG001
calls.append(1)
n = len(calls)
if n == 1:
content = (
'{"schemaVersion":"quote-template-mapping-v1",'
'"fields":{"quote_no":"S!B3"},"feeRegions":[]}'
)
elif n == 2:
content = (
'{"schemaVersion":"quote-template-mapping-v1",'
'"fields":{"quote_no":"S!B3","pickup_address":"S!H6"},'
'"feeRegions":[{"id":"origin_charges","title":"始发港费用",'
'"tmsSource":"polCostItems","anchor":"S!A19","stopAtText":["海运费用"],'
'"grow":"insert_before_stop","styleCopyRows":1}]}'
)
else:
content = (
'{"schemaVersion":"quote-template-mapping-v1",'
'"fields":{"quote_no":"S!B3","pickup_address":"S!H6","cargo_value":"S!B11"},'
'"feeRegions":['
'{"id":"origin_charges","title":"始发港费用","tmsSource":"polCostItems",'
'"anchor":"S!A19","stopAtText":["海运费用"],'
'"grow":"insert_before_stop","styleCopyRows":1},'
'{"id":"ocean_freight","title":"海运费用","tmsSource":"oceanCostItems",'
'"anchor":"S!A31","stopAtText":["目的港费用"],'
'"grow":"insert_before_stop","styleCopyRows":1},'
'{"id":"destination_charges","title":"目的港费用","tmsSource":"podCostItems",'
'"anchor":"S!A36","stopAtText":["备注"],'
'"grow":"insert_before_stop","styleCopyRows":1}'
']}'
)
return SimpleNamespace(ok=True, content=content, provider="stub", error="")
payload = {
"versionId": "v2",
"transportMode": "sea",
"fileName": "sea.xlsx",
"sheet": "S",
"cellInventory": [
"S!A18 | 始发港费用/Origin Charges | - | -",
"S!A30 | 海运费用/Ocean Freight | - | -",
"S!A35 | 目的港费用/Destination Charges | - | -",
"S!E6 | 提货地址/Place of Receipt | - | -",
"S!A11 | 货值/Cargo Value | - | -",
],
}
out = invoke_sheet_map(payload, chat_fn=fake_chat, allow_network=False, max_rounds=3)
self.assertTrue(out["ok"])
self.assertEqual(out.get("rounds"), 3)
self.assertEqual(len(calls), 3)
self.assertEqual(out["mapping"]["mappingGaps"], [])
self.assertEqual(len(out["mapping"]["feeRegions"]), 3)
if __name__ == "__main__":
unittest.main()
@@ -127,4 +127,15 @@ public class LoginUser {
/**设备id uniapp推送用*/
private String clientId;
/**
* 工号(询价账号页暂存企微 userid)
*/
@SensitiveField
private String workNo;
/**
* 只看本账号看板数据:登录态透传;看板聚合读取后收紧范围。
*/
private Boolean dashboardSelfOnly;
}
@@ -70,8 +70,15 @@ public class InquiryAgentController {
: (mappingObj instanceof String ? (String) mappingObj : JSON.toJSONString(mappingObj));
String parseMessage = body.get("parseMessage") == null ? null : String.valueOf(body.get("parseMessage"));
String parseStatus = body.get("parseStatus") == null ? null : String.valueOf(body.get("parseStatus"));
boolean mergeExisting = false;
Object mergeFlag = body.get("mergeExisting");
if (mergeFlag instanceof Boolean) {
mergeExisting = (Boolean) mergeFlag;
} else if (mergeFlag != null) {
mergeExisting = "true".equalsIgnoreCase(String.valueOf(mergeFlag));
}
try {
quoteTemplateService.applyParseResult(versionId, ok, mappingJson, parseMessage, parseStatus);
quoteTemplateService.applyParseResult(versionId, ok, mappingJson, parseMessage, parseStatus, mergeExisting);
return Result.OK("ok");
} catch (IllegalArgumentException e) {
return Result.error(e.getMessage());
@@ -185,8 +185,14 @@ public class InquiryQuoteTemplateController {
@ApiOperation("对最新 Excel 版重新提交 AI 异步解析(已解析/失败/映射未完成均可;解析中跳过)")
@RequiresPermissions("inquiry:template:edit")
@PostMapping("/reparse")
public Result<String> reparse(@RequestParam String id) {
String msg = quoteTemplateService.reparseLatest(id);
public Result<String> reparse(@RequestParam(required = false) String id,
@RequestParam(required = false) String templateId) {
// 兼容旧前端只传 id、新前端传 templateId,任一即可。
String key = StringUtils.isNotBlank(templateId) ? templateId : id;
if (StringUtils.isBlank(key)) {
return Result.error("templateId 不能为空");
}
String msg = quoteTemplateService.reparseLatest(key);
return Result.OK(msg);
}
@@ -89,8 +89,11 @@ public interface IInquiryQuoteTemplateService extends IService<InquiryQuoteTempl
* 智能体回写 AI 映射结果(成功 mappingJson 或失败原因)。
* <p>
* 终态优先:已是 parsed/mapping_incomplete/failed 时可被同 version 覆盖;pending 必接受。
*
* @param mergeExisting true=未换文件重新解析:旧字段优先保留,只补新映射;false=整份覆盖
*/
void applyParseResult(String versionId, boolean ok, String mappingJson, String parseMessage, String parseStatus);
void applyParseResult(String versionId, boolean ok, String mappingJson, String parseMessage,
String parseStatus, boolean mergeExisting);
/**
* 纠偏:把「已有映射点但仍标 mapping_incomplete」的版本改为 parsed。
@@ -2,6 +2,8 @@ package org.jeecg.modules.inquiry.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.SecurityUtils;
import org.jeecg.common.system.vo.LoginUser;
import org.jeecg.modules.inquiry.domain.TicketStatuses;
import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
import org.jeecg.modules.inquiry.entity.InquiryTicket;
@@ -75,9 +77,11 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
Date from = Date.from(fromDate.atStartOfDay(ZONE).toInstant());
Date to = Date.from(LocalDateTime.of(today, LocalTime.MAX).atZone(ZONE).toInstant());
List<InquiryTicket> tickets = ticketMapper.selectList(new LambdaQueryWrapper<InquiryTicket>()
LambdaQueryWrapper<InquiryTicket> qw = new LambdaQueryWrapper<InquiryTicket>()
.ge(InquiryTicket::getCreateTime, from)
.le(InquiryTicket::getCreateTime, to));
.le(InquiryTicket::getCreateTime, to);
applyDashboardSelfOnly(qw);
List<InquiryTicket> tickets = ticketMapper.selectList(qw);
long total = tickets.size();
long deal = tickets.stream().filter(t -> TicketStatuses.DEAL.equals(t.getStatus())).count();
@@ -115,9 +119,11 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
// 近 7 日趋势 + 成本:一次拉周内工单,避免 7 次 count
Date weekFrom = Date.from(today.minusDays(6).atStartOfDay(ZONE).toInstant());
List<InquiryTicket> weekTickets = ticketMapper.selectList(new LambdaQueryWrapper<InquiryTicket>()
LambdaQueryWrapper<InquiryTicket> weekQw = new LambdaQueryWrapper<InquiryTicket>()
.ge(InquiryTicket::getCreateTime, weekFrom)
.le(InquiryTicket::getCreateTime, to));
.le(InquiryTicket::getCreateTime, to);
applyDashboardSelfOnly(weekQw);
List<InquiryTicket> weekTickets = ticketMapper.selectList(weekQw);
List<String> trendDates = new ArrayList<>(7);
List<Map<String, Object>> trend = new ArrayList<>(7);
@@ -442,4 +448,72 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
}
return "sales";
}
/**
* 账号勾选「只看自己数据」时收紧看板工单范围。
* <p>
* 口径对齐原项目:非超管且 dashboardSelfOnly=true →
* 工单 salesWecomId = 当前账号工号(企微 id)或 createBy = 登录名。
* 超管(username=admin 或角色 admin)忽略开关。
*/
private void applyDashboardSelfOnly(LambdaQueryWrapper<InquiryTicket> qw) {
LoginUser login = currentLoginUser();
if (login == null) {
return;
}
if (isAdministrator(login)) {
return;
}
if (!Boolean.TRUE.equals(login.getDashboardSelfOnly())) {
return;
}
String username = StringUtils.trimToEmpty(login.getUsername());
String wecom = StringUtils.trimToEmpty(login.getWorkNo());
qw.and(w -> {
boolean any = false;
if (StringUtils.isNotBlank(wecom)) {
w.eq(InquiryTicket::getSalesWecomId, wecom);
any = true;
}
if (StringUtils.isNotBlank(username)) {
if (any) {
w.or();
}
w.eq(InquiryTicket::getCreateBy, username);
any = true;
}
if (!any) {
// 未绑企微且无用户名:宁可不看数据,也不漏看全站
w.apply("1 = 0");
}
});
}
private static LoginUser currentLoginUser() {
try {
Object p = SecurityUtils.getSubject().getPrincipal();
if (p instanceof LoginUser) {
return (LoginUser) p;
}
} catch (Exception ignored) {
// 无登录上下文时不做过滤
}
return null;
}
/** 超管:登录名 admin,或 Shiro 角色 admin/管理员。 */
private static boolean isAdministrator(LoginUser login) {
if (login == null) {
return false;
}
if ("admin".equalsIgnoreCase(StringUtils.trimToEmpty(login.getUsername()))) {
return true;
}
try {
return SecurityUtils.getSubject().hasRole("admin")
|| SecurityUtils.getSubject().hasRole("管理员");
} catch (Exception e) {
return false;
}
}
}
@@ -245,8 +245,8 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
version.setMappingPointCount(0);
version.setMappingJson("{\"schemaVersion\":\"quote-template-mapping-v1\",\"fields\":{},\"status\":\"pending\"}");
versionMapper.insert(version);
// 事务提交后再入队,避免 Worker 回写时版本行尚未可见
scheduleAiParseAfterCommit(version.getId(), template.getId(), fileBytes);
// 事务提交后再入队,避免 Worker 回写时版本行尚未可见;换文件 = 整份覆盖
scheduleAiParseAfterCommit(version.getId(), template.getId(), fileBytes, false);
}
if (isDefault) {
@@ -269,8 +269,11 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
/**
* 事务提交后提交 AI 解析;无事务时立刻提交。
*
* @param mergeExisting true=未换文件的重新解析,回写时与旧 mapping 补缺合并;false=换文件/新上传,整份覆盖
*/
private void scheduleAiParseAfterCommit(String versionId, String templateId, byte[] fileBytes) {
private void scheduleAiParseAfterCommit(String versionId, String templateId, byte[] fileBytes,
boolean mergeExisting) {
Runnable job = () -> {
try {
InquiryQuoteTemplateVersion ver = versionMapper.selectById(versionId);
@@ -279,7 +282,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
log.warn("AI 解析入队跳过:版本或模板不存在 versionId={}", versionId);
return;
}
String enqueueErr = submitAiParse(ver, template, fileBytes);
String enqueueErr = submitAiParse(ver, template, fileBytes, mergeExisting);
if (enqueueErr != null) {
ver.setParseStatus("failed");
ver.setParseMessage(enqueueErr);
@@ -313,8 +316,11 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
/**
* 抽单元格清单并提交智能体入队。成功返回 null,失败返回中文原因。
*
* @param mergeExisting 重新解析时 true:带上旧 mapping,回写补缺合并
*/
private String submitAiParse(InquiryQuoteTemplateVersion version, InquiryQuoteTemplate template, byte[] fileBytes) {
private String submitAiParse(InquiryQuoteTemplateVersion version, InquiryQuoteTemplate template,
byte[] fileBytes, boolean mergeExisting) {
try {
JSONObject inventory;
try (InputStream in = new ByteArrayInputStream(fileBytes)) {
@@ -335,6 +341,19 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
body.put("cellCount", inventory.getIntValue("cellCount"));
body.put("yellowCount", inventory.getIntValue("yellowCount"));
body.put("truncated", inventory.getBooleanValue("truncated"));
body.put("mergeExisting", mergeExisting);
// 仅补缺合并时附带旧映射;换文件不传,避免模型/回写误用
if (mergeExisting && StringUtils.isNotBlank(version.getMappingJson())) {
try {
JSONObject old = JSON.parseObject(version.getMappingJson());
if (old != null && old.getJSONObject("fields") != null
&& !old.getJSONObject("fields").isEmpty()) {
body.put("existingMapping", old);
}
} catch (Exception e) {
log.warn("重新解析附带旧 mapping 失败 versionId={} err={}", version.getId(), e.getMessage());
}
}
body.put("requestId", version.getId() + "-" + System.currentTimeMillis());
return aiParseClient.enqueueParse(body);
} catch (Exception e) {
@@ -980,7 +999,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
ver.setParseMessage("解析中,请稍候");
ver.setUpdateTime(new Date());
versionMapper.updateById(ver);
scheduleAiParseAfterCommit(ver.getId(), template.getId(), fileBytes);
scheduleAiParseAfterCommit(ver.getId(), template.getId(), fileBytes, true);
enqueueSnapshotPreview(ver.getId());
} catch (Exception e) {
log.error("模板补解析失败 templateId={} objectKey={}", templateId, ver.getObjectKey(), e);
@@ -993,10 +1012,13 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
/**
* 智能体回写 AI 映射。校验白名单与 feeRegion 增行配置;未映射必填 → mapping_incomplete。
* <p>
* mergeExisting=true 时:与库中旧 mapping 补缺合并(旧字段/旧区域优先,新结果只补没有的)。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void applyParseResult(String versionId, boolean ok, String mappingJson, String parseMessage, String parseStatus) {
public void applyParseResult(String versionId, boolean ok, String mappingJson, String parseMessage,
String parseStatus, boolean mergeExisting) {
if (StringUtils.isBlank(versionId)) {
throw new IllegalArgumentException("versionId 必填");
}
@@ -1032,6 +1054,20 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
versionMapper.updateById(ver);
return;
}
// 未换文件重新解析:旧映射优先,避免已对上的字段被新一轮 AI 冲掉
if (mergeExisting && StringUtils.isNotBlank(ver.getMappingJson())) {
try {
JSONObject old = JSON.parseObject(ver.getMappingJson());
if (old != null) {
mapping = mergeMappingPreferExisting(old, mapping);
log.info("模板映射补缺合并 versionId={} keepFields={}",
versionId,
old.getJSONObject("fields") == null ? 0 : old.getJSONObject("fields").size());
}
} catch (Exception e) {
log.warn("旧 mapping 合并跳过 versionId={} err={}", versionId, e.getMessage());
}
}
String validateErr = validateAiMapping(mapping);
if (validateErr != null) {
ver.setParseStatus("failed");
@@ -1049,12 +1085,103 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
applyExcelMapping(ver, template != null ? template : new InquiryQuoteTemplate(), mapping);
if (StringUtils.isNotBlank(parseMessage) && "failed".equals(ver.getParseStatus())) {
ver.setParseMessage(parseMessage);
} else if (mergeExisting && !"failed".equals(ver.getParseStatus())) {
// 合并后重算摘要点数,避免沿用 AI 单轮字数
int pts = ver.getMappingPointCount() == null ? 0 : ver.getMappingPointCount();
String base = "已解析 " + pts + " 个映射点(保留原映射并补缺)";
if (StringUtils.isNotBlank(parseMessage) && parseMessage.contains("未解析到")) {
int idx = parseMessage.indexOf(";未解析到");
if (idx > 0) {
base = base + parseMessage.substring(idx);
}
}
ver.setParseMessage(base);
}
ver.setUpdateBy("agent");
ver.setUpdateTime(new Date());
versionMapper.updateById(ver);
}
/**
* 补缺合并:旧 fields / feeRegions / routeRegions 优先;新结果只补充旧里没有的 code/id。
* 不删旧项;mappingGaps 以新结果为准(智能体已按合并后复评时更准,此处兜底保留新 gaps)。
*/
static JSONObject mergeMappingPreferExisting(JSONObject oldMapping, JSONObject newMapping) {
JSONObject out = new JSONObject(true);
if (newMapping != null) {
out.putAll(newMapping);
}
if (oldMapping == null) {
return out;
}
// fields:先放新的,再用旧的覆盖同名 → 旧优先
JSONObject fields = new JSONObject(true);
JSONObject newFields = newMapping == null ? null : newMapping.getJSONObject("fields");
JSONObject oldFields = oldMapping.getJSONObject("fields");
if (newFields != null) {
fields.putAll(newFields);
}
if (oldFields != null) {
for (String code : oldFields.keySet()) {
Object cell = oldFields.get(code);
if (cell != null && StringUtils.isNotBlank(String.valueOf(cell))) {
fields.put(code, cell);
}
}
}
out.put("fields", fields);
out.put("feeRegions", mergeRegionsById(
oldMapping.getJSONArray("feeRegions"),
newMapping == null ? null : newMapping.getJSONArray("feeRegions")));
out.put("routeRegions", mergeRegionsById(
oldMapping.getJSONArray("routeRegions"),
newMapping == null ? null : newMapping.getJSONArray("routeRegions")));
// 说明性标签:齐全时清空;否则保留新的
if (out.getJSONArray("mappingGaps") != null && out.getJSONArray("mappingGaps").isEmpty()) {
out.put("unmappedRequired", new JSONArray());
out.put("unmappedLabels", new JSONArray());
}
return out;
}
/**
* 区域按 id 合并:旧 id 整段保留;新 id 追加在后。
*/
private static JSONArray mergeRegionsById(JSONArray oldArr, JSONArray newArr) {
JSONArray out = new JSONArray();
Set<String> seen = new LinkedHashSet<>();
if (oldArr != null) {
for (int i = 0; i < oldArr.size(); i++) {
JSONObject r = oldArr.getJSONObject(i);
if (r == null) {
continue;
}
String id = StringUtils.defaultString(r.getString("id"));
if (StringUtils.isNotBlank(id)) {
seen.add(id);
}
out.add(r);
}
}
if (newArr != null) {
for (int i = 0; i < newArr.size(); i++) {
JSONObject r = newArr.getJSONObject(i);
if (r == null) {
continue;
}
String id = StringUtils.defaultString(r.getString("id"));
if (StringUtils.isNotBlank(id) && seen.contains(id)) {
continue;
}
if (StringUtils.isNotBlank(id)) {
seen.add(id);
}
out.add(r);
}
}
return out;
}
/**
* 把误标为 mapping_incomplete、但已有映射点的版本纠偏为 parsed。
*/
@@ -1384,7 +1511,10 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
/**
* 从 mappingJson 取出智能体对照清单算出的未映射说明(中文)。
* 若 mappingGaps 为空,用 unmappedLabels / unmappedRequired 兜底,保证列表能提示。
* <p>
* 口径:有 {@code mappingGaps} 字段时(含空数组)只认它——空数组即「系统判定齐全、角标 0」;
* 仅旧数据完全没有该字段时,才用 unmappedLabels / unmappedRequired 兜底。
* 说明性 unmappedLabels 不得在齐全时抬高列表「未解析」角标。
*/
private static List<String> extractMappingGaps(String mappingJson) {
if (StringUtils.isBlank(mappingJson)) {
@@ -1392,19 +1522,24 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
}
try {
JSONObject obj = JSON.parseObject(mappingJson);
JSONArray arr = obj.getJSONArray("mappingGaps");
if (obj == null) {
return Collections.emptyList();
}
List<String> out = new ArrayList<>();
if (arr != null) {
for (int i = 0; i < arr.size(); i++) {
String s = arr.getString(i);
if (StringUtils.isNotBlank(s)) {
out.add(s);
// 显式有 mappingGaps 键:空也算齐全,禁止再用 AI 自报标签抬角标
if (obj.containsKey("mappingGaps")) {
JSONArray arr = obj.getJSONArray("mappingGaps");
if (arr != null) {
for (int i = 0; i < arr.size(); i++) {
String s = arr.getString(i);
if (StringUtils.isNotBlank(s)) {
out.add(s);
}
}
}
}
if (!out.isEmpty()) {
return out;
}
// 旧版映射无 mappingGaps:才兜底
JSONArray labels = obj.getJSONArray("unmappedLabels");
if (labels != null) {
for (int i = 0; i < labels.size(); i++) {
@@ -0,0 +1,26 @@
-- 账号「只看本账号看板数据」+ 确保 admin 绑定超级管理员角色
-- 库:inquiry_robot;幂等。
-- 1) 列
SET @has_col := (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'sys_user'
AND column_name = 'dashboard_self_only'
);
SET @sql := IF(@has_col = 0,
'ALTER TABLE sys_user ADD COLUMN dashboard_self_only tinyint(1) NOT NULL DEFAULT 0 COMMENT ''只看本账号看板数据:1=是,0=否;超管忽略''',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 2) admin 绑定 role_code=admin(无则补一行)
INSERT INTO sys_user_role (id, user_id, role_id)
SELECT REPLACE(UUID(), '-', ''), u.id, r.id
FROM sys_user u
JOIN sys_role r ON r.role_code = 'admin'
WHERE u.username = 'admin'
AND NOT EXISTS (
SELECT 1 FROM sys_user_role ur
WHERE ur.user_id = u.id AND ur.role_id = r.id
)
LIMIT 1;
@@ -187,6 +187,9 @@ public class SysUserController {
baseCommonService.addLog("编辑用户,username: " +sysUser.getUsername() ,CommonConstant.LOG_TYPE_2, 2);
if(sysUser==null) {
result.error500("未找到对应实体");
}else if("admin".equalsIgnoreCase(String.valueOf(sysUser.getUsername()))) {
// 询价后台:内置超级管理员不可被运营编辑(角色/资料/开关)
result.error500("超级管理员不可在后台编辑");
}else {
SysUser user = JSON.parseObject(jsonObject.toJSONString(), SysUser.class);
user.setUpdateTime(new Date());
@@ -204,4 +204,11 @@ public class SysUser implements Serializable {
* 流程状态
*/
private String bpmStatus;
/**
* 只看本账号看板数据:true/1 时分析看板仅含本人发起/归属工单;超级管理员忽略。
* 对应列 dashboard_self_only;原项目同名字段。
*/
@TableField("dashboard_self_only")
private Boolean dashboardSelfOnly;
}
@@ -31,6 +31,10 @@ export type AccountRow = {
phoneMasked: string;
lastLogin: string;
status: 'active' | 'disabled';
/** 只看本账号看板数据 */
dashboardSelfOnly: boolean;
/** 内置超管:列表禁编、展示固定为超级管理员 */
isSuperAdmin: boolean;
};
const ROLE_TAG_CYCLE = ['tag-violet', 'tag-blue', 'tag-teal', 'tag-amber'];
@@ -39,6 +43,16 @@ export function roleTagFor(index: number) {
return ROLE_TAG_CYCLE[index % ROLE_TAG_CYCLE.length];
}
/** username=admin 或角色码/名含超级管理员 → 内置超管 */
export function isSuperAdminUser(u: { username?: string; roleLabel?: string; roleCode?: string }) {
const name = String(u.username || '').trim().toLowerCase();
if (name === 'admin') return true;
const code = String(u.roleCode || '').trim().toLowerCase();
if (code === 'admin') return true;
const label = String(u.roleLabel || '');
return label.includes('超级管理员');
}
/** Jeecg status:1 正常 → active;其余视为禁用。 */
export function mapUserToAccount(
u: any,
@@ -46,16 +60,20 @@ export function mapUserToAccount(
roleLabel = '未绑定角色',
roleTag = 'tag-blue',
): AccountRow {
const username = u.username || '';
const superAdmin = isSuperAdminUser({ username, roleLabel });
return {
id: String(u.id || ''),
username: u.username || '',
username,
name: u.realname || u.username || '',
wecomUserId: u.workNo || u.wecomId || u.thirdId || '',
roleId,
roleLabel,
roleTag,
roleLabel: superAdmin ? '超级管理员' : roleLabel,
roleTag: superAdmin ? 'tag-violet' : roleTag,
phoneMasked: u.phone || '未填写',
lastLogin: u.lastLoginTime || u.createTime || '—',
status: Number(u.status) === 1 ? 'active' : 'disabled',
dashboardSelfOnly: Boolean(u.dashboardSelfOnly === true || u.dashboardSelfOnly === 1),
isSuperAdmin: superAdmin,
};
}
@@ -25,7 +25,7 @@ const loading = ref(false);
const total = ref(0);
const pageNo = ref(1);
const pageSize = ref(10);
const roleOptions = ref<{ value: string; label: string; tag: string }[]>([]);
const roleOptions = ref<{ value: string; label: string; tag: string; roleCode?: string }[]>([]);
const filters = reactive({ keyword: "", status: "" as "" | "active" | "disabled" });
const modalOpen = ref(false);
const editing = ref<AccountRow | null>(null);
@@ -37,6 +37,8 @@ const form = reactive({
phone: "",
status: "active" as AccountRow["status"],
password: "",
/** 只看本账号看板数据(对齐原项目 dashboardSelfOnly) */
dashboardSelfOnly: false,
});
const modalTitle = computed(() => (editing.value ? "编辑账号" : "新建账号"));
@@ -52,9 +54,11 @@ async function loadRoles() {
value: String(r.id),
label: r.roleName || r.roleCode || r.id,
tag: roleTagFor(i),
roleCode: String(r.roleCode || ""),
}));
if (!form.roleId && roleOptions.value.length) {
form.roleId = roleOptions.value.find((r) => r.label.includes("运营"))?.value || roleOptions.value[0].value;
form.roleId =
roleOptions.value.find((r) => r.label.includes("运营"))?.value || roleOptions.value[0].value;
}
}
@@ -97,6 +101,23 @@ async function loadList() {
const hit = roleOptions.value.find((r) => r.value === roleId);
roleLabel = hit?.label || roleLabel;
roleTag = hit?.tag || roleTag;
// 用角色码辅助识别超管
if (hit?.roleCode === "admin" || (hit?.label || "").includes("超级管理员")) {
roleLabel = hit?.label || "超级管理员";
}
} else if (String(u.username || "").toLowerCase() === "admin") {
// 库里未绑角色时仍展示超管,并尝试选中 admin 角色以便保存时能补绑
const adminRole = roleOptions.value.find(
(r) => r.roleCode === "admin" || r.label.includes("超级管理员"),
);
if (adminRole) {
roleId = adminRole.value;
roleLabel = adminRole.label || "超级管理员";
roleTag = adminRole.tag;
} else {
roleLabel = "超级管理员";
roleTag = "tag-violet";
}
}
} catch {
/* 无角色仍展示账号 */
@@ -142,11 +163,16 @@ function openCreate() {
phone: "",
status: "active",
password: "",
dashboardSelfOnly: false,
});
modalOpen.value = true;
}
function openEdit(row: AccountRow) {
if (row.isSuperAdmin) {
message.warning("超级管理员不可在后台编辑");
return;
}
editing.value = row;
Object.assign(form, {
username: row.username,
@@ -156,11 +182,16 @@ function openEdit(row: AccountRow) {
phone: row.phoneMasked === "未填写" ? "" : row.phoneMasked,
status: row.status,
password: "",
dashboardSelfOnly: Boolean(row.dashboardSelfOnly),
});
modalOpen.value = true;
}
async function save() {
if (editing.value?.isSuperAdmin) {
message.warning("超级管理员不可在后台编辑");
return;
}
if (!form.username.trim() || !form.name.trim()) {
message.warning("请填写账号与姓名");
return;
@@ -185,6 +216,7 @@ async function save() {
phone: form.phone.trim() || undefined,
status: form.status === "active" ? 1 : 2,
selectedroles: form.roleId,
dashboardSelfOnly: form.dashboardSelfOnly ? 1 : 0,
};
if (!editing.value) payload.password = form.password;
try {
@@ -321,12 +353,25 @@ function removeAccount(row: AccountRow) {
</td>
<td>
<div class="account-row-actions">
<button class="link-action" type="button" @click="openEdit(row)">编辑</button>
<button class="link-action link-muted" type="button" @click="resetPassword(row)">
<button
v-if="!row.isSuperAdmin"
class="link-action"
type="button"
@click="openEdit(row)"
>
编辑
</button>
<span v-else class="account-locked" title="超级管理员不可在后台编辑">不可编辑</span>
<button
v-if="!row.isSuperAdmin"
class="link-action link-muted"
type="button"
@click="resetPassword(row)"
>
重置密码
</button>
<button
v-if="row.username !== 'admin'"
v-if="!row.isSuperAdmin"
class="link-action link-danger"
type="button"
@click="removeAccount(row)"
@@ -400,6 +445,14 @@ function removeAccount(row: AccountRow) {
<option v-for="r in roleOptions" :key="r.value" :value="r.value">{{ r.label }}</option>
</select>
</div>
<div class="form-row">
<label class="lbl">数据范围</label>
<label class="account-self-only">
<input v-model="form.dashboardSelfOnly" type="checkbox" />
<span>只看自己数据</span>
<em>勾选后,分析看板仅统计本账号企微归属的工单</em>
</label>
</div>
<div class="form-row">
<label class="lbl">状态</label>
<div class="status-toggle">
@@ -466,4 +519,26 @@ function removeAccount(row: AccountRow) {
align-items: center;
gap: 10px;
}
.account-locked {
color: #94a3b8;
font-size: 12px;
}
.account-self-only {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
font-size: 13px;
color: #0f172a;
cursor: pointer;
}
.account-self-only em {
flex: 1 1 100%;
font-style: normal;
font-size: 12px;
color: #64748b;
}
</style>
@@ -129,7 +129,7 @@ async function commitHours(row) {
<tr>
<th style="width: 88px">角色</th>
<th style="width: 160px" title="每条唯一;智能体用此码取超时小时">编码</th>
<th>运输类型</th>
<th>运输分类</th>
<th style="width: 140px">超时设置(小时)</th>
</tr>
</thead>
@@ -81,20 +81,15 @@ const parseDetailState = reactive({
mapping: emptyMappingSummary() as MappingParseSummary,
});
/** 未解析清单:优先 mappingGaps,其次 AI 自报项 */
/** 未解析清单:只认系统 mappingGaps;齐全(空数组)时不展示 AI 自报标签 */
const parseDetailUnparsed = computed(() => {
const m = parseDetailState.mapping;
const list: string[] = [];
for (const g of m.gaps || []) {
if (g) list.push(g);
if (Array.isArray(m.gaps)) {
return (m.gaps || []).filter(Boolean).map(String);
}
if (!list.length) {
for (const c of m.unmappedRequired || []) {
list.push(`字段未映射:${FIELD_LABELS[c] || c}`);
}
for (const lab of m.unmappedLabels || []) {
if (lab) list.push(`表标签未落点:${lab}`);
}
const list: string[] = [];
for (const c of m.unmappedRequired || []) {
list.push(`字段未映射:${FIELD_LABELS[c] || c}`);
}
return list;
});
@@ -377,22 +372,22 @@ function openEdit(row: TemplateRow) {
formOpen.value = true;
}
/** 运营可见的未解析项:优先 mappingGaps,其次标签/必填未映射 */
/** 运营可见的未解析项:只认系统对照清单 mappingGaps;齐全时角标为 0 */
function listMappingGaps(row: TemplateRow): string[] {
if (row.mappingGaps?.length) return [...row.mappingGaps];
const list: string[] = [];
for (const lab of row.unmappedLabels || []) {
if (lab) list.push(`表标签未落点:${lab}`);
// 主账已带 mappingGaps(含空数组)时,不再用 AI 自报 unmapped* 抬角标
if (Array.isArray(row.mappingGaps)) {
return row.mappingGaps.filter(Boolean).map(String);
}
const list: string[] = [];
for (const c of row.unmappedRequired || []) {
if (c) list.push(`字段未映射:${c}`);
}
return list;
}
/** 列表角标条数 */
/** 列表角标条数:优先主账 unparsedHintCount(与 mappingGaps 同口径) */
function unparsedCount(row: TemplateRow): number {
if (row.unparsedHintCount && row.unparsedHintCount > 0) return row.unparsedHintCount;
if (typeof row.unparsedHintCount === "number") return Math.max(0, row.unparsedHintCount);
return listMappingGaps(row).length;
}
@@ -439,6 +434,7 @@ async function toggleStatus(row: TemplateRow) {
/**
* 重新提交 AI 映射解析(已解析也可点;解析中禁用)。
* 未换文件:主账带 mergeExisting,回写时保留已有字段只补缺。
* 副作用:主账入队;列表进入解析中并轮询。
*/
async function reparseRow(row: TemplateRow) {
@@ -692,7 +688,11 @@ async function downloadPreviewOrOriginal() {
type="button"
class="link-action"
:disabled="row.parseStatus === '解析中'"
:title="row.parseStatus === '解析中' ? '解析中,请稍候' : '重新提交 AI 映射解析'"
:title="
row.parseStatus === '解析中'
? '解析中,请稍候'
: '重新解析:保留已有映射,只补缺;换文件请用编辑上传'
"
@click.stop="reparseRow(row)"
>
重新解析
@@ -91,11 +91,18 @@ export const listTemplateVersions = (templateId: string) =>
export const saveTemplateMapping = (versionId: string, mappingJson: string) =>
defHttp.put({ url: Api.mapping, data: { versionId, mappingJson } });
/** 非 GET 时 params 会进 JSON 体,@RequestParam 读不到,必须拼到查询串。 */
export const enableTemplate = (templateId: string, versionId: string) =>
defHttp.post({ url: Api.enable, params: { templateId, versionId } });
defHttp.post(
{ url: Api.enable, params: { templateId, versionId } },
{ joinParamsToUrl: true },
);
export const disableTemplate = (templateId: string) =>
defHttp.post({ url: Api.disable, params: { templateId } });
defHttp.post(
{ url: Api.disable, params: { templateId } },
{ joinParamsToUrl: true },
);
/**
* 删除模板逻辑头及全部版本;不可恢复。
@@ -109,7 +116,10 @@ export const deleteTemplate = (templateId: string) =>
);
export const setTemplateDefault = (templateId: string, isDefault = true) =>
defHttp.post({ url: Api.setDefault, params: { templateId, isDefault } });
defHttp.post(
{ url: Api.setDefault, params: { templateId, isDefault } },
{ joinParamsToUrl: true },
);
/** 只改名称/业务/关键词/默认标记,不换文件;走 JSON 体对接主账 @RequestBody */
export const editTemplateMeta = (data) => defHttp.put({ url: Api.editMeta, data });
@@ -119,7 +129,7 @@ export const editTemplateMeta = (data) => defHttp.put({ url: Api.editMeta, data
*/
export const reparseTemplate = (templateId: string) =>
defHttp.post(
{ url: Api.reparse, params: { id: templateId }, timeout: 60 * 1000 },
{ url: Api.reparse, params: { id: templateId, templateId }, timeout: 60 * 1000 },
{ joinParamsToUrl: true },
);