合并远程报价模板 AI 解析与超时设置:保留私聊图片和附件询价。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -211,7 +211,10 @@ class WorkerRuntime:
|
||||
return
|
||||
entry_id, data = claimed[0]
|
||||
try:
|
||||
process_recognition(data)
|
||||
process_recognition(
|
||||
data,
|
||||
allow_network=bool(self.settings.llm_allow_network),
|
||||
)
|
||||
finally:
|
||||
stream.ack(entry_id)
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""
|
||||
识别任务壳:入队 Redis Stream,Worker HTTP 槽认领后跑占位处理。
|
||||
识别任务:入队 Redis Stream,Worker HTTP 槽认领后处理。
|
||||
|
||||
本文件职责:识别入队;Worker 槽消费。kind=vision 跑私聊图片波次,kind=attachment 跑附件材料,其它 kind 仍占位。
|
||||
本文件职责:enqueue;process_recognition 按 kind 分支。
|
||||
kind=vision 跑私聊图片波次,kind=attachment 跑附件材料,
|
||||
kind=quote_template_parse 跑报价模板 AI 映射;其它 kind 仍占位。
|
||||
禁止:回调线程同步等识别完成;禁止本模块写主账六态。
|
||||
"""
|
||||
|
||||
@@ -41,7 +43,7 @@ def enqueue_recognition(
|
||||
"""
|
||||
识别任务入队(快回)。
|
||||
|
||||
kind 例:attachment_text / vision / sheet_map。
|
||||
kind 例:attachment / vision / quote_template_parse。
|
||||
"""
|
||||
rt = redis or get_redis_runtime()
|
||||
if idempotency_key:
|
||||
@@ -62,14 +64,29 @@ def enqueue_recognition(
|
||||
return RecognitionEnqueueResult(accepted=True, entry_id=entry_id)
|
||||
|
||||
|
||||
def process_recognition(data: dict[str, Any]) -> dict[str, Any]:
|
||||
def process_recognition_stub(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Worker 槽:vision 跑图片波次,其它 kind 仍走占位壳。
|
||||
识别占位:回显 kind,不读附件、不调模型。
|
||||
|
||||
兼容旧调用名;正式路径请用 process_recognition。
|
||||
禁止再调本函数以免与 process_recognition 互相递归。
|
||||
"""
|
||||
return process_recognition(data, allow_network=False)
|
||||
|
||||
|
||||
def process_recognition(data: dict[str, Any], *, allow_network: bool = True) -> dict[str, Any]:
|
||||
"""
|
||||
Worker 槽:按 kind 处理识别/映射任务。
|
||||
|
||||
vision:私聊图片波次收口。
|
||||
attachment:H5 附件材料收口。
|
||||
quote_template_parse:调千问 sheet_map,再 PUT 主账 parseResult。
|
||||
禁止在本函数写六态。
|
||||
"""
|
||||
kind = str(data.get("kind") or "")
|
||||
kind = str(data.get("kind") or "unknown")
|
||||
payload = data.get("payload") if isinstance(data.get("payload"), dict) else {}
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
if kind == "vision":
|
||||
from agent.handlers.image_inquiry import finish_queued_image
|
||||
|
||||
@@ -78,21 +95,94 @@ def process_recognition(data: dict[str, Any]) -> dict[str, Any]:
|
||||
from agent.handlers.attachment_inquiry import finish_queued_attachment
|
||||
|
||||
return finish_queued_attachment(payload)
|
||||
return process_recognition_stub(data)
|
||||
|
||||
|
||||
def process_recognition_stub(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
识别占位:回显 kind,不读附件、不调模型。
|
||||
|
||||
Worker 槽调用;正式实现替换为本函数旁的真实处理器。
|
||||
"""
|
||||
kind = str(data.get("kind") or "unknown")
|
||||
payload = data.get("payload") or {}
|
||||
logger.info("recognition.stub kind=%s keys=%s", kind, sorted(payload.keys()) if isinstance(payload, dict) else [])
|
||||
if kind == "quote_template_parse":
|
||||
return _process_quote_template_parse(payload, allow_network=allow_network)
|
||||
logger.info(
|
||||
"recognition.stub kind=%s keys=%s",
|
||||
kind,
|
||||
sorted(payload.keys()),
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"stub": True,
|
||||
"kind": kind,
|
||||
"note": "识别壳:未跑真实抽取",
|
||||
}
|
||||
|
||||
|
||||
def _process_quote_template_parse(payload: dict[str, Any], *, allow_network: bool) -> dict[str, Any]:
|
||||
"""跑 sheet_map 并回写主账;失败也必须回写 failed+原因。"""
|
||||
from agent.config import get_settings
|
||||
from agent.llm.mode_sheet_map import invoke_sheet_map
|
||||
|
||||
version_id = str(payload.get("versionId") or "").strip()
|
||||
settings = get_settings()
|
||||
net = bool(allow_network and settings.llm_allow_network)
|
||||
result = invoke_sheet_map(payload, allow_network=net)
|
||||
body: dict[str, Any]
|
||||
if result.get("ok") and isinstance(result.get("mapping"), dict):
|
||||
mapping = dict(result["mapping"])
|
||||
gaps = result.get("gaps_after")
|
||||
if not isinstance(gaps, list):
|
||||
gaps = mapping.get("mappingGaps") if isinstance(mapping.get("mappingGaps"), list) else []
|
||||
mapping["mappingGaps"] = [str(g) for g in gaps if g]
|
||||
# 运营可读摘要:点数之外固定带上未解析项
|
||||
n_fields = len(mapping.get("fields") or {})
|
||||
n_fee = len(mapping.get("feeRegions") or []) if isinstance(mapping.get("feeRegions"), list) else 0
|
||||
n_route = len(mapping.get("routeRegions") or []) if isinstance(mapping.get("routeRegions"), list) else 0
|
||||
points = n_fields + n_fee + n_route
|
||||
parse_message = f"已解析 {points} 个映射点"
|
||||
if mapping["mappingGaps"]:
|
||||
preview = ";".join(mapping["mappingGaps"][:8])
|
||||
more = len(mapping["mappingGaps"]) - 8
|
||||
parse_message += f";未解析到 {len(mapping['mappingGaps'])} 项:{preview}"
|
||||
if more > 0:
|
||||
parse_message += f" 等共{len(mapping['mappingGaps'])}项"
|
||||
body = {
|
||||
"versionId": version_id,
|
||||
"ok": True,
|
||||
"mappingJson": mapping,
|
||||
"parseStatus": "parsed",
|
||||
"parseMessage": parse_message,
|
||||
}
|
||||
else:
|
||||
err = str(result.get("error") or "AI 解析失败")
|
||||
body = {
|
||||
"versionId": version_id,
|
||||
"ok": False,
|
||||
"parseStatus": "failed",
|
||||
"parseMessage": err,
|
||||
}
|
||||
posted = _post_parse_result(body)
|
||||
logger.info(
|
||||
"quote_template_parse done versionId=%s ok=%s posted=%s",
|
||||
version_id,
|
||||
body.get("ok"),
|
||||
posted.get("ok"),
|
||||
)
|
||||
return {"ok": bool(body.get("ok")), "posted": posted, "kind": "quote_template_parse"}
|
||||
|
||||
|
||||
def _post_parse_result(body: dict[str, Any]) -> dict[str, Any]:
|
||||
"""PUT /inquiry/agent/quoteTemplate/parseResult。"""
|
||||
try:
|
||||
from agent.ledger import LedgerClient
|
||||
|
||||
client = LedgerClient.from_settings()
|
||||
resp = client.request_json(
|
||||
"PUT",
|
||||
"/inquiry/agent/quoteTemplate/parseResult",
|
||||
json_body=body,
|
||||
idempotency_key=f"parseResult:{body.get('versionId')}",
|
||||
)
|
||||
if not resp.ok:
|
||||
logger.warning(
|
||||
"parseResult 回写失败 status=%s err=%s",
|
||||
resp.status_code,
|
||||
resp.error,
|
||||
)
|
||||
return {"ok": False, "error": resp.error or f"http_{resp.status_code}"}
|
||||
return {"ok": True, "data": resp.data}
|
||||
except Exception as exc:
|
||||
logger.exception("parseResult 回写异常")
|
||||
return {"ok": False, "error": str(exc)}
|
||||
|
||||
@@ -154,6 +154,10 @@ class LlmHttpClient:
|
||||
if provider == "qwen":
|
||||
# 识图只要可见原文。思考开着时 content 常为空,销售侧就会「认不到」。
|
||||
body["enable_thinking"] = False
|
||||
# 报价模板 mappingJson 体积大,显式抬高上限,降低半截 JSON
|
||||
mode_val = mode.value if isinstance(mode, LlmMode) else str(mode)
|
||||
if mode_val == LlmMode.SHEET_MAP.value:
|
||||
body["max_tokens"] = 8192
|
||||
if tools:
|
||||
body["tools"] = tools
|
||||
if tool_choice is not None:
|
||||
|
||||
@@ -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。
|
||||
Reference in New Issue
Block a user