Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bb69ae72e | ||
|
|
6cd6803147 |
@@ -37,6 +37,9 @@ class QuoteTemplateParseBody(BaseModel):
|
|||||||
yellow_count: int = Field(default=0, alias="yellowCount")
|
yellow_count: int = Field(default=0, alias="yellowCount")
|
||||||
truncated: bool = False
|
truncated: bool = False
|
||||||
request_id: str = Field(default="", alias="requestId")
|
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}
|
model_config = {"populate_by_name": True}
|
||||||
|
|
||||||
@@ -103,7 +106,10 @@ def enqueue_parse(
|
|||||||
"cellCount": body.cell_count or len(inventory),
|
"cellCount": body.cell_count or len(inventory),
|
||||||
"yellowCount": body.yellow_count,
|
"yellowCount": body.yellow_count,
|
||||||
"truncated": bool(body.truncated),
|
"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(
|
result = enqueue_recognition(
|
||||||
kind="quote_template_parse",
|
kind="quote_template_parse",
|
||||||
payload=payload,
|
payload=payload,
|
||||||
|
|||||||
@@ -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]:
|
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.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()
|
version_id = str(payload.get("versionId") or "").strip()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
net = bool(allow_network and settings.llm_allow_network)
|
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]
|
body: dict[str, Any]
|
||||||
if result.get("ok") and isinstance(result.get("mapping"), dict):
|
if result.get("ok") and isinstance(result.get("mapping"), dict):
|
||||||
mapping = dict(result["mapping"])
|
mapping = dict(result["mapping"])
|
||||||
gaps = result.get("gaps_after")
|
if merge_existing and isinstance(existing, dict):
|
||||||
if not isinstance(gaps, list):
|
mapping = merge_mapping_prefer_existing(existing, mapping)
|
||||||
gaps = mapping.get("mappingGaps") if isinstance(mapping.get("mappingGaps"), list) else []
|
# 合并后再对照清单复评缺口,避免角标沿用合并前数字
|
||||||
mapping["mappingGaps"] = [str(g) for g in gaps if g]
|
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_fields = len(mapping.get("fields") or {})
|
||||||
n_fee = len(mapping.get("feeRegions") or []) if isinstance(mapping.get("feeRegions"), list) else 0
|
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
|
n_route = len(mapping.get("routeRegions") or []) if isinstance(mapping.get("routeRegions"), list) else 0
|
||||||
points = n_fields + n_fee + n_route
|
points = n_fields + n_fee + n_route
|
||||||
parse_message = f"已解析 {points} 个映射点"
|
parse_message = f"已解析 {points} 个映射点"
|
||||||
if mapping["mappingGaps"]:
|
if merge_existing:
|
||||||
|
parse_message += "(保留原映射并补缺)"
|
||||||
|
if mapping.get("mappingGaps"):
|
||||||
preview = ";".join(mapping["mappingGaps"][:8])
|
preview = ";".join(mapping["mappingGaps"][:8])
|
||||||
more = len(mapping["mappingGaps"]) - 8
|
more = len(mapping["mappingGaps"]) - 8
|
||||||
parse_message += f";未解析到 {len(mapping['mappingGaps'])} 项:{preview}"
|
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,
|
"mappingJson": mapping,
|
||||||
"parseStatus": "parsed",
|
"parseStatus": "parsed",
|
||||||
"parseMessage": parse_message,
|
"parseMessage": parse_message,
|
||||||
|
"mergeExisting": merge_existing,
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
err = str(result.get("error") or "AI 解析失败")
|
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,
|
"ok": False,
|
||||||
"parseStatus": "failed",
|
"parseStatus": "failed",
|
||||||
"parseMessage": err,
|
"parseMessage": err,
|
||||||
|
"mergeExisting": merge_existing,
|
||||||
}
|
}
|
||||||
posted = _post_parse_result(body)
|
posted = _post_parse_result(body)
|
||||||
logger.info(
|
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,
|
version_id,
|
||||||
body.get("ok"),
|
body.get("ok"),
|
||||||
|
merge_existing,
|
||||||
posted.get("ok"),
|
posted.get("ok"),
|
||||||
)
|
)
|
||||||
return {"ok": bool(body.get("ok")), "posted": posted, "kind": "quote_template_parse"}
|
return {"ok": bool(body.get("ok")), "posted": posted, "kind": "quote_template_parse"}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
报价工作表映射 mode:上传时用千问产出 mappingJson。
|
报价工作表映射 mode:上传时用千问产出 mappingJson。
|
||||||
|
|
||||||
本文件职责:加载映射分析提示词、拼用户消息、调千问、剥 Markdown、校验 JSON、
|
本文件职责:加载映射分析提示词、拼用户消息、调千问、剥 Markdown、校验 JSON、
|
||||||
对照单元格清单做缺项检测,必要时再补跑一轮。
|
对照单元格清单做缺项检测;有缺口则多轮补跑(默认最多 3 轮),目标一次流程内齐全。
|
||||||
禁止:发明运价;在企微回调线程同步调用;出单阶段再调本 mode。
|
禁止:发明运价;在企微回调线程同步调用;出单阶段再调本 mode。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -22,30 +22,58 @@ MODE = LlmMode.SHEET_MAP
|
|||||||
|
|
||||||
_PROMPT_CACHE: str | None = None
|
_PROMPT_CACHE: str | None = None
|
||||||
|
|
||||||
# 清单原文片段 → 白名单 code(用于缺项检测;不要求全覆盖词典)
|
# 清单原文片段 → 白名单 code(按运输方式过滤;关键词避免过短英文误报)
|
||||||
_LABEL_TO_CODE: list[tuple[tuple[str, ...], str]] = [
|
_LABEL_TO_CODE_COMMON: list[tuple[tuple[str, ...], str]] = [
|
||||||
(("提货地址", "Place of Receipt", "place of receipt"), "pickup_address"),
|
(("报价单编号", "询价单号", "RFQ No", "Quote No"), "quote_no"),
|
||||||
(("送货地址", "派送地址", "Place of Delivery", "place of delivery"), "delivery_address"),
|
(("报价日期", "询价日期", "Inquiry Date", "Quote Date"), "quote_date"),
|
||||||
(("货值", "Cargo Value", "cargo value"), "cargo_value"),
|
(("有效期至", "有效期", "Validity"), "valid_until"),
|
||||||
(("海关编码", "HS Code", "HS编码", "hs code"), "hs_code"),
|
(("客户名称", "Customer Name", "Customer"), "customer_name"),
|
||||||
(("油、电、磁", "油,电,磁", "油电磁", "IL, ELEC", "L,ELEC", "oil"), "oil_elec_mag"),
|
(("联系人", "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"),
|
(("特殊说明", "特别说明", "Special Remarks"), "special_remarks"),
|
||||||
(("要求货物到达", "Requested Arriv", "抵达时间"), "eta_request"),
|
(("要求货物到达", "Requested Arrival"), "eta_request"),
|
||||||
(("运输时效", "Transit Time", "航程时间", "Navigation time"), "transit_time"),
|
(("运输时效", "Transit Time", "航程时间"), "transit_time"),
|
||||||
(("船期", "Sailing", "Requested Sh"), "sailing_schedule"),
|
(("船期", "Sailing Schedule"), "sailing_schedule"),
|
||||||
(("开船日", "On Board"), "on_board_date"),
|
(("开船日", "On Board Date"), "on_board_date"),
|
||||||
(("船公司", "Shipping Line", "Shipping Co"), "shipping_line"),
|
(("船公司", "Shipping Line", "Shipping Company"), "shipping_line"),
|
||||||
(("货好时间", "Cargo Ready"), "cargo_ready_date"),
|
(("货好时间", "Cargo Ready"), "cargo_ready_date"),
|
||||||
(("贸易条款", "Terms"), "trade_terms"),
|
(("贸易条款", "Trade Terms", "Incoterms"), "trade_terms"),
|
||||||
(("整柜或散货", "整柜或拼柜", "FCL or LCL"), "fcl_or_lcl"),
|
(("整柜或散货", "整柜或拼柜", "FCL or LCL"), "fcl_or_lcl"),
|
||||||
(("箱型", "柜量", "Type & Qty", "Cont."), "container_type_qty"),
|
(("箱型箱量", "箱型", "柜量", "Type & Qty"), "container_type_qty"),
|
||||||
(("起运港", "POL"), "pol"),
|
(("起运港", "Port of Loading", "POL"), "pol"),
|
||||||
(("目的港", "POD"), "pod"),
|
(("目的港", "Port of Discharge", "POD"), "pod"),
|
||||||
(("品名", "Commodity"), "commodity"),
|
|
||||||
(("货物数量", "Quantity"), "quantity"),
|
(("货物数量", "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, ...]]] = [
|
_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]:
|
def mode_meta() -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"mode": MODE.value,
|
"mode": MODE.value,
|
||||||
@@ -178,7 +233,7 @@ def assess_mapping_gaps(payload: dict[str, Any], mapping: dict[str, Any] | None)
|
|||||||
"""
|
"""
|
||||||
对照单元格清单,列出 mapping 明显缺口(中文说明,供补跑用户消息)。
|
对照单元格清单,列出 mapping 明显缺口(中文说明,供补跑用户消息)。
|
||||||
|
|
||||||
不打外网。海运:费用段标题出现则要求对应 feeRegion;常见业务标签出现则要求 fields code。
|
不打外网。按运输方式过滤字段词典;海运另检查三段费用区。
|
||||||
"""
|
"""
|
||||||
blob = _inventory_blob(payload)
|
blob = _inventory_blob(payload)
|
||||||
if not blob.strip():
|
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
|
for r in fee_regions
|
||||||
if isinstance(r, dict)
|
if isinstance(r, dict)
|
||||||
).lower()
|
).lower()
|
||||||
|
route_regions = mapping.get("routeRegions") if isinstance(mapping.get("routeRegions"), list) else []
|
||||||
|
|
||||||
gaps: list[str] = []
|
gaps: list[str] = []
|
||||||
mode = str(payload.get("transportMode") or payload.get("transport_mode") or "sea").strip().lower()
|
mode = str(payload.get("transportMode") or payload.get("transport_mode") or "sea").strip().lower()
|
||||||
|
# 字段匹配用:去掉费用段标题,避免「目的港费用」误报成 pod 等字段缺口
|
||||||
|
field_blob = blob
|
||||||
if mode in ("sea", "海运"):
|
if mode in ("sea", "海运"):
|
||||||
for markers, matchers in _SEA_FEE_MARKERS:
|
for markers, matchers in _SEA_FEE_MARKERS:
|
||||||
if not any(m in blob for m in markers):
|
if not any(m in blob for m in markers):
|
||||||
continue
|
continue
|
||||||
if not any(x in fee_blob for x in matchers):
|
if not any(x in fee_blob for x in matchers):
|
||||||
gaps.append(f"费用区缺失:清单有「{markers[0]}」,须建对应 feeRegion")
|
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:
|
# 陆运已建 routeRegions 时,始发地/目的地/时效常在线路矩阵列头,不再强制 fields
|
||||||
if code in field_codes:
|
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
|
continue
|
||||||
if any(lab in blob for lab in labels):
|
if _label_hit(field_blob, labels):
|
||||||
gaps.append(f"字段缺失:清单有「{labels[0]}」→ 应映射 {code}")
|
gaps.append(f"字段缺失:清单有「{labels[0]}」→ 应映射 {code}")
|
||||||
return gaps
|
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]:
|
def invoke_shell(*, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
无网/单测入口:若带 assistant_json 则直接解析;否则返回未实现。
|
无网/单测入口:若带 assistant_json 则直接解析;否则返回未实现。
|
||||||
@@ -273,10 +393,12 @@ def invoke_sheet_map(
|
|||||||
*,
|
*,
|
||||||
allow_network: bool = False,
|
allow_network: bool = False,
|
||||||
chat_fn: Optional[Any] = None,
|
chat_fn: Optional[Any] = None,
|
||||||
timeout_seconds: float = 180.0,
|
timeout_seconds: float = 240.0,
|
||||||
|
max_rounds: int = 3,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
调用千问产出 mappingJson;若对照清单有明显缺口,再补跑一轮(最多 2 次 chat)。
|
调用千问产出 mappingJson;对照清单有缺口则补跑,最多 max_rounds 轮(默认 3),
|
||||||
|
目标一次流程内尽量齐全,缺口写进 mappingGaps。
|
||||||
|
|
||||||
allow_network=False 且无 chat_fn:不打外网。
|
allow_network=False 且无 chat_fn:不打外网。
|
||||||
副作用:可能出站 HTTPS;应在 Worker HTTP 槽内调用。
|
副作用:可能出站 HTTPS;应在 Worker HTTP 槽内调用。
|
||||||
@@ -293,36 +415,54 @@ def invoke_sheet_map(
|
|||||||
else:
|
else:
|
||||||
chat = chat_fn
|
chat = chat_fn
|
||||||
|
|
||||||
|
rounds_cap = max(1, min(int(max_rounds or 3), 4))
|
||||||
first = _chat_once(chat=chat, payload=payload, gaps=None)
|
first = _chat_once(chat=chat, payload=payload, gaps=None)
|
||||||
if not first.get("ok"):
|
if not first.get("ok"):
|
||||||
return first
|
return first
|
||||||
|
|
||||||
mapping = first["mapping"]
|
mapping = first["mapping"]
|
||||||
gaps = assess_mapping_gaps(payload, mapping)
|
|
||||||
rounds = 1
|
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)
|
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):
|
if isinstance(mapping, dict):
|
||||||
mapping["mappingGaps"] = list(remaining)
|
mapping["mappingGaps"] = list(remaining)
|
||||||
|
if not remaining:
|
||||||
|
mapping["unmappedRequired"] = []
|
||||||
|
mapping["unmappedLabels"] = []
|
||||||
|
elif not isinstance(mapping.get("unmappedLabels"), list):
|
||||||
|
mapping["unmappedLabels"] = []
|
||||||
logger.info(
|
logger.info(
|
||||||
"sheet_map ok versionId=%s rounds=%s fields=%s feeRegions=%s routeRegions=%s remain_gaps=%s",
|
"sheet_map ok versionId=%s rounds=%s fields=%s feeRegions=%s routeRegions=%s remain_gaps=%s",
|
||||||
payload.get("versionId"),
|
payload.get("versionId"),
|
||||||
|
|||||||
@@ -360,6 +360,8 @@ transportMode:sea | air | land
|
|||||||
5. 空运必须能承接 departureCharges + 空运费;陆运主表必须用 routeRegions 承接 landSegments。
|
5. 空运必须能承接 departureCharges + 空运费;陆运主表必须用 routeRegions 承接 landSegments。
|
||||||
6. 看不清 → unmappedRequired / unmappedLabels;表上有、能对上白名单却未映射 → 视为错误,必须补进 fields。
|
6. 看不清 → unmappedRequired / unmappedLabels;表上有、能对上白名单却未映射 → 视为错误,必须补进 fields。
|
||||||
7. 用户未声明运输方式时推断 transportMode,并在 notes 说明。
|
7. 用户未声明运输方式时推断 transportMode,并在 notes 说明。
|
||||||
|
8. **一次齐全**:第一轮就要尽量映射清单中全部可见业务标签;宁可多写 fields/feeRegions/routeRegions,也不要省略。补跑时输出完整 mappingJson(覆盖式),不要只回增量。
|
||||||
|
9. 说明性/公式性文字(如「1CBM=***KG」「重量体积取大」)无法落白名单字段时写入 unmappedLabels,不要假装成业务字段缺口。
|
||||||
|
|
||||||
【用户消息将提供】
|
【用户消息将提供】
|
||||||
运输方式:海运 | 空运 | 陆运
|
运输方式:海运 | 空运 | 陆运
|
||||||
@@ -368,4 +370,4 @@ transportMode:sea | air | land
|
|||||||
(可选)TMS 样例或出单投影说明——仅对齐字段
|
(可选)TMS 样例或出单投影说明——仅对齐字段
|
||||||
(补跑时)缺口清单:请据此补全整份 mappingJson,不要只回增量。
|
(补跑时)缺口清单:请据此补全整份 mappingJson,不要只回增量。
|
||||||
|
|
||||||
输出前自检 fields 与 feeRegions/routeRegions 是否覆盖清单中的可见业务标签,再只输出 mappingJson。
|
输出前自检:fields 与 feeRegions/routeRegions 是否覆盖清单中的可见业务标签;有缺口则先补齐再输出。只输出 mappingJson。
|
||||||
|
|||||||
@@ -101,6 +101,93 @@ class TestSheetMapParse(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
self.assertEqual(assess_mapping_gaps(payload, mapping), [])
|
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:
|
def test_invoke_retries_when_gaps(self) -> None:
|
||||||
calls: list[str] = []
|
calls: list[str] = []
|
||||||
|
|
||||||
@@ -145,6 +232,64 @@ class TestSheetMapParse(unittest.TestCase):
|
|||||||
self.assertEqual(out.get("gaps_after"), out["mapping"]["mappingGaps"])
|
self.assertEqual(out.get("gaps_after"), out["mapping"]["mappingGaps"])
|
||||||
self.assertEqual(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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
+11
@@ -127,4 +127,15 @@ public class LoginUser {
|
|||||||
/**设备id uniapp推送用*/
|
/**设备id uniapp推送用*/
|
||||||
private String clientId;
|
private String clientId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工号(询价账号页暂存企微 userid)
|
||||||
|
*/
|
||||||
|
@SensitiveField
|
||||||
|
private String workNo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 只看本账号看板数据:登录态透传;看板聚合读取后收紧范围。
|
||||||
|
*/
|
||||||
|
private Boolean dashboardSelfOnly;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-1
@@ -38,6 +38,9 @@ public class InquiryAgentController {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private org.jeecg.modules.inquiry.service.IInquiryStaffRoleService staffRoleService;
|
private org.jeecg.modules.inquiry.service.IInquiryStaffRoleService staffRoleService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private org.jeecg.modules.inquiry.service.IInquiryResponseTimeoutService responseTimeoutService;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private org.jeecg.modules.inquiry.tms.InquiryTmsCabinService cabinService;
|
private org.jeecg.modules.inquiry.tms.InquiryTmsCabinService cabinService;
|
||||||
|
|
||||||
@@ -67,8 +70,15 @@ public class InquiryAgentController {
|
|||||||
: (mappingObj instanceof String ? (String) mappingObj : JSON.toJSONString(mappingObj));
|
: (mappingObj instanceof String ? (String) mappingObj : JSON.toJSONString(mappingObj));
|
||||||
String parseMessage = body.get("parseMessage") == null ? null : String.valueOf(body.get("parseMessage"));
|
String parseMessage = body.get("parseMessage") == null ? null : String.valueOf(body.get("parseMessage"));
|
||||||
String parseStatus = body.get("parseStatus") == null ? null : String.valueOf(body.get("parseStatus"));
|
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 {
|
try {
|
||||||
quoteTemplateService.applyParseResult(versionId, ok, mappingJson, parseMessage, parseStatus);
|
quoteTemplateService.applyParseResult(versionId, ok, mappingJson, parseMessage, parseStatus, mergeExisting);
|
||||||
return Result.OK("ok");
|
return Result.OK("ok");
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
return Result.error(e.getMessage());
|
return Result.error(e.getMessage());
|
||||||
@@ -234,6 +244,36 @@ public class InquiryAgentController {
|
|||||||
return Result.OK(staffRoleService.findDisplayByName(firstStr(body, "name")));
|
return Result.OK(staffRoleService.findDisplayByName(firstStr(body, "name")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation("按唯一超时编码取小时数(timeoutCode,如 sea_sales)")
|
||||||
|
@PostMapping("/responseTimeout/byCode")
|
||||||
|
public Result<Map<String, Object>> responseTimeoutByCode(@RequestBody Map<String, Object> body,
|
||||||
|
HttpServletRequest http) {
|
||||||
|
if (!authed(http)) {
|
||||||
|
return Result.error(401, "unauthorized");
|
||||||
|
}
|
||||||
|
String code = firstStr(body, "timeoutCode", "timeout_code", "code");
|
||||||
|
if (StringUtils.isBlank(code)) {
|
||||||
|
return Result.error("timeoutCode 必填");
|
||||||
|
}
|
||||||
|
responseTimeoutService.ensureTimeoutCodes();
|
||||||
|
org.jeecg.modules.inquiry.entity.InquiryResponseTimeout row =
|
||||||
|
responseTimeoutService.getByTimeoutCode(code);
|
||||||
|
Map<String, Object> out = new java.util.LinkedHashMap<>();
|
||||||
|
if (row == null) {
|
||||||
|
out.put("found", false);
|
||||||
|
out.put("timeoutCode", code);
|
||||||
|
return Result.OK(out);
|
||||||
|
}
|
||||||
|
out.put("found", true);
|
||||||
|
out.put("timeoutCode", row.getTimeoutCode());
|
||||||
|
out.put("timeoutHours", row.getTimeoutHours());
|
||||||
|
out.put("transportMode", row.getTransportMode());
|
||||||
|
out.put("roleCode", row.getRoleCode());
|
||||||
|
out.put("routeCategory", row.getRouteCategory());
|
||||||
|
out.put("transportType", row.getTransportType());
|
||||||
|
return Result.OK(out);
|
||||||
|
}
|
||||||
|
|
||||||
@ApiOperation("记下海运协同群(不改六态)")
|
@ApiOperation("记下海运协同群(不改六态)")
|
||||||
@PostMapping("/ticket/bindCollabGroup")
|
@PostMapping("/ticket/bindCollabGroup")
|
||||||
public Result<Map<String, Object>> bindCollabGroup(@RequestBody Map<String, Object> body,
|
public Result<Map<String, Object>> bindCollabGroup(@RequestBody Map<String, Object> body,
|
||||||
|
|||||||
+8
-2
@@ -185,8 +185,14 @@ public class InquiryQuoteTemplateController {
|
|||||||
@ApiOperation("对最新 Excel 版重新提交 AI 异步解析(已解析/失败/映射未完成均可;解析中跳过)")
|
@ApiOperation("对最新 Excel 版重新提交 AI 异步解析(已解析/失败/映射未完成均可;解析中跳过)")
|
||||||
@RequiresPermissions("inquiry:template:edit")
|
@RequiresPermissions("inquiry:template:edit")
|
||||||
@PostMapping("/reparse")
|
@PostMapping("/reparse")
|
||||||
public Result<String> reparse(@RequestParam String id) {
|
public Result<String> reparse(@RequestParam(required = false) String id,
|
||||||
String msg = quoteTemplateService.reparseLatest(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);
|
return Result.OK(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -20,9 +20,10 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 响应超时配置 API(系统设置页)。
|
* 响应超时配置 API(后台「超时响应」页)。
|
||||||
* <p>
|
* <p>
|
||||||
* 只提供全量列表与改小时数;无 add/delete,维度由 SQL 种子固定。
|
* 只提供全量列表与改小时数;无 add/delete,维度由 SQL 种子固定。
|
||||||
|
* 每条有唯一 timeoutCode(如 sea_sales),智能体单码调用;roleCode 仅为角色维度可重复。
|
||||||
* 本接口供管理后台使用;工单超时判定尚未接入。
|
* 本接口供管理后台使用;工单超时判定尚未接入。
|
||||||
* </p>
|
* </p>
|
||||||
*/
|
*/
|
||||||
@@ -59,8 +60,9 @@ public class InquiryResponseTimeoutController
|
|||||||
@ApiOperation("全量列表(按 sort_no,供系统设置页一次加载)")
|
@ApiOperation("全量列表(按 sort_no,供系统设置页一次加载)")
|
||||||
@GetMapping("/listAll")
|
@GetMapping("/listAll")
|
||||||
public Result<List<InquiryResponseTimeout>> listAll() {
|
public Result<List<InquiryResponseTimeout>> listAll() {
|
||||||
// 打开系统设置时顺带纠偏旧运输类型文案(幂等,已是新文案则无写)
|
// 打开超时响应页时顺带纠偏旧运输类型文案、回填唯一编码(幂等)
|
||||||
responseTimeoutService.ensureCanonicalTransportTypeLabels();
|
responseTimeoutService.ensureCanonicalTransportTypeLabels();
|
||||||
|
responseTimeoutService.ensureTimeoutCodes();
|
||||||
QueryWrapper<InquiryResponseTimeout> qw = new QueryWrapper<>();
|
QueryWrapper<InquiryResponseTimeout> qw = new QueryWrapper<>();
|
||||||
qw.orderByAsc("sort_no");
|
qw.orderByAsc("sort_no");
|
||||||
return Result.OK(responseTimeoutService.list(qw));
|
return Result.OK(responseTimeoutService.list(qw));
|
||||||
|
|||||||
+5
-1
@@ -18,6 +18,7 @@ import java.util.Date;
|
|||||||
* 响应超时配置,对应 inq_response_timeout。
|
* 响应超时配置,对应 inq_response_timeout。
|
||||||
* <p>
|
* <p>
|
||||||
* 维度行由种子固定(运输方式/角色/线路类别/运输类型),后台只允许改 timeoutHours。
|
* 维度行由种子固定(运输方式/角色/线路类别/运输类型),后台只允许改 timeoutHours。
|
||||||
|
* timeoutCode 每条唯一,供智能体单码查询超时小时;roleCode 仅为角色维度,可重复。
|
||||||
* 本表是配置真相;工单超时判定后续再读本表,本期仅 CRUD 小时数。
|
* 本表是配置真相;工单超时判定后续再读本表,本期仅 CRUD 小时数。
|
||||||
* </p>
|
* </p>
|
||||||
*/
|
*/
|
||||||
@@ -33,10 +34,13 @@ public class InquiryResponseTimeout implements Serializable {
|
|||||||
@TableId(type = IdType.ASSIGN_ID)
|
@TableId(type = IdType.ASSIGN_ID)
|
||||||
private String id;
|
private String id;
|
||||||
|
|
||||||
|
@ApiModelProperty("唯一业务编码(智能体调用),如 sea_sales / land_hk_express")
|
||||||
|
private String timeoutCode;
|
||||||
|
|
||||||
@ApiModelProperty("运输方式 AIR/SEA/LAND")
|
@ApiModelProperty("运输方式 AIR/SEA/LAND")
|
||||||
private String transportMode;
|
private String transportMode;
|
||||||
|
|
||||||
@ApiModelProperty("角色 sales/product/air_route")
|
@ApiModelProperty("角色维度 sales/product/air_route(可重复,非调用主键)")
|
||||||
private String roleCode;
|
private String roleCode;
|
||||||
|
|
||||||
@ApiModelProperty("线路类别(仅陆运产品有值,其余空串)")
|
@ApiModelProperty("线路类别(仅陆运产品有值,其余空串)")
|
||||||
|
|||||||
+4
-1
@@ -89,8 +89,11 @@ public interface IInquiryQuoteTemplateService extends IService<InquiryQuoteTempl
|
|||||||
* 智能体回写 AI 映射结果(成功 mappingJson 或失败原因)。
|
* 智能体回写 AI 映射结果(成功 mappingJson 或失败原因)。
|
||||||
* <p>
|
* <p>
|
||||||
* 终态优先:已是 parsed/mapping_incomplete/failed 时可被同 version 覆盖;pending 必接受。
|
* 终态优先:已是 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。
|
* 纠偏:把「已有映射点但仍标 mapping_incomplete」的版本改为 parsed。
|
||||||
|
|||||||
+11
@@ -28,4 +28,15 @@ public interface IInquiryResponseTimeoutService extends IService<InquiryResponse
|
|||||||
* </p>
|
* </p>
|
||||||
*/
|
*/
|
||||||
void ensureCanonicalTransportTypeLabels();
|
void ensureCanonicalTransportTypeLabels();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回填每条规则的唯一 timeoutCode(已有则跳过)。
|
||||||
|
* 智能体只凭 timeoutCode 取超时小时。
|
||||||
|
*/
|
||||||
|
void ensureTimeoutCodes();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按唯一业务编码查超时配置;无则 null。
|
||||||
|
*/
|
||||||
|
InquiryResponseTimeout getByTimeoutCode(String timeoutCode);
|
||||||
}
|
}
|
||||||
|
|||||||
+78
-4
@@ -2,6 +2,8 @@ package org.jeecg.modules.inquiry.service.impl;
|
|||||||
|
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
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.domain.TicketStatuses;
|
||||||
import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
|
import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
|
||||||
import org.jeecg.modules.inquiry.entity.InquiryTicket;
|
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 from = Date.from(fromDate.atStartOfDay(ZONE).toInstant());
|
||||||
Date to = Date.from(LocalDateTime.of(today, LocalTime.MAX).atZone(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)
|
.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 total = tickets.size();
|
||||||
long deal = tickets.stream().filter(t -> TicketStatuses.DEAL.equals(t.getStatus())).count();
|
long deal = tickets.stream().filter(t -> TicketStatuses.DEAL.equals(t.getStatus())).count();
|
||||||
@@ -115,9 +119,11 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
|||||||
|
|
||||||
// 近 7 日趋势 + 成本:一次拉周内工单,避免 7 次 count
|
// 近 7 日趋势 + 成本:一次拉周内工单,避免 7 次 count
|
||||||
Date weekFrom = Date.from(today.minusDays(6).atStartOfDay(ZONE).toInstant());
|
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)
|
.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<String> trendDates = new ArrayList<>(7);
|
||||||
List<Map<String, Object>> trend = new ArrayList<>(7);
|
List<Map<String, Object>> trend = new ArrayList<>(7);
|
||||||
@@ -442,4 +448,72 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
|||||||
}
|
}
|
||||||
return "sales";
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+200
-10
@@ -132,7 +132,10 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
|||||||
m.put("objectKey", ver.getObjectKey());
|
m.put("objectKey", ver.getObjectKey());
|
||||||
m.put("parseMessage", ver.getParseMessage());
|
m.put("parseMessage", ver.getParseMessage());
|
||||||
m.put("unmappedRequired", extractUnmapped(ver.getMappingJson()));
|
m.put("unmappedRequired", extractUnmapped(ver.getMappingJson()));
|
||||||
|
m.put("unmappedLabels", extractUnmappedLabels(ver.getMappingJson()));
|
||||||
m.put("mappingGaps", extractMappingGaps(ver.getMappingJson()));
|
m.put("mappingGaps", extractMappingGaps(ver.getMappingJson()));
|
||||||
|
// 列表提示用:合并后的未解析条数(有则运营要点开看)
|
||||||
|
m.put("unparsedHintCount", countUnparsedHints(ver.getMappingJson()));
|
||||||
} else {
|
} else {
|
||||||
m.put("versionId", null);
|
m.put("versionId", null);
|
||||||
m.put("fileName", "");
|
m.put("fileName", "");
|
||||||
@@ -242,8 +245,8 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
|||||||
version.setMappingPointCount(0);
|
version.setMappingPointCount(0);
|
||||||
version.setMappingJson("{\"schemaVersion\":\"quote-template-mapping-v1\",\"fields\":{},\"status\":\"pending\"}");
|
version.setMappingJson("{\"schemaVersion\":\"quote-template-mapping-v1\",\"fields\":{},\"status\":\"pending\"}");
|
||||||
versionMapper.insert(version);
|
versionMapper.insert(version);
|
||||||
// 事务提交后再入队,避免 Worker 回写时版本行尚未可见
|
// 事务提交后再入队,避免 Worker 回写时版本行尚未可见;换文件 = 整份覆盖
|
||||||
scheduleAiParseAfterCommit(version.getId(), template.getId(), fileBytes);
|
scheduleAiParseAfterCommit(version.getId(), template.getId(), fileBytes, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDefault) {
|
if (isDefault) {
|
||||||
@@ -266,8 +269,11 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 事务提交后提交 AI 解析;无事务时立刻提交。
|
* 事务提交后提交 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 = () -> {
|
Runnable job = () -> {
|
||||||
try {
|
try {
|
||||||
InquiryQuoteTemplateVersion ver = versionMapper.selectById(versionId);
|
InquiryQuoteTemplateVersion ver = versionMapper.selectById(versionId);
|
||||||
@@ -276,7 +282,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
|||||||
log.warn("AI 解析入队跳过:版本或模板不存在 versionId={}", versionId);
|
log.warn("AI 解析入队跳过:版本或模板不存在 versionId={}", versionId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String enqueueErr = submitAiParse(ver, template, fileBytes);
|
String enqueueErr = submitAiParse(ver, template, fileBytes, mergeExisting);
|
||||||
if (enqueueErr != null) {
|
if (enqueueErr != null) {
|
||||||
ver.setParseStatus("failed");
|
ver.setParseStatus("failed");
|
||||||
ver.setParseMessage(enqueueErr);
|
ver.setParseMessage(enqueueErr);
|
||||||
@@ -310,8 +316,11 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 抽单元格清单并提交智能体入队。成功返回 null,失败返回中文原因。
|
* 抽单元格清单并提交智能体入队。成功返回 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 {
|
try {
|
||||||
JSONObject inventory;
|
JSONObject inventory;
|
||||||
try (InputStream in = new ByteArrayInputStream(fileBytes)) {
|
try (InputStream in = new ByteArrayInputStream(fileBytes)) {
|
||||||
@@ -332,6 +341,19 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
|||||||
body.put("cellCount", inventory.getIntValue("cellCount"));
|
body.put("cellCount", inventory.getIntValue("cellCount"));
|
||||||
body.put("yellowCount", inventory.getIntValue("yellowCount"));
|
body.put("yellowCount", inventory.getIntValue("yellowCount"));
|
||||||
body.put("truncated", inventory.getBooleanValue("truncated"));
|
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());
|
body.put("requestId", version.getId() + "-" + System.currentTimeMillis());
|
||||||
return aiParseClient.enqueueParse(body);
|
return aiParseClient.enqueueParse(body);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -977,7 +999,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
|||||||
ver.setParseMessage("解析中,请稍候");
|
ver.setParseMessage("解析中,请稍候");
|
||||||
ver.setUpdateTime(new Date());
|
ver.setUpdateTime(new Date());
|
||||||
versionMapper.updateById(ver);
|
versionMapper.updateById(ver);
|
||||||
scheduleAiParseAfterCommit(ver.getId(), template.getId(), fileBytes);
|
scheduleAiParseAfterCommit(ver.getId(), template.getId(), fileBytes, true);
|
||||||
enqueueSnapshotPreview(ver.getId());
|
enqueueSnapshotPreview(ver.getId());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("模板补解析失败 templateId={} objectKey={}", templateId, ver.getObjectKey(), e);
|
log.error("模板补解析失败 templateId={} objectKey={}", templateId, ver.getObjectKey(), e);
|
||||||
@@ -990,10 +1012,13 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 智能体回写 AI 映射。校验白名单与 feeRegion 增行配置;未映射必填 → mapping_incomplete。
|
* 智能体回写 AI 映射。校验白名单与 feeRegion 增行配置;未映射必填 → mapping_incomplete。
|
||||||
|
* <p>
|
||||||
|
* mergeExisting=true 时:与库中旧 mapping 补缺合并(旧字段/旧区域优先,新结果只补没有的)。
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@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)) {
|
if (StringUtils.isBlank(versionId)) {
|
||||||
throw new IllegalArgumentException("versionId 必填");
|
throw new IllegalArgumentException("versionId 必填");
|
||||||
}
|
}
|
||||||
@@ -1029,6 +1054,20 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
|||||||
versionMapper.updateById(ver);
|
versionMapper.updateById(ver);
|
||||||
return;
|
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);
|
String validateErr = validateAiMapping(mapping);
|
||||||
if (validateErr != null) {
|
if (validateErr != null) {
|
||||||
ver.setParseStatus("failed");
|
ver.setParseStatus("failed");
|
||||||
@@ -1046,12 +1085,103 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
|||||||
applyExcelMapping(ver, template != null ? template : new InquiryQuoteTemplate(), mapping);
|
applyExcelMapping(ver, template != null ? template : new InquiryQuoteTemplate(), mapping);
|
||||||
if (StringUtils.isNotBlank(parseMessage) && "failed".equals(ver.getParseStatus())) {
|
if (StringUtils.isNotBlank(parseMessage) && "failed".equals(ver.getParseStatus())) {
|
||||||
ver.setParseMessage(parseMessage);
|
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.setUpdateBy("agent");
|
||||||
ver.setUpdateTime(new Date());
|
ver.setUpdateTime(new Date());
|
||||||
versionMapper.updateById(ver);
|
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。
|
* 把误标为 mapping_incomplete、但已有映射点的版本纠偏为 parsed。
|
||||||
*/
|
*/
|
||||||
@@ -1355,14 +1485,14 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从 mappingJson 取出智能体对照清单算出的未映射说明(中文)。
|
* AI 自报「表上标签未落点」原文。
|
||||||
*/
|
*/
|
||||||
private static List<String> extractMappingGaps(String mappingJson) {
|
private static List<String> extractUnmappedLabels(String mappingJson) {
|
||||||
if (StringUtils.isBlank(mappingJson)) {
|
if (StringUtils.isBlank(mappingJson)) {
|
||||||
return Collections.emptyList();
|
return Collections.emptyList();
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
JSONArray arr = JSON.parseObject(mappingJson).getJSONArray("mappingGaps");
|
JSONArray arr = JSON.parseObject(mappingJson).getJSONArray("unmappedLabels");
|
||||||
if (arr == null || arr.isEmpty()) {
|
if (arr == null || arr.isEmpty()) {
|
||||||
return Collections.emptyList();
|
return Collections.emptyList();
|
||||||
}
|
}
|
||||||
@@ -1379,6 +1509,66 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 mappingJson 取出智能体对照清单算出的未映射说明(中文)。
|
||||||
|
* <p>
|
||||||
|
* 口径:有 {@code mappingGaps} 字段时(含空数组)只认它——空数组即「系统判定齐全、角标 0」;
|
||||||
|
* 仅旧数据完全没有该字段时,才用 unmappedLabels / unmappedRequired 兜底。
|
||||||
|
* 说明性 unmappedLabels 不得在齐全时抬高列表「未解析」角标。
|
||||||
|
*/
|
||||||
|
private static List<String> extractMappingGaps(String mappingJson) {
|
||||||
|
if (StringUtils.isBlank(mappingJson)) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
JSONObject obj = JSON.parseObject(mappingJson);
|
||||||
|
if (obj == null) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
List<String> out = new ArrayList<>();
|
||||||
|
// 显式有 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
// 旧版映射无 mappingGaps:才兜底
|
||||||
|
JSONArray labels = obj.getJSONArray("unmappedLabels");
|
||||||
|
if (labels != null) {
|
||||||
|
for (int i = 0; i < labels.size(); i++) {
|
||||||
|
String s = labels.getString(i);
|
||||||
|
if (StringUtils.isNotBlank(s)) {
|
||||||
|
out.add("表标签未落点:" + s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSONArray required = obj.getJSONArray("unmappedRequired");
|
||||||
|
if (required != null) {
|
||||||
|
for (int i = 0; i < required.size(); i++) {
|
||||||
|
String s = required.getString(i);
|
||||||
|
if (StringUtils.isNotBlank(s)) {
|
||||||
|
out.add("字段未映射:" + s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表角标用的未解析条数(与 extractMappingGaps 口径一致)。 */
|
||||||
|
private static int countUnparsedHints(String mappingJson) {
|
||||||
|
return extractMappingGaps(mappingJson).size();
|
||||||
|
}
|
||||||
|
|
||||||
private static String mapParseStatusLabel(String status) {
|
private static String mapParseStatusLabel(String status) {
|
||||||
if ("parsed".equals(status)) {
|
if ("parsed".equals(status)) {
|
||||||
return "已解析";
|
return "已解析";
|
||||||
|
|||||||
+80
-1
@@ -1,5 +1,6 @@
|
|||||||
package org.jeecg.modules.inquiry.service.impl;
|
package org.jeecg.modules.inquiry.service.impl;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
|
||||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
import org.jeecg.modules.inquiry.entity.InquiryResponseTimeout;
|
import org.jeecg.modules.inquiry.entity.InquiryResponseTimeout;
|
||||||
import org.jeecg.modules.inquiry.mapper.InquiryResponseTimeoutMapper;
|
import org.jeecg.modules.inquiry.mapper.InquiryResponseTimeoutMapper;
|
||||||
@@ -9,13 +10,15 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
|
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 响应超时配置:只改 timeout_hours,不动维度列(文案纠偏除外)。
|
* 响应超时配置:只改 timeout_hours,不动维度列(文案纠偏与编码回填除外)。
|
||||||
* <p>
|
* <p>
|
||||||
* 为何单独方法而不用 updateById 整行:避免前端误传改写运输方式/角色等固定种子。
|
* 为何单独方法而不用 updateById 整行:避免前端误传改写运输方式/角色等固定种子。
|
||||||
* 运输类型纠偏是产品确认的一次性旧→新映射,不开放通用改维度接口。
|
* 运输类型纠偏是产品确认的一次性旧→新映射,不开放通用改维度接口。
|
||||||
|
* timeoutCode 每条唯一,供智能体单码调用。
|
||||||
* </p>
|
* </p>
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
@@ -23,12 +26,20 @@ public class InquiryResponseTimeoutServiceImpl
|
|||||||
extends ServiceImpl<InquiryResponseTimeoutMapper, InquiryResponseTimeout>
|
extends ServiceImpl<InquiryResponseTimeoutMapper, InquiryResponseTimeout>
|
||||||
implements IInquiryResponseTimeoutService {
|
implements IInquiryResponseTimeoutService {
|
||||||
|
|
||||||
|
private static final org.slf4j.Logger log =
|
||||||
|
org.slf4j.LoggerFactory.getLogger(InquiryResponseTimeoutServiceImpl.class);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 陆运产品运输类型:旧逗号写法 → 产品确认的括号写法。
|
* 陆运产品运输类型:旧逗号写法 → 产品确认的括号写法。
|
||||||
* 急件文案不变,不在此映射。
|
* 急件文案不变,不在此映射。
|
||||||
*/
|
*/
|
||||||
private static final Map<String, String> LAND_PRODUCT_TYPE_RENAMES = new LinkedHashMap<>();
|
private static final Map<String, String> LAND_PRODUCT_TYPE_RENAMES = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 种子 id → 唯一业务编码。智能体只认 timeoutCode,不认 roleCode。
|
||||||
|
*/
|
||||||
|
private static final Map<String, String> ID_TO_TIMEOUT_CODE = new LinkedHashMap<>();
|
||||||
|
|
||||||
static {
|
static {
|
||||||
LAND_PRODUCT_TYPE_RENAMES.put(
|
LAND_PRODUCT_TYPE_RENAMES.put(
|
||||||
"普件,中港、东南亚常用线路/常规车型",
|
"普件,中港、东南亚常用线路/常规车型",
|
||||||
@@ -45,10 +56,31 @@ public class InquiryResponseTimeoutServiceImpl
|
|||||||
LAND_PRODUCT_TYPE_RENAMES.put(
|
LAND_PRODUCT_TYPE_RENAMES.put(
|
||||||
"普件,非常用线路/临时线路/特种车型",
|
"普件,非常用线路/临时线路/特种车型",
|
||||||
"普件(非常用线路/临时线路/特种车型)");
|
"普件(非常用线路/临时线路/特种车型)");
|
||||||
|
|
||||||
|
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000001", "air_sales");
|
||||||
|
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000002", "air_route");
|
||||||
|
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000003", "sea_sales");
|
||||||
|
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000004", "land_sales");
|
||||||
|
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000005", "sea_product_ptp");
|
||||||
|
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000006", "sea_product_other");
|
||||||
|
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000007", "land_hk_common");
|
||||||
|
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000008", "land_hk_cross");
|
||||||
|
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000009", "land_hk_special");
|
||||||
|
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000010", "land_hk_express");
|
||||||
|
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000011", "land_ltl_common");
|
||||||
|
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000012", "land_ltl_special");
|
||||||
|
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000013", "land_ltl_express");
|
||||||
|
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000014", "land_city_common");
|
||||||
|
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000015", "land_city_special");
|
||||||
|
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000016", "land_city_express");
|
||||||
|
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000017", "land_sea_common");
|
||||||
|
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000018", "land_sea_special");
|
||||||
|
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000019", "land_sea_express");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 本进程内只纠偏一次,避免每次打开系统设置都打 UPDATE */
|
/** 本进程内只纠偏一次,避免每次打开系统设置都打 UPDATE */
|
||||||
private volatile boolean labelsEnsured;
|
private volatile boolean labelsEnsured;
|
||||||
|
private volatile boolean codesEnsured;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
@@ -94,4 +126,51 @@ public class InquiryResponseTimeoutServiceImpl
|
|||||||
labelsEnsured = true;
|
labelsEnsured = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public void ensureTimeoutCodes() {
|
||||||
|
if (codesEnsured) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
synchronized (this) {
|
||||||
|
if (codesEnsured) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Date now = new Date();
|
||||||
|
List<InquiryResponseTimeout> all = list();
|
||||||
|
for (InquiryResponseTimeout row : all) {
|
||||||
|
if (row == null || StringUtils.isNotBlank(row.getTimeoutCode())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String code = ID_TO_TIMEOUT_CODE.get(row.getId());
|
||||||
|
if (StringUtils.isBlank(code)) {
|
||||||
|
// 非种子行:用 id 兜底,保证唯一
|
||||||
|
code = "rt_" + row.getId();
|
||||||
|
}
|
||||||
|
lambdaUpdate()
|
||||||
|
.eq(InquiryResponseTimeout::getId, row.getId())
|
||||||
|
.set(InquiryResponseTimeout::getTimeoutCode, code)
|
||||||
|
.set(InquiryResponseTimeout::getUpdateBy, "system")
|
||||||
|
.set(InquiryResponseTimeout::getUpdateTime, now)
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
codesEnsured = true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 列未迁移时不挡列表;运维执行 migration_inq_response_timeout_code_v1.sql 后即可
|
||||||
|
log.warn("ensureTimeoutCodes 跳过(请确认已执行 timeout_code 迁移): {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public InquiryResponseTimeout getByTimeoutCode(String timeoutCode) {
|
||||||
|
if (StringUtils.isBlank(timeoutCode)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return lambdaQuery()
|
||||||
|
.eq(InquiryResponseTimeout::getTimeoutCode, timeoutCode.trim())
|
||||||
|
.one();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
-- 超时响应:每条规则增加唯一业务编码 timeout_code,供智能体单码调用。
|
||||||
|
-- 仅改 inquiry_robot;幂等可重复执行。
|
||||||
|
-- 库:inquiry_robot
|
||||||
|
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
-- 1) 加列(已存在则跳过:手工执行前请先 DESCRIBE 确认)
|
||||||
|
ALTER TABLE `inq_response_timeout`
|
||||||
|
ADD COLUMN `timeout_code` varchar(64) NULL COMMENT '唯一业务编码,智能体调用' AFTER `id`;
|
||||||
|
|
||||||
|
-- 2) 按主键回填(与种子 id 对齐)
|
||||||
|
UPDATE `inq_response_timeout` SET `timeout_code`='air_sales' WHERE `id`='rt100000000000000000000000000001' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||||
|
UPDATE `inq_response_timeout` SET `timeout_code`='air_route' WHERE `id`='rt100000000000000000000000000002' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||||
|
UPDATE `inq_response_timeout` SET `timeout_code`='sea_sales' WHERE `id`='rt100000000000000000000000000003' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||||
|
UPDATE `inq_response_timeout` SET `timeout_code`='land_sales' WHERE `id`='rt100000000000000000000000000004' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||||
|
UPDATE `inq_response_timeout` SET `timeout_code`='sea_product_ptp' WHERE `id`='rt100000000000000000000000000005' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||||
|
UPDATE `inq_response_timeout` SET `timeout_code`='sea_product_other' WHERE `id`='rt100000000000000000000000000006' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||||
|
UPDATE `inq_response_timeout` SET `timeout_code`='land_hk_common' WHERE `id`='rt100000000000000000000000000007' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||||
|
UPDATE `inq_response_timeout` SET `timeout_code`='land_hk_cross' WHERE `id`='rt100000000000000000000000000008' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||||
|
UPDATE `inq_response_timeout` SET `timeout_code`='land_hk_special' WHERE `id`='rt100000000000000000000000000009' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||||
|
UPDATE `inq_response_timeout` SET `timeout_code`='land_hk_express' WHERE `id`='rt100000000000000000000000000010' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||||
|
UPDATE `inq_response_timeout` SET `timeout_code`='land_ltl_common' WHERE `id`='rt100000000000000000000000000011' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||||
|
UPDATE `inq_response_timeout` SET `timeout_code`='land_ltl_special' WHERE `id`='rt100000000000000000000000000012' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||||
|
UPDATE `inq_response_timeout` SET `timeout_code`='land_ltl_express' WHERE `id`='rt100000000000000000000000000013' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||||
|
UPDATE `inq_response_timeout` SET `timeout_code`='land_city_common' WHERE `id`='rt100000000000000000000000000014' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||||
|
UPDATE `inq_response_timeout` SET `timeout_code`='land_city_special' WHERE `id`='rt100000000000000000000000000015' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||||
|
UPDATE `inq_response_timeout` SET `timeout_code`='land_city_express' WHERE `id`='rt100000000000000000000000000016' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||||
|
UPDATE `inq_response_timeout` SET `timeout_code`='land_sea_common' WHERE `id`='rt100000000000000000000000000017' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||||
|
UPDATE `inq_response_timeout` SET `timeout_code`='land_sea_special' WHERE `id`='rt100000000000000000000000000018' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||||
|
UPDATE `inq_response_timeout` SET `timeout_code`='land_sea_express' WHERE `id`='rt100000000000000000000000000019' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||||
|
|
||||||
|
-- 3) 唯一索引(列已全部非空后再建;若仍有 NULL 会失败)
|
||||||
|
UPDATE `inq_response_timeout` SET `timeout_code`=CONCAT('rt_', `id`) WHERE `timeout_code` IS NULL OR `timeout_code`='';
|
||||||
|
ALTER TABLE `inq_response_timeout`
|
||||||
|
MODIFY COLUMN `timeout_code` varchar(64) NOT NULL COMMENT '唯一业务编码,智能体调用';
|
||||||
|
ALTER TABLE `inq_response_timeout`
|
||||||
|
ADD UNIQUE KEY `uk_inq_resp_timeout_code` (`timeout_code`);
|
||||||
+26
@@ -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;
|
||||||
+3
@@ -187,6 +187,9 @@ public class SysUserController {
|
|||||||
baseCommonService.addLog("编辑用户,username: " +sysUser.getUsername() ,CommonConstant.LOG_TYPE_2, 2);
|
baseCommonService.addLog("编辑用户,username: " +sysUser.getUsername() ,CommonConstant.LOG_TYPE_2, 2);
|
||||||
if(sysUser==null) {
|
if(sysUser==null) {
|
||||||
result.error500("未找到对应实体");
|
result.error500("未找到对应实体");
|
||||||
|
}else if("admin".equalsIgnoreCase(String.valueOf(sysUser.getUsername()))) {
|
||||||
|
// 询价后台:内置超级管理员不可被运营编辑(角色/资料/开关)
|
||||||
|
result.error500("超级管理员不可在后台编辑");
|
||||||
}else {
|
}else {
|
||||||
SysUser user = JSON.parseObject(jsonObject.toJSONString(), SysUser.class);
|
SysUser user = JSON.parseObject(jsonObject.toJSONString(), SysUser.class);
|
||||||
user.setUpdateTime(new Date());
|
user.setUpdateTime(new Date());
|
||||||
|
|||||||
+7
@@ -204,4 +204,11 @@ public class SysUser implements Serializable {
|
|||||||
* 流程状态
|
* 流程状态
|
||||||
*/
|
*/
|
||||||
private String bpmStatus;
|
private String bpmStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 只看本账号看板数据:true/1 时分析看板仅含本人发起/归属工单;超级管理员忽略。
|
||||||
|
* 对应列 dashboard_self_only;原项目同名字段。
|
||||||
|
*/
|
||||||
|
@TableField("dashboard_self_only")
|
||||||
|
private Boolean dashboardSelfOnly;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ pnpm run dev
|
|||||||
| 机器人回复语 | `inquiry/botReply/index` |
|
| 机器人回复语 | `inquiry/botReply/index` |
|
||||||
| 账号管理 | `inquiry/account/index` |
|
| 账号管理 | `inquiry/account/index` |
|
||||||
| 角色管理 | `inquiry/role/index` |
|
| 角色管理 | `inquiry/role/index` |
|
||||||
| 系统设置 | `inquiry/systemSetting/index` |
|
| 超时响应 | `inquiry/systemSetting/index` |
|
||||||
|
|
||||||
菜单由后端 `sys_permission` 种子下发(见 `deploy/sql/inquiry_admin_menu_seed_v1.sql`)。登录后刷新权限即可看到询价菜单。
|
菜单由后端 `sys_permission` 种子下发(见 `deploy/sql/inquiry_admin_menu_seed_v1.sql`)。登录后刷新权限即可看到询价菜单。
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
{
|
{
|
||||||
title: '系统配置',
|
title: '系统配置',
|
||||||
items: [
|
items: [
|
||||||
{ key: '/inquiry-sys/system-setting', label: '系统设置', icon: 'clock' },
|
{ key: '/inquiry-sys/system-setting', label: '超时响应', icon: 'clock' },
|
||||||
{ key: '/inquiry-sys/user', label: '账号管理', icon: 'account' },
|
{ key: '/inquiry-sys/user', label: '账号管理', icon: 'account' },
|
||||||
{ key: '/inquiry-sys/role', label: '角色管理', icon: 'role' },
|
{ key: '/inquiry-sys/role', label: '角色管理', icon: 'role' },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const INQUIRY_LEAVES: InquiryLeaf[] = [
|
|||||||
path: '/inquiry-sys/system-setting',
|
path: '/inquiry-sys/system-setting',
|
||||||
name: 'inquiry-system-setting',
|
name: 'inquiry-system-setting',
|
||||||
component: 'inquiry/systemSetting/index',
|
component: 'inquiry/systemSetting/index',
|
||||||
title: '系统设置',
|
title: '超时响应',
|
||||||
},
|
},
|
||||||
{ path: '/inquiry-sys/user', name: 'inquiry-sys-user', component: 'inquiry/account/index', title: '账号管理' },
|
{ path: '/inquiry-sys/user', name: 'inquiry-sys-user', component: 'inquiry/account/index', title: '账号管理' },
|
||||||
{ path: '/inquiry-sys/role', name: 'inquiry-sys-role', component: 'inquiry/role/index', title: '角色管理' },
|
{ path: '/inquiry-sys/role', name: 'inquiry-sys-role', component: 'inquiry/role/index', title: '角色管理' },
|
||||||
@@ -102,8 +102,9 @@ function ensureInquiryHandwrittenRoutes(routeList: AppRouteRecordRaw[]) {
|
|||||||
const comp = String((existing as any).component || '');
|
const comp = String((existing as any).component || '');
|
||||||
if (!comp.includes(leaf.component) && !comp.includes(leaf.component.replace(/\/index$/, ''))) {
|
if (!comp.includes(leaf.component) && !comp.includes(leaf.component.replace(/\/index$/, ''))) {
|
||||||
(existing as any).component = leaf.component;
|
(existing as any).component = leaf.component;
|
||||||
existing.meta = { ...(existing.meta || {}), title: leaf.title, ignoreKeepAlive: true };
|
|
||||||
}
|
}
|
||||||
|
// 侧栏/页题以手写清单为准(例:系统设置 → 超时响应)
|
||||||
|
existing.meta = { ...(existing.meta || {}), title: leaf.title, ignoreKeepAlive: true };
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const node: AppRouteRecordRaw = {
|
const node: AppRouteRecordRaw = {
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ export type AccountRow = {
|
|||||||
phoneMasked: string;
|
phoneMasked: string;
|
||||||
lastLogin: string;
|
lastLogin: string;
|
||||||
status: 'active' | 'disabled';
|
status: 'active' | 'disabled';
|
||||||
|
/** 只看本账号看板数据 */
|
||||||
|
dashboardSelfOnly: boolean;
|
||||||
|
/** 内置超管:列表禁编、展示固定为超级管理员 */
|
||||||
|
isSuperAdmin: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ROLE_TAG_CYCLE = ['tag-violet', 'tag-blue', 'tag-teal', 'tag-amber'];
|
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];
|
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;其余视为禁用。 */
|
/** Jeecg status:1 正常 → active;其余视为禁用。 */
|
||||||
export function mapUserToAccount(
|
export function mapUserToAccount(
|
||||||
u: any,
|
u: any,
|
||||||
@@ -46,16 +60,20 @@ export function mapUserToAccount(
|
|||||||
roleLabel = '未绑定角色',
|
roleLabel = '未绑定角色',
|
||||||
roleTag = 'tag-blue',
|
roleTag = 'tag-blue',
|
||||||
): AccountRow {
|
): AccountRow {
|
||||||
|
const username = u.username || '';
|
||||||
|
const superAdmin = isSuperAdminUser({ username, roleLabel });
|
||||||
return {
|
return {
|
||||||
id: String(u.id || ''),
|
id: String(u.id || ''),
|
||||||
username: u.username || '',
|
username,
|
||||||
name: u.realname || u.username || '',
|
name: u.realname || u.username || '',
|
||||||
wecomUserId: u.workNo || u.wecomId || u.thirdId || '',
|
wecomUserId: u.workNo || u.wecomId || u.thirdId || '',
|
||||||
roleId,
|
roleId,
|
||||||
roleLabel,
|
roleLabel: superAdmin ? '超级管理员' : roleLabel,
|
||||||
roleTag,
|
roleTag: superAdmin ? 'tag-violet' : roleTag,
|
||||||
phoneMasked: u.phone || '未填写',
|
phoneMasked: u.phone || '未填写',
|
||||||
lastLogin: u.lastLoginTime || u.createTime || '—',
|
lastLogin: u.lastLoginTime || u.createTime || '—',
|
||||||
status: Number(u.status) === 1 ? 'active' : 'disabled',
|
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 total = ref(0);
|
||||||
const pageNo = ref(1);
|
const pageNo = ref(1);
|
||||||
const pageSize = ref(10);
|
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 filters = reactive({ keyword: "", status: "" as "" | "active" | "disabled" });
|
||||||
const modalOpen = ref(false);
|
const modalOpen = ref(false);
|
||||||
const editing = ref<AccountRow | null>(null);
|
const editing = ref<AccountRow | null>(null);
|
||||||
@@ -37,6 +37,8 @@ const form = reactive({
|
|||||||
phone: "",
|
phone: "",
|
||||||
status: "active" as AccountRow["status"],
|
status: "active" as AccountRow["status"],
|
||||||
password: "",
|
password: "",
|
||||||
|
/** 只看本账号看板数据(对齐原项目 dashboardSelfOnly) */
|
||||||
|
dashboardSelfOnly: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const modalTitle = computed(() => (editing.value ? "编辑账号" : "新建账号"));
|
const modalTitle = computed(() => (editing.value ? "编辑账号" : "新建账号"));
|
||||||
@@ -52,9 +54,11 @@ async function loadRoles() {
|
|||||||
value: String(r.id),
|
value: String(r.id),
|
||||||
label: r.roleName || r.roleCode || r.id,
|
label: r.roleName || r.roleCode || r.id,
|
||||||
tag: roleTagFor(i),
|
tag: roleTagFor(i),
|
||||||
|
roleCode: String(r.roleCode || ""),
|
||||||
}));
|
}));
|
||||||
if (!form.roleId && roleOptions.value.length) {
|
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);
|
const hit = roleOptions.value.find((r) => r.value === roleId);
|
||||||
roleLabel = hit?.label || roleLabel;
|
roleLabel = hit?.label || roleLabel;
|
||||||
roleTag = hit?.tag || roleTag;
|
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 {
|
} catch {
|
||||||
/* 无角色仍展示账号 */
|
/* 无角色仍展示账号 */
|
||||||
@@ -142,11 +163,16 @@ function openCreate() {
|
|||||||
phone: "",
|
phone: "",
|
||||||
status: "active",
|
status: "active",
|
||||||
password: "",
|
password: "",
|
||||||
|
dashboardSelfOnly: false,
|
||||||
});
|
});
|
||||||
modalOpen.value = true;
|
modalOpen.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(row: AccountRow) {
|
function openEdit(row: AccountRow) {
|
||||||
|
if (row.isSuperAdmin) {
|
||||||
|
message.warning("超级管理员不可在后台编辑");
|
||||||
|
return;
|
||||||
|
}
|
||||||
editing.value = row;
|
editing.value = row;
|
||||||
Object.assign(form, {
|
Object.assign(form, {
|
||||||
username: row.username,
|
username: row.username,
|
||||||
@@ -156,11 +182,16 @@ function openEdit(row: AccountRow) {
|
|||||||
phone: row.phoneMasked === "未填写" ? "" : row.phoneMasked,
|
phone: row.phoneMasked === "未填写" ? "" : row.phoneMasked,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
password: "",
|
password: "",
|
||||||
|
dashboardSelfOnly: Boolean(row.dashboardSelfOnly),
|
||||||
});
|
});
|
||||||
modalOpen.value = true;
|
modalOpen.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
|
if (editing.value?.isSuperAdmin) {
|
||||||
|
message.warning("超级管理员不可在后台编辑");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!form.username.trim() || !form.name.trim()) {
|
if (!form.username.trim() || !form.name.trim()) {
|
||||||
message.warning("请填写账号与姓名");
|
message.warning("请填写账号与姓名");
|
||||||
return;
|
return;
|
||||||
@@ -185,6 +216,7 @@ async function save() {
|
|||||||
phone: form.phone.trim() || undefined,
|
phone: form.phone.trim() || undefined,
|
||||||
status: form.status === "active" ? 1 : 2,
|
status: form.status === "active" ? 1 : 2,
|
||||||
selectedroles: form.roleId,
|
selectedroles: form.roleId,
|
||||||
|
dashboardSelfOnly: form.dashboardSelfOnly ? 1 : 0,
|
||||||
};
|
};
|
||||||
if (!editing.value) payload.password = form.password;
|
if (!editing.value) payload.password = form.password;
|
||||||
try {
|
try {
|
||||||
@@ -321,12 +353,25 @@ function removeAccount(row: AccountRow) {
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="account-row-actions">
|
<div class="account-row-actions">
|
||||||
<button class="link-action" type="button" @click="openEdit(row)">编辑</button>
|
<button
|
||||||
<button class="link-action link-muted" type="button" @click="resetPassword(row)">
|
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>
|
||||||
<button
|
<button
|
||||||
v-if="row.username !== 'admin'"
|
v-if="!row.isSuperAdmin"
|
||||||
class="link-action link-danger"
|
class="link-action link-danger"
|
||||||
type="button"
|
type="button"
|
||||||
@click="removeAccount(row)"
|
@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>
|
<option v-for="r in roleOptions" :key="r.value" :value="r.value">{{ r.label }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div class="form-row">
|
||||||
<label class="lbl">状态</label>
|
<label class="lbl">状态</label>
|
||||||
<div class="status-toggle">
|
<div class="status-toggle">
|
||||||
@@ -466,4 +519,26 @@ function removeAccount(row: AccountRow) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -28,8 +28,11 @@ export type TemplateRow = {
|
|||||||
currentVersion?: number;
|
currentVersion?: number;
|
||||||
parseMessage?: string;
|
parseMessage?: string;
|
||||||
unmappedRequired?: string[];
|
unmappedRequired?: string[];
|
||||||
|
unmappedLabels?: string[];
|
||||||
/** 表上有、对照清单未映射上的中文说明(智能体 mappingGaps) */
|
/** 表上有、对照清单未映射上的中文说明(智能体 mappingGaps) */
|
||||||
mappingGaps?: string[];
|
mappingGaps?: string[];
|
||||||
|
/** 列表提示:未解析条数(主账合并口径) */
|
||||||
|
unparsedHintCount?: number;
|
||||||
objectKey?: string;
|
objectKey?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -61,7 +64,9 @@ export function mapApiRow(raw: any): TemplateRow {
|
|||||||
currentVersion: raw.currentVersion,
|
currentVersion: raw.currentVersion,
|
||||||
parseMessage: raw.parseMessage,
|
parseMessage: raw.parseMessage,
|
||||||
unmappedRequired: Array.isArray(raw.unmappedRequired) ? raw.unmappedRequired.map(String) : [],
|
unmappedRequired: Array.isArray(raw.unmappedRequired) ? raw.unmappedRequired.map(String) : [],
|
||||||
|
unmappedLabels: Array.isArray(raw.unmappedLabels) ? raw.unmappedLabels.map(String) : [],
|
||||||
mappingGaps: Array.isArray(raw.mappingGaps) ? raw.mappingGaps.map(String) : [],
|
mappingGaps: Array.isArray(raw.mappingGaps) ? raw.mappingGaps.map(String) : [],
|
||||||
|
unparsedHintCount: Number(raw.unparsedHintCount || 0),
|
||||||
objectKey: raw.objectKey,
|
objectKey: raw.objectKey,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
/**
|
/**
|
||||||
* 系统设置:响应超时小时配置。
|
* 超时响应:响应超时小时配置。
|
||||||
* 海运 / 空运 / 陆运同页分三块展示,只允许改超时小时;无增删、无 Tab。
|
* 海运 / 空运 / 陆运同页自上而下分块展示,只允许改超时小时;无增删、无 Tab。
|
||||||
|
* 「编码」= timeoutCode,每条唯一(如 sea_sales / land_hk_express),供智能体单码调用;只读。
|
||||||
|
* roleCode(销售/产品)仅为角色维度,可重复,不是调用主键。
|
||||||
* 不展示线路类别(维度仍在主账,仅后台列表隐藏)。
|
* 不展示线路类别(维度仍在主账,仅后台列表隐藏)。
|
||||||
* 数据来自主账 inq_response_timeout;本页不做工单超时判定。
|
* 数据来自主账 inq_response_timeout;本页不做工单超时判定。
|
||||||
*/
|
*/
|
||||||
@@ -50,6 +52,12 @@ function displayOrDash(v) {
|
|||||||
return s || '—';
|
return s || '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 唯一业务编码;缺省时提示需迁移 */
|
||||||
|
function timeoutCodeOf(row) {
|
||||||
|
const c = String(row.timeoutCode || '').trim();
|
||||||
|
return c || '—';
|
||||||
|
}
|
||||||
|
|
||||||
async function loadList() {
|
async function loadList() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -105,7 +113,7 @@ async function commitHours(row) {
|
|||||||
<template>
|
<template>
|
||||||
<div class="sys-setting-page">
|
<div class="sys-setting-page">
|
||||||
<div class="toolbar-bar">
|
<div class="toolbar-bar">
|
||||||
<span class="hint">固定规则,仅可改超时小时;失焦或回车保存</span>
|
<span class="hint">固定规则,仅可改超时小时;编码唯一只读,供智能体调用;失焦或回车保存</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loading" style="padding: 24px; color: #94a3b8">加载中…</div>
|
<div v-if="loading" style="padding: 24px; color: #94a3b8">加载中…</div>
|
||||||
@@ -120,16 +128,22 @@ async function commitHours(row) {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width: 88px">角色</th>
|
<th style="width: 88px">角色</th>
|
||||||
<th>运输类型</th>
|
<th style="width: 160px" title="每条唯一;智能体用此码取超时小时">编码</th>
|
||||||
|
<th>运输分类</th>
|
||||||
<th style="width: 140px">超时设置(小时)</th>
|
<th style="width: 140px">超时设置(小时)</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-if="!block.rows.length">
|
<tr v-if="!block.rows.length">
|
||||||
<td colspan="3" style="color: #94a3b8">暂无配置</td>
|
<td colspan="4" style="color: #94a3b8">暂无配置</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-for="row in block.rows" :key="row.id">
|
<tr v-for="row in block.rows" :key="row.id">
|
||||||
<td>{{ roleLabel(row.roleCode) }}</td>
|
<td>{{ roleLabel(row.roleCode) }}</td>
|
||||||
|
<td>
|
||||||
|
<code class="role-code" :title="'智能体调用编码:' + timeoutCodeOf(row)">{{
|
||||||
|
timeoutCodeOf(row)
|
||||||
|
}}</code>
|
||||||
|
</td>
|
||||||
<td>{{ displayOrDash(row.transportType) }}</td>
|
<td>{{ displayOrDash(row.transportType) }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="hours-cell">
|
<div class="hours-cell">
|
||||||
@@ -202,4 +216,16 @@ async function commitHours(row) {
|
|||||||
color: #667085;
|
color: #667085;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.role-code {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #344054;
|
||||||
|
background: #f2f4f7;
|
||||||
|
border: 1px solid #e4e7ec;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* 系统设置 · 响应超时配置 API。
|
* 超时响应配置 API。
|
||||||
* 对接主账 /inquiry/responseTimeout;仅 listAll + 改小时数。
|
* 对接主账 /inquiry/responseTimeout;仅 listAll + 改小时数。
|
||||||
|
* timeoutCode 每条唯一(如 sea_sales),智能体 POST /inquiry/agent/responseTimeout/byCode 单码查询。
|
||||||
*/
|
*/
|
||||||
import { defHttp } from '/@/utils/http/axios';
|
import { defHttp } from '/@/utils/http/axios';
|
||||||
|
|
||||||
@@ -11,6 +12,8 @@ enum Api {
|
|||||||
|
|
||||||
export type ResponseTimeoutRow = {
|
export type ResponseTimeoutRow = {
|
||||||
id: string;
|
id: string;
|
||||||
|
/** 唯一业务编码,智能体调用主键 */
|
||||||
|
timeoutCode?: string;
|
||||||
transportMode: string;
|
transportMode: string;
|
||||||
roleCode: string;
|
roleCode: string;
|
||||||
routeCategory?: string;
|
routeCategory?: string;
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
* 报价模板页:对接主账 /inquiry/quoteTemplate。
|
* 报价模板页:对接主账 /inquiry/quoteTemplate。
|
||||||
* - 上传按钮只在顶栏一处;弹窗用原生 overlay
|
* - 上传按钮只在顶栏一处;弹窗用原生 overlay
|
||||||
* - 版式由上传 Excel 决定;列表展示「映射点数」而非假占位符
|
* - 版式由上传 Excel 决定;列表展示「映射点数」而非假占位符
|
||||||
* - 预览:原表截图(LibreOffice 转图),不是 AI 画格子
|
* - 预览:原表截图(LibreOffice 转图)
|
||||||
|
* - 解析状态列可点:弹窗看已解析 / 未解析清单
|
||||||
*/
|
*/
|
||||||
import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
|
import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
|
||||||
import { Modal, message } from "ant-design-vue";
|
import { Modal, message } from "ant-design-vue";
|
||||||
@@ -17,9 +18,15 @@ import {
|
|||||||
enableTemplate,
|
enableTemplate,
|
||||||
fetchTemplateSnapshotBlob,
|
fetchTemplateSnapshotBlob,
|
||||||
getTemplateList,
|
getTemplateList,
|
||||||
|
getTemplateVersion,
|
||||||
reparseTemplate,
|
reparseTemplate,
|
||||||
uploadTemplate,
|
uploadTemplate,
|
||||||
} from "/@/views/inquiry/template/template.api";
|
} from "/@/views/inquiry/template/template.api";
|
||||||
|
import {
|
||||||
|
FIELD_LABELS,
|
||||||
|
summarizeMappingParse,
|
||||||
|
type MappingParseSummary,
|
||||||
|
} from "/@/views/inquiry/template/previewSample";
|
||||||
|
|
||||||
// buildTemplateFileUrl 已由 downloadTemplateFile 替代(鉴权 blob 下载)
|
// buildTemplateFileUrl 已由 downloadTemplateFile 替代(鉴权 blob 下载)
|
||||||
|
|
||||||
@@ -45,7 +52,17 @@ const formOpen = ref(false);
|
|||||||
const editing = ref<TemplateRow | null>(null);
|
const editing = ref<TemplateRow | null>(null);
|
||||||
const previewOpen = ref(false);
|
const previewOpen = ref(false);
|
||||||
const previewRow = ref<TemplateRow | null>(null);
|
const previewRow = ref<TemplateRow | null>(null);
|
||||||
/** 原表截图预览状态 */
|
/** 空映射摘要 */
|
||||||
|
const emptyMappingSummary = (): MappingParseSummary => ({
|
||||||
|
fields: [],
|
||||||
|
regions: [],
|
||||||
|
gaps: [],
|
||||||
|
unmappedRequired: [],
|
||||||
|
unmappedLabels: [],
|
||||||
|
pointCount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 原表截图预览状态(不含映射清单) */
|
||||||
const previewState = reactive({
|
const previewState = reactive({
|
||||||
loading: false,
|
loading: false,
|
||||||
error: "",
|
error: "",
|
||||||
@@ -54,6 +71,29 @@ const previewState = reactive({
|
|||||||
mappedCount: 0,
|
mappedCount: 0,
|
||||||
fileName: "",
|
fileName: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 列表「解析状态」点击弹窗:已解析 / 未解析 */
|
||||||
|
const parseDetailOpen = ref(false);
|
||||||
|
const parseDetailRow = ref<TemplateRow | null>(null);
|
||||||
|
const parseDetailState = reactive({
|
||||||
|
loading: false,
|
||||||
|
mappedCount: 0,
|
||||||
|
mapping: emptyMappingSummary() as MappingParseSummary,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 未解析清单:只认系统 mappingGaps;齐全(空数组)时不展示 AI 自报标签 */
|
||||||
|
const parseDetailUnparsed = computed(() => {
|
||||||
|
const m = parseDetailState.mapping;
|
||||||
|
if (Array.isArray(m.gaps)) {
|
||||||
|
return (m.gaps || []).filter(Boolean).map(String);
|
||||||
|
}
|
||||||
|
const list: string[] = [];
|
||||||
|
for (const c of m.unmappedRequired || []) {
|
||||||
|
list.push(`字段未映射:${FIELD_LABELS[c] || c}`);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
|
||||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||||
const pendingFile = ref<File | null>(null);
|
const pendingFile = ref<File | null>(null);
|
||||||
|
|
||||||
@@ -98,7 +138,7 @@ function revokePreviewUrl() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 打开预览:读取上传后已缓存的截图(未就绪则现转)。
|
* 打开预览:仅原表截图。
|
||||||
* 副作用:HTTP 读 previewSnapshot;本地 blob URL;不写库。
|
* 副作用:HTTP 读 previewSnapshot;本地 blob URL;不写库。
|
||||||
*/
|
*/
|
||||||
async function openPreview(row: TemplateRow) {
|
async function openPreview(row: TemplateRow) {
|
||||||
@@ -132,6 +172,57 @@ async function openPreview(row: TemplateRow) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列表点「解析状态」:弹窗展示已解析 / 未解析清单。
|
||||||
|
* 副作用:HTTP 读 version.mappingJson;不写库。
|
||||||
|
*/
|
||||||
|
async function openParseDetail(row: TemplateRow) {
|
||||||
|
if (row.parseStatus === "解析中" || row.parseStatus === "未上传") {
|
||||||
|
message.info(row.parseStatus === "解析中" ? "解析中,请稍候再看" : "请先上传模板文件");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!row.versionId) {
|
||||||
|
message.warning("无可用版本");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
parseDetailRow.value = row;
|
||||||
|
parseDetailOpen.value = true;
|
||||||
|
parseDetailState.loading = true;
|
||||||
|
parseDetailState.mappedCount = row.mappingPointCount || 0;
|
||||||
|
parseDetailState.mapping = emptyMappingSummary();
|
||||||
|
try {
|
||||||
|
const ver: any = await getTemplateVersion(row.versionId);
|
||||||
|
const mappingJson = ver?.mappingJson ?? ver?.result?.mappingJson ?? null;
|
||||||
|
let summary = summarizeMappingParse(mappingJson);
|
||||||
|
if (!summary.gaps.length && row.mappingGaps?.length) {
|
||||||
|
summary = { ...summary, gaps: [...row.mappingGaps] };
|
||||||
|
}
|
||||||
|
if (!summary.unmappedRequired.length && row.unmappedRequired?.length) {
|
||||||
|
summary = { ...summary, unmappedRequired: [...row.unmappedRequired] };
|
||||||
|
}
|
||||||
|
parseDetailState.mapping = summary;
|
||||||
|
if (summary.pointCount > 0) {
|
||||||
|
parseDetailState.mappedCount = summary.pointCount;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
parseDetailState.mapping = {
|
||||||
|
...emptyMappingSummary(),
|
||||||
|
gaps: listMappingGaps(row),
|
||||||
|
unmappedRequired: row.unmappedRequired || [],
|
||||||
|
pointCount: row.mappingPointCount || 0,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
parseDetailState.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeParseDetail() {
|
||||||
|
parseDetailOpen.value = false;
|
||||||
|
parseDetailRow.value = null;
|
||||||
|
parseDetailState.loading = false;
|
||||||
|
parseDetailState.mapping = emptyMappingSummary();
|
||||||
|
}
|
||||||
|
|
||||||
function closeForm() {
|
function closeForm() {
|
||||||
formOpen.value = false;
|
formOpen.value = false;
|
||||||
}
|
}
|
||||||
@@ -281,13 +372,23 @@ function openEdit(row: TemplateRow) {
|
|||||||
formOpen.value = true;
|
formOpen.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 运营可见的未解析项:优先 mappingGaps,其次 AI 自报 unmappedRequired */
|
/** 运营可见的未解析项:只认系统对照清单 mappingGaps;齐全时角标为 0 */
|
||||||
function listMappingGaps(row: TemplateRow): string[] {
|
function listMappingGaps(row: TemplateRow): string[] {
|
||||||
if (row.mappingGaps?.length) return [...row.mappingGaps];
|
// 主账已带 mappingGaps(含空数组)时,不再用 AI 自报 unmapped* 抬角标
|
||||||
if (row.unmappedRequired?.length) {
|
if (Array.isArray(row.mappingGaps)) {
|
||||||
return row.unmappedRequired.map((c) => `字段未映射:${c}`);
|
return row.mappingGaps.filter(Boolean).map(String);
|
||||||
}
|
}
|
||||||
return [];
|
const list: string[] = [];
|
||||||
|
for (const c of row.unmappedRequired || []) {
|
||||||
|
if (c) list.push(`字段未映射:${c}`);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表角标条数:优先主账 unparsedHintCount(与 mappingGaps 同口径) */
|
||||||
|
function unparsedCount(row: TemplateRow): number {
|
||||||
|
if (typeof row.unparsedHintCount === "number") return Math.max(0, row.unparsedHintCount);
|
||||||
|
return listMappingGaps(row).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeKeywordItems(items: string[]): string[] {
|
function normalizeKeywordItems(items: string[]): string[] {
|
||||||
@@ -333,6 +434,7 @@ async function toggleStatus(row: TemplateRow) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 重新提交 AI 映射解析(已解析也可点;解析中禁用)。
|
* 重新提交 AI 映射解析(已解析也可点;解析中禁用)。
|
||||||
|
* 未换文件:主账带 mergeExisting,回写时保留已有字段只补缺。
|
||||||
* 副作用:主账入队;列表进入解析中并轮询。
|
* 副作用:主账入队;列表进入解析中并轮询。
|
||||||
*/
|
*/
|
||||||
async function reparseRow(row: TemplateRow) {
|
async function reparseRow(row: TemplateRow) {
|
||||||
@@ -543,24 +645,29 @@ async function downloadPreviewOrOriginal() {
|
|||||||
{{ formatKeywords(row.keywords) || "—" }}
|
{{ formatKeywords(row.keywords) || "—" }}
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
class="qt-col-muted"
|
class="qt-col-muted qt-parse-cell-click"
|
||||||
:class="{
|
:class="{
|
||||||
'qt-parse-fail': row.parseStatus === '失败' || row.parseStatus === '映射未完成',
|
'qt-parse-fail': row.parseStatus === '失败' || row.parseStatus === '映射未完成',
|
||||||
'qt-parse-gaps': listMappingGaps(row).length > 0 && row.parseStatus === '已解析',
|
'qt-parse-gaps': unparsedCount(row) > 0 && row.parseStatus === '已解析',
|
||||||
}"
|
}"
|
||||||
:title="row.parseMessage || row.parseStatus"
|
:title="
|
||||||
|
unparsedCount(row) > 0
|
||||||
|
? `有 ${unparsedCount(row)} 项未解析,点击查看`
|
||||||
|
: '点击查看已解析 / 未解析内容'
|
||||||
|
"
|
||||||
|
@click.stop="openParseDetail(row)"
|
||||||
>
|
>
|
||||||
<span>{{ row.parseStatus }}</span>
|
<div class="qt-parse-status-row">
|
||||||
<span v-if="listMappingGaps(row).length" class="qt-gap-badge">
|
<span class="qt-parse-status-link">{{ row.parseStatus }}</span>
|
||||||
未解析 {{ listMappingGaps(row).length }}
|
<span v-if="unparsedCount(row) > 0" class="qt-gap-badge">未解析 {{ unparsedCount(row) }}</span>
|
||||||
</span>
|
</div>
|
||||||
|
<div v-if="unparsedCount(row) > 0" class="qt-parse-gap-hint">有未解析内容,点击查看</div>
|
||||||
<div
|
<div
|
||||||
v-if="
|
v-else-if="
|
||||||
row.parseMessage &&
|
row.parseMessage &&
|
||||||
(row.parseStatus === '失败' ||
|
(row.parseStatus === '失败' ||
|
||||||
row.parseStatus === '映射未完成' ||
|
row.parseStatus === '映射未完成' ||
|
||||||
row.parseStatus === '解析中' ||
|
row.parseStatus === '解析中')
|
||||||
listMappingGaps(row).length > 0)
|
|
||||||
"
|
"
|
||||||
class="qt-parse-msg"
|
class="qt-parse-msg"
|
||||||
>
|
>
|
||||||
@@ -581,7 +688,11 @@ async function downloadPreviewOrOriginal() {
|
|||||||
type="button"
|
type="button"
|
||||||
class="link-action"
|
class="link-action"
|
||||||
:disabled="row.parseStatus === '解析中'"
|
:disabled="row.parseStatus === '解析中'"
|
||||||
:title="row.parseStatus === '解析中' ? '解析中,请稍候' : '重新提交 AI 映射解析'"
|
:title="
|
||||||
|
row.parseStatus === '解析中'
|
||||||
|
? '解析中,请稍候'
|
||||||
|
: '重新解析:保留已有映射,只补缺;换文件请用编辑上传'
|
||||||
|
"
|
||||||
@click.stop="reparseRow(row)"
|
@click.stop="reparseRow(row)"
|
||||||
>
|
>
|
||||||
重新解析
|
重新解析
|
||||||
@@ -830,6 +941,86 @@ async function downloadPreviewOrOriginal() {
|
|||||||
</div>
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
|
|
||||||
|
<!-- 解析状态弹窗:已解析 / 未解析清单 -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="parseDetailOpen && parseDetailRow"
|
||||||
|
class="modal-overlay"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
@click.self="closeParseDetail"
|
||||||
|
>
|
||||||
|
<div class="modal-dialog qt-parse-detail-dialog" @click.stop>
|
||||||
|
<div class="qt-modal-head">
|
||||||
|
<div class="qt-modal-title-row">
|
||||||
|
<div class="qt-modal-icon qt-modal-icon--green">
|
||||||
|
<ProtoIcon name="fileSpreadsheet" :size="16" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="qt-modal-sub">解析结果</div>
|
||||||
|
<div class="qt-modal-title">{{ parseDetailRow.name }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-ghost" aria-label="关闭" @click="closeParseDetail">
|
||||||
|
<ProtoIcon name="x" :size="18" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="qt-preview-modal-body">
|
||||||
|
<div class="qt-preview-tags">
|
||||||
|
<span class="tag" :class="bizTag(parseDetailRow.bizType)">{{ parseDetailRow.bizType }}</span>
|
||||||
|
<span class="tag tag-green">已解析 {{ parseDetailState.mappedCount }} 项</span>
|
||||||
|
<span v-if="parseDetailUnparsed.length" class="tag tag-amber"
|
||||||
|
>未解析 {{ parseDetailUnparsed.length }} 项</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="qt-parse-panels">
|
||||||
|
<div class="qt-parse-panel qt-parse-panel--ok">
|
||||||
|
<div class="qt-parse-panel-title">已解析内容</div>
|
||||||
|
<div v-if="parseDetailState.loading" class="qt-parse-panel-empty">加载映射中…</div>
|
||||||
|
<template
|
||||||
|
v-else-if="parseDetailState.mapping.fields.length || parseDetailState.mapping.regions.length"
|
||||||
|
>
|
||||||
|
<ul class="qt-parse-panel-list">
|
||||||
|
<li v-for="f in parseDetailState.mapping.fields" :key="'f-' + f.code">
|
||||||
|
<span class="qt-parse-name">{{ f.label }}</span>
|
||||||
|
<code class="qt-parse-code">{{ f.code }}</code>
|
||||||
|
<span class="qt-parse-cell">{{ f.cell }}</span>
|
||||||
|
</li>
|
||||||
|
<li v-for="r in parseDetailState.mapping.regions" :key="'r-' + r.kind + r.id">
|
||||||
|
<span class="qt-parse-name"
|
||||||
|
>{{ r.kind === "fee" ? "费用区" : "线路区" }} · {{ r.title }}</span
|
||||||
|
>
|
||||||
|
<code v-if="r.id" class="qt-parse-code">{{ r.id }}</code>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
<div v-else class="qt-parse-panel-empty">暂无已映射字段(解析中或未完成)</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="qt-parse-panel"
|
||||||
|
:class="parseDetailUnparsed.length ? 'qt-parse-panel--gap' : 'qt-parse-panel--ok'"
|
||||||
|
>
|
||||||
|
<div class="qt-parse-panel-title">未解析内容</div>
|
||||||
|
<div v-if="parseDetailState.loading" class="qt-parse-panel-empty">加载映射中…</div>
|
||||||
|
<ul v-else-if="parseDetailUnparsed.length" class="qt-parse-panel-list">
|
||||||
|
<li v-for="(g, i) in parseDetailUnparsed" :key="'g-' + i">{{ g }}</li>
|
||||||
|
</ul>
|
||||||
|
<div v-else class="qt-parse-panel-empty">
|
||||||
|
暂无未解析项。若刚升级功能,可点「重新解析」刷新缺口清单。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="qt-preview-modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" @click="closeParseDetail">关闭</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
<!-- 截图放大灯箱:1:1 比例,仅关灯箱不关预览弹窗 -->
|
<!-- 截图放大灯箱:1:1 比例,仅关灯箱不关预览弹窗 -->
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div
|
<div
|
||||||
@@ -855,6 +1046,95 @@ async function downloadPreviewOrOriginal() {
|
|||||||
max-width: min(96vw, 980px) !important;
|
max-width: min(96vw, 980px) !important;
|
||||||
width: min(96vw, 980px);
|
width: min(96vw, 980px);
|
||||||
}
|
}
|
||||||
|
.qt-parse-detail-dialog {
|
||||||
|
max-width: min(96vw, 720px) !important;
|
||||||
|
width: min(96vw, 720px);
|
||||||
|
}
|
||||||
|
.qt-parse-cell-click {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.qt-parse-cell-click:hover .qt-parse-status-link {
|
||||||
|
color: #155eef;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.qt-parse-status-link {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.qt-parse-panels {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.qt-parse-panels {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.qt-parse-panel {
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e4e7ec;
|
||||||
|
background: #f9fafb;
|
||||||
|
padding: 10px 12px;
|
||||||
|
min-height: 88px;
|
||||||
|
max-height: min(50vh, 360px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.qt-parse-panel--ok {
|
||||||
|
border-color: #abefc6;
|
||||||
|
background: #f0fdf4;
|
||||||
|
}
|
||||||
|
.qt-parse-panel--gap {
|
||||||
|
border-color: #fec84b;
|
||||||
|
background: #fffaeb;
|
||||||
|
}
|
||||||
|
.qt-parse-panel-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #344054;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.qt-parse-panel--gap .qt-parse-panel-title {
|
||||||
|
color: #b54708;
|
||||||
|
}
|
||||||
|
.qt-parse-panel-empty {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #98a2b3;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.qt-parse-panel-list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 16px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #344054;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.qt-parse-panel-list li {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.qt-parse-name {
|
||||||
|
font-weight: 700;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
.qt-parse-code {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #475467;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e4e7ec;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0 4px;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
.qt-parse-cell {
|
||||||
|
color: #667085;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.tag-amber {
|
||||||
|
background: #fffaeb;
|
||||||
|
color: #b54708;
|
||||||
|
border: 1px solid #fec84b;
|
||||||
|
}
|
||||||
.qt-col-name {
|
.qt-col-name {
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
@@ -875,9 +1155,21 @@ async function downloadPreviewOrOriginal() {
|
|||||||
.qt-parse-gaps {
|
.qt-parse-gaps {
|
||||||
color: #b45309;
|
color: #b45309;
|
||||||
}
|
}
|
||||||
|
.qt-parse-status-row {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.qt-parse-gap-hint {
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #b54708;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
.qt-gap-badge {
|
.qt-gap-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-left: 6px;
|
|
||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* 报价模板预览:Excel 样式网格(上传原文),辅以映射摘要。
|
* 报价模板预览:Excel 样式网格(上传原文),辅以映射摘要。
|
||||||
* 主数据来自 /previewSheet;mappingJson 仅用于未映射必填提示。
|
* 主数据来自 /previewSheet;mappingJson 用于已映射/未映射清单。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** 系统字段 code → 中文标签 */
|
/** 系统字段 code → 中文标签 */
|
||||||
@@ -27,8 +27,131 @@ export const FIELD_LABELS: Record<string, string> = {
|
|||||||
weight_kg: "重量",
|
weight_kg: "重量",
|
||||||
volume_cbm: "体积",
|
volume_cbm: "体积",
|
||||||
total_amount: "报价合计",
|
total_amount: "报价合计",
|
||||||
|
chargeable_weight_kg: "计费重",
|
||||||
|
delivery_address: "送货地址",
|
||||||
|
pickup_address: "提货地址",
|
||||||
|
transit_time: "运输时效",
|
||||||
|
cargo_value: "货值",
|
||||||
|
hs_code: "HS编码",
|
||||||
|
oil_elec_mag: "油电磁",
|
||||||
|
has_battery: "是否带电",
|
||||||
|
on_board_date: "开船日",
|
||||||
|
special_remarks: "特殊备注",
|
||||||
|
eta_request: "要求到港",
|
||||||
|
sailing_schedule: "船期",
|
||||||
|
shipping_line: "船公司",
|
||||||
|
flight_no: "航班号",
|
||||||
|
transfer_airport: "中转机场",
|
||||||
|
effective_date: "生效日期",
|
||||||
|
fuel_surcharge_rate: "燃油附加费率",
|
||||||
|
tax_rate: "税率",
|
||||||
|
has_oil: "是否含油",
|
||||||
|
has_magnet: "是否含磁",
|
||||||
|
vehicle_qty: "车型/数量",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 解析结果摘要:供预览弹窗展示「已解析 / 未解析」 */
|
||||||
|
export type MappingFieldItem = {
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
cell: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MappingRegionItem = {
|
||||||
|
kind: "fee" | "route";
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MappingParseSummary = {
|
||||||
|
fields: MappingFieldItem[];
|
||||||
|
regions: MappingRegionItem[];
|
||||||
|
gaps: string[];
|
||||||
|
unmappedRequired: string[];
|
||||||
|
unmappedLabels: string[];
|
||||||
|
pointCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function asMappingObject(mappingJson: unknown): Record<string, any> | null {
|
||||||
|
if (!mappingJson) return null;
|
||||||
|
if (typeof mappingJson === "object") return mappingJson as Record<string, any>;
|
||||||
|
if (typeof mappingJson === "string") {
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(mappingJson);
|
||||||
|
return obj && typeof obj === "object" ? obj : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 mappingJson 拆出运营可读的已映射 / 未映射清单。
|
||||||
|
* 不打接口;mappingGaps 为空时(旧版解析)仍可看 fields 与 unmapped*。
|
||||||
|
*/
|
||||||
|
export function summarizeMappingParse(mappingJson: unknown): MappingParseSummary {
|
||||||
|
const obj = asMappingObject(mappingJson);
|
||||||
|
const empty: MappingParseSummary = {
|
||||||
|
fields: [],
|
||||||
|
regions: [],
|
||||||
|
gaps: [],
|
||||||
|
unmappedRequired: [],
|
||||||
|
unmappedLabels: [],
|
||||||
|
pointCount: 0,
|
||||||
|
};
|
||||||
|
if (!obj) return empty;
|
||||||
|
|
||||||
|
const fieldsObj = obj.fields && typeof obj.fields === "object" ? obj.fields : {};
|
||||||
|
const fields: MappingFieldItem[] = Object.keys(fieldsObj)
|
||||||
|
.filter((k) => fieldsObj[k])
|
||||||
|
.map((code) => ({
|
||||||
|
code,
|
||||||
|
label: FIELD_LABELS[code] || code,
|
||||||
|
cell: String(fieldsObj[code] || ""),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label, "zh"));
|
||||||
|
|
||||||
|
const regions: MappingRegionItem[] = [];
|
||||||
|
const feeRegions = Array.isArray(obj.feeRegions) ? obj.feeRegions : [];
|
||||||
|
for (const r of feeRegions) {
|
||||||
|
if (!r || typeof r !== "object") continue;
|
||||||
|
regions.push({
|
||||||
|
kind: "fee",
|
||||||
|
id: String(r.id || ""),
|
||||||
|
title: String(r.title || r.id || "费用区"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const routeRegions = Array.isArray(obj.routeRegions) ? obj.routeRegions : [];
|
||||||
|
for (const r of routeRegions) {
|
||||||
|
if (!r || typeof r !== "object") continue;
|
||||||
|
regions.push({
|
||||||
|
kind: "route",
|
||||||
|
id: String(r.id || ""),
|
||||||
|
title: String(r.title || r.id || "线路区"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const gaps = Array.isArray(obj.mappingGaps)
|
||||||
|
? obj.mappingGaps.map((x: any) => String(x)).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const unmappedRequired = Array.isArray(obj.unmappedRequired)
|
||||||
|
? obj.unmappedRequired.map((x: any) => String(x)).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const unmappedLabels = Array.isArray(obj.unmappedLabels)
|
||||||
|
? obj.unmappedLabels.map((x: any) => String(x)).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
fields,
|
||||||
|
regions,
|
||||||
|
gaps,
|
||||||
|
unmappedRequired,
|
||||||
|
unmappedLabels,
|
||||||
|
pointCount: fields.length + regions.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export type SheetPreviewCell = {
|
export type SheetPreviewCell = {
|
||||||
addr: string;
|
addr: string;
|
||||||
text: string;
|
text: string;
|
||||||
|
|||||||
@@ -91,11 +91,18 @@ export const listTemplateVersions = (templateId: string) =>
|
|||||||
export const saveTemplateMapping = (versionId: string, mappingJson: string) =>
|
export const saveTemplateMapping = (versionId: string, mappingJson: string) =>
|
||||||
defHttp.put({ url: Api.mapping, data: { versionId, mappingJson } });
|
defHttp.put({ url: Api.mapping, data: { versionId, mappingJson } });
|
||||||
|
|
||||||
|
/** 非 GET 时 params 会进 JSON 体,@RequestParam 读不到,必须拼到查询串。 */
|
||||||
export const enableTemplate = (templateId: string, versionId: string) =>
|
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) =>
|
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) =>
|
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 */
|
/** 只改名称/业务/关键词/默认标记,不换文件;走 JSON 体对接主账 @RequestBody */
|
||||||
export const editTemplateMeta = (data) => defHttp.put({ url: Api.editMeta, data });
|
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) =>
|
export const reparseTemplate = (templateId: string) =>
|
||||||
defHttp.post(
|
defHttp.post(
|
||||||
{ url: Api.reparse, params: { id: templateId }, timeout: 60 * 1000 },
|
{ url: Api.reparse, params: { id: templateId, templateId }, timeout: 60 * 1000 },
|
||||||
{ joinParamsToUrl: true },
|
{ joinParamsToUrl: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user