From 9bb69ae72e9c168142d7842fd84ea2bfea0493a5 Mon Sep 17 00:00:00 2001 From: linmianxun <634841751@qq.com> Date: Sat, 19 Sep 2026 10:38:53 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E6=94=AF=E6=8C=81=E5=8F=AA?= =?UTF-8?q?=E7=9C=8B=E6=9C=AC=E8=B4=A6=E5=8F=B7=E7=9C=8B=E6=9D=BF=EF=BC=9B?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E9=87=8D=E6=96=B0=E8=A7=A3=E6=9E=90=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E4=BF=9D=E7=95=99=E5=8E=9F=E6=98=A0=E5=B0=84=E5=B9=B6?= =?UTF-8?q?=E5=A4=9A=E8=BD=AE=E8=A1=A5=E7=BC=BA=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../agent/channel/quote_template_parse.py | 6 + inquiry-agent/agent/jobs/recognition.py | 43 +++- inquiry-agent/agent/llm/mode_sheet_map.py | 236 ++++++++++++++---- .../prompts/mapping_analyzer.txt | 4 +- inquiry-agent/tests/test_sheet_map_mode.py | 145 +++++++++++ .../org/jeecg/common/system/vo/LoginUser.java | 11 + .../controller/InquiryAgentController.java | 9 +- .../InquiryQuoteTemplateController.java | 10 +- .../service/IInquiryQuoteTemplateService.java | 5 +- .../impl/InquiryDashboardServiceImpl.java | 82 +++++- .../impl/InquiryQuoteTemplateServiceImpl.java | 167 +++++++++++-- ...ration_sys_user_dashboard_self_only_v1.sql | 26 ++ .../system/controller/SysUserController.java | 3 + .../jeecg/modules/system/entity/SysUser.java | 7 + .../src/views/inquiry/account/account.api.ts | 24 +- .../src/views/inquiry/account/index.vue | 85 ++++++- .../src/views/inquiry/systemSetting/index.vue | 2 +- .../src/views/inquiry/template/index.vue | 38 +-- .../views/inquiry/template/template.api.ts | 18 +- 19 files changed, 807 insertions(+), 114 deletions(-) create mode 100644 inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_sys_user_dashboard_self_only_v1.sql diff --git a/inquiry-agent/agent/channel/quote_template_parse.py b/inquiry-agent/agent/channel/quote_template_parse.py index 413aa0e..b300a1a 100644 --- a/inquiry-agent/agent/channel/quote_template_parse.py +++ b/inquiry-agent/agent/channel/quote_template_parse.py @@ -37,6 +37,9 @@ class QuoteTemplateParseBody(BaseModel): yellow_count: int = Field(default=0, alias="yellowCount") truncated: bool = False request_id: str = Field(default="", alias="requestId") + # 未换文件重新解析:true + existingMapping,回写时旧字段优先补缺 + merge_existing: bool = Field(default=False, alias="mergeExisting") + existing_mapping: Optional[dict[str, Any]] = Field(default=None, alias="existingMapping") model_config = {"populate_by_name": True} @@ -103,7 +106,10 @@ def enqueue_parse( "cellCount": body.cell_count or len(inventory), "yellowCount": body.yellow_count, "truncated": bool(body.truncated), + "mergeExisting": bool(body.merge_existing), } + if body.merge_existing and isinstance(body.existing_mapping, dict): + payload["existingMapping"] = body.existing_mapping result = enqueue_recognition( kind="quote_template_parse", payload=payload, diff --git a/inquiry-agent/agent/jobs/recognition.py b/inquiry-agent/agent/jobs/recognition.py index ae055ec..cf2ac3d 100644 --- a/inquiry-agent/agent/jobs/recognition.py +++ b/inquiry-agent/agent/jobs/recognition.py @@ -111,28 +111,50 @@ def process_recognition(data: dict[str, Any], *, allow_network: bool = True) -> def _process_quote_template_parse(payload: dict[str, Any], *, allow_network: bool) -> dict[str, Any]: - """跑 sheet_map 并回写主账;失败也必须回写 failed+原因。""" + """ + 跑 sheet_map 并回写主账;失败也必须回写 failed+原因。 + + mergeExisting=true(未换文件重新解析)时:与 existingMapping 补缺合并后再回写, + 旧字段优先保留;换文件/首传不传该标记,整份覆盖。 + """ from agent.config import get_settings - from agent.llm.mode_sheet_map import invoke_sheet_map + from agent.llm.mode_sheet_map import ( + assess_mapping_gaps, + invoke_sheet_map, + merge_mapping_prefer_existing, + ) version_id = str(payload.get("versionId") or "").strip() settings = get_settings() net = bool(allow_network and settings.llm_allow_network) - result = invoke_sheet_map(payload, allow_network=net) + result = invoke_sheet_map(payload, allow_network=net, timeout_seconds=240.0, max_rounds=3) + merge_existing = bool(payload.get("mergeExisting") or payload.get("merge_existing")) + existing = payload.get("existingMapping") or payload.get("existing_mapping") body: dict[str, Any] if result.get("ok") and isinstance(result.get("mapping"), dict): mapping = dict(result["mapping"]) - gaps = result.get("gaps_after") - if not isinstance(gaps, list): - gaps = mapping.get("mappingGaps") if isinstance(mapping.get("mappingGaps"), list) else [] - mapping["mappingGaps"] = [str(g) for g in gaps if g] + if merge_existing and isinstance(existing, dict): + mapping = merge_mapping_prefer_existing(existing, mapping) + # 合并后再对照清单复评缺口,避免角标沿用合并前数字 + remaining = assess_mapping_gaps(payload, mapping) + mapping["mappingGaps"] = [str(g) for g in remaining if g] + if not remaining: + mapping["unmappedRequired"] = [] + mapping["unmappedLabels"] = [] + else: + gaps = result.get("gaps_after") + if not isinstance(gaps, list): + gaps = mapping.get("mappingGaps") if isinstance(mapping.get("mappingGaps"), list) else [] + mapping["mappingGaps"] = [str(g) for g in gaps if g] # 运营可读摘要:点数之外固定带上未解析项 n_fields = len(mapping.get("fields") or {}) n_fee = len(mapping.get("feeRegions") or []) if isinstance(mapping.get("feeRegions"), list) else 0 n_route = len(mapping.get("routeRegions") or []) if isinstance(mapping.get("routeRegions"), list) else 0 points = n_fields + n_fee + n_route parse_message = f"已解析 {points} 个映射点" - if mapping["mappingGaps"]: + if merge_existing: + parse_message += "(保留原映射并补缺)" + if mapping.get("mappingGaps"): preview = ";".join(mapping["mappingGaps"][:8]) more = len(mapping["mappingGaps"]) - 8 parse_message += f";未解析到 {len(mapping['mappingGaps'])} 项:{preview}" @@ -144,6 +166,7 @@ def _process_quote_template_parse(payload: dict[str, Any], *, allow_network: boo "mappingJson": mapping, "parseStatus": "parsed", "parseMessage": parse_message, + "mergeExisting": merge_existing, } else: err = str(result.get("error") or "AI 解析失败") @@ -152,12 +175,14 @@ def _process_quote_template_parse(payload: dict[str, Any], *, allow_network: boo "ok": False, "parseStatus": "failed", "parseMessage": err, + "mergeExisting": merge_existing, } posted = _post_parse_result(body) logger.info( - "quote_template_parse done versionId=%s ok=%s posted=%s", + "quote_template_parse done versionId=%s ok=%s merge=%s posted=%s", version_id, body.get("ok"), + merge_existing, posted.get("ok"), ) return {"ok": bool(body.get("ok")), "posted": posted, "kind": "quote_template_parse"} diff --git a/inquiry-agent/agent/llm/mode_sheet_map.py b/inquiry-agent/agent/llm/mode_sheet_map.py index f623bfd..e936a81 100644 --- a/inquiry-agent/agent/llm/mode_sheet_map.py +++ b/inquiry-agent/agent/llm/mode_sheet_map.py @@ -2,7 +2,7 @@ 报价工作表映射 mode:上传时用千问产出 mappingJson。 本文件职责:加载映射分析提示词、拼用户消息、调千问、剥 Markdown、校验 JSON、 -对照单元格清单做缺项检测,必要时再补跑一轮。 +对照单元格清单做缺项检测;有缺口则多轮补跑(默认最多 3 轮),目标一次流程内齐全。 禁止:发明运价;在企微回调线程同步调用;出单阶段再调本 mode。 """ @@ -22,30 +22,58 @@ MODE = LlmMode.SHEET_MAP _PROMPT_CACHE: str | None = None -# 清单原文片段 → 白名单 code(用于缺项检测;不要求全覆盖词典) -_LABEL_TO_CODE: list[tuple[tuple[str, ...], str]] = [ - (("提货地址", "Place of Receipt", "place of receipt"), "pickup_address"), - (("送货地址", "派送地址", "Place of Delivery", "place of delivery"), "delivery_address"), - (("货值", "Cargo Value", "cargo value"), "cargo_value"), - (("海关编码", "HS Code", "HS编码", "hs code"), "hs_code"), - (("油、电、磁", "油,电,磁", "油电磁", "IL, ELEC", "L,ELEC", "oil"), "oil_elec_mag"), +# 清单原文片段 → 白名单 code(按运输方式过滤;关键词避免过短英文误报) +_LABEL_TO_CODE_COMMON: list[tuple[tuple[str, ...], str]] = [ + (("报价单编号", "询价单号", "RFQ No", "Quote No"), "quote_no"), + (("报价日期", "询价日期", "Inquiry Date", "Quote Date"), "quote_date"), + (("有效期至", "有效期", "Validity"), "valid_until"), + (("客户名称", "Customer Name", "Customer"), "customer_name"), + (("联系人", "Contact Person", "Contact Name"), "contact_name"), + (("电话", "Tel", "Phone", "Mobile"), "contact_phone"), + (("币别", "Currency"), "currency"), + (("业务员", "Sales"), "sales_name"), + (("品名", "Commodity", "Goods Description"), "commodity"), +] + +_LABEL_TO_CODE_SEA: list[tuple[tuple[str, ...], str]] = [ + (("提货地址", "Place of Receipt"), "pickup_address"), + (("送货地址", "派送地址", "Place of Delivery"), "delivery_address"), + (("货值", "Cargo Value"), "cargo_value"), + (("海关编码", "HS Code", "HS编码"), "hs_code"), + (("油、电、磁", "油,电,磁", "油电磁"), "oil_elec_mag"), (("特殊说明", "特别说明", "Special Remarks"), "special_remarks"), - (("要求货物到达", "Requested Arriv", "抵达时间"), "eta_request"), - (("运输时效", "Transit Time", "航程时间", "Navigation time"), "transit_time"), - (("船期", "Sailing", "Requested Sh"), "sailing_schedule"), - (("开船日", "On Board"), "on_board_date"), - (("船公司", "Shipping Line", "Shipping Co"), "shipping_line"), + (("要求货物到达", "Requested Arrival"), "eta_request"), + (("运输时效", "Transit Time", "航程时间"), "transit_time"), + (("船期", "Sailing Schedule"), "sailing_schedule"), + (("开船日", "On Board Date"), "on_board_date"), + (("船公司", "Shipping Line", "Shipping Company"), "shipping_line"), (("货好时间", "Cargo Ready"), "cargo_ready_date"), - (("贸易条款", "Terms"), "trade_terms"), + (("贸易条款", "Trade Terms", "Incoterms"), "trade_terms"), (("整柜或散货", "整柜或拼柜", "FCL or LCL"), "fcl_or_lcl"), - (("箱型", "柜量", "Type & Qty", "Cont."), "container_type_qty"), - (("起运港", "POL"), "pol"), - (("目的港", "POD"), "pod"), - (("品名", "Commodity"), "commodity"), + (("箱型箱量", "箱型", "柜量", "Type & Qty"), "container_type_qty"), + (("起运港", "Port of Loading", "POL"), "pol"), + (("目的港", "Port of Discharge", "POD"), "pod"), (("货物数量", "Quantity"), "quantity"), - (("询价日期", "Inquiry Date", "报价日期"), "quote_date"), - (("RFQ", "询价单号", "报价单编号"), "quote_no"), - (("有效期", "period"), "valid_until"), +] + +_LABEL_TO_CODE_AIR: list[tuple[tuple[str, ...], str]] = [ + (("起运地", "起运港", "Departure", "Origin Airport"), "origin"), + (("目的地", "目的港", "Destination Airport"), "destination"), + (("件数", "Pieces", "Pkgs"), "pieces"), + (("毛重", "重量", "Gross Weight", "Weight"), "weight_kg"), + (("体积", "Volume", "CBM"), "volume_cbm"), + (("计费重", "Chargeable Weight"), "chargeable_weight_kg"), + (("航班", "Flight"), "flight_no"), + (("中转机场", "Transfer Airport"), "transfer_airport"), + (("贸易条款", "Trade Terms", "Incoterms"), "trade_terms"), + (("特殊说明", "特别说明", "Special Remarks"), "special_remarks"), +] + +_LABEL_TO_CODE_LAND: list[tuple[tuple[str, ...], str]] = [ + (("始发地", "起运地", "发站", "Origin"), "origin"), + (("目的地", "到站", "Destination"), "destination"), + (("运输时效", "时效", "Transit Time"), "transit_time"), + (("特殊说明", "特别说明"), "special_remarks"), ] _SEA_FEE_MARKERS: list[tuple[tuple[str, ...], tuple[str, ...]]] = [ @@ -65,6 +93,33 @@ _SEA_FEE_MARKERS: list[tuple[tuple[str, ...], tuple[str, ...]]] = [ ] +def _labels_for_mode(mode: str) -> list[tuple[tuple[str, ...], str]]: + m = (mode or "sea").strip().lower() + out = list(_LABEL_TO_CODE_COMMON) + if m in ("sea", "海运"): + out.extend(_LABEL_TO_CODE_SEA) + elif m in ("air", "空运"): + out.extend(_LABEL_TO_CODE_AIR) + elif m in ("land", "陆运"): + out.extend(_LABEL_TO_CODE_LAND) + else: + out.extend(_LABEL_TO_CODE_SEA) + return out + + +def _label_hit(blob: str, labels: tuple[str, ...]) -> bool: + """清单命中:中文标签直接包含;英文至少 4 字符,降低误报。""" + for lab in labels: + if not lab: + continue + if any("\u4e00" <= ch <= "\u9fff" for ch in lab): + if lab in blob: + return True + elif len(lab) >= 4 and lab.lower() in blob.lower(): + return True + return False + + def mode_meta() -> dict[str, Any]: return { "mode": MODE.value, @@ -178,7 +233,7 @@ def assess_mapping_gaps(payload: dict[str, Any], mapping: dict[str, Any] | None) """ 对照单元格清单,列出 mapping 明显缺口(中文说明,供补跑用户消息)。 - 不打外网。海运:费用段标题出现则要求对应 feeRegion;常见业务标签出现则要求 fields code。 + 不打外网。按运输方式过滤字段词典;海运另检查三段费用区。 """ blob = _inventory_blob(payload) if not blob.strip(): @@ -196,24 +251,89 @@ def assess_mapping_gaps(payload: dict[str, Any], mapping: dict[str, Any] | None) for r in fee_regions if isinstance(r, dict) ).lower() + route_regions = mapping.get("routeRegions") if isinstance(mapping.get("routeRegions"), list) else [] gaps: list[str] = [] mode = str(payload.get("transportMode") or payload.get("transport_mode") or "sea").strip().lower() + # 字段匹配用:去掉费用段标题,避免「目的港费用」误报成 pod 等字段缺口 + field_blob = blob if mode in ("sea", "海运"): for markers, matchers in _SEA_FEE_MARKERS: if not any(m in blob for m in markers): continue if not any(x in fee_blob for x in matchers): gaps.append(f"费用区缺失:清单有「{markers[0]}」,须建对应 feeRegion") + for m in markers: + field_blob = field_blob.replace(m, " ") + elif mode in ("land", "陆运"): + # 陆运主表常见项目/线路矩阵:清单有「项目」或车型价列但无 routeRegions + if ( + ("项目" in blob or "车型" in blob or "3T" in blob or "拼车" in blob) + and not route_regions + ): + gaps.append("线路区缺失:陆运表须建 routeRegions 承接线路/车型报价") - for labels, code in _LABEL_TO_CODE: - if code in field_codes: + # 陆运已建 routeRegions 时,始发地/目的地/时效常在线路矩阵列头,不再强制 fields + land_route_covered = mode in ("land", "陆运") and bool(route_regions) + land_skip_codes = {"origin", "destination", "transit_time"} if land_route_covered else set() + + for labels, code in _labels_for_mode(mode): + if code in field_codes or code in land_skip_codes: continue - if any(lab in blob for lab in labels): + if _label_hit(field_blob, labels): gaps.append(f"字段缺失:清单有「{labels[0]}」→ 应映射 {code}") return gaps +def merge_mapping_prefer_existing( + old_mapping: dict[str, Any] | None, + new_mapping: dict[str, Any] | None, +) -> dict[str, Any]: + """ + 补缺合并:旧 fields / 区域优先,新结果只补充旧里没有的 code/id。 + + 用于「未换文件重新解析」:避免已对上的字段被新一轮 AI 冲掉。 + """ + new_mapping = dict(new_mapping or {}) + old_mapping = old_mapping if isinstance(old_mapping, dict) else {} + out = dict(new_mapping) + + new_fields = new_mapping.get("fields") if isinstance(new_mapping.get("fields"), dict) else {} + old_fields = old_mapping.get("fields") if isinstance(old_mapping.get("fields"), dict) else {} + fields: dict[str, Any] = dict(new_fields) + for code, cell in old_fields.items(): + if cell is not None and str(cell).strip(): + fields[str(code)] = cell + out["fields"] = fields + + out["feeRegions"] = _merge_regions_by_id( + old_mapping.get("feeRegions"), new_mapping.get("feeRegions") + ) + out["routeRegions"] = _merge_regions_by_id( + old_mapping.get("routeRegions"), new_mapping.get("routeRegions") + ) + return out + + +def _merge_regions_by_id(old_arr: Any, new_arr: Any) -> list[dict[str, Any]]: + """区域按 id 合并:旧整段保留,新 id 追加。""" + out: list[dict[str, Any]] = [] + seen: set[str] = set() + for src in (old_arr, new_arr): + if not isinstance(src, list): + continue + for item in src: + if not isinstance(item, dict): + continue + rid = str(item.get("id") or "").strip() + if rid and rid in seen: + continue + if rid: + seen.add(rid) + out.append(dict(item)) + return out + + def invoke_shell(*, payload: dict[str, Any]) -> dict[str, Any]: """ 无网/单测入口:若带 assistant_json 则直接解析;否则返回未实现。 @@ -273,10 +393,12 @@ def invoke_sheet_map( *, allow_network: bool = False, chat_fn: Optional[Any] = None, - timeout_seconds: float = 180.0, + timeout_seconds: float = 240.0, + max_rounds: int = 3, ) -> dict[str, Any]: """ - 调用千问产出 mappingJson;若对照清单有明显缺口,再补跑一轮(最多 2 次 chat)。 + 调用千问产出 mappingJson;对照清单有缺口则补跑,最多 max_rounds 轮(默认 3), + 目标一次流程内尽量齐全,缺口写进 mappingGaps。 allow_network=False 且无 chat_fn:不打外网。 副作用:可能出站 HTTPS;应在 Worker HTTP 槽内调用。 @@ -293,36 +415,54 @@ def invoke_sheet_map( else: chat = chat_fn + rounds_cap = max(1, min(int(max_rounds or 3), 4)) first = _chat_once(chat=chat, payload=payload, gaps=None) if not first.get("ok"): return first mapping = first["mapping"] - gaps = assess_mapping_gaps(payload, mapping) rounds = 1 - if gaps: - logger.info( - "sheet_map gaps versionId=%s n=%s sample=%s", - payload.get("versionId"), - len(gaps), - gaps[:5], - ) - second = _chat_once(chat=chat, payload=payload, gaps=gaps) - rounds = 2 - if second.get("ok") and second.get("mapping"): - mapping = second["mapping"] - # 第二轮仍失败结构时保留第一轮 - elif not second.get("ok"): - logger.warning( - "sheet_map 补跑失败,保留第一轮 versionId=%s err=%s", - payload.get("versionId"), - second.get("error"), - ) - remaining = assess_mapping_gaps(payload, mapping) - # 缺口落进 mappingJson,主账/后台可直接展示「表上有、未映射上」 + # 有缺口就补跑,直到齐、轮次用尽、或本轮未减少缺口 + while remaining and rounds < rounds_cap: + logger.info( + "sheet_map gaps versionId=%s round=%s n=%s sample=%s", + payload.get("versionId"), + rounds, + len(remaining), + remaining[:5], + ) + nxt = _chat_once(chat=chat, payload=payload, gaps=remaining) + rounds += 1 + if not (nxt.get("ok") and nxt.get("mapping")): + logger.warning( + "sheet_map 补跑失败,保留上一轮 versionId=%s err=%s", + payload.get("versionId"), + nxt.get("error"), + ) + break + mapping = nxt["mapping"] + after = assess_mapping_gaps(payload, mapping) + if len(after) >= len(remaining): + # 未继续收敛则停止,避免空转烧额度 + logger.info( + "sheet_map gaps 未收敛 versionId=%s before=%s after=%s stop", + payload.get("versionId"), + len(remaining), + len(after), + ) + remaining = after + break + remaining = after + + # 缺口落进 mappingJson;齐全时清空 AI 自报未映射,避免列表角标被说明性标签抬高 if isinstance(mapping, dict): mapping["mappingGaps"] = list(remaining) + if not remaining: + mapping["unmappedRequired"] = [] + mapping["unmappedLabels"] = [] + elif not isinstance(mapping.get("unmappedLabels"), list): + mapping["unmappedLabels"] = [] logger.info( "sheet_map ok versionId=%s rounds=%s fields=%s feeRegions=%s routeRegions=%s remain_gaps=%s", payload.get("versionId"), diff --git a/inquiry-agent/agent/quote_templates/prompts/mapping_analyzer.txt b/inquiry-agent/agent/quote_templates/prompts/mapping_analyzer.txt index 9ffbb04..bf8aec8 100644 --- a/inquiry-agent/agent/quote_templates/prompts/mapping_analyzer.txt +++ b/inquiry-agent/agent/quote_templates/prompts/mapping_analyzer.txt @@ -360,6 +360,8 @@ transportMode:sea | air | land 5. 空运必须能承接 departureCharges + 空运费;陆运主表必须用 routeRegions 承接 landSegments。 6. 看不清 → unmappedRequired / unmappedLabels;表上有、能对上白名单却未映射 → 视为错误,必须补进 fields。 7. 用户未声明运输方式时推断 transportMode,并在 notes 说明。 +8. **一次齐全**:第一轮就要尽量映射清单中全部可见业务标签;宁可多写 fields/feeRegions/routeRegions,也不要省略。补跑时输出完整 mappingJson(覆盖式),不要只回增量。 +9. 说明性/公式性文字(如「1CBM=***KG」「重量体积取大」)无法落白名单字段时写入 unmappedLabels,不要假装成业务字段缺口。 【用户消息将提供】 运输方式:海运 | 空运 | 陆运 @@ -368,4 +370,4 @@ transportMode:sea | air | land (可选)TMS 样例或出单投影说明——仅对齐字段 (补跑时)缺口清单:请据此补全整份 mappingJson,不要只回增量。 -输出前自检 fields 与 feeRegions/routeRegions 是否覆盖清单中的可见业务标签,再只输出 mappingJson。 +输出前自检:fields 与 feeRegions/routeRegions 是否覆盖清单中的可见业务标签;有缺口则先补齐再输出。只输出 mappingJson。 diff --git a/inquiry-agent/tests/test_sheet_map_mode.py b/inquiry-agent/tests/test_sheet_map_mode.py index c40028f..77d5242 100644 --- a/inquiry-agent/tests/test_sheet_map_mode.py +++ b/inquiry-agent/tests/test_sheet_map_mode.py @@ -101,6 +101,93 @@ class TestSheetMapParse(unittest.TestCase): } self.assertEqual(assess_mapping_gaps(payload, mapping), []) + def test_assess_gaps_air_ignores_sea_labels(self) -> None: + """空运不按海运词典报「提货地址/货值」缺口。""" + payload = { + "transportMode": "air", + "cellInventory": [ + "A!B2 | 起运地/Origin Airport | - | -", + "A!B3 | 毛重/Gross Weight | - | -", + "A!Z99 | 提货地址/Place of Receipt | - | -", + ], + } + mapping = {"fields": {"origin": "A!C2", "weight_kg": "A!C3"}, "feeRegions": []} + gaps = assess_mapping_gaps(payload, mapping) + self.assertFalse(any("pickup_address" in g for g in gaps)) + self.assertEqual(gaps, []) + + def test_assess_gaps_short_en_no_false_positive(self) -> None: + """短英文(如 POL)单独出现不误报;须中文或 ≥4 字符英文标签。""" + payload = { + "transportMode": "sea", + "cellInventory": ["S!A1 | POL code table | - | -"], + } + mapping = {"fields": {}, "feeRegions": []} + gaps = assess_mapping_gaps(payload, mapping) + self.assertFalse(any("→ 应映射 pol" in g for g in gaps)) + + def test_assess_gaps_land_needs_route_regions(self) -> None: + payload = { + "transportMode": "land", + "cellInventory": [ + "L!A1 | 项目 | - | -", + "L!B1 | 3T | - | -", + "L!C1 | 拼车 | - | -", + ], + } + mapping = {"fields": {}, "feeRegions": [], "routeRegions": []} + gaps = assess_mapping_gaps(payload, mapping) + self.assertTrue(any("routeRegions" in g for g in gaps)) + + def test_assess_gaps_land_route_skips_matrix_fields(self) -> None: + """陆运已有 routeRegions 时,不把矩阵列头始发地/目的地当 fields 缺口。""" + payload = { + "transportMode": "land", + "cellInventory": [ + "L!A1 | 项目 | - | -", + "L!B1 | 始发地 | - | -", + "L!C1 | 目的地 | - | -", + "L!D1 | 运输时效 | - | -", + "L!E1 | 特殊说明 | - | -", + ], + } + mapping = { + "fields": {}, + "feeRegions": [], + "routeRegions": [{"id": "main", "title": "线路", "anchor": "L!A2"}], + } + gaps = assess_mapping_gaps(payload, mapping) + self.assertFalse(any("origin" in g for g in gaps)) + self.assertFalse(any("destination" in g for g in gaps)) + self.assertFalse(any("transit_time" in g for g in gaps)) + self.assertTrue(any("special_remarks" in g for g in gaps)) + + def test_merge_prefer_existing_keeps_old_field(self) -> None: + from agent.llm.mode_sheet_map import merge_mapping_prefer_existing + + old = { + "fields": {"quote_no": "S!B3", "pol": "S!B5"}, + "feeRegions": [{"id": "origin_charges", "title": "旧始发港"}], + "routeRegions": [], + } + new = { + "fields": {"quote_no": "S!Z99", "pod": "S!B6"}, + "feeRegions": [ + {"id": "origin_charges", "title": "新始发港"}, + {"id": "ocean_freight", "title": "海运费用"}, + ], + "routeRegions": [], + "mappingGaps": [], + } + merged = merge_mapping_prefer_existing(old, new) + self.assertEqual(merged["fields"]["quote_no"], "S!B3") # 旧优先 + self.assertEqual(merged["fields"]["pol"], "S!B5") # 旧保留 + self.assertEqual(merged["fields"]["pod"], "S!B6") # 新补缺 + ids = [r["id"] for r in merged["feeRegions"]] + self.assertEqual(ids[0], "origin_charges") + self.assertEqual(merged["feeRegions"][0]["title"], "旧始发港") + self.assertIn("ocean_freight", ids) + def test_invoke_retries_when_gaps(self) -> None: calls: list[str] = [] @@ -145,6 +232,64 @@ class TestSheetMapParse(unittest.TestCase): self.assertEqual(out.get("gaps_after"), out["mapping"]["mappingGaps"]) self.assertEqual(out["mapping"]["mappingGaps"], []) + def test_invoke_multi_round_until_complete(self) -> None: + """最多 3 轮:第 1 残缺 → 第 2 仍缺 → 第 3 齐。""" + calls: list[int] = [] + + def fake_chat(*, mode, messages, temperature=0.0): # noqa: ARG001 + calls.append(1) + n = len(calls) + if n == 1: + content = ( + '{"schemaVersion":"quote-template-mapping-v1",' + '"fields":{"quote_no":"S!B3"},"feeRegions":[]}' + ) + elif n == 2: + content = ( + '{"schemaVersion":"quote-template-mapping-v1",' + '"fields":{"quote_no":"S!B3","pickup_address":"S!H6"},' + '"feeRegions":[{"id":"origin_charges","title":"始发港费用",' + '"tmsSource":"polCostItems","anchor":"S!A19","stopAtText":["海运费用"],' + '"grow":"insert_before_stop","styleCopyRows":1}]}' + ) + else: + content = ( + '{"schemaVersion":"quote-template-mapping-v1",' + '"fields":{"quote_no":"S!B3","pickup_address":"S!H6","cargo_value":"S!B11"},' + '"feeRegions":[' + '{"id":"origin_charges","title":"始发港费用","tmsSource":"polCostItems",' + '"anchor":"S!A19","stopAtText":["海运费用"],' + '"grow":"insert_before_stop","styleCopyRows":1},' + '{"id":"ocean_freight","title":"海运费用","tmsSource":"oceanCostItems",' + '"anchor":"S!A31","stopAtText":["目的港费用"],' + '"grow":"insert_before_stop","styleCopyRows":1},' + '{"id":"destination_charges","title":"目的港费用","tmsSource":"podCostItems",' + '"anchor":"S!A36","stopAtText":["备注"],' + '"grow":"insert_before_stop","styleCopyRows":1}' + ']}' + ) + return SimpleNamespace(ok=True, content=content, provider="stub", error="") + + payload = { + "versionId": "v2", + "transportMode": "sea", + "fileName": "sea.xlsx", + "sheet": "S", + "cellInventory": [ + "S!A18 | 始发港费用/Origin Charges | - | -", + "S!A30 | 海运费用/Ocean Freight | - | -", + "S!A35 | 目的港费用/Destination Charges | - | -", + "S!E6 | 提货地址/Place of Receipt | - | -", + "S!A11 | 货值/Cargo Value | - | -", + ], + } + out = invoke_sheet_map(payload, chat_fn=fake_chat, allow_network=False, max_rounds=3) + self.assertTrue(out["ok"]) + self.assertEqual(out.get("rounds"), 3) + self.assertEqual(len(calls), 3) + self.assertEqual(out["mapping"]["mappingGaps"], []) + self.assertEqual(len(out["mapping"]["feeRegions"]), 3) + if __name__ == "__main__": unittest.main() diff --git a/inquiry-api/jeecg-boot-base-core/src/main/java/org/jeecg/common/system/vo/LoginUser.java b/inquiry-api/jeecg-boot-base-core/src/main/java/org/jeecg/common/system/vo/LoginUser.java index 6d31b24..21f2804 100644 --- a/inquiry-api/jeecg-boot-base-core/src/main/java/org/jeecg/common/system/vo/LoginUser.java +++ b/inquiry-api/jeecg-boot-base-core/src/main/java/org/jeecg/common/system/vo/LoginUser.java @@ -127,4 +127,15 @@ public class LoginUser { /**设备id uniapp推送用*/ private String clientId; + /** + * 工号(询价账号页暂存企微 userid) + */ + @SensitiveField + private String workNo; + + /** + * 只看本账号看板数据:登录态透传;看板聚合读取后收紧范围。 + */ + private Boolean dashboardSelfOnly; + } diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryAgentController.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryAgentController.java index 5acf2dc..4f24f9d 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryAgentController.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryAgentController.java @@ -70,8 +70,15 @@ public class InquiryAgentController { : (mappingObj instanceof String ? (String) mappingObj : JSON.toJSONString(mappingObj)); String parseMessage = body.get("parseMessage") == null ? null : String.valueOf(body.get("parseMessage")); String parseStatus = body.get("parseStatus") == null ? null : String.valueOf(body.get("parseStatus")); + boolean mergeExisting = false; + Object mergeFlag = body.get("mergeExisting"); + if (mergeFlag instanceof Boolean) { + mergeExisting = (Boolean) mergeFlag; + } else if (mergeFlag != null) { + mergeExisting = "true".equalsIgnoreCase(String.valueOf(mergeFlag)); + } try { - quoteTemplateService.applyParseResult(versionId, ok, mappingJson, parseMessage, parseStatus); + quoteTemplateService.applyParseResult(versionId, ok, mappingJson, parseMessage, parseStatus, mergeExisting); return Result.OK("ok"); } catch (IllegalArgumentException e) { return Result.error(e.getMessage()); diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryQuoteTemplateController.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryQuoteTemplateController.java index 21fa194..47075c7 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryQuoteTemplateController.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryQuoteTemplateController.java @@ -185,8 +185,14 @@ public class InquiryQuoteTemplateController { @ApiOperation("对最新 Excel 版重新提交 AI 异步解析(已解析/失败/映射未完成均可;解析中跳过)") @RequiresPermissions("inquiry:template:edit") @PostMapping("/reparse") - public Result reparse(@RequestParam String id) { - String msg = quoteTemplateService.reparseLatest(id); + public Result reparse(@RequestParam(required = false) String id, + @RequestParam(required = false) String templateId) { + // 兼容旧前端只传 id、新前端传 templateId,任一即可。 + String key = StringUtils.isNotBlank(templateId) ? templateId : id; + if (StringUtils.isBlank(key)) { + return Result.error("templateId 不能为空"); + } + String msg = quoteTemplateService.reparseLatest(key); return Result.OK(msg); } diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryQuoteTemplateService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryQuoteTemplateService.java index 0989ca7..2c7f416 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryQuoteTemplateService.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryQuoteTemplateService.java @@ -89,8 +89,11 @@ public interface IInquiryQuoteTemplateService extends IService * 终态优先:已是 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。 diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryDashboardServiceImpl.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryDashboardServiceImpl.java index 7a1fdcc..7e21429 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryDashboardServiceImpl.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryDashboardServiceImpl.java @@ -2,6 +2,8 @@ package org.jeecg.modules.inquiry.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import org.apache.commons.lang3.StringUtils; +import org.apache.shiro.SecurityUtils; +import org.jeecg.common.system.vo.LoginUser; import org.jeecg.modules.inquiry.domain.TicketStatuses; import org.jeecg.modules.inquiry.entity.InquiryStaffRole; import org.jeecg.modules.inquiry.entity.InquiryTicket; @@ -75,9 +77,11 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService { Date from = Date.from(fromDate.atStartOfDay(ZONE).toInstant()); Date to = Date.from(LocalDateTime.of(today, LocalTime.MAX).atZone(ZONE).toInstant()); - List tickets = ticketMapper.selectList(new LambdaQueryWrapper() + LambdaQueryWrapper qw = new LambdaQueryWrapper() .ge(InquiryTicket::getCreateTime, from) - .le(InquiryTicket::getCreateTime, to)); + .le(InquiryTicket::getCreateTime, to); + applyDashboardSelfOnly(qw); + List tickets = ticketMapper.selectList(qw); long total = tickets.size(); long deal = tickets.stream().filter(t -> TicketStatuses.DEAL.equals(t.getStatus())).count(); @@ -115,9 +119,11 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService { // 近 7 日趋势 + 成本:一次拉周内工单,避免 7 次 count Date weekFrom = Date.from(today.minusDays(6).atStartOfDay(ZONE).toInstant()); - List weekTickets = ticketMapper.selectList(new LambdaQueryWrapper() + LambdaQueryWrapper weekQw = new LambdaQueryWrapper() .ge(InquiryTicket::getCreateTime, weekFrom) - .le(InquiryTicket::getCreateTime, to)); + .le(InquiryTicket::getCreateTime, to); + applyDashboardSelfOnly(weekQw); + List weekTickets = ticketMapper.selectList(weekQw); List trendDates = new ArrayList<>(7); List> trend = new ArrayList<>(7); @@ -442,4 +448,72 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService { } return "sales"; } + + /** + * 账号勾选「只看自己数据」时收紧看板工单范围。 + *

+ * 口径对齐原项目:非超管且 dashboardSelfOnly=true → + * 工单 salesWecomId = 当前账号工号(企微 id)或 createBy = 登录名。 + * 超管(username=admin 或角色 admin)忽略开关。 + */ + private void applyDashboardSelfOnly(LambdaQueryWrapper 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; + } + } } diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java index 4ed7932..928a67f 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java @@ -245,8 +245,8 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl { try { InquiryQuoteTemplateVersion ver = versionMapper.selectById(versionId); @@ -279,7 +282,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl + * mergeExisting=true 时:与库中旧 mapping 补缺合并(旧字段/旧区域优先,新结果只补没有的)。 */ @Override @Transactional(rollbackFor = Exception.class) - public void applyParseResult(String versionId, boolean ok, String mappingJson, String parseMessage, String parseStatus) { + public void applyParseResult(String versionId, boolean ok, String mappingJson, String parseMessage, + String parseStatus, boolean mergeExisting) { if (StringUtils.isBlank(versionId)) { throw new IllegalArgumentException("versionId 必填"); } @@ -1032,6 +1054,20 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl 0) { + base = base + parseMessage.substring(idx); + } + } + ver.setParseMessage(base); } ver.setUpdateBy("agent"); ver.setUpdateTime(new Date()); versionMapper.updateById(ver); } + /** + * 补缺合并:旧 fields / feeRegions / routeRegions 优先;新结果只补充旧里没有的 code/id。 + * 不删旧项;mappingGaps 以新结果为准(智能体已按合并后复评时更准,此处兜底保留新 gaps)。 + */ + static JSONObject mergeMappingPreferExisting(JSONObject oldMapping, JSONObject newMapping) { + JSONObject out = new JSONObject(true); + if (newMapping != null) { + out.putAll(newMapping); + } + if (oldMapping == null) { + return out; + } + // fields:先放新的,再用旧的覆盖同名 → 旧优先 + JSONObject fields = new JSONObject(true); + JSONObject newFields = newMapping == null ? null : newMapping.getJSONObject("fields"); + JSONObject oldFields = oldMapping.getJSONObject("fields"); + if (newFields != null) { + fields.putAll(newFields); + } + if (oldFields != null) { + for (String code : oldFields.keySet()) { + Object cell = oldFields.get(code); + if (cell != null && StringUtils.isNotBlank(String.valueOf(cell))) { + fields.put(code, cell); + } + } + } + out.put("fields", fields); + out.put("feeRegions", mergeRegionsById( + oldMapping.getJSONArray("feeRegions"), + newMapping == null ? null : newMapping.getJSONArray("feeRegions"))); + out.put("routeRegions", mergeRegionsById( + oldMapping.getJSONArray("routeRegions"), + newMapping == null ? null : newMapping.getJSONArray("routeRegions"))); + // 说明性标签:齐全时清空;否则保留新的 + if (out.getJSONArray("mappingGaps") != null && out.getJSONArray("mappingGaps").isEmpty()) { + out.put("unmappedRequired", new JSONArray()); + out.put("unmappedLabels", new JSONArray()); + } + return out; + } + + /** + * 区域按 id 合并:旧 id 整段保留;新 id 追加在后。 + */ + private static JSONArray mergeRegionsById(JSONArray oldArr, JSONArray newArr) { + JSONArray out = new JSONArray(); + Set seen = new LinkedHashSet<>(); + if (oldArr != null) { + for (int i = 0; i < oldArr.size(); i++) { + JSONObject r = oldArr.getJSONObject(i); + if (r == null) { + continue; + } + String id = StringUtils.defaultString(r.getString("id")); + if (StringUtils.isNotBlank(id)) { + seen.add(id); + } + out.add(r); + } + } + if (newArr != null) { + for (int i = 0; i < newArr.size(); i++) { + JSONObject r = newArr.getJSONObject(i); + if (r == null) { + continue; + } + String id = StringUtils.defaultString(r.getString("id")); + if (StringUtils.isNotBlank(id) && seen.contains(id)) { + continue; + } + if (StringUtils.isNotBlank(id)) { + seen.add(id); + } + out.add(r); + } + } + return out; + } + /** * 把误标为 mapping_incomplete、但已有映射点的版本纠偏为 parsed。 */ @@ -1384,7 +1511,10 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl + * 口径:有 {@code mappingGaps} 字段时(含空数组)只认它——空数组即「系统判定齐全、角标 0」; + * 仅旧数据完全没有该字段时,才用 unmappedLabels / unmappedRequired 兜底。 + * 说明性 unmappedLabels 不得在齐全时抬高列表「未解析」角标。 */ private static List extractMappingGaps(String mappingJson) { if (StringUtils.isBlank(mappingJson)) { @@ -1392,19 +1522,24 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl out = new ArrayList<>(); - if (arr != null) { - for (int i = 0; i < arr.size(); i++) { - String s = arr.getString(i); - if (StringUtils.isNotBlank(s)) { - out.add(s); + // 显式有 mappingGaps 键:空也算齐全,禁止再用 AI 自报标签抬角标 + if (obj.containsKey("mappingGaps")) { + JSONArray arr = obj.getJSONArray("mappingGaps"); + if (arr != null) { + for (int i = 0; i < arr.size(); i++) { + String s = arr.getString(i); + if (StringUtils.isNotBlank(s)) { + out.add(s); + } } } - } - if (!out.isEmpty()) { return out; } + // 旧版映射无 mappingGaps:才兜底 JSONArray labels = obj.getJSONArray("unmappedLabels"); if (labels != null) { for (int i = 0; i < labels.size(); i++) { diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_sys_user_dashboard_self_only_v1.sql b/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_sys_user_dashboard_self_only_v1.sql new file mode 100644 index 0000000..ad7fe00 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_sys_user_dashboard_self_only_v1.sql @@ -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; diff --git a/inquiry-api/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysUserController.java b/inquiry-api/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysUserController.java index 1578008..7947fb9 100644 --- a/inquiry-api/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysUserController.java +++ b/inquiry-api/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysUserController.java @@ -187,6 +187,9 @@ public class SysUserController { baseCommonService.addLog("编辑用户,username: " +sysUser.getUsername() ,CommonConstant.LOG_TYPE_2, 2); if(sysUser==null) { result.error500("未找到对应实体"); + }else if("admin".equalsIgnoreCase(String.valueOf(sysUser.getUsername()))) { + // 询价后台:内置超级管理员不可被运营编辑(角色/资料/开关) + result.error500("超级管理员不可在后台编辑"); }else { SysUser user = JSON.parseObject(jsonObject.toJSONString(), SysUser.class); user.setUpdateTime(new Date()); diff --git a/inquiry-api/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/entity/SysUser.java b/inquiry-api/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/entity/SysUser.java index 7f9f457..7c72f1b 100644 --- a/inquiry-api/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/entity/SysUser.java +++ b/inquiry-api/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/entity/SysUser.java @@ -204,4 +204,11 @@ public class SysUser implements Serializable { * 流程状态 */ private String bpmStatus; + + /** + * 只看本账号看板数据:true/1 时分析看板仅含本人发起/归属工单;超级管理员忽略。 + * 对应列 dashboard_self_only;原项目同名字段。 + */ + @TableField("dashboard_self_only") + private Boolean dashboardSelfOnly; } diff --git a/inquiry-backend/src/views/inquiry/account/account.api.ts b/inquiry-backend/src/views/inquiry/account/account.api.ts index abed053..a1d89c2 100644 --- a/inquiry-backend/src/views/inquiry/account/account.api.ts +++ b/inquiry-backend/src/views/inquiry/account/account.api.ts @@ -31,6 +31,10 @@ export type AccountRow = { phoneMasked: string; lastLogin: string; status: 'active' | 'disabled'; + /** 只看本账号看板数据 */ + dashboardSelfOnly: boolean; + /** 内置超管:列表禁编、展示固定为超级管理员 */ + isSuperAdmin: boolean; }; const ROLE_TAG_CYCLE = ['tag-violet', 'tag-blue', 'tag-teal', 'tag-amber']; @@ -39,6 +43,16 @@ export function roleTagFor(index: number) { return ROLE_TAG_CYCLE[index % ROLE_TAG_CYCLE.length]; } +/** username=admin 或角色码/名含超级管理员 → 内置超管 */ +export function isSuperAdminUser(u: { username?: string; roleLabel?: string; roleCode?: string }) { + const name = String(u.username || '').trim().toLowerCase(); + if (name === 'admin') return true; + const code = String(u.roleCode || '').trim().toLowerCase(); + if (code === 'admin') return true; + const label = String(u.roleLabel || ''); + return label.includes('超级管理员'); +} + /** Jeecg status:1 正常 → active;其余视为禁用。 */ export function mapUserToAccount( u: any, @@ -46,16 +60,20 @@ export function mapUserToAccount( roleLabel = '未绑定角色', roleTag = 'tag-blue', ): AccountRow { + const username = u.username || ''; + const superAdmin = isSuperAdminUser({ username, roleLabel }); return { id: String(u.id || ''), - username: u.username || '', + username, name: u.realname || u.username || '', wecomUserId: u.workNo || u.wecomId || u.thirdId || '', roleId, - roleLabel, - roleTag, + roleLabel: superAdmin ? '超级管理员' : roleLabel, + roleTag: superAdmin ? 'tag-violet' : roleTag, phoneMasked: u.phone || '未填写', lastLogin: u.lastLoginTime || u.createTime || '—', status: Number(u.status) === 1 ? 'active' : 'disabled', + dashboardSelfOnly: Boolean(u.dashboardSelfOnly === true || u.dashboardSelfOnly === 1), + isSuperAdmin: superAdmin, }; } diff --git a/inquiry-backend/src/views/inquiry/account/index.vue b/inquiry-backend/src/views/inquiry/account/index.vue index 3f71393..e086a48 100644 --- a/inquiry-backend/src/views/inquiry/account/index.vue +++ b/inquiry-backend/src/views/inquiry/account/index.vue @@ -25,7 +25,7 @@ const loading = ref(false); const total = ref(0); const pageNo = ref(1); const pageSize = ref(10); -const roleOptions = ref<{ value: string; label: string; tag: string }[]>([]); +const roleOptions = ref<{ value: string; label: string; tag: string; roleCode?: string }[]>([]); const filters = reactive({ keyword: "", status: "" as "" | "active" | "disabled" }); const modalOpen = ref(false); const editing = ref(null); @@ -37,6 +37,8 @@ const form = reactive({ phone: "", status: "active" as AccountRow["status"], password: "", + /** 只看本账号看板数据(对齐原项目 dashboardSelfOnly) */ + dashboardSelfOnly: false, }); const modalTitle = computed(() => (editing.value ? "编辑账号" : "新建账号")); @@ -52,9 +54,11 @@ async function loadRoles() { value: String(r.id), label: r.roleName || r.roleCode || r.id, tag: roleTagFor(i), + roleCode: String(r.roleCode || ""), })); if (!form.roleId && roleOptions.value.length) { - form.roleId = roleOptions.value.find((r) => r.label.includes("运营"))?.value || roleOptions.value[0].value; + form.roleId = + roleOptions.value.find((r) => r.label.includes("运营"))?.value || roleOptions.value[0].value; } } @@ -97,6 +101,23 @@ async function loadList() { const hit = roleOptions.value.find((r) => r.value === roleId); roleLabel = hit?.label || roleLabel; roleTag = hit?.tag || roleTag; + // 用角色码辅助识别超管 + if (hit?.roleCode === "admin" || (hit?.label || "").includes("超级管理员")) { + roleLabel = hit?.label || "超级管理员"; + } + } else if (String(u.username || "").toLowerCase() === "admin") { + // 库里未绑角色时仍展示超管,并尝试选中 admin 角色以便保存时能补绑 + const adminRole = roleOptions.value.find( + (r) => r.roleCode === "admin" || r.label.includes("超级管理员"), + ); + if (adminRole) { + roleId = adminRole.value; + roleLabel = adminRole.label || "超级管理员"; + roleTag = adminRole.tag; + } else { + roleLabel = "超级管理员"; + roleTag = "tag-violet"; + } } } catch { /* 无角色仍展示账号 */ @@ -142,11 +163,16 @@ function openCreate() { phone: "", status: "active", password: "", + dashboardSelfOnly: false, }); modalOpen.value = true; } function openEdit(row: AccountRow) { + if (row.isSuperAdmin) { + message.warning("超级管理员不可在后台编辑"); + return; + } editing.value = row; Object.assign(form, { username: row.username, @@ -156,11 +182,16 @@ function openEdit(row: AccountRow) { phone: row.phoneMasked === "未填写" ? "" : row.phoneMasked, status: row.status, password: "", + dashboardSelfOnly: Boolean(row.dashboardSelfOnly), }); modalOpen.value = true; } async function save() { + if (editing.value?.isSuperAdmin) { + message.warning("超级管理员不可在后台编辑"); + return; + } if (!form.username.trim() || !form.name.trim()) { message.warning("请填写账号与姓名"); return; @@ -185,6 +216,7 @@ async function save() { phone: form.phone.trim() || undefined, status: form.status === "active" ? 1 : 2, selectedroles: form.roleId, + dashboardSelfOnly: form.dashboardSelfOnly ? 1 : 0, }; if (!editing.value) payload.password = form.password; try { @@ -321,12 +353,25 @@ function removeAccount(row: AccountRow) {

+
+ + +
@@ -466,4 +519,26 @@ function removeAccount(row: AccountRow) { align-items: center; gap: 10px; } + +.account-locked { + color: #94a3b8; + font-size: 12px; +} + +.account-self-only { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + font-size: 13px; + color: #0f172a; + cursor: pointer; +} + +.account-self-only em { + flex: 1 1 100%; + font-style: normal; + font-size: 12px; + color: #64748b; +} diff --git a/inquiry-backend/src/views/inquiry/systemSetting/index.vue b/inquiry-backend/src/views/inquiry/systemSetting/index.vue index ae4fc39..f209d76 100644 --- a/inquiry-backend/src/views/inquiry/systemSetting/index.vue +++ b/inquiry-backend/src/views/inquiry/systemSetting/index.vue @@ -129,7 +129,7 @@ async function commitHours(row) { 角色 编码 - 运输类型 + 运输分类 超时设置(小时) diff --git a/inquiry-backend/src/views/inquiry/template/index.vue b/inquiry-backend/src/views/inquiry/template/index.vue index 812fc6a..86adfae 100644 --- a/inquiry-backend/src/views/inquiry/template/index.vue +++ b/inquiry-backend/src/views/inquiry/template/index.vue @@ -81,20 +81,15 @@ const parseDetailState = reactive({ mapping: emptyMappingSummary() as MappingParseSummary, }); -/** 未解析清单:优先 mappingGaps,其次 AI 自报项 */ +/** 未解析清单:只认系统 mappingGaps;齐全(空数组)时不展示 AI 自报标签 */ const parseDetailUnparsed = computed(() => { const m = parseDetailState.mapping; - const list: string[] = []; - for (const g of m.gaps || []) { - if (g) list.push(g); + if (Array.isArray(m.gaps)) { + return (m.gaps || []).filter(Boolean).map(String); } - if (!list.length) { - for (const c of m.unmappedRequired || []) { - list.push(`字段未映射:${FIELD_LABELS[c] || c}`); - } - for (const lab of m.unmappedLabels || []) { - if (lab) list.push(`表标签未落点:${lab}`); - } + const list: string[] = []; + for (const c of m.unmappedRequired || []) { + list.push(`字段未映射:${FIELD_LABELS[c] || c}`); } return list; }); @@ -377,22 +372,22 @@ function openEdit(row: TemplateRow) { formOpen.value = true; } -/** 运营可见的未解析项:优先 mappingGaps,其次标签/必填未映射 */ +/** 运营可见的未解析项:只认系统对照清单 mappingGaps;齐全时角标为 0 */ function listMappingGaps(row: TemplateRow): string[] { - if (row.mappingGaps?.length) return [...row.mappingGaps]; - const list: string[] = []; - for (const lab of row.unmappedLabels || []) { - if (lab) list.push(`表标签未落点:${lab}`); + // 主账已带 mappingGaps(含空数组)时,不再用 AI 自报 unmapped* 抬角标 + if (Array.isArray(row.mappingGaps)) { + return row.mappingGaps.filter(Boolean).map(String); } + const list: string[] = []; for (const c of row.unmappedRequired || []) { if (c) list.push(`字段未映射:${c}`); } return list; } -/** 列表角标条数 */ +/** 列表角标条数:优先主账 unparsedHintCount(与 mappingGaps 同口径) */ function unparsedCount(row: TemplateRow): number { - if (row.unparsedHintCount && row.unparsedHintCount > 0) return row.unparsedHintCount; + if (typeof row.unparsedHintCount === "number") return Math.max(0, row.unparsedHintCount); return listMappingGaps(row).length; } @@ -439,6 +434,7 @@ async function toggleStatus(row: TemplateRow) { /** * 重新提交 AI 映射解析(已解析也可点;解析中禁用)。 + * 未换文件:主账带 mergeExisting,回写时保留已有字段只补缺。 * 副作用:主账入队;列表进入解析中并轮询。 */ async function reparseRow(row: TemplateRow) { @@ -692,7 +688,11 @@ async function downloadPreviewOrOriginal() { type="button" class="link-action" :disabled="row.parseStatus === '解析中'" - :title="row.parseStatus === '解析中' ? '解析中,请稍候' : '重新提交 AI 映射解析'" + :title=" + row.parseStatus === '解析中' + ? '解析中,请稍候' + : '重新解析:保留已有映射,只补缺;换文件请用编辑上传' + " @click.stop="reparseRow(row)" > 重新解析 diff --git a/inquiry-backend/src/views/inquiry/template/template.api.ts b/inquiry-backend/src/views/inquiry/template/template.api.ts index 2fb9ccc..7c5b0be 100644 --- a/inquiry-backend/src/views/inquiry/template/template.api.ts +++ b/inquiry-backend/src/views/inquiry/template/template.api.ts @@ -91,11 +91,18 @@ export const listTemplateVersions = (templateId: string) => export const saveTemplateMapping = (versionId: string, mappingJson: string) => defHttp.put({ url: Api.mapping, data: { versionId, mappingJson } }); +/** 非 GET 时 params 会进 JSON 体,@RequestParam 读不到,必须拼到查询串。 */ export const enableTemplate = (templateId: string, versionId: string) => - defHttp.post({ url: Api.enable, params: { templateId, versionId } }); + defHttp.post( + { url: Api.enable, params: { templateId, versionId } }, + { joinParamsToUrl: true }, + ); export const disableTemplate = (templateId: string) => - defHttp.post({ url: Api.disable, params: { templateId } }); + defHttp.post( + { url: Api.disable, params: { templateId } }, + { joinParamsToUrl: true }, + ); /** * 删除模板逻辑头及全部版本;不可恢复。 @@ -109,7 +116,10 @@ export const deleteTemplate = (templateId: string) => ); export const setTemplateDefault = (templateId: string, isDefault = true) => - defHttp.post({ url: Api.setDefault, params: { templateId, isDefault } }); + defHttp.post( + { url: Api.setDefault, params: { templateId, isDefault } }, + { joinParamsToUrl: true }, + ); /** 只改名称/业务/关键词/默认标记,不换文件;走 JSON 体对接主账 @RequestBody */ export const editTemplateMeta = (data) => defHttp.put({ url: Api.editMeta, data }); @@ -119,7 +129,7 @@ export const editTemplateMeta = (data) => defHttp.put({ url: Api.editMeta, data */ export const reparseTemplate = (templateId: string) => defHttp.post( - { url: Api.reparse, params: { id: templateId }, timeout: 60 * 1000 }, + { url: Api.reparse, params: { id: templateId, templateId }, timeout: 60 * 1000 }, { joinParamsToUrl: true }, );