Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
527050bbf9 |
@@ -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}
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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。
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
+29
@@ -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,
|
||||
|
||||
-2
@@ -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));
|
||||
|
||||
+60
-5
@@ -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")
|
||||
|
||||
+110
@@ -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";
|
||||
}
|
||||
}
|
||||
+65
@@ -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;
|
||||
}
|
||||
+12
@@ -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> {
|
||||
}
|
||||
+51
-1
@@ -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 的模板指针列表。
|
||||
*/
|
||||
|
||||
+31
@@ -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();
|
||||
}
|
||||
+683
-57
@@ -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 "已解析";
|
||||
|
||||
+97
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+120
@@ -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) + "…";
|
||||
}
|
||||
}
|
||||
+646
-22
@@ -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;
|
||||
|
||||
+238
@@ -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 <:>。本类先认远程 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";
|
||||
}
|
||||
}
|
||||
+201
@@ -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/");
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
-1
@@ -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": {
|
||||
|
||||
+125
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -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/"));
|
||||
}
|
||||
}
|
||||
+10
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
/**
|
||||
* 带鉴权下载样例预览 / 原文件。
|
||||
|
||||
Reference in New Issue
Block a user