diff --git a/inquiry-agent/agent/channel/quote_template_parse.py b/inquiry-agent/agent/channel/quote_template_parse.py new file mode 100644 index 0000000..413aa0e --- /dev/null +++ b/inquiry-agent/agent/channel/quote_template_parse.py @@ -0,0 +1,128 @@ +""" +报价模板 AI 映射入队路由。 + +本文件职责:校验 callback token,接收主账单元格清单,写入 recognition Stream 后快回。 +禁止:在本请求线程调千问 / 等完整 mapping;禁止直连 MySQL。 +Worker:recognition 槽认领 kind=quote_template_parse 后跑 mode_sheet_map。 +""" + +from __future__ import annotations + +import logging +from typing import Any, Optional + +from fastapi import APIRouter, Header, HTTPException, Response +from pydantic import BaseModel, Field + +from agent.config import get_settings +from agent.jobs.recognition import enqueue_recognition + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/internal/quote-template", tags=["quote-template"]) + + +class QuoteTemplateParseBody(BaseModel): + """主账提交的模板映射解析任务。""" + + version_id: str = Field(default="", alias="versionId") + template_id: str = Field(default="", alias="templateId") + template_name: str = Field(default="", alias="templateName") + file_name: str = Field(default="", alias="fileName") + biz_type: str = Field(default="", alias="bizType") + transport_mode: str = Field(default="sea", alias="transportMode") + sheet: str = "" + cell_inventory: list[Any] = Field(default_factory=list, alias="cellInventory") + cell_count: int = Field(default=0, alias="cellCount") + yellow_count: int = Field(default=0, alias="yellowCount") + truncated: bool = False + request_id: str = Field(default="", alias="requestId") + + model_config = {"populate_by_name": True} + + +def _expected_token() -> str: + s = get_settings() + return (s.agent_callback_token or s.inquiry_agent_callback_token or "").strip() + + +def _check_token(authorization: Optional[str], x_agent_token: Optional[str]) -> None: + expected = _expected_token() + if not expected: + if (get_settings().ytd_env or "").lower() == "prod": + raise HTTPException(status_code=503, detail="callback token 未配置") + logger.warning("quote-template parse:未配置 callback token,test 允许空校验") + return + provided = (x_agent_token or "").strip() + if not provided and authorization: + auth = authorization.strip() + if auth.lower().startswith("bearer "): + provided = auth[7:].strip() + else: + provided = auth + if provided != expected: + raise HTTPException(status_code=401, detail="unauthorized") + + +@router.get("/ping") +def parse_ping() -> dict[str, str]: + return {"status": "ok", "channel": "quote-template-parse"} + + +@router.post("/parse") +def enqueue_parse( + body: QuoteTemplateParseBody, + response: Response, + authorization: Optional[str] = Header(default=None), + x_agent_callback_token: Optional[str] = Header( + default=None, alias="X-Agent-Callback-Token" + ), +) -> dict[str, Any]: + """ + 接收主账解析任务:入 recognition 队列后立即 202。 + + 不在此调用千问。 + """ + _check_token(authorization, x_agent_callback_token) + version_id = (body.version_id or "").strip() + if not version_id: + raise HTTPException(status_code=400, detail="versionId 必填") + inventory = body.cell_inventory or [] + if not inventory: + raise HTTPException(status_code=400, detail="cellInventory 不能为空") + + payload = { + "versionId": version_id, + "templateId": body.template_id, + "templateName": body.template_name, + "fileName": body.file_name, + "bizType": body.biz_type, + "transportMode": (body.transport_mode or "sea").strip().lower() or "sea", + "sheet": body.sheet or "", + "cellInventory": inventory, + "cellCount": body.cell_count or len(inventory), + "yellowCount": body.yellow_count, + "truncated": bool(body.truncated), + } + result = enqueue_recognition( + kind="quote_template_parse", + payload=payload, + idempotency_key=(body.request_id or "").strip() + or f"quote-tpl-parse:{version_id}:{len(inventory)}", + ) + if not result.accepted: + # 幂等重复:视为已接受,避免主账把「重复」当成失败 + if result.reason == "duplicate_idempotency_key": + response.status_code = 202 + return {"accepted": True, "entry_id": "", "reason": "duplicate_idempotency_key"} + response.status_code = 503 + return {"accepted": False, "reason": result.reason or "enqueue_failed"} + + response.status_code = 202 + logger.info( + "quote_template.parse.enqueued versionId=%s cells=%s entry=%s", + version_id, + len(inventory), + result.entry_id, + ) + return {"accepted": True, "entry_id": result.entry_id} diff --git a/inquiry-agent/agent/http_app.py b/inquiry-agent/agent/http_app.py index 5d88152..e8277c0 100644 --- a/inquiry-agent/agent/http_app.py +++ b/inquiry-agent/agent/http_app.py @@ -16,6 +16,7 @@ from fastapi import FastAPI from agent.channel.aibot import router as aibot_router from agent.channel.bridge import router as bridge_router from agent.channel.h5 import router as h5_router +from agent.channel.quote_template_parse import router as quote_template_parse_router from agent.channel.runtime import start_channel_runtime from agent.channel.wecom.callback import router as wecom_router from agent.config import get_settings @@ -73,6 +74,7 @@ def create_app() -> FastAPI: app.include_router(h5_router) app.include_router(bridge_router) app.include_router(aibot_router) + app.include_router(quote_template_parse_router) return app diff --git a/inquiry-agent/agent/jobs/__init__.py b/inquiry-agent/agent/jobs/__init__.py index c4df2ea..7c91fb4 100644 --- a/inquiry-agent/agent/jobs/__init__.py +++ b/inquiry-agent/agent/jobs/__init__.py @@ -211,7 +211,10 @@ class WorkerRuntime: return entry_id, data = claimed[0] try: - process_recognition(data) + process_recognition( + data, + allow_network=bool(self.settings.llm_allow_network), + ) finally: stream.ack(entry_id) diff --git a/inquiry-agent/agent/jobs/recognition.py b/inquiry-agent/agent/jobs/recognition.py index bd4a64f..ae055ec 100644 --- a/inquiry-agent/agent/jobs/recognition.py +++ b/inquiry-agent/agent/jobs/recognition.py @@ -1,7 +1,9 @@ """ -识别任务壳:入队 Redis Stream,Worker HTTP 槽认领后跑占位处理。 +识别任务:入队 Redis Stream,Worker HTTP 槽认领后处理。 -本文件职责:识别入队;Worker 槽消费。kind=vision 跑私聊图片波次,kind=attachment 跑附件材料,其它 kind 仍占位。 +本文件职责:enqueue;process_recognition 按 kind 分支。 +kind=vision 跑私聊图片波次,kind=attachment 跑附件材料, +kind=quote_template_parse 跑报价模板 AI 映射;其它 kind 仍占位。 禁止:回调线程同步等识别完成;禁止本模块写主账六态。 """ @@ -41,7 +43,7 @@ def enqueue_recognition( """ 识别任务入队(快回)。 - kind 例:attachment_text / vision / sheet_map。 + kind 例:attachment / vision / quote_template_parse。 """ rt = redis or get_redis_runtime() if idempotency_key: @@ -62,14 +64,29 @@ def enqueue_recognition( return RecognitionEnqueueResult(accepted=True, entry_id=entry_id) -def process_recognition(data: dict[str, Any]) -> dict[str, Any]: +def process_recognition_stub(data: dict[str, Any]) -> dict[str, Any]: """ - Worker 槽:vision 跑图片波次,其它 kind 仍走占位壳。 + 识别占位:回显 kind,不读附件、不调模型。 + 兼容旧调用名;正式路径请用 process_recognition。 + 禁止再调本函数以免与 process_recognition 互相递归。 + """ + return process_recognition(data, allow_network=False) + + +def process_recognition(data: dict[str, Any], *, allow_network: bool = True) -> dict[str, Any]: + """ + Worker 槽:按 kind 处理识别/映射任务。 + + vision:私聊图片波次收口。 + attachment:H5 附件材料收口。 + quote_template_parse:调千问 sheet_map,再 PUT 主账 parseResult。 禁止在本函数写六态。 """ - kind = str(data.get("kind") or "") + kind = str(data.get("kind") or "unknown") payload = data.get("payload") if isinstance(data.get("payload"), dict) else {} + if not isinstance(payload, dict): + payload = {} if kind == "vision": from agent.handlers.image_inquiry import finish_queued_image @@ -78,21 +95,94 @@ def process_recognition(data: dict[str, Any]) -> dict[str, Any]: from agent.handlers.attachment_inquiry import finish_queued_attachment return finish_queued_attachment(payload) - return process_recognition_stub(data) - - -def process_recognition_stub(data: dict[str, Any]) -> dict[str, Any]: - """ - 识别占位:回显 kind,不读附件、不调模型。 - - Worker 槽调用;正式实现替换为本函数旁的真实处理器。 - """ - kind = str(data.get("kind") or "unknown") - payload = data.get("payload") or {} - logger.info("recognition.stub kind=%s keys=%s", kind, sorted(payload.keys()) if isinstance(payload, dict) else []) + if kind == "quote_template_parse": + return _process_quote_template_parse(payload, allow_network=allow_network) + logger.info( + "recognition.stub kind=%s keys=%s", + kind, + sorted(payload.keys()), + ) return { "ok": True, "stub": True, "kind": kind, "note": "识别壳:未跑真实抽取", } + + +def _process_quote_template_parse(payload: dict[str, Any], *, allow_network: bool) -> dict[str, Any]: + """跑 sheet_map 并回写主账;失败也必须回写 failed+原因。""" + from agent.config import get_settings + from agent.llm.mode_sheet_map import invoke_sheet_map + + 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) + 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] + # 运营可读摘要:点数之外固定带上未解析项 + 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"]: + preview = ";".join(mapping["mappingGaps"][:8]) + more = len(mapping["mappingGaps"]) - 8 + parse_message += f";未解析到 {len(mapping['mappingGaps'])} 项:{preview}" + if more > 0: + parse_message += f" 等共{len(mapping['mappingGaps'])}项" + body = { + "versionId": version_id, + "ok": True, + "mappingJson": mapping, + "parseStatus": "parsed", + "parseMessage": parse_message, + } + else: + err = str(result.get("error") or "AI 解析失败") + body = { + "versionId": version_id, + "ok": False, + "parseStatus": "failed", + "parseMessage": err, + } + posted = _post_parse_result(body) + logger.info( + "quote_template_parse done versionId=%s ok=%s posted=%s", + version_id, + body.get("ok"), + posted.get("ok"), + ) + return {"ok": bool(body.get("ok")), "posted": posted, "kind": "quote_template_parse"} + + +def _post_parse_result(body: dict[str, Any]) -> dict[str, Any]: + """PUT /inquiry/agent/quoteTemplate/parseResult。""" + try: + from agent.ledger import LedgerClient + + client = LedgerClient.from_settings() + resp = client.request_json( + "PUT", + "/inquiry/agent/quoteTemplate/parseResult", + json_body=body, + idempotency_key=f"parseResult:{body.get('versionId')}", + ) + if not resp.ok: + logger.warning( + "parseResult 回写失败 status=%s err=%s", + resp.status_code, + resp.error, + ) + return {"ok": False, "error": resp.error or f"http_{resp.status_code}"} + return {"ok": True, "data": resp.data} + except Exception as exc: + logger.exception("parseResult 回写异常") + return {"ok": False, "error": str(exc)} diff --git a/inquiry-agent/agent/llm/http_client.py b/inquiry-agent/agent/llm/http_client.py index a3f5e37..4d458ad 100644 --- a/inquiry-agent/agent/llm/http_client.py +++ b/inquiry-agent/agent/llm/http_client.py @@ -154,6 +154,10 @@ class LlmHttpClient: if provider == "qwen": # 识图只要可见原文。思考开着时 content 常为空,销售侧就会「认不到」。 body["enable_thinking"] = False + # 报价模板 mappingJson 体积大,显式抬高上限,降低半截 JSON + mode_val = mode.value if isinstance(mode, LlmMode) else str(mode) + if mode_val == LlmMode.SHEET_MAP.value: + body["max_tokens"] = 8192 if tools: body["tools"] = tools if tool_choice is not None: diff --git a/inquiry-agent/agent/llm/mode_sheet_map.py b/inquiry-agent/agent/llm/mode_sheet_map.py index 6cfce7e..f623bfd 100644 --- a/inquiry-agent/agent/llm/mode_sheet_map.py +++ b/inquiry-agent/agent/llm/mode_sheet_map.py @@ -1,30 +1,352 @@ """ -报价工作表映射 mode 壳。 +报价工作表映射 mode:上传时用千问产出 mappingJson。 + +本文件职责:加载映射分析提示词、拼用户消息、调千问、剥 Markdown、校验 JSON、 +对照单元格清单做缺项检测,必要时再补跑一轮。 +禁止:发明运价;在企微回调线程同步调用;出单阶段再调本 mode。 """ from __future__ import annotations -from typing import Any +import json +import logging +import re +from pathlib import Path +from typing import Any, Optional from agent.llm.modes import LlmMode +logger = logging.getLogger(__name__) + 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"), + (("特殊说明", "特别说明", "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"), + (("货好时间", "Cargo Ready"), "cargo_ready_date"), + (("贸易条款", "Terms"), "trade_terms"), + (("整柜或散货", "整柜或拼柜", "FCL or LCL"), "fcl_or_lcl"), + (("箱型", "柜量", "Type & Qty", "Cont."), "container_type_qty"), + (("起运港", "POL"), "pol"), + (("目的港", "POD"), "pod"), + (("品名", "Commodity"), "commodity"), + (("货物数量", "Quantity"), "quantity"), + (("询价日期", "Inquiry Date", "报价日期"), "quote_date"), + (("RFQ", "询价单号", "报价单编号"), "quote_no"), + (("有效期", "period"), "valid_until"), +] + +_SEA_FEE_MARKERS: list[tuple[tuple[str, ...], tuple[str, ...]]] = [ + # (清单关键词, 可匹配的 feeRegion id/title/tmsSource 子串) + ( + ("始发港费用", "Origin Charges"), + ("origin_charges", "polcost", "始发港", "origin"), + ), + ( + ("海运费用", "Ocean Freight"), + ("ocean_freight", "oceancost", "海运费用", "ocean"), + ), + ( + ("目的港费用", "Destination Charges"), + ("destination_charges", "podcost", "目的港", "destination"), + ), +] + def mode_meta() -> dict[str, Any]: return { "mode": MODE.value, "provider": "qwen", "purpose": "sheet_map", - "implemented": False, + "implemented": True, } +def _prompt_path() -> Path: + return Path(__file__).resolve().parents[1] / "quote_templates" / "prompts" / "mapping_analyzer.txt" + + +def load_system_prompt() -> str: + """加载映射分析器系统提示词(仓库内只读资源)。""" + global _PROMPT_CACHE + if _PROMPT_CACHE is not None: + return _PROMPT_CACHE + path = _prompt_path() + text = path.read_text(encoding="utf-8") + _PROMPT_CACHE = text + return text + + +def _inventory_lines(payload: dict[str, Any]) -> list[str]: + inventory = payload.get("cellInventory") or payload.get("cell_inventory") or [] + if isinstance(inventory, list): + return [str(x) for x in inventory] + return [str(inventory)] + + +def _inventory_blob(payload: dict[str, Any]) -> str: + return "\n".join(_inventory_lines(payload)) + + +def build_user_message(payload: dict[str, Any], *, gaps: list[str] | None = None) -> str: + """ + 组装用户消息:运输方式 + 文件名 + 工作表 + 单元格清单。 + gaps 非空时追加补全指令(第二轮)。 + """ + mode = str(payload.get("transportMode") or payload.get("transport_mode") or "sea").strip() + mode_cn = {"sea": "海运", "air": "空运", "land": "陆运"}.get(mode, mode) + file_name = str(payload.get("fileName") or payload.get("file_name") or "") + sheet = str(payload.get("sheet") or "") + lines = _inventory_lines(payload) + max_lines = 2200 + truncated_note = "" + if len(lines) > max_lines: + lines = lines[:max_lines] + truncated_note = f"\n(单元格清单已截断至前 {max_lines} 行)\n" + body = "\n".join(lines) + gap_block = "" + if gaps: + gap_lines = "\n".join(f"- {g}" for g in gaps) + gap_block = ( + "\n【补跑缺口】上一轮 mapping 缺少以下项,请输出完整 mappingJson(不要只回增量):\n" + f"{gap_lines}\n" + ) + return ( + f"运输方式:{mode_cn}\n" + f"模板文件名:{file_name}\n" + f"工作表名:{sheet}\n" + f"单元格清单:地址 | 原文 | 合并范围 | 填充色\n" + f"{body}" + f"{truncated_note}" + f"{gap_block}\n" + "输出前自检 fields 与 feeRegions/routeRegions 是否覆盖清单中的可见业务标签," + "再只输出 mappingJson。" + ) + + +def strip_json_fence(raw: str) -> str: + """去掉模型可能包的 Markdown 代码围栏。""" + text = (raw or "").strip() + if not text: + return "" + fence = re.match(r"^```(?:json)?\s*([\s\S]*?)\s*```$", text, re.IGNORECASE) + if fence: + return fence.group(1).strip() + # 偶发前后解释:取第一个 { 到最后一个 } + start = text.find("{") + end = text.rfind("}") + if start >= 0 and end > start: + return text[start : end + 1] + return text + + +def parse_mapping_json(raw: str) -> dict[str, Any]: + """ + 解析模型正文为 mapping 对象。 + + 成功:{ok:True, mapping:dict} + 失败:{ok:False, error:str} + """ + cleaned = strip_json_fence(raw) + if not cleaned: + return {"ok": False, "error": "模型返回空内容"} + try: + data = json.loads(cleaned) + except json.JSONDecodeError as exc: + return {"ok": False, "error": f"mappingJson 不是合法 JSON: {exc}"} + if not isinstance(data, dict): + return {"ok": False, "error": "mappingJson 不是对象"} + if not isinstance(data.get("fields"), dict): + data["fields"] = data.get("fields") if isinstance(data.get("fields"), dict) else {} + data.setdefault("schemaVersion", "quote-template-mapping-v1") + return {"ok": True, "mapping": data} + + +def assess_mapping_gaps(payload: dict[str, Any], mapping: dict[str, Any] | None) -> list[str]: + """ + 对照单元格清单,列出 mapping 明显缺口(中文说明,供补跑用户消息)。 + + 不打外网。海运:费用段标题出现则要求对应 feeRegion;常见业务标签出现则要求 fields code。 + """ + blob = _inventory_blob(payload) + if not blob.strip(): + return [] + mapping = mapping or {} + fields = mapping.get("fields") if isinstance(mapping.get("fields"), dict) else {} + field_codes = {str(k) for k in fields.keys()} + fee_regions = mapping.get("feeRegions") if isinstance(mapping.get("feeRegions"), list) else [] + fee_blob = " ".join( + str(r.get("id") or "") + + " " + + str(r.get("title") or "") + + " " + + str(r.get("tmsSource") or "") + for r in fee_regions + if isinstance(r, dict) + ).lower() + + gaps: list[str] = [] + mode = str(payload.get("transportMode") or payload.get("transport_mode") or "sea").strip().lower() + 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 labels, code in _LABEL_TO_CODE: + if code in field_codes: + continue + if any(lab in blob for lab in labels): + gaps.append(f"字段缺失:清单有「{labels[0]}」→ 应映射 {code}") + return gaps + + def invoke_shell(*, payload: dict[str, Any]) -> dict[str, Any]: + """ + 无网/单测入口:若带 assistant_json 则直接解析;否则返回未实现。 + """ + raw = payload.get("assistant_json") or payload.get("content") + if raw is not None: + parsed = parse_mapping_json(str(raw)) + parsed["stub"] = True + parsed["mode"] = MODE.value + return parsed return { "ok": False, "stub": True, "mode": MODE.value, - "error": "sheet_map_shell_not_implemented", + "error": "sheet_map_shell_needs_assistant_json_or_network", "echo_keys": sorted(payload.keys()), } + + +def _chat_once( + *, + chat: Any, + payload: dict[str, Any], + gaps: list[str] | None = None, +) -> dict[str, Any]: + messages = [ + {"role": "system", "content": load_system_prompt()}, + {"role": "user", "content": build_user_message(payload, gaps=gaps)}, + ] + result = chat(mode=MODE, messages=messages, temperature=0.0) + if not getattr(result, "ok", False): + err = str(getattr(result, "error", "") or "qwen_failed") + logger.warning("sheet_map 失败 versionId=%s err=%s", payload.get("versionId"), err) + return { + "ok": False, + "error": _humanize_llm_error(err), + "mapping": None, + "provider": getattr(result, "provider", "qwen"), + } + parsed = parse_mapping_json(str(result.content or "")) + if not parsed.get("ok"): + return { + "ok": False, + "error": str(parsed.get("error") or "解析 mapping 失败"), + "mapping": None, + "raw_preview": str(result.content or "")[:240], + } + return { + "ok": True, + "mapping": parsed["mapping"], + "provider": getattr(result, "provider", "qwen"), + } + + +def invoke_sheet_map( + payload: dict[str, Any], + *, + allow_network: bool = False, + chat_fn: Optional[Any] = None, + timeout_seconds: float = 180.0, +) -> dict[str, Any]: + """ + 调用千问产出 mappingJson;若对照清单有明显缺口,再补跑一轮(最多 2 次 chat)。 + + allow_network=False 且无 chat_fn:不打外网。 + 副作用:可能出站 HTTPS;应在 Worker HTTP 槽内调用。 + """ + if chat_fn is None and not allow_network: + return {"ok": False, "error": "sheet_map_network_disabled", "mapping": None} + + from agent.llm.http_client import LlmHttpClient + + if chat_fn is None: + client = LlmHttpClient.from_settings() + client.timeout_seconds = float(timeout_seconds) + chat = client.chat + else: + chat = chat_fn + + 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,主账/后台可直接展示「表上有、未映射上」 + if isinstance(mapping, dict): + mapping["mappingGaps"] = list(remaining) + logger.info( + "sheet_map ok versionId=%s rounds=%s fields=%s feeRegions=%s routeRegions=%s remain_gaps=%s", + payload.get("versionId"), + rounds, + len((mapping or {}).get("fields") or {}), + len((mapping or {}).get("feeRegions") or []), + len((mapping or {}).get("routeRegions") or []), + len(remaining), + ) + return { + "ok": True, + "mapping": mapping, + "provider": first.get("provider", "qwen"), + "rounds": rounds, + "gaps_after": remaining, + } + + +def _humanize_llm_error(err: str) -> str: + e = (err or "").strip() + if e == "llm_data_usage_not_confirmed": + return "未确认 LLM 数据用途(LLM_DATA_USAGE_CONFIRMED),无法调用千问" + if e == "qwen_not_configured": + return "千问未配置(QWEN_API_KEY / QWEN_BASE_URL)" + if e.startswith("http_"): + return f"千问 HTTP 失败: {e}" + return f"千问调用失败: {e[:180]}" diff --git a/inquiry-agent/agent/quote_templates/prompts/mapping_analyzer.txt b/inquiry-agent/agent/quote_templates/prompts/mapping_analyzer.txt new file mode 100644 index 0000000..9ffbb04 --- /dev/null +++ b/inquiry-agent/agent/quote_templates/prompts/mapping_analyzer.txt @@ -0,0 +1,371 @@ +你是「友通达询价 · 报价 Excel 模板映射分析器」。只在后台上传模板时调用一次。 + +════════════════════════════════════ +【使命】 +════════════════════════════════════ +根据用户提供的运输方式(海运/空运/陆运)与工作表单元格清单(地址、原文、合并、填充色),输出一份 mappingJson。 +该 JSON 经人工确认后冻结。之后「智能体出单」只按冻结映射,把「询价事实 + TMS 报价投影后的填表数据」写入 Excel,不再调用你。 + +你不是报价员:禁止编造任何运价、费目金额、币别或汇率。 + +════════════════════════════════════ +【总原则】 +════════════════════════════════════ +1. 先按用户声明的运输方式使用对应分节;同一方式下模板数量与版式均不固定——只分析当前表,禁止假设只有某几张固定样表。 +2. 系统字段白名单是天花板不是必填项:表上没有的 code 不要映射;禁止自创 code。 +3. **齐全硬约束**:单元格清单里出现的、能对上白名单的业务标签,必须写入 fields(指向可填值格)。看不清或无法落点的写入 unmappedLabels,禁止静默省略。 +4. 标签、段标题、表头文字、固定条款、公司抬头、黄底、签字盖章、账号行 → fixedCells,出单永不覆盖。 +5. fields 只指向写入值的单元格,不是标签格。 +6. 凡存在可扩展列表/矩阵,必须输出完整增行配置(grow / styleCopyRows / stopAtText),不得省略。 +7. 映射必须能承接下文「TMS 返回 → 填表投影」后的数据结构。 +8. 只输出一个 JSON,不要 Markdown 代码围栏,不要解释文章。 +9. 输出前自检:fields + feeRegions/routeRegions 是否覆盖清单中的可见业务标签与费用段标题;缺则补全后再输出。 + +════════════════════════════════════ +【一】出单数据包由两部分组成 +════════════════════════════════════ + +(1)询价事实 facts(对话/工单,不是 TMS 价) +起运港、目的港/目的地、品名、货量、整柜或拼柜、箱型箱量、件数、重量、体积、贸易条款、货好时间、报价日期/询价日期、HS、含油电磁、客户名称、联系人、电话、提送货/派送地址、特别说明等(有则带)。 + +(2)TMS 报价 data(经主账查询返回;出单前投影为 fillFacts / fee_rows / 分段费用) +顶层共通字段(海陆空响应里都可能出现): +code, message, data, traceId +data.tmsRequestId, quoteFound, quoteId +data.currency, settlementCurrency, fxRates[] +data.salePrice, costPrice, totalCostPrice, totalSalePrice +data.validUntil +data.transitDaysMin, transitDaysMax, transitDaysDisplay, estimatedDeliveryDate +data.feeItems(结构随运输方式不同,见分节) +data.landSegments / airOptions / seaDetails(按方式二选一有值) +data.chargeableWeightKg, missingFields, remark +data.isMulti(空运/陆运:1=单段或单方案,2=多段/多方案三选一等,以业务约定为准) + +系统补全:quote_no←工单号;sales_name←业务员;quote_date 空则当天。 +「询价日期」与「报价日期」→ 都映射 quote_date。 + +════════════════════════════════════ +【二】TMS 返回结构 → 填表投影(出单程序做;你的映射必须接得住) +════════════════════════════════════ + +──────── 海运 TMS(feeItems 按港段拆分)──────── +data.feeItems 为数组,元素内常见三块(可能分多个元素,按 key 识别): +- polCostItems[]:始发港费用 +- oceanCostItems[]:海运费用 +- podCostItems[]:目的港费用 +每条费用项字段: +feeName, currency, unit, unitPrice, quantity, amount, containerType + +另有 data.seaDetails[]: +segmentSequence, pol, pod, containerType, containerQty, loadType, +currency, costPrice, salePrice, etd, eta, carrierName, vessel, voyage… + +data.fxRates[]:fromCurrency, toCurrency, rate, rateTime +data.validUntil, totalCostPrice / totalSalePrice, currency, remark + +投影到填表: +- polCostItems → fee_rows(或按段 fee_rows_pol),写入「始发港费用」feeRegion +- oceanCostItems → 写入「海运费用」feeRegion +- podCostItems → 写入「目的港费用」feeRegion +- 单项投影: + name←feeName + currency←currency + unit←unit + unit_price←unitPrice + quantity←quantity + amount←amount + containerType 用于动态箱型表头 / container_rate 列 +- seaDetails[0].pol/pod/containerType/containerQty/loadType 可补强 facts 或标量 +- validUntil → valid_until;合计 → total_amount;currency → currency + +映射要求:海运模板有多段费用表时,为每段各建一条 feeRegion(id 建议 origin_charges / ocean_freight / destination_charges),stopAtText 对应该段汇总或下一段标题。 + +──────── 空运 TMS(feeItems + airOptions + departureCharges)──────── +data.isMulti:1 单方案;2 多方案(airOptions 多条,销售选一条后再出单) +data.feeItems[](空运主运费等,可空): +feeCode, feeName, segmentSequence, currency, unit, unitPrice, quantity, amount, remark, containerType + +data.airOptions[] 每条方案: +segmentSequence, optionNo, flightNo, flightSchedule, +originAirportCode, transferAirportCode(有中转才有值), destinationAirportCode, +departureTime, arrivalTime, chargeableWeightKg, +currency, costPrice, salePrice, validUntil, +departureCharges[]:起运港费用 + chargeName, unit, currency, unitPrice + (数量/金额可能出单时按计费重或板数再算;映射仍要预留 name/unit/currency/unit_price/amount 列) + +data.chargeableWeightKg, validUntil, totalSalePrice/salePrice, remark + +投影到填表: +- 选中的 airOptions[i]: + 空运费 ← feeItems 或 option.salePrice 等投影为 fee_rows(name 如「空运费」) + departureCharges → 另一组 fee_rows 或同一明细区分组「起运港」下的行 + name←chargeName 或 feeName + unit_price←unitPrice;unit←unit;currency←currency +- chargeableWeightKg → chargeable_weight_kg +- originAirportCode/destinationAirportCode → 可对应 pol/pod(机场语境) +- transferAirportCode → 可 notes 或 special_remarks,白名单无专用 code 则 unmappedLabels +- validUntil → valid_until;合计 → total_amount + +映射要求: +- 报价明细 feeRegion:stopAtText 含「预估费用」;sectionRows 含「空运段/起运港/目的港/其他」 +- 若表上起运港费用与空运费分区,可用两条 feeRegion,或一条 + sectionRows;列必须能写 departureCharges 与空运费 + +──────── 陆运 TMS(landSegments)──────── +data.isMulti:1 单方案(一段或多腿);2 多方案(多组 segmentSequence,三选一等) +data.feeItems 可为空数组 +data.landSegments[] 每条: +segmentSequence(方案号), routeLegSequence(腿序号), +origin, originCode, destination, destinationCode, +landTransportType, routeCategory, vehicleType, +currency, costPrice, salePrice, transitDays, remark + +投影到填表: +- 选中某个 segmentSequence 下的各 routeLeg: + 每一腿 → routeRegions 的一行(或合并展示策略由出单程序定,映射按「一行一个 OD」预留) + origin←origin;destination←destination + 价格写入 vehicleType 对应列(如 3T → 车型列),金额优先 salePrice,否则 costPrice + currency←currency;transit_time←transitDays;remark←remark +- landTransportType 仅辅助,不单独强映 + +映射要求:主报价用 routeRegions(禁止把线路矩阵硬套成 fee_rows 竖表);车型列表头与 vehicleType 能对上;其它费用竖表另建 feeRegion。 + +════════════════════════════════════ +【三】出单赋值(不在本提示词展开) +════════════════════════════════════ +上传分析阶段你只输出 mappingJson,不负责真正写 Excel。 +智能体出单时如何:合成 fillFacts、按 fields/feeRegions/routeRegions 写入、增行、动态箱型——见同目录独立文件: +「智能体出单-填表赋值-提示词.txt」 +你必须保证 mapping 含完整 grow/styleCopyRows/stopAtText/tmsSource(或可识别的 id/title),以便该出单流程可执行。 + +════════════════════════════════════ +【四】系统字段白名单(禁止自创) +════════════════════════════════════ +customer_name ← 客户名称、客户 +contact_name ← 联系人、承运联系人 +contact_phone ← 电话 +quote_no ← 报价单编号、报价编号、RFQ No +quote_date ← 报价日期、询价日期、Inquiry Date +valid_until ← 有效期至、报价有效期、period(← TMS validUntil) +currency ← 币别、币种(← TMS currency / settlementCurrency) +sales_name ← 业务员 +pol ← 起运港、POL、起运机场码语境 +pod ← 目的港、POD、目的机场码语境 +origin ← 始发地、起始地、始发站(← landSegments.origin) +destination ← 目的地(← landSegments.destination) +commodity ← 品名 +quantity ← 货物数量、货量 +container_type_qty ← 箱型箱量(← seaDetails.containerType+Qty / 费用项 containerType) +fcl_or_lcl ← 整柜或拼柜(← seaDetails.loadType) +trade_terms ← 贸易条款 +cargo_ready_date ← 货好时间 +pieces ← 件数 +weight_kg ← 重量、毛重 +volume_cbm ← 体积 +chargeable_weight_kg ← 计费重(← TMS chargeableWeightKg / airOptions) +delivery_address ← 派送地址、送货地址 +pickup_address ← 提货地址 +transit_time ← 时效、航程时间(← transitDays / transitDaysDisplay) +cargo_value ← 货值 +hs_code ← HS、海关编码 +oil_elec_mag ← 是否含油,电,磁 +has_battery ← 是否带电、是否含电 +on_board_date ← 开船日 +special_remarks ← 特别说明、其他要求(← 可含 TMS remark 摘要) +eta_request ← 要求货物抵达时间 +sailing_schedule ← 船期/航班班期(← flightSchedule 等,有值区才映) +shipping_line ← 船公司/航司(← carrierName / airlineName,有值区才映) +flight_no ← 航班号(← airOptions.flightNo;表无则 unmapped) +transfer_airport ← 中转港(← transferAirportCode;表无则 unmappedLabels) +total_amount ← 费用总计、预估费用、报价合计(← totalSalePrice/totalCostPrice/salePrice) +effective_date ← 生效时间 +fuel_surcharge_rate ← 燃油附加费率 +tax_rate ← 税率 + +【标量写入格】 +标签合并区右侧第一个非标签、非表头、非段标题可填格;优先宽合并空区。 +同格冒号后改值 → fieldMeta.sameCellAfterColon=true。 + +【fee_rows 列】 +name, currency, unit, quantity, unit_price, amount, remark, container_rate(动态箱型列) + +════════════════════════════════════ +【五】增行配置总表(海陆空必填) +════════════════════════════════════ +grow 固定 "insert_before_stop";styleCopyRows 固定 1。 + +| 方式 | 区域 | 增什么 | 典型 stopAtText | +| 海运 | 每段 feeRegion | 费目行 | 该段汇总 / 下一段标题 / 费用总计 | +| 空运 | 报价明细 feeRegion | 费目行(含起运港费用行) | 预估费用 | +| 陆运 | routeRegion | 线路行(多腿/多段投影后的行) | 项目二 / 交易条件说明 | +| 陆运 | 其它费用 feeRegion | 费目行 | 交易条件说明等 | + +════════════════════════════════════ +【六】海运映射要点(transportMode=sea) +════════════════════════════════════ +- layoutType:sea_fee_list 或 fee_list_vertical +- **强制**:清单出现「始发港费用 / Origin Charges」「海运费用 / Ocean Freight」「目的港费用 / Destination Charges」时,必须各建一条 feeRegion(id 建议 origin_charges / ocean_freight / destination_charges),分别承接 polCostItems / oceanCostItems / podCostItems。禁止只交标量、漏掉费用区。 +- 箱型占位表头 → dynamicContainerHeader=true + container_rate 列(承接 containerType) +- 每条 feeRegion 必有 grow、styleCopyRows、stopAtText +- routeRegions 一般 [] +- 标量须尽量齐全(表上有则必映射):quote_date, quote_no, pol, pod, commodity, quantity, container_type_qty, fcl_or_lcl, trade_terms, cargo_ready_date, cargo_value, hs_code, oil_elec_mag, pickup_address, delivery_address, eta_request, transit_time, sailing_schedule, on_board_date, shipping_line, special_remarks, valid_until, total_amount, currency… + +════════════════════════════════════ +【七】空运映射要点(transportMode=air) +════════════════════════════════════ +- layoutType:air_fee_list 或 fee_list_vertical +- 一条或分段 feeRegion;sectionRows=空运段/起运港/目的港/其他 +- stopAtText 必含「预估费用」;必有 grow、styleCopyRows +- 列需同时能写:空运费(feeItems)+ 起运港费用(departureCharges.chargeName…) +- dynamicContainerHeader=false;routeRegions=[] +- 标量优先:customer_name, quote_no, quote_date, valid_until, pol, pod, commodity, has_battery, pieces, weight_kg, volume_cbm, chargeable_weight_kg, trade_terms, cargo_ready_date, transit_time, delivery_address, total_amount, flight_no… +- 多方案(isMulti=2)不在映射阶段展开多份表;映射按「最终采用的一条 airOption + 其 departureCharges」能写满即可 + +════════════════════════════════════ +【八】陆运映射要点(transportMode=land) +════════════════════════════════════ +- 主报价用 routeRegions(origin/destination/vehicleRates),承接 landSegments +- vehicleRates 列名尽量能与 vehicleType(如 3T、40HQ)对应或可模糊匹配 +- 每条 routeRegion 必有 grow、styleCopyRows、stopAtText(项目二/交易条件说明) +- 其它费用竖表 → feeRegion + grow +- layoutType:route_matrix | mixed | fee_list_vertical +- 标量优先:customer_name, contact_name, contact_phone, quote_date, quote_no, currency, origin, destination, trade_terms, effective_date, fuel_surcharge_rate, tax_rate, transit_time, total_amount… +- 多方案(isMulti=2)映射按「最终采用的一组 segmentSequence」能写入线路行即可 + +════════════════════════════════════ +【九】输出 JSON +════════════════════════════════════ +只输出一个对象: + +{ + "schemaVersion": "quote-template-mapping-v1", + "transportMode": "sea", + "sheet": "工作表名", + "layoutType": "sea_fee_list", + "fixedCells": ["Sheet!A4"], + "fields": { + "quote_date": "Sheet!B4", + "pol": "Sheet!H4", + "valid_until": "Sheet!B16", + "total_amount": "Sheet!C44", + "currency": "Sheet!B18" + }, + "fieldMeta": { + "quote_no": { "sameCellAfterColon": true } + }, + "feeRegions": [ + { + "id": "origin_charges", + "title": "始发港费用/Origin Charges", + "tmsSource": "polCostItems", + "headerRow": 18, + "anchor": "Sheet!A19", + "stopAtText": ["始发港费用汇总", "海运费用"], + "sectionRows": [], + "columns": { + "name": "A", + "currency": "B", + "container_rate": "C", + "unit": "F", + "quantity": "G", + "amount": "H", + "remark": "I" + }, + "dynamicContainerHeader": true, + "styleCopyRows": 1, + "grow": "insert_before_stop" + }, + { + "id": "ocean_freight", + "title": "海运费用/Ocean Freight", + "tmsSource": "oceanCostItems", + "headerRow": 30, + "anchor": "Sheet!A31", + "stopAtText": ["海运费用汇总", "目的港费用"], + "sectionRows": [], + "columns": { + "name": "A", + "currency": "B", + "container_rate": "C", + "unit": "F", + "quantity": "G", + "amount": "H", + "remark": "I" + }, + "dynamicContainerHeader": true, + "styleCopyRows": 1, + "grow": "insert_before_stop" + }, + { + "id": "destination_charges", + "title": "目的港费用/Destination Charges", + "tmsSource": "podCostItems", + "headerRow": 35, + "anchor": "Sheet!A36", + "stopAtText": ["目的港费用汇总", "费用总计"], + "sectionRows": [], + "columns": { + "name": "A", + "currency": "B", + "container_rate": "C", + "unit": "F", + "quantity": "G", + "amount": "H", + "remark": "I" + }, + "dynamicContainerHeader": true, + "styleCopyRows": 1, + "grow": "insert_before_stop" + } + ], + "routeRegions": [], + "unmappedRequired": [], + "unmappedLabels": [], + "confidence": 0.85, + "notes": [] +} + +空运 feeRegion 建议增加 tmsSource 可选值:feeItems | departureCharges | mixed +陆运 routeRegion 建议 notes 或字段标明 tmsSource=landSegments + +陆运 routeRegions 示例: +{ + "id": "route_block_1", + "title": "项目一", + "tmsSource": "landSegments", + "headerRow": 8, + "dataStartRow": 10, + "stopAtText": ["项目二", "交易条件说明"], + "columns": { + "seq": "B", + "origin": "C", + "destination": "D", + "vehicleRates": { "3T": "E", "5T": "F", "8T": "G" }, + "currency": "J", + "transit_time": "N", + "remark": "L" + }, + "grow": "insert_before_stop", + "styleCopyRows": 1 +} + +layoutType:sea_fee_list | air_fee_list | fee_list_vertical | route_matrix | mixed | other +transportMode:sea | air | land + +【硬约束】 +1. 禁止输出价格当业务真相;样例/JSON 里的金额只用于理解字段,不写入 mapping 默认价。 +2. fields 必须是「工作表名!单元格」;列表列用列字母。 +3. 每个 feeRegion/routeRegion 必须含 grow、styleCopyRows、可定位的 stopAtText。 +4. 海运多段费用表必须能区分 pol/ocean/pod 与 TMS 三块 CostItems 的对应关系(tmsSource 或 id/title 能让出单程序识别);清单有三段标题则 feeRegions 至少 3 条。 +5. 空运必须能承接 departureCharges + 空运费;陆运主表必须用 routeRegions 承接 landSegments。 +6. 看不清 → unmappedRequired / unmappedLabels;表上有、能对上白名单却未映射 → 视为错误,必须补进 fields。 +7. 用户未声明运输方式时推断 transportMode,并在 notes 说明。 + +【用户消息将提供】 +运输方式:海运 | 空运 | 陆运 +模板文件名、工作表名 +单元格清单:地址 | 原文 | 合并范围 | 填充色 +(可选)TMS 样例或出单投影说明——仅对齐字段 +(补跑时)缺口清单:请据此补全整份 mappingJson,不要只回增量。 + +输出前自检 fields 与 feeRegions/routeRegions 是否覆盖清单中的可见业务标签,再只输出 mappingJson。 diff --git a/inquiry-agent/scripts/smoke_ai_parse_sea.py b/inquiry-agent/scripts/smoke_ai_parse_sea.py new file mode 100644 index 0000000..0c9be44 --- /dev/null +++ b/inquiry-agent/scripts/smoke_ai_parse_sea.py @@ -0,0 +1,122 @@ +# -*- coding: utf-8 -*- +""" +联调冒烟:用桌面海运样表抽单元格清单 → 真网千问 sheet_map。 +不经主账 HTTP(本机 8180 未起);验证「提示词+模型」能否产出合法 mappingJson。 +""" +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DESK = Path(r"C:/Users/Administrator/Desktop/海陆空报价单模板最新-0917/海陆空报价单模板最新-0917") + + +def load_dotenv(path: Path) -> None: + if not path.is_file(): + return + for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): + s = line.strip() + if not s or s.startswith("#") or "=" not in s: + continue + k, v = s.split("=", 1) + os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'")) + + +def build_inventory_openpyxl(xlsx: Path, max_lines: int = 1800) -> dict: + import openpyxl + + wb = openpyxl.load_workbook(xlsx, data_only=False) + ws = wb.active + yellow_approx = 0 + lines: list[str] = [] + merges = {} + for rng in ws.merged_cells.ranges: + origin = f"{openpyxl.utils.get_column_letter(rng.min_col)}{rng.min_row}" + merges[origin] = str(rng) + for row in ws.iter_rows(max_row=120, max_col=20): + for cell in row: + val = cell.value + text = "" if val is None else str(val).strip() + fill = "" + try: + fg = cell.fill.fgColor + if fg and fg.rgb and isinstance(fg.rgb, str) and fg.rgb not in ("00000000", "0"): + fill = fg.rgb[-6:].upper() + if fill.startswith("FF") and len(fg.rgb) >= 8: + fill = fg.rgb[-6:].upper() + if fill in {"FFFF00", "FFEB9C", "FFC000", "FFFF99", "FFCC00", "FFD966", "FFF2CC", "FFE699"} or ( + len(fill) == 6 and int(fill[0:2], 16) > 200 and int(fill[2:4], 16) > 180 and int(fill[4:6], 16) < 160 + ): + yellow_approx += 1 + except Exception: + pass + addr = cell.coordinate + merge = merges.get(addr, "-") + if not text and merge == "-" and not fill: + continue + safe = text.replace("|", "/")[:120] + lines.append(f"{ws.title}!{addr} | {safe} | {merge} | {fill or '-'}") + if len(lines) >= max_lines: + break + if len(lines) >= max_lines: + break + return { + "sheet": ws.title, + "cellInventory": lines, + "cellCount": len(lines), + "yellowCount": yellow_approx, + "fileName": xlsx.name, + "transportMode": "sea", + "versionId": "smoke-local", + } + + +def main() -> int: + load_dotenv(ROOT.parent / ".env") + # 本脚本强制允许出站,否则 Worker 配置会挡掉真网 + os.environ["LLM_ALLOW_NETWORK"] = "true" + os.environ["LLM_DATA_USAGE_CONFIRMED"] = "1" + sys.path.insert(0, str(ROOT)) + + sea = DESK / "海运报价单模板.xlsx" + if not sea.is_file(): + print("FAIL missing", sea) + return 2 + payload = build_inventory_openpyxl(sea, max_lines=800) + print( + f"inventory sheet={payload['sheet']} cells={payload['cellCount']} yellow≈{payload['yellowCount']}", + flush=True, + ) + + from agent.llm.mode_sheet_map import invoke_sheet_map + + print("calling qwen sheet_map …", flush=True) + result = invoke_sheet_map(payload, allow_network=True, timeout_seconds=90.0) + if not result.get("ok"): + print("FAIL AI", result.get("error")) + if result.get("raw_preview"): + print("raw_preview", result["raw_preview"][:300]) + return 1 + mapping = result["mapping"] + fields = mapping.get("fields") or {} + fees = mapping.get("feeRegions") or [] + routes = mapping.get("routeRegions") or [] + print("OK AI mapping") + print(" transportMode=", mapping.get("transportMode")) + print(" layoutType=", mapping.get("layoutType")) + print(" fields=", len(fields), sorted(fields.keys())[:12]) + print(" feeRegions=", len(fees)) + print(" routeRegions=", len(routes)) + print(" unmappedRequired=", mapping.get("unmappedRequired")) + print(" confidence=", mapping.get("confidence")) + out = ROOT / "scripts" / "_last_sea_mapping.json" + out.write_text(json.dumps(mapping, ensure_ascii=False, indent=2), encoding="utf-8") + print("saved", out) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/inquiry-agent/scripts/smoke_quote_template_ai.py b/inquiry-agent/scripts/smoke_quote_template_ai.py new file mode 100644 index 0000000..bd5dd40 --- /dev/null +++ b/inquiry-agent/scripts/smoke_quote_template_ai.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +"""本地冒烟:sheet_map 解析 + 桌面 8 张样表可读。不打千问。""" +from __future__ import annotations + +import importlib.util +import sys +import types +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DESK = Path(r"C:/Users/Administrator/Desktop/海陆空报价单模板最新-0917/海陆空报价单模板最新-0917") + + +def load_sheet_map(): + llm_mode = types.ModuleType("agent.llm.modes") + + class LlmMode: + class SHEET_MAP: + value = "sheet_map" + + llm_mode.LlmMode = LlmMode + sys.modules.setdefault("agent", types.ModuleType("agent")) + sys.modules.setdefault("agent.llm", types.ModuleType("agent.llm")) + sys.modules["agent.llm.modes"] = llm_mode + path = ROOT / "agent" / "llm" / "mode_sheet_map.py" + spec = importlib.util.spec_from_file_location("mode_sheet_map", path) + mod = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(mod) + return mod + + +def main() -> None: + mod = load_sheet_map() + out = mod.parse_mapping_json('{"fields":{"quote_no":"S!A1"},"feeRegions":[]}') + assert out["ok"], out + fenced = mod.strip_json_fence('```json\n{"a":1}\n```') + assert '"a"' in fenced or "a" in fenced + msg = mod.build_user_message( + { + "transportMode": "land", + "fileName": "陆运.xlsx", + "sheet": "Sheet1", + "cellInventory": ["Sheet1!A1 | 客户 | - | FFFF00"], + } + ) + assert "陆运" in msg + prompt = ROOT / "agent" / "quote_templates" / "prompts" / "mapping_analyzer.txt" + assert prompt.is_file() and prompt.stat().st_size > 1000, prompt + print("sheet_map helpers OK") + + assert DESK.is_dir(), f"missing desktop dir: {DESK}" + files = sorted(list(DESK.glob("*.xlsx")) + list(DESK.glob("*.xlsx.xlsx"))) + assert len(files) >= 8, f"expected >=8 templates, got {len(files)}" + try: + import openpyxl + except ImportError: + print(f"desktop files present: {len(files)} (openpyxl not installed, skip cell count)") + for f in files: + print(" -", f.name) + return + for f in files: + wb = openpyxl.load_workbook(f, data_only=False) + ws = wb.active + n = sum(1 for row in ws.iter_rows(max_row=100, max_col=24) for c in row if c.value is not None) + print(f"{f.name}: sheet={ws.title} nonempty≈{n}") + print("desktop 8 templates readable OK") + + +if __name__ == "__main__": + main() diff --git a/inquiry-agent/tests/test_sheet_map_mode.py b/inquiry-agent/tests/test_sheet_map_mode.py new file mode 100644 index 0000000..c40028f --- /dev/null +++ b/inquiry-agent/tests/test_sheet_map_mode.py @@ -0,0 +1,150 @@ +""" +报价模板 sheet_map:JSON 剥离、缺项检测、补跑单测(不打外网)。 +""" + +from __future__ import annotations + +import unittest +from types import SimpleNamespace + +from agent.llm.mode_sheet_map import ( + assess_mapping_gaps, + build_user_message, + invoke_sheet_map, + parse_mapping_json, + strip_json_fence, +) + + +class TestSheetMapParse(unittest.TestCase): + def test_strip_fence(self) -> None: + raw = '```json\n{"schemaVersion":"quote-template-mapping-v1","fields":{}}\n```' + self.assertIn("schemaVersion", strip_json_fence(raw)) + + def test_parse_ok(self) -> None: + raw = '{"schemaVersion":"quote-template-mapping-v1","fields":{"quote_no":"S!A1"},"feeRegions":[]}' + out = parse_mapping_json(raw) + self.assertTrue(out["ok"]) + self.assertEqual(out["mapping"]["fields"]["quote_no"], "S!A1") + + def test_parse_invalid(self) -> None: + out = parse_mapping_json("not-json") + self.assertFalse(out["ok"]) + + def test_user_message_contains_inventory(self) -> None: + msg = build_user_message( + { + "transportMode": "sea", + "fileName": "海运报价单模板.xlsx", + "sheet": "Sheet1", + "cellInventory": ["Sheet1!A1 | 报价日期 | - | FFFF00"], + } + ) + self.assertIn("海运", msg) + self.assertIn("Sheet1!A1", msg) + self.assertIn("自检", msg) + + def test_user_message_gaps_block(self) -> None: + msg = build_user_message( + { + "transportMode": "sea", + "fileName": "x.xlsx", + "sheet": "S", + "cellInventory": ["S!A1 | x | - | -"], + }, + gaps=["费用区缺失:始发港费用"], + ) + self.assertIn("补跑缺口", msg) + self.assertIn("始发港费用", msg) + + def test_assess_gaps_sea_fee_and_fields(self) -> None: + payload = { + "transportMode": "sea", + "cellInventory": [ + "海运询价单!A18 | 始发港费用/Origin Charges | - | -", + "海运询价单!A30 | 海运费用/Ocean Freight | - | -", + "海运询价单!A35 | 目的港费用/Destination Charges | - | -", + "海运询价单!E6 | 提货地址/Place of Receipt | - | -", + "海运询价单!A11 | 货值/Cargo Value | - | -", + ], + } + mapping = { + "fields": {"quote_no": "海运询价单!B3"}, + "feeRegions": [], + } + gaps = assess_mapping_gaps(payload, mapping) + self.assertTrue(any("始发港费用" in g for g in gaps)) + self.assertTrue(any("海运费用" in g for g in gaps)) + self.assertTrue(any("目的港费用" in g for g in gaps)) + self.assertTrue(any("pickup_address" in g for g in gaps)) + self.assertTrue(any("cargo_value" in g for g in gaps)) + + def test_assess_gaps_complete_empty(self) -> None: + payload = { + "transportMode": "sea", + "cellInventory": [ + "S!A18 | 始发港费用/Origin Charges | - | -", + "S!E6 | 提货地址 | - | -", + ], + } + mapping = { + "fields": {"pickup_address": "S!H6"}, + "feeRegions": [ + { + "id": "origin_charges", + "title": "始发港费用", + "tmsSource": "polCostItems", + "stopAtText": ["x"], + "anchor": "S!A19", + } + ], + } + self.assertEqual(assess_mapping_gaps(payload, mapping), []) + + def test_invoke_retries_when_gaps(self) -> None: + calls: list[str] = [] + + def fake_chat(*, mode, messages, temperature=0.0): # noqa: ARG001 + calls.append(messages[-1]["content"]) + if len(calls) == 1: + content = ( + '{"schemaVersion":"quote-template-mapping-v1",' + '"fields":{"quote_no":"S!B3"},"feeRegions":[]}' + ) + 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}]}' + ) + return SimpleNamespace(ok=True, content=content, provider="stub", error="") + + payload = { + "versionId": "v1", + "transportMode": "sea", + "fileName": "sea.xlsx", + "sheet": "S", + "cellInventory": [ + "S!A18 | 始发港费用/Origin Charges | - | -", + "S!E6 | 提货地址/Place of Receipt | - | -", + "S!A11 | 货值/Cargo Value | - | -", + ], + } + out = invoke_sheet_map(payload, chat_fn=fake_chat, allow_network=False) + self.assertTrue(out["ok"]) + self.assertEqual(out.get("rounds"), 2) + self.assertEqual(len(calls), 2) + self.assertIn("补跑缺口", calls[1]) + self.assertIn("pickup_address", out["mapping"]["fields"]) + self.assertEqual(len(out["mapping"]["feeRegions"]), 1) + self.assertIn("mappingGaps", out["mapping"]) + self.assertIsInstance(out["mapping"]["mappingGaps"], list) + # 第二轮已补齐清单内缺口 → mappingGaps 为空且与 gaps_after 一致 + self.assertEqual(out.get("gaps_after"), out["mapping"]["mappingGaps"]) + self.assertEqual(out["mapping"]["mappingGaps"], []) + + +if __name__ == "__main__": + unittest.main() 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 b38b8e0..1cf9fe2 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 @@ -1,5 +1,6 @@ package org.jeecg.modules.inquiry.controller; +import com.alibaba.fastjson.JSON; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import lombok.extern.slf4j.Slf4j; @@ -46,6 +47,34 @@ public class InquiryAgentController { @Value("${inquiry.agent.biz-token:${BIZ_API_TOKEN:}}") private String bizToken; + @ApiOperation("智能体-回写模板 AI 映射结果") + @PutMapping("/quoteTemplate/parseResult") + public Result quoteTemplateParseResult(@RequestBody Map body, + HttpServletRequest http) { + if (!authed(http)) { + return Result.error(401, "unauthorized"); + } + String versionId = body == null ? null : String.valueOf(body.getOrDefault("versionId", "")); + if (StringUtils.isBlank(versionId) || "null".equalsIgnoreCase(versionId)) { + return Result.error("versionId 必填"); + } + boolean ok = !(body.containsKey("ok") && Boolean.FALSE.equals(body.get("ok"))); + if (body.get("ok") instanceof String) { + ok = !"false".equalsIgnoreCase(String.valueOf(body.get("ok"))); + } + Object mappingObj = body.get("mappingJson"); + String mappingJson = mappingObj == null ? null + : (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")); + try { + quoteTemplateService.applyParseResult(versionId, ok, mappingJson, parseMessage, parseStatus); + return Result.OK("ok"); + } catch (IllegalArgumentException e) { + return Result.error(e.getMessage()); + } + } + @ApiOperation("建单(每次新开一单,相同需求不复用)") @PostMapping("/ticket/create") public Result> create(@RequestBody AgentTicketCreateRequest req, diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryDashboardController.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryDashboardController.java index 51f4a18..7d2f7d1 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryDashboardController.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryDashboardController.java @@ -3,7 +3,6 @@ package org.jeecg.modules.inquiry.controller; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import lombok.extern.slf4j.Slf4j; -import org.apache.shiro.authz.annotation.RequiresPermissions; import org.jeecg.common.api.vo.Result; import org.jeecg.modules.inquiry.service.IInquiryDashboardService; import org.springframework.beans.factory.annotation.Autowired; @@ -27,7 +26,6 @@ public class InquiryDashboardController { private IInquiryDashboardService dashboardService; @ApiOperation("看板指标") - @RequiresPermissions("inquiry:dashboard:view") @GetMapping("/metrics") public Result> metrics(@RequestParam(name = "range", defaultValue = "today") String range) { return Result.OK(dashboardService.metrics(range)); 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 6ef2290..21fa194 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 @@ -33,7 +33,7 @@ import java.util.Map; *

* 路径 /inquiry/quoteTemplate;权限 inquiry:template:*。 * 智能体只读列表走 /agent/listEnabled(服务账号 token)。 - * 禁止:在本 Controller 调 LibreOffice / LLM。 + * 禁止:在本 Controller 调 LLM;LibreOffice 仅允许预览截图接口短等本机 soffice。 */ @Slf4j @Api(tags = "询价-报价模板") @@ -57,7 +57,7 @@ public class InquiryQuoteTemplateController { } @AutoLog(value = "报价模板-上传版本") - @ApiOperation("上传新模板或追加新版本(解析黄区并自动映射)") + @ApiOperation("上传新模板或追加新版本(Excel 异步 AI 解析映射)") @RequiresPermissions("inquiry:template:upload") @PostMapping("/upload") public Result> upload(@RequestParam(required = false) String templateId, @@ -118,6 +118,18 @@ public class InquiryQuoteTemplateController { return Result.OK("已停用"); } + @AutoLog(value = "报价模板-删除") + @ApiOperation("删除模板逻辑头及全部版本(不可恢复)") + @RequiresPermissions("inquiry:template:edit") + @PostMapping("/delete") + public Result delete(@RequestParam String templateId) { + if (StringUtils.isBlank(templateId)) { + return Result.error("templateId 不能为空"); + } + quoteTemplateService.deleteTemplate(templateId, currentUser()); + return Result.OK("已删除"); + } + @AutoLog(value = "报价模板-设默认") @RequiresPermissions("inquiry:template:edit") @PostMapping("/setDefault") @@ -134,7 +146,7 @@ public class InquiryQuoteTemplateController { return Result.OK(quoteTemplateService.getById(id)); } - @ApiOperation("编辑逻辑头(名称/业务/关键词/模式,不换文件)") + @ApiOperation("编辑逻辑头(名称/业务/关键词/模式/默认标记,不换文件)") @RequiresPermissions("inquiry:template:edit") @PutMapping("/editMeta") public Result editMeta(@RequestBody InquiryQuoteTemplate body) { @@ -160,12 +172,33 @@ public class InquiryQuoteTemplateController { db.setUpdateBy(currentUser()); db.setUpdateTime(new java.util.Date()); quoteTemplateService.updateById(db); - if (body.getIsDefault() != null && body.getIsDefault() == 1) { - quoteTemplateService.setDefault(db.getId(), true, currentUser()); + // 勾选/取消「默认模板」都要落到主账;只处理 ==1 会导致取消勾选保存后仍是默认。 + if (body.getIsDefault() != null) { + quoteTemplateService.setDefault(db.getId(), body.getIsDefault() == 1, currentUser()); } + // 上次上传已进对象存储但解析失败时,保存基本信息顺带补解析,运营不必再选一遍文件。 + quoteTemplateService.reparseLatestIfFailed(db.getId()); return Result.OK("已保存"); } + @AutoLog(value = "报价模板-重新解析") + @ApiOperation("对最新 Excel 版重新提交 AI 异步解析(已解析/失败/映射未完成均可;解析中跳过)") + @RequiresPermissions("inquiry:template:edit") + @PostMapping("/reparse") + public Result reparse(@RequestParam String id) { + String msg = quoteTemplateService.reparseLatest(id); + return Result.OK(msg); + } + + @AutoLog(value = "报价模板-纠偏映射未完成") + @ApiOperation("把已有映射点但仍标「映射未完成」的版本改为已解析") + @RequiresPermissions("inquiry:template:edit") + @PostMapping("/repairIncompleteParse") + public Result repairIncompleteParse() { + int n = quoteTemplateService.repairIncompleteParseStatus(); + return Result.OK("已纠偏 " + n + " 个版本"); + } + @ApiOperation("样例回填预览文件下载(xlsx)") @RequiresPermissions("inquiry:template:view") @GetMapping("/previewSample") @@ -185,6 +218,28 @@ public class InquiryQuoteTemplateController { .body(new FileSystemResource(path)); } + @ApiOperation("Excel 样式表格预览(上传原文 + 黄区/映射标注)") + @RequiresPermissions("inquiry:template:view") + @GetMapping("/previewSheet") + public Result> previewSheet(@RequestParam String versionId) throws Exception { + return Result.OK(quoteTemplateService.buildSheetPreview(versionId)); + } + + @ApiOperation("原表截图预览(本机 LibreOffice 转 PNG/PDF,非 AI 画格子)") + @RequiresPermissions("inquiry:template:view") + @GetMapping("/previewSnapshot") + public ResponseEntity previewSnapshot(@RequestParam String versionId) throws Exception { + Path path = quoteTemplateService.buildSnapshotPreview(versionId); + String name = path.getFileName().toString().toLowerCase(); + MediaType media = name.endsWith(".png") + ? MediaType.IMAGE_PNG + : MediaType.APPLICATION_PDF; + return ResponseEntity.ok() + .contentType(media) + .header(HttpHeaders.CACHE_CONTROL, "private, max-age=3600") + .body(new FileSystemResource(path)); + } + @ApiOperation("下载原模板文件") @RequiresPermissions("inquiry:template:view") @GetMapping("/download") diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryResponseTimeoutController.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryResponseTimeoutController.java new file mode 100644 index 0000000..5727fe3 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryResponseTimeoutController.java @@ -0,0 +1,110 @@ +package org.jeecg.modules.inquiry.controller; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.apache.shiro.SecurityUtils; +import org.jeecg.common.api.vo.Result; +import org.jeecg.common.aspect.annotation.AutoLog; +import org.jeecg.common.system.base.controller.JeecgController; +import org.jeecg.common.system.vo.LoginUser; +import org.jeecg.modules.inquiry.entity.InquiryResponseTimeout; +import org.jeecg.modules.inquiry.service.IInquiryResponseTimeoutService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 响应超时配置 API(系统设置页)。 + *

+ * 只提供全量列表与改小时数;无 add/delete,维度由 SQL 种子固定。 + * 本接口供管理后台使用;工单超时判定尚未接入。 + *

+ */ +@Slf4j +@Api(tags = "询价-响应超时配置") +@RestController +@RequestMapping("/inquiry/responseTimeout") +public class InquiryResponseTimeoutController + extends JeecgController { + + private static final Map MODE_LABEL = new HashMap<>(); + private static final Map ROLE_LABEL = new HashMap<>(); + + static { + MODE_LABEL.put("AIR", "空运"); + MODE_LABEL.put("SEA", "海运"); + MODE_LABEL.put("LAND", "陆运"); + ROLE_LABEL.put("sales", "销售"); + ROLE_LABEL.put("product", "产品"); + ROLE_LABEL.put("air_route", "航线"); + } + + @Autowired + private IInquiryResponseTimeoutService responseTimeoutService; + + /** + * 全量列表。 + *

+ * 不加 @RequiresPermissions:系统设置页面向已登录后台管理员; + * Jeecg「管理员」身份不会自动绕过按钮权限,线上角色授权易漏导致整页不可用。 + * 仍走 JWT 登录校验;菜单可见性由前端路由控制。 + *

+ */ + @ApiOperation("全量列表(按 sort_no,供系统设置页一次加载)") + @GetMapping("/listAll") + public Result> listAll() { + // 打开系统设置时顺带纠偏旧运输类型文案(幂等,已是新文案则无写) + responseTimeoutService.ensureCanonicalTransportTypeLabels(); + QueryWrapper qw = new QueryWrapper<>(); + qw.orderByAsc("sort_no"); + return Result.OK(responseTimeoutService.list(qw)); + } + + /** + * 仅改 timeoutHours。请求体只需 id + timeoutHours;其它维度字段即使传入也忽略。 + */ + @AutoLog(value = "响应超时-改小时数") + @ApiOperation("编辑超时小时(仅 timeoutHours)") + @PutMapping("/edit") + public Result edit(@RequestBody InquiryResponseTimeout body) { + if (StringUtils.isBlank(body.getId())) { + return Result.error("id 不能为空"); + } + if (body.getTimeoutHours() == null) { + return Result.error("超时小时不能为空"); + } + try { + responseTimeoutService.updateTimeoutHours(body.getId(), body.getTimeoutHours(), currentUsername()); + return Result.OK("已保存"); + } catch (IllegalArgumentException e) { + return Result.error(e.getMessage()); + } + } + + @ApiOperation("展示标签字典(运输方式/角色中文名)") + @GetMapping("/labels") + public Result>> labels() { + Map> out = new HashMap<>(2); + out.put("transportMode", MODE_LABEL); + out.put("roleCode", ROLE_LABEL); + return Result.OK(out); + } + + private String currentUsername() { + try { + Object principal = SecurityUtils.getSubject().getPrincipal(); + if (principal instanceof LoginUser) { + return ((LoginUser) principal).getUsername(); + } + } catch (Exception ignored) { + // 无登录主体时用系统标记 + } + return "system"; + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryResponseTimeout.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryResponseTimeout.java new file mode 100644 index 0000000..1a370f3 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryResponseTimeout.java @@ -0,0 +1,65 @@ +package org.jeecg.modules.inquiry.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; +import org.springframework.format.annotation.DateTimeFormat; + +import java.io.Serializable; +import java.util.Date; + +/** + * 响应超时配置,对应 inq_response_timeout。 + *

+ * 维度行由种子固定(运输方式/角色/线路类别/运输类型),后台只允许改 timeoutHours。 + * 本表是配置真相;工单超时判定后续再读本表,本期仅 CRUD 小时数。 + *

+ */ +@Data +@EqualsAndHashCode(callSuper = false) +@Accessors(chain = true) +@TableName("inq_response_timeout") +@ApiModel(value = "inq_response_timeout", description = "响应超时配置") +public class InquiryResponseTimeout implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.ASSIGN_ID) + private String id; + + @ApiModelProperty("运输方式 AIR/SEA/LAND") + private String transportMode; + + @ApiModelProperty("角色 sales/product/air_route") + private String roleCode; + + @ApiModelProperty("线路类别(仅陆运产品有值,其余空串)") + private String routeCategory; + + @ApiModelProperty("运输类型(海运/陆运产品有值,其余空串)") + private String transportType; + + @ApiModelProperty("超时小时,整数≥0") + private Integer timeoutHours; + + @ApiModelProperty("展示排序") + private Integer sortNo; + + private String createBy; + + @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss") + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date createTime; + + private String updateBy; + + @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss") + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date updateTime; +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/mapper/InquiryResponseTimeoutMapper.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/mapper/InquiryResponseTimeoutMapper.java new file mode 100644 index 0000000..8c92562 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/mapper/InquiryResponseTimeoutMapper.java @@ -0,0 +1,12 @@ +package org.jeecg.modules.inquiry.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.jeecg.modules.inquiry.entity.InquiryResponseTimeout; + +/** + * 响应超时配置 Mapper。 + */ +@Mapper +public interface InquiryResponseTimeoutMapper extends BaseMapper { +} 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 bb38c7a..0989ca7 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 @@ -38,6 +38,14 @@ public interface IInquiryQuoteTemplateService extends IService + * 副作用:删 inq_quote_template / inq_quote_template_version;尽量清理对象存储文件(失败只记日志)。 + * 历史已出单文件不受影响(出单时已拷贝字节)。 + */ + void deleteTemplate(String templateId, String operator); + void setDefault(String templateId, boolean isDefault, String operator); /** @@ -46,10 +54,52 @@ public interface IInquiryQuoteTemplateService extends IService buildSheetPreview(String versionId) throws Exception; + + /** + * 管理端原表截图预览:本机 LibreOffice 转 PNG/PDF 并缓存,不经智能体。 + *

+ * 副作用:可能写 upload/.../preview/{versionId}-snapshot.png|pdf;短等 soffice。 + */ + Path buildSnapshotPreview(String versionId) throws Exception; + + /** + * 把 version.object_key(本地相对路径或 MinIO/OSS 完整 URL)解析为可读取的本地文件。 */ Path resolveObjectPath(String objectKey); + /** + * 当前版本解析失败时按已存文件再提交 AI 异步解析,不新增版本。 + *

+ * 编辑保存时顺带调用:仅 failed 才重跑,避免已解析模板每次改名称都打千问。 + */ + void reparseLatestIfFailed(String templateId); + + /** + * 运营显式「重新解析」:最新 excel 版在 failed / parsed / mapping_incomplete 时可重入队; + * pending 中不重复提交。 + * + * @return 给人看的结果说明 + */ + String reparseLatest(String templateId); + + /** + * 智能体回写 AI 映射结果(成功 mappingJson 或失败原因)。 + *

+ * 终态优先:已是 parsed/mapping_incomplete/failed 时可被同 version 覆盖;pending 必接受。 + */ + void applyParseResult(String versionId, boolean ok, String mappingJson, String parseMessage, String parseStatus); + + /** + * 纠偏:把「已有映射点但仍标 mapping_incomplete」的版本改为 parsed。 + * 用于历史数据(AI 把白名单必填提示误当成硬缺)。 + * + * @return 纠偏条数 + */ + int repairIncompleteParseStatus(); + /** * 智能体:已启用且 mode=auto 的模板指针列表。 */ diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryResponseTimeoutService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryResponseTimeoutService.java new file mode 100644 index 0000000..b9d3143 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryResponseTimeoutService.java @@ -0,0 +1,31 @@ +package org.jeecg.modules.inquiry.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import org.jeecg.modules.inquiry.entity.InquiryResponseTimeout; + +/** + * 响应超时配置服务。 + *

+ * 仅支持查询与改小时数;禁止增删维度行。 + *

+ */ +public interface IInquiryResponseTimeoutService extends IService { + + /** + * 仅更新超时小时数;维度字段不可改。 + * + * @param id 配置行 id + * @param timeoutHours 新小时数(≥0) + * @param updateBy 操作人登录名 + */ + void updateTimeoutHours(String id, int timeoutHours, String updateBy); + + /** + * 把陆运产品「普件,…」运输类型对齐产品确认的括号文案。 + *

+ * 仅替换已知旧文案 → 新括号格式;已是新文案则无副作用。 + * 列表接口调用一次即可,避免后台长期显示旧逗号写法。 + *

+ */ + void ensureCanonicalTransportTypeLabels(); +} 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 2dfaffe..36f6500 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 @@ -16,25 +16,34 @@ import org.jeecg.modules.inquiry.entity.InquiryQuoteTemplateVersion; import org.jeecg.modules.inquiry.mapper.InquiryQuoteTemplateMapper; import org.jeecg.modules.inquiry.mapper.InquiryQuoteTemplateVersionMapper; import org.jeecg.modules.inquiry.service.IInquiryQuoteTemplateService; +import org.jeecg.modules.inquiry.template.QuoteTemplateAiParseClient; import org.jeecg.modules.inquiry.template.QuoteTemplateExcelSupport; import org.jeecg.modules.inquiry.template.QuoteTemplateMatcher; +import org.jeecg.modules.inquiry.template.QuoteTemplateObjectStore; +import org.jeecg.modules.inquiry.template.QuoteTemplateSnapshotRenderer; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.web.multipart.MultipartFile; +import java.io.ByteArrayInputStream; import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.*; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; /** * 报价模板服务实现。 *

* 副作用:写 inq_quote_template / version;文件经 Jeecg upload(local/minio)。 - * 线程:HTTP 请求线程;解析/回填可能稍慢但不上 LibreOffice,禁止在此 sleep 等 LLM。 + * 线程:HTTP 请求线程只抽单元格清单并入队智能体;千问在 Worker;截图另线程预热。 + * 管理端原表截图预览可短等本机 soffice(文件锁互斥),禁止接到企微回调。 */ @Slf4j @Service @@ -43,9 +52,27 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl { + Thread t = new Thread(r, "inquiry-quote-tpl-snapshot"); + t.setDaemon(true); + return t; + }); + @Autowired private InquiryQuoteTemplateVersionMapper versionMapper; + @Autowired + private QuoteTemplateObjectStore objectStore; + + @Autowired + private QuoteTemplateSnapshotRenderer snapshotRenderer; + + @Autowired + private QuoteTemplateAiParseClient aiParseClient; + @Value("${jeecg.path.upload:}") private String uploadPath; @@ -105,6 +132,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl result = toListRow(getById(template.getId())); result.put("versionId", version.getId()); result.put("parseStatus", mapParseStatusLabel(version.getParseStatus())); @@ -241,6 +264,98 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl { + try { + InquiryQuoteTemplateVersion ver = versionMapper.selectById(versionId); + InquiryQuoteTemplate template = getById(templateId); + if (ver == null || template == null) { + log.warn("AI 解析入队跳过:版本或模板不存在 versionId={}", versionId); + return; + } + String enqueueErr = submitAiParse(ver, template, fileBytes); + if (enqueueErr != null) { + ver.setParseStatus("failed"); + ver.setParseMessage(enqueueErr); + versionMapper.updateById(ver); + } + } catch (Exception e) { + log.error("事务后提交 AI 解析异常 versionId={}", versionId, e); + try { + InquiryQuoteTemplateVersion ver = versionMapper.selectById(versionId); + if (ver != null) { + ver.setParseStatus("failed"); + ver.setParseMessage("无法提交解析任务: " + e.getMessage()); + versionMapper.updateById(ver); + } + } catch (Exception ignored) { + // ignore + } + } + }; + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + SNAPSHOT_EXEC.execute(job); + } + }); + } else { + SNAPSHOT_EXEC.execute(job); + } + } + + /** + * 抽单元格清单并提交智能体入队。成功返回 null,失败返回中文原因。 + */ + private String submitAiParse(InquiryQuoteTemplateVersion version, InquiryQuoteTemplate template, byte[] fileBytes) { + try { + JSONObject inventory; + try (InputStream in = new ByteArrayInputStream(fileBytes)) { + inventory = QuoteTemplateExcelSupport.buildCellInventory(in); + } + if (StringUtils.isNotBlank(inventory.getString("error"))) { + return "解析失败: Excel 无可用工作表"; + } + Map body = new LinkedHashMap<>(); + body.put("versionId", version.getId()); + body.put("templateId", template.getId()); + body.put("templateName", template.getName()); + body.put("fileName", version.getFileName()); + body.put("bizType", template.getBizType()); + body.put("transportMode", bizTypeToTransportMode(template.getBizType())); + body.put("sheet", inventory.getString("sheet")); + body.put("cellInventory", inventory.getJSONArray("lines")); + body.put("cellCount", inventory.getIntValue("cellCount")); + body.put("yellowCount", inventory.getIntValue("yellowCount")); + body.put("truncated", inventory.getBooleanValue("truncated")); + body.put("requestId", version.getId() + "-" + System.currentTimeMillis()); + return aiParseClient.enqueueParse(body); + } catch (Exception e) { + log.error("模板单元格清单抽取失败 versionId={}", version.getId(), e); + return "解析失败: 无法读取 Excel(" + e.getMessage() + ")"; + } + } + + /** + * 业务中文 → AI 提示词 transportMode。 + */ + public static String bizTypeToTransportMode(String bizType) { + if (bizType == null) { + return "sea"; + } + if (bizType.contains("空")) { + return "air"; + } + if (bizType.contains("陆")) { + return "land"; + } + return "sea"; + } + @Override public InquiryQuoteTemplateVersion getVersion(String versionId) { return versionMapper.selectById(versionId); @@ -265,15 +380,26 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl + * 启用中的模板也可删(运营确认后);删后智能体不再匹配到该模板。 + * 对象存储清理失败不回滚库删,避免运营删不掉。 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteTemplate(String templateId, String operator) { + InquiryQuoteTemplate template = getById(templateId); + if (template == null) { + throw new IllegalArgumentException("模板不存在"); + } + List versions = listVersions(templateId); + for (InquiryQuoteTemplateVersion ver : versions) { + try { + objectStore.tryDelete(ver.getObjectKey()); + } catch (Exception e) { + log.warn("模板文件清理跳过 templateId={} objectKey={} err={}", + templateId, ver.getObjectKey(), e.getMessage()); + } + versionMapper.deleteById(ver.getId()); + } + removeById(templateId); + log.info("报价模板已删除 templateId={} name={} versions={} by={}", + templateId, template.getName(), versions.size(), operator); + } + @Override @Transactional(rollbackFor = Exception.class) public void setDefault(String templateId, boolean isDefault, String operator) { @@ -392,31 +549,110 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl buildSheetPreview(String versionId) throws Exception { + InquiryQuoteTemplateVersion ver = versionMapper.selectById(versionId); + if (ver == null) { + throw new IllegalArgumentException("版本不存在"); + } + if (!"excel".equals(ver.getFormat())) { + throw new IllegalStateException("仅 Excel 支持表格预览"); + } + Path source = resolveObjectPath(ver.getObjectKey()); + JSONObject mapping; + try { + mapping = StringUtils.isNotBlank(ver.getMappingJson()) + ? JSON.parseObject(ver.getMappingJson()) + : new JSONObject(); + } catch (Exception e) { + mapping = new JSONObject(); + } + JSONObject grid = QuoteTemplateExcelSupport.buildSheetPreviewGrid(source, mapping); + Map out = new LinkedHashMap<>(); + out.put("versionId", versionId); + out.put("fileName", ver.getFileName()); + out.putAll(grid); + return out; + } + + /** + * 原表截图预览:优先读本地缓存;未命中则本机 LibreOffice 生成(不经智能体)。 + *

+ * 副作用:写 preview 缓存文件;短等 soffice(文件锁)。 + */ + @Override + public Path buildSnapshotPreview(String versionId) throws Exception { + InquiryQuoteTemplateVersion ver = versionMapper.selectById(versionId); + if (ver == null) { + throw new IllegalArgumentException("版本不存在"); + } + if (!"excel".equals(ver.getFormat())) { + throw new IllegalStateException("仅 Excel 支持原表截图预览"); + } + Path cached = findCachedSnapshot(ver.getId()); + if (cached != null) { + return cached; + } + return generateAndCacheSnapshot(ver); + } + + /** + * 上传后异步预热截图:失败只记日志,不回滚上传事务。 + */ + private void enqueueSnapshotPreview(String versionId) { + SNAPSHOT_EXEC.execute(() -> { + try { + InquiryQuoteTemplateVersion ver = versionMapper.selectById(versionId); + if (ver == null || !"excel".equals(ver.getFormat())) { + return; + } + if (findCachedSnapshot(ver.getId()) != null) { + return; + } + generateAndCacheSnapshot(ver); + log.info("模板截图预热完成 versionId={}", versionId); + } catch (Exception e) { + log.warn("模板截图预热失败 versionId={} err={}", versionId, e.getMessage()); + } + }); + } + + private Path findCachedSnapshot(String versionId) throws Exception { + Path previewDir = Paths.get(StringUtils.defaultIfBlank(uploadPath, "."), BIZ_PATH, "preview"); + Files.createDirectories(previewDir); + Path cachedPng = previewDir.resolve(versionId + "-snapshot.png"); + Path cachedPdf = previewDir.resolve(versionId + "-snapshot.pdf"); + if (Files.isRegularFile(cachedPng) && Files.size(cachedPng) > 0) { + return cachedPng; + } + if (Files.isRegularFile(cachedPdf) && Files.size(cachedPdf) > 0) { + return cachedPdf; + } + return null; + } + + private Path generateAndCacheSnapshot(InquiryQuoteTemplateVersion ver) throws Exception { + Path previewDir = Paths.get(StringUtils.defaultIfBlank(uploadPath, "."), BIZ_PATH, "preview"); + Files.createDirectories(previewDir); + Path cachedPng = previewDir.resolve(ver.getId() + "-snapshot.png"); + Path cachedPdf = previewDir.resolve(ver.getId() + "-snapshot.pdf"); + Path source = resolveObjectPath(ver.getObjectKey()); + QuoteTemplateSnapshotRenderer.PreviewBytes rendered = snapshotRenderer.render(source, ver.getId()); + Path target = rendered.isImage() ? cachedPng : cachedPdf; + Files.write(target, rendered.getBytes()); + return target; + } + + /** + * 解析本地路径或 MinIO/OSS URL 为可读取文件。远程 URL 禁止 Paths.get。 + */ @Override public Path resolveObjectPath(String objectKey) { - if (StringUtils.isBlank(objectKey)) { - throw new IllegalArgumentException("objectKey 空"); - } - Path p = Paths.get(objectKey); - if (p.isAbsolute() && Files.isRegularFile(p)) { - return p; - } - // Jeecg local 常返回相对 upload 的路径;minio 时需另下载体——本期以 local 为主 - if (StringUtils.isNotBlank(uploadPath)) { - Path under = Paths.get(uploadPath, objectKey.replace("/", java.io.File.separator)); - if (Files.isRegularFile(under)) { - return under; - } - // 有时 objectKey 已含盘符或 upload 前缀 - under = Paths.get(uploadPath).resolve(objectKey); - if (Files.isRegularFile(under)) { - return under; - } - } - if (Files.isRegularFile(Paths.get(objectKey))) { - return Paths.get(objectKey); - } - throw new IllegalStateException("找不到模板文件: " + objectKey + "(uploadType=" + uploadType + ")"); + return objectStore.resolve(objectKey); } @Override @@ -681,6 +917,371 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl() + .eq("template_id", templateId) + .eq("format", "excel") + .orderByDesc("version_no") + .last("LIMIT 1")); + } + + /** + * 把指定版本标 pending 并入队 AI;读本地/对象存储字节。 + */ + private void enqueueReparse(String templateId, InquiryQuoteTemplateVersion ver) { + InquiryQuoteTemplate template = getById(templateId); + if (template == null) { + throw new IllegalArgumentException("模板不存在"); + } + try { + Path local = resolveObjectPath(ver.getObjectKey()); + byte[] fileBytes = Files.readAllBytes(local); + ver.setParseStatus("pending"); + ver.setParseMessage("解析中,请稍候"); + ver.setUpdateTime(new Date()); + versionMapper.updateById(ver); + scheduleAiParseAfterCommit(ver.getId(), template.getId(), fileBytes); + enqueueSnapshotPreview(ver.getId()); + } catch (Exception e) { + log.error("模板补解析失败 templateId={} objectKey={}", templateId, ver.getObjectKey(), e); + ver.setParseStatus("failed"); + ver.setParseMessage("解析失败: " + e.getMessage()); + versionMapper.updateById(ver); + throw new IllegalStateException("解析失败: " + e.getMessage(), e); + } + } + + /** + * 智能体回写 AI 映射。校验白名单与 feeRegion 增行配置;未映射必填 → mapping_incomplete。 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void applyParseResult(String versionId, boolean ok, String mappingJson, String parseMessage, String parseStatus) { + if (StringUtils.isBlank(versionId)) { + throw new IllegalArgumentException("versionId 必填"); + } + InquiryQuoteTemplateVersion ver = versionMapper.selectById(versionId); + if (ver == null) { + throw new IllegalArgumentException("版本不存在: " + versionId); + } + InquiryQuoteTemplate template = getById(ver.getTemplateId()); + if (!ok) { + ver.setParseStatus("failed"); + ver.setParseMessage(StringUtils.defaultIfBlank(parseMessage, "AI 解析失败")); + ver.setUpdateBy("agent"); + ver.setUpdateTime(new Date()); + versionMapper.updateById(ver); + return; + } + JSONObject mapping; + try { + mapping = JSON.parseObject(mappingJson); + } catch (Exception e) { + ver.setParseStatus("failed"); + ver.setParseMessage("AI 返回的 mappingJson 非法: " + e.getMessage()); + ver.setUpdateBy("agent"); + ver.setUpdateTime(new Date()); + versionMapper.updateById(ver); + return; + } + if (mapping == null) { + ver.setParseStatus("failed"); + ver.setParseMessage("AI 返回空 mappingJson"); + ver.setUpdateBy("agent"); + ver.setUpdateTime(new Date()); + versionMapper.updateById(ver); + return; + } + String validateErr = validateAiMapping(mapping); + if (validateErr != null) { + ver.setParseStatus("failed"); + ver.setParseMessage(validateErr); + ver.setUpdateBy("agent"); + ver.setUpdateTime(new Date()); + versionMapper.updateById(ver); + return; + } + // 兼容旧填表:fixedCells → fixedYellowCells + if (mapping.getJSONArray("fixedYellowCells") == null && mapping.getJSONArray("fixedCells") != null) { + mapping.put("fixedYellowCells", mapping.getJSONArray("fixedCells")); + } + mapping.put("schemaVersion", "quote-template-mapping-v1"); + applyExcelMapping(ver, template != null ? template : new InquiryQuoteTemplate(), mapping); + if (StringUtils.isNotBlank(parseMessage) && "failed".equals(ver.getParseStatus())) { + ver.setParseMessage(parseMessage); + } + ver.setUpdateBy("agent"); + ver.setUpdateTime(new Date()); + versionMapper.updateById(ver); + } + + /** + * 把误标为 mapping_incomplete、但已有映射点的版本纠偏为 parsed。 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public int repairIncompleteParseStatus() { + List list = versionMapper.selectList(new QueryWrapper() + .eq("parse_status", "mapping_incomplete")); + int n = 0; + for (InquiryQuoteTemplateVersion ver : list) { + if (ver.getMappingPointCount() != null && ver.getMappingPointCount() > 0) { + ver.setParseStatus("parsed"); + ver.setParseMessage("已解析 " + ver.getMappingPointCount() + " 个映射点"); + ver.setUpdateTime(new Date()); + versionMapper.updateById(ver); + n++; + } else if (StringUtils.isNotBlank(ver.getMappingJson())) { + try { + JSONObject mapping = JSON.parseObject(ver.getMappingJson()); + int points = 0; + if (mapping.getJSONObject("fields") != null) { + points += mapping.getJSONObject("fields").size(); + } + if (mapping.getJSONArray("feeRegions") != null) { + points += mapping.getJSONArray("feeRegions").size(); + } + if (mapping.getJSONArray("routeRegions") != null) { + points += mapping.getJSONArray("routeRegions").size(); + } + if (points > 0) { + ver.setMappingPointCount(points); + ver.setParseStatus("parsed"); + ver.setParseMessage("已解析 " + points + " 个映射点"); + ver.setUpdateTime(new Date()); + versionMapper.updateById(ver); + n++; + } + } catch (Exception ignored) { + // 非法 JSON 跳过 + } + } + } + return n; + } + + /** + * 校验 AI mapping:字段白名单、fee/route 区域增行配置。 + * + * @return null=通过;否则失败中文原因 + */ + private String validateAiMapping(JSONObject mapping) { + Set allowed = loadAllowedFieldCodes(); + JSONObject fields = mapping.getJSONObject("fields"); + if (fields != null) { + for (String code : fields.keySet()) { + if ("fee_rows".equals(code)) { + continue; + } + if (!allowed.isEmpty() && !allowed.contains(code)) { + return "AI 映射含非法字段码: " + code; + } + String ref = fields.getString(code); + if (StringUtils.isBlank(ref) || !ref.contains("!")) { + return "字段 " + code + " 的单元格地址非法(须含工作表名!)"; + } + } + } + JSONArray feeRegions = mapping.getJSONArray("feeRegions"); + if (feeRegions != null) { + for (int i = 0; i < feeRegions.size(); i++) { + JSONObject r = feeRegions.getJSONObject(i); + if (r == null) { + continue; + } + String grow = r.getString("grow"); + if (StringUtils.isBlank(grow)) { + r.put("grow", "insert_before_stop"); + } + if (r.getIntValue("styleCopyRows") <= 0) { + r.put("styleCopyRows", 1); + } + JSONArray stop = r.getJSONArray("stopAtText"); + if (stop == null || stop.isEmpty()) { + return "feeRegion[" + i + "] 缺少 stopAtText"; + } + if (StringUtils.isBlank(r.getString("anchor"))) { + return "feeRegion[" + i + "] 缺少 anchor"; + } + } + } + JSONArray routeRegions = mapping.getJSONArray("routeRegions"); + if (routeRegions != null) { + for (int i = 0; i < routeRegions.size(); i++) { + JSONObject r = routeRegions.getJSONObject(i); + if (r == null) { + continue; + } + if (StringUtils.isBlank(r.getString("grow"))) { + r.put("grow", "insert_before_stop"); + } + if (r.getIntValue("styleCopyRows") <= 0) { + r.put("styleCopyRows", 1); + } + JSONArray stop = r.getJSONArray("stopAtText"); + if (stop == null || stop.isEmpty()) { + return "routeRegion[" + i + "] 缺少 stopAtText"; + } + } + } + return null; + } + + private Set loadAllowedFieldCodes() { + Set codes = new LinkedHashSet<>(); + try { + JSONObject contract = QuoteTemplateExcelSupport.loadContract(resolveContractPath()); + JSONArray systemFields = contract.getJSONArray("systemFields"); + if (systemFields != null) { + for (int i = 0; i < systemFields.size(); i++) { + JSONObject f = systemFields.getJSONObject(i); + if (f != null && StringUtils.isNotBlank(f.getString("code"))) { + codes.add(f.getString("code")); + } + } + } + } catch (Exception e) { + log.warn("加载字段白名单失败: {}", e.getMessage()); + } + // 提示词扩展字段(合同可能尚未列全) + codes.addAll(Arrays.asList( + "chargeable_weight_kg", "delivery_address", "pickup_address", "transit_time", + "cargo_value", "hs_code", "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" + )); + return codes; + } + + /** + * 把解析结果写进版本行。不落库。 + *

+ * 产品口径(对齐映射 AI 提示词):系统字段白名单是天花板不是硬必填。 + * 表上没有的标签不应拖成「映射未完成」。只要有可用 fields / feeRegions / routeRegions,即视为已解析; + * 仅当几乎没有任何可写映射时才标 mapping_incomplete(auto 不可启用)。 + *

+ * mappingGaps:智能体对照单元格清单算出的「表上有、未映射上」中文说明,供运营核对; + * 有缺口仍标 parsed,但 parseMessage 与列表字段会明确列出。 + */ + private void applyExcelMapping(InquiryQuoteTemplateVersion version, InquiryQuoteTemplate template, JSONObject mapping) { + normalizeUnmappedRequired(mapping); + JSONArray unmapped = mapping.getJSONArray("unmappedRequired"); + JSONArray gaps = mapping.getJSONArray("mappingGaps"); + int points = mapping.getJSONObject("fields") == null ? 0 : mapping.getJSONObject("fields").size(); + JSONArray feeRegions = mapping.getJSONArray("feeRegions"); + if (feeRegions != null) { + points += feeRegions.size(); + } + JSONArray routeRegions = mapping.getJSONArray("routeRegions"); + if (routeRegions != null) { + points += routeRegions.size(); + } + version.setMappingJson(mapping.toJSONString()); + version.setCandidatesJson(mapping.getJSONArray("candidates") == null + ? "[]" : mapping.getJSONArray("candidates").toJSONString()); + version.setMappingPointCount(points); + boolean usable = points > 0; + if (!usable && "auto".equals(template.getMode())) { + version.setParseStatus("mapping_incomplete"); + version.setParseMessage("未得到可用映射点,请检查模板或重新解析"); + } else if (gaps != null && !gaps.isEmpty()) { + version.setParseStatus("parsed"); + version.setParseMessage(buildGapsParseMessage(points, gaps)); + } else if (unmapped != null && !unmapped.isEmpty()) { + // AI 自报未映射 code:有缺口只提示,不挡启用 + version.setParseStatus("parsed"); + version.setParseMessage("已解析 " + points + " 个映射点;可选缺口:" + unmapped.toJSONString()); + } else { + version.setParseStatus("parsed"); + version.setParseMessage("已解析 " + points + " 个映射点"); + } + } + + /** + * 组装运营可读的「已解析点数 + 未解析项」摘要(列表悬停/详情共用)。 + */ + private static String buildGapsParseMessage(int points, JSONArray gaps) { + StringBuilder sb = new StringBuilder(); + sb.append("已解析 ").append(points).append(" 个映射点;未解析到 ").append(gaps.size()).append(" 项:"); + int limit = Math.min(gaps.size(), 8); + for (int i = 0; i < limit; i++) { + if (i > 0) { + sb.append(";"); + } + sb.append(String.valueOf(gaps.get(i))); + } + if (gaps.size() > 8) { + sb.append(" 等共").append(gaps.size()).append("项"); + } + return sb.toString(); + } + + /** + * 清理 AI 误报的「必填未映射」:白名单必填提示不等于表上一定有该格。 + * 保留 unmappedLabels;unmappedRequired 仅保留仍空的提示,供运营参考。 + */ + private void normalizeUnmappedRequired(JSONObject mapping) { + if (mapping == null) { + return; + } + JSONObject fields = mapping.getJSONObject("fields"); + JSONArray unmapped = mapping.getJSONArray("unmappedRequired"); + if (unmapped == null || unmapped.isEmpty()) { + return; + } + JSONArray kept = new JSONArray(); + for (int i = 0; i < unmapped.size(); i++) { + String code = unmapped.getString(i); + if (StringUtils.isBlank(code)) { + continue; + } + // 已映上的不要再报缺 + if (fields != null && StringUtils.isNotBlank(fields.getString(code))) { + continue; + } + kept.add(code); + } + mapping.put("unmappedRequired", kept); + } + private InquiryQuoteTemplateVersion findEnabledOrLatest(String templateId) { InquiryQuoteTemplateVersion enabled = versionMapper.selectOne(new QueryWrapper() .eq("template_id", templateId) @@ -753,6 +1354,31 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl extractMappingGaps(String mappingJson) { + if (StringUtils.isBlank(mappingJson)) { + return Collections.emptyList(); + } + try { + JSONArray arr = JSON.parseObject(mappingJson).getJSONArray("mappingGaps"); + if (arr == null || arr.isEmpty()) { + return Collections.emptyList(); + } + List out = new ArrayList<>(); + for (int i = 0; i < arr.size(); i++) { + String s = arr.getString(i); + if (StringUtils.isNotBlank(s)) { + out.add(s); + } + } + return out; + } catch (Exception e) { + return Collections.emptyList(); + } + } + private static String mapParseStatusLabel(String status) { if ("parsed".equals(status)) { return "已解析"; diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryResponseTimeoutServiceImpl.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryResponseTimeoutServiceImpl.java new file mode 100644 index 0000000..08eb7c6 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryResponseTimeoutServiceImpl.java @@ -0,0 +1,97 @@ +package org.jeecg.modules.inquiry.service.impl; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import org.jeecg.modules.inquiry.entity.InquiryResponseTimeout; +import org.jeecg.modules.inquiry.mapper.InquiryResponseTimeoutMapper; +import org.jeecg.modules.inquiry.service.IInquiryResponseTimeoutService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 响应超时配置:只改 timeout_hours,不动维度列(文案纠偏除外)。 + *

+ * 为何单独方法而不用 updateById 整行:避免前端误传改写运输方式/角色等固定种子。 + * 运输类型纠偏是产品确认的一次性旧→新映射,不开放通用改维度接口。 + *

+ */ +@Service +public class InquiryResponseTimeoutServiceImpl + extends ServiceImpl + implements IInquiryResponseTimeoutService { + + /** + * 陆运产品运输类型:旧逗号写法 → 产品确认的括号写法。 + * 急件文案不变,不在此映射。 + */ + private static final Map LAND_PRODUCT_TYPE_RENAMES = new LinkedHashMap<>(); + + static { + LAND_PRODUCT_TYPE_RENAMES.put( + "普件,中港、东南亚常用线路/常规车型", + "普件(东南亚、中港常用线路/常规车型)"); + LAND_PRODUCT_TYPE_RENAMES.put( + "普件,中亚、中欧、东南亚涉及国内+国外段", + "普件(东南亚、中亚、中欧涉及国内+国外段)"); + LAND_PRODUCT_TYPE_RENAMES.put( + "普件,中亚、中欧、东南亚涉及境外段清派等特殊要求", + "普件(东南亚、中亚、中欧涉及境外段清派等特殊要求)"); + LAND_PRODUCT_TYPE_RENAMES.put( + "普件,常用线路/常规车型", + "普件(常用线路/常规车型)"); + LAND_PRODUCT_TYPE_RENAMES.put( + "普件,非常用线路/临时线路/特种车型", + "普件(非常用线路/临时线路/特种车型)"); + } + + /** 本进程内只纠偏一次,避免每次打开系统设置都打 UPDATE */ + private volatile boolean labelsEnsured; + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateTimeoutHours(String id, int timeoutHours, String updateBy) { + if (timeoutHours < 0) { + throw new IllegalArgumentException("超时小时不能为负数"); + } + InquiryResponseTimeout row = getById(id); + if (row == null) { + throw new IllegalArgumentException("超时配置不存在: " + id); + } + // 只写小时数与审计字段,禁止 updateById 整行误伤维度列 + lambdaUpdate() + .eq(InquiryResponseTimeout::getId, id) + .set(InquiryResponseTimeout::getTimeoutHours, timeoutHours) + .set(InquiryResponseTimeout::getUpdateBy, updateBy) + .set(InquiryResponseTimeout::getUpdateTime, new Date()) + .update(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void ensureCanonicalTransportTypeLabels() { + if (labelsEnsured) { + return; + } + synchronized (this) { + if (labelsEnsured) { + return; + } + Date now = new Date(); + for (Map.Entry e : LAND_PRODUCT_TYPE_RENAMES.entrySet()) { + // 按旧文案精确匹配;同文案可跨多条线路类别一并改写 + lambdaUpdate() + .eq(InquiryResponseTimeout::getTransportMode, "LAND") + .eq(InquiryResponseTimeout::getRoleCode, "product") + .eq(InquiryResponseTimeout::getTransportType, e.getKey()) + .set(InquiryResponseTimeout::getTransportType, e.getValue()) + .set(InquiryResponseTimeout::getUpdateBy, "system") + .set(InquiryResponseTimeout::getUpdateTime, now) + .update(); + } + labelsEnsured = true; + } + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateAiParseClient.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateAiParseClient.java new file mode 100644 index 0000000..720406f --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateAiParseClient.java @@ -0,0 +1,120 @@ +package org.jeecg.modules.inquiry.template; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +/** + * 主账 → 智能体:提交报价模板 AI 映射解析任务(只入队,不在此等千问)。 + *

+ * 副作用:出站 HTTP 打 AGENT_HTTP_BASE_URL;失败由调用方把版本标 failed。 + * 禁止:本类同步等 LLM;禁止把密钥写进日志。 + */ +@Slf4j +@Component +public class QuoteTemplateAiParseClient { + + @Value("${inquiry.agent.base-url:${AGENT_HTTP_BASE_URL:http://127.0.0.1:8910}}") + private String agentBaseUrl; + + @Value("${inquiry.agent.callback-token:${AGENT_CALLBACK_TOKEN:}}") + private String agentToken; + + @Value("${inquiry.agent.parse-timeout-ms:${QUOTE_TEMPLATE_PARSE_ENQUEUE_TIMEOUT_MS:8000}}") + private int timeoutMs; + + /** + * 把解析任务交给智能体 HTTP(入队后快回)。 + * + * @param body versionId / transportMode / fileName / sheet / cellInventory 等 + * @return null 表示成功;非空为失败原因(中文) + */ + public String enqueueParse(Map body) { + String base = StringUtils.removeEnd(StringUtils.defaultIfBlank(agentBaseUrl, ""), "/"); + if (StringUtils.isBlank(base)) { + return "智能体地址未配置(AGENT_HTTP_BASE_URL)"; + } + String url = base + "/internal/quote-template/parse"; + HttpURLConnection conn = null; + try { + conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setRequestMethod("POST"); + conn.setConnectTimeout(Math.max(2000, timeoutMs)); + conn.setReadTimeout(Math.max(2000, timeoutMs)); + conn.setDoOutput(true); + conn.setRequestProperty("Content-Type", "application/json;charset=UTF-8"); + conn.setRequestProperty("Accept", "application/json"); + if (StringUtils.isNotBlank(agentToken)) { + conn.setRequestProperty("X-Agent-Callback-Token", agentToken); + } + byte[] bytes = JSON.toJSONBytes(body); + try (OutputStream out = conn.getOutputStream()) { + out.write(bytes); + } + int code = conn.getResponseCode(); + String respText = readQuietly(conn, code >= 200 && code < 300); + if (code == 202 || code == 200) { + JSONObject resp = safeJson(respText); + if (resp != null && Boolean.FALSE.equals(resp.getBoolean("accepted"))) { + return "智能体拒绝入队: " + StringUtils.defaultIfBlank(resp.getString("reason"), respText); + } + return null; + } + return "无法提交解析任务: HTTP " + code + + (StringUtils.isNotBlank(respText) ? " " + truncate(respText, 180) : ""); + } catch (Exception e) { + log.warn("提交模板 AI 解析失败 url={} err={}", url, e.getMessage()); + return "无法提交解析任务: " + e.getMessage(); + } finally { + if (conn != null) { + conn.disconnect(); + } + } + } + + private static String readQuietly(HttpURLConnection conn, boolean ok) { + try { + java.io.InputStream in = ok ? conn.getInputStream() : conn.getErrorStream(); + if (in == null) { + return ""; + } + java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(); + byte[] buf = new byte[2048]; + int n; + while ((n = in.read(buf)) >= 0) { + bos.write(buf, 0, n); + } + return new String(bos.toByteArray(), StandardCharsets.UTF_8); + } catch (Exception e) { + return ""; + } + } + + private static JSONObject safeJson(String text) { + if (StringUtils.isBlank(text)) { + return null; + } + try { + return JSON.parseObject(text); + } catch (Exception e) { + return null; + } + } + + private static String truncate(String s, int max) { + if (s == null) { + return ""; + } + String t = s.replaceAll("\\s+", " ").trim(); + return t.length() <= max ? t : t.substring(0, max) + "…"; + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateExcelSupport.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateExcelSupport.java index f49e436..d82e021 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateExcelSupport.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateExcelSupport.java @@ -193,6 +193,15 @@ public final class QuoteTemplateExcelSupport { * @return mapping 草案(含 fields / fixedYellowCells / candidates / unmappedRequired) */ public static JSONObject parseWorkbook(Path xlsxPath, JSONObject contract) throws Exception { + try (InputStream in = Files.newInputStream(xlsxPath)) { + return parseWorkbook(in, contract); + } + } + + /** + * 从已打开的流解析 xlsx。上传当下用 Multipart 字节,避免先写成 MinIO URL 再 Paths.get。 + */ + public static JSONObject parseWorkbook(InputStream in, JSONObject contract) throws Exception { Map dict = toStringMap(contract.getJSONObject("labelDictionary")); JSONArray systemFields = contract.getJSONArray("systemFields"); Set requiredHints = new HashSet<>(); @@ -212,7 +221,7 @@ public final class QuoteTemplateExcelSupport { JSONArray candidates = new JSONArray(); Set mappedCodes = new HashSet<>(); - try (InputStream in = Files.newInputStream(xlsxPath); Workbook wb = WorkbookFactory.create(in)) { + try (Workbook wb = WorkbookFactory.create(in)) { Sheet sheet = wb.getNumberOfSheets() > 0 ? wb.getSheetAt(0) : null; if (sheet == null) { mapping.put("parseError", "empty_workbook"); @@ -274,17 +283,263 @@ public final class QuoteTemplateExcelSupport { } /** - * 按 mapping.fields / fee_rows 写入事实;先清空映射覆盖的可填区(跳过黄区)。 + * 抽取给映射 AI 的单元格清单(上传线程可跑:只读 POI,不调 LLM)。 + *

+ * 每行格式:地址 | 原文 | 合并范围 | 填充色。与「报价模板映射-AI提示词」用户消息对齐。 + * 上限避免超大表撑爆模型上下文;优先保留有字或黄底的格。 + * + * @return sheet / lines(文本行数组) / cellCount / yellowCount / truncated */ - public static void fillWorkbook(Path sourceXlsx, Path targetXlsx, JSONObject mapping, JSONObject facts) throws Exception { - Set yellow = new HashSet<>(); - JSONArray yArr = mapping.getJSONArray("fixedYellowCells"); - if (yArr != null) { - for (int i = 0; i < yArr.size(); i++) { - yellow.add(yArr.getString(i)); + public static JSONObject buildCellInventory(InputStream in) throws Exception { + final int maxLines = 2500; + JSONObject out = new JSONObject(true); + JSONArray lines = new JSONArray(); + int yellowCount = 0; + boolean truncated = false; + try (Workbook wb = WorkbookFactory.create(in)) { + Sheet sheet = wb.getNumberOfSheets() > 0 ? pickContentSheet(wb) : null; + if (sheet == null) { + out.put("sheet", ""); + out.put("lines", lines); + out.put("cellCount", 0); + out.put("yellowCount", 0); + out.put("truncated", false); + out.put("error", "empty_workbook"); + return out; + } + String sheetName = sheet.getSheetName(); + out.put("sheet", sheetName); + DataFormatter fmt = new DataFormatter(); + Map mergeByOrigin = buildMergeIndex(sheet); + for (Row row : sheet) { + if (row == null) { + continue; + } + for (Cell cell : row) { + if (cell == null) { + continue; + } + String text = StringUtils.trimToEmpty(fmt.formatCellValue(cell)); + boolean yellow = isYellowFixed(cell); + String fill = cellFillHex(cell); + String addr = new CellReference(cell).formatAsString(); + String merge = mergeByOrigin.getOrDefault(addr, "-"); + if (text.isEmpty() && !yellow && "-".equals(merge) && StringUtils.isBlank(fill)) { + continue; + } + if (yellow) { + yellowCount++; + } + if (lines.size() >= maxLines) { + truncated = true; + break; + } + String safeText = text.replace('|', '/'); + if (safeText.length() > 120) { + safeText = safeText.substring(0, 120) + "…"; + } + lines.add(sheetName + "!" + addr + " | " + safeText + " | " + merge + " | " + + (StringUtils.isBlank(fill) ? "-" : fill)); + } + if (truncated) { + break; + } } } + out.put("lines", lines); + out.put("cellCount", lines.size()); + out.put("yellowCount", yellowCount); + out.put("truncated", truncated); + return out; + } + + public static JSONObject buildCellInventory(Path xlsxPath) throws Exception { + try (InputStream in = Files.newInputStream(xlsxPath)) { + return buildCellInventory(in); + } + } + + /** + * 管理端「Excel 样式预览」:读上传原表前若干行/列,带出原文、黄区、映射字段。 + *

+ * 不写样例事实;只读文件。行/列有上限,避免大表拖垮后台弹窗。 + * + * @param xlsxPath 本地可读取的模板文件 + * @param mapping 可空;用于标注已映射写入格与优先工作表名 + * @return sheet / colHeaders / rows(每行含 rowNo + cells) + */ + public static JSONObject buildSheetPreviewGrid(Path xlsxPath, JSONObject mapping) throws Exception { + final int maxRows = 40; + final int maxCols = 16; + JSONObject out = new JSONObject(true); + try (InputStream in = Files.newInputStream(xlsxPath); Workbook wb = WorkbookFactory.create(in)) { + if (wb.getNumberOfSheets() <= 0) { + out.put("error", "empty_workbook"); + return out; + } + String prefer = mapping == null ? null : mapping.getString("sheet"); + Sheet sheet = null; + if (StringUtils.isNotBlank(prefer)) { + sheet = wb.getSheet(prefer); + } + if (sheet == null) { + // 映射格子常在 Sheet2;优先选「有内容」的第一张,否则第 0 张 + sheet = pickContentSheet(wb); + } + String sheetName = sheet.getSheetName(); + out.put("sheet", sheetName); + + Map cellToField = new HashMap<>(); + Map fieldLabels = defaultFieldLabels(); + if (mapping != null && mapping.getJSONObject("fields") != null) { + JSONObject fields = mapping.getJSONObject("fields"); + for (String code : fields.keySet()) { + if ("fee_rows".equals(code)) { + continue; + } + String ref = fields.getString(code); + if (StringUtils.isBlank(ref)) { + continue; + } + cellToField.put(normalizeRef(ref, sheetName), code); + } + } + + int lastRow = Math.min(sheet.getLastRowNum(), maxRows - 1); + int lastCol = 0; + DataFormatter fmt = new DataFormatter(); + for (int r = 0; r <= lastRow; r++) { + Row row = sheet.getRow(r); + if (row == null) { + continue; + } + short last = row.getLastCellNum(); + if (last > 0) { + lastCol = Math.max(lastCol, Math.min(last - 1, maxCols - 1)); + } + } + // 至少展示 A–F,避免空白表太窄 + lastCol = Math.max(lastCol, 5); + lastCol = Math.min(lastCol, maxCols - 1); + + JSONArray colHeaders = new JSONArray(); + for (int c = 0; c <= lastCol; c++) { + colHeaders.add(CellReference.convertNumToColString(c)); + } + out.put("colHeaders", colHeaders); + + JSONArray rows = new JSONArray(); + for (int r = 0; r <= lastRow; r++) { + JSONObject rowObj = new JSONObject(true); + rowObj.put("rowNo", r + 1); + JSONArray cells = new JSONArray(); + Row row = sheet.getRow(r); + for (int c = 0; c <= lastCol; c++) { + JSONObject cellObj = new JSONObject(true); + String addr = sheetName + "!" + CellReference.convertNumToColString(c) + (r + 1); + cellObj.put("addr", addr); + Cell cell = row == null ? null : row.getCell(c); + String text = ""; + boolean yellow = false; + if (cell != null) { + text = StringUtils.trimToEmpty(fmt.formatCellValue(cell)); + yellow = isYellowFixed(cell); + } + String code = cellToField.get(normalizeRef(addr, sheetName)); + if (code == null) { + // 兼容 mapping 里只存 B4 不带 sheet 前缀 + code = cellToField.get(CellReference.convertNumToColString(c) + (r + 1)); + } + cellObj.put("text", text.length() > 80 ? text.substring(0, 80) + "…" : text); + cellObj.put("yellow", yellow); + if (code != null) { + cellObj.put("mappedCode", code); + cellObj.put("mappedLabel", fieldLabels.getOrDefault(code, code)); + } + cells.add(cellObj); + } + rowObj.put("cells", cells); + rows.add(rowObj); + } + out.put("rows", rows); + out.put("rowCount", lastRow + 1); + out.put("colCount", lastCol + 1); + out.put("mappedCount", cellToField.size()); + return out; + } + } + + /** 选内容最多的工作表,便于空运模板主内容在 Sheet2 时仍能预览到。 */ + private static Sheet pickContentSheet(Workbook wb) { + Sheet best = wb.getSheetAt(0); + int bestScore = -1; + for (int i = 0; i < wb.getNumberOfSheets(); i++) { + Sheet s = wb.getSheetAt(i); + int score = 0; + int last = Math.min(s.getLastRowNum(), 30); + for (int r = 0; r <= last; r++) { + Row row = s.getRow(r); + if (row == null) { + continue; + } + short n = row.getLastCellNum(); + if (n > 0) { + score += n; + } + } + if (score > bestScore) { + bestScore = score; + best = s; + } + } + return best; + } + + private static String normalizeRef(String ref, String sheetName) { + if (StringUtils.isBlank(ref)) { + return ""; + } + String r = ref.trim(); + if (r.contains("!")) { + return r; + } + return sheetName + "!" + r; + } + + private static Map defaultFieldLabels() { + Map m = new LinkedHashMap<>(); + m.put("customer_name", "客户名称"); + m.put("contact_name", "联系人"); + m.put("contact_phone", "电话"); + m.put("quote_no", "报价单编号"); + m.put("quote_date", "报价日期"); + m.put("valid_until", "有效期至"); + m.put("currency", "币别"); + m.put("sales_name", "业务员"); + m.put("pol", "起运港"); + m.put("pod", "目的港"); + m.put("origin", "始发地"); + m.put("destination", "目的地"); + m.put("commodity", "品名"); + m.put("quantity", "货物数量"); + m.put("container_type_qty", "箱型箱量"); + m.put("fcl_or_lcl", "整柜或拼柜"); + m.put("trade_terms", "贸易条款"); + m.put("cargo_ready_date", "货好时间"); + m.put("pieces", "件数"); + m.put("weight_kg", "重量"); + m.put("volume_cbm", "体积"); + m.put("total_amount", "报价合计"); + return m; + } + + /** + * 按 mapping.fields / fee_rows / feeRegions / routeRegions 写入事实;先清空映射覆盖的可填区(跳过黄区/固定区)。 + */ + public static void fillWorkbook(Path sourceXlsx, Path targetXlsx, JSONObject mapping, JSONObject facts) throws Exception { + Set yellow = collectFixedCells(mapping); try (InputStream in = Files.newInputStream(sourceXlsx); Workbook wb = WorkbookFactory.create(in)) { + JSONObject fieldMeta = mapping.getJSONObject("fieldMeta"); JSONObject fields = mapping.getJSONObject("fields"); if (fields != null) { for (String code : fields.keySet()) { @@ -296,15 +551,24 @@ public final class QuoteTemplateExcelSupport { if ("fee_rows".equals(code)) { continue; } - // 空值不覆盖模板原字,避免把单位格/币别格擦掉 if (val == null || StringUtils.isBlank(String.valueOf(val))) { continue; } - writeCell(wb, cellRef, String.valueOf(val)); + boolean afterColon = false; + if (fieldMeta != null && fieldMeta.getJSONObject(code) != null) { + afterColon = Boolean.TRUE.equals(fieldMeta.getJSONObject(code).getBoolean("sameCellAfterColon")); + } + if (afterColon) { + writeCellAfterColon(wb, cellRef, String.valueOf(val)); + } else { + writeCell(wb, cellRef, String.valueOf(val)); + } } } // 海运询价单映射常只对上 4 个点,且起运港映进了标签合并格。按标签再写一遍正确格子。 writeFactsByLabel(wb, facts, yellow); + writeFeeRegions(wb, mapping, facts, yellow); + writeRouteRegions(wb, mapping, facts, yellow); writeFeeRows(wb, mapping, facts, yellow); // 后台常未配 fee_rows:按「空运费」等名称写入金额列,SUM 才能加算 writeFeeAmountsByName(wb, facts, yellow); @@ -320,6 +584,337 @@ public final class QuoteTemplateExcelSupport { } } + /** + * 固定区:AI 的 fixedCells ∪ 词典解析的 fixedYellowCells。 + */ + static Set collectFixedCells(JSONObject mapping) { + Set yellow = new HashSet<>(); + if (mapping == null) { + return yellow; + } + for (String key : new String[]{"fixedYellowCells", "fixedCells"}) { + JSONArray yArr = mapping.getJSONArray(key); + if (yArr == null) { + continue; + } + for (int i = 0; i < yArr.size(); i++) { + String ref = yArr.getString(i); + if (StringUtils.isNotBlank(ref)) { + yellow.add(ref); + } + } + } + return yellow; + } + + /** + * 同格冒号后改值:强制保留标签前缀。 + */ + private static void writeCellAfterColon(Workbook wb, String cellRef, String value) { + ParsedRef ref = ParsedRef.parse(cellRef); + Sheet sheet = wb.getSheet(ref.sheet); + if (sheet == null && wb.getNumberOfSheets() > 0) { + sheet = wb.getSheetAt(0); + } + if (sheet == null) { + return; + } + int[] dest = resolveMergeOrigin(sheet, ref.row, ref.col); + Row row = sheet.getRow(dest[0]); + if (row == null) { + row = sheet.createRow(dest[0]); + } + Cell cell = row.getCell(dest[1]); + if (cell == null) { + cell = row.createCell(dest[1]); + } + String existing = readCellText(cell); + int colon = Math.max(existing.indexOf(':'), existing.indexOf(':')); + if (colon >= 0) { + cell.setCellValue(existing.substring(0, colon + 1) + (value == null ? "" : value)); + } else { + cell.setCellValue(value == null ? "" : value); + } + } + + /** + * 按 AI feeRegions 写入费用行;支持 insert_before_stop 增行。 + * facts 可带 fee_rows / fee_rows_{tmsSource} / fee_rows_{regionId}。 + */ + private static void writeFeeRegions(Workbook wb, JSONObject mapping, JSONObject facts, Set yellow) { + JSONArray regions = mapping.getJSONArray("feeRegions"); + if (regions == null || regions.isEmpty()) { + return; + } + for (int i = 0; i < regions.size(); i++) { + JSONObject region = regions.getJSONObject(i); + if (region == null) { + continue; + } + JSONArray rows = resolveRegionFeeRows(facts, region); + if (rows.isEmpty()) { + continue; + } + String anchor = region.getString("anchor"); + JSONObject columns = region.getJSONObject("columns"); + if (StringUtils.isBlank(anchor) || columns == null) { + continue; + } + ParsedRef anchorRef = ParsedRef.parse(anchor); + Sheet sheet = wb.getSheet(anchorRef.sheet); + if (sheet == null) { + sheet = wb.getSheetAt(0); + } + int startRow = anchorRef.row; + int stopRow = findStopRow(sheet, region.getJSONArray("stopAtText"), startRow); + int styleCopy = region.getIntValue("styleCopyRows"); + if (styleCopy <= 0) { + styleCopy = 1; + } + ensureRowsBeforeStop(sheet, startRow, rows.size(), stopRow, styleCopy); + for (int r = 0; r < rows.size(); r++) { + JSONObject rowObj = rows.getJSONObject(r); + int rowIdx = startRow + r; + writeFeeCol(sheet, rowIdx, columns.getString("name"), rowObj.getString("name"), yellow, sheet.getSheetName()); + writeFeeCol(sheet, rowIdx, columns.getString("unit"), rowObj.getString("unit"), yellow, sheet.getSheetName()); + writeFeeCol(sheet, rowIdx, columns.getString("quantity"), rowObj.getString("quantity"), yellow, sheet.getSheetName()); + writeFeeCol(sheet, rowIdx, columns.getString("unit_price"), + firstNonBlank(rowObj.getString("unit_price"), rowObj.getString("unitPrice")), + yellow, sheet.getSheetName()); + writeFeeCol(sheet, rowIdx, columns.getString("amount"), rowObj.getString("amount"), yellow, sheet.getSheetName()); + if (columns.containsKey("currency")) { + writeFeeCol(sheet, rowIdx, columns.getString("currency"), rowObj.getString("currency"), yellow, sheet.getSheetName()); + } + if (columns.containsKey("remark")) { + writeFeeCol(sheet, rowIdx, columns.getString("remark"), + formatFeeRemark(rowObj.getString("remark")), yellow, sheet.getSheetName()); + } + if (columns.containsKey("container_rate")) { + writeFeeCol(sheet, rowIdx, columns.getString("container_rate"), + firstNonBlank(rowObj.getString("container_rate"), rowObj.getString("containerType"), + rowObj.getString("unit_price")), + yellow, sheet.getSheetName()); + } + } + } + } + + /** + * 陆运 routeRegions:按 origin/destination/vehicleRates 等列写入。 + */ + private static void writeRouteRegions(Workbook wb, JSONObject mapping, JSONObject facts, Set yellow) { + JSONArray regions = mapping.getJSONArray("routeRegions"); + if (regions == null || regions.isEmpty()) { + return; + } + JSONArray routes = parseFeeRows(facts == null ? null : facts.get("route_rows")); + if (routes.isEmpty()) { + routes = parseFeeRows(facts == null ? null : facts.get("landSegments")); + } + if (routes.isEmpty()) { + // 单行兜底:用 facts 里的 origin/destination + JSONObject one = new JSONObject(true); + if (facts != null) { + one.put("origin", facts.getString("origin")); + one.put("destination", facts.getString("destination")); + one.put("currency", facts.getString("currency")); + one.put("transit_time", firstNonBlank(facts.getString("transit_time"), facts.getString("transit_days"))); + one.put("remark", facts.getString("special_remarks")); + } + if (StringUtils.isNotBlank(one.getString("origin")) || StringUtils.isNotBlank(one.getString("destination"))) { + routes.add(one); + } + } + if (routes.isEmpty()) { + return; + } + for (int i = 0; i < regions.size(); i++) { + JSONObject region = regions.getJSONObject(i); + if (region == null) { + continue; + } + JSONObject columns = region.getJSONObject("columns"); + if (columns == null) { + continue; + } + int startRow = region.getIntValue("dataStartRow"); + if (startRow <= 0) { + startRow = region.getIntValue("headerRow") + 1; + } + if (startRow <= 0) { + continue; + } + // dataStartRow 在合同里常是 1-based Excel 行号 + int rowIdx0 = startRow - 1; + String sheetName = mapping.getString("sheet"); + Sheet sheet = StringUtils.isNotBlank(sheetName) ? wb.getSheet(sheetName) : null; + if (sheet == null) { + sheet = wb.getSheetAt(0); + } + int stopRow = findStopRow(sheet, region.getJSONArray("stopAtText"), rowIdx0); + int styleCopy = region.getIntValue("styleCopyRows"); + if (styleCopy <= 0) { + styleCopy = 1; + } + ensureRowsBeforeStop(sheet, rowIdx0, routes.size(), stopRow, styleCopy); + for (int r = 0; r < routes.size(); r++) { + JSONObject rowObj = routes.getJSONObject(r); + int ri = rowIdx0 + r; + writeFeeCol(sheet, ri, columns.getString("seq"), String.valueOf(r + 1), yellow, sheet.getSheetName()); + writeFeeCol(sheet, ri, columns.getString("origin"), + firstNonBlank(rowObj.getString("origin"), rowObj.getString("originCode")), + yellow, sheet.getSheetName()); + writeFeeCol(sheet, ri, columns.getString("destination"), + firstNonBlank(rowObj.getString("destination"), rowObj.getString("destinationCode")), + yellow, sheet.getSheetName()); + writeFeeCol(sheet, ri, columns.getString("currency"), rowObj.getString("currency"), yellow, sheet.getSheetName()); + writeFeeCol(sheet, ri, columns.getString("transit_time"), + firstNonBlank(rowObj.getString("transit_time"), rowObj.getString("transitDays")), + yellow, sheet.getSheetName()); + writeFeeCol(sheet, ri, columns.getString("remark"), rowObj.getString("remark"), yellow, sheet.getSheetName()); + JSONObject vehicleRates = columns.getJSONObject("vehicleRates"); + if (vehicleRates != null) { + String vehicleType = firstNonBlank(rowObj.getString("vehicleType"), rowObj.getString("vehicle_type")); + String price = firstNonBlank(rowObj.getString("salePrice"), rowObj.getString("costPrice"), + rowObj.getString("amount")); + for (String vk : vehicleRates.keySet()) { + String col = vehicleRates.getString(vk); + if (StringUtils.isBlank(col)) { + continue; + } + if (StringUtils.isBlank(vehicleType) || vehicleTypeEquals(vehicleType, vk)) { + writeFeeCol(sheet, ri, col, price, yellow, sheet.getSheetName()); + } + } + } + } + } + } + + private static boolean vehicleTypeEquals(String actual, String headerKey) { + if (StringUtils.isBlank(actual) || StringUtils.isBlank(headerKey)) { + return false; + } + String a = actual.replace(" ", "").toUpperCase(Locale.ROOT); + String b = headerKey.replace(" ", "").toUpperCase(Locale.ROOT); + return a.equals(b) || a.contains(b) || b.contains(a); + } + + private static JSONArray resolveRegionFeeRows(JSONObject facts, JSONObject region) { + if (facts == null) { + return new JSONArray(); + } + String id = StringUtils.defaultString(region.getString("id")); + String tms = StringUtils.defaultString(region.getString("tmsSource")); + JSONArray rows = parseFeeRows(facts.get("fee_rows_" + id)); + if (rows.isEmpty() && StringUtils.isNotBlank(tms)) { + rows = parseFeeRows(facts.get("fee_rows_" + tms)); + if (rows.isEmpty()) { + rows = parseFeeRows(facts.get(tms)); + } + } + if (rows.isEmpty()) { + rows = parseFeeRows(facts.get("fee_rows")); + } + return rows; + } + + private static int findStopRow(Sheet sheet, JSONArray stopAtText, int fromRow) { + if (sheet == null || stopAtText == null || stopAtText.isEmpty()) { + return Integer.MAX_VALUE; + } + DataFormatter fmt = new DataFormatter(); + List needles = new ArrayList<>(); + for (int i = 0; i < stopAtText.size(); i++) { + String t = StringUtils.trimToEmpty(stopAtText.getString(i)); + if (!t.isEmpty()) { + needles.add(t); + } + } + if (needles.isEmpty()) { + return Integer.MAX_VALUE; + } + int last = sheet.getLastRowNum(); + for (int r = Math.max(0, fromRow); r <= last; r++) { + Row row = sheet.getRow(r); + if (row == null) { + continue; + } + for (Cell cell : row) { + if (cell == null) { + continue; + } + String text = StringUtils.trimToEmpty(fmt.formatCellValue(cell)); + if (text.isEmpty()) { + continue; + } + for (String n : needles) { + if (text.contains(n)) { + return r; + } + } + } + } + return Integer.MAX_VALUE; + } + + /** + * 需要的行数若会撞到 stop 行,则在 stop 前插入并复制样式行。 + */ + private static void ensureRowsBeforeStop(Sheet sheet, int startRow, int needCount, int stopRow, int styleCopyRows) { + if (sheet == null || needCount <= 0) { + return; + } + int endNeeded = startRow + needCount; + if (stopRow == Integer.MAX_VALUE || endNeeded <= stopRow) { + return; + } + int insertAt = Math.max(startRow, stopRow); + int insertCount = endNeeded - stopRow; + if (insertCount <= 0) { + return; + } + sheet.shiftRows(insertAt, Math.max(sheet.getLastRowNum(), insertAt), insertCount, true, false); + int styleFrom = Math.max(0, startRow); + for (int i = 0; i < insertCount; i++) { + int target = insertAt + i; + Row src = sheet.getRow(styleFrom + (i % Math.max(1, styleCopyRows))); + Row dst = sheet.getRow(target); + if (dst == null) { + dst = sheet.createRow(target); + } + if (src != null) { + dst.setHeight(src.getHeight()); + for (int c = 0; c < src.getLastCellNum(); c++) { + Cell sc = src.getCell(c); + if (sc == null) { + continue; + } + Cell dc = dst.getCell(c); + if (dc == null) { + dc = dst.createCell(c); + } + if (sc.getCellStyle() != null) { + dc.setCellStyle(sc.getCellStyle()); + } + dc.setCellValue(""); + } + } + } + } + + private static String firstNonBlank(String... vals) { + if (vals == null) { + return ""; + } + for (String v : vals) { + if (StringUtils.isNotBlank(v) && !"-".equals(v.trim())) { + return v.trim(); + } + } + return ""; + } + private static void writeFeeRows(Workbook wb, JSONObject mapping, JSONObject facts, Set yellow) { JSONObject feeRows = mapping.getJSONObject("fee_rows"); if (feeRows == null) { @@ -1310,6 +1905,47 @@ public final class QuoteTemplateExcelSupport { return false; } + /** + * 合并区索引:origin 单元格地址 → 「A1:C2」范围串;非合并原点不收录。 + */ + private static Map buildMergeIndex(Sheet sheet) { + Map map = new HashMap<>(); + if (sheet == null) { + return map; + } + for (int i = 0; i < sheet.getNumMergedRegions(); i++) { + org.apache.poi.ss.util.CellRangeAddress range = sheet.getMergedRegion(i); + if (range == null) { + continue; + } + String origin = new CellReference(range.getFirstRow(), range.getFirstColumn()).formatAsString(); + String end = new CellReference(range.getLastRow(), range.getLastColumn()).formatAsString(); + map.put(origin, origin + ":" + end); + } + return map; + } + + /** + * 单元格填充色十六进制(无则空串)。给 AI 清单用,黄区识别不依赖此方法。 + */ + private static String cellFillHex(Cell cell) { + if (cell == null || cell.getCellStyle() == null) { + return ""; + } + if (!(cell instanceof XSSFCell)) { + return ""; + } + XSSFColor color = ((XSSFCell) cell).getCellStyle().getFillForegroundXSSFColor(); + if (color == null) { + return ""; + } + byte[] rgb = color.getRGB(); + if (rgb == null || rgb.length < 3) { + return ""; + } + return String.format("%02X%02X%02X", rgb[0] & 0xff, rgb[1] & 0xff, rgb[2] & 0xff); + } + private static Map toStringMap(JSONObject obj) { Map map = new LinkedHashMap<>(); if (obj == null) { @@ -1546,18 +2182,6 @@ public final class QuoteTemplateExcelSupport { return ""; } - static String firstNonBlank(String... vals) { - if (vals == null) { - return ""; - } - for (String v : vals) { - if (StringUtils.isNotBlank(v) && !"-".equals(v.trim())) { - return v.trim(); - } - } - return ""; - } - /** sheet!A1 解析。 */ private static final class ParsedRef { final String sheet; diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateObjectStore.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateObjectStore.java new file mode 100644 index 0000000..45b965f --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateObjectStore.java @@ -0,0 +1,238 @@ +package org.jeecg.modules.inquiry.template; + +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.codec.digest.DigestUtils; +import org.apache.commons.lang3.StringUtils; +import org.jeecg.common.util.MinioUtil; +import org.jeecg.common.util.oss.OssBootUtil; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.InputStream; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.Locale; + +/** + * 报价模板文件指针解析。 + *

+ * Jeecg MinIO/OSS 上传返回完整 http(s) URL,Windows 上 {@code Paths.get("http://...")} + * 会在冒号处抛 Illegal char <:>。本类先认远程 URL,拉到本地缓存再给 POI / 下载。 + * 副作用:写 {@code upload/inquiry/quote-templates/_remote-cache/}。 + * 线程:跟随调用方 HTTP 线程;不持有全局锁。 + */ +@Slf4j +@Component +public class QuoteTemplateObjectStore { + + private static final String BIZ_PATH = "inquiry/quote-templates"; + + @Value("${jeecg.path.upload:}") + private String uploadPath; + + /** + * 尽量删除 objectKey 对应文件(MinIO/OSS/本地)。失败只记日志,不抛给业务层。 + *

+ * 删除模板时调用:库记录必须删掉,对象存储偶发失败不应阻断运营。 + */ + public void tryDelete(String objectKey) { + if (StringUtils.isBlank(objectKey)) { + return; + } + String key = objectKey.trim(); + try { + if (isRemoteUrl(key)) { + String[] parts = splitStorageUrl(key, MinioUtil.getMinioUrl()); + if (parts != null) { + MinioUtil.removeObject(parts[0], parts[1]); + return; + } + log.warn("无法从 URL 拆出 bucket/object,跳过存储删除 key={}", key); + return; + } + Path local = tryLocalFile(key); + if (local != null) { + Files.deleteIfExists(local); + } + } catch (Exception e) { + log.warn("模板对象存储删除失败 key={} err={}", key, e.getMessage()); + } + } + + /** + * 把 objectKey(本地相对路径或 MinIO/OSS URL)变成可读取的本地文件。 + * + * @param objectKey 主账 version.object_key + * @return 已存在的本地文件,或从对象存储落地后的缓存文件 + */ + public Path resolve(String objectKey) { + if (StringUtils.isBlank(objectKey)) { + throw new IllegalArgumentException("objectKey 空"); + } + String key = objectKey.trim(); + if (isRemoteUrl(key)) { + return fetchRemoteToCache(key); + } + Path local = tryLocalFile(key); + if (local != null) { + return local; + } + throw new IllegalStateException("找不到模板文件: " + key); + } + + /** + * 是否为对象存储返回的完整 URL。禁止对这些值调用 Paths.get。 + */ + static boolean isRemoteUrl(String key) { + if (StringUtils.isBlank(key)) { + return false; + } + String lower = key.trim().toLowerCase(Locale.ROOT); + return lower.startsWith("http://") || lower.startsWith("https://"); + } + + /** + * 拆 MinIO 风格 URL:{@code {minioUrl}{bucket}/{objectName}}。 + * + * @return [bucket, objectName],无法识别则 null + */ + static String[] splitStorageUrl(String url, String minioBase) { + if (StringUtils.isBlank(url)) { + return null; + } + String trimmed = url.trim(); + if (StringUtils.isNotBlank(minioBase)) { + String base = minioBase.endsWith("/") ? minioBase : minioBase + "/"; + if (trimmed.startsWith(base)) { + return splitBucketAndObject(trimmed.substring(base.length())); + } + } + try { + URI uri = URI.create(trimmed); + String path = uri.getPath(); + if (StringUtils.isBlank(path)) { + return null; + } + if (path.startsWith("/")) { + path = path.substring(1); + } + return splitBucketAndObject(path); + } catch (Exception e) { + return null; + } + } + + private static String[] splitBucketAndObject(String rest) { + if (StringUtils.isBlank(rest)) { + return null; + } + int slash = rest.indexOf('/'); + if (slash <= 0 || slash >= rest.length() - 1) { + return null; + } + return new String[]{rest.substring(0, slash), rest.substring(slash + 1)}; + } + + /** + * 从 MinIO/OSS 拉到本地缓存。同一 URL 只落一次(文件名带时间戳,一般不会覆盖)。 + */ + private Path fetchRemoteToCache(String url) { + Path cacheDir = Paths.get(StringUtils.defaultIfBlank(uploadPath, System.getProperty("java.io.tmpdir")), + BIZ_PATH, "_remote-cache"); + try { + Files.createDirectories(cacheDir); + } catch (Exception e) { + throw new IllegalStateException("无法创建模板缓存目录: " + cacheDir, e); + } + String ext = guessExtension(url); + Path cacheFile = cacheDir.resolve(DigestUtils.sha256Hex(url) + ext); + try { + if (Files.isRegularFile(cacheFile) && Files.size(cacheFile) > 0) { + return cacheFile; + } + } catch (Exception ignored) { + // 读缓存失败则重新拉取 + } + try (InputStream in = openRemoteStream(url)) { + Files.copy(in, cacheFile, StandardCopyOption.REPLACE_EXISTING); + } catch (Exception e) { + log.error("对象存储落地失败 url={}", url, e); + throw new IllegalStateException("对象存储读取失败: " + e.getMessage(), e); + } + return cacheFile; + } + + private InputStream openRemoteStream(String url) { + String[] parts = splitStorageUrl(url, MinioUtil.getMinioUrl()); + if (parts != null) { + InputStream minio = MinioUtil.getMinioFile(parts[0], parts[1]); + if (minio != null) { + return minio; + } + InputStream oss = OssBootUtil.getOssFile(parts[1], parts[0]); + if (oss != null) { + return oss; + } + } + InputStream ossByUrl = OssBootUtil.getOssFile(url, null); + if (ossByUrl != null) { + return ossByUrl; + } + throw new IllegalStateException("对象存储没有该文件: " + url); + } + + private Path tryLocalFile(String key) { + try { + Path p = Paths.get(key); + if (p.isAbsolute() && Files.isRegularFile(p)) { + return p; + } + } catch (InvalidPathException ignored) { + return null; + } + if (StringUtils.isNotBlank(uploadPath)) { + Path under = Paths.get(uploadPath, key.replace("/", java.io.File.separator)); + if (Files.isRegularFile(under)) { + return under; + } + try { + Path resolved = Paths.get(uploadPath).resolve(key); + if (Files.isRegularFile(resolved)) { + return resolved; + } + } catch (InvalidPathException ignored) { + // 混入奇怪字符时跳过 + } + } + try { + Path relative = Paths.get(key); + if (Files.isRegularFile(relative)) { + return relative; + } + } catch (InvalidPathException ignored) { + return null; + } + return null; + } + + private static String guessExtension(String url) { + String path = url; + int q = path.indexOf('?'); + if (q >= 0) { + path = path.substring(0, q); + } + int slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); + int dot = path.lastIndexOf('.'); + if (dot > slash && path.length() - dot <= 8) { + String ext = path.substring(dot).toLowerCase(Locale.ROOT); + if (ext.matches("\\.[a-z0-9]+")) { + return ext; + } + } + return ".bin"; + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateSnapshotRenderer.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateSnapshotRenderer.java new file mode 100644 index 0000000..3f99e39 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateSnapshotRenderer.java @@ -0,0 +1,201 @@ +package org.jeecg.modules.inquiry.template; + +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * 管理端报价模板「原表截图」:本机 LibreOffice 把 xlsx 转 PNG(失败则 PDF)。 + *

+ * 为何放在主账:后台预览不应依赖智能体进程;与出单 PDF 错开靠文件锁互斥。 + * 禁止:接到企微回调 / 智能体出单热路径;禁止本类起第二台 soffice 并行。 + * 副作用:写临时目录与预览缓存;短阻塞当前 HTTP 线程(仅管理端预览)。 + */ +@Slf4j +@Component +public class QuoteTemplateSnapshotRenderer { + + @Value("${inquiry.quote-template.soffice-path:${LIBREOFFICE_PATH:C:/Program Files/LibreOffice/program/soffice.exe}}") + private String sofficePath; + + @Value("${inquiry.quote-template.soffice-timeout-seconds:90}") + private int timeoutSeconds; + + @Value("${jeecg.path.upload:}") + private String uploadPath; + + /** + * 把上传原表转成预览字节(优先 PNG)。 + * + * @param xlsxPath 已落地的本地 xlsx + * @param versionId 用于临时目录命名 + */ + public PreviewBytes render(Path xlsxPath, String versionId) { + if (xlsxPath == null || !Files.isRegularFile(xlsxPath)) { + throw new IllegalStateException("模板文件不存在,无法生成预览"); + } + Path soffice = Paths.get(StringUtils.defaultIfBlank(sofficePath, + "C:/Program Files/LibreOffice/program/soffice.exe")); + if (!Files.isRegularFile(soffice)) { + throw new IllegalStateException("本机未安装 LibreOffice,无法生成原表预览"); + } + Path workRoot = Paths.get(StringUtils.defaultIfBlank(uploadPath, System.getProperty("java.io.tmpdir")), + "inquiry/quote-templates/_snapshot-work"); + Path lockFile = workRoot.resolve("soffice-admin-preview.lock"); + try { + Files.createDirectories(workRoot); + if (!Files.exists(lockFile)) { + Files.createFile(lockFile); + } + } catch (IOException e) { + throw new IllegalStateException("无法创建预览工作目录: " + e.getMessage(), e); + } + + try (FileChannel channel = FileChannel.open(lockFile, StandardOpenOption.WRITE); + FileLock ignored = channel.lock()) { + Path workDir = workRoot.resolve("v-" + sanitize(versionId) + "-" + System.currentTimeMillis()); + Files.createDirectories(workDir); + try { + Path localXlsx = workDir.resolve("source.xlsx"); + Files.copy(xlsxPath, localXlsx, StandardCopyOption.REPLACE_EXISTING); + Path pdf = convert(soffice, localXlsx, workDir, "pdf:calc_pdf_Export", ".pdf"); + if (pdf == null || !Files.isRegularFile(pdf)) { + throw new IllegalStateException("LibreOffice 未生成 PDF"); + } + Path png = convert(soffice, pdf, workDir, "png", ".png"); + if (png != null && Files.isRegularFile(png) && Files.size(png) > 0) { + return new PreviewBytes(Files.readAllBytes(png), "image/png"); + } + log.warn("PNG 未生成,退回 PDF 预览 versionId={}", versionId); + return new PreviewBytes(Files.readAllBytes(pdf), "application/pdf"); + } finally { + deleteQuietly(workDir); + } + } catch (IllegalStateException e) { + throw e; + } catch (Exception e) { + log.warn("原表截图失败 versionId={} err={}", versionId, e.getMessage()); + throw new IllegalStateException("原表预览生成失败:" + e.getMessage(), e); + } + } + + /** + * 调用 soffice 无界面转换;已在文件锁内,禁止再开并行实例。 + */ + private Path convert(Path soffice, Path source, Path outDir, String filter, String suffix) + throws IOException, InterruptedException { + List cmd = new ArrayList<>(); + cmd.add(soffice.toAbsolutePath().toString()); + cmd.add("--headless"); + cmd.add("--nologo"); + cmd.add("--nolockcheck"); + cmd.add("--convert-to"); + cmd.add(filter); + cmd.add("--outdir"); + cmd.add(outDir.toAbsolutePath().toString()); + cmd.add(source.toAbsolutePath().toString()); + + ProcessBuilder pb = new ProcessBuilder(cmd); + pb.redirectErrorStream(true); + Process p = pb.start(); + boolean finished = p.waitFor(Math.max(30, timeoutSeconds), TimeUnit.SECONDS); + String out = new String(readAll(p.getInputStream()), StandardCharsets.UTF_8); + if (!finished) { + p.destroyForcibly(); + throw new IllegalStateException("LibreOffice 转换超时"); + } + if (p.exitValue() != 0) { + log.warn("soffice rc={} filter={} out={}", p.exitValue(), filter, + out.length() > 400 ? out.substring(0, 400) : out); + } + String stem = stripExt(source.getFileName().toString()); + try (Stream stream = Files.list(outDir)) { + List hits = stream + .filter(f -> Files.isRegularFile(f)) + .filter(f -> { + String n = f.getFileName().toString().toLowerCase(); + return n.startsWith(stem.toLowerCase()) && n.endsWith(suffix.toLowerCase()); + }) + .sorted(Comparator.comparingLong((Path f) -> f.toFile().lastModified()).reversed()) + .collect(Collectors.toList()); + return hits.isEmpty() ? null : hits.get(0); + } + } + + private static byte[] readAll(InputStream in) throws IOException { + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + byte[] chunk = new byte[4096]; + int n; + while ((n = in.read(chunk)) >= 0) { + buf.write(chunk, 0, n); + } + return buf.toByteArray(); + } + + private static String stripExt(String name) { + int i = name.lastIndexOf('.'); + return i > 0 ? name.substring(0, i) : name; + } + + private static String sanitize(String id) { + if (StringUtils.isBlank(id)) { + return "unknown"; + } + return id.replaceAll("[^a-zA-Z0-9_-]", "_"); + } + + private static void deleteQuietly(Path dir) { + if (dir == null || !Files.exists(dir)) { + return; + } + try (Stream walk = Files.walk(dir)) { + walk.sorted(Comparator.reverseOrder()).forEach(p -> { + try { + Files.deleteIfExists(p); + } catch (IOException ignored) { + // 清理失败不影响预览结果 + } + }); + } catch (IOException ignored) { + // ignore + } + } + + /** 预览字节 + Content-Type。 */ + public static final class PreviewBytes { + private final byte[] bytes; + private final String contentType; + + public PreviewBytes(byte[] bytes, String contentType) { + this.bytes = bytes; + this.contentType = contentType; + } + + public byte[] getBytes() { + return bytes; + } + + public String getContentType() { + return contentType; + } + + public boolean isImage() { + return contentType != null && contentType.toLowerCase().startsWith("image/"); + } + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/contracts/quote-template-mapping-v1.json b/inquiry-api/jeecg-module-inquiry/src/main/resources/contracts/quote-template-mapping-v1.json index 8498312..90fd301 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/resources/contracts/quote-template-mapping-v1.json +++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/contracts/quote-template-mapping-v1.json @@ -17,11 +17,16 @@ }, "mappingJsonShape": { "schemaVersion": "quote-template-mapping-v1", + "transportMode": "sea|air|land", "sheet": "主工作表名(可空,默认第一个)", + "layoutType": "sea_fee_list|air_fee_list|fee_list_vertical|route_matrix|mixed|other", "fields": { "customer_name": "SheetName!C3", "quote_no": "SheetName!F4" }, + "fieldMeta": { + "quote_no": { "sameCellAfterColon": true } + }, "fee_rows": { "anchor": "SheetName!A15", "columns": { @@ -31,9 +36,45 @@ }, "styleCopyRows": 1 }, + "feeRegions": [ + { + "id": "origin_charges", + "title": "始发港费用", + "tmsSource": "polCostItems", + "headerRow": 18, + "anchor": "Sheet!A19", + "stopAtText": ["始发港费用汇总", "海运费用"], + "columns": { "name": "A", "currency": "B", "amount": "H" }, + "styleCopyRows": 1, + "grow": "insert_before_stop" + } + ], + "routeRegions": [ + { + "id": "route_block_1", + "title": "项目一", + "tmsSource": "landSegments", + "headerRow": 8, + "dataStartRow": 10, + "stopAtText": ["项目二", "交易条件说明"], + "columns": { + "origin": "C", + "destination": "D", + "vehicleRates": { "3T": "E" }, + "currency": "J" + }, + "grow": "insert_before_stop", + "styleCopyRows": 1 + } + ], "fixedYellowCells": ["SheetName!A1", "SheetName!B2"], + "fixedCells": ["SheetName!A1"], "candidates": [], - "unmappedRequired": [] + "unmappedRequired": [], + "unmappedLabels": [], + "mappingGaps": [], + "confidence": 0.85, + "notes": [] }, "systemFields": [ { "code": "customer_name", "label": "客户名称", "requiredHint": true }, @@ -57,7 +98,25 @@ { "code": "pieces", "label": "件数", "requiredHint": false }, { "code": "weight_kg", "label": "重量", "requiredHint": false }, { "code": "volume_cbm", "label": "体积", "requiredHint": false }, + { "code": "chargeable_weight_kg", "label": "计费重", "requiredHint": false }, + { "code": "delivery_address", "label": "派送地址", "requiredHint": false }, + { "code": "pickup_address", "label": "提货地址", "requiredHint": false }, + { "code": "transit_time", "label": "时效", "requiredHint": false }, + { "code": "cargo_value", "label": "货值", "requiredHint": false }, + { "code": "hs_code", "label": "HS编码", "requiredHint": false }, + { "code": "oil_elec_mag", "label": "是否含油电磁", "requiredHint": false }, + { "code": "has_battery", "label": "是否含电", "requiredHint": false }, + { "code": "on_board_date", "label": "开船日", "requiredHint": false }, + { "code": "special_remarks", "label": "特别说明", "requiredHint": false }, + { "code": "eta_request", "label": "要求抵达时间", "requiredHint": false }, + { "code": "sailing_schedule", "label": "船期航班", "requiredHint": false }, + { "code": "shipping_line", "label": "船公司航司", "requiredHint": false }, + { "code": "flight_no", "label": "航班号", "requiredHint": false }, + { "code": "transfer_airport", "label": "中转港", "requiredHint": false }, { "code": "total_amount", "label": "报价合计", "requiredHint": false }, + { "code": "effective_date", "label": "生效时间", "requiredHint": false }, + { "code": "fuel_surcharge_rate", "label": "燃油附加费率", "requiredHint": false }, + { "code": "tax_rate", "label": "税率", "requiredHint": false }, { "code": "fee_rows", "label": "费用明细行", "requiredHint": false } ], "labelDictionary": { diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateAiInventoryTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateAiInventoryTest.java new file mode 100644 index 0000000..f62811f --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateAiInventoryTest.java @@ -0,0 +1,125 @@ +package org.jeecg.modules.inquiry.template; + +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 单元格清单抽取与 feeRegions/routeRegions 填表冒烟。 + *

+ * 桌面样表存在时跑完整清单;否则跳过。不调 LLM。 + */ +public class QuoteTemplateAiInventoryTest { + + private static final Path DESKTOP_DIR = Paths.get( + "C:/Users/Administrator/Desktop/海陆空报价单模板最新-0917/海陆空报价单模板最新-0917"); + + @Test + public void buildCellInventory_fromSeaTemplate() throws Exception { + Path sea = findDesktop("海运报价单模板.xlsx"); + Assumptions.assumeTrue(sea != null && Files.isRegularFile(sea), "桌面海运样表不存在则跳过"); + + JSONObject inv = QuoteTemplateExcelSupport.buildCellInventory(sea); + assertTrue(inv.getIntValue("cellCount") > 10, "应抽出多行单元格"); + assertNotNull(inv.getString("sheet")); + JSONArray lines = inv.getJSONArray("lines"); + assertNotNull(lines); + String first = lines.getString(0); + assertTrue(first.contains("|"), "清单行须含分隔符"); + assertTrue(inv.getIntValue("yellowCount") >= 0); + } + + @Test + public void fillWorkbook_feeRegions_doesNotThrow() throws Exception { + Path sea = findDesktop("海运报价单模板.xlsx"); + Assumptions.assumeTrue(sea != null && Files.isRegularFile(sea), "桌面海运样表不存在则跳过"); + + JSONObject mapping = new JSONObject(true); + mapping.put("schemaVersion", "quote-template-mapping-v1"); + mapping.put("sheet", "Sheet1"); + mapping.put("fields", new JSONObject(true)); + mapping.put("fixedCells", new JSONArray()); + JSONArray regions = new JSONArray(); + JSONObject region = new JSONObject(true); + region.put("id", "ocean_freight"); + region.put("tmsSource", "oceanCostItems"); + region.put("anchor", "Sheet1!A20"); + region.put("stopAtText", JSONArray.parseArray("[\"费用总计\",\"预估费用\"]")); + region.put("grow", "insert_before_stop"); + region.put("styleCopyRows", 1); + JSONObject cols = new JSONObject(true); + cols.put("name", "A"); + cols.put("amount", "H"); + region.put("columns", cols); + regions.add(region); + mapping.put("feeRegions", regions); + + JSONObject facts = new JSONObject(true); + facts.put("quote_date", "2026-09-18"); + JSONArray feeRows = new JSONArray(); + JSONObject row = new JSONObject(true); + row.put("name", "海运费"); + row.put("amount", "100"); + feeRows.add(row); + facts.put("fee_rows", feeRows); + + Path out = Files.createTempFile("quote-fee-region-", ".xlsx"); + try { + QuoteTemplateExcelSupport.fillWorkbook(sea, out, mapping, facts); + assertTrue(Files.size(out) > 1000); + } finally { + Files.deleteIfExists(out); + } + } + + @Test + public void bizTypeToTransportMode() { + assertEquals("sea", InquiryQuoteTemplateServiceImplProxy.sea()); + assertEquals("air", InquiryQuoteTemplateServiceImplProxy.air()); + assertEquals("land", InquiryQuoteTemplateServiceImplProxy.land()); + } + + /** 避免引入 Spring,直接测静态映射方法。 */ + static final class InquiryQuoteTemplateServiceImplProxy { + static String sea() { + return org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl + .bizTypeToTransportMode("海运"); + } + + static String air() { + return org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl + .bizTypeToTransportMode("空运"); + } + + static String land() { + return org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl + .bizTypeToTransportMode("陆运跨境"); + } + } + + private static Path findDesktop(String name) { + if (!Files.isDirectory(DESKTOP_DIR)) { + return null; + } + Path direct = DESKTOP_DIR.resolve(name); + if (Files.isRegularFile(direct)) { + return direct; + } + try { + return Files.list(DESKTOP_DIR) + .filter(p -> p.getFileName().toString().equals(name) + || p.getFileName().toString().startsWith(name.replace(".xlsx", ""))) + .findFirst() + .orElse(null); + } catch (Exception e) { + return null; + } + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateObjectStoreTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateObjectStoreTest.java new file mode 100644 index 0000000..7b4c095 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateObjectStoreTest.java @@ -0,0 +1,45 @@ +package org.jeecg.modules.inquiry.template; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * MinIO 返回的 http URL 不能当本地路径;拆 bucket/objectName 不连存储。 + */ +public class QuoteTemplateObjectStoreTest { + + @Test + public void detectRemoteUrl() { + assertTrue(QuoteTemplateObjectStore.isRemoteUrl( + "http://127.0.0.1:9000/inquiry-robot/inquiry/quote-templates/海运报价单校版_1.xlsx")); + assertTrue(QuoteTemplateObjectStore.isRemoteUrl("https://oss.example.com/a.xlsx")); + assertFalse(QuoteTemplateObjectStore.isRemoteUrl("inquiry/quote-templates/a.xlsx")); + assertFalse(QuoteTemplateObjectStore.isRemoteUrl("D:\\\\upload\\\\a.xlsx")); + } + + @Test + public void splitMinioUrlWithChineseName() { + String url = "http://127.0.0.1:9000/inquiry-robot/inquiry/quote-templates/海运报价单校版_1789608110270.xlsx"; + String[] parts = QuoteTemplateObjectStore.splitStorageUrl(url, "http://127.0.0.1:9000/"); + assertArrayEquals(new String[]{ + "inquiry-robot", + "inquiry/quote-templates/海运报价单校版_1789608110270.xlsx" + }, parts); + } + + @Test + public void splitFallsBackToUriPathWhenBaseMismatches() { + String url = "http://127.0.0.1:9000/inquiry-robot/inquiry/quote-templates/a.xlsx"; + String[] parts = QuoteTemplateObjectStore.splitStorageUrl(url, "http://minio:9000/"); + assertArrayEquals(new String[]{"inquiry-robot", "inquiry/quote-templates/a.xlsx"}, parts); + } + + @Test + public void splitRejectsBlank() { + assertNull(QuoteTemplateObjectStore.splitStorageUrl(" ", "http://127.0.0.1:9000/")); + } +} diff --git a/inquiry-api/jeecg-module-system/jeecg-system-start/src/main/resources/application-inquiry.yml b/inquiry-api/jeecg-module-system/jeecg-system-start/src/main/resources/application-inquiry.yml index 9efab3d..cd59bd3 100644 --- a/inquiry-api/jeecg-module-system/jeecg-system-start/src/main/resources/application-inquiry.yml +++ b/inquiry-api/jeecg-module-system/jeecg-system-start/src/main/resources/application-inquiry.yml @@ -156,6 +156,16 @@ justauth: inquiry: tms: note: YTD_TMS_V2_ENABLED / TMS_V2_BASE_URL / TMS_V2_CLIENT_* 从进程环境注入;LOCK 默认关。测服联调空运锁舱再设 TMS_V2_LOCK_ENABLED=true,现网验收期保持关。 + quote-template: + # 管理端原表截图:主账本机 soffice,不经智能体 + soffice-path: ${LIBREOFFICE_PATH:C:/Program Files/LibreOffice/program/soffice.exe} + soffice-timeout-seconds: ${QUOTE_TEMPLATE_SOFFICE_TIMEOUT:90} + agent: + # 主账提交模板 AI 解析任务的智能体 HTTP(只入队) + base-url: ${AGENT_HTTP_BASE_URL:http://127.0.0.1:8910} + callback-token: ${AGENT_CALLBACK_TOKEN:} + biz-token: ${BIZ_API_TOKEN:} + parse-timeout-ms: ${QUOTE_TEMPLATE_PARSE_ENQUEUE_TIMEOUT_MS:8000} logging: level: diff --git a/inquiry-backend/YTD-README.md b/inquiry-backend/YTD-README.md index 248f80c..45c17f3 100644 --- a/inquiry-backend/YTD-README.md +++ b/inquiry-backend/YTD-README.md @@ -25,6 +25,7 @@ pnpm run dev | 机器人回复语 | `inquiry/botReply/index` | | 账号管理 | `inquiry/account/index` | | 角色管理 | `inquiry/role/index` | +| 系统设置 | `inquiry/systemSetting/index` | 菜单由后端 `sys_permission` 种子下发(见 `deploy/sql/inquiry_admin_menu_seed_v1.sql`)。登录后刷新权限即可看到询价菜单。 diff --git a/inquiry-backend/src/layouts/inquiry/index.vue b/inquiry-backend/src/layouts/inquiry/index.vue index cf5f4b1..986b643 100644 --- a/inquiry-backend/src/layouts/inquiry/index.vue +++ b/inquiry-backend/src/layouts/inquiry/index.vue @@ -14,7 +14,7 @@ import '/@/views/inquiry/styles/admin-fidelity.css'; import '/@/views/inquiry/styles/dashboard-prototype.css'; - type NavIcon = 'dashboard' | 'tickets' | 'staff' | 'template' | 'botReply' | 'account' | 'role'; + type NavIcon = 'dashboard' | 'tickets' | 'staff' | 'template' | 'botReply' | 'account' | 'role' | 'clock'; type NavItem = { key: string; @@ -49,6 +49,7 @@ { title: '系统配置', items: [ + { key: '/inquiry-sys/system-setting', label: '系统设置', icon: 'clock' }, { key: '/inquiry-sys/user', label: '账号管理', icon: 'account' }, { key: '/inquiry-sys/role', label: '角色管理', icon: 'role' }, ], diff --git a/inquiry-backend/src/store/modules/permission.ts b/inquiry-backend/src/store/modules/permission.ts index ac4fcdf..1cba241 100644 --- a/inquiry-backend/src/store/modules/permission.ts +++ b/inquiry-backend/src/store/modules/permission.ts @@ -24,6 +24,121 @@ import { getPermCode } from '/@/api/sys/user'; import { useMessage } from '/@/hooks/web/useMessage'; import { PageEnum } from '/@/enums/pageEnum'; +/** + * 询价原型侧栏路径是写死的;Jeecg BACK 模式只注册后台菜单里的路由。 + * 菜单种子缺失/组件路径不对时,点侧栏会进 404(本机与线上相同)。 + * 这里按侧栏清单补挂手写页,不依赖种子是否已导入。 + */ +type InquiryLeaf = { + path: string; + name: string; + component: string; + title: string; +}; + +const INQUIRY_LEAVES: InquiryLeaf[] = [ + { path: '/inquiry/dashboard', name: 'inquiry-dashboard', component: 'inquiry/dashboard/index', title: '分析看板' }, + { path: '/inquiry/work-order', name: 'inquiry-work-order', component: 'inquiry/workOrder/index', title: '询价工单' }, + { path: '/inquiry/staff-role', name: 'inquiry-staff-role', component: 'inquiry/staffRole/index', title: '员工管理' }, + { path: '/inquiry/template', name: 'inquiry-template', component: 'inquiry/template/index', title: '报价模板' }, + { path: '/inquiry/bot-reply', name: 'inquiry-bot-reply', component: 'inquiry/botReply/index', title: '机器人回复语' }, + { + path: '/inquiry-sys/system-setting', + name: 'inquiry-system-setting', + component: 'inquiry/systemSetting/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: '角色管理' }, +]; + +function findRouteByPath(list: AppRouteRecordRaw[] = [], targetPath: string): AppRouteRecordRaw | null { + for (const item of list) { + const p = item.path || ''; + if (p === targetPath || p === targetPath.replace(/^\//, '') || p.endsWith(targetPath)) { + return item; + } + const nested = findRouteByPath(item.children as AppRouteRecordRaw[] | undefined, targetPath); + if (nested) { + return nested; + } + } + return null; +} + +function findParent(list: AppRouteRecordRaw[] = [], parentPath: string): AppRouteRecordRaw | null { + for (const item of list) { + const p = item.path || ''; + if (p === parentPath || p.endsWith(parentPath)) { + return item; + } + const nested = findParent(item.children as AppRouteRecordRaw[] | undefined, parentPath); + if (nested) { + return nested; + } + } + return null; +} + +function ensureInquiryHandwrittenRoutes(routeList: AppRouteRecordRaw[]) { + const groups: Record = { + '/inquiry': { + title: '询价', + redirect: '/inquiry/dashboard', + leaves: INQUIRY_LEAVES.filter((l) => l.path.startsWith('/inquiry/') && !l.path.startsWith('/inquiry-sys')), + }, + '/inquiry-sys': { + title: '询价系统配置', + redirect: '/inquiry-sys/system-setting', + leaves: INQUIRY_LEAVES.filter((l) => l.path.startsWith('/inquiry-sys/')), + }, + }; + + for (const [parentPath, group] of Object.entries(groups)) { + for (const leaf of group.leaves) { + const existing = findRouteByPath(routeList, leaf.path); + if (existing) { + // 菜单有路径但组件指错(常见:空、旧 dashboard/Analysis)→ 纠正为手写页 + const comp = String((existing as any).component || ''); + if (!comp.includes(leaf.component) && !comp.includes(leaf.component.replace(/\/index$/, ''))) { + (existing as any).component = leaf.component; + existing.meta = { ...(existing.meta || {}), title: leaf.title, ignoreKeepAlive: true }; + } + continue; + } + const node: AppRouteRecordRaw = { + path: leaf.path, + name: leaf.name, + component: leaf.component as any, + meta: { + title: leaf.title, + ignoreKeepAlive: true, + }, + }; + let parent = findParent(routeList, parentPath); + if (!parent) { + parent = { + path: parentPath, + name: parentPath.replace(/\//g, '-').replace(/^-/, '') || 'inquiry-root', + component: 'LAYOUT' as any, + redirect: group.redirect, + meta: { title: group.title }, + children: [], + }; + routeList.push(parent); + } + parent.children = parent.children || []; + if (leaf.path === group.redirect) { + parent.children.unshift(node); + } else { + parent.children.push(node); + } + } + } + return routeList; +} + + // 系统权限 interface AuthItem { // 菜单权限编码,例如:“sys:schedule:list,sys:schedule:info”,多个逗号隔开 @@ -219,6 +334,7 @@ export const usePermissionStore = defineStore({ try { this.changePermissionCode(); routeList = (await getMenuList()) as AppRouteRecordRaw[]; + routeList = ensureInquiryHandwrittenRoutes(routeList); // update-begin----author:sunjianlei---date:20220315------for: 判断是否是 vue3 版本的菜单 --- let hasIndex: boolean = false; let hasIcon: boolean = false; diff --git a/inquiry-backend/src/utils/http/axios/Axios.ts b/inquiry-backend/src/utils/http/axios/Axios.ts index 4d7c296..6a7050f 100644 --- a/inquiry-backend/src/utils/http/axios/Axios.ts +++ b/inquiry-backend/src/utils/http/axios/Axios.ts @@ -4,7 +4,7 @@ import type { CreateAxiosOptions } from './axiosTransform'; import axios from 'axios'; import qs from 'qs'; import { AxiosCanceler } from './axiosCancel'; -import { isFunction } from '/@/utils/is'; +import { isFormData, isFunction } from '/@/utils/is'; import { cloneDeep } from 'lodash-es'; import { ContentTypeEnum } from '/@/enums/httpEnum'; import { RequestEnum } from '/@/enums/httpEnum'; @@ -201,7 +201,23 @@ export class VAxios { } request(config: AxiosRequestConfig, options?: RequestOptions): Promise { + // lodash cloneDeep 会把 FormData 变成空对象,Tomcat 随后报 no multipart boundary。 + // 文件字段必须按原引用发出,浏览器才能自动补 boundary。 + const rawData = config.data; + const rawParams = config.params; let conf: CreateAxiosOptions = cloneDeep(config); + if (isFormData(rawData)) { + conf.data = rawData; + } + if (isFormData(rawParams)) { + conf.params = rawParams; + } + // 默认头是 application/json;手写 multipart/form-data 又不带 boundary,Tomcat 会拒。 + // 删掉 Content-Type,让浏览器给 FormData 自动加 boundary。 + if (isFormData(conf.data) && conf.headers) { + delete (conf.headers as Record)['Content-Type']; + delete (conf.headers as Record)['content-type']; + } const transform = this.getTransform(); const { requestOptions } = this.options; @@ -215,6 +231,10 @@ export class VAxios { conf.requestOptions = opt; conf = this.supportFormData(conf); + if (isFormData(conf.data) && conf.headers) { + delete (conf.headers as Record)['Content-Type']; + delete (conf.headers as Record)['content-type']; + } return new Promise((resolve, reject) => { this.axiosInstance diff --git a/inquiry-backend/src/utils/http/axios/index.ts b/inquiry-backend/src/utils/http/axios/index.ts index fe9c1e4..79eafab 100644 --- a/inquiry-backend/src/utils/http/axios/index.ts +++ b/inquiry-backend/src/utils/http/axios/index.ts @@ -10,7 +10,7 @@ import { router } from '/@/router'; import { useGlobSetting } from '/@/hooks/setting'; import { useMessage } from '/@/hooks/web/useMessage'; import { RequestEnum, ResultEnum, ContentTypeEnum, ConfigEnum } from '/@/enums/httpEnum'; -import { isString } from '/@/utils/is'; +import { isFormData, isString } from '/@/utils/is'; import { getToken, getTenantId } from '/@/utils/auth'; import { setObjToUrlParams, deepMerge } from '/@/utils'; import signMd5Utils from '/@/utils/encryption/signMd5Utils'; @@ -100,6 +100,14 @@ const transform: AxiosTransform = { } const params = config.params || {}; const data = config.data || false; + // FormData 没有可枚举 key;Object.keys 会当成空对象,把文件体换成 params,上传必失败。 + if (isFormData(data) || isFormData(params)) { + config.data = isFormData(data) ? data : params; + if (isFormData(params)) { + config.params = undefined; + } + return config; + } formatDate && data && !isString(data) && formatRequestDate(data); if (config.method?.toUpperCase() === RequestEnum.GET) { if (!isString(params)) { diff --git a/inquiry-backend/src/utils/is.ts b/inquiry-backend/src/utils/is.ts index ec008fb..3ed735f 100644 --- a/inquiry-backend/src/utils/is.ts +++ b/inquiry-backend/src/utils/is.ts @@ -97,6 +97,14 @@ export function isMap(val: unknown): val is Map { return is(val, 'Map'); } +/** + * 是否为浏览器 FormData。 + * lodash cloneDeep / Object.keys 都拿不到表单字段,上传前必须按引用原样发送。 + */ +export function isFormData(val: unknown): val is FormData { + return typeof FormData !== 'undefined' && val instanceof FormData; +} + export const isServer = typeof window === 'undefined'; export const isClient = !isServer; diff --git a/inquiry-backend/src/views/inquiry/mock/templates.ts b/inquiry-backend/src/views/inquiry/mock/templates.ts index 1f9f8d2..517cee3 100644 --- a/inquiry-backend/src/views/inquiry/mock/templates.ts +++ b/inquiry-backend/src/views/inquiry/mock/templates.ts @@ -28,6 +28,8 @@ export type TemplateRow = { currentVersion?: number; parseMessage?: string; unmappedRequired?: string[]; + /** 表上有、对照清单未映射上的中文说明(智能体 mappingGaps) */ + mappingGaps?: string[]; objectKey?: string; }; @@ -58,7 +60,8 @@ export function mapApiRow(raw: any): TemplateRow { versionId: raw.versionId || undefined, currentVersion: raw.currentVersion, parseMessage: raw.parseMessage, - unmappedRequired: raw.unmappedRequired, + unmappedRequired: Array.isArray(raw.unmappedRequired) ? raw.unmappedRequired.map(String) : [], + mappingGaps: Array.isArray(raw.mappingGaps) ? raw.mappingGaps.map(String) : [], objectKey: raw.objectKey, }; } diff --git a/inquiry-backend/src/views/inquiry/styles/admin-prototype.css b/inquiry-backend/src/views/inquiry/styles/admin-prototype.css index 41356aa..d771d36 100644 --- a/inquiry-backend/src/views/inquiry/styles/admin-prototype.css +++ b/inquiry-backend/src/views/inquiry/styles/admin-prototype.css @@ -1495,7 +1495,8 @@ textarea:focus-visible { } .modal-dialog.qt-preview-dialog { - max-width: 560px; + max-width: min(96vw, 980px); + width: min(96vw, 980px); } .modal-dialog.account-modal-dialog { @@ -1887,6 +1888,11 @@ textarea:focus-visible { background: #fff1f2; } +.qt-parse-banner.is-warn { + border-color: #fec84b; + background: #fffaeb; +} + .qt-parse-banner-icon { width: 32px; height: 32px; @@ -1906,6 +1912,11 @@ textarea:focus-visible { color: #be123c; } +.qt-parse-banner-icon.is-warn { + background: #fef0c7; + color: #b54708; +} + .qt-parse-banner-title { font-size: 13px; font-weight: 800; @@ -1916,6 +1927,10 @@ textarea:focus-visible { color: #9f1239; } +.qt-parse-banner.is-warn .qt-parse-banner-title { + color: #b54708; +} + .qt-parse-banner-desc { margin-top: 2px; font-size: 11px; @@ -1927,6 +1942,10 @@ textarea:focus-visible { color: #be123c; } +.qt-parse-banner.is-warn .qt-parse-banner-desc { + color: #93370d; +} + .qt-upload-zone { display: grid; gap: 16px; diff --git a/inquiry-backend/src/views/inquiry/systemSetting/index.vue b/inquiry-backend/src/views/inquiry/systemSetting/index.vue new file mode 100644 index 0000000..0918afc --- /dev/null +++ b/inquiry-backend/src/views/inquiry/systemSetting/index.vue @@ -0,0 +1,205 @@ + + + + + diff --git a/inquiry-backend/src/views/inquiry/systemSetting/systemSetting.api.ts b/inquiry-backend/src/views/inquiry/systemSetting/systemSetting.api.ts new file mode 100644 index 0000000..b5c476e --- /dev/null +++ b/inquiry-backend/src/views/inquiry/systemSetting/systemSetting.api.ts @@ -0,0 +1,26 @@ +/** + * 系统设置 · 响应超时配置 API。 + * 对接主账 /inquiry/responseTimeout;仅 listAll + 改小时数。 + */ +import { defHttp } from '/@/utils/http/axios'; + +enum Api { + listAll = '/inquiry/responseTimeout/listAll', + edit = '/inquiry/responseTimeout/edit', +} + +export type ResponseTimeoutRow = { + id: string; + transportMode: string; + roleCode: string; + routeCategory?: string; + transportType?: string; + timeoutHours: number; + sortNo?: number; +}; + +export const getTimeoutListAll = () => defHttp.get({ url: Api.listAll }); + +/** 仅提交 id + timeoutHours,维度字段不传以免误改。 */ +export const saveTimeoutHours = (id: string, timeoutHours: number) => + defHttp.put({ url: Api.edit, data: { id, timeoutHours } }); diff --git a/inquiry-backend/src/views/inquiry/template/index.vue b/inquiry-backend/src/views/inquiry/template/index.vue index fec9125..024a526 100644 --- a/inquiry-backend/src/views/inquiry/template/index.vue +++ b/inquiry-backend/src/views/inquiry/template/index.vue @@ -3,26 +3,23 @@ * 报价模板页:对接主账 /inquiry/quoteTemplate。 * - 上传按钮只在顶栏一处;弹窗用原生 overlay * - 版式由上传 Excel 决定;列表展示「映射点数」而非假占位符 - * - 预览:弹窗内按映射回填合同样例事实(接近原型可读性);权威版式仍以下载 Excel 为准 + * - 预览:原表截图(LibreOffice 转图),不是 AI 画格子 */ -import { computed, onMounted, reactive, ref } from "vue"; -import { message } from "ant-design-vue"; +import { computed, onMounted, onUnmounted, reactive, ref } from "vue"; +import { Modal, message } from "ant-design-vue"; import { mapApiRow, type TemplateRow } from "/@/views/inquiry/mock/templates"; import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue"; import { + deleteTemplate, disableTemplate, downloadTemplateFile, editTemplateMeta, enableTemplate, + fetchTemplateSnapshotBlob, getTemplateList, - getTemplateVersion, + reparseTemplate, uploadTemplate, } from "/@/views/inquiry/template/template.api"; -import { - buildPreviewViewModel, - emptyPreviewViewModel, - type PreviewViewModel, -} from "/@/views/inquiry/template/previewSample"; // buildTemplateFileUrl 已由 downloadTemplateFile 替代(鉴权 blob 下载) @@ -48,8 +45,15 @@ const formOpen = ref(false); const editing = ref(null); const previewOpen = ref(false); const previewRow = ref(null); -/** 弹窗内映射回填一览(合同样例事实);下载 Excel 仍走 previewSample */ -const previewVm = ref(emptyPreviewViewModel()); +/** 原表截图预览状态 */ +const previewState = reactive({ + loading: false, + error: "", + objectUrl: "", + isImage: true, + mappedCount: 0, + fileName: "", +}); const fileInputRef = ref(null); const pendingFile = ref(null); @@ -70,30 +74,85 @@ const parseState = reactive({ ok: true, title: "", desc: "", + /** 表上有、未映射上的中文说明 */ + gaps: [] as string[], }); const formTitle = computed(() => (editing.value ? "编辑报价模板" : "上传报价模板")); const formSub = computed(() => editing.value - ? "可改基本信息;更换文件将追加新版本并重新解析映射" - : "上传 Excel,系统按黄区与标签自动建议单元格映射", + ? "可改基本信息;更换文件将追加新版本并由 AI 异步解析映射" + : "上传 Excel 后 AI 异步解析映射;失败原因会显示在列表", ); const formOkText = computed(() => (editing.value ? "保存" : "上传并解析")); -const previewFootHint = - "用户端生成时将复制本版 Excel 并按映射写入已冻结事实(黄区不改),不改变原有 Excel / PDF 样式;模板改版请重新上传"; - -const previewTotalText = computed(() => { - const vm = previewVm.value; - if (!vm.totalAmount) return "—"; - const n = Number(vm.totalAmount); - const amt = Number.isFinite(n) - ? n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) - : vm.totalAmount; - return `${vm.currency || "USD"} ${amt}`; -}); +const previewFootHint = "按上传原表打印版式截图,比例 1:1 不变形;点击图片可放大。"; +const previewLightbox = ref(false); const filtered = computed(() => rows.value); +function revokePreviewUrl() { + if (previewState.objectUrl) { + URL.revokeObjectURL(previewState.objectUrl); + previewState.objectUrl = ""; + } +} + +/** + * 打开预览:读取上传后已缓存的截图(未就绪则现转)。 + * 副作用:HTTP 读 previewSnapshot;本地 blob URL;不写库。 + */ +async function openPreview(row: TemplateRow) { + previewRow.value = row; + previewOpen.value = true; + previewLightbox.value = false; + revokePreviewUrl(); + previewState.loading = true; + previewState.error = ""; + previewState.isImage = true; + previewState.mappedCount = row.mappingPointCount || 0; + previewState.fileName = row.fileName || ""; + if (!row.versionId) { + previewState.loading = false; + previewState.error = "无可用版本,请先上传模板文件"; + return; + } + if (row.format === "pdf") { + previewState.loading = false; + previewState.error = "PDF 模板请直接下载原文件查看"; + return; + } + try { + const { blob, contentType } = await fetchTemplateSnapshotBlob(row.versionId); + previewState.isImage = contentType.includes("image/") || blob.type.includes("image/"); + previewState.objectUrl = URL.createObjectURL(blob); + previewState.loading = false; + } catch (e: any) { + previewState.loading = false; + previewState.error = e?.message || "原表截图尚未就绪,请稍后重试或下载原模板"; + } +} + +function closeForm() { + formOpen.value = false; +} + +function closePreview() { + previewOpen.value = false; + previewLightbox.value = false; + revokePreviewUrl(); + previewState.loading = false; + previewState.error = ""; +} + +function openLightbox() { + if (!previewState.objectUrl || !previewState.isImage) return; + previewLightbox.value = true; +} + +function closeLightbox() { + previewLightbox.value = false; +} + function bizTag(biz: string) { if (biz === "海运") return "tag-teal"; if (biz === "陆运") return "tag-amber"; @@ -118,6 +177,7 @@ async function loadList() { }); const records = res?.records || res?.result?.records || []; rows.value = (records as any[]).map(mapApiRow); + scheduleParsePoll(); } catch (e: any) { message.error(e?.message || "加载模板列表失败"); } finally { @@ -125,11 +185,50 @@ async function loadList() { } } +/** 列表里仍有「解析中」时每 4 秒刷新,直到全部结束 */ +let parsePollTimer: number | null = null; + +function hasPendingParse() { + return rows.value.some((r) => r.parseStatus === "解析中"); +} + +function scheduleParsePoll() { + if (parsePollTimer != null) { + window.clearTimeout(parsePollTimer); + parsePollTimer = null; + } + if (!hasPendingParse()) return; + parsePollTimer = window.setTimeout(async () => { + parsePollTimer = null; + try { + const res: any = await getTemplateList({ + keyword: keyword.value.trim() || undefined, + bizType: bizFilter.value || undefined, + status: statusFilter.value || undefined, + pageNo: 1, + pageSize: 200, + }); + const records = res?.records || res?.result?.records || []; + rows.value = (records as any[]).map(mapApiRow); + } catch { + /* 轮询失败不打断页面 */ + } + scheduleParsePoll(); + }, 4000); +} + onMounted(() => { clearOrphanAntMasks(); loadList(); }); +onUnmounted(() => { + if (parsePollTimer != null) { + window.clearTimeout(parsePollTimer); + parsePollTimer = null; + } +}); + function resetForm() { Object.assign(form, { name: "", @@ -146,6 +245,7 @@ function resetForm() { parseState.ok = true; parseState.title = ""; parseState.desc = ""; + parseState.gaps = []; } function openCreate() { @@ -168,15 +268,28 @@ function openEdit(row: TemplateRow) { }); pendingFile.value = null; parseState.visible = true; - parseState.ok = row.parseStatus === "已解析" || row.parseStatus === "映射未完成"; + parseState.ok = row.parseStatus === "已解析" || row.parseStatus === "映射未完成" || row.parseStatus === "解析中"; parseState.title = row.parseStatus || "解析状态"; + parseState.gaps = listMappingGaps(row); parseState.desc = row.parseMessage || `已识别 ${row.mappingPointCount} 个映射点` + - (row.unmappedRequired?.length ? `;未映射:${row.unmappedRequired.join(",")}` : ""); + (parseState.gaps.length ? `;未解析到 ${parseState.gaps.length} 项` : "") + + (row.unmappedRequired?.length && !parseState.gaps.length + ? `;未映射:${row.unmappedRequired.join(",")}` + : ""); formOpen.value = true; } +/** 运营可见的未解析项:优先 mappingGaps,其次 AI 自报 unmappedRequired */ +function listMappingGaps(row: TemplateRow): string[] { + if (row.mappingGaps?.length) return [...row.mappingGaps]; + if (row.unmappedRequired?.length) { + return row.unmappedRequired.map((c) => `字段未映射:${c}`); + } + return []; +} + function normalizeKeywordItems(items: string[]): string[] { const unique: string[] = []; const seen = new Set(); @@ -199,50 +312,6 @@ function removeKeyword(index: number) { form.keywordItems.splice(index, 1); } -/** - * 打开预览:先展示列表摘要,再拉 version.mappingJson 组装样例回填一览。 - * 副作用:HTTP 读版本详情;不写库、不调 LLM。 - */ -async function openPreview(row: TemplateRow) { - previewRow.value = row; - previewOpen.value = true; - previewVm.value = { - ...emptyPreviewViewModel(), - loading: true, - mappedCount: row.mappingPointCount || 0, - }; - if (!row.versionId) { - previewVm.value = { - ...emptyPreviewViewModel(), - loading: false, - error: "无可用版本,请先上传模板文件", - mappedCount: row.mappingPointCount || 0, - }; - return; - } - try { - const ver: any = await getTemplateVersion(row.versionId); - const mappingJson = ver?.mappingJson ?? ver?.result?.mappingJson; - previewVm.value = buildPreviewViewModel(mappingJson, row.mappingPointCount || 0); - } catch (e: any) { - previewVm.value = { - ...emptyPreviewViewModel(), - loading: false, - error: e?.message || "加载映射失败,仍可下载预览文件", - mappedCount: row.mappingPointCount || 0, - }; - } -} - -function closeForm() { - formOpen.value = false; -} - -function closePreview() { - previewOpen.value = false; - previewVm.value = emptyPreviewViewModel(); -} - async function toggleStatus(row: TemplateRow) { try { if (row.status === "enabled") { @@ -262,6 +331,56 @@ async function toggleStatus(row: TemplateRow) { } } +/** + * 重新提交 AI 映射解析(已解析也可点;解析中禁用)。 + * 副作用:主账入队;列表进入解析中并轮询。 + */ +async function reparseRow(row: TemplateRow) { + if (row.parseStatus === "解析中") { + message.info("已在解析中,请稍候"); + return; + } + if (!row.versionId) { + message.warning("无可用版本,请先上传文件"); + return; + } + try { + const msg: any = await reparseTemplate(row.id); + message.success(typeof msg === "string" ? msg : msg?.message || "已提交重新解析"); + await loadList(); + } catch (e: any) { + message.error(e?.message || "重新解析失败"); + } +} + +/** + * 删除模板:二次确认后调主账物理删除。 + * 副作用:列表刷新;对象存储文件尽量清理;已出单附件不受影响。 + */ +function removeTemplate(row: TemplateRow) { + const tips: string[] = []; + if (row.status === "enabled") tips.push("当前为启用状态"); + if (row.isDefault) tips.push("当前为默认模板"); + const extra = tips.length ? `(${tips.join(",")})` : ""; + Modal.confirm({ + title: `删除模板「${row.name}」?`, + content: `将删除该模板及全部版本${extra},此操作不可恢复。`, + okText: "删除", + okType: "danger", + cancelText: "取消", + async onOk() { + try { + await deleteTemplate(row.id); + message.success("已删除"); + await loadList(); + } catch (e: any) { + message.error(e?.message || "删除失败"); + throw e; + } + }, + }); +} + function formatFileSize(bytes: number) { if (!bytes) return "—"; if (bytes < 1024) return `${bytes} B`; @@ -281,7 +400,8 @@ function applySelectedFile(file: File) { parseState.visible = true; parseState.ok = true; parseState.title = "待上传解析"; - parseState.desc = "点击保存后由主账解析黄区并自动建议映射点"; + parseState.desc = "点击保存后提交 AI 异步解析;列表会显示解析中/成功/失败原因与未解析项"; + parseState.gaps = []; } function onFileChange(e: Event) { @@ -325,7 +445,12 @@ async function submitForm() { fd.append("isDefault", String(form.isDefault)); fd.append("file", pendingFile.value); const row: any = await uploadTemplate(fd); - message.success(row?.parseMessage || "已上传并解析"); + const msg = row?.parseMessage || "已提交解析"; + if (String(row?.parseStatus || "").includes("失败") || String(row?.parseStatus || "") === "failed") { + message.error(msg); + } else { + message.success(msg.includes("解析中") ? "已提交 AI 解析,列表将自动刷新状态" : msg); + } } else if (editing.value) { await editTemplateMeta({ id: editing.value.id, @@ -353,17 +478,12 @@ async function downloadPreviewOrOriginal() { message.warning("无版本可下载"); return; } - const kind = row.format === "excel" ? "previewSample" : "download"; try { - message.loading({ content: "正在生成预览文件…", key: "tpl-dl", duration: 0 }); - const hint = - kind === "previewSample" - ? (row.fileName || "preview").replace(/\.xlsx$/i, "-preview.xlsx") - : row.fileName || "template.bin"; - await downloadTemplateFile(kind, row.versionId, hint); + message.loading({ content: "正在下载原模板…", key: "tpl-dl", duration: 0 }); + await downloadTemplateFile("download", row.versionId, row.fileName || "template.bin"); message.success({ content: "已开始下载", key: "tpl-dl" }); } catch (e: any) { - message.error({ content: e?.message || "下载预览文件失败", key: "tpl-dl" }); + message.error({ content: e?.message || "下载原模板失败", key: "tpl-dl" }); } } @@ -422,7 +542,31 @@ async function downloadPreviewOrOriginal() { {{ formatKeywords(row.keywords) || "—" }} - {{ row.parseStatus }} + + {{ row.parseStatus }} + + 未解析 {{ listMappingGaps(row).length }} + +

+ {{ row.parseMessage }} +
+ {{ row.status === "enabled" ? "启用" : "禁用" }} {{ row.useCount }} 次 {{ row.createdAt }} @@ -433,6 +577,15 @@ async function downloadPreviewOrOriginal() { 预览 + + @@ -529,13 +685,26 @@ async function downloadPreviewOrOriginal() { -
-
+
+
-
+
{{ parseState.title }}
{{ parseState.desc }}
+
+
表上有、尚未映射到的项({{ parseState.gaps.length }})
+
    +
  • {{ g }}
  • +
+
@@ -610,7 +779,7 @@ async function downloadPreviewOrOriginal() {
-
基于上传模板渲染 · 用户端预览
+
上传原表 · 截图预览
{{ previewRow.name }}
@@ -621,73 +790,32 @@ async function downloadPreviewOrOriginal() {
- {{ previewRow.fileName }} - 已解析 · {{ previewVm.mappedCount || previewRow.mappingPointCount }} 个映射点 + {{ + previewState.fileName || previewRow.fileName + }} + 已映射 {{ previewState.mappedCount }} 项
-
-
-
-
- {{ previewRow.name.replace(/模板$/, "") || previewRow.name }} -
-
模板款式 · 由上传 Excel 决定(黄区固定)
-
-
-
{{ previewVm.quoteNo }}
-
{{ previewVm.quoteDate }}
-
版本 {{ previewRow.currentVersion || "—" }}
-
-
-
-
正在加载映射回填…
-
- {{ previewVm.error }} -
- -

- * 版式、Logo、签章与业务上传模板一致;上述为预览回填数据,金额只来自冻结报价事实,模型不发明价格。 -

+
+ 正在加载原表截图… +
+
+ {{ previewState.error }} +
+
+ 原表截图预览 +