Compare commits

...
Author SHA1 Message Date
linmianxunandCursor 527050bbf9 落地报价模板 AI 解析与对象存储,并增加回复超时系统设置页。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 17:42:46 +08:00
41 changed files with 4965 additions and 426 deletions
@@ -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}
+2
View File
@@ -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
+5 -2
View File
@@ -19,7 +19,7 @@ from agent.jobs.archive import archive_stream, process_archive_stub
from agent.jobs.libreoffice import LibreOfficeConverter
from agent.jobs.pdf_job import pdf_stream, process_pdf_job
from agent.jobs.quote_fill import fill_quote_workbook
from agent.jobs.recognition import process_recognition_stub
from agent.jobs.recognition import process_recognition
from agent.jobs.wake_consumer import claim_and_apply_wake, wake_stream
from agent.llm import LlmGateway, LlmMode
from agent.redis_coord.runtime import RedisRuntime
@@ -211,7 +211,10 @@ class WorkerRuntime:
return
entry_id, data = claimed[0]
try:
process_recognition_stub(data)
process_recognition(
data,
allow_network=bool(self.settings.llm_allow_network),
)
finally:
stream.ack(entry_id)
+100 -5
View File
@@ -1,7 +1,7 @@
"""
识别任务壳:入队 Redis Stream,Worker HTTP 槽认领后跑占位处理。
识别任务:入队 Redis Stream,Worker HTTP 槽认领后处理。
本文件职责:enqueue / process_stub;正式识别(附件/OCR/字段)后续按 payload.kind 分支。
本文件职责:enqueue;process_recognition 按 kind 分支(含报价模板 AI 映射)。
禁止:回调线程同步等识别完成;禁止本模块写主账六态。
"""
@@ -41,7 +41,7 @@ def enqueue_recognition(
"""
识别任务入队(快回)。
kind 例:attachment_text / vision / sheet_map。
kind 例:attachment_text / vision / quote_template_parse。
"""
rt = redis or get_redis_runtime()
if idempotency_key:
@@ -66,14 +66,109 @@ def process_recognition_stub(data: dict[str, Any]) -> dict[str, Any]:
"""
识别占位:回显 kind,不读附件、不调模型。
Worker 槽调用;正式实现替换为本函数旁的真实处理器。
兼容旧调用名;正式路径请用 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 处理识别/映射任务。
quote_template_parse:调千问 sheet_map,再 PUT 主账 parseResult。
"""
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 not isinstance(payload, dict):
payload = {}
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)}
+4
View File
@@ -115,6 +115,10 @@ class LlmHttpClient:
"messages": messages,
"temperature": temperature,
}
# 报价模板 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:
+326 -4
View File
@@ -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]}"
@@ -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。
+122
View File
@@ -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())
@@ -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()
+150
View File
@@ -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()
@@ -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<String> quoteTemplateParseResult(@RequestBody Map<String, Object> 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<Map<String, Object>> create(@RequestBody AgentTicketCreateRequest req,
@@ -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<Map<String, Object>> metrics(@RequestParam(name = "range", defaultValue = "today") String range) {
return Result.OK(dashboardService.metrics(range));
@@ -33,7 +33,7 @@ import java.util.Map;
* <p>
* 路径 /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<Map<String, Object>> 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<String> 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<String> 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<String> reparse(@RequestParam String id) {
String msg = quoteTemplateService.reparseLatest(id);
return Result.OK(msg);
}
@AutoLog(value = "报价模板-纠偏映射未完成")
@ApiOperation("把已有映射点但仍标「映射未完成」的版本改为已解析")
@RequiresPermissions("inquiry:template:edit")
@PostMapping("/repairIncompleteParse")
public Result<String> 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<Map<String, Object>> 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<Resource> 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")
@@ -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(系统设置页)。
* <p>
* 只提供全量列表与改小时数;无 add/delete,维度由 SQL 种子固定。
* 本接口供管理后台使用;工单超时判定尚未接入。
* </p>
*/
@Slf4j
@Api(tags = "询价-响应超时配置")
@RestController
@RequestMapping("/inquiry/responseTimeout")
public class InquiryResponseTimeoutController
extends JeecgController<InquiryResponseTimeout, IInquiryResponseTimeoutService> {
private static final Map<String, String> MODE_LABEL = new HashMap<>();
private static final Map<String, String> 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;
/**
* 全量列表。
* <p>
* 不加 @RequiresPermissions:系统设置页面向已登录后台管理员;
* Jeecg「管理员」身份不会自动绕过按钮权限,线上角色授权易漏导致整页不可用。
* 仍走 JWT 登录校验;菜单可见性由前端路由控制。
* </p>
*/
@ApiOperation("全量列表(按 sort_no,供系统设置页一次加载)")
@GetMapping("/listAll")
public Result<List<InquiryResponseTimeout>> listAll() {
// 打开系统设置时顺带纠偏旧运输类型文案(幂等,已是新文案则无写)
responseTimeoutService.ensureCanonicalTransportTypeLabels();
QueryWrapper<InquiryResponseTimeout> qw = new QueryWrapper<>();
qw.orderByAsc("sort_no");
return Result.OK(responseTimeoutService.list(qw));
}
/**
* 仅改 timeoutHours。请求体只需 id + timeoutHours;其它维度字段即使传入也忽略。
*/
@AutoLog(value = "响应超时-改小时数")
@ApiOperation("编辑超时小时(仅 timeoutHours)")
@PutMapping("/edit")
public Result<String> 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<Map<String, Map<String, String>>> labels() {
Map<String, Map<String, String>> 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";
}
}
@@ -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。
* <p>
* 维度行由种子固定(运输方式/角色/线路类别/运输类型),后台只允许改 timeoutHours。
* 本表是配置真相;工单超时判定后续再读本表,本期仅 CRUD 小时数。
* </p>
*/
@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;
}
@@ -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<InquiryResponseTimeout> {
}
@@ -38,6 +38,14 @@ public interface IInquiryQuoteTemplateService extends IService<InquiryQuoteTempl
void disableTemplate(String templateId, String operator);
/**
* 删除逻辑模板及其全部版本行。
* <p>
* 副作用:删 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<InquiryQuoteTempl
Path buildSamplePreview(String versionId) throws Exception;
/**
* 解析本地上传路径为绝对 Path。
* 管理端 Excel 样式预览:读上传原表格子原文 + 黄区/映射标注。
*/
Map<String, Object> buildSheetPreview(String versionId) throws Exception;
/**
* 管理端原表截图预览:本机 LibreOffice 转 PNG/PDF 并缓存,不经智能体。
* <p>
* 副作用:可能写 upload/.../preview/{versionId}-snapshot.png|pdf;短等 soffice。
*/
Path buildSnapshotPreview(String versionId) throws Exception;
/**
* 把 version.object_key(本地相对路径或 MinIO/OSS 完整 URL)解析为可读取的本地文件。
*/
Path resolveObjectPath(String objectKey);
/**
* 当前版本解析失败时按已存文件再提交 AI 异步解析,不新增版本。
* <p>
* 编辑保存时顺带调用:仅 failed 才重跑,避免已解析模板每次改名称都打千问。
*/
void reparseLatestIfFailed(String templateId);
/**
* 运营显式「重新解析」:最新 excel 版在 failed / parsed / mapping_incomplete 时可重入队;
* pending 中不重复提交。
*
* @return 给人看的结果说明
*/
String reparseLatest(String templateId);
/**
* 智能体回写 AI 映射结果(成功 mappingJson 或失败原因)。
* <p>
* 终态优先:已是 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 的模板指针列表。
*/
@@ -0,0 +1,31 @@
package org.jeecg.modules.inquiry.service;
import com.baomidou.mybatisplus.extension.service.IService;
import org.jeecg.modules.inquiry.entity.InquiryResponseTimeout;
/**
* 响应超时配置服务。
* <p>
* 仅支持查询与改小时数;禁止增删维度行。
* </p>
*/
public interface IInquiryResponseTimeoutService extends IService<InquiryResponseTimeout> {
/**
* 仅更新超时小时数;维度字段不可改。
*
* @param id 配置行 id
* @param timeoutHours 新小时数(≥0)
* @param updateBy 操作人登录名
*/
void updateTimeoutHours(String id, int timeoutHours, String updateBy);
/**
* 把陆运产品「普件,…」运输类型对齐产品确认的括号文案。
* <p>
* 仅替换已知旧文案 → 新括号格式;已是新文案则无副作用。
* 列表接口调用一次即可,避免后台长期显示旧逗号写法。
* </p>
*/
void ensureCanonicalTransportTypeLabels();
}
@@ -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;
/**
* 报价模板服务实现。
* <p>
* 副作用:写 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<InquiryQuoteTem
private static final String BIZ_PATH = "inquiry/quote-templates";
/**
* 截图生成单线程池:上传接口快回,后台慢慢转;与智能体出单错开靠 SnapshotRenderer 文件锁。
*/
private static final ExecutorService SNAPSHOT_EXEC = Executors.newSingleThreadExecutor(r -> {
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<InquiryQuoteTem
m.put("objectKey", ver.getObjectKey());
m.put("parseMessage", ver.getParseMessage());
m.put("unmappedRequired", extractUnmapped(ver.getMappingJson()));
m.put("mappingGaps", extractMappingGaps(ver.getMappingJson()));
} else {
m.put("versionId", null);
m.put("fileName", "");
@@ -165,6 +193,17 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
updateById(template);
}
// 先读字节:MinIO upload 会关掉流;解析必须用这份字节,不能把返回的 http URL 当本地路径。
byte[] fileBytes;
try {
fileBytes = file.getBytes();
} catch (Exception e) {
throw new IllegalStateException("读取上传文件失败: " + e.getMessage(), e);
}
if (fileBytes.length == 0) {
throw new IllegalArgumentException("上传文件为空");
}
String objectKey;
try {
objectKey = CommonUtils.upload(file, BIZ_PATH, uploadType);
@@ -177,12 +216,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
}
int nextVer = nextVersionNo(template.getId());
String sha;
try (InputStream in = file.getInputStream()) {
sha = DigestUtils.sha256Hex(in);
} catch (Exception e) {
sha = null;
}
String sha = DigestUtils.sha256Hex(fileBytes);
InquiryQuoteTemplateVersion version = new InquiryQuoteTemplateVersion();
version.setTemplateId(template.getId());
@@ -201,37 +235,26 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
version.setMappingPointCount(0);
version.setMappingJson("{\"schemaVersion\":\"quote-template-mapping-v1\",\"fields\":{},\"note\":\"pdf_manual_or_download_only\"}");
version.setParseMessage("PDF 不做单元格映射,可用于 manual 下载");
versionMapper.insert(version);
} else {
try {
Path local = resolveObjectPath(objectKey);
JSONObject contract = QuoteTemplateExcelSupport.loadContract(resolveContractPath());
JSONObject mapping = QuoteTemplateExcelSupport.parseWorkbook(local, contract);
JSONArray unmapped = mapping.getJSONArray("unmappedRequired");
int points = mapping.getJSONObject("fields") == null ? 0 : mapping.getJSONObject("fields").size();
version.setMappingJson(mapping.toJSONString());
version.setCandidatesJson(mapping.getJSONArray("candidates") == null
? "[]" : mapping.getJSONArray("candidates").toJSONString());
version.setMappingPointCount(points);
if (unmapped != null && !unmapped.isEmpty() && "auto".equals(template.getMode())) {
version.setParseStatus("mapping_incomplete");
version.setParseMessage("已解析 " + points + " 个映射点,尚有未映射必填:" + unmapped.toJSONString());
} else {
version.setParseStatus("parsed");
version.setParseMessage("已解析 " + points + " 个映射点");
}
} catch (Exception e) {
log.error("模板解析失败 objectKey={}", objectKey, e);
version.setParseStatus("failed");
version.setParseMessage("解析失败: " + e.getMessage());
version.setMappingPointCount(0);
}
version.setParseStatus("pending");
version.setParseMessage("解析中,请稍候");
version.setMappingPointCount(0);
version.setMappingJson("{\"schemaVersion\":\"quote-template-mapping-v1\",\"fields\":{},\"status\":\"pending\"}");
versionMapper.insert(version);
// 事务提交后再入队,避免 Worker 回写时版本行尚未可见
scheduleAiParseAfterCommit(version.getId(), template.getId(), fileBytes);
}
versionMapper.insert(version);
if (isDefault) {
setDefault(template.getId(), true, operator);
}
// 上传/换文件后立刻异步生成截图缓存,打开预览时尽量不现转
if (!isPdf && StringUtils.isNotBlank(version.getId())) {
enqueueSnapshotPreview(version.getId());
}
Map<String, Object> 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<InquiryQuoteTem
return result;
}
/**
* 事务提交后提交 AI 解析;无事务时立刻提交。
*/
private void scheduleAiParseAfterCommit(String versionId, String templateId, byte[] fileBytes) {
Runnable job = () -> {
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<String, Object> 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<InquiryQuoteTem
throw new IllegalArgumentException("mappingJson 非法");
}
mapping.put("schemaVersion", "quote-template-mapping-v1");
normalizeUnmappedRequired(mapping);
JSONObject fields = mapping.getJSONObject("fields");
int points = fields == null ? 0 : fields.size();
JSONArray feeRegions = mapping.getJSONArray("feeRegions");
if (feeRegions != null) {
points += feeRegions.size();
}
JSONArray routeRegions = mapping.getJSONArray("routeRegions");
if (routeRegions != null) {
points += routeRegions.size();
}
ver.setMappingJson(mapping.toJSONString());
ver.setMappingPointCount(points);
JSONArray unmapped = mapping.getJSONArray("unmappedRequired");
InquiryQuoteTemplate template = getById(ver.getTemplateId());
if (unmapped != null && !unmapped.isEmpty() && template != null && "auto".equals(template.getMode())) {
if (points <= 0) {
ver.setParseStatus("mapping_incomplete");
ver.setParseMessage("映射已保存,尚有未映射必填:" + unmapped.toJSONString());
ver.setParseMessage("映射已保存,但没有可用映射点");
} else if (unmapped != null && !unmapped.isEmpty()) {
ver.setParseStatus("parsed");
ver.setParseMessage("映射已确认,共 " + points + " 个映射点;可选缺口:" + unmapped.toJSONString());
} else {
ver.setParseStatus("parsed");
ver.setParseMessage("映射已确认,共 " + points + " 个映射点");
@@ -297,6 +423,9 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
if ("failed".equals(ver.getParseStatus())) {
throw new IllegalStateException("解析失败的版本不能启用");
}
if ("pending".equals(ver.getParseStatus())) {
throw new IllegalStateException("模板仍在解析中,请稍后再启用");
}
if ("auto".equals(template.getMode()) && "mapping_incomplete".equals(ver.getParseStatus())) {
throw new IllegalStateException("自动模板须完成必填映射后才能启用");
}
@@ -334,6 +463,34 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
updateById(template);
}
/**
* 物理删除模板:先清版本与对象存储指针,再删逻辑头。
* <p>
* 启用中的模板也可删(运营确认后);删后智能体不再匹配到该模板。
* 对象存储清理失败不回滚库删,避免运营删不掉。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteTemplate(String templateId, String operator) {
InquiryQuoteTemplate template = getById(templateId);
if (template == null) {
throw new IllegalArgumentException("模板不存在");
}
List<InquiryQuoteTemplateVersion> 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<InquiryQuoteTem
return target;
}
/**
* 读上传原 Excel,输出表格快照供后台「Excel 样式」预览。
* 副作用:可能从 MinIO 落地缓存;不改 mapping、不写样例事实。
*/
@Override
public Map<String, Object> 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<String, Object> out = new LinkedHashMap<>();
out.put("versionId", versionId);
out.put("fileName", ver.getFileName());
out.putAll(grid);
return out;
}
/**
* 原表截图预览:优先读本地缓存;未命中则本机 LibreOffice 生成(不经智能体)。
* <p>
* 副作用:写 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<InquiryQuoteTem
}
}
/**
* 最近一版解析失败时,用已存 objectKey 再提交 AI 异步解析,不新增版本。
* 仅 failed(编辑保存顺带补解析用)。
*/
@Override
public void reparseLatestIfFailed(String templateId) {
InquiryQuoteTemplateVersion ver = latestExcelVersion(templateId);
if (ver == null || !"failed".equals(ver.getParseStatus())) {
return;
}
enqueueReparse(templateId, ver);
}
/**
* 运营显式重新解析:failed / parsed / mapping_incomplete 可重跑;pending 跳过。
*/
@Override
public String reparseLatest(String templateId) {
InquiryQuoteTemplate template = getById(templateId);
if (template == null) {
throw new IllegalArgumentException("模板不存在");
}
InquiryQuoteTemplateVersion ver = latestExcelVersion(templateId);
if (ver == null) {
throw new IllegalStateException("无可用 Excel 版本,请先上传文件");
}
String st = ver.getParseStatus();
if ("pending".equals(st)) {
return "已在解析中,请稍候刷新列表";
}
if (!("failed".equals(st) || "parsed".equals(st) || "mapping_incomplete".equals(st))) {
throw new IllegalStateException("当前状态不可重新解析: " + st);
}
enqueueReparse(templateId, ver);
return "已提交重新解析,列表将显示解析中";
}
private InquiryQuoteTemplateVersion latestExcelVersion(String templateId) {
return versionMapper.selectOne(new QueryWrapper<InquiryQuoteTemplateVersion>()
.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<InquiryQuoteTemplateVersion> list = versionMapper.selectList(new QueryWrapper<InquiryQuoteTemplateVersion>()
.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<String> 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<String> loadAllowedFieldCodes() {
Set<String> 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;
}
/**
* 把解析结果写进版本行。不落库。
* <p>
* 产品口径(对齐映射 AI 提示词):系统字段白名单是天花板不是硬必填。
* 表上没有的标签不应拖成「映射未完成」。只要有可用 fields / feeRegions / routeRegions,即视为已解析;
* 仅当几乎没有任何可写映射时才标 mapping_incomplete(auto 不可启用)。
* <p>
* 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<InquiryQuoteTemplateVersion>()
.eq("template_id", templateId)
@@ -753,6 +1354,31 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
}
}
/**
* 从 mappingJson 取出智能体对照清单算出的未映射说明(中文)。
*/
private static List<String> 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<String> 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 "已解析";
@@ -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,不动维度列(文案纠偏除外)。
* <p>
* 为何单独方法而不用 updateById 整行:避免前端误传改写运输方式/角色等固定种子。
* 运输类型纠偏是产品确认的一次性旧→新映射,不开放通用改维度接口。
* </p>
*/
@Service
public class InquiryResponseTimeoutServiceImpl
extends ServiceImpl<InquiryResponseTimeoutMapper, InquiryResponseTimeout>
implements IInquiryResponseTimeoutService {
/**
* 陆运产品运输类型:旧逗号写法 → 产品确认的括号写法。
* 急件文案不变,不在此映射。
*/
private static final Map<String, String> 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<String, String> 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;
}
}
}
@@ -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 映射解析任务(只入队,不在此等千问)。
* <p>
* 副作用:出站 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<String, Object> 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) + "…";
}
}
@@ -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<String, String> dict = toStringMap(contract.getJSONObject("labelDictionary"));
JSONArray systemFields = contract.getJSONArray("systemFields");
Set<String> requiredHints = new HashSet<>();
@@ -212,7 +221,7 @@ public final class QuoteTemplateExcelSupport {
JSONArray candidates = new JSONArray();
Set<String> 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)。
* <p>
* 每行格式:地址 | 原文 | 合并范围 | 填充色。与「报价模板映射-AI提示词」用户消息对齐。
* 上限避免超大表撑爆模型上下文;优先保留有字或黄底的格。
*
* @return sheet / lines(文本行数组) / cellCount / yellowCount / truncated
*/
public static void fillWorkbook(Path sourceXlsx, Path targetXlsx, JSONObject mapping, JSONObject facts) throws Exception {
Set<String> 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<String, String> 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 样式预览」:读上传原表前若干行/列,带出原文、黄区、映射字段。
* <p>
* 不写样例事实;只读文件。行/列有上限,避免大表拖垮后台弹窗。
*
* @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<String, String> cellToField = new HashMap<>();
Map<String, String> 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<String, String> defaultFieldLabels() {
Map<String, String> 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<String> 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<String> collectFixedCells(JSONObject mapping) {
Set<String> 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<String> 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<String> 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<String> 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<String> 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<String, String> buildMergeIndex(Sheet sheet) {
Map<String, String> 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<String, String> toStringMap(JSONObject obj) {
Map<String, String> 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;
@@ -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;
/**
* 报价模板文件指针解析。
* <p>
* Jeecg MinIO/OSS 上传返回完整 http(s) URL,Windows 上 {@code Paths.get("http://...")}
* 会在冒号处抛 Illegal char &lt;:&gt;。本类先认远程 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/本地)。失败只记日志,不抛给业务层。
* <p>
* 删除模板时调用:库记录必须删掉,对象存储偶发失败不应阻断运营。
*/
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";
}
}
@@ -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)。
* <p>
* 为何放在主账:后台预览不应依赖智能体进程;与出单 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<String> 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<Path> stream = Files.list(outDir)) {
List<Path> 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<Path> 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/");
}
}
}
@@ -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": {
@@ -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 填表冒烟。
* <p>
* 桌面样表存在时跑完整清单;否则跳过。不调 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;
}
}
}
@@ -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/"));
}
}
@@ -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:
+1
View File
@@ -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`)。登录后刷新权限即可看到询价菜单。
@@ -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' },
],
@@ -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<string, { title: string; redirect: string; leaves: InquiryLeaf[] }> = {
'/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;
+21 -1
View File
@@ -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<T = any>(config: AxiosRequestConfig, options?: RequestOptions): Promise<T> {
// 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<string, unknown>)['Content-Type'];
delete (conf.headers as Record<string, unknown>)['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<string, unknown>)['Content-Type'];
delete (conf.headers as Record<string, unknown>)['content-type'];
}
return new Promise((resolve, reject) => {
this.axiosInstance
@@ -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)) {
+8
View File
@@ -97,6 +97,14 @@ export function isMap(val: unknown): val is Map<any, any> {
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;
@@ -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,
};
}
@@ -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;
@@ -0,0 +1,205 @@
<script setup>
/**
* 系统设置:响应超时小时配置。
* 海运 / 空运 / 陆运同页分三块展示,只允许改超时小时;无增删、无 Tab。
* 不展示线路类别(维度仍在主账,仅后台列表隐藏)。
* 数据来自主账 inq_response_timeout;本页不做工单超时判定。
*/
import { computed, onMounted, ref } from 'vue';
import { message } from 'ant-design-vue';
import {
getTimeoutListAll,
saveTimeoutHours,
} from '/@/views/inquiry/systemSetting/systemSetting.api';
/** 分块顺序:海运 → 空运 → 陆运(产品确认同页分块) */
const MODE_BLOCKS = [
{ code: 'SEA', title: '海运' },
{ code: 'AIR', title: '空运' },
{ code: 'LAND', title: '陆运' },
];
const ROLE_LABEL = {
sales: '销售',
product: '产品',
air_route: '航线',
};
const rows = ref([]);
const loading = ref(false);
const savingId = ref('');
/** 编辑中的草稿小时数(按 id),失焦或回车时提交 */
const draftHours = ref({});
/** 按运输方式分组,块内按 sort_no 排 */
const blocks = computed(() =>
MODE_BLOCKS.map((b) => ({
...b,
rows: rows.value
.filter((r) => r.transportMode === b.code)
.sort((a, c) => (a.sortNo || 0) - (c.sortNo || 0)),
})),
);
function roleLabel(code) {
return ROLE_LABEL[code] || code;
}
function displayOrDash(v) {
const s = (v || '').trim();
return s || '—';
}
async function loadList() {
loading.value = true;
try {
const res = await getTimeoutListAll();
const list = Array.isArray(res) ? res : res?.result || res?.records || [];
rows.value = list.map((r) => ({
...r,
timeoutHours: Number(r.timeoutHours ?? 0),
}));
const next = {};
for (const r of rows.value) {
next[r.id] = r.timeoutHours;
}
draftHours.value = next;
} catch (e) {
message.error(e?.message || '加载超时配置失败');
} finally {
loading.value = false;
}
}
onMounted(() => loadList());
function onHoursInput(id, raw) {
const n = Math.floor(Number(raw));
draftHours.value[id] = Number.isFinite(n) && n >= 0 ? n : 0;
}
async function commitHours(row) {
const hours = draftHours.value[row.id];
if (hours == null || !Number.isFinite(hours) || hours < 0) {
message.warning('超时小时须为 ≥0 的整数');
draftHours.value[row.id] = row.timeoutHours;
return;
}
if (hours === row.timeoutHours) {
return;
}
savingId.value = row.id;
try {
await saveTimeoutHours(row.id, hours);
row.timeoutHours = hours;
message.success('已保存');
} catch (e) {
message.error(e?.message || '保存失败');
draftHours.value[row.id] = row.timeoutHours;
} finally {
savingId.value = '';
}
}
</script>
<template>
<div class="sys-setting-page">
<div class="toolbar-bar">
<span class="hint">固定规则,仅可改超时小时;失焦或回车保存</span>
</div>
<div v-if="loading" style="padding: 24px; color: #94a3b8">加载中…</div>
<template v-else>
<section v-for="block in blocks" :key="block.code" class="section-card mode-block">
<div class="section-card-header">
<div class="section-card-title">{{ block.title }}</div>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th style="width: 88px">角色</th>
<th>运输类型</th>
<th style="width: 140px">超时设置(小时)</th>
</tr>
</thead>
<tbody>
<tr v-if="!block.rows.length">
<td colspan="3" style="color: #94a3b8">暂无配置</td>
</tr>
<tr v-for="row in block.rows" :key="row.id">
<td>{{ roleLabel(row.roleCode) }}</td>
<td>{{ displayOrDash(row.transportType) }}</td>
<td>
<div class="hours-cell">
<input
class="field hours-input"
type="number"
min="0"
step="1"
:value="draftHours[row.id]"
:disabled="savingId === row.id"
@input="onHoursInput(row.id, $event.target.value)"
@blur="commitHours(row)"
@keydown.enter.prevent="commitHours(row)"
/>
<span class="hours-unit">h</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</template>
</div>
</template>
<style scoped>
.sys-setting-page {
display: flex;
flex-direction: column;
gap: 12px;
}
.toolbar-bar {
display: flex;
justify-content: flex-end;
align-items: center;
}
.hint {
font-size: 11px;
color: #94a3b8;
font-weight: 500;
}
.mode-block {
margin: 0;
}
.mode-block .table-wrap {
border: none;
border-radius: 0;
box-shadow: none;
}
.hours-cell {
display: inline-flex;
align-items: center;
gap: 6px;
}
.hours-input {
width: 88px;
min-height: 32px;
padding: 4px 8px;
}
.hours-unit {
font-size: 11px;
color: #667085;
font-weight: 600;
}
</style>
@@ -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 } });
@@ -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<TemplateRow | null>(null);
const previewOpen = ref(false);
const previewRow = ref<TemplateRow | null>(null);
/** 弹窗内映射回填一览(合同样例事实);下载 Excel 仍走 previewSample */
const previewVm = ref<PreviewViewModel>(emptyPreviewViewModel());
/** 原表截图预览状态 */
const previewState = reactive({
loading: false,
error: "",
objectUrl: "",
isImage: true,
mappedCount: 0,
fileName: "",
});
const fileInputRef = ref<HTMLInputElement | null>(null);
const pendingFile = ref<File | null>(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<string>();
@@ -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" });
}
}
</script>
@@ -422,7 +542,31 @@ async function downloadPreviewOrOriginal() {
<td class="qt-col-keywords" :title="formatKeywords(row.keywords)">
{{ formatKeywords(row.keywords) || "—" }}
</td>
<td class="qt-col-muted">{{ row.parseStatus }}</td>
<td
class="qt-col-muted"
:class="{
'qt-parse-fail': row.parseStatus === '失败' || row.parseStatus === '映射未完成',
'qt-parse-gaps': listMappingGaps(row).length > 0 && row.parseStatus === '已解析',
}"
:title="row.parseMessage || row.parseStatus"
>
<span>{{ row.parseStatus }}</span>
<span v-if="listMappingGaps(row).length" class="qt-gap-badge">
未解析 {{ listMappingGaps(row).length }}
</span>
<div
v-if="
row.parseMessage &&
(row.parseStatus === '失败' ||
row.parseStatus === '映射未完成' ||
row.parseStatus === '解析中' ||
listMappingGaps(row).length > 0)
"
class="qt-parse-msg"
>
{{ row.parseMessage }}
</div>
</td>
<td class="qt-col-muted">{{ row.status === "enabled" ? "启用" : "禁用" }}</td>
<td class="qt-col-count">{{ row.useCount }} 次</td>
<td class="qt-col-time">{{ row.createdAt }}</td>
@@ -433,6 +577,15 @@ async function downloadPreviewOrOriginal() {
预览
</button>
<button type="button" class="link-action" @click.stop="openEdit(row)">编辑</button>
<button
type="button"
class="link-action"
:disabled="row.parseStatus === '解析中'"
:title="row.parseStatus === '解析中' ? '解析中,请稍候' : '重新提交 AI 映射解析'"
@click.stop="reparseRow(row)"
>
重新解析
</button>
<button
type="button"
class="link-action"
@@ -441,6 +594,9 @@ async function downloadPreviewOrOriginal() {
>
{{ row.status === "enabled" ? "禁用" : "启用" }}
</button>
<button type="button" class="link-action link-danger" @click.stop="removeTemplate(row)">
删除
</button>
</div>
</td>
</tr>
@@ -529,13 +685,26 @@ async function downloadPreviewOrOriginal() {
</div>
</div>
<div v-if="parseState.visible" class="qt-parse-banner" :class="{ 'is-error': !parseState.ok }">
<div class="qt-parse-banner-icon" :class="parseState.ok ? 'is-ok' : 'is-err'">
<div
v-if="parseState.visible"
class="qt-parse-banner"
:class="{ 'is-error': !parseState.ok, 'is-warn': parseState.ok && parseState.gaps.length > 0 }"
>
<div
class="qt-parse-banner-icon"
:class="!parseState.ok ? 'is-err' : parseState.gaps.length ? 'is-warn' : 'is-ok'"
>
<ProtoIcon :name="parseState.ok ? 'circleCheck' : 'circleX'" :size="16" />
</div>
<div>
<div class="qt-parse-banner-body">
<div class="qt-parse-banner-title">{{ parseState.title }}</div>
<div class="qt-parse-banner-desc">{{ parseState.desc }}</div>
<div v-if="parseState.gaps.length" class="qt-gap-block">
<div class="qt-gap-block-title">表上有、尚未映射到的项({{ parseState.gaps.length }})</div>
<ul class="qt-gap-list">
<li v-for="(g, i) in parseState.gaps" :key="i">{{ g }}</li>
</ul>
</div>
</div>
</div>
@@ -610,7 +779,7 @@ async function downloadPreviewOrOriginal() {
<ProtoIcon name="eye" :size="16" />
</div>
<div>
<div class="qt-modal-sub">基于上传模板渲染 · 用户端预览</div>
<div class="qt-modal-sub">上传原表 · 截图预览</div>
<div class="qt-modal-title">{{ previewRow.name }}</div>
</div>
</div>
@@ -621,73 +790,32 @@ async function downloadPreviewOrOriginal() {
<div class="qt-preview-modal-body">
<div class="qt-preview-tags">
<span class="tag" :class="bizTag(previewRow.bizType)">{{ previewRow.fileName }}</span>
<span class="tag tag-green"
>已解析 · {{ previewVm.mappedCount || previewRow.mappingPointCount }} 个映射点</span
>
<span class="tag" :class="bizTag(previewRow.bizType)">{{
previewState.fileName || previewRow.fileName
}}</span>
<span class="tag tag-green">已映射 {{ previewState.mappedCount }} 项</span>
</div>
<div class="qt-paper">
<div class="qt-paper-head">
<div>
<div class="qt-paper-title">
{{ previewRow.name.replace(/模板$/, "") || previewRow.name }}
</div>
<div class="qt-paper-cap">模板款式 · 由上传 Excel 决定(黄区固定)</div>
</div>
<div class="qt-paper-meta">
<div v-if="previewVm.quoteNo">{{ previewVm.quoteNo }}</div>
<div v-if="previewVm.quoteDate">{{ previewVm.quoteDate }}</div>
<div v-else>版本 {{ previewRow.currentVersion || "—" }}</div>
</div>
</div>
<hr />
<div v-if="previewVm.loading" class="qt-paper-loading">正在加载映射回填…</div>
<div v-else-if="previewVm.error" class="qt-paper-loading qt-paper-loading--err">
{{ previewVm.error }}
</div>
<template v-else>
<div class="qt-paper-grid">
<div v-for="f in previewVm.fields" :key="f.code">
<span class="qt-paper-label">{{ f.label }}</span>
<span class="qt-paper-value">{{ f.value }}</span>
</div>
<div v-if="!previewVm.fields.length">
<span class="qt-paper-label">映射</span>
<span class="qt-paper-value">暂无已映射字段,请先确认映射或下载 Excel 查看</span>
</div>
</div>
<div v-if="previewVm.totalAmount" class="qt-paper-total">
<div class="qt-paper-total-label">报价合计(样例)</div>
<div class="qt-paper-amt">{{ previewTotalText }}</div>
<div class="qt-paper-total-hint">
按映射回填合同样例事实
<template v-if="previewVm.validUntil"> · 有效期至 {{ previewVm.validUntil }}</template>
</div>
</div>
<table v-if="previewVm.fees.length" class="qt-paper-fee">
<thead>
<tr>
<th>费用项</th>
<th style="text-align: right">金额</th>
</tr>
</thead>
<tbody>
<tr v-for="(fee, idx) in previewVm.fees" :key="idx">
<td>{{ fee.name }}</td>
<td style="text-align: right">{{ fee.amount }}</td>
</tr>
</tbody>
<tfoot>
<tr>
<td>合计</td>
<td style="text-align: right">{{ previewVm.totalAmount }}</td>
</tr>
</tfoot>
</table>
</template>
<p class="qt-paper-note">
* 版式、Logo、签章与业务上传模板一致;上述为预览回填数据,金额只来自冻结报价事实,模型不发明价格。
</p>
<div v-if="previewState.loading" class="qt-paper-loading">
正在加载原表截图…
</div>
<div v-else-if="previewState.error" class="qt-paper-loading qt-paper-loading--err">
{{ previewState.error }}
</div>
<div v-else-if="previewState.objectUrl" class="qt-snapshot-wrap">
<img
v-if="previewState.isImage"
class="qt-snapshot-img"
:src="previewState.objectUrl"
alt="原表截图预览"
title="点击放大"
@click="openLightbox"
/>
<iframe
v-else
class="qt-snapshot-iframe"
:src="previewState.objectUrl"
title="原表 PDF 预览"
/>
</div>
<p class="qt-preview-foot-hint">{{ previewFootHint }}</p>
</div>
@@ -695,15 +823,38 @@ async function downloadPreviewOrOriginal() {
<div class="qt-preview-modal-footer">
<button type="button" class="btn btn-secondary" @click="closePreview">关闭</button>
<button type="button" class="btn btn-primary" @click="downloadPreviewOrOriginal">
<ProtoIcon name="download" :size="16" />下载预览文件
<ProtoIcon name="download" :size="16" />下载原模板
</button>
</div>
</div>
</div>
</Teleport>
<!-- 截图放大灯箱:1:1 比例,仅关灯箱不关预览弹窗 -->
<Teleport to="body">
<div
v-if="previewLightbox && previewState.objectUrl"
class="qt-lightbox"
role="dialog"
aria-modal="true"
@click.self="closeLightbox"
>
<button type="button" class="qt-lightbox-close" @click="closeLightbox">关闭</button>
<img
class="qt-lightbox-img"
:src="previewState.objectUrl"
alt="原表截图放大"
/>
</div>
</Teleport>
</template>
<style scoped>
:deep(.qt-preview-dialog),
.qt-preview-dialog {
max-width: min(96vw, 980px) !important;
width: min(96vw, 980px);
}
.qt-col-name {
font-weight: 800;
color: #0f172a;
@@ -718,6 +869,54 @@ async function downloadPreviewOrOriginal() {
font-size: 11px;
color: #334155;
}
.qt-parse-fail {
color: #b91c1c;
}
.qt-parse-gaps {
color: #b45309;
}
.qt-gap-badge {
display: inline-block;
margin-left: 6px;
padding: 1px 6px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
color: #b54708;
background: #fffaeb;
border: 1px solid #fec84b;
vertical-align: middle;
}
.qt-parse-msg {
margin-top: 2px;
font-size: 10px;
font-weight: 500;
color: #b45309;
max-width: 220px;
line-height: 1.35;
white-space: normal;
word-break: break-word;
}
.qt-parse-fail .qt-parse-msg {
color: #b91c1c;
}
.qt-gap-block {
margin-top: 8px;
}
.qt-gap-block-title {
font-size: 11px;
font-weight: 800;
color: #b54708;
margin-bottom: 4px;
}
.qt-gap-list {
margin: 0;
padding-left: 18px;
font-size: 12px;
font-weight: 600;
color: #93370d;
line-height: 1.45;
}
.qt-col-keywords {
font-size: 11px;
font-weight: 600;
@@ -757,6 +956,71 @@ async function downloadPreviewOrOriginal() {
.qt-paper-loading--err {
color: #b42318;
}
.qt-snapshot-wrap {
max-height: min(70vh, 720px);
overflow: auto;
border: 1px solid #e2e8f0;
background: #f1f5f9;
border-radius: 8px;
text-align: center;
}
.qt-snapshot-img {
display: inline-block;
width: auto;
height: auto;
max-width: none;
vertical-align: top;
background: #fff;
cursor: zoom-in;
}
.qt-snapshot-iframe {
display: block;
width: 100%;
min-height: min(70vh, 720px);
border: 0;
background: #fff;
}
.qt-lightbox {
position: fixed;
inset: 0;
z-index: 2000;
background: rgba(15, 23, 42, 0.82);
display: flex;
align-items: center;
justify-content: center;
padding: 48px 24px 24px;
box-sizing: border-box;
}
.qt-lightbox-close {
position: absolute;
top: 16px;
right: 16px;
z-index: 2001;
min-width: 72px;
height: 36px;
padding: 0 14px;
border: 0;
border-radius: 8px;
background: #fff;
color: #0f172a;
font-size: 14px;
font-weight: 700;
cursor: pointer;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
}
.qt-lightbox-close:hover {
background: #f8fafc;
}
.qt-lightbox-img {
display: block;
width: auto;
height: auto;
max-width: calc(100vw - 48px);
max-height: calc(100vh - 72px);
object-fit: contain;
background: #fff;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
}
.qt-create-row--keywords {
/* 与名称/业务同用 center,单行时标签与输入框垂直对齐 */
@@ -843,4 +1107,129 @@ async function downloadPreviewOrOriginal() {
accent-color: #155eef;
cursor: pointer;
}
.qt-parse-miss {
margin: 0 0 12px;
padding: 10px 12px;
border-radius: 8px;
background: #fef3f2;
border: 1px solid #fecdca;
}
.qt-parse-miss-title {
font-size: 11px;
font-weight: 800;
color: #b42318;
margin-bottom: 4px;
}
.qt-parse-miss-list {
font-size: 12px;
font-weight: 600;
color: #912018;
}
.qt-excel-legend {
display: flex;
flex-wrap: wrap;
gap: 14px;
margin: 0 0 10px;
font-size: 11px;
font-weight: 600;
color: #667085;
}
.qt-leg {
display: inline-block;
width: 12px;
height: 12px;
margin-right: 6px;
vertical-align: -2px;
border: 1px solid #d0d5dd;
border-radius: 2px;
}
.qt-leg--yellow {
background: #fff2cc;
border-color: #f5d565;
}
.qt-leg--mapped {
background: #eff8ff;
border-color: #84caff;
box-shadow: inset 0 0 0 1px #2e90fa;
}
.qt-excel-wrap {
max-height: min(62vh, 560px);
overflow: auto;
border: 1px solid #d0d5dd;
border-radius: 8px;
background: #fff;
}
.qt-excel {
border-collapse: collapse;
min-width: 100%;
font-size: 11px;
}
.qt-excel-corner,
.qt-excel-col,
.qt-excel-row {
position: sticky;
background: #f2f4f7;
color: #667085;
font-weight: 700;
text-align: center;
border: 1px solid #e4e7ec;
z-index: 1;
}
.qt-excel-corner {
left: 0;
z-index: 3;
min-width: 36px;
top: 0;
}
.qt-excel-col {
top: 0;
min-width: 88px;
padding: 4px 6px;
}
.qt-excel-row {
left: 0;
z-index: 2;
padding: 0 6px;
min-width: 36px;
}
.qt-excel-cell {
border: 1px solid #e4e7ec;
padding: 4px 6px;
vertical-align: top;
min-width: 88px;
max-width: 160px;
background: #fff;
color: #101828;
}
.qt-excel-cell.is-yellow {
background: #fff2cc;
}
.qt-excel-cell.is-mapped {
outline: 2px solid #2e90fa;
outline-offset: -2px;
background: #f5faff;
}
.qt-excel-cell.is-yellow.is-mapped {
background: #fff8e0;
}
.qt-excel-map-tag {
display: block;
font-size: 9px;
font-weight: 800;
color: #175cd3;
margin-bottom: 2px;
line-height: 1.2;
}
.qt-excel-text {
display: block;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.35;
font-weight: 600;
}
.tag-red {
background: #fef3f2;
color: #b42318;
}
</style>
@@ -1,7 +1,6 @@
/**
* 报价模板预览用:合同样例事实 + 字段中文名。
* 与 contracts/quote-template-mapping-v1.json 的 samplePreviewFacts / systemFields 对齐。
* 仅用于管理端「用户端预览」弹窗回填一览;金额不由模型发明。
* 报价模板预览:Excel 样式网格(上传原文),辅以映射摘要。
* 主数据来自 /previewSheet;mappingJson 仅用于未映射必填提示。
*/
/** 系统字段 code → 中文标签 */
@@ -30,178 +29,82 @@ export const FIELD_LABELS: Record<string, string> = {
total_amount: "报价合计",
};
/** 弹窗优先展示的字段顺序(接近原型) */
export const PREVIEW_FIELD_ORDER = [
"customer_name",
"container_type_qty",
"pol",
"pod",
"origin",
"destination",
"sales_name",
"commodity",
"fcl_or_lcl",
"trade_terms",
"contact_name",
"contact_phone",
"quantity",
"pieces",
"weight_kg",
"volume_cbm",
"cargo_ready_date",
"currency",
] as const;
/** 与主账 previewSample 同源的合同样例事实 */
export const SAMPLE_PREVIEW_FACTS: Record<string, any> = {
customer_name: "ABC Trading Co.",
contact_name: "张三",
contact_phone: "13800000000",
quote_no: "QT-202607020001",
quote_date: "2026-07-02",
valid_until: "2026-07-05",
currency: "USD",
sales_name: "张敏",
pol: "CNSHA",
pod: "USLAX",
origin: "深圳",
destination: "洛杉矶",
commodity: "通用货物",
quantity: "2",
container_type_qty: "40HQ × 2",
fcl_or_lcl: "FCL",
trade_terms: "FOB",
cargo_ready_date: "2026-07-10",
pieces: "10",
weight_kg: "1200",
volume_cbm: "8.5",
total_amount: "3680.00",
fee_rows: [
{ name: "O/F 海运费", amount: "2900", remark: "" },
{ name: "THC + DOC + SEAL", amount: "780", remark: "" },
],
export type SheetPreviewCell = {
addr: string;
text: string;
yellow: boolean;
mappedCode?: string;
mappedLabel?: string;
};
export type PreviewFeeRow = { name: string; amount: string; remark?: string };
export type PreviewFieldRow = {
code: string;
label: string;
value: string;
cell: string;
export type SheetPreviewRow = {
rowNo: number;
cells: SheetPreviewCell[];
};
export type PreviewViewModel = {
loading: boolean;
error: string;
quoteNo: string;
quoteDate: string;
currency: string;
totalAmount: string;
validUntil: string;
/** 航线摘要:有 pol/pod 或 origin/destination 时拼出 */
routeText: string;
fields: PreviewFieldRow[];
fees: PreviewFeeRow[];
sheet: string;
fileName: string;
colHeaders: string[];
rows: SheetPreviewRow[];
mappedCount: number;
yellowCount: number;
unmappedLabels: string[];
unmappedRequired: string[];
};
function parseMapping(raw: unknown): { fields: Record<string, string>; unmapped: string[] } {
if (!raw) return { fields: {}, unmapped: [] };
let obj: any = raw;
if (typeof raw === "string") {
function parseUnmapped(mappingJson: unknown): string[] {
if (!mappingJson) return [];
let obj: any = mappingJson;
if (typeof mappingJson === "string") {
try {
obj = JSON.parse(raw);
obj = JSON.parse(mappingJson);
} catch {
return { fields: {}, unmapped: [] };
return [];
}
}
const fields: Record<string, string> = {};
const src = obj?.fields || {};
if (src && typeof src === "object") {
for (const [k, v] of Object.entries(src)) {
if (k === "fee_rows") continue;
const cell = String(v ?? "").trim();
if (cell) fields[k] = cell;
}
}
const unmapped = Array.isArray(obj?.unmappedRequired)
? obj.unmappedRequired.map((x: any) => String(x))
: [];
return { fields, unmapped };
return Array.isArray(obj?.unmappedRequired) ? obj.unmappedRequired.map((x: any) => String(x)) : [];
}
/**
* 根据 mappingJson 与样例事实组装弹窗视图。
* 只展示「已映射」字段的样例值;费用行在有 fee_rows 映射或样例费用时展示。
*/
export function buildPreviewViewModel(mappingJson: unknown, pointCountHint = 0): PreviewViewModel {
const { fields: mapped, unmapped } = parseMapping(mappingJson);
const facts = SAMPLE_PREVIEW_FACTS;
const fieldRows: PreviewFieldRow[] = [];
const ordered = [
...PREVIEW_FIELD_ORDER.filter((c) => mapped[c]),
...Object.keys(mapped).filter((c) => !(PREVIEW_FIELD_ORDER as readonly string[]).includes(c)),
];
// 航线合并展示,避免 grid 里重复 pol/pod
const hasRoute = Boolean(mapped.pol || mapped.pod || mapped.origin || mapped.destination);
let routeText = "";
if (hasRoute) {
if (mapped.pol || mapped.pod) {
routeText = `${facts.pol || "—"} → ${facts.pod || "—"}`;
} else {
routeText = `${facts.origin || "—"} → ${facts.destination || "—"}`;
}
}
for (const code of ordered) {
if (code === "pol" || code === "pod" || code === "origin" || code === "destination") {
continue;
}
const rawVal = facts[code];
if (rawVal == null || rawVal === "") continue;
fieldRows.push({
code,
label: FIELD_LABELS[code] || code,
value: String(rawVal),
cell: mapped[code],
});
}
if (routeText) {
fieldRows.splice(Math.min(2, fieldRows.length), 0, {
code: "route",
label: "航线",
value: routeText,
cell: [mapped.pol, mapped.pod, mapped.origin, mapped.destination].filter(Boolean).join(" / "),
});
}
const fees: PreviewFeeRow[] = Array.isArray(facts.fee_rows)
? facts.fee_rows.map((r: any) => ({
name: String(r.name ?? ""),
amount: String(r.amount ?? ""),
remark: String(r.remark ?? ""),
}))
/** 把主账 previewSheet 结果转成弹窗视图 */
export function buildSheetPreviewViewModel(sheetRes: any, mappingJson?: unknown): PreviewViewModel {
const colHeaders = Array.isArray(sheetRes?.colHeaders)
? sheetRes.colHeaders.map((x: any) => String(x))
: [];
const currency = String(facts.currency || "USD");
const total = String(facts.total_amount || "");
const rowsRaw = Array.isArray(sheetRes?.rows) ? sheetRes.rows : [];
const rows: SheetPreviewRow[] = rowsRaw.map((r: any) => ({
rowNo: Number(r.rowNo || 0),
cells: Array.isArray(r.cells)
? r.cells.map((c: any) => ({
addr: String(c.addr || ""),
text: String(c.text || ""),
yellow: Boolean(c.yellow),
mappedCode: c.mappedCode ? String(c.mappedCode) : undefined,
mappedLabel: c.mappedLabel ? String(c.mappedLabel) : undefined,
}))
: [],
}));
let yellowCount = 0;
let mappedHit = 0;
for (const row of rows) {
for (const c of row.cells) {
if (c.yellow) yellowCount += 1;
if (c.mappedCode) mappedHit += 1;
}
}
const unmapped = parseUnmapped(mappingJson);
return {
loading: false,
error: "",
quoteNo: String(facts.quote_no || ""),
quoteDate: String(facts.quote_date || ""),
currency,
totalAmount: total,
validUntil: String(facts.valid_until || ""),
routeText,
fields: fieldRows,
fees,
mappedCount: Object.keys(mapped).length || pointCountHint,
error: String(sheetRes?.error || ""),
sheet: String(sheetRes?.sheet || ""),
fileName: String(sheetRes?.fileName || ""),
colHeaders,
rows,
mappedCount: Number(sheetRes?.mappedCount || mappedHit || 0),
yellowCount,
unmappedLabels: unmapped.map((c) => FIELD_LABELS[c] || c),
unmappedRequired: unmapped,
};
}
@@ -210,15 +113,18 @@ export function emptyPreviewViewModel(): PreviewViewModel {
return {
loading: false,
error: "",
quoteNo: "",
quoteDate: "",
currency: "USD",
totalAmount: "",
validUntil: "",
routeText: "",
fields: [],
fees: [],
sheet: "",
fileName: "",
colHeaders: [],
rows: [],
mappedCount: 0,
yellowCount: 0,
unmappedLabels: [],
unmappedRequired: [],
};
}
/** @deprecated 兼容旧名:请用 buildSheetPreviewViewModel */
export function buildPreviewViewModel(_mappingJson: unknown, _pointCountHint = 0): PreviewViewModel {
return emptyPreviewViewModel();
}
@@ -16,22 +16,75 @@ enum Api {
disable = '/inquiry/quoteTemplate/disable',
setDefault = '/inquiry/quoteTemplate/setDefault',
editMeta = '/inquiry/quoteTemplate/editMeta',
delete = '/inquiry/quoteTemplate/delete',
previewSample = '/inquiry/quoteTemplate/previewSample',
previewSheet = '/inquiry/quoteTemplate/previewSheet',
previewSnapshot = '/inquiry/quoteTemplate/previewSnapshot',
download = '/inquiry/quoteTemplate/download',
reparse = '/inquiry/quoteTemplate/reparse',
}
export const getTemplateList = (params) => defHttp.get({ url: Api.list, params });
/**
* 上传模板文件(新建或追加版本)。
* 禁止手写 Content-Type: multipart/form-data:缺 boundary 会被 Tomcat 直接拒收。
* FormData 交给浏览器自动带 boundary;解析可能超过默认 10 秒,单独放宽超时。
*/
export const uploadTemplate = (data: FormData) =>
defHttp.post({
url: Api.upload,
data,
headers: { 'Content-Type': 'multipart/form-data' },
});
defHttp.post(
{
url: Api.upload,
data,
timeout: 120 * 1000,
},
{ errorMessageMode: 'message' },
);
export const getTemplateVersion = (versionId: string) =>
defHttp.get({ url: Api.version, params: { versionId } });
/** 上传原表 Excel 样式快照(格子原文 + 黄区/映射;旧接口保留) */
export const getTemplateSheetPreview = (versionId: string) =>
defHttp.get({ url: Api.previewSheet, params: { versionId }, timeout: 60 * 1000 });
/**
* 原表截图预览(主账本机 LibreOffice 转 PNG/PDF)。
* 走 blob:带鉴权;首次转换可能较慢。
*/
export async function fetchTemplateSnapshotBlob(versionId: string): Promise<{ blob: Blob; contentType: string }> {
const glob = useGlobSetting();
const base = (glob.apiUrl || '').replace(/\/$/, '');
const url = `${base}${Api.previewSnapshot}?versionId=${encodeURIComponent(versionId)}`;
const token = getToken() || '';
const controller = new AbortController();
const timer = window.setTimeout(() => controller.abort(), 150 * 1000);
try {
const res = await fetch(url, {
method: 'GET',
headers: {
'X-Access-Token': String(token),
},
signal: controller.signal,
});
const ctype = (res.headers.get('content-type') || '').toLowerCase();
if (!res.ok || ctype.includes('application/json')) {
let msg = `预览失败 HTTP ${res.status}`;
try {
const j = await res.json();
if (j?.message) msg = String(j.message);
} catch {
/* ignore */
}
throw new Error(msg);
}
const blob = await res.blob();
return { blob, contentType: ctype || blob.type || 'application/octet-stream' };
} finally {
window.clearTimeout(timer);
}
}
export const listTemplateVersions = (templateId: string) =>
defHttp.get({ url: Api.versions, params: { templateId } });
@@ -44,10 +97,31 @@ export const enableTemplate = (templateId: string, versionId: string) =>
export const disableTemplate = (templateId: string) =>
defHttp.post({ url: Api.disable, params: { templateId } });
/**
* 删除模板逻辑头及全部版本;不可恢复。
* 走 POST(与 Jeecg 惯例一致):部分网关/Nginx 对 DELETE 会直接 404。
* joinParamsToUrl:非 GET 时 axios 会把 params 挪进 JSON 体,必须拼到查询串给 @RequestParam。
*/
export const deleteTemplate = (templateId: string) =>
defHttp.post(
{ url: Api.delete, params: { templateId } },
{ joinParamsToUrl: true },
);
export const setTemplateDefault = (templateId: string, isDefault = true) =>
defHttp.post({ url: Api.setDefault, params: { templateId, isDefault } });
export const editTemplateMeta = (params) => defHttp.put({ url: Api.editMeta, params });
/** 只改名称/业务/关键词/默认标记,不换文件;走 JSON 体对接主账 @RequestBody */
export const editTemplateMeta = (data) => defHttp.put({ url: Api.editMeta, data });
/**
* 对最新 Excel 版重新提交 AI 异步解析(已解析也可点;解析中跳过)。
*/
export const reparseTemplate = (templateId: string) =>
defHttp.post(
{ url: Api.reparse, params: { id: templateId }, timeout: 60 * 1000 },
{ joinParamsToUrl: true },
);
/**
* 带鉴权下载样例预览 / 原文件。