Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4743541f7 | ||
|
|
1a2e1d6cb2 | ||
|
|
9bb69ae72e |
@@ -22,6 +22,9 @@ WECOM_CALLBACK_TOKEN=
|
||||
WECOM_ENCODING_AES_KEY=
|
||||
WECOM_CALLBACK_REPLY_TEXT=询价智能体接通成功。已收到:{content}
|
||||
WECOM_OPS_AGENT_ID=1000014
|
||||
# 现网「询价消息服务」= 运维应用 1000014(与 WECOM_OPERATIONS_* 同一套)。空则只成交不推。
|
||||
WECOM_NOTIFY_AGENT_ID=1000014
|
||||
WECOM_NOTIFY_SECRET=
|
||||
# 会话存档席:后台员工管理里「询价机器人」的企微 userid,每个协同群必拉。可逗号分隔多个。
|
||||
WECOM_ARCHIVE_SEAT_USER_IDS=
|
||||
# 微盛会话存档(Worker 按存档席拉群消息)。游标只写 REDIS_KEY_PREFIX,禁止推现网官方 seq。
|
||||
|
||||
@@ -37,6 +37,9 @@ class QuoteTemplateParseBody(BaseModel):
|
||||
yellow_count: int = Field(default=0, alias="yellowCount")
|
||||
truncated: bool = False
|
||||
request_id: str = Field(default="", alias="requestId")
|
||||
# 未换文件重新解析:true + existingMapping,回写时智能合并(对齐保留、对不上改坐标)
|
||||
merge_existing: bool = Field(default=False, alias="mergeExisting")
|
||||
existing_mapping: Optional[dict[str, Any]] = Field(default=None, alias="existingMapping")
|
||||
|
||||
model_config = {"populate_by_name": True}
|
||||
|
||||
@@ -103,7 +106,10 @@ def enqueue_parse(
|
||||
"cellCount": body.cell_count or len(inventory),
|
||||
"yellowCount": body.yellow_count,
|
||||
"truncated": bool(body.truncated),
|
||||
"mergeExisting": bool(body.merge_existing),
|
||||
}
|
||||
if body.merge_existing and isinstance(body.existing_mapping, dict):
|
||||
payload["existingMapping"] = body.existing_mapping
|
||||
result = enqueue_recognition(
|
||||
kind="quote_template_parse",
|
||||
payload=payload,
|
||||
|
||||
@@ -43,6 +43,15 @@ class ChannelRuntime:
|
||||
secret=settings.wecom_secret,
|
||||
agent_id=agent_id,
|
||||
)
|
||||
notify_id = int(getattr(settings, "wecom_notify_agent_id", "") or "0") or 0
|
||||
notify_secret = str(getattr(settings, "wecom_notify_secret", "") or "").strip()
|
||||
self._notify_client = None
|
||||
if notify_id and notify_secret:
|
||||
self._notify_client = WeComAppClient(
|
||||
corp_id=settings.wecom_corp_id,
|
||||
secret=notify_secret,
|
||||
agent_id=notify_id,
|
||||
)
|
||||
|
||||
@property
|
||||
def store(self):
|
||||
@@ -105,10 +114,20 @@ class ChannelRuntime:
|
||||
logger.warning("outbox 过期丢弃 id=%s", item.id)
|
||||
self._store.complete_outbound(item.id)
|
||||
continue
|
||||
result = self._client.send_outbound(
|
||||
payload = getattr(item, "payload", None) or {}
|
||||
client = self._client
|
||||
if payload.get("wecom_app") == "notify":
|
||||
if self._notify_client is None:
|
||||
logger.error("询价消息服务未配置,成交通知丢弃 id=%s", item.id)
|
||||
self._store.complete_outbound(
|
||||
item.id, error="notify_app_missing", permanent=True
|
||||
)
|
||||
continue
|
||||
client = self._notify_client
|
||||
result = client.send_outbound(
|
||||
touser=item.touser,
|
||||
content=item.content,
|
||||
payload=getattr(item, "payload", None) or {},
|
||||
payload=payload,
|
||||
)
|
||||
if result.ok:
|
||||
self._store.complete_outbound(item.id)
|
||||
|
||||
@@ -60,6 +60,9 @@ class Settings(BaseSettings):
|
||||
wecom_corp_id: str = Field(default="", alias="WECOM_CORP_ID")
|
||||
wecom_agent_id: str = Field(default="1000010", alias="WECOM_AGENT_ID")
|
||||
wecom_secret: str = Field(default="", alias="WECOM_SECRET")
|
||||
# 现网「询价消息服务」:只推成交通知,不走询价小助手 1000010。
|
||||
wecom_notify_agent_id: str = Field(default="", alias="WECOM_NOTIFY_AGENT_ID")
|
||||
wecom_notify_secret: str = Field(default="", alias="WECOM_NOTIFY_SECRET")
|
||||
wecom_archive_seat_user_ids: str = Field(
|
||||
default="",
|
||||
alias="WECOM_ARCHIVE_SEAT_USER_IDS",
|
||||
|
||||
@@ -27,7 +27,12 @@ from agent.policy.air_group_ops import (
|
||||
resolve_cabin_ticket,
|
||||
)
|
||||
from agent.policy.air_text_flow import get_air_text_flow
|
||||
from agent.policy.sea_group_ops import group_client_of, send_group_text, ticket_view
|
||||
from agent.policy.sea_group_ops import (
|
||||
capture_group_lost_reason,
|
||||
group_client_of,
|
||||
send_group_text,
|
||||
ticket_view,
|
||||
)
|
||||
from agent.routing.air_group_intent import (
|
||||
INTENT_ACTIVATE,
|
||||
INTENT_CONFIRM_TMS,
|
||||
@@ -233,6 +238,14 @@ def _handle_bound(
|
||||
injected_quote: Optional[dict[str, Any]],
|
||||
) -> str:
|
||||
chat_id = message.chat_id
|
||||
lost = capture_group_lost_reason(
|
||||
flow=engine,
|
||||
ticket=ticket,
|
||||
sender_id=message.sender_id,
|
||||
text=message.content or "",
|
||||
)
|
||||
if lost:
|
||||
return lost
|
||||
facts = dict(ticket_view(ticket).get("collab_facts") or {})
|
||||
if intent == INTENT_PICK_OPTION or (
|
||||
intent == INTENT_OTHER and str(facts.get("__pending_air_lock") or "") == "1"
|
||||
|
||||
@@ -17,6 +17,7 @@ from agent.policy.sea_group_ops import (
|
||||
apply_group_deal,
|
||||
apply_product_quote,
|
||||
apply_tms_quote_choice,
|
||||
capture_group_lost_reason,
|
||||
dissolve_group,
|
||||
group_client_of,
|
||||
handle_group_file,
|
||||
@@ -138,6 +139,16 @@ def handle_group_inbound(
|
||||
classify_group_text,
|
||||
)
|
||||
|
||||
lost = capture_group_lost_reason(
|
||||
flow=engine,
|
||||
ticket=ticket,
|
||||
sender_id=message.sender_id,
|
||||
text=message.content or "",
|
||||
sess=sess,
|
||||
)
|
||||
if lost:
|
||||
return lost
|
||||
|
||||
intent = classify_group_text(message.content or "", injected=injected_intent)
|
||||
logger.info(
|
||||
"group_inbound 文字 chat=%s sender=%s intent=%s chars=%s",
|
||||
|
||||
@@ -198,6 +198,8 @@ def handle_text_other(message: InboundMessage, *, store=None) -> str:
|
||||
sess = engine.session_for_action(
|
||||
message.sender_id, message.raw, text=message.content or ""
|
||||
)
|
||||
if sess and sess.phase == "wait_lost_reason":
|
||||
return handle_text_inquiry(message, store=msg_store)
|
||||
btn = engine.match_button(message.content or "", sess)
|
||||
if btn:
|
||||
return handle_text_inquiry(message, store=msg_store)
|
||||
|
||||
@@ -111,6 +111,7 @@ class WorkerRuntime:
|
||||
threading.Thread(target=self._recognition_loop, name="recog-loop", daemon=True),
|
||||
threading.Thread(target=self._archive_loop, name="archive-loop", daemon=True),
|
||||
threading.Thread(target=self._wshoto_poll_loop, name="wshoto-poll", daemon=True),
|
||||
threading.Thread(target=self._deal_followup_loop, name="deal-followup", daemon=True),
|
||||
threading.Thread(target=self._pdf_loop, name="pdf-loop", daemon=True),
|
||||
]
|
||||
for t in self._threads:
|
||||
@@ -273,6 +274,22 @@ class WorkerRuntime:
|
||||
wait = 1.0 if has_hot_room() else interval
|
||||
wait_wshoto_poll(wait)
|
||||
|
||||
def _deal_followup_loop(self) -> None:
|
||||
"""
|
||||
HTTP 槽轮询协商再问。独立线程,不占回调、不与 soffice 串队。
|
||||
"""
|
||||
from agent.jobs.deal_followup_poll import poll_due_negotiate
|
||||
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
self.http_jobs.run(
|
||||
lambda: poll_due_negotiate(settings=self.settings),
|
||||
label="deal-followup",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("协商再问轮询失败")
|
||||
self._stop.wait(60.0)
|
||||
|
||||
def _pdf_loop(self) -> None:
|
||||
"""仅 LibreOffice 单槽消费 PDF Stream。"""
|
||||
if not self.redis:
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
协商中到期再问:Worker HTTP 槽轮询,回到当初那个会话。
|
||||
|
||||
本文件职责:认领主账到期单、发成交跟进。不在回调线程跑。
|
||||
禁止:群解散后改发私聊;占 LibreOffice 槽;把六态写进 Redis。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.config import Settings, get_settings
|
||||
from agent.policy import inquiry_copy as copy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def poll_due_negotiate(*, ledger: Any = None, settings: Optional[Settings] = None) -> int:
|
||||
"""
|
||||
认领到期协商中工单并再问一次。
|
||||
|
||||
返回发出条数。主账已清空 next_followup_at,本轮失败也不再自动补发。
|
||||
"""
|
||||
cfg = settings or get_settings()
|
||||
book = ledger
|
||||
if book is None:
|
||||
if str(getattr(cfg, "ledger_backend", "http") or "http").lower() == "memory":
|
||||
return 0
|
||||
from agent.ledger.http_ledger import HttpLedger
|
||||
|
||||
book = HttpLedger()
|
||||
claimer = getattr(book, "claim_due_negotiate", None)
|
||||
if not callable(claimer):
|
||||
return 0
|
||||
rows = claimer() or []
|
||||
sent = 0
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
try:
|
||||
if _reask_one(row, settings=cfg):
|
||||
sent += 1
|
||||
except Exception:
|
||||
logger.exception("协商再问失败 wo=%s", row.get("work_order_no") or row.get("workOrderNo"))
|
||||
return sent
|
||||
|
||||
|
||||
def _reask_one(row: dict[str, Any], *, settings: Settings) -> bool:
|
||||
no = str(row.get("work_order_no") or row.get("workOrderNo") or "").strip()
|
||||
if not no:
|
||||
return False
|
||||
chat_id = str(row.get("collab_chat_id") or row.get("collabChatId") or "").strip()
|
||||
sales = str(row.get("sales_wecom_id") or row.get("salesWecomId") or "").strip()
|
||||
name = str(row.get("sales_name") or row.get("salesName") or "").strip() or "销售"
|
||||
facts = dict(row.get("facts") or {})
|
||||
quote = dict(row.get("quote") or {})
|
||||
line = str(row.get("business_line") or row.get("businessLine") or "").strip().upper()
|
||||
if chat_id:
|
||||
ok = _send_group_reask(
|
||||
chat_id=chat_id,
|
||||
work_order_no=no,
|
||||
sales_name=name,
|
||||
facts=facts,
|
||||
quote=quote,
|
||||
business_line=line,
|
||||
settings=settings,
|
||||
)
|
||||
if not ok:
|
||||
logger.warning("协商再问原群失败且不补发私聊 wo=%s chat=%s", no, chat_id)
|
||||
return ok
|
||||
if not sales:
|
||||
logger.warning("协商再问无销售 userid wo=%s", no)
|
||||
return False
|
||||
from agent.channel.queue import get_message_store
|
||||
|
||||
text = copy.deal_card(work_order_no=no, quote=quote, facts=facts)
|
||||
payload = copy.deal_wecom_payload(work_order_no=no, quote=quote, facts=facts)
|
||||
ok, info = get_message_store().enqueue_outbound(
|
||||
touser=sales,
|
||||
content=text,
|
||||
dedupe_key=f"deal-reask:{no}",
|
||||
payload=payload,
|
||||
)
|
||||
if not ok:
|
||||
logger.warning("协商再问私聊入队失败 wo=%s info=%s", no, info)
|
||||
return bool(ok)
|
||||
|
||||
|
||||
def _send_group_reask(
|
||||
*,
|
||||
chat_id: str,
|
||||
work_order_no: str,
|
||||
sales_name: str,
|
||||
facts: dict[str, str],
|
||||
quote: dict[str, Any],
|
||||
business_line: str,
|
||||
settings: Settings,
|
||||
) -> bool:
|
||||
text = copy.group_deal_followup(sales_name=sales_name, work_order_no=work_order_no)
|
||||
if business_line == "AIR":
|
||||
from agent.channel.aibot.reply import AibotReplyClient
|
||||
|
||||
out = AibotReplyClient().send_group(chat_id=chat_id, content=text)
|
||||
return bool(out.get("ok"))
|
||||
from agent.channel.outbox.sender import WeComAppClient
|
||||
|
||||
client = WeComAppClient(
|
||||
corp_id=settings.wecom_corp_id,
|
||||
secret=settings.wecom_secret,
|
||||
agent_id=int(settings.wecom_agent_id or "0") or 0,
|
||||
)
|
||||
out = client.send_group(chat_id=chat_id, content=text)
|
||||
if not out.get("ok"):
|
||||
return False
|
||||
carder = getattr(client, "send_group_card", None)
|
||||
if callable(carder):
|
||||
payload = copy.deal_wecom_payload(work_order_no=work_order_no, quote=quote, facts=facts)
|
||||
carder(chat_id=chat_id, template_card=payload.get("template_card") or {})
|
||||
return True
|
||||
@@ -111,28 +111,53 @@ def process_recognition(data: dict[str, Any], *, allow_network: bool = True) ->
|
||||
|
||||
|
||||
def _process_quote_template_parse(payload: dict[str, Any], *, allow_network: bool) -> dict[str, Any]:
|
||||
"""跑 sheet_map 并回写主账;失败也必须回写 failed+原因。"""
|
||||
"""
|
||||
跑 sheet_map 并回写主账;失败也必须回写 failed+原因。
|
||||
|
||||
mergeExisting=true(未换文件重新解析)时:与 existingMapping 智能合并后再回写,
|
||||
仍对齐标签的旧坐标保留,对不上的改用新坐标;换文件/首传不传该标记,整份覆盖。
|
||||
"""
|
||||
from agent.config import get_settings
|
||||
from agent.llm.mode_sheet_map import invoke_sheet_map
|
||||
from agent.llm.mode_sheet_map import (
|
||||
assess_mapping_gaps,
|
||||
invoke_sheet_map,
|
||||
merge_mapping_prefer_existing,
|
||||
)
|
||||
|
||||
version_id = str(payload.get("versionId") or "").strip()
|
||||
settings = get_settings()
|
||||
net = bool(allow_network and settings.llm_allow_network)
|
||||
result = invoke_sheet_map(payload, allow_network=net)
|
||||
result = invoke_sheet_map(payload, allow_network=net, timeout_seconds=240.0, max_rounds=3)
|
||||
merge_existing = bool(payload.get("mergeExisting") or payload.get("merge_existing"))
|
||||
existing = payload.get("existingMapping") or payload.get("existing_mapping")
|
||||
inventory = _inventory_lines_from_payload(payload)
|
||||
body: dict[str, Any]
|
||||
if result.get("ok") and isinstance(result.get("mapping"), dict):
|
||||
mapping = dict(result["mapping"])
|
||||
gaps = result.get("gaps_after")
|
||||
if not isinstance(gaps, list):
|
||||
gaps = mapping.get("mappingGaps") if isinstance(mapping.get("mappingGaps"), list) else []
|
||||
mapping["mappingGaps"] = [str(g) for g in gaps if g]
|
||||
if merge_existing and isinstance(existing, dict):
|
||||
mapping = merge_mapping_prefer_existing(
|
||||
existing, mapping, cell_inventory=inventory
|
||||
)
|
||||
# 合并后再对照清单复评缺口,避免角标沿用合并前数字
|
||||
remaining = assess_mapping_gaps(payload, mapping)
|
||||
mapping["mappingGaps"] = [str(g) for g in remaining if g]
|
||||
if not remaining:
|
||||
mapping["unmappedRequired"] = []
|
||||
mapping["unmappedLabels"] = []
|
||||
else:
|
||||
gaps = result.get("gaps_after")
|
||||
if not isinstance(gaps, list):
|
||||
gaps = mapping.get("mappingGaps") if isinstance(mapping.get("mappingGaps"), list) else []
|
||||
mapping["mappingGaps"] = [str(g) for g in gaps if g]
|
||||
# 运营可读摘要:点数之外固定带上未解析项
|
||||
n_fields = len(mapping.get("fields") or {})
|
||||
n_fee = len(mapping.get("feeRegions") or []) if isinstance(mapping.get("feeRegions"), list) else 0
|
||||
n_route = len(mapping.get("routeRegions") or []) if isinstance(mapping.get("routeRegions"), list) else 0
|
||||
points = n_fields + n_fee + n_route
|
||||
parse_message = f"已解析 {points} 个映射点"
|
||||
if mapping["mappingGaps"]:
|
||||
if merge_existing:
|
||||
parse_message += "(对齐的保留,对不上的改用新坐标)"
|
||||
if mapping.get("mappingGaps"):
|
||||
preview = ";".join(mapping["mappingGaps"][:8])
|
||||
more = len(mapping["mappingGaps"]) - 8
|
||||
parse_message += f";未解析到 {len(mapping['mappingGaps'])} 项:{preview}"
|
||||
@@ -144,6 +169,7 @@ def _process_quote_template_parse(payload: dict[str, Any], *, allow_network: boo
|
||||
"mappingJson": mapping,
|
||||
"parseStatus": "parsed",
|
||||
"parseMessage": parse_message,
|
||||
"mergeExisting": merge_existing,
|
||||
}
|
||||
else:
|
||||
err = str(result.get("error") or "AI 解析失败")
|
||||
@@ -152,17 +178,26 @@ def _process_quote_template_parse(payload: dict[str, Any], *, allow_network: boo
|
||||
"ok": False,
|
||||
"parseStatus": "failed",
|
||||
"parseMessage": err,
|
||||
"mergeExisting": merge_existing,
|
||||
}
|
||||
posted = _post_parse_result(body)
|
||||
logger.info(
|
||||
"quote_template_parse done versionId=%s ok=%s posted=%s",
|
||||
"quote_template_parse done versionId=%s ok=%s merge=%s posted=%s",
|
||||
version_id,
|
||||
body.get("ok"),
|
||||
merge_existing,
|
||||
posted.get("ok"),
|
||||
)
|
||||
return {"ok": bool(body.get("ok")), "posted": posted, "kind": "quote_template_parse"}
|
||||
|
||||
|
||||
def _inventory_lines_from_payload(payload: dict[str, Any]) -> list[str]:
|
||||
"""从解析入队载荷取出单元格清单行。"""
|
||||
raw = payload.get("cellInventory") or payload.get("cell_inventory") or []
|
||||
if isinstance(raw, list):
|
||||
return [str(x) for x in raw]
|
||||
return []
|
||||
|
||||
def _post_parse_result(body: dict[str, Any]) -> dict[str, Any]:
|
||||
"""PUT /inquiry/agent/quoteTemplate/parseResult。"""
|
||||
try:
|
||||
|
||||
@@ -193,15 +193,46 @@ class HttpLedger:
|
||||
out.setdefault("needsPdf", str(format).lower() == "pdf")
|
||||
return out
|
||||
|
||||
def transition(self, *, work_order_no: str, to_status: str) -> dict[str, Any]:
|
||||
out = self._post(
|
||||
"/inquiry/agent/ticket/transition",
|
||||
{"workOrderNo": work_order_no, "toStatus": to_status},
|
||||
)
|
||||
def transition(
|
||||
self,
|
||||
*,
|
||||
work_order_no: str,
|
||||
to_status: str,
|
||||
remark: str = "",
|
||||
lost_reason: str = "",
|
||||
next_followup_at: str = "",
|
||||
deal_notify_summary: str = "",
|
||||
) -> dict[str, Any]:
|
||||
body: dict[str, Any] = {"workOrderNo": work_order_no, "toStatus": to_status}
|
||||
if remark:
|
||||
body["remark"] = remark
|
||||
if lost_reason:
|
||||
body["lostReason"] = lost_reason
|
||||
if next_followup_at:
|
||||
body["nextFollowupAt"] = next_followup_at
|
||||
if deal_notify_summary:
|
||||
body["dealNotifySummary"] = deal_notify_summary
|
||||
out = self._post("/inquiry/agent/ticket/transition", body)
|
||||
if not out.get("ok") and out.get("status"):
|
||||
out["ok"] = True
|
||||
return out if out.get("ok") else {"ok": False, "error": out.get("error") or "transition_failed"}
|
||||
|
||||
def patch_lost_reason(self, *, work_order_no: str, lost_reason: str) -> dict[str, Any]:
|
||||
"""未成交后记下原因。不改六态。"""
|
||||
out = self._post(
|
||||
"/inquiry/agent/ticket/patchLostReason",
|
||||
{"workOrderNo": work_order_no, "lostReason": lost_reason},
|
||||
)
|
||||
return out if out.get("ok") else {"ok": False, "error": out.get("error") or "patch_lost_reason_failed"}
|
||||
|
||||
def claim_due_negotiate(self) -> list[dict[str, Any]]:
|
||||
"""认领到期协商再问,主账会清空再问时刻防重发。"""
|
||||
out = self._post("/inquiry/agent/ticket/claimDueNegotiate", {})
|
||||
rows = out.get("tickets") or out.get("result") or []
|
||||
if isinstance(rows, list):
|
||||
return [dict(x) for x in rows if isinstance(x, dict)]
|
||||
return []
|
||||
|
||||
def match_sea_staff(self, *, origin: str, destination: str) -> list[dict[str, Any]]:
|
||||
"""主账按线路返回启用的海运产品岗。"""
|
||||
out = self._post(
|
||||
|
||||
@@ -41,6 +41,9 @@ class MemoryTicket:
|
||||
collab_ended: bool = False
|
||||
attachments: list[dict[str, Any]] = field(default_factory=list)
|
||||
quote_file_ready: bool = False
|
||||
lost_reason: str = ""
|
||||
next_followup_at: str = ""
|
||||
deal_notify_summary: str = ""
|
||||
|
||||
|
||||
class MemoryLedger:
|
||||
@@ -207,14 +210,70 @@ class MemoryLedger:
|
||||
"status": ticket.status,
|
||||
}
|
||||
|
||||
def transition(self, *, work_order_no: str, to_status: str) -> dict[str, Any]:
|
||||
def transition(
|
||||
self,
|
||||
*,
|
||||
work_order_no: str,
|
||||
to_status: str,
|
||||
remark: str = "",
|
||||
lost_reason: str = "",
|
||||
next_followup_at: str = "",
|
||||
deal_notify_summary: str = "",
|
||||
) -> dict[str, Any]:
|
||||
_ = remark
|
||||
with self._lock:
|
||||
ticket = self._tickets.get(work_order_no)
|
||||
if ticket is None:
|
||||
return {"ok": False, "error": "ticket_not_found"}
|
||||
ticket.status = to_status
|
||||
if to_status == "未成交":
|
||||
ticket.next_followup_at = ""
|
||||
if lost_reason:
|
||||
ticket.lost_reason = lost_reason
|
||||
elif to_status == "已成交":
|
||||
ticket.next_followup_at = ""
|
||||
if deal_notify_summary:
|
||||
ticket.deal_notify_summary = deal_notify_summary
|
||||
elif to_status == "协商中":
|
||||
ticket.next_followup_at = next_followup_at
|
||||
return {"ok": True, "status": ticket.status}
|
||||
|
||||
def patch_lost_reason(self, *, work_order_no: str, lost_reason: str) -> dict[str, Any]:
|
||||
reason = (lost_reason or "").strip()
|
||||
with self._lock:
|
||||
ticket = self._tickets.get(work_order_no)
|
||||
if ticket is None:
|
||||
return {"ok": False, "error": "ticket_not_found"}
|
||||
if ticket.status != "未成交":
|
||||
return {"ok": False, "error": "not_lost", "status": ticket.status}
|
||||
if not reason:
|
||||
return {"ok": False, "error": "reason_blank"}
|
||||
ticket.lost_reason = reason
|
||||
return {"ok": True, "work_order_no": work_order_no, "lost_reason": reason}
|
||||
|
||||
def claim_due_negotiate(self) -> list[dict[str, Any]]:
|
||||
now = datetime.now(_SHANGHAI).strftime("%Y-%m-%d %H:%M:%S")
|
||||
hits: list[dict[str, Any]] = []
|
||||
with self._lock:
|
||||
for ticket in self._tickets.values():
|
||||
due = (ticket.next_followup_at or "").strip()
|
||||
if ticket.status != "协商中" or not due or due > now:
|
||||
continue
|
||||
ticket.next_followup_at = ""
|
||||
hits.append(
|
||||
{
|
||||
"work_order_no": ticket.work_order_no,
|
||||
"sales_wecom_id": ticket.sales_wecom_id,
|
||||
"sales_name": "",
|
||||
"business_line": ticket.business_line,
|
||||
"collab_chat_id": ticket.collab_chat_id,
|
||||
"status": ticket.status,
|
||||
"facts": dict(ticket.facts),
|
||||
"quote": dict(ticket.quote),
|
||||
}
|
||||
)
|
||||
return hits
|
||||
|
||||
def get_ticket(self, *, work_order_no: str) -> MemoryTicket | None:
|
||||
with self._lock:
|
||||
return self._tickets.get(work_order_no)
|
||||
@@ -282,8 +341,12 @@ class MemoryLedger:
|
||||
status = ticket.status
|
||||
quote = dict(ticket.quote or {})
|
||||
wait_phase = "done"
|
||||
if status in {"已成交", "未成交", "协商中", "已关闭"}:
|
||||
if status in {"已成交", "已关闭"}:
|
||||
wait_phase = "done"
|
||||
elif status == "未成交":
|
||||
wait_phase = "wait_lost_reason" if not (ticket.lost_reason or "").strip() else "done"
|
||||
elif status == "协商中":
|
||||
wait_phase = "wait_deal"
|
||||
elif status == "已报价" and quote:
|
||||
# 文件已发给销售 → 成交跟进;否则海运协同卡、空运方案卡
|
||||
if ticket.quote_file_ready or quote.get("quoteFileIssued"):
|
||||
@@ -306,6 +369,10 @@ class MemoryLedger:
|
||||
"wait_phase": wait_phase,
|
||||
"collab_chat_id": ticket.collab_chat_id,
|
||||
"collab_facts": dict(ticket.collab_facts),
|
||||
"lost_reason": ticket.lost_reason,
|
||||
"lostReason": ticket.lost_reason,
|
||||
"deal_notify_summary": ticket.deal_notify_summary,
|
||||
"next_followup_at": ticket.next_followup_at,
|
||||
}
|
||||
|
||||
def upsert_staff(self, row: dict[str, Any]) -> None:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
报价工作表映射 mode:上传时用千问产出 mappingJson。
|
||||
|
||||
本文件职责:加载映射分析提示词、拼用户消息、调千问、剥 Markdown、校验 JSON、
|
||||
对照单元格清单做缺项检测,必要时再补跑一轮。
|
||||
对照单元格清单做缺项检测;有缺口则多轮补跑(默认最多 3 轮),目标一次流程内齐全。
|
||||
禁止:发明运价;在企微回调线程同步调用;出单阶段再调本 mode。
|
||||
"""
|
||||
|
||||
@@ -22,30 +22,58 @@ MODE = LlmMode.SHEET_MAP
|
||||
|
||||
_PROMPT_CACHE: str | None = None
|
||||
|
||||
# 清单原文片段 → 白名单 code(用于缺项检测;不要求全覆盖词典)
|
||||
_LABEL_TO_CODE: list[tuple[tuple[str, ...], str]] = [
|
||||
(("提货地址", "Place of Receipt", "place of receipt"), "pickup_address"),
|
||||
(("送货地址", "派送地址", "Place of Delivery", "place of delivery"), "delivery_address"),
|
||||
(("货值", "Cargo Value", "cargo value"), "cargo_value"),
|
||||
(("海关编码", "HS Code", "HS编码", "hs code"), "hs_code"),
|
||||
(("油、电、磁", "油,电,磁", "油电磁", "IL, ELEC", "L,ELEC", "oil"), "oil_elec_mag"),
|
||||
# 清单原文片段 → 白名单 code(按运输方式过滤;关键词避免过短英文误报)
|
||||
_LABEL_TO_CODE_COMMON: list[tuple[tuple[str, ...], str]] = [
|
||||
(("报价单编号", "询价单号", "RFQ No", "Quote No"), "quote_no"),
|
||||
(("报价日期", "询价日期", "Inquiry Date", "Quote Date"), "quote_date"),
|
||||
(("有效期至", "有效期", "Validity"), "valid_until"),
|
||||
(("客户名称", "Customer Name", "Customer"), "customer_name"),
|
||||
(("联系人", "Contact Person", "Contact Name"), "contact_name"),
|
||||
(("电话", "Tel", "Phone", "Mobile"), "contact_phone"),
|
||||
(("币别", "Currency"), "currency"),
|
||||
(("业务员", "Sales"), "sales_name"),
|
||||
(("品名", "Commodity", "Goods Description"), "commodity"),
|
||||
]
|
||||
|
||||
_LABEL_TO_CODE_SEA: list[tuple[tuple[str, ...], str]] = [
|
||||
(("提货地址", "Place of Receipt"), "pickup_address"),
|
||||
(("送货地址", "派送地址", "Place of Delivery"), "delivery_address"),
|
||||
(("货值", "Cargo Value"), "cargo_value"),
|
||||
(("海关编码", "HS Code", "HS编码"), "hs_code"),
|
||||
(("油、电、磁", "油,电,磁", "油电磁"), "oil_elec_mag"),
|
||||
(("特殊说明", "特别说明", "Special Remarks"), "special_remarks"),
|
||||
(("要求货物到达", "Requested Arriv", "抵达时间"), "eta_request"),
|
||||
(("运输时效", "Transit Time", "航程时间", "Navigation time"), "transit_time"),
|
||||
(("船期", "Sailing", "Requested Sh"), "sailing_schedule"),
|
||||
(("开船日", "On Board"), "on_board_date"),
|
||||
(("船公司", "Shipping Line", "Shipping Co"), "shipping_line"),
|
||||
(("要求货物到达", "Requested Arrival"), "eta_request"),
|
||||
(("运输时效", "Transit Time", "航程时间"), "transit_time"),
|
||||
(("船期", "Sailing Schedule"), "sailing_schedule"),
|
||||
(("开船日", "On Board Date"), "on_board_date"),
|
||||
(("船公司", "Shipping Line", "Shipping Company"), "shipping_line"),
|
||||
(("货好时间", "Cargo Ready"), "cargo_ready_date"),
|
||||
(("贸易条款", "Terms"), "trade_terms"),
|
||||
(("贸易条款", "Trade Terms", "Incoterms"), "trade_terms"),
|
||||
(("整柜或散货", "整柜或拼柜", "FCL or LCL"), "fcl_or_lcl"),
|
||||
(("箱型", "柜量", "Type & Qty", "Cont."), "container_type_qty"),
|
||||
(("起运港", "POL"), "pol"),
|
||||
(("目的港", "POD"), "pod"),
|
||||
(("品名", "Commodity"), "commodity"),
|
||||
(("箱型箱量", "箱型", "柜量", "Type & Qty"), "container_type_qty"),
|
||||
(("起运港", "Port of Loading", "POL"), "pol"),
|
||||
(("目的港", "Port of Discharge", "POD"), "pod"),
|
||||
(("货物数量", "Quantity"), "quantity"),
|
||||
(("询价日期", "Inquiry Date", "报价日期"), "quote_date"),
|
||||
(("RFQ", "询价单号", "报价单编号"), "quote_no"),
|
||||
(("有效期", "period"), "valid_until"),
|
||||
]
|
||||
|
||||
_LABEL_TO_CODE_AIR: list[tuple[tuple[str, ...], str]] = [
|
||||
(("起运地", "起运港", "Departure", "Origin Airport"), "origin"),
|
||||
(("目的地", "目的港", "Destination Airport"), "destination"),
|
||||
(("件数", "Pieces", "Pkgs"), "pieces"),
|
||||
(("毛重", "重量", "Gross Weight", "Weight"), "weight_kg"),
|
||||
(("体积", "Volume", "CBM"), "volume_cbm"),
|
||||
(("计费重", "Chargeable Weight"), "chargeable_weight_kg"),
|
||||
(("航班", "Flight"), "flight_no"),
|
||||
(("中转机场", "Transfer Airport"), "transfer_airport"),
|
||||
(("贸易条款", "Trade Terms", "Incoterms"), "trade_terms"),
|
||||
(("特殊说明", "特别说明", "Special Remarks"), "special_remarks"),
|
||||
]
|
||||
|
||||
_LABEL_TO_CODE_LAND: list[tuple[tuple[str, ...], str]] = [
|
||||
(("始发地", "起运地", "发站", "Origin"), "origin"),
|
||||
(("目的地", "到站", "Destination"), "destination"),
|
||||
(("运输时效", "时效", "Transit Time"), "transit_time"),
|
||||
(("特殊说明", "特别说明"), "special_remarks"),
|
||||
]
|
||||
|
||||
_SEA_FEE_MARKERS: list[tuple[tuple[str, ...], tuple[str, ...]]] = [
|
||||
@@ -65,6 +93,33 @@ _SEA_FEE_MARKERS: list[tuple[tuple[str, ...], tuple[str, ...]]] = [
|
||||
]
|
||||
|
||||
|
||||
def _labels_for_mode(mode: str) -> list[tuple[tuple[str, ...], str]]:
|
||||
m = (mode or "sea").strip().lower()
|
||||
out = list(_LABEL_TO_CODE_COMMON)
|
||||
if m in ("sea", "海运"):
|
||||
out.extend(_LABEL_TO_CODE_SEA)
|
||||
elif m in ("air", "空运"):
|
||||
out.extend(_LABEL_TO_CODE_AIR)
|
||||
elif m in ("land", "陆运"):
|
||||
out.extend(_LABEL_TO_CODE_LAND)
|
||||
else:
|
||||
out.extend(_LABEL_TO_CODE_SEA)
|
||||
return out
|
||||
|
||||
|
||||
def _label_hit(blob: str, labels: tuple[str, ...]) -> bool:
|
||||
"""清单命中:中文标签直接包含;英文至少 4 字符,降低误报。"""
|
||||
for lab in labels:
|
||||
if not lab:
|
||||
continue
|
||||
if any("\u4e00" <= ch <= "\u9fff" for ch in lab):
|
||||
if lab in blob:
|
||||
return True
|
||||
elif len(lab) >= 4 and lab.lower() in blob.lower():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def mode_meta() -> dict[str, Any]:
|
||||
return {
|
||||
"mode": MODE.value,
|
||||
@@ -178,7 +233,7 @@ def assess_mapping_gaps(payload: dict[str, Any], mapping: dict[str, Any] | None)
|
||||
"""
|
||||
对照单元格清单,列出 mapping 明显缺口(中文说明,供补跑用户消息)。
|
||||
|
||||
不打外网。海运:费用段标题出现则要求对应 feeRegion;常见业务标签出现则要求 fields code。
|
||||
不打外网。按运输方式过滤字段词典;海运另检查三段费用区。
|
||||
"""
|
||||
blob = _inventory_blob(payload)
|
||||
if not blob.strip():
|
||||
@@ -196,24 +251,280 @@ def assess_mapping_gaps(payload: dict[str, Any], mapping: dict[str, Any] | None)
|
||||
for r in fee_regions
|
||||
if isinstance(r, dict)
|
||||
).lower()
|
||||
route_regions = mapping.get("routeRegions") if isinstance(mapping.get("routeRegions"), list) else []
|
||||
|
||||
gaps: list[str] = []
|
||||
mode = str(payload.get("transportMode") or payload.get("transport_mode") or "sea").strip().lower()
|
||||
# 字段匹配用:去掉费用段标题,避免「目的港费用」误报成 pod 等字段缺口
|
||||
field_blob = blob
|
||||
if mode in ("sea", "海运"):
|
||||
for markers, matchers in _SEA_FEE_MARKERS:
|
||||
if not any(m in blob for m in markers):
|
||||
continue
|
||||
if not any(x in fee_blob for x in matchers):
|
||||
gaps.append(f"费用区缺失:清单有「{markers[0]}」,须建对应 feeRegion")
|
||||
for m in markers:
|
||||
field_blob = field_blob.replace(m, " ")
|
||||
elif mode in ("land", "陆运"):
|
||||
# 陆运主表常见项目/线路矩阵:清单有「项目」或车型价列但无 routeRegions
|
||||
if (
|
||||
("项目" in blob or "车型" in blob or "3T" in blob or "拼车" in blob)
|
||||
and not route_regions
|
||||
):
|
||||
gaps.append("线路区缺失:陆运表须建 routeRegions 承接线路/车型报价")
|
||||
|
||||
for labels, code in _LABEL_TO_CODE:
|
||||
if code in field_codes:
|
||||
# 陆运已建 routeRegions 时,始发地/目的地/时效常在线路矩阵列头,不再强制 fields
|
||||
land_route_covered = mode in ("land", "陆运") and bool(route_regions)
|
||||
land_skip_codes = {"origin", "destination", "transit_time"} if land_route_covered else set()
|
||||
|
||||
for labels, code in _labels_for_mode(mode):
|
||||
if code in field_codes or code in land_skip_codes:
|
||||
continue
|
||||
if any(lab in blob for lab in labels):
|
||||
if _label_hit(field_blob, labels):
|
||||
gaps.append(f"字段缺失:清单有「{labels[0]}」→ 应映射 {code}")
|
||||
return gaps
|
||||
|
||||
|
||||
def merge_mapping_prefer_existing(
|
||||
old_mapping: dict[str, Any] | None,
|
||||
new_mapping: dict[str, Any] | None,
|
||||
*,
|
||||
cell_inventory: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
重新解析合并:费用/线路区按 id 旧优先;fields 对照单元格清单智能取舍。
|
||||
|
||||
规则:旧无→用新;新无→留旧;相同→留旧;旧坐标仍贴标签→留旧;
|
||||
旧坐标对不上(清单里能看到该标签)→改用新坐标。无清单时保守旧优先。
|
||||
"""
|
||||
new_mapping = dict(new_mapping or {})
|
||||
old_mapping = old_mapping if isinstance(old_mapping, dict) else {}
|
||||
out = dict(new_mapping)
|
||||
|
||||
new_fields = new_mapping.get("fields") if isinstance(new_mapping.get("fields"), dict) else {}
|
||||
old_fields = old_mapping.get("fields") if isinstance(old_mapping.get("fields"), dict) else {}
|
||||
new_meta = new_mapping.get("fieldMeta") if isinstance(new_mapping.get("fieldMeta"), dict) else {}
|
||||
old_meta = old_mapping.get("fieldMeta") if isinstance(old_mapping.get("fieldMeta"), dict) else {}
|
||||
meta_out: dict[str, Any] = dict(new_meta)
|
||||
|
||||
codes = set(str(k) for k in old_fields.keys()) | set(str(k) for k in new_fields.keys())
|
||||
fields: dict[str, Any] = {}
|
||||
inv = list(cell_inventory or [])
|
||||
for code in codes:
|
||||
old_cell = str(old_fields.get(code) or "").strip()
|
||||
new_cell = str(new_fields.get(code) or "").strip()
|
||||
kept_old = False
|
||||
if not old_cell:
|
||||
chosen = new_cell
|
||||
elif not new_cell:
|
||||
chosen = old_cell
|
||||
kept_old = True
|
||||
elif old_cell.lower() == new_cell.lower():
|
||||
chosen = old_cell
|
||||
kept_old = True
|
||||
elif inv and _field_mapping_still_aligned(code, old_cell, inv):
|
||||
chosen = old_cell
|
||||
kept_old = True
|
||||
elif inv:
|
||||
chosen = new_cell
|
||||
logger.info("映射坐标纠偏 code=%s old=%s -> new=%s", code, old_cell, new_cell)
|
||||
else:
|
||||
chosen = old_cell
|
||||
kept_old = True
|
||||
if chosen:
|
||||
fields[code] = chosen
|
||||
if kept_old and isinstance(old_meta.get(code), dict):
|
||||
meta_out[code] = dict(old_meta[code])
|
||||
out["fields"] = fields
|
||||
if meta_out:
|
||||
out["fieldMeta"] = meta_out
|
||||
|
||||
out["feeRegions"] = _merge_regions_by_id(
|
||||
old_mapping.get("feeRegions"), new_mapping.get("feeRegions")
|
||||
)
|
||||
out["routeRegions"] = _merge_regions_by_id(
|
||||
old_mapping.get("routeRegions"), new_mapping.get("routeRegions")
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
# 与主账 FILL_BY_LABEL 对齐的常用码→标签(智能合并校验用)
|
||||
_FIELD_LABELS: dict[str, tuple[str, ...]] = {
|
||||
"quote_no": ("RFQ No", "报价单编号", "报价编号"),
|
||||
"quote_date": ("询价日期", "报价日期", "Inquiry Date"),
|
||||
"pol": ("起运港", "POL"),
|
||||
"pod": ("目的港", "POD"),
|
||||
"commodity": ("品名", "货物品名", "Commodity"),
|
||||
"quantity": ("货物数量", "货量"),
|
||||
"fcl_or_lcl": ("整柜或拼柜",),
|
||||
"container_type_qty": ("箱型箱量",),
|
||||
"valid_until": ("报价有效期", "有效期至"),
|
||||
"total_amount": ("费用总计", "报价合计"),
|
||||
"origin": ("始发地", "始发站", "起运地"),
|
||||
"destination": ("目的地",),
|
||||
}
|
||||
|
||||
|
||||
def _parse_inventory_line(line: str) -> tuple[str, str, str] | None:
|
||||
"""地址 | 原文 | 合并 | 色 → (full_addr, text, merge)。"""
|
||||
parts = [p.strip() for p in str(line or "").split("|")]
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
return parts[0], parts[1], parts[2] if len(parts) > 2 else "-"
|
||||
|
||||
|
||||
def _col_index(letter: str) -> int:
|
||||
n = 0
|
||||
for ch in (letter or "").upper():
|
||||
if "A" <= ch <= "Z":
|
||||
n = n * 26 + (ord(ch) - ord("A") + 1)
|
||||
return max(n - 1, 0)
|
||||
|
||||
|
||||
def _split_addr(full: str) -> tuple[str, int, int] | None:
|
||||
"""Sheet!B3 → (sheet, row0, col0)。"""
|
||||
text = (full or "").strip()
|
||||
if "!" not in text:
|
||||
return None
|
||||
sheet, addr = text.split("!", 1)
|
||||
m = re.match(r"^\$?([A-Za-z]+)\$?(\d+)$", addr.strip())
|
||||
if not m:
|
||||
return None
|
||||
return sheet, int(m.group(2)) - 1, _col_index(m.group(1))
|
||||
|
||||
|
||||
def _merge_end_col(merge: str, fallback_col: int) -> int:
|
||||
"""A3:C3 → 末列 index;无合并返回 fallback。"""
|
||||
m = (merge or "").strip()
|
||||
if not m or m == "-":
|
||||
return fallback_col
|
||||
# 可能是 A3:C3 或 Sheet!A3:C3
|
||||
if "!" in m:
|
||||
m = m.split("!", 1)[1]
|
||||
if ":" not in m:
|
||||
return fallback_col
|
||||
end = m.split(":", 1)[1].strip()
|
||||
mm = re.match(r"^\$?([A-Za-z]+)\$?(\d+)$", end)
|
||||
if not mm:
|
||||
return fallback_col
|
||||
return _col_index(mm.group(1))
|
||||
|
||||
|
||||
def _text_looks_like_label(text: str, labels: tuple[str, ...]) -> bool:
|
||||
norm = (text or "").replace(":", ":").strip()
|
||||
if not norm:
|
||||
return False
|
||||
primary = norm.split("/")[0].strip()
|
||||
upper = norm.upper()
|
||||
for lb in labels:
|
||||
if not lb:
|
||||
continue
|
||||
if norm.lower().startswith(lb.lower()) or primary.lower().startswith(lb.lower()):
|
||||
return True
|
||||
if len(lb) >= 3 and lb.upper() in upper:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _field_mapping_still_aligned(code: str, cell_ref: str, inventory: list[str]) -> bool:
|
||||
"""
|
||||
旧坐标是否仍可贴标签。
|
||||
|
||||
quote_no:贴着 RFQ 的 B3 常是 Port to Port / 工作天单选,不算对齐。
|
||||
"""
|
||||
labels = _FIELD_LABELS.get(str(code), ())
|
||||
if not labels:
|
||||
return True
|
||||
parsed = _split_addr(cell_ref)
|
||||
if not parsed:
|
||||
return False
|
||||
sheet, row, col = parsed
|
||||
rows: list[tuple[str, int, int, str, str]] = []
|
||||
mapped_text = ""
|
||||
for line in inventory:
|
||||
one = _parse_inventory_line(line)
|
||||
if not one:
|
||||
continue
|
||||
addr, text, merge = one
|
||||
sp = _split_addr(addr)
|
||||
if not sp or sp[0] != sheet:
|
||||
continue
|
||||
rows.append((addr, sp[1], sp[2], text, merge))
|
||||
if sp[1] == row and sp[2] == col:
|
||||
mapped_text = text
|
||||
if _text_looks_like_label(text, labels):
|
||||
return True
|
||||
if _is_unsuitable_value_cell(mapped_text):
|
||||
return False
|
||||
if str(code) == "quote_no" and mapped_text.strip() and not _looks_like_quote_no(mapped_text):
|
||||
return False
|
||||
saw = False
|
||||
for _addr, r, c, text, merge in rows:
|
||||
if not _text_looks_like_label(text, labels):
|
||||
continue
|
||||
saw = True
|
||||
if r == row and c == col:
|
||||
return True
|
||||
end_col = _merge_end_col(merge, c)
|
||||
if r == row:
|
||||
gap = col - end_col
|
||||
if 1 <= gap <= 3 and (not mapped_text.strip() or _looks_like_quote_no(mapped_text)):
|
||||
return True
|
||||
if str(code) == "quote_no" and gap > 3 and (
|
||||
not mapped_text.strip() or _looks_like_quote_no(mapped_text)
|
||||
):
|
||||
return True
|
||||
return not saw
|
||||
|
||||
|
||||
def _is_unsuitable_value_cell(text: str) -> bool:
|
||||
t = (text or "").replace(":", ":").strip()
|
||||
if not t:
|
||||
return False
|
||||
lower = t.lower()
|
||||
if any(x in t for x in ("○", "●", "⊙", "□", "☑")):
|
||||
return True
|
||||
if "port to port" in lower or "door to door" in lower:
|
||||
return True
|
||||
if "工作天" in t or "工作日" in t or "回复" in t:
|
||||
return True
|
||||
if "其它" in t and ("天" in t or "回复" in t or "Port" in t):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _looks_like_quote_no(text: str) -> bool:
|
||||
t = (text or "").strip()
|
||||
if not t or len(t) > 40:
|
||||
return False
|
||||
import re as _re
|
||||
|
||||
if _re.match(r"(?i)WO\d{6,}", t) or _re.match(r"(?i)W0\d{6,}", t):
|
||||
return True
|
||||
if _re.match(r"(?i)RFQ[-_]?[A-Z0-9]+", t):
|
||||
return True
|
||||
return bool(_re.match(r"[A-Z]{1,5}[-_]?\d{8,}", t))
|
||||
|
||||
|
||||
def _merge_regions_by_id(old_arr: Any, new_arr: Any) -> list[dict[str, Any]]:
|
||||
"""区域按 id 合并:旧整段保留,新 id 追加。"""
|
||||
out: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for src in (old_arr, new_arr):
|
||||
if not isinstance(src, list):
|
||||
continue
|
||||
for item in src:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
rid = str(item.get("id") or "").strip()
|
||||
if rid and rid in seen:
|
||||
continue
|
||||
if rid:
|
||||
seen.add(rid)
|
||||
out.append(dict(item))
|
||||
return out
|
||||
|
||||
|
||||
def invoke_shell(*, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
无网/单测入口:若带 assistant_json 则直接解析;否则返回未实现。
|
||||
@@ -273,10 +584,12 @@ def invoke_sheet_map(
|
||||
*,
|
||||
allow_network: bool = False,
|
||||
chat_fn: Optional[Any] = None,
|
||||
timeout_seconds: float = 180.0,
|
||||
timeout_seconds: float = 240.0,
|
||||
max_rounds: int = 3,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
调用千问产出 mappingJson;若对照清单有明显缺口,再补跑一轮(最多 2 次 chat)。
|
||||
调用千问产出 mappingJson;对照清单有缺口则补跑,最多 max_rounds 轮(默认 3),
|
||||
目标一次流程内尽量齐全,缺口写进 mappingGaps。
|
||||
|
||||
allow_network=False 且无 chat_fn:不打外网。
|
||||
副作用:可能出站 HTTPS;应在 Worker HTTP 槽内调用。
|
||||
@@ -293,36 +606,54 @@ def invoke_sheet_map(
|
||||
else:
|
||||
chat = chat_fn
|
||||
|
||||
rounds_cap = max(1, min(int(max_rounds or 3), 4))
|
||||
first = _chat_once(chat=chat, payload=payload, gaps=None)
|
||||
if not first.get("ok"):
|
||||
return first
|
||||
|
||||
mapping = first["mapping"]
|
||||
gaps = assess_mapping_gaps(payload, mapping)
|
||||
rounds = 1
|
||||
if gaps:
|
||||
logger.info(
|
||||
"sheet_map gaps versionId=%s n=%s sample=%s",
|
||||
payload.get("versionId"),
|
||||
len(gaps),
|
||||
gaps[:5],
|
||||
)
|
||||
second = _chat_once(chat=chat, payload=payload, gaps=gaps)
|
||||
rounds = 2
|
||||
if second.get("ok") and second.get("mapping"):
|
||||
mapping = second["mapping"]
|
||||
# 第二轮仍失败结构时保留第一轮
|
||||
elif not second.get("ok"):
|
||||
logger.warning(
|
||||
"sheet_map 补跑失败,保留第一轮 versionId=%s err=%s",
|
||||
payload.get("versionId"),
|
||||
second.get("error"),
|
||||
)
|
||||
|
||||
remaining = assess_mapping_gaps(payload, mapping)
|
||||
# 缺口落进 mappingJson,主账/后台可直接展示「表上有、未映射上」
|
||||
# 有缺口就补跑,直到齐、轮次用尽、或本轮未减少缺口
|
||||
while remaining and rounds < rounds_cap:
|
||||
logger.info(
|
||||
"sheet_map gaps versionId=%s round=%s n=%s sample=%s",
|
||||
payload.get("versionId"),
|
||||
rounds,
|
||||
len(remaining),
|
||||
remaining[:5],
|
||||
)
|
||||
nxt = _chat_once(chat=chat, payload=payload, gaps=remaining)
|
||||
rounds += 1
|
||||
if not (nxt.get("ok") and nxt.get("mapping")):
|
||||
logger.warning(
|
||||
"sheet_map 补跑失败,保留上一轮 versionId=%s err=%s",
|
||||
payload.get("versionId"),
|
||||
nxt.get("error"),
|
||||
)
|
||||
break
|
||||
mapping = nxt["mapping"]
|
||||
after = assess_mapping_gaps(payload, mapping)
|
||||
if len(after) >= len(remaining):
|
||||
# 未继续收敛则停止,避免空转烧额度
|
||||
logger.info(
|
||||
"sheet_map gaps 未收敛 versionId=%s before=%s after=%s stop",
|
||||
payload.get("versionId"),
|
||||
len(remaining),
|
||||
len(after),
|
||||
)
|
||||
remaining = after
|
||||
break
|
||||
remaining = after
|
||||
|
||||
# 缺口落进 mappingJson;齐全时清空 AI 自报未映射,避免列表角标被说明性标签抬高
|
||||
if isinstance(mapping, dict):
|
||||
mapping["mappingGaps"] = list(remaining)
|
||||
if not remaining:
|
||||
mapping["unmappedRequired"] = []
|
||||
mapping["unmappedLabels"] = []
|
||||
elif not isinstance(mapping.get("unmappedLabels"), list):
|
||||
mapping["unmappedLabels"] = []
|
||||
logger.info(
|
||||
"sheet_map ok versionId=%s rounds=%s fields=%s feeRegions=%s routeRegions=%s remain_gaps=%s",
|
||||
payload.get("versionId"),
|
||||
|
||||
@@ -539,12 +539,28 @@ def apply_air_deal(
|
||||
copy.ticket_locked(work_order_no=ticket_no(ticket), status=ticket_status(ticket)),
|
||||
)
|
||||
return "ticket_locked"
|
||||
result = flow.ledger.transition(work_order_no=ticket_no(ticket), to_status=outcome) or {}
|
||||
from agent.policy.deal_outcome import finish_deal_outcome
|
||||
from agent.policy.sea_group_ops import facts_of_group
|
||||
|
||||
view = ticket_view(ticket)
|
||||
result = finish_deal_outcome(
|
||||
ledger=flow.ledger,
|
||||
work_order_no=ticket_no(ticket),
|
||||
outcome=outcome,
|
||||
facts=facts_of_group(view),
|
||||
quote=dict(view.get("quote") or {}),
|
||||
business_line="AIR",
|
||||
sales_name=str(view.get("sales_name") or ""),
|
||||
)
|
||||
if not result.get("ok"):
|
||||
send_group_text(group_client_of(flow), chat_id, "主账状态更新失败,成交结果未入账。")
|
||||
return "ledger_fail"
|
||||
send_group_text(group_client_of(flow), chat_id, copy.deal_ack(outcome, ticket_no(ticket)))
|
||||
return "done"
|
||||
send_group_text(
|
||||
group_client_of(flow),
|
||||
chat_id,
|
||||
copy.deal_ack(outcome, ticket_no(ticket), str(result.get("next_followup_at") or "")),
|
||||
)
|
||||
return str(result.get("phase") or "done")
|
||||
|
||||
|
||||
def apply_air_lock(
|
||||
|
||||
@@ -322,6 +322,8 @@ class AirTextInquiryFlow:
|
||||
allowed = (copy.BTN_ADOPT_EXCEL, copy.BTN_ADOPT_PDF, copy.BTN_REJECT)
|
||||
elif phase == "wait_deal":
|
||||
allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
|
||||
elif phase == "wait_lost_reason":
|
||||
allowed = ()
|
||||
return FlowSession(
|
||||
sender_id=sender_id,
|
||||
thread_id=sender_id,
|
||||
@@ -333,11 +335,17 @@ class AirTextInquiryFlow:
|
||||
status=status,
|
||||
allowed=allowed,
|
||||
wait_version=1,
|
||||
invited_sales_name=str(view.get("sales_name") or view.get("salesName") or ""),
|
||||
collab_chat_id=str(view.get("collab_chat_id") or ""),
|
||||
)
|
||||
|
||||
def _infer_wait_phase(self, *, status: str, quote: dict[str, Any]) -> str:
|
||||
if status in {"已成交", "未成交", "协商中", "已关闭"}:
|
||||
if status in {"已成交", "已关闭"}:
|
||||
return "done"
|
||||
if status == "未成交":
|
||||
return "wait_lost_reason"
|
||||
if status == "协商中":
|
||||
return "wait_deal"
|
||||
if status == "已报价" and quote:
|
||||
return "wait_adopt"
|
||||
return "tms_miss"
|
||||
@@ -413,6 +421,9 @@ class AirTextInquiryFlow:
|
||||
# 成交卡已经绑在报价文件那条出站上。这里再发一张独立卡,
|
||||
# 会在文件还在上传时被另一条出站线程抢走,销售又先看到成交跟进。
|
||||
return "wait_deal"
|
||||
if sess.phase == "wait_lost_reason":
|
||||
reply(copy.deal_ack("未成交", sess.work_order_no))
|
||||
return "wait_lost_reason"
|
||||
if sess.phase == "done":
|
||||
reply(copy.deal_ack(sess.status or "已关闭", sess.work_order_no))
|
||||
return "done"
|
||||
@@ -462,6 +473,13 @@ class AirTextInquiryFlow:
|
||||
reply=reply,
|
||||
card_meta=inbound_raw,
|
||||
)
|
||||
# 未成交后下一句任意文字即原因;完整新询价(注入字段)则空着原因开新单。
|
||||
if (
|
||||
sess
|
||||
and sess.phase == "wait_lost_reason"
|
||||
and not (injected_facts and injected_mode)
|
||||
):
|
||||
return self._capture_lost_reason(sess, text, reply)
|
||||
|
||||
snap = extract_inquiry_snapshot(
|
||||
text,
|
||||
@@ -1056,15 +1074,48 @@ class AirTextInquiryFlow:
|
||||
) -> str:
|
||||
"""点成交/未成交/协商中:先整卡置灰,再入主账。"""
|
||||
self._grey_clicked_card(reply, card_meta, replace_name=status)
|
||||
result = self._ledger.transition(work_order_no=sess.work_order_no, to_status=status)
|
||||
from agent.policy.deal_outcome import finish_deal_outcome
|
||||
|
||||
result = finish_deal_outcome(
|
||||
ledger=self._ledger,
|
||||
work_order_no=sess.work_order_no,
|
||||
outcome=status,
|
||||
facts=sess.facts,
|
||||
quote=sess.quote,
|
||||
business_line=sess.business_line,
|
||||
sales_name=sess.invited_sales_name,
|
||||
)
|
||||
if not result.get("ok"):
|
||||
reply("主账状态更新失败,成交结果未入账。")
|
||||
return "ledger_fail"
|
||||
phase = str(result.get("phase") or "done")
|
||||
sess.status = status
|
||||
sess.phase = phase
|
||||
if phase == "wait_deal":
|
||||
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
|
||||
else:
|
||||
sess.allowed = ()
|
||||
sess.wait_version += 1
|
||||
self._save(sess)
|
||||
reply(copy.deal_ack(status, sess.work_order_no, str(result.get("next_followup_at") or "")))
|
||||
return phase
|
||||
|
||||
def _capture_lost_reason(self, sess: FlowSession, text: str, reply: ReplyFn) -> str:
|
||||
"""私聊未成交后收下原因,不再追问。"""
|
||||
from agent.policy.deal_outcome import capture_lost_reason
|
||||
|
||||
result = capture_lost_reason(
|
||||
ledger=self._ledger,
|
||||
work_order_no=sess.work_order_no,
|
||||
reason=text,
|
||||
)
|
||||
if not result.get("ok"):
|
||||
reply("未成交原因没有记下,请再发一句。")
|
||||
return "wait_lost_reason"
|
||||
sess.phase = "done"
|
||||
sess.allowed = ()
|
||||
self._save(sess)
|
||||
reply(copy.deal_ack(status, sess.work_order_no))
|
||||
reply(copy.lost_reason_ack(text))
|
||||
return "done"
|
||||
|
||||
def mark_wait_deal(self, sender_id: str, work_order_no: str) -> None:
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
"""
|
||||
成交跟进纯规则:工作日顺延、线路对负责人。
|
||||
|
||||
本文件职责:给私聊/群聊成交共用,不发企微、不写主账。
|
||||
禁止:在回调线程算完还同步推负责人;把六态写进本文件。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta, time
|
||||
from typing import Any, Iterable
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from agent.schema.tms_air_query import resolve_airport_code
|
||||
|
||||
SHANGHAI = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
# 法定休息日(不含调休上班)。调休日放进 MAKEUP_WORKDAYS,周末也算工作日。
|
||||
_HOLIDAYS = frozenset(
|
||||
{
|
||||
# 2026
|
||||
date(2026, 1, 1),
|
||||
date(2026, 1, 2),
|
||||
date(2026, 1, 3),
|
||||
date(2026, 2, 15),
|
||||
date(2026, 2, 16),
|
||||
date(2026, 2, 17),
|
||||
date(2026, 2, 18),
|
||||
date(2026, 2, 19),
|
||||
date(2026, 2, 20),
|
||||
date(2026, 2, 21),
|
||||
date(2026, 2, 22),
|
||||
date(2026, 2, 23),
|
||||
date(2026, 4, 4),
|
||||
date(2026, 4, 5),
|
||||
date(2026, 4, 6),
|
||||
date(2026, 5, 1),
|
||||
date(2026, 5, 2),
|
||||
date(2026, 5, 3),
|
||||
date(2026, 5, 4),
|
||||
date(2026, 5, 5),
|
||||
date(2026, 6, 19),
|
||||
date(2026, 6, 20),
|
||||
date(2026, 6, 21),
|
||||
date(2026, 9, 25),
|
||||
date(2026, 9, 26),
|
||||
date(2026, 9, 27),
|
||||
date(2026, 10, 1),
|
||||
date(2026, 10, 2),
|
||||
date(2026, 10, 3),
|
||||
date(2026, 10, 4),
|
||||
date(2026, 10, 5),
|
||||
date(2026, 10, 6),
|
||||
date(2026, 10, 7),
|
||||
date(2026, 10, 8),
|
||||
# 2027 元旦预留
|
||||
date(2027, 1, 1),
|
||||
date(2027, 1, 2),
|
||||
date(2027, 1, 3),
|
||||
}
|
||||
)
|
||||
|
||||
# 周末调休上班:算工作日。
|
||||
_MAKEUP_WORKDAYS = frozenset(
|
||||
{
|
||||
date(2026, 1, 4),
|
||||
date(2026, 2, 14),
|
||||
date(2026, 2, 28),
|
||||
date(2026, 5, 9),
|
||||
date(2026, 10, 10),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def is_workday(day: date) -> bool:
|
||||
"""周六日默认休息;法定假休息;调休上班算工作日。"""
|
||||
if day in _HOLIDAYS:
|
||||
return False
|
||||
if day in _MAKEUP_WORKDAYS:
|
||||
return True
|
||||
return day.weekday() < 5
|
||||
|
||||
|
||||
def shift_workdays(start: date, count: int) -> date:
|
||||
"""
|
||||
从 start 当天之后数 count 个工作日。
|
||||
|
||||
成交选协商中:顺延 3 个工作日后,第四个工作日再问,故 count=4。
|
||||
"""
|
||||
if count < 1:
|
||||
return start
|
||||
cur = start
|
||||
seen = 0
|
||||
while seen < count:
|
||||
cur = cur + timedelta(days=1)
|
||||
if is_workday(cur):
|
||||
seen += 1
|
||||
return cur
|
||||
|
||||
|
||||
def next_followup_at(now: datetime | None = None) -> datetime:
|
||||
"""第四个工作日 Asia/Shanghai 09:00。"""
|
||||
base = now.astimezone(SHANGHAI) if now else datetime.now(SHANGHAI)
|
||||
day = shift_workdays(base.date(), 4)
|
||||
return datetime.combine(day, time(9, 0), tzinfo=SHANGHAI)
|
||||
|
||||
|
||||
def _split_routes(raw: str) -> list[str]:
|
||||
out: list[str] = []
|
||||
for part in (raw or "").replace(",", ",").replace("、", ",").split(","):
|
||||
t = part.strip()
|
||||
if t:
|
||||
out.append(t)
|
||||
return out
|
||||
|
||||
|
||||
def air_route_keys(facts: dict[str, str] | None) -> set[str]:
|
||||
"""本单可对上的空运线路名:三字码对 + 目的港。"""
|
||||
src = dict(facts or {})
|
||||
origin = resolve_airport_code(str(src.get("起运港") or src.get("originCode") or ""))
|
||||
dest = resolve_airport_code(str(src.get("目的港") or src.get("destinationCode") or ""))
|
||||
keys: set[str] = set()
|
||||
if dest:
|
||||
keys.add(dest)
|
||||
if origin and dest:
|
||||
keys.add(f"{origin}-{dest}")
|
||||
return keys
|
||||
|
||||
|
||||
def _is_waicai(facts: dict[str, str] | None) -> bool:
|
||||
src = dict(facts or {})
|
||||
for key in ("外采", "是否外采", "采购方式"):
|
||||
val = str(src.get(key) or "").strip().lower()
|
||||
if val in {"1", "是", "true", "yes", "外采"}:
|
||||
return True
|
||||
blob = "".join(str(v) for v in src.values())
|
||||
return "外采" in blob
|
||||
|
||||
|
||||
def leader_matches(
|
||||
*,
|
||||
role_code: str,
|
||||
routes: str,
|
||||
is_leader: bool,
|
||||
business_line: str,
|
||||
facts: dict[str, str] | None,
|
||||
) -> bool:
|
||||
"""
|
||||
员工管理「是负责人 + 负责线路对得上」。
|
||||
|
||||
海运:海运全线。陆运:线路类别整词。空运:三字码对 / 目的港 / 全线;外采须工单标明。
|
||||
"""
|
||||
if not is_leader:
|
||||
return False
|
||||
line = (business_line or "").strip().upper()
|
||||
code = (role_code or "").strip().lower()
|
||||
tokens = _split_routes(routes)
|
||||
if line == "SEA":
|
||||
return code == "sea" and "海运全线" in tokens
|
||||
if line == "LAND":
|
||||
want = str((facts or {}).get("线路类别") or "").strip()
|
||||
return code == "land" and bool(want) and want in tokens
|
||||
if line == "AIR":
|
||||
if code != "air":
|
||||
return False
|
||||
if "全线" in tokens:
|
||||
return True
|
||||
if "外采-空运" in tokens and _is_waicai(facts):
|
||||
return True
|
||||
keys = air_route_keys(facts)
|
||||
return any(t in keys for t in tokens)
|
||||
return False
|
||||
|
||||
|
||||
def pick_leaders(
|
||||
staff: Iterable[dict[str, Any]],
|
||||
*,
|
||||
business_line: str,
|
||||
facts: dict[str, str] | None,
|
||||
) -> list[dict[str, str]]:
|
||||
"""从员工行筛出该推的负责人(启用且有企微 id)。"""
|
||||
out: list[dict[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
for row in staff:
|
||||
if str(row.get("status") or "active").lower() != "active":
|
||||
continue
|
||||
wecom = str(row.get("wecomId") or row.get("wecom_id") or "").strip()
|
||||
if not wecom or wecom in seen:
|
||||
continue
|
||||
leader_flag = row.get("isLeader")
|
||||
if leader_flag is None:
|
||||
leader_flag = row.get("is_leader")
|
||||
is_leader = leader_flag in {True, 1, "1", "true", "True"}
|
||||
if not leader_matches(
|
||||
role_code=str(row.get("roleCode") or row.get("role_code") or ""),
|
||||
routes=str(row.get("routes") or ""),
|
||||
is_leader=bool(is_leader),
|
||||
business_line=business_line,
|
||||
facts=facts,
|
||||
):
|
||||
continue
|
||||
seen.add(wecom)
|
||||
out.append(
|
||||
{
|
||||
"name": str(row.get("name") or "").strip(),
|
||||
"wecomId": wecom,
|
||||
"roleCode": str(row.get("roleCode") or row.get("role_code") or ""),
|
||||
}
|
||||
)
|
||||
return out
|
||||
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
成交三态落地:改主账六态、推负责人、收下未成交原因、排协商再问。
|
||||
|
||||
本文件职责:私聊/群聊共用。不在回调线程等 LLM。推送只入 outbox。
|
||||
禁止:把图成功当主账成功;成交失败还去推负责人;未成交推负责人。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from agent.config import get_settings
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.policy.deal_followup import next_followup_at, pick_leaders
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EnqueueFn = Callable[..., tuple[bool, str]]
|
||||
|
||||
|
||||
def notify_app_ready() -> bool:
|
||||
"""询价消息服务已配 AgentId+Secret 才推。空则只成交不推。"""
|
||||
settings = get_settings()
|
||||
return bool(
|
||||
str(getattr(settings, "wecom_notify_agent_id", "") or "").strip()
|
||||
and str(getattr(settings, "wecom_notify_secret", "") or "").strip()
|
||||
)
|
||||
|
||||
|
||||
def _role_of(business_line: str) -> str:
|
||||
line = (business_line or "").strip().upper()
|
||||
if line == "SEA":
|
||||
return "sea"
|
||||
if line == "LAND":
|
||||
return "land"
|
||||
return "air"
|
||||
|
||||
|
||||
def _enqueue_default(
|
||||
*,
|
||||
touser: str,
|
||||
content: str,
|
||||
dedupe_key: str,
|
||||
payload: dict[str, Any],
|
||||
) -> tuple[bool, str]:
|
||||
from agent.channel.queue import get_message_store
|
||||
|
||||
return get_message_store().enqueue_outbound(
|
||||
touser=touser,
|
||||
content=content,
|
||||
dedupe_key=dedupe_key,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
def finish_deal_outcome(
|
||||
*,
|
||||
ledger: Any,
|
||||
work_order_no: str,
|
||||
outcome: str,
|
||||
facts: dict[str, str] | None,
|
||||
quote: dict[str, Any] | None,
|
||||
business_line: str,
|
||||
sales_name: str = "",
|
||||
enqueue: Optional[EnqueueFn] = None,
|
||||
notify_ready: Optional[bool] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
先入主账,再按结果推人或排再问。
|
||||
|
||||
返回 ok / phase / notify_summary。phase:
|
||||
已成交 done;未成交 wait_lost_reason;协商中 wait_deal。
|
||||
"""
|
||||
no = (work_order_no or "").strip()
|
||||
status = (outcome or "").strip()
|
||||
extra: dict[str, Any] = {}
|
||||
summary = ""
|
||||
if status == "协商中":
|
||||
extra["next_followup_at"] = next_followup_at().strftime("%Y-%m-%d %H:%M:%S")
|
||||
if status == "已成交":
|
||||
staff: list[dict[str, Any]] = []
|
||||
lister = getattr(ledger, "list_staff_by_role", None)
|
||||
if callable(lister):
|
||||
try:
|
||||
staff = list(lister(role_code=_role_of(business_line)) or [])
|
||||
except Exception:
|
||||
logger.exception("成交匹配负责人失败 wo=%s", no)
|
||||
leaders = pick_leaders(staff, business_line=business_line, facts=facts)
|
||||
ready = notify_app_ready() if notify_ready is None else bool(notify_ready)
|
||||
if not leaders:
|
||||
summary = "无匹配负责人"
|
||||
elif not ready:
|
||||
summary = "通知未发出"
|
||||
else:
|
||||
names = [str(x.get("name") or x.get("wecomId") or "").strip() for x in leaders]
|
||||
summary = "已推:" + "、".join(n for n in names if n)
|
||||
sender = enqueue or _enqueue_default
|
||||
payload = copy.deal_notify_wecom_payload(
|
||||
work_order_no=no,
|
||||
sales_name=sales_name,
|
||||
facts=facts or {},
|
||||
quote=quote or {},
|
||||
business_line=business_line,
|
||||
)
|
||||
text = copy.deal_notify_card(
|
||||
work_order_no=no,
|
||||
sales_name=sales_name,
|
||||
facts=facts or {},
|
||||
quote=quote or {},
|
||||
business_line=business_line,
|
||||
)
|
||||
for row in leaders:
|
||||
uid = str(row.get("wecomId") or "").strip()
|
||||
if not uid:
|
||||
continue
|
||||
try:
|
||||
ok, info = sender(
|
||||
touser=uid,
|
||||
content=text,
|
||||
dedupe_key=f"deal-notify:{no}:{uid}",
|
||||
payload=dict(payload),
|
||||
)
|
||||
if not ok:
|
||||
logger.warning("成交通知入队失败 wo=%s uid=%s info=%s", no, uid, info)
|
||||
except Exception:
|
||||
logger.exception("成交通知入队异常 wo=%s uid=%s", no, uid)
|
||||
extra["deal_notify_summary"] = summary
|
||||
extra["remark"] = summary
|
||||
writer = getattr(ledger, "transition", None)
|
||||
if not callable(writer):
|
||||
return {"ok": False, "error": "no_transition", "phase": "ledger_fail"}
|
||||
result = writer(work_order_no=no, to_status=status, **extra) or {}
|
||||
if not result.get("ok"):
|
||||
return {"ok": False, "error": result.get("error") or "transition_failed", "phase": "ledger_fail"}
|
||||
if status == "未成交":
|
||||
phase = "wait_lost_reason"
|
||||
elif status == "协商中":
|
||||
phase = "wait_deal"
|
||||
else:
|
||||
phase = "done"
|
||||
return {
|
||||
"ok": True,
|
||||
"phase": phase,
|
||||
"notify_summary": summary,
|
||||
"status": status,
|
||||
"next_followup_at": str(extra.get("next_followup_at") or ""),
|
||||
}
|
||||
|
||||
|
||||
def capture_lost_reason(*, ledger: Any, work_order_no: str, reason: str) -> dict[str, Any]:
|
||||
"""把销售下一句写进未成交原因。"""
|
||||
text = (reason or "").strip()
|
||||
no = (work_order_no or "").strip()
|
||||
if not no or not text:
|
||||
return {"ok": False, "error": "blank"}
|
||||
patcher = getattr(ledger, "patch_lost_reason", None)
|
||||
if not callable(patcher):
|
||||
return {"ok": False, "error": "no_patch"}
|
||||
return patcher(work_order_no=no, lost_reason=text) or {"ok": False}
|
||||
@@ -1732,12 +1732,104 @@ def group_deal_mention(*, sales_name: str, work_order_no: str) -> str:
|
||||
return group_deal_followup(sales_name=sales_name, work_order_no=work_order_no)
|
||||
|
||||
|
||||
def deal_ack(outcome: str, work_order_no: str) -> str:
|
||||
def deal_ack(outcome: str, work_order_no: str, next_followup_at: str = "") -> str:
|
||||
"""成交三态回执。协商中写明顺延三个工作日和下次再问的月日 9:00。"""
|
||||
if outcome == "已成交":
|
||||
return f"已记录为「已成交」。工单 {work_order_no} 已关闭智能体跟进。"
|
||||
if outcome == "未成交":
|
||||
return "已记录为「未成交」。请补充未成交原因。"
|
||||
return f"已记录为「协商中」。工单 {work_order_no} 跟进周期已顺延。"
|
||||
when = _followup_month_day(next_followup_at)
|
||||
if when:
|
||||
return f"已记录为「协商中」。已顺延三个工作日,{when}的9:00再次发起成交状态询问。"
|
||||
return "已记录为「协商中」。已顺延三个工作日,第四个工作日早上9:00再次发起成交状态询问。"
|
||||
|
||||
|
||||
def _followup_month_day(raw: str) -> str:
|
||||
"""把主账再问时刻收成「9月24日」。"""
|
||||
from datetime import datetime
|
||||
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
for fmt, n in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d", 10)):
|
||||
try:
|
||||
dt = datetime.strptime(text[:n], fmt)
|
||||
return f"{dt.month}月{dt.day}日"
|
||||
except ValueError:
|
||||
continue
|
||||
return ""
|
||||
|
||||
|
||||
def lost_reason_ack(reason: str = "") -> str:
|
||||
"""收下未成交原因后回显原句,不再追问。"""
|
||||
text = (reason or "").strip()
|
||||
if text.endswith(("。", ".", "!", "!", "?", "?")):
|
||||
return f"已记下未成交原因:{text}"
|
||||
return f"已记下未成交原因:{text}。" if text else "已记下未成交原因。"
|
||||
|
||||
|
||||
def deal_notify_card(
|
||||
*,
|
||||
work_order_no: str,
|
||||
sales_name: str,
|
||||
facts: dict[str, str] | None,
|
||||
quote: dict[str, Any] | None,
|
||||
business_line: str,
|
||||
) -> str:
|
||||
"""成交通知文字兜底:方案卡压缩。不写销售,避免标题出现「销售 销售」。"""
|
||||
no = (work_order_no or "").strip()
|
||||
_ = sales_name
|
||||
mode = scheme_transport_mode(business_line)
|
||||
rows = scheme_horizontal_rows(
|
||||
work_order_no=no,
|
||||
facts=dict(facts or {}),
|
||||
quote=dict(quote or {}),
|
||||
transport_mode=mode,
|
||||
)
|
||||
lines = [f"成交通知 工单 {no}"]
|
||||
lines.extend(f"{row['keyname']}:{row['value']}" for row in rows)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def deal_notify_wecom_payload(
|
||||
*,
|
||||
work_order_no: str,
|
||||
sales_name: str,
|
||||
facts: dict[str, str] | None,
|
||||
quote: dict[str, Any] | None,
|
||||
business_line: str,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
成交通知卡:走询价消息服务,不走询价小助手。
|
||||
|
||||
字段按报价方案卡压缩,避免超过 6 格。标题不写销售。
|
||||
"""
|
||||
no = (work_order_no or "").strip()
|
||||
_ = sales_name
|
||||
mode = scheme_transport_mode(business_line)
|
||||
rows = scheme_horizontal_rows(
|
||||
work_order_no=no,
|
||||
facts=dict(facts or {}),
|
||||
quote=dict(quote or {}),
|
||||
transport_mode=mode,
|
||||
)
|
||||
from agent.config import get_settings
|
||||
|
||||
base = str(getattr(get_settings(), "public_base_url", "") or "").strip().rstrip("/")
|
||||
action_url = base or "https://work.weixin.qq.com"
|
||||
return {
|
||||
"msgtype": "template_card",
|
||||
"wecom_app": "notify",
|
||||
"template_card": {
|
||||
"card_type": "text_notice",
|
||||
"source": {"desc": "成交通知", "desc_color": 0},
|
||||
"main_title": {
|
||||
"title": f"成交通知 工单 {no}",
|
||||
},
|
||||
"horizontal_content_list": rows,
|
||||
"card_action": {"type": 1, "url": action_url},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def air_need_work_order() -> str:
|
||||
|
||||
@@ -91,6 +91,13 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
|
||||
reply=reply,
|
||||
sess=sess,
|
||||
)
|
||||
# 未成交后下一句任意文字即原因。不能走下面的 new_over_old,否则会当新询价丢掉。
|
||||
if (
|
||||
sess
|
||||
and sess.phase == "wait_lost_reason"
|
||||
and not (injected_facts and injected_mode)
|
||||
):
|
||||
return self._capture_lost_reason(sess, text, reply)
|
||||
if sess and phase not in _LAND_FRONT:
|
||||
# 查价后半段书签还在时,销售又发新陆运:只走陆运前半段。
|
||||
# 禁止再 super 去海运 on_text,海运会把 LAND 转回来,递归到死、一句都不回。
|
||||
|
||||
@@ -64,6 +64,7 @@ def ticket_view(ticket: object) -> dict[str, Any]:
|
||||
view.get("productNames") or view.get("product_names") or [],
|
||||
)
|
||||
view.setdefault("sales_name", view.get("salesName") or view.get("sales_name") or "")
|
||||
view.setdefault("lost_reason", view.get("lostReason") or view.get("lost_reason") or "")
|
||||
facts = dict(view.get("collab_facts") or view.get("collabFacts") or {})
|
||||
view.setdefault("collab_facts", facts)
|
||||
flag = str(facts.get("__ended") or view.get("collab_ended") or view.get("collabEnded") or "").strip().lower()
|
||||
@@ -89,6 +90,7 @@ def ticket_view(ticket: object) -> dict[str, Any]:
|
||||
"product_ids": list(getattr(ticket, "product_ids", []) or []),
|
||||
"product_names": list(getattr(ticket, "product_names", []) or []),
|
||||
"sales_name": str(getattr(ticket, "sales_name", "") or ""),
|
||||
"lost_reason": str(getattr(ticket, "lost_reason", "") or ""),
|
||||
}
|
||||
|
||||
|
||||
@@ -987,18 +989,75 @@ def apply_group_deal(
|
||||
if not (quote.get("total") or _quote_fee_rows(quote) or status_now in {"已报价", "协商中"}):
|
||||
send_group_text(client, chat_id, copy.group_deal_need_quote())
|
||||
return "deal_need_quote"
|
||||
writer = getattr(flow.ledger, "transition", None)
|
||||
if callable(writer):
|
||||
result = writer(work_order_no=no, to_status=outcome) or {}
|
||||
if not result.get("ok"):
|
||||
send_group_text(client, chat_id, "主账状态更新失败,成交结果未入账。")
|
||||
return "ledger_fail"
|
||||
from agent.policy.deal_outcome import finish_deal_outcome
|
||||
|
||||
result = finish_deal_outcome(
|
||||
ledger=flow.ledger,
|
||||
work_order_no=no,
|
||||
outcome=outcome,
|
||||
facts=facts_of_group(view, sess),
|
||||
quote=quote,
|
||||
business_line=str(view.get("business_line") or (sess.business_line if sess else "SEA") or "SEA"),
|
||||
sales_name=str(view.get("sales_name") or (getattr(sess, "invited_sales_name", "") if sess else "") or ""),
|
||||
)
|
||||
if not result.get("ok"):
|
||||
send_group_text(client, chat_id, "主账状态更新失败,成交结果未入账。")
|
||||
return "ledger_fail"
|
||||
phase = str(result.get("phase") or "done")
|
||||
if sess is not None:
|
||||
sess.status = outcome
|
||||
sess.phase = phase
|
||||
if phase == "wait_deal":
|
||||
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
|
||||
else:
|
||||
sess.allowed = ()
|
||||
flow._save(sess)
|
||||
send_group_text(client, chat_id, copy.deal_ack(outcome, no, str(result.get("next_followup_at") or "")))
|
||||
return phase
|
||||
|
||||
|
||||
def facts_of_group(view: dict[str, Any], sess: Any = None) -> dict[str, str]:
|
||||
"""主账字段打底,书签覆盖;给负责人匹配和成交通知卡用。"""
|
||||
facts = dict(view.get("facts") or {})
|
||||
if sess is not None and getattr(sess, "facts", None):
|
||||
facts.update(dict(sess.facts or {}))
|
||||
return facts
|
||||
|
||||
|
||||
def capture_group_lost_reason(
|
||||
*,
|
||||
flow: Any,
|
||||
ticket: object,
|
||||
sender_id: str,
|
||||
text: str,
|
||||
sess: Any = None,
|
||||
) -> str:
|
||||
"""
|
||||
群里未成交后,本单销售下一句任意文字即原因。
|
||||
空运群是否要 @ 由入站通道先拦,这里不再判。
|
||||
"""
|
||||
ticket = hydrate_ticket(flow, ticket)
|
||||
view = ticket_view(ticket)
|
||||
no = ticket_no(ticket)
|
||||
chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "")
|
||||
client = group_client_of(flow)
|
||||
if ticket_status(ticket) != "未成交":
|
||||
return ""
|
||||
if str(view.get("lost_reason") or "").strip():
|
||||
return ""
|
||||
if not is_sales(ticket, sender_id):
|
||||
return ""
|
||||
from agent.policy.deal_outcome import capture_lost_reason
|
||||
|
||||
result = capture_lost_reason(ledger=flow.ledger, work_order_no=no, reason=text)
|
||||
if not result.get("ok"):
|
||||
send_group_text(client, chat_id, "未成交原因没有记下,请再发一句。")
|
||||
return "wait_lost_reason"
|
||||
if sess is not None:
|
||||
sess.phase = "done"
|
||||
sess.allowed = ()
|
||||
flow._save(sess)
|
||||
send_group_text(client, chat_id, copy.deal_ack(outcome, no))
|
||||
send_group_text(client, chat_id, copy.lost_reason_ack(text))
|
||||
return "done"
|
||||
|
||||
|
||||
|
||||
@@ -85,6 +85,13 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
reply=reply,
|
||||
card_meta=inbound_raw,
|
||||
)
|
||||
# 未成交后下一句任意文字即原因,不能先抽字段当成新询价。
|
||||
if (
|
||||
sess
|
||||
and sess.phase == "wait_lost_reason"
|
||||
and not (injected_facts and injected_mode)
|
||||
):
|
||||
return self._capture_lost_reason(sess, text, reply)
|
||||
|
||||
snap = extract_inquiry_snapshot(
|
||||
text,
|
||||
|
||||
@@ -360,6 +360,8 @@ transportMode:sea | air | land
|
||||
5. 空运必须能承接 departureCharges + 空运费;陆运主表必须用 routeRegions 承接 landSegments。
|
||||
6. 看不清 → unmappedRequired / unmappedLabels;表上有、能对上白名单却未映射 → 视为错误,必须补进 fields。
|
||||
7. 用户未声明运输方式时推断 transportMode,并在 notes 说明。
|
||||
8. **一次齐全**:第一轮就要尽量映射清单中全部可见业务标签;宁可多写 fields/feeRegions/routeRegions,也不要省略。补跑时输出完整 mappingJson(覆盖式),不要只回增量。
|
||||
9. 说明性/公式性文字(如「1CBM=***KG」「重量体积取大」)无法落白名单字段时写入 unmappedLabels,不要假装成业务字段缺口。
|
||||
|
||||
【用户消息将提供】
|
||||
运输方式:海运 | 空运 | 陆运
|
||||
@@ -368,4 +370,4 @@ transportMode:sea | air | land
|
||||
(可选)TMS 样例或出单投影说明——仅对齐字段
|
||||
(补跑时)缺口清单:请据此补全整份 mappingJson,不要只回增量。
|
||||
|
||||
输出前自检 fields 与 feeRegions/routeRegions 是否覆盖清单中的可见业务标签,再只输出 mappingJson。
|
||||
输出前自检:fields 与 feeRegions/routeRegions 是否覆盖清单中的可见业务标签;有缺口则先补齐再输出。只输出 mappingJson。
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""
|
||||
成交跟进:工作日顺延、线路对负责人、未成交收下原因(不打外网)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import date, datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from agent.policy.deal_followup import (
|
||||
SHANGHAI,
|
||||
air_route_keys,
|
||||
leader_matches,
|
||||
next_followup_at,
|
||||
shift_workdays,
|
||||
)
|
||||
|
||||
|
||||
class TestWorkdays(unittest.TestCase):
|
||||
def test_skip_weekend(self) -> None:
|
||||
# 2026-09-18 周五,顺延 3 个工作日 → 21、22、23,第四个工作日 24
|
||||
self.assertEqual(shift_workdays(date(2026, 9, 18), 3), date(2026, 9, 23))
|
||||
self.assertEqual(shift_workdays(date(2026, 9, 18), 4), date(2026, 9, 24))
|
||||
|
||||
def test_skip_national_day(self) -> None:
|
||||
# 2026-09-30 周三,国庆休 → 第四个工作日落到节后
|
||||
fourth = shift_workdays(date(2026, 9, 30), 4)
|
||||
self.assertGreaterEqual(fourth, date(2026, 10, 9))
|
||||
self.assertNotEqual(fourth.weekday(), 5)
|
||||
self.assertNotEqual(fourth.weekday(), 6)
|
||||
|
||||
def test_next_followup_is_9am(self) -> None:
|
||||
when = next_followup_at(datetime(2026, 9, 18, 15, 30, tzinfo=SHANGHAI))
|
||||
self.assertEqual(when.hour, 9)
|
||||
self.assertEqual(when.minute, 0)
|
||||
self.assertEqual(when.tzinfo, SHANGHAI)
|
||||
self.assertEqual(when.date(), date(2026, 9, 24))
|
||||
|
||||
|
||||
class TestAirRouteKeys(unittest.TestCase):
|
||||
def test_pair_and_dest(self) -> None:
|
||||
keys = air_route_keys({"起运港": "SZX", "目的港": "KUL"})
|
||||
self.assertIn("SZX-KUL", keys)
|
||||
self.assertIn("KUL", keys)
|
||||
|
||||
def test_chinese_city_projects_iata(self) -> None:
|
||||
keys = air_route_keys({"起运港": "深圳", "目的港": "吉隆坡"})
|
||||
self.assertIn("SZX-KUL", keys)
|
||||
self.assertIn("KUL", keys)
|
||||
|
||||
|
||||
class TestLeaderMatch(unittest.TestCase):
|
||||
def test_sea_all_line(self) -> None:
|
||||
self.assertTrue(
|
||||
leader_matches(
|
||||
role_code="sea",
|
||||
routes="海运全线",
|
||||
is_leader=True,
|
||||
business_line="SEA",
|
||||
facts={},
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
leader_matches(
|
||||
role_code="sea",
|
||||
routes="海运全线",
|
||||
is_leader=False,
|
||||
business_line="SEA",
|
||||
facts={},
|
||||
)
|
||||
)
|
||||
|
||||
def test_land_route_category(self) -> None:
|
||||
self.assertTrue(
|
||||
leader_matches(
|
||||
role_code="land",
|
||||
routes="国内长途/零担,东南亚",
|
||||
is_leader=True,
|
||||
business_line="LAND",
|
||||
facts={"线路类别": "国内长途/零担"},
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
leader_matches(
|
||||
role_code="land",
|
||||
routes="东南亚",
|
||||
is_leader=True,
|
||||
business_line="LAND",
|
||||
facts={"线路类别": "国内长途/零担"},
|
||||
)
|
||||
)
|
||||
|
||||
def test_air_pair_dest_and_all(self) -> None:
|
||||
facts = {"起运港": "SZX", "目的港": "KUL"}
|
||||
self.assertTrue(
|
||||
leader_matches(
|
||||
role_code="air",
|
||||
routes="SZX-KUL",
|
||||
is_leader=True,
|
||||
business_line="AIR",
|
||||
facts=facts,
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
leader_matches(
|
||||
role_code="air",
|
||||
routes="KUL",
|
||||
is_leader=True,
|
||||
business_line="AIR",
|
||||
facts=facts,
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
leader_matches(
|
||||
role_code="air",
|
||||
routes="全线",
|
||||
is_leader=True,
|
||||
business_line="AIR",
|
||||
facts=facts,
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
leader_matches(
|
||||
role_code="air",
|
||||
routes="外采-空运",
|
||||
is_leader=True,
|
||||
business_line="AIR",
|
||||
facts=facts,
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
leader_matches(
|
||||
role_code="air",
|
||||
routes="外采-空运",
|
||||
is_leader=True,
|
||||
business_line="AIR",
|
||||
facts={**facts, "外采": "1"},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,247 @@
|
||||
"""
|
||||
成交三态:入账、推负责人、收下未成交原因、协商中不停会话。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from agent.ledger.memory_ledger import MemoryLedger
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.policy.air_text_flow import AirTextInquiryFlow, FlowSession
|
||||
from agent.policy.deal_outcome import capture_lost_reason, finish_deal_outcome
|
||||
from agent.policy.sea_text_flow import SeaTextInquiryFlow
|
||||
|
||||
|
||||
class TestDealOutcome(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.ledger = MemoryLedger()
|
||||
created = self.ledger.create_ticket(
|
||||
sender_id="sales1",
|
||||
business_line="SEA",
|
||||
facts={"起运港": "上海", "目的港": "洛杉矶", "品名": "衣服"},
|
||||
)
|
||||
self.wo = str(created.get("work_order_no") or "")
|
||||
self.ledger.transition(work_order_no=self.wo, to_status="已报价")
|
||||
self.ledger.upsert_staff(
|
||||
{
|
||||
"name": "海运负责人",
|
||||
"wecomId": "lead-sea",
|
||||
"roleCode": "sea",
|
||||
"routes": "海运全线",
|
||||
"isLeader": 1,
|
||||
"status": "active",
|
||||
}
|
||||
)
|
||||
|
||||
def test_deal_notifies_leaders(self) -> None:
|
||||
queued: list[dict] = []
|
||||
|
||||
def enqueue(*, touser, content, dedupe_key, payload):
|
||||
queued.append(
|
||||
{
|
||||
"touser": touser,
|
||||
"content": content,
|
||||
"dedupe_key": dedupe_key,
|
||||
"payload": payload,
|
||||
}
|
||||
)
|
||||
return True, "ok"
|
||||
|
||||
out = finish_deal_outcome(
|
||||
ledger=self.ledger,
|
||||
work_order_no=self.wo,
|
||||
outcome="已成交",
|
||||
facts={"起运港": "上海", "目的港": "洛杉矶"},
|
||||
quote={"total": "1000", "currency": "USD"},
|
||||
business_line="SEA",
|
||||
sales_name="吴吉林",
|
||||
enqueue=enqueue,
|
||||
notify_ready=True,
|
||||
)
|
||||
self.assertTrue(out.get("ok"))
|
||||
self.assertEqual(out.get("phase"), "done")
|
||||
self.assertIn("海运负责人", str(out.get("notify_summary") or ""))
|
||||
self.assertEqual(queued[0]["touser"], "lead-sea")
|
||||
self.assertEqual(queued[0]["payload"].get("wecom_app"), "notify")
|
||||
ticket = self.ledger.get_ticket(work_order_no=self.wo)
|
||||
self.assertEqual(ticket.status, "已成交")
|
||||
self.assertEqual(ticket.next_followup_at, "")
|
||||
|
||||
def test_deal_notify_card_omits_sales_label(self) -> None:
|
||||
"""成交通知不要「销售」二字:标题副标和正文都不写。"""
|
||||
text = copy.deal_notify_card(
|
||||
work_order_no="WO202609180008",
|
||||
sales_name="吴吉林",
|
||||
facts={"起运地": "泉州", "目的地": "南京", "线路类别": "国内城配/拖车/打包"},
|
||||
quote={},
|
||||
business_line="LAND",
|
||||
)
|
||||
self.assertNotIn("销售", text)
|
||||
payload = copy.deal_notify_wecom_payload(
|
||||
work_order_no="WO202609180008",
|
||||
sales_name="吴吉林",
|
||||
facts={"起运地": "泉州", "目的地": "南京", "线路类别": "国内城配/拖车/打包"},
|
||||
quote={},
|
||||
business_line="LAND",
|
||||
)
|
||||
title = (payload.get("template_card") or {}).get("main_title") or {}
|
||||
blob = str(title.get("title") or "") + str(title.get("desc") or "")
|
||||
self.assertNotIn("销售", blob)
|
||||
self.assertNotIn("desc", title)
|
||||
|
||||
def test_deal_without_leader_still_closes(self) -> None:
|
||||
self.ledger.upsert_staff(
|
||||
{
|
||||
"name": "不是负责人",
|
||||
"wecomId": "lead-sea",
|
||||
"roleCode": "sea",
|
||||
"routes": "海运全线",
|
||||
"isLeader": 0,
|
||||
"status": "active",
|
||||
}
|
||||
)
|
||||
out = finish_deal_outcome(
|
||||
ledger=self.ledger,
|
||||
work_order_no=self.wo,
|
||||
outcome="已成交",
|
||||
facts={},
|
||||
quote={},
|
||||
business_line="SEA",
|
||||
notify_ready=True,
|
||||
)
|
||||
self.assertEqual(out.get("notify_summary"), "无匹配负责人")
|
||||
self.assertEqual(self.ledger.get_ticket(work_order_no=self.wo).status, "已成交")
|
||||
|
||||
def test_lost_waits_for_reason(self) -> None:
|
||||
out = finish_deal_outcome(
|
||||
ledger=self.ledger,
|
||||
work_order_no=self.wo,
|
||||
outcome="未成交",
|
||||
facts={},
|
||||
quote={},
|
||||
business_line="SEA",
|
||||
)
|
||||
self.assertEqual(out.get("phase"), "wait_lost_reason")
|
||||
ticket = self.ledger.get_ticket(work_order_no=self.wo)
|
||||
self.assertEqual(ticket.status, "未成交")
|
||||
self.assertEqual(ticket.lost_reason, "")
|
||||
view = self.ledger.get_for_agent(work_order_no=self.wo, sender_id="sales1")
|
||||
self.assertEqual(view.get("wait_phase"), "wait_lost_reason")
|
||||
captured = capture_lost_reason(
|
||||
ledger=self.ledger, work_order_no=self.wo, reason="客人比价了"
|
||||
)
|
||||
self.assertTrue(captured.get("ok"))
|
||||
ticket = self.ledger.get_ticket(work_order_no=self.wo)
|
||||
self.assertEqual(ticket.lost_reason, "客人比价了")
|
||||
view = self.ledger.get_for_agent(work_order_no=self.wo, sender_id="sales1")
|
||||
self.assertEqual(view.get("wait_phase"), "done")
|
||||
|
||||
def test_lost_reason_ack_repeats_user_text(self) -> None:
|
||||
self.assertEqual(copy.lost_reason_ack("客户不要了"), "已记下未成交原因:客户不要了。")
|
||||
self.assertEqual(copy.lost_reason_ack("价格高。"), "已记下未成交原因:价格高。")
|
||||
|
||||
def test_negotiate_ack_names_followup_day(self) -> None:
|
||||
text = copy.deal_ack("协商中", "WO202609190007", "2026-09-24 09:00:00")
|
||||
self.assertEqual(
|
||||
text,
|
||||
"已记录为「协商中」。已顺延三个工作日,9月24日的9:00再次发起成交状态询问。",
|
||||
)
|
||||
self.assertNotIn("跟进周期已顺延", text)
|
||||
|
||||
def test_negotiate_keeps_wait_deal(self) -> None:
|
||||
out = finish_deal_outcome(
|
||||
ledger=self.ledger,
|
||||
work_order_no=self.wo,
|
||||
outcome="协商中",
|
||||
facts={},
|
||||
quote={},
|
||||
business_line="SEA",
|
||||
)
|
||||
self.assertEqual(out.get("phase"), "wait_deal")
|
||||
ticket = self.ledger.get_ticket(work_order_no=self.wo)
|
||||
self.assertEqual(ticket.status, "协商中")
|
||||
self.assertTrue(ticket.next_followup_at)
|
||||
view = self.ledger.get_for_agent(work_order_no=self.wo, sender_id="sales1")
|
||||
self.assertEqual(view.get("wait_phase"), "wait_deal")
|
||||
again = finish_deal_outcome(
|
||||
ledger=self.ledger,
|
||||
work_order_no=self.wo,
|
||||
outcome="协商中",
|
||||
facts={},
|
||||
quote={},
|
||||
business_line="SEA",
|
||||
)
|
||||
self.assertEqual(again.get("phase"), "wait_deal")
|
||||
self.assertEqual(self.ledger.get_ticket(work_order_no=self.wo).status, "协商中")
|
||||
self.assertTrue(again.get("next_followup_at"))
|
||||
|
||||
def test_private_next_text_is_lost_reason(self) -> None:
|
||||
flow = AirTextInquiryFlow(self.ledger)
|
||||
sess = FlowSession(
|
||||
sender_id="sales1",
|
||||
thread_id="sales1",
|
||||
phase="wait_deal",
|
||||
business_line="SEA",
|
||||
work_order_no=self.wo,
|
||||
allowed=(copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE),
|
||||
)
|
||||
flow._save(sess)
|
||||
replies: list[str] = []
|
||||
phase = flow.on_button(
|
||||
sender_id="sales1",
|
||||
action=copy.BTN_LOST,
|
||||
reply=lambda t, extra=None: replies.append(t),
|
||||
)
|
||||
self.assertEqual(phase, "wait_lost_reason")
|
||||
phase = flow.on_text(
|
||||
sender_id="sales1",
|
||||
text="价格高",
|
||||
reply=lambda t, extra=None: replies.append(t),
|
||||
)
|
||||
self.assertEqual(phase, "done")
|
||||
self.assertEqual(self.ledger.get_ticket(work_order_no=self.wo).lost_reason, "价格高")
|
||||
self.assertTrue(any(t == "已记下未成交原因:价格高。" for t in replies))
|
||||
|
||||
def test_sea_private_next_text_is_lost_reason(self) -> None:
|
||||
flow = SeaTextInquiryFlow(self.ledger)
|
||||
flow._save(
|
||||
FlowSession(
|
||||
sender_id="sales1",
|
||||
thread_id="sales1",
|
||||
phase="wait_deal",
|
||||
business_line="SEA",
|
||||
work_order_no=self.wo,
|
||||
allowed=(copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE),
|
||||
)
|
||||
)
|
||||
replies: list[str] = []
|
||||
phase = flow.on_button(
|
||||
sender_id="sales1",
|
||||
action=copy.BTN_LOST,
|
||||
reply=lambda t, extra=None: replies.append(t),
|
||||
)
|
||||
self.assertEqual(phase, "wait_lost_reason")
|
||||
phase = flow.on_text(
|
||||
sender_id="sales1",
|
||||
text="客人比价了",
|
||||
reply=lambda t, extra=None: replies.append(t),
|
||||
)
|
||||
self.assertEqual(phase, "done")
|
||||
self.assertEqual(self.ledger.get_ticket(work_order_no=self.wo).lost_reason, "客人比价了")
|
||||
|
||||
def test_claim_due_negotiate_clears_schedule(self) -> None:
|
||||
finish_deal_outcome(
|
||||
ledger=self.ledger,
|
||||
work_order_no=self.wo,
|
||||
outcome="协商中",
|
||||
facts={},
|
||||
quote={},
|
||||
business_line="SEA",
|
||||
)
|
||||
ticket = self.ledger.get_ticket(work_order_no=self.wo)
|
||||
ticket.next_followup_at = "2020-01-01 09:00:00"
|
||||
rows = self.ledger.claim_due_negotiate()
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0]["work_order_no"], self.wo)
|
||||
self.assertEqual(self.ledger.get_ticket(work_order_no=self.wo).next_followup_at, "")
|
||||
@@ -26,6 +26,7 @@ os.environ.setdefault("LEDGER_BACKEND", "memory")
|
||||
|
||||
from agent.ledger.memory_ledger import MemoryLedger
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.policy.air_text_flow import FlowSession
|
||||
from agent.policy.land_text_flow import LandTextInquiryFlow
|
||||
|
||||
|
||||
@@ -623,5 +624,46 @@ class LandTextPullTests(unittest.TestCase):
|
||||
self.assertTrue(is_product(ticket, "u1", flow=self.flow))
|
||||
|
||||
|
||||
class LandLostReasonTests(unittest.TestCase):
|
||||
"""陆运私聊未成交后,下一句任意文字要记下原因,不能当新询价丢掉。"""
|
||||
|
||||
def test_land_private_next_text_is_lost_reason(self) -> None:
|
||||
ledger = MemoryLedger()
|
||||
created = ledger.create_ticket(
|
||||
sender_id="WuJiLin",
|
||||
business_line="LAND",
|
||||
facts={"起运地": "泉州", "目的地": "南京", "线路类别": "国内城配/拖车/打包"},
|
||||
)
|
||||
wo = str(created.get("work_order_no") or "")
|
||||
ledger.transition(work_order_no=wo, to_status="已报价")
|
||||
flow = LandTextInquiryFlow(ledger)
|
||||
flow._save(
|
||||
FlowSession(
|
||||
sender_id="WuJiLin",
|
||||
thread_id="WuJiLin",
|
||||
phase="wait_deal",
|
||||
business_line="LAND",
|
||||
work_order_no=wo,
|
||||
allowed=(copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE),
|
||||
)
|
||||
)
|
||||
replies: list[str] = []
|
||||
phase = flow.on_button(
|
||||
sender_id="WuJiLin",
|
||||
action=copy.BTN_LOST,
|
||||
reply=lambda t, extra=None: replies.append(t),
|
||||
)
|
||||
self.assertEqual(phase, "wait_lost_reason")
|
||||
self.assertEqual(ledger.get_ticket(work_order_no=wo).status, "未成交")
|
||||
phase = flow.on_text(
|
||||
sender_id="WuJiLin",
|
||||
text="价格高",
|
||||
reply=lambda t, extra=None: replies.append(t),
|
||||
)
|
||||
self.assertEqual(phase, "done")
|
||||
self.assertEqual(ledger.get_ticket(work_order_no=wo).lost_reason, "价格高")
|
||||
self.assertTrue(any(t == "已记下未成交原因:价格高。" for t in replies))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1522,9 +1522,36 @@ class SeaGroupOpsTests(unittest.TestCase):
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "done")
|
||||
self.assertEqual(phase, "wait_lost_reason")
|
||||
self.assertEqual(self.ledger.get_ticket(work_order_no=wo).status, "未成交")
|
||||
|
||||
def test_sales_next_text_is_lost_reason(self) -> None:
|
||||
wo, chat = self._open_group()
|
||||
handle_group_collab(
|
||||
InboundMessage(
|
||||
sender_id="sales1",
|
||||
message_id="lost-1",
|
||||
content="未成交",
|
||||
chat_id=chat,
|
||||
chat_type="group",
|
||||
),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
phase = handle_group_collab(
|
||||
InboundMessage(
|
||||
sender_id="sales1",
|
||||
message_id="lost-2",
|
||||
content="客人比价了",
|
||||
chat_id=chat,
|
||||
chat_type="group",
|
||||
),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "done")
|
||||
self.assertEqual(self.ledger.get_ticket(work_order_no=wo).lost_reason, "客人比价了")
|
||||
|
||||
def test_sales_can_close_deal_by_text(self) -> None:
|
||||
"""群里销售说成交,要入账并回话,不能装没听见。相近说法也认。"""
|
||||
wo, chat = self._open_group()
|
||||
|
||||
@@ -101,6 +101,132 @@ class TestSheetMapParse(unittest.TestCase):
|
||||
}
|
||||
self.assertEqual(assess_mapping_gaps(payload, mapping), [])
|
||||
|
||||
def test_assess_gaps_air_ignores_sea_labels(self) -> None:
|
||||
"""空运不按海运词典报「提货地址/货值」缺口。"""
|
||||
payload = {
|
||||
"transportMode": "air",
|
||||
"cellInventory": [
|
||||
"A!B2 | 起运地/Origin Airport | - | -",
|
||||
"A!B3 | 毛重/Gross Weight | - | -",
|
||||
"A!Z99 | 提货地址/Place of Receipt | - | -",
|
||||
],
|
||||
}
|
||||
mapping = {"fields": {"origin": "A!C2", "weight_kg": "A!C3"}, "feeRegions": []}
|
||||
gaps = assess_mapping_gaps(payload, mapping)
|
||||
self.assertFalse(any("pickup_address" in g for g in gaps))
|
||||
self.assertEqual(gaps, [])
|
||||
|
||||
def test_assess_gaps_short_en_no_false_positive(self) -> None:
|
||||
"""短英文(如 POL)单独出现不误报;须中文或 ≥4 字符英文标签。"""
|
||||
payload = {
|
||||
"transportMode": "sea",
|
||||
"cellInventory": ["S!A1 | POL code table | - | -"],
|
||||
}
|
||||
mapping = {"fields": {}, "feeRegions": []}
|
||||
gaps = assess_mapping_gaps(payload, mapping)
|
||||
self.assertFalse(any("→ 应映射 pol" in g for g in gaps))
|
||||
|
||||
def test_assess_gaps_land_needs_route_regions(self) -> None:
|
||||
payload = {
|
||||
"transportMode": "land",
|
||||
"cellInventory": [
|
||||
"L!A1 | 项目 | - | -",
|
||||
"L!B1 | 3T | - | -",
|
||||
"L!C1 | 拼车 | - | -",
|
||||
],
|
||||
}
|
||||
mapping = {"fields": {}, "feeRegions": [], "routeRegions": []}
|
||||
gaps = assess_mapping_gaps(payload, mapping)
|
||||
self.assertTrue(any("routeRegions" in g for g in gaps))
|
||||
|
||||
def test_assess_gaps_land_route_skips_matrix_fields(self) -> None:
|
||||
"""陆运已有 routeRegions 时,不把矩阵列头始发地/目的地当 fields 缺口。"""
|
||||
payload = {
|
||||
"transportMode": "land",
|
||||
"cellInventory": [
|
||||
"L!A1 | 项目 | - | -",
|
||||
"L!B1 | 始发地 | - | -",
|
||||
"L!C1 | 目的地 | - | -",
|
||||
"L!D1 | 运输时效 | - | -",
|
||||
"L!E1 | 特殊说明 | - | -",
|
||||
],
|
||||
}
|
||||
mapping = {
|
||||
"fields": {},
|
||||
"feeRegions": [],
|
||||
"routeRegions": [{"id": "main", "title": "线路", "anchor": "L!A2"}],
|
||||
}
|
||||
gaps = assess_mapping_gaps(payload, mapping)
|
||||
self.assertFalse(any("origin" in g for g in gaps))
|
||||
self.assertFalse(any("destination" in g for g in gaps))
|
||||
self.assertFalse(any("transit_time" in g for g in gaps))
|
||||
self.assertTrue(any("special_remarks" in g for g in gaps))
|
||||
|
||||
def test_merge_prefer_existing_keeps_old_field(self) -> None:
|
||||
from agent.llm.mode_sheet_map import merge_mapping_prefer_existing
|
||||
|
||||
old = {
|
||||
"fields": {"quote_no": "S!B3", "pol": "S!B5"},
|
||||
"feeRegions": [{"id": "origin_charges", "title": "旧始发港"}],
|
||||
"routeRegions": [],
|
||||
}
|
||||
new = {
|
||||
"fields": {"quote_no": "S!Z99", "pod": "S!B6"},
|
||||
"feeRegions": [
|
||||
{"id": "origin_charges", "title": "新始发港"},
|
||||
{"id": "ocean_freight", "title": "海运费用"},
|
||||
],
|
||||
"routeRegions": [],
|
||||
"mappingGaps": [],
|
||||
}
|
||||
# 无清单:保守旧优先
|
||||
merged = merge_mapping_prefer_existing(old, new)
|
||||
self.assertEqual(merged["fields"]["quote_no"], "S!B3")
|
||||
self.assertEqual(merged["fields"]["pol"], "S!B5")
|
||||
self.assertEqual(merged["fields"]["pod"], "S!B6")
|
||||
ids = [r["id"] for r in merged["feeRegions"]]
|
||||
self.assertEqual(ids[0], "origin_charges")
|
||||
self.assertEqual(merged["feeRegions"][0]["title"], "旧始发港")
|
||||
self.assertIn("ocean_freight", ids)
|
||||
|
||||
def test_merge_replaces_misaligned_quote_no(self) -> None:
|
||||
"""旧 quote_no 落在单选 B3 → 改用新坐标 F3。"""
|
||||
from agent.llm.mode_sheet_map import merge_mapping_prefer_existing
|
||||
|
||||
inventory = [
|
||||
"S!A3 | RFQ No: | A3:A3 | -",
|
||||
"S!B3 | ○ Port to Port / 其它,3工作天回复 | B3:E3 | -",
|
||||
"S!F3 | | F3:G3 | -",
|
||||
]
|
||||
old = {"fields": {"quote_no": "S!B3"}, "feeRegions": [], "routeRegions": []}
|
||||
new = {"fields": {"quote_no": "S!F3"}, "feeRegions": [], "routeRegions": []}
|
||||
merged = merge_mapping_prefer_existing(old, new, cell_inventory=inventory)
|
||||
self.assertEqual(merged["fields"]["quote_no"], "S!F3")
|
||||
|
||||
def test_merge_keeps_aligned_quote_no_when_ai_drifts(self) -> None:
|
||||
"""旧已在 RFQ 旁空格、新一轮漂到远处 → 仍保留旧坐标。"""
|
||||
from agent.llm.mode_sheet_map import merge_mapping_prefer_existing
|
||||
|
||||
inventory = [
|
||||
"S!A3 | RFQ No: | - | -",
|
||||
"S!B3 | | - | -",
|
||||
]
|
||||
old = {"fields": {"quote_no": "S!B3"}, "feeRegions": [], "routeRegions": []}
|
||||
new = {"fields": {"quote_no": "S!Z99"}, "feeRegions": [], "routeRegions": []}
|
||||
merged = merge_mapping_prefer_existing(old, new, cell_inventory=inventory)
|
||||
self.assertEqual(merged["fields"]["quote_no"], "S!B3")
|
||||
|
||||
def test_b3_radio_not_aligned(self) -> None:
|
||||
from agent.llm.mode_sheet_map import _field_mapping_still_aligned
|
||||
|
||||
inventory = [
|
||||
"S!A3 | RFQ No: | - | -",
|
||||
"S!B3 | ○ Port to Port | - | -",
|
||||
"S!F3 | WO202609190002 | - | -",
|
||||
]
|
||||
self.assertFalse(_field_mapping_still_aligned("quote_no", "S!B3", inventory))
|
||||
self.assertTrue(_field_mapping_still_aligned("quote_no", "S!F3", inventory))
|
||||
|
||||
def test_invoke_retries_when_gaps(self) -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
@@ -145,6 +271,64 @@ class TestSheetMapParse(unittest.TestCase):
|
||||
self.assertEqual(out.get("gaps_after"), out["mapping"]["mappingGaps"])
|
||||
self.assertEqual(out["mapping"]["mappingGaps"], [])
|
||||
|
||||
def test_invoke_multi_round_until_complete(self) -> None:
|
||||
"""最多 3 轮:第 1 残缺 → 第 2 仍缺 → 第 3 齐。"""
|
||||
calls: list[int] = []
|
||||
|
||||
def fake_chat(*, mode, messages, temperature=0.0): # noqa: ARG001
|
||||
calls.append(1)
|
||||
n = len(calls)
|
||||
if n == 1:
|
||||
content = (
|
||||
'{"schemaVersion":"quote-template-mapping-v1",'
|
||||
'"fields":{"quote_no":"S!B3"},"feeRegions":[]}'
|
||||
)
|
||||
elif n == 2:
|
||||
content = (
|
||||
'{"schemaVersion":"quote-template-mapping-v1",'
|
||||
'"fields":{"quote_no":"S!B3","pickup_address":"S!H6"},'
|
||||
'"feeRegions":[{"id":"origin_charges","title":"始发港费用",'
|
||||
'"tmsSource":"polCostItems","anchor":"S!A19","stopAtText":["海运费用"],'
|
||||
'"grow":"insert_before_stop","styleCopyRows":1}]}'
|
||||
)
|
||||
else:
|
||||
content = (
|
||||
'{"schemaVersion":"quote-template-mapping-v1",'
|
||||
'"fields":{"quote_no":"S!B3","pickup_address":"S!H6","cargo_value":"S!B11"},'
|
||||
'"feeRegions":['
|
||||
'{"id":"origin_charges","title":"始发港费用","tmsSource":"polCostItems",'
|
||||
'"anchor":"S!A19","stopAtText":["海运费用"],'
|
||||
'"grow":"insert_before_stop","styleCopyRows":1},'
|
||||
'{"id":"ocean_freight","title":"海运费用","tmsSource":"oceanCostItems",'
|
||||
'"anchor":"S!A31","stopAtText":["目的港费用"],'
|
||||
'"grow":"insert_before_stop","styleCopyRows":1},'
|
||||
'{"id":"destination_charges","title":"目的港费用","tmsSource":"podCostItems",'
|
||||
'"anchor":"S!A36","stopAtText":["备注"],'
|
||||
'"grow":"insert_before_stop","styleCopyRows":1}'
|
||||
']}'
|
||||
)
|
||||
return SimpleNamespace(ok=True, content=content, provider="stub", error="")
|
||||
|
||||
payload = {
|
||||
"versionId": "v2",
|
||||
"transportMode": "sea",
|
||||
"fileName": "sea.xlsx",
|
||||
"sheet": "S",
|
||||
"cellInventory": [
|
||||
"S!A18 | 始发港费用/Origin Charges | - | -",
|
||||
"S!A30 | 海运费用/Ocean Freight | - | -",
|
||||
"S!A35 | 目的港费用/Destination Charges | - | -",
|
||||
"S!E6 | 提货地址/Place of Receipt | - | -",
|
||||
"S!A11 | 货值/Cargo Value | - | -",
|
||||
],
|
||||
}
|
||||
out = invoke_sheet_map(payload, chat_fn=fake_chat, allow_network=False, max_rounds=3)
|
||||
self.assertTrue(out["ok"])
|
||||
self.assertEqual(out.get("rounds"), 3)
|
||||
self.assertEqual(len(calls), 3)
|
||||
self.assertEqual(out["mapping"]["mappingGaps"], [])
|
||||
self.assertEqual(len(out["mapping"]["feeRegions"]), 3)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+18
@@ -127,4 +127,22 @@ public class LoginUser {
|
||||
/**设备id uniapp推送用*/
|
||||
private String clientId;
|
||||
|
||||
/**
|
||||
* 工号(询价账号页暂存企微 userid)
|
||||
*/
|
||||
@SensitiveField
|
||||
private String workNo;
|
||||
|
||||
/**
|
||||
* 只看本账号看板数据:登录态透传;看板聚合读取后收紧范围。
|
||||
*/
|
||||
private Boolean dashboardSelfOnly;
|
||||
|
||||
/**
|
||||
* 最后登录时间(账号列表展示;登录成功写入)。
|
||||
*/
|
||||
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private Date lastLoginTime;
|
||||
|
||||
}
|
||||
|
||||
+37
-1
@@ -70,8 +70,15 @@ public class InquiryAgentController {
|
||||
: (mappingObj instanceof String ? (String) mappingObj : JSON.toJSONString(mappingObj));
|
||||
String parseMessage = body.get("parseMessage") == null ? null : String.valueOf(body.get("parseMessage"));
|
||||
String parseStatus = body.get("parseStatus") == null ? null : String.valueOf(body.get("parseStatus"));
|
||||
boolean mergeExisting = false;
|
||||
Object mergeFlag = body.get("mergeExisting");
|
||||
if (mergeFlag instanceof Boolean) {
|
||||
mergeExisting = (Boolean) mergeFlag;
|
||||
} else if (mergeFlag != null) {
|
||||
mergeExisting = "true".equalsIgnoreCase(String.valueOf(mergeFlag));
|
||||
}
|
||||
try {
|
||||
quoteTemplateService.applyParseResult(versionId, ok, mappingJson, parseMessage, parseStatus);
|
||||
quoteTemplateService.applyParseResult(versionId, ok, mappingJson, parseMessage, parseStatus, mergeExisting);
|
||||
return Result.OK("ok");
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Result.error(e.getMessage());
|
||||
@@ -115,6 +122,10 @@ public class InquiryAgentController {
|
||||
Object to = body == null ? null : body.get("toStatus");
|
||||
req.setWorkOrderNo(no == null ? "" : String.valueOf(no));
|
||||
req.setToStatus(to == null ? "" : String.valueOf(to));
|
||||
req.setRemark(firstStr(body, "remark"));
|
||||
req.setLostReason(firstStr(body, "lostReason", "lost_reason"));
|
||||
req.setNextFollowupAt(firstStr(body, "nextFollowupAt", "next_followup_at"));
|
||||
req.setDealNotifySummary(firstStr(body, "dealNotifySummary", "deal_notify_summary"));
|
||||
try {
|
||||
org.jeecg.modules.inquiry.entity.InquiryTicket ticket =
|
||||
workOrderService.transition(req, "agent");
|
||||
@@ -128,6 +139,31 @@ public class InquiryAgentController {
|
||||
}
|
||||
}
|
||||
|
||||
@ApiOperation("智能体补未成交原因(不改六态)")
|
||||
@PostMapping("/ticket/patchLostReason")
|
||||
public Result<Map<String, Object>> patchLostReason(@RequestBody Map<String, Object> body,
|
||||
HttpServletRequest http) {
|
||||
if (!authed(http)) {
|
||||
return Result.error(401, "unauthorized");
|
||||
}
|
||||
return Result.OK(workOrderService.patchLostReason(
|
||||
firstStr(body, "workOrderNo", "work_order_no"),
|
||||
firstStr(body, "lostReason", "lost_reason"),
|
||||
"agent"));
|
||||
}
|
||||
|
||||
@ApiOperation("认领到期协商再问(Worker HTTP 槽)")
|
||||
@PostMapping("/ticket/claimDueNegotiate")
|
||||
public Result<Map<String, Object>> claimDueNegotiate(HttpServletRequest http) {
|
||||
if (!authed(http)) {
|
||||
return Result.error(401, "unauthorized");
|
||||
}
|
||||
Map<String, Object> out = new java.util.LinkedHashMap<>();
|
||||
out.put("ok", true);
|
||||
out.put("tickets", workOrderService.claimDueNegotiate());
|
||||
return Result.OK(out);
|
||||
}
|
||||
|
||||
@ApiOperation("智能体续办读单(只读,校验发起人)")
|
||||
@PostMapping("/ticket/get")
|
||||
public Result<Map<String, Object>> get(@RequestBody Map<String, Object> body,
|
||||
|
||||
+8
-2
@@ -185,8 +185,14 @@ public class InquiryQuoteTemplateController {
|
||||
@ApiOperation("对最新 Excel 版重新提交 AI 异步解析(已解析/失败/映射未完成均可;解析中跳过)")
|
||||
@RequiresPermissions("inquiry:template:edit")
|
||||
@PostMapping("/reparse")
|
||||
public Result<String> reparse(@RequestParam String id) {
|
||||
String msg = quoteTemplateService.reparseLatest(id);
|
||||
public Result<String> reparse(@RequestParam(required = false) String id,
|
||||
@RequestParam(required = false) String templateId) {
|
||||
// 兼容旧前端只传 id、新前端传 templateId,任一即可。
|
||||
String key = StringUtils.isNotBlank(templateId) ? templateId : id;
|
||||
if (StringUtils.isBlank(key)) {
|
||||
return Result.error("templateId 不能为空");
|
||||
}
|
||||
String msg = quoteTemplateService.reparseLatest(key);
|
||||
return Result.OK(msg);
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -30,6 +30,7 @@ public final class TicketStatuses {
|
||||
/**
|
||||
* from -> 允许的 to 集合。
|
||||
* 终态(已成交/未成交/已关闭)默认不可再流转,除非显式「关闭」从非终态进入已关闭。
|
||||
* 协商中允许再进协商中:只顺延再问,不重开询价。
|
||||
*/
|
||||
private static final Map<String, Set<String>> ALLOWED;
|
||||
|
||||
@@ -37,7 +38,8 @@ public final class TicketStatuses {
|
||||
Map<String, Set<String>> m = new HashMap<>();
|
||||
m.put(INQUIRING, setOf(QUOTED, NEGOTIATING, CLOSED));
|
||||
m.put(QUOTED, setOf(NEGOTIATING, DEAL, LOST, CLOSED));
|
||||
m.put(NEGOTIATING, setOf(QUOTED, DEAL, LOST, CLOSED));
|
||||
// 协商中可再选协商中:顺延下一轮再问,次数不限。
|
||||
m.put(NEGOTIATING, setOf(QUOTED, DEAL, LOST, CLOSED, NEGOTIATING));
|
||||
m.put(DEAL, Collections.emptySet());
|
||||
m.put(LOST, Collections.emptySet());
|
||||
m.put(CLOSED, Collections.emptySet());
|
||||
|
||||
+9
@@ -20,4 +20,13 @@ public class TicketTransitionRequest {
|
||||
|
||||
@ApiModelProperty("备注/关单原因")
|
||||
private String remark;
|
||||
|
||||
@ApiModelProperty("未成交原因(仅未成交)")
|
||||
private String lostReason;
|
||||
|
||||
@ApiModelProperty("协商中下次再问时间")
|
||||
private String nextFollowupAt;
|
||||
|
||||
@ApiModelProperty("成交通知结果摘要")
|
||||
private String dealNotifySummary;
|
||||
}
|
||||
|
||||
+22
@@ -115,6 +115,28 @@ public class InquiryTicket implements Serializable {
|
||||
@ApiModelProperty("关单原因")
|
||||
private String closeReason;
|
||||
|
||||
/**
|
||||
* 未成交原因:销售下一句任意文字;没回则空着。
|
||||
* 不是 closeReason,后台要单独展示。
|
||||
*/
|
||||
@ApiModelProperty("未成交原因")
|
||||
private String lostReason;
|
||||
|
||||
/**
|
||||
* 协商中下次再问时刻(第四个工作日 09:00)。
|
||||
* 中途改成交/未成交或再问发出后清空。
|
||||
*/
|
||||
@ApiModelProperty("协商中下次再问时间")
|
||||
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private Date nextFollowupAt;
|
||||
|
||||
/**
|
||||
* 成交通知结果:已推哪些负责人 / 无匹配负责人 / 通知未发出。
|
||||
*/
|
||||
@ApiModelProperty("成交通知结果")
|
||||
private String dealNotifySummary;
|
||||
|
||||
@ApiModelProperty("业务版本")
|
||||
private Integer version;
|
||||
|
||||
|
||||
+4
-1
@@ -89,8 +89,11 @@ public interface IInquiryQuoteTemplateService extends IService<InquiryQuoteTempl
|
||||
* 智能体回写 AI 映射结果(成功 mappingJson 或失败原因)。
|
||||
* <p>
|
||||
* 终态优先:已是 parsed/mapping_incomplete/failed 时可被同 version 覆盖;pending 必接受。
|
||||
*
|
||||
* @param mergeExisting true=未换文件重新解析:旧字段优先保留,只补新映射;false=整份覆盖
|
||||
*/
|
||||
void applyParseResult(String versionId, boolean ok, String mappingJson, String parseMessage, String parseStatus);
|
||||
void applyParseResult(String versionId, boolean ok, String mappingJson, String parseMessage,
|
||||
String parseStatus, boolean mergeExisting);
|
||||
|
||||
/**
|
||||
* 纠偏:把「已有映射点但仍标 mapping_incomplete」的版本改为 parsed。
|
||||
|
||||
+11
@@ -109,4 +109,15 @@ public interface IInquiryWorkOrderService extends IService<InquiryTicket> {
|
||||
* 空运群激活/切换当前工单。一单一群,未结案才能绑。不改六态。
|
||||
*/
|
||||
Map<String, Object> activateAirGroup(Map<String, Object> body);
|
||||
|
||||
/**
|
||||
* 未成交后补原因。已成交/其它六态拒绝。不改六态。
|
||||
*/
|
||||
Map<String, Object> patchLostReason(String workOrderNo, String lostReason, String operator);
|
||||
|
||||
/**
|
||||
* 认领已到期的协商中再问:清空 nextFollowupAt,避免 Worker 重复发。
|
||||
* 只改到期行,不加全局锁。
|
||||
*/
|
||||
java.util.List<java.util.Map<String, Object>> claimDueNegotiate();
|
||||
}
|
||||
|
||||
+78
-4
@@ -2,6 +2,8 @@ package org.jeecg.modules.inquiry.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.shiro.SecurityUtils;
|
||||
import org.jeecg.common.system.vo.LoginUser;
|
||||
import org.jeecg.modules.inquiry.domain.TicketStatuses;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryTicket;
|
||||
@@ -75,9 +77,11 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
||||
Date from = Date.from(fromDate.atStartOfDay(ZONE).toInstant());
|
||||
Date to = Date.from(LocalDateTime.of(today, LocalTime.MAX).atZone(ZONE).toInstant());
|
||||
|
||||
List<InquiryTicket> tickets = ticketMapper.selectList(new LambdaQueryWrapper<InquiryTicket>()
|
||||
LambdaQueryWrapper<InquiryTicket> qw = new LambdaQueryWrapper<InquiryTicket>()
|
||||
.ge(InquiryTicket::getCreateTime, from)
|
||||
.le(InquiryTicket::getCreateTime, to));
|
||||
.le(InquiryTicket::getCreateTime, to);
|
||||
applyDashboardSelfOnly(qw);
|
||||
List<InquiryTicket> tickets = ticketMapper.selectList(qw);
|
||||
|
||||
long total = tickets.size();
|
||||
long deal = tickets.stream().filter(t -> TicketStatuses.DEAL.equals(t.getStatus())).count();
|
||||
@@ -115,9 +119,11 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
||||
|
||||
// 近 7 日趋势 + 成本:一次拉周内工单,避免 7 次 count
|
||||
Date weekFrom = Date.from(today.minusDays(6).atStartOfDay(ZONE).toInstant());
|
||||
List<InquiryTicket> weekTickets = ticketMapper.selectList(new LambdaQueryWrapper<InquiryTicket>()
|
||||
LambdaQueryWrapper<InquiryTicket> weekQw = new LambdaQueryWrapper<InquiryTicket>()
|
||||
.ge(InquiryTicket::getCreateTime, weekFrom)
|
||||
.le(InquiryTicket::getCreateTime, to));
|
||||
.le(InquiryTicket::getCreateTime, to);
|
||||
applyDashboardSelfOnly(weekQw);
|
||||
List<InquiryTicket> weekTickets = ticketMapper.selectList(weekQw);
|
||||
|
||||
List<String> trendDates = new ArrayList<>(7);
|
||||
List<Map<String, Object>> trend = new ArrayList<>(7);
|
||||
@@ -442,4 +448,72 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
||||
}
|
||||
return "sales";
|
||||
}
|
||||
|
||||
/**
|
||||
* 账号勾选「只看自己数据」时收紧看板工单范围。
|
||||
* <p>
|
||||
* 口径对齐原项目:非超管且 dashboardSelfOnly=true →
|
||||
* 工单 salesWecomId = 当前账号工号(企微 id)或 createBy = 登录名。
|
||||
* 超管(username=admin 或角色 admin)忽略开关。
|
||||
*/
|
||||
private void applyDashboardSelfOnly(LambdaQueryWrapper<InquiryTicket> qw) {
|
||||
LoginUser login = currentLoginUser();
|
||||
if (login == null) {
|
||||
return;
|
||||
}
|
||||
if (isAdministrator(login)) {
|
||||
return;
|
||||
}
|
||||
if (!Boolean.TRUE.equals(login.getDashboardSelfOnly())) {
|
||||
return;
|
||||
}
|
||||
String username = StringUtils.trimToEmpty(login.getUsername());
|
||||
String wecom = StringUtils.trimToEmpty(login.getWorkNo());
|
||||
qw.and(w -> {
|
||||
boolean any = false;
|
||||
if (StringUtils.isNotBlank(wecom)) {
|
||||
w.eq(InquiryTicket::getSalesWecomId, wecom);
|
||||
any = true;
|
||||
}
|
||||
if (StringUtils.isNotBlank(username)) {
|
||||
if (any) {
|
||||
w.or();
|
||||
}
|
||||
w.eq(InquiryTicket::getCreateBy, username);
|
||||
any = true;
|
||||
}
|
||||
if (!any) {
|
||||
// 未绑企微且无用户名:宁可不看数据,也不漏看全站
|
||||
w.apply("1 = 0");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static LoginUser currentLoginUser() {
|
||||
try {
|
||||
Object p = SecurityUtils.getSubject().getPrincipal();
|
||||
if (p instanceof LoginUser) {
|
||||
return (LoginUser) p;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 无登录上下文时不做过滤
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 超管:登录名 admin,或 Shiro 角色 admin/管理员。 */
|
||||
private static boolean isAdministrator(LoginUser login) {
|
||||
if (login == null) {
|
||||
return false;
|
||||
}
|
||||
if ("admin".equalsIgnoreCase(StringUtils.trimToEmpty(login.getUsername()))) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
return SecurityUtils.getSubject().hasRole("admin")
|
||||
|| SecurityUtils.getSubject().hasRole("管理员");
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+355
-16
@@ -245,8 +245,8 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
version.setMappingPointCount(0);
|
||||
version.setMappingJson("{\"schemaVersion\":\"quote-template-mapping-v1\",\"fields\":{},\"status\":\"pending\"}");
|
||||
versionMapper.insert(version);
|
||||
// 事务提交后再入队,避免 Worker 回写时版本行尚未可见
|
||||
scheduleAiParseAfterCommit(version.getId(), template.getId(), fileBytes);
|
||||
// 事务提交后再入队,避免 Worker 回写时版本行尚未可见;换文件 = 整份覆盖
|
||||
scheduleAiParseAfterCommit(version.getId(), template.getId(), fileBytes, false);
|
||||
}
|
||||
|
||||
if (isDefault) {
|
||||
@@ -269,8 +269,11 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
|
||||
/**
|
||||
* 事务提交后提交 AI 解析;无事务时立刻提交。
|
||||
*
|
||||
* @param mergeExisting true=未换文件的重新解析,回写时与旧 mapping 补缺合并;false=换文件/新上传,整份覆盖
|
||||
*/
|
||||
private void scheduleAiParseAfterCommit(String versionId, String templateId, byte[] fileBytes) {
|
||||
private void scheduleAiParseAfterCommit(String versionId, String templateId, byte[] fileBytes,
|
||||
boolean mergeExisting) {
|
||||
Runnable job = () -> {
|
||||
try {
|
||||
InquiryQuoteTemplateVersion ver = versionMapper.selectById(versionId);
|
||||
@@ -279,7 +282,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
log.warn("AI 解析入队跳过:版本或模板不存在 versionId={}", versionId);
|
||||
return;
|
||||
}
|
||||
String enqueueErr = submitAiParse(ver, template, fileBytes);
|
||||
String enqueueErr = submitAiParse(ver, template, fileBytes, mergeExisting);
|
||||
if (enqueueErr != null) {
|
||||
ver.setParseStatus("failed");
|
||||
ver.setParseMessage(enqueueErr);
|
||||
@@ -313,8 +316,11 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
|
||||
/**
|
||||
* 抽单元格清单并提交智能体入队。成功返回 null,失败返回中文原因。
|
||||
*
|
||||
* @param mergeExisting 重新解析时 true:带上旧 mapping,回写补缺合并
|
||||
*/
|
||||
private String submitAiParse(InquiryQuoteTemplateVersion version, InquiryQuoteTemplate template, byte[] fileBytes) {
|
||||
private String submitAiParse(InquiryQuoteTemplateVersion version, InquiryQuoteTemplate template,
|
||||
byte[] fileBytes, boolean mergeExisting) {
|
||||
try {
|
||||
JSONObject inventory;
|
||||
try (InputStream in = new ByteArrayInputStream(fileBytes)) {
|
||||
@@ -335,6 +341,19 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
body.put("cellCount", inventory.getIntValue("cellCount"));
|
||||
body.put("yellowCount", inventory.getIntValue("yellowCount"));
|
||||
body.put("truncated", inventory.getBooleanValue("truncated"));
|
||||
body.put("mergeExisting", mergeExisting);
|
||||
// 仅补缺合并时附带旧映射;换文件不传,避免模型/回写误用
|
||||
if (mergeExisting && StringUtils.isNotBlank(version.getMappingJson())) {
|
||||
try {
|
||||
JSONObject old = JSON.parseObject(version.getMappingJson());
|
||||
if (old != null && old.getJSONObject("fields") != null
|
||||
&& !old.getJSONObject("fields").isEmpty()) {
|
||||
body.put("existingMapping", old);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("重新解析附带旧 mapping 失败 versionId={} err={}", version.getId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
body.put("requestId", version.getId() + "-" + System.currentTimeMillis());
|
||||
return aiParseClient.enqueueParse(body);
|
||||
} catch (Exception e) {
|
||||
@@ -827,6 +846,13 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
String eta = first(q, "eta", "transit_days", "transitDays");
|
||||
if (StringUtils.isNotBlank(eta) && !"-".equals(eta)) {
|
||||
out.put("transit_days", eta);
|
||||
out.put("transit_time", eta);
|
||||
}
|
||||
// 空运计费重:TMS chargeableWeightKg → 模板 chargeable_weight_kg
|
||||
String chargeable = first(q, "chargeableWeightKg", "chargeable_weight_kg", "chargeable_weight");
|
||||
if (StringUtils.isNotBlank(chargeable) && !"-".equals(chargeable)) {
|
||||
out.put("chargeable_weight_kg", chargeable);
|
||||
out.put("chargeableWeightKg", chargeable);
|
||||
}
|
||||
for (Map.Entry<String, String> e : QuoteTemplateExcelSupport.LAND_TMS_KEY_TO_CODE.entrySet()) {
|
||||
String val = first(q, e.getKey(), e.getValue());
|
||||
@@ -873,9 +899,102 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
if (!rows.isEmpty()) {
|
||||
out.put("fee_rows", rows);
|
||||
}
|
||||
// 海运分区:透传 pol/ocean/pod 桶,供 feeRegions 按段回填
|
||||
for (String bucket : new String[]{"polCostItems", "oceanCostItems", "podCostItems"}) {
|
||||
JSONArray part = QuoteTemplateExcelSupport.parseFeeRows(q.get("fee_rows_" + bucket));
|
||||
if (part.isEmpty()) {
|
||||
part = QuoteTemplateExcelSupport.parseFeeRows(q.get(bucket));
|
||||
}
|
||||
if (!part.isEmpty()) {
|
||||
out.put("fee_rows_" + bucket, part);
|
||||
out.put(bucket, part);
|
||||
}
|
||||
}
|
||||
// 陆运线路:透传 landSegments / route_rows,供 routeRegions 填项目一
|
||||
JSONArray landSegs = QuoteTemplateExcelSupport.parseFeeRows(q.get("route_rows"));
|
||||
if (landSegs.isEmpty()) {
|
||||
landSegs = QuoteTemplateExcelSupport.parseFeeRows(q.get("landSegments"));
|
||||
}
|
||||
if (!landSegs.isEmpty()) {
|
||||
out.put("landSegments", landSegs);
|
||||
out.put("route_rows", landSegs);
|
||||
// 首段时效若 facts 尚无,用腿上 transit
|
||||
if (StringUtils.isBlank(out.getString("transit_time")) || "-".equals(out.getString("transit_time"))) {
|
||||
JSONObject firstSeg = landSegs.getJSONObject(0);
|
||||
if (firstSeg != null) {
|
||||
String t = firstNonBlankLocal(firstSeg.getString("transit_time"), firstSeg.getString("transitDays"));
|
||||
if (StringUtils.isNotBlank(t)) {
|
||||
out.put("transit_time", t);
|
||||
out.put("transit_days", t);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (looksLandInquiry(src)) {
|
||||
// 无 landSegments 时:用询价 OD + fee_lines 分项拼一行,尽量填「提货费/运输费/送货费」列
|
||||
JSONObject one = new JSONObject(true);
|
||||
one.put("origin", out.getString("origin"));
|
||||
one.put("destination", out.getString("destination"));
|
||||
one.put("transit_time", out.getString("transit_time"));
|
||||
one.put("transitDays", out.getString("transit_days"));
|
||||
one.put("currency", out.getString("currency"));
|
||||
one.put("pickup_fee", out.getString("pickup_fee"));
|
||||
one.put("delivery_fee", out.getString("delivery_fee"));
|
||||
one.put("price_per_cbm", out.getString("price_per_cbm"));
|
||||
one.put("price_per_kg", out.getString("price_per_kg"));
|
||||
String landAmt = firstNonBlankLocal(out.getString("land_freight"), out.getString("price_per_cbm"));
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
JSONObject fee = rows.getJSONObject(i);
|
||||
if (fee == null) {
|
||||
continue;
|
||||
}
|
||||
String fname = StringUtils.trimToEmpty(fee.getString("name"));
|
||||
String famt = StringUtils.trimToEmpty(fee.getString("amount"));
|
||||
if (StringUtils.isBlank(famt) || "-".equals(famt)) {
|
||||
continue;
|
||||
}
|
||||
if (fname.contains("提货") && StringUtils.isBlank(one.getString("pickup_fee"))) {
|
||||
one.put("pickup_fee", famt);
|
||||
} else if ((fname.contains("送货") || fname.contains("派送")) && StringUtils.isBlank(one.getString("delivery_fee"))) {
|
||||
one.put("delivery_fee", famt);
|
||||
} else if (fname.contains("CBM") && StringUtils.isBlank(one.getString("price_per_cbm"))) {
|
||||
one.put("price_per_cbm", famt);
|
||||
} else if ((fname.contains("KG") || fname.contains("kg") || fname.contains("公斤"))
|
||||
&& StringUtils.isBlank(one.getString("price_per_kg"))) {
|
||||
one.put("price_per_kg", famt);
|
||||
} else if (fname.contains("运输") || fname.contains("陆运") || fname.contains("运费")) {
|
||||
landAmt = famt;
|
||||
one.put("salePrice", famt);
|
||||
one.put("amount", famt);
|
||||
if (StringUtils.isNotBlank(fee.getString("remark"))) {
|
||||
one.put("remark", fee.getString("remark"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (StringUtils.isNotBlank(landAmt) && StringUtils.isBlank(one.getString("amount"))) {
|
||||
one.put("amount", landAmt);
|
||||
one.put("salePrice", landAmt);
|
||||
}
|
||||
if (StringUtils.isNotBlank(one.getString("origin")) || StringUtils.isNotBlank(one.getString("destination"))) {
|
||||
JSONArray routeRows = new JSONArray();
|
||||
routeRows.add(one);
|
||||
out.put("route_rows", routeRows);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String firstNonBlankLocal(String... vals) {
|
||||
if (vals == null) {
|
||||
return "";
|
||||
}
|
||||
for (String v : vals) {
|
||||
if (StringUtils.isNotBlank(v) && !"-".equals(v.trim())) {
|
||||
return v.trim();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 询价事实里已有陆运选项/始发站,运费兜底名就不能再写成空运费。
|
||||
*/
|
||||
@@ -980,7 +1099,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
ver.setParseMessage("解析中,请稍候");
|
||||
ver.setUpdateTime(new Date());
|
||||
versionMapper.updateById(ver);
|
||||
scheduleAiParseAfterCommit(ver.getId(), template.getId(), fileBytes);
|
||||
scheduleAiParseAfterCommit(ver.getId(), template.getId(), fileBytes, true);
|
||||
enqueueSnapshotPreview(ver.getId());
|
||||
} catch (Exception e) {
|
||||
log.error("模板补解析失败 templateId={} objectKey={}", templateId, ver.getObjectKey(), e);
|
||||
@@ -993,10 +1112,13 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
|
||||
/**
|
||||
* 智能体回写 AI 映射。校验白名单与 feeRegion 增行配置;未映射必填 → mapping_incomplete。
|
||||
* <p>
|
||||
* mergeExisting=true 时:与库中旧 mapping 智能合并(对齐标签的旧坐标保留,对不上的改用新坐标)。
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void applyParseResult(String versionId, boolean ok, String mappingJson, String parseMessage, String parseStatus) {
|
||||
public void applyParseResult(String versionId, boolean ok, String mappingJson, String parseMessage,
|
||||
String parseStatus, boolean mergeExisting) {
|
||||
if (StringUtils.isBlank(versionId)) {
|
||||
throw new IllegalArgumentException("versionId 必填");
|
||||
}
|
||||
@@ -1032,6 +1154,41 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
versionMapper.updateById(ver);
|
||||
return;
|
||||
}
|
||||
// 未换文件重新解析:仍对齐的旧坐标保留;对不上标签的改用新坐标
|
||||
if (mergeExisting && StringUtils.isNotBlank(ver.getMappingJson())) {
|
||||
try {
|
||||
JSONObject old = JSON.parseObject(ver.getMappingJson());
|
||||
if (old != null) {
|
||||
org.apache.poi.ss.usermodel.Workbook wb = null;
|
||||
try {
|
||||
if (StringUtils.isNotBlank(ver.getObjectKey())) {
|
||||
Path local = resolveObjectPath(ver.getObjectKey());
|
||||
try (InputStream in = Files.newInputStream(local)) {
|
||||
wb = org.apache.poi.ss.usermodel.WorkbookFactory.create(in);
|
||||
}
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
log.warn("合并校验打开模板失败 versionId={} err={}", versionId, ex.getMessage());
|
||||
}
|
||||
try {
|
||||
mapping = mergeMappingPreferExisting(old, mapping, wb);
|
||||
} finally {
|
||||
if (wb != null) {
|
||||
try {
|
||||
wb.close();
|
||||
} catch (Exception ignored) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
log.info("模板映射智能合并 versionId={} oldFields={}",
|
||||
versionId,
|
||||
old.getJSONObject("fields") == null ? 0 : old.getJSONObject("fields").size());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("旧 mapping 合并跳过 versionId={} err={}", versionId, e.getMessage());
|
||||
}
|
||||
}
|
||||
String validateErr = validateAiMapping(mapping);
|
||||
if (validateErr != null) {
|
||||
ver.setParseStatus("failed");
|
||||
@@ -1049,12 +1206,186 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
applyExcelMapping(ver, template != null ? template : new InquiryQuoteTemplate(), mapping);
|
||||
if (StringUtils.isNotBlank(parseMessage) && "failed".equals(ver.getParseStatus())) {
|
||||
ver.setParseMessage(parseMessage);
|
||||
} else if (mergeExisting && !"failed".equals(ver.getParseStatus())) {
|
||||
// 合并后重算摘要点数,避免沿用 AI 单轮字数
|
||||
int pts = ver.getMappingPointCount() == null ? 0 : ver.getMappingPointCount();
|
||||
String base = "已解析 " + pts + " 个映射点(对齐的保留,对不上的改用新坐标)";
|
||||
if (StringUtils.isNotBlank(parseMessage) && parseMessage.contains("未解析到")) {
|
||||
int idx = parseMessage.indexOf(";未解析到");
|
||||
if (idx > 0) {
|
||||
base = base + parseMessage.substring(idx);
|
||||
}
|
||||
}
|
||||
ver.setParseMessage(base);
|
||||
}
|
||||
ver.setUpdateBy("agent");
|
||||
ver.setUpdateTime(new Date());
|
||||
versionMapper.updateById(ver);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新解析合并(无 Workbook 时退化为旧坐标优先,兼容单测)。
|
||||
*/
|
||||
public static JSONObject mergeMappingPreferExisting(JSONObject oldMapping, JSONObject newMapping) {
|
||||
return mergeMappingPreferExisting(oldMapping, newMapping, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新解析合并:费用/线路区仍按 id 旧优先;fields 在对照模板后智能取舍。
|
||||
* <p>
|
||||
* 规则:旧无→用新;新无→留旧;两者相同→留旧;旧坐标仍贴标签→留旧;
|
||||
* 旧坐标对不上标签(有 Workbook 可校验)→改用新坐标。
|
||||
* mappingGaps 以新结果为准。
|
||||
*
|
||||
* @param workbook 可空;有则校验旧坐标是否仍对齐标签
|
||||
*/
|
||||
public static JSONObject mergeMappingPreferExisting(JSONObject oldMapping, JSONObject newMapping,
|
||||
org.apache.poi.ss.usermodel.Workbook workbook) {
|
||||
JSONObject out = new JSONObject(true);
|
||||
if (newMapping != null) {
|
||||
out.putAll(newMapping);
|
||||
}
|
||||
if (oldMapping == null) {
|
||||
return out;
|
||||
}
|
||||
JSONObject newFields = newMapping == null ? null : newMapping.getJSONObject("fields");
|
||||
JSONObject oldFields = oldMapping.getJSONObject("fields");
|
||||
JSONObject fields = new JSONObject(true);
|
||||
if (newFields != null) {
|
||||
fields.putAll(newFields);
|
||||
}
|
||||
JSONObject newMeta = newMapping == null ? null : newMapping.getJSONObject("fieldMeta");
|
||||
JSONObject oldMeta = oldMapping.getJSONObject("fieldMeta");
|
||||
JSONObject metaOut = new JSONObject(true);
|
||||
if (newMeta != null) {
|
||||
metaOut.putAll(newMeta);
|
||||
}
|
||||
Set<String> codes = new LinkedHashSet<>();
|
||||
if (oldFields != null) {
|
||||
codes.addAll(oldFields.keySet());
|
||||
}
|
||||
if (newFields != null) {
|
||||
codes.addAll(newFields.keySet());
|
||||
}
|
||||
for (String code : codes) {
|
||||
String oldCell = oldFields == null ? null : StringUtils.trimToNull(oldFields.getString(code));
|
||||
String newCell = newFields == null ? null : StringUtils.trimToNull(newFields.getString(code));
|
||||
String chosen;
|
||||
boolean keptOld;
|
||||
if (StringUtils.isBlank(oldCell)) {
|
||||
chosen = newCell;
|
||||
keptOld = false;
|
||||
} else if (StringUtils.isBlank(newCell)) {
|
||||
chosen = oldCell;
|
||||
keptOld = true;
|
||||
} else if (oldCell.equalsIgnoreCase(newCell)) {
|
||||
chosen = oldCell;
|
||||
keptOld = true;
|
||||
// 新旧都是 B3 单选时仍要纠到 RFQ 标签格
|
||||
if ("quote_no".equals(code) && workbook != null
|
||||
&& !QuoteTemplateExcelSupport.fieldMappingStillAligned(workbook, code, oldCell)) {
|
||||
String better = QuoteTemplateExcelSupport.relocateQuoteNoCell(workbook, oldCell);
|
||||
if (StringUtils.isNotBlank(better) && !better.equalsIgnoreCase(oldCell)) {
|
||||
chosen = better;
|
||||
keptOld = false;
|
||||
JSONObject qm = metaOut.getJSONObject("quote_no");
|
||||
if (qm == null) {
|
||||
qm = new JSONObject(true);
|
||||
}
|
||||
qm.put("sameCellAfterColon", true);
|
||||
metaOut.put("quote_no", qm);
|
||||
log.info("映射坐标纠偏 code={} old={} -> new={}", code, oldCell, chosen);
|
||||
}
|
||||
}
|
||||
} else if (workbook != null
|
||||
&& QuoteTemplateExcelSupport.fieldMappingStillAligned(workbook, code, oldCell)) {
|
||||
chosen = oldCell;
|
||||
keptOld = true;
|
||||
} else if (workbook != null) {
|
||||
// 旧坐标对不上标签 → 用新;quote_no 若仍落在单选格则再纠到标签格冒号后
|
||||
chosen = newCell;
|
||||
keptOld = false;
|
||||
if ("quote_no".equals(code)) {
|
||||
String better = QuoteTemplateExcelSupport.relocateQuoteNoCell(workbook, newCell);
|
||||
if (StringUtils.isNotBlank(better)) {
|
||||
chosen = better;
|
||||
}
|
||||
// 纠到标签格后,出单必须同格冒号后写
|
||||
JSONObject qm = metaOut.getJSONObject("quote_no");
|
||||
if (qm == null) {
|
||||
qm = new JSONObject(true);
|
||||
}
|
||||
qm.put("sameCellAfterColon", true);
|
||||
metaOut.put("quote_no", qm);
|
||||
}
|
||||
log.info("映射坐标纠偏 code={} old={} -> new={}", code, oldCell, chosen);
|
||||
} else {
|
||||
// 无表可校验时保守保留旧坐标
|
||||
chosen = oldCell;
|
||||
keptOld = true;
|
||||
}
|
||||
if (StringUtils.isNotBlank(chosen)) {
|
||||
fields.put(code, chosen);
|
||||
}
|
||||
if (keptOld && oldMeta != null && oldMeta.getJSONObject(code) != null) {
|
||||
metaOut.put(code, oldMeta.getJSONObject(code));
|
||||
}
|
||||
}
|
||||
out.put("fields", fields);
|
||||
if (!metaOut.isEmpty()) {
|
||||
out.put("fieldMeta", metaOut);
|
||||
}
|
||||
out.put("feeRegions", mergeRegionsById(
|
||||
oldMapping.getJSONArray("feeRegions"),
|
||||
newMapping == null ? null : newMapping.getJSONArray("feeRegions")));
|
||||
out.put("routeRegions", mergeRegionsById(
|
||||
oldMapping.getJSONArray("routeRegions"),
|
||||
newMapping == null ? null : newMapping.getJSONArray("routeRegions")));
|
||||
if (out.getJSONArray("mappingGaps") != null && out.getJSONArray("mappingGaps").isEmpty()) {
|
||||
out.put("unmappedRequired", new JSONArray());
|
||||
out.put("unmappedLabels", new JSONArray());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 区域按 id 合并:旧 id 整段保留;新 id 追加在后。
|
||||
*/
|
||||
private static JSONArray mergeRegionsById(JSONArray oldArr, JSONArray newArr) {
|
||||
JSONArray out = new JSONArray();
|
||||
Set<String> seen = new LinkedHashSet<>();
|
||||
if (oldArr != null) {
|
||||
for (int i = 0; i < oldArr.size(); i++) {
|
||||
JSONObject r = oldArr.getJSONObject(i);
|
||||
if (r == null) {
|
||||
continue;
|
||||
}
|
||||
String id = StringUtils.defaultString(r.getString("id"));
|
||||
if (StringUtils.isNotBlank(id)) {
|
||||
seen.add(id);
|
||||
}
|
||||
out.add(r);
|
||||
}
|
||||
}
|
||||
if (newArr != null) {
|
||||
for (int i = 0; i < newArr.size(); i++) {
|
||||
JSONObject r = newArr.getJSONObject(i);
|
||||
if (r == null) {
|
||||
continue;
|
||||
}
|
||||
String id = StringUtils.defaultString(r.getString("id"));
|
||||
if (StringUtils.isNotBlank(id) && seen.contains(id)) {
|
||||
continue;
|
||||
}
|
||||
if (StringUtils.isNotBlank(id)) {
|
||||
seen.add(id);
|
||||
}
|
||||
out.add(r);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把误标为 mapping_incomplete、但已有映射点的版本纠偏为 parsed。
|
||||
*/
|
||||
@@ -1384,7 +1715,10 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
|
||||
/**
|
||||
* 从 mappingJson 取出智能体对照清单算出的未映射说明(中文)。
|
||||
* 若 mappingGaps 为空,用 unmappedLabels / unmappedRequired 兜底,保证列表能提示。
|
||||
* <p>
|
||||
* 口径:有 {@code mappingGaps} 字段时(含空数组)只认它——空数组即「系统判定齐全、角标 0」;
|
||||
* 仅旧数据完全没有该字段时,才用 unmappedLabels / unmappedRequired 兜底。
|
||||
* 说明性 unmappedLabels 不得在齐全时抬高列表「未解析」角标。
|
||||
*/
|
||||
private static List<String> extractMappingGaps(String mappingJson) {
|
||||
if (StringUtils.isBlank(mappingJson)) {
|
||||
@@ -1392,19 +1726,24 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
}
|
||||
try {
|
||||
JSONObject obj = JSON.parseObject(mappingJson);
|
||||
JSONArray arr = obj.getJSONArray("mappingGaps");
|
||||
if (obj == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<String> out = new ArrayList<>();
|
||||
if (arr != null) {
|
||||
for (int i = 0; i < arr.size(); i++) {
|
||||
String s = arr.getString(i);
|
||||
if (StringUtils.isNotBlank(s)) {
|
||||
out.add(s);
|
||||
// 显式有 mappingGaps 键:空也算齐全,禁止再用 AI 自报标签抬角标
|
||||
if (obj.containsKey("mappingGaps")) {
|
||||
JSONArray arr = obj.getJSONArray("mappingGaps");
|
||||
if (arr != null) {
|
||||
for (int i = 0; i < arr.size(); i++) {
|
||||
String s = arr.getString(i);
|
||||
if (StringUtils.isNotBlank(s)) {
|
||||
out.add(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!out.isEmpty()) {
|
||||
return out;
|
||||
}
|
||||
// 旧版映射无 mappingGaps:才兜底
|
||||
JSONArray labels = obj.getJSONArray("unmappedLabels");
|
||||
if (labels != null) {
|
||||
for (int i = 0; i < labels.size(); i++) {
|
||||
|
||||
+3
@@ -174,6 +174,9 @@ public class InquiryStaffRoleServiceImpl extends ServiceImpl<InquiryStaffRoleMap
|
||||
one.put("wecomId", row.getWecomId());
|
||||
one.put("roleCode", row.getRoleCode());
|
||||
one.put("status", row.getStatus());
|
||||
// 成交跟进按「是负责人 + 负责线路」匹配,缺这两项智能体对不上人。
|
||||
one.put("isLeader", row.getIsLeader());
|
||||
one.put("routes", StringUtils.defaultString(row.getRoutes()));
|
||||
out.add(one);
|
||||
}
|
||||
return out;
|
||||
|
||||
+155
-3
@@ -209,11 +209,27 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
if (TicketStatuses.CLOSED.equals(to) && StringUtils.isNotBlank(req.getRemark())) {
|
||||
ticket.setCloseReason(req.getRemark());
|
||||
}
|
||||
if (TicketStatuses.LOST.equals(to)) {
|
||||
ticket.setNextFollowupAt(null);
|
||||
if (StringUtils.isNotBlank(req.getLostReason())) {
|
||||
ticket.setLostReason(StringUtils.left(req.getLostReason().trim(), 500));
|
||||
}
|
||||
} else if (TicketStatuses.DEAL.equals(to)) {
|
||||
ticket.setNextFollowupAt(null);
|
||||
if (StringUtils.isNotBlank(req.getDealNotifySummary())) {
|
||||
ticket.setDealNotifySummary(StringUtils.left(req.getDealNotifySummary().trim(), 500));
|
||||
}
|
||||
} else if (TicketStatuses.NEGOTIATING.equals(to)) {
|
||||
Date follow = parseDateTime(req.getNextFollowupAt());
|
||||
ticket.setNextFollowupAt(follow);
|
||||
}
|
||||
ticket.setVersion(ticket.getVersion() == null ? 2 : ticket.getVersion() + 1);
|
||||
ticket.setUpdateBy(operator);
|
||||
ticket.setUpdateTime(new Date());
|
||||
updateById(ticket);
|
||||
appendLog(ticket, from, to, "transition", operator, req.getRemark());
|
||||
String remark = StringUtils.defaultIfBlank(req.getRemark(),
|
||||
StringUtils.defaultIfBlank(req.getDealNotifySummary(), req.getLostReason()));
|
||||
appendLog(ticket, from, to, "transition", operator, remark);
|
||||
return ticket;
|
||||
}
|
||||
|
||||
@@ -569,9 +585,13 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
}
|
||||
String status = StringUtils.defaultString(ticket.getStatus());
|
||||
String waitPhase;
|
||||
if (TicketStatuses.DEAL.equals(status) || TicketStatuses.LOST.equals(status)
|
||||
|| TicketStatuses.NEGOTIATING.equals(status) || TicketStatuses.CLOSED.equals(status)) {
|
||||
if (TicketStatuses.DEAL.equals(status) || TicketStatuses.CLOSED.equals(status)) {
|
||||
waitPhase = "done";
|
||||
} else if (TicketStatuses.LOST.equals(status)) {
|
||||
waitPhase = StringUtils.isBlank(ticket.getLostReason()) ? "wait_lost_reason" : "done";
|
||||
} else if (TicketStatuses.NEGOTIATING.equals(status)) {
|
||||
// 协商中会话不能结束:还要再问成交,也可中途改成交/未成交。
|
||||
waitPhase = "wait_deal";
|
||||
} else if (TicketStatuses.QUOTED.equals(status) && !quote.isEmpty()) {
|
||||
// 文件已发出:成交跟进。六态仍是已报价,不能退回协同/出方案卡
|
||||
if (quoteFileAlreadyIssued(no, quote)) {
|
||||
@@ -592,6 +612,12 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
out.put("facts", facts);
|
||||
out.put("quote", quote);
|
||||
out.put("wait_phase", waitPhase);
|
||||
out.put("lost_reason", StringUtils.defaultString(ticket.getLostReason()));
|
||||
out.put("lostReason", StringUtils.defaultString(ticket.getLostReason()));
|
||||
out.put("deal_notify_summary", StringUtils.defaultString(ticket.getDealNotifySummary()));
|
||||
if (ticket.getNextFollowupAt() != null) {
|
||||
out.put("next_followup_at", new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(ticket.getNextFollowupAt()));
|
||||
}
|
||||
out.put("collab_chat_id", StringUtils.defaultString(ticket.getCollabChatId()));
|
||||
Map<String, String> collabFacts = new LinkedHashMap<>();
|
||||
if (StringUtils.isNotBlank(ticket.getCollabFactsJson())) {
|
||||
@@ -773,6 +799,8 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
out.put("business_line", StringUtils.defaultString(ticket.getBusinessLine()));
|
||||
out.put("facts", parseFactsJson(ticket.getFactsJson()));
|
||||
out.put("quote", latestQuotePayload(ticket.getWorkOrderNo()));
|
||||
out.put("lost_reason", StringUtils.defaultString(ticket.getLostReason()));
|
||||
out.put("lostReason", StringUtils.defaultString(ticket.getLostReason()));
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -1027,6 +1055,130 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
.last("LIMIT 1"), false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 未成交后补原因。不改六态;已有原因可覆盖。
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> patchLostReason(String workOrderNo, String lostReason, String operator) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
String no = StringUtils.trimToEmpty(workOrderNo);
|
||||
String reason = StringUtils.left(StringUtils.trimToEmpty(lostReason), 500);
|
||||
out.put("ok", false);
|
||||
if (StringUtils.isBlank(no) || StringUtils.isBlank(reason)) {
|
||||
out.put("error", "workOrderNo_or_reason_blank");
|
||||
return out;
|
||||
}
|
||||
InquiryTicket ticket = findByWorkOrderNo(no);
|
||||
if (ticket == null) {
|
||||
out.put("error", "ticket_not_found");
|
||||
return out;
|
||||
}
|
||||
if (!TicketStatuses.LOST.equals(ticket.getStatus())) {
|
||||
out.put("error", "not_lost");
|
||||
out.put("status", ticket.getStatus());
|
||||
return out;
|
||||
}
|
||||
ticket.setLostReason(reason);
|
||||
ticket.setUpdateBy(StringUtils.defaultIfBlank(operator, "agent"));
|
||||
ticket.setUpdateTime(new Date());
|
||||
updateById(ticket);
|
||||
appendLog(ticket, ticket.getStatus(), ticket.getStatus(), "lost_reason", operator, reason);
|
||||
out.put("ok", true);
|
||||
out.put("work_order_no", no);
|
||||
out.put("lost_reason", reason);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 认领到期协商再问:状态仍是协商中且 next_followup_at 已到。
|
||||
* 清空再问时刻,Worker 只发这一轮;销售再选协商中才会排下一轮。
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public List<Map<String, Object>> claimDueNegotiate() {
|
||||
Date now = new Date();
|
||||
List<InquiryTicket> due = list(new LambdaQueryWrapper<InquiryTicket>()
|
||||
.eq(InquiryTicket::getStatus, TicketStatuses.NEGOTIATING)
|
||||
.isNotNull(InquiryTicket::getNextFollowupAt)
|
||||
.le(InquiryTicket::getNextFollowupAt, now)
|
||||
.last("LIMIT 50"));
|
||||
List<Map<String, Object>> out = new ArrayList<>();
|
||||
if (due == null || due.isEmpty()) {
|
||||
return out;
|
||||
}
|
||||
for (InquiryTicket ticket : due) {
|
||||
ticket.setNextFollowupAt(null);
|
||||
ticket.setUpdateBy("agent");
|
||||
ticket.setUpdateTime(now);
|
||||
updateById(ticket);
|
||||
appendLog(ticket, ticket.getStatus(), ticket.getStatus(), "negotiate_followup", "agent", "协商再问已发出");
|
||||
out.add(toDueNegotiateRow(ticket));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Worker 再问需要的最小快照:会话通道、字段、报价。
|
||||
*/
|
||||
private Map<String, Object> toDueNegotiateRow(InquiryTicket ticket) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("work_order_no", ticket.getWorkOrderNo());
|
||||
row.put("workOrderNo", ticket.getWorkOrderNo());
|
||||
row.put("sales_wecom_id", StringUtils.defaultString(ticket.getSalesWecomId()));
|
||||
row.put("salesWecomId", StringUtils.defaultString(ticket.getSalesWecomId()));
|
||||
row.put("sales_name", StringUtils.defaultString(ticket.getSalesName()));
|
||||
row.put("business_line", StringUtils.defaultString(ticket.getBusinessLine()));
|
||||
row.put("collab_chat_id", StringUtils.defaultString(ticket.getCollabChatId()));
|
||||
row.put("status", ticket.getStatus());
|
||||
Map<String, String> facts = new LinkedHashMap<>();
|
||||
if (StringUtils.isNotBlank(ticket.getFactsJson())) {
|
||||
JSONObject stored = JSON.parseObject(ticket.getFactsJson());
|
||||
if (stored != null) {
|
||||
for (String key : stored.keySet()) {
|
||||
if (stored.get(key) != null) {
|
||||
facts.put(key, String.valueOf(stored.get(key)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (StringUtils.isNotBlank(ticket.getOrigin())) {
|
||||
facts.putIfAbsent("起运港", ticket.getOrigin());
|
||||
}
|
||||
if (StringUtils.isNotBlank(ticket.getDestination())) {
|
||||
facts.putIfAbsent("目的港", ticket.getDestination());
|
||||
}
|
||||
row.put("facts", facts);
|
||||
Map<String, Object> quote = new LinkedHashMap<>();
|
||||
List<InquiryQuote> quotes = quoteMapper.selectList(new LambdaQueryWrapper<InquiryQuote>()
|
||||
.eq(InquiryQuote::getWorkOrderNo, ticket.getWorkOrderNo())
|
||||
.orderByDesc(InquiryQuote::getCreateTime)
|
||||
.last("LIMIT 1"));
|
||||
if (quotes != null && !quotes.isEmpty() && StringUtils.isNotBlank(quotes.get(0).getPayloadJson())) {
|
||||
JSONObject parsed = JSON.parseObject(quotes.get(0).getPayloadJson());
|
||||
if (parsed != null) {
|
||||
quote.putAll(parsed);
|
||||
}
|
||||
}
|
||||
row.put("quote", quote);
|
||||
return row;
|
||||
}
|
||||
|
||||
private static Date parseDateTime(String raw) {
|
||||
if (StringUtils.isBlank(raw)) {
|
||||
return null;
|
||||
}
|
||||
String text = raw.trim().replace("T", " ");
|
||||
if (text.length() >= 19) {
|
||||
text = text.substring(0, 19);
|
||||
}
|
||||
try {
|
||||
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse(text);
|
||||
} catch (Exception e) {
|
||||
return parseDate(raw);
|
||||
}
|
||||
}
|
||||
|
||||
private void appendLog(InquiryTicket ticket, String from, String to, String action, String operator, String remark) {
|
||||
InquiryTicketStatusLog log = new InquiryTicketStatusLog();
|
||||
log.setTicketId(ticket.getId());
|
||||
|
||||
+4
-2
@@ -12,7 +12,7 @@ import java.util.Map;
|
||||
* 陆运拉群人员匹配:只看员工管理里岗位=产品·陆运且启用的人。
|
||||
* <p>
|
||||
* 用本单线路类别对负责线路:按逗号/顿号拆整词,禁止把「中港/中亚/中欧」按斜杠切开。
|
||||
* 没有「陆运全线」快捷项;四条都要跟就须后台四条都勾。
|
||||
* 负责线路含「陆运全线」时视为命中全部线路类别(与海运「海运全线」对称)。
|
||||
* 禁止:按销售 userid 伪造名单;不查企微通讯录。
|
||||
*/
|
||||
public final class LandStaffMatcher {
|
||||
@@ -47,7 +47,9 @@ public final class LandStaffMatcher {
|
||||
if (StringUtils.isBlank(row.getWecomId())) {
|
||||
continue;
|
||||
}
|
||||
if (!routeTokens(row.getRoutes()).contains(want)) {
|
||||
List<String> tokens = routeTokens(row.getRoutes());
|
||||
// 陆运全线:跟海运全线一样,一条勾选覆盖全部线路类别
|
||||
if (!tokens.contains("陆运全线") && !tokens.contains(want)) {
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
|
||||
+799
-35
File diff suppressed because it is too large
Load Diff
+201
-8
@@ -300,6 +300,8 @@ public class InquiryTmsQueryService {
|
||||
quote.put("source", "TMS");
|
||||
quote.put("source_label", "TMS 标准报价");
|
||||
quote.put("fee_lines", buildFeeLinesJson(data, freightMoney.amount, otherMoney.amount, headerCurrency));
|
||||
// 海运 feeItems 是分段桶:polCostItems / oceanCostItems / podCostItems,单独透出给模板分区回填
|
||||
putSeaFeeBuckets(quote, data);
|
||||
// 锁舱必须带着 TMS 报价编号和舱位选项,不能只落金额。
|
||||
// 真实 TMS 的 quoteId 形如 AIR-数字-数字,optionNo 是 AIR-OPT01(中间没有第二根横杠)。
|
||||
String quoteId = firstText(data.get("quoteId"), data.get("quote_id"),
|
||||
@@ -318,6 +320,12 @@ public class InquiryTmsQueryService {
|
||||
? String.valueOf(airOptions)
|
||||
: JSON.toJSONString(airOptions));
|
||||
}
|
||||
// 陆运线路段:与空运 airOptions 同级透出,供模板 routeRegions 填项目一
|
||||
putLandSegments(quote, data, tms);
|
||||
String isMulti = firstText(data.get("isMulti"), data.get("is_multi"));
|
||||
if (StringUtils.isNotBlank(isMulti)) {
|
||||
quote.put("isMulti", isMulti);
|
||||
}
|
||||
String chargeable = firstText(data.get("chargeableWeightKg"), data.get("chargeable_weight_kg"));
|
||||
if (StringUtils.isNotBlank(chargeable)) {
|
||||
quote.put("chargeableWeightKg", chargeable);
|
||||
@@ -325,6 +333,127 @@ public class InquiryTmsQueryService {
|
||||
return quote;
|
||||
}
|
||||
|
||||
/**
|
||||
* 陆运 landSegments 原样+规范化写入 quote。
|
||||
* <p>
|
||||
* 为何:模板 routeRegions 的 tmsSource=landSegments;若不落库/不入 quote,
|
||||
* 出单只能写出始发目的时效,车型价/分腿价全空。
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void putLandSegments(Map<String, String> quote, Map<String, Object> data, Map<String, Object> tms) {
|
||||
if (quote == null) {
|
||||
return;
|
||||
}
|
||||
Object raw = first(
|
||||
data == null ? null : data.get("landSegments"),
|
||||
data == null ? null : data.get("land_segments"),
|
||||
tms == null ? null : tms.get("landSegments"),
|
||||
tms == null ? null : tms.get("land_segments"));
|
||||
if (raw == null) {
|
||||
return;
|
||||
}
|
||||
String json = raw instanceof String ? String.valueOf(raw) : JSON.toJSONString(raw);
|
||||
if (StringUtils.isBlank(json) || "null".equalsIgnoreCase(json) || "[]".equals(json.trim())) {
|
||||
return;
|
||||
}
|
||||
quote.put("landSegments", json);
|
||||
// 同步一份 route_rows,字段名对齐填表(transit_time / salePrice 等)
|
||||
List<Map<String, String>> rows = normalizeLandSegmentRows(raw);
|
||||
if (!rows.isEmpty()) {
|
||||
quote.put("route_rows", JSON.toJSONString(rows));
|
||||
}
|
||||
// feeItems 为空时,用各腿售价补一条运输费,避免报价明细全空
|
||||
String feeLines = quote.get("fee_lines");
|
||||
if ((StringUtils.isBlank(feeLines) || "[]".equals(feeLines.trim())) && !rows.isEmpty()) {
|
||||
List<Map<String, String>> lines = new ArrayList<>();
|
||||
for (Map<String, String> seg : rows) {
|
||||
String amount = firstText(seg.get("salePrice"), seg.get("costPrice"), seg.get("amount"));
|
||||
if (StringUtils.isBlank(amount) || "-".equals(amount)) {
|
||||
continue;
|
||||
}
|
||||
Map<String, String> line = new LinkedHashMap<>();
|
||||
line.put("name", "运输费");
|
||||
line.put("amount", amount);
|
||||
if (StringUtils.isNotBlank(seg.get("currency"))) {
|
||||
line.put("currency", seg.get("currency"));
|
||||
}
|
||||
if (StringUtils.isNotBlank(seg.get("remark"))) {
|
||||
line.put("remark", seg.get("remark"));
|
||||
}
|
||||
lines.add(line);
|
||||
}
|
||||
if (!lines.isEmpty()) {
|
||||
quote.put("fee_lines", JSON.toJSONString(lines));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* landSegments[] → 填表行:统一 origin/destination/salePrice/transit_time/vehicleType。
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
static List<Map<String, String>> normalizeLandSegmentRows(Object raw) {
|
||||
List<Map<String, String>> out = new ArrayList<>();
|
||||
List<?> list;
|
||||
if (raw instanceof List) {
|
||||
list = (List<?>) raw;
|
||||
} else if (raw instanceof String) {
|
||||
try {
|
||||
Object parsed = JSON.parse(String.valueOf(raw));
|
||||
if (!(parsed instanceof List)) {
|
||||
return out;
|
||||
}
|
||||
list = (List<?>) parsed;
|
||||
} catch (Exception e) {
|
||||
return out;
|
||||
}
|
||||
} else {
|
||||
return out;
|
||||
}
|
||||
for (Object item : list) {
|
||||
if (!(item instanceof Map)) {
|
||||
continue;
|
||||
}
|
||||
Map<?, ?> seg = (Map<?, ?>) item;
|
||||
Map<String, String> row = new LinkedHashMap<>();
|
||||
putExtra(row, "segmentSequence", firstText(seg.get("segmentSequence"), seg.get("sequence")));
|
||||
putExtra(row, "routeLegSequence", firstText(seg.get("routeLegSequence"), seg.get("legSequence")));
|
||||
putExtra(row, "origin", firstText(seg.get("origin"), seg.get("originName"), seg.get("originCode")));
|
||||
putExtra(row, "originCode", firstText(seg.get("originCode")));
|
||||
putExtra(row, "destination", firstText(seg.get("destination"), seg.get("destinationName"), seg.get("destinationCode")));
|
||||
putExtra(row, "destinationCode", firstText(seg.get("destinationCode")));
|
||||
putExtra(row, "vehicleType", firstText(seg.get("vehicleType"), seg.get("vehicle_type"), seg.get("truckType")));
|
||||
putExtra(row, "landTransportType", firstText(seg.get("landTransportType")));
|
||||
putExtra(row, "routeCategory", firstText(seg.get("routeCategory")));
|
||||
putExtra(row, "currency", firstText(seg.get("currency"), seg.get("settlementCurrency")));
|
||||
String sale = firstText(seg.get("salePrice"), seg.get("sale_price"));
|
||||
String cost = firstText(seg.get("costPrice"), seg.get("cost_price"));
|
||||
putExtra(row, "salePrice", sale);
|
||||
putExtra(row, "costPrice", cost);
|
||||
putExtra(row, "amount", firstText(sale, cost));
|
||||
String transit = firstText(seg.get("transitDays"), seg.get("transit_time"), seg.get("transitTime"),
|
||||
seg.get("aging"));
|
||||
putExtra(row, "transitDays", transit);
|
||||
putExtra(row, "transit_time", transit);
|
||||
putExtra(row, "remark", firstText(seg.get("remark"), seg.get("note"), seg.get("description")));
|
||||
// 腿上若带分项费率扩展字段,原样带上供「提货费/元/CBM」列匹配
|
||||
String[][] rateKeys = {
|
||||
{"pickupFee", "pickup_fee"}, {"deliveryFee", "delivery_fee"},
|
||||
{"pricePerCbm", "price_per_cbm"}, {"pricePerKg", "price_per_kg"},
|
||||
{"minCharge", "min_charge"}, {"landFreight", "land_freight"}
|
||||
};
|
||||
for (String[] pair : rateKeys) {
|
||||
putExtra(row, pair[1], firstText(seg.get(pair[0]), seg.get(pair[1])));
|
||||
putExtra(row, pair[0], firstText(seg.get(pair[0])));
|
||||
}
|
||||
if (StringUtils.isNotBlank(row.get("origin")) || StringUtils.isNotBlank(row.get("destination"))
|
||||
|| StringUtils.isNotBlank(row.get("amount"))) {
|
||||
out.add(row);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把本次 TMS 报价写回工单主表,供后台列表/详情展示。
|
||||
* 没有的成本/毛利率不编;售价用销售合计。
|
||||
@@ -351,6 +480,8 @@ public class InquiryTmsQueryService {
|
||||
/**
|
||||
* 抽出销售侧价格明细行。优先 TMS 费用清单,没有再用常见费用字段兜底。
|
||||
* 每行带自己的币种;没有就继承表头,仍然不编造。
|
||||
* 海运 feeItems 常是 [{polCostItems:[...]},{oceanCostItems:[...]},{podCostItems:[...]}],
|
||||
* 需展开各桶再扁平,否则顶层无 feeName 会抽成空列表。
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private static String buildFeeLinesJson(Map<String, Object> data, String freight, String other, String headerCurrency) {
|
||||
@@ -362,14 +493,22 @@ public class InquiryTmsQueryService {
|
||||
continue;
|
||||
}
|
||||
Map<?, ?> row = (Map<?, ?>) item;
|
||||
String name = firstText(row.get("feeName"), row.get("name"), row.get("chargeName"),
|
||||
row.get("itemName"), row.get("feeType"));
|
||||
String amount = firstText(row.get("salePrice"), row.get("amount"), row.get("price"),
|
||||
row.get("feeAmount"), row.get("totalPrice"));
|
||||
String lineCurrency = TmsQuoteCurrency.fromFeeRow(row);
|
||||
addFeeLine(lines, name, amount,
|
||||
StringUtils.isNotBlank(lineCurrency) ? lineCurrency : headerCurrency,
|
||||
extrasFromFee(row));
|
||||
// 海运分段桶:展开子列表
|
||||
boolean nested = false;
|
||||
for (String bucket : new String[]{"polCostItems", "oceanCostItems", "podCostItems"}) {
|
||||
Object sub = row.get(bucket);
|
||||
if (sub instanceof List) {
|
||||
nested = true;
|
||||
for (Object one : (List<?>) sub) {
|
||||
if (one instanceof Map) {
|
||||
appendFeeLineFromRow(lines, (Map<?, ?>) one, headerCurrency);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!nested) {
|
||||
appendFeeLineFromRow(lines, row, headerCurrency);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (lines.isEmpty()) {
|
||||
@@ -384,6 +523,60 @@ public class InquiryTmsQueryService {
|
||||
return JSON.toJSONString(lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* 海运三段费用桶单独落 quote,供模板 feeRegions 按 tmsSource 分区写,禁止三段共用扁平 fee_lines。
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void putSeaFeeBuckets(Map<String, String> quote, Map<String, Object> data) {
|
||||
if (quote == null || data == null) {
|
||||
return;
|
||||
}
|
||||
Object raw = first(data.get("feeItems"), data.get("feeList"));
|
||||
if (!(raw instanceof List)) {
|
||||
return;
|
||||
}
|
||||
Map<String, List<Map<String, String>>> buckets = new LinkedHashMap<>();
|
||||
buckets.put("polCostItems", new ArrayList<>());
|
||||
buckets.put("oceanCostItems", new ArrayList<>());
|
||||
buckets.put("podCostItems", new ArrayList<>());
|
||||
String headerCurrency = TmsQuoteCurrency.fromHeader(data);
|
||||
for (Object item : (List<?>) raw) {
|
||||
if (!(item instanceof Map)) {
|
||||
continue;
|
||||
}
|
||||
Map<?, ?> row = (Map<?, ?>) item;
|
||||
for (String bucket : buckets.keySet()) {
|
||||
Object sub = row.get(bucket);
|
||||
if (!(sub instanceof List)) {
|
||||
continue;
|
||||
}
|
||||
for (Object one : (List<?>) sub) {
|
||||
if (one instanceof Map) {
|
||||
appendFeeLineFromRow(buckets.get(bucket), (Map<?, ?>) one, headerCurrency);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (Map.Entry<String, List<Map<String, String>>> e : buckets.entrySet()) {
|
||||
if (!e.getValue().isEmpty()) {
|
||||
String json = JSON.toJSONString(e.getValue());
|
||||
quote.put(e.getKey(), json);
|
||||
quote.put("fee_rows_" + e.getKey(), json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void appendFeeLineFromRow(List<Map<String, String>> lines, Map<?, ?> row, String headerCurrency) {
|
||||
String name = firstText(row.get("feeName"), row.get("name"), row.get("chargeName"),
|
||||
row.get("itemName"), row.get("feeType"));
|
||||
String amount = firstText(row.get("salePrice"), row.get("amount"), row.get("price"),
|
||||
row.get("feeAmount"), row.get("totalPrice"));
|
||||
String lineCurrency = TmsQuoteCurrency.fromFeeRow(row);
|
||||
addFeeLine(lines, name, amount,
|
||||
StringUtils.isNotBlank(lineCurrency) ? lineCurrency : headerCurrency,
|
||||
extrasFromFee(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* 费用行上的单位 / 数量 / 单价 / 备注只取 TMS 原文,没有就空着。
|
||||
*/
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
-- 成交跟进:未成交原因 / 协商再问时刻 / 成交通知摘要。
|
||||
-- 仅改 inquiry_robot;幂等需手工确认列是否已存在。
|
||||
-- 库:inquiry_robot
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
ALTER TABLE `inq_ticket`
|
||||
ADD COLUMN `lost_reason` varchar(500) NULL COMMENT '未成交原因,不是关单原因' AFTER `close_reason`;
|
||||
|
||||
ALTER TABLE `inq_ticket`
|
||||
ADD COLUMN `next_followup_at` datetime NULL COMMENT '协商中下次再问(工作日 09:00)' AFTER `lost_reason`;
|
||||
|
||||
ALTER TABLE `inq_ticket`
|
||||
ADD COLUMN `deal_notify_summary` varchar(500) NULL COMMENT '成交通知:已推负责人/无匹配/未发出' AFTER `next_followup_at`;
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
-- 询价后台菜单按钮:让角色树展开「查看/导出」等明细(对齐旧运营后台)
|
||||
-- 库:inquiry_robot;幂等。
|
||||
-- 根因:子菜单 is_leaf=1 时 Jeecg queryTreeList 不递归 menu_type=2 按钮。
|
||||
|
||||
-- 1) 有子节点的菜单必须非叶子
|
||||
UPDATE sys_permission
|
||||
SET is_leaf = 0
|
||||
WHERE id IN (
|
||||
'b1000000000000000000000000000001',
|
||||
'b1000000000000000000000000000002',
|
||||
'b1000000000000000000000000000003',
|
||||
'b1000000000000000000000000000004',
|
||||
'b1000000000000000000000000000005',
|
||||
'b1000000000000000000000000000006',
|
||||
'b1000000000000000000000000000010',
|
||||
'b1000000000000000000000000000011',
|
||||
'b1000000000000000000000000000012',
|
||||
'b1000000000000000000000000000013'
|
||||
);
|
||||
|
||||
-- 2) 页面菜单不要挂 perms(权限码只在按钮上)
|
||||
UPDATE sys_permission
|
||||
SET perms = NULL
|
||||
WHERE id IN (
|
||||
'b1000000000000000000000000000002',
|
||||
'b1000000000000000000000000000003',
|
||||
'b1000000000000000000000000000004',
|
||||
'b1000000000000000000000000000005',
|
||||
'b1000000000000000000000000000006'
|
||||
);
|
||||
|
||||
-- 3) 已有按钮改名为旧后台口径
|
||||
UPDATE sys_permission SET name='查看' WHERE id='b1000000000000000000000000000021';
|
||||
UPDATE sys_permission SET name='查看' WHERE id='b1000000000000000000000000000031';
|
||||
UPDATE sys_permission SET name='关闭工单' WHERE id='b1000000000000000000000000000032';
|
||||
UPDATE sys_permission SET name='查看' WHERE id='b1000000000000000000000000000041';
|
||||
UPDATE sys_permission SET name='新增' WHERE id='b1000000000000000000000000000042';
|
||||
UPDATE sys_permission SET name='编辑' WHERE id='b1000000000000000000000000000043';
|
||||
UPDATE sys_permission SET name='停用' WHERE id='b1000000000000000000000000000044';
|
||||
UPDATE sys_permission SET name='查看' WHERE id='b1000000000000000000000000000051';
|
||||
UPDATE sys_permission SET name='新增' WHERE id='b1000000000000000000000000000052';
|
||||
UPDATE sys_permission SET name='编辑' WHERE id='b1000000000000000000000000000053';
|
||||
UPDATE sys_permission SET name='停用' WHERE id='b1000000000000000000000000000054';
|
||||
UPDATE sys_permission SET name='查看' WHERE id='b1000000000000000000000000000061';
|
||||
UPDATE sys_permission SET name='上传' WHERE id='b1000000000000000000000000000062';
|
||||
UPDATE sys_permission SET name='编辑' WHERE id='b1000000000000000000000000000063';
|
||||
UPDATE sys_permission SET name='启停' WHERE id='b1000000000000000000000000000064';
|
||||
UPDATE sys_permission SET name='查看' WHERE id='b1000000000000000000000000000071';
|
||||
UPDATE sys_permission SET name='编辑' WHERE id='b1000000000000000000000000000072';
|
||||
|
||||
-- 4) 补旧后台有、当前缺的按钮
|
||||
INSERT IGNORE INTO sys_permission
|
||||
(id,parent_id,name,is_route,menu_type,perms,perms_type,sort_no,always_show,is_leaf,keep_alive,hidden,hide_tab,create_by,create_time,del_flag,rule_flag,status,internal_or_external)
|
||||
VALUES
|
||||
-- 分析看板:导出 / 更新数据
|
||||
('b1000000000000000000000000000022','b1000000000000000000000000000002','导出',0,2,'inquiry:dashboard:export','1',2,0,1,0,0,0,'admin',NOW(),0,0,'1',0),
|
||||
('b1000000000000000000000000000023','b1000000000000000000000000000002','更新数据',0,2,'inquiry:dashboard:refresh','1',3,0,1,0,0,0,'admin',NOW(),0,0,'1',0),
|
||||
-- 询价工单:导出 / 查看详情
|
||||
('b1000000000000000000000000000033','b1000000000000000000000000000003','导出',0,2,'inquiry:workorder:export','1',2,0,1,0,0,0,'admin',NOW(),0,0,'1',0),
|
||||
('b1000000000000000000000000000034','b1000000000000000000000000000003','查看详情',0,2,'inquiry:workorder:detail','1',3,0,1,0,0,0,'admin',NOW(),0,0,'1',0),
|
||||
-- 账号管理
|
||||
('b1000000000000000000000000000081','b1000000000000000000000000000011','查看',0,2,'inquiry:account:view','1',1,0,1,0,0,0,'admin',NOW(),0,0,'1',0),
|
||||
('b1000000000000000000000000000082','b1000000000000000000000000000011','编辑',0,2,'inquiry:account:edit','1',2,0,1,0,0,0,'admin',NOW(),0,0,'1',0),
|
||||
-- 角色管理
|
||||
('b1000000000000000000000000000083','b1000000000000000000000000000012','查看',0,2,'inquiry:role:view','1',1,0,1,0,0,0,'admin',NOW(),0,0,'1',0),
|
||||
('b1000000000000000000000000000084','b1000000000000000000000000000012','编辑',0,2,'inquiry:role:edit','1',2,0,1,0,0,0,'admin',NOW(),0,0,'1',0);
|
||||
|
||||
-- 关闭工单排序靠后(详情后)
|
||||
UPDATE sys_permission SET sort_no = 4 WHERE id = 'b1000000000000000000000000000032';
|
||||
|
||||
-- 5) admin 角色补授权全部 b100 权限
|
||||
INSERT IGNORE INTO sys_role_permission (id, role_id, permission_id)
|
||||
SELECT MD5(CONCAT('ytd-inq-menu:', r.id, ':', p.id)), r.id, p.id
|
||||
FROM sys_role r
|
||||
JOIN sys_permission p ON p.id LIKE 'b100%' AND p.del_flag = 0
|
||||
WHERE r.role_code = 'admin';
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
-- 账号「只看本账号看板数据」+ 确保 admin 绑定超级管理员角色
|
||||
-- 库:inquiry_robot;幂等。
|
||||
|
||||
-- 1) 列
|
||||
SET @has_col := (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'sys_user'
|
||||
AND column_name = 'dashboard_self_only'
|
||||
);
|
||||
SET @sql := IF(@has_col = 0,
|
||||
'ALTER TABLE sys_user ADD COLUMN dashboard_self_only tinyint(1) NOT NULL DEFAULT 0 COMMENT ''只看本账号看板数据:1=是,0=否;超管忽略''',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 2) admin 绑定 role_code=admin(无则补一行)
|
||||
INSERT INTO sys_user_role (id, user_id, role_id)
|
||||
SELECT REPLACE(UUID(), '-', ''), u.id, r.id
|
||||
FROM sys_user u
|
||||
JOIN sys_role r ON r.role_code = 'admin'
|
||||
WHERE u.username = 'admin'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sys_user_role ur
|
||||
WHERE ur.user_id = u.id AND ur.role_id = r.id
|
||||
)
|
||||
LIMIT 1;
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
-- 账号「最后登录时间」:列表展示真实登录,不再误用 createTime。
|
||||
-- 库:inquiry_robot;幂等。
|
||||
|
||||
-- 1) 列
|
||||
SET @has_col := (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'sys_user'
|
||||
AND column_name = 'last_login_time'
|
||||
);
|
||||
SET @sql := IF(@has_col = 0,
|
||||
'ALTER TABLE sys_user ADD COLUMN last_login_time datetime NULL DEFAULT NULL COMMENT ''最后登录时间'' AFTER update_time',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 2) 用登录日志回填(log_type=1);已有值不覆盖
|
||||
UPDATE sys_user u
|
||||
INNER JOIN (
|
||||
SELECT COALESCE(NULLIF(userid, ''), username) AS uname, MAX(create_time) AS last_t
|
||||
FROM sys_log
|
||||
WHERE log_type = 1
|
||||
AND (userid IS NOT NULL OR username IS NOT NULL)
|
||||
GROUP BY COALESCE(NULLIF(userid, ''), username)
|
||||
) x ON x.uname = u.username
|
||||
SET u.last_login_time = x.last_t
|
||||
WHERE u.last_login_time IS NULL
|
||||
AND x.last_t IS NOT NULL;
|
||||
+9
@@ -14,6 +14,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
* 陆运拉群人员匹配:不连库。
|
||||
* <p>
|
||||
* 只认产品·陆运启用岗;负责线路按逗号整词对线路类别,禁止按斜杠切开「中港/中亚/中欧」。
|
||||
* 「陆运全线」命中任意线路类别。
|
||||
*/
|
||||
public class LandStaffMatcherTest {
|
||||
|
||||
@@ -37,6 +38,14 @@ public class LandStaffMatcherTest {
|
||||
assertEquals(0, LandStaffMatcher.match(Arrays.asList(row), "东南亚").size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void landAllRoutesHitsAnyCategory() {
|
||||
InquiryStaffRole all = staff("land", "active", "z", "全线岗", "陆运全线");
|
||||
assertEquals(1, LandStaffMatcher.match(Arrays.asList(all), "东南亚").size());
|
||||
assertEquals(1, LandStaffMatcher.match(Arrays.asList(all), "中港/中亚/中欧").size());
|
||||
assertEquals(1, LandStaffMatcher.match(Arrays.asList(all), "国内长途/零担").size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ignoreSeaRole() {
|
||||
InquiryStaffRole sea = staff("sea", "active", "s", "海", "东南亚");
|
||||
|
||||
+6
@@ -26,6 +26,12 @@ public class SeaStaffMatcherTest {
|
||||
assertEquals("p1", hits.get(0).get("wecomId"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void seaAllLineAliasPulls() {
|
||||
InquiryStaffRole row = staff("sea", "active", "p1b", "全线产品", "海运全线");
|
||||
assertEquals(1, SeaStaffMatcher.match(Arrays.asList(row), "上海", "洛杉矶").size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void specificPortPullsWhenMatch() {
|
||||
InquiryStaffRole row = staff("sea", "active", "p2", "李产品", "上海,宁波");
|
||||
|
||||
+174
-8
@@ -132,7 +132,7 @@ public class QuoteTemplateFillTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void seaFeeRemarkSplitsAndWidensColumn() throws Exception {
|
||||
public void seaFeeRemarkStripsInternalMarkup() throws Exception {
|
||||
Path src = Files.createTempFile("quote-remark-src", ".xlsx");
|
||||
Path out = Files.createTempFile("quote-remark-out", ".xlsx");
|
||||
try (Workbook wb = new XSSFWorkbook()) {
|
||||
@@ -156,13 +156,9 @@ public class QuoteTemplateFillTest {
|
||||
try (Workbook wb = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||
Sheet sheet = wb.getSheetAt(0);
|
||||
org.apache.poi.ss.usermodel.Row row = sheet.getRow(18);
|
||||
String remark = row.getCell(8).getStringCellValue();
|
||||
assertTrue(remark.contains("计价单位:柜"));
|
||||
assertTrue(remark.contains("加点系数:0.0000000"));
|
||||
assertTrue(remark.contains("\n"));
|
||||
assertTrue(row.getCell(8).getCellStyle().getWrapText());
|
||||
assertTrue(sheet.getColumnWidth(8) >= 18 * 256);
|
||||
assertTrue(row.getHeightInPoints() >= 30f);
|
||||
String remark = row.getCell(8) == null ? "" : row.getCell(8).getStringCellValue();
|
||||
assertEquals("", remark == null ? "" : remark.trim());
|
||||
assertEquals(15.0, row.getCell(7).getNumericCellValue(), 0.01);
|
||||
} finally {
|
||||
Files.deleteIfExists(src);
|
||||
Files.deleteIfExists(out);
|
||||
@@ -591,6 +587,176 @@ public class QuoteTemplateFillTest {
|
||||
assertEquals(2, out.getJSONArray("fee_rows").size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void buildFillFactsProjectsLandRouteFromFeeLinesWhenNoSegments() {
|
||||
Map<String, Object> facts = new HashMap<>();
|
||||
facts.put("起运港", "泉州市");
|
||||
facts.put("目的港", "南京市");
|
||||
facts.put("运输类型", "国内运输拼车");
|
||||
Map<String, Object> quote = new HashMap<>();
|
||||
quote.put("total", "110.00");
|
||||
quote.put("currency", "CNY");
|
||||
quote.put("eta", "48天");
|
||||
quote.put("fee_lines", "[{\"name\":\"运输费\",\"amount\":\"110.00\",\"currency\":\"CNY\",\"remark\":\"票结\"}]");
|
||||
JSONObject out = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl
|
||||
.buildFillFacts("WO202609190017", facts, quote, "LinMianXun");
|
||||
assertEquals("泉州市", out.getString("origin"));
|
||||
assertEquals("南京市", out.getString("destination"));
|
||||
assertEquals("48天", out.getString("transit_time"));
|
||||
assertTrue(out.getJSONArray("route_rows") != null && !out.getJSONArray("route_rows").isEmpty());
|
||||
assertEquals("110.00", out.getJSONArray("route_rows").getJSONObject(0).getString("salePrice"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 陆运拼车:H5「报价日期:」常被标进 fixedCells;同格冒号后必须仍能写出日期。
|
||||
* 与有效期误绑同一格时,只写报价日期,不让 TMS valid_until 盖掉标签含义。
|
||||
*/
|
||||
@Test
|
||||
public void quoteDateWritesAfterColonEvenWhenFixedYellow() throws Exception {
|
||||
Path src = Files.createTempFile("quote-date-src", ".xlsx");
|
||||
Path out = Files.createTempFile("quote-date-out", ".xlsx");
|
||||
try (Workbook wb = new XSSFWorkbook()) {
|
||||
Sheet sheet = wb.createSheet("国内运输拼车报价模板");
|
||||
row(sheet, 4).createCell(7).setCellValue("报价日期:");
|
||||
try (java.io.OutputStream os = Files.newOutputStream(src)) {
|
||||
wb.write(os);
|
||||
}
|
||||
}
|
||||
JSONObject mapping = new JSONObject(true);
|
||||
JSONObject fields = new JSONObject(true);
|
||||
fields.put("valid_until", "国内运输拼车报价模板!H5");
|
||||
fields.put("quote_date", "国内运输拼车报价模板!H5");
|
||||
mapping.put("fields", fields);
|
||||
JSONObject meta = new JSONObject(true);
|
||||
JSONObject after = new JSONObject(true);
|
||||
after.put("sameCellAfterColon", true);
|
||||
meta.put("quote_date", after);
|
||||
meta.put("valid_until", after);
|
||||
mapping.put("fieldMeta", meta);
|
||||
mapping.put("fixedCells", JSONArray.parseArray("[\"国内运输拼车报价模板!H5\"]"));
|
||||
mapping.put("fixedYellowCells", JSONArray.parseArray("[\"国内运输拼车报价模板!H5\"]"));
|
||||
|
||||
JSONObject facts = new JSONObject(true);
|
||||
facts.put("quote_date", "2026-09-19");
|
||||
facts.put("valid_until", "2026-04-30");
|
||||
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
|
||||
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||
assertEquals("报价日期:2026-09-19", read(filled.getSheetAt(0), 4, 7));
|
||||
} finally {
|
||||
Files.deleteIfExists(src);
|
||||
Files.deleteIfExists(out);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 产品口径:单元格标黄 = 固定占位,出单不赋值。
|
||||
* 陆运「项目二」整段黄底时,即使 mapping 配了 feeRegions,也不得改写模板占位行。
|
||||
*/
|
||||
@Test
|
||||
public void yellowFeeRegionRowsAreNotOverwritten() throws Exception {
|
||||
Path src = Files.createTempFile("quote-yellow-src", ".xlsx");
|
||||
Path out = Files.createTempFile("quote-yellow-out", ".xlsx");
|
||||
try (Workbook wb = new XSSFWorkbook()) {
|
||||
Sheet sheet = wb.createSheet("国内运输拼车报价模板");
|
||||
row(sheet, 11).createCell(1).setCellValue("项目二:其它费用");
|
||||
org.apache.poi.ss.usermodel.CellStyle yellow = wb.createCellStyle();
|
||||
yellow.setFillForegroundColor(org.apache.poi.ss.usermodel.IndexedColors.YELLOW.getIndex());
|
||||
yellow.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND);
|
||||
org.apache.poi.ss.usermodel.Row data = row(sheet, 13);
|
||||
org.apache.poi.ss.usermodel.Cell cName = data.createCell(2);
|
||||
cName.setCellValue("打包费");
|
||||
cName.setCellStyle(yellow);
|
||||
org.apache.poi.ss.usermodel.Cell cAmt = data.createCell(7);
|
||||
cAmt.setCellValue("明确收费");
|
||||
cAmt.setCellStyle(yellow);
|
||||
row(sheet, 18).createCell(1).setCellValue("交易条件说明");
|
||||
try (java.io.OutputStream os = Files.newOutputStream(src)) {
|
||||
wb.write(os);
|
||||
}
|
||||
}
|
||||
JSONObject mapping = new JSONObject(true);
|
||||
mapping.put("fields", new JSONObject());
|
||||
JSONArray regions = new JSONArray();
|
||||
JSONObject region = new JSONObject(true);
|
||||
region.put("id", "other_charges");
|
||||
region.put("title", "项目二:其它费用");
|
||||
region.put("anchor", "国内运输拼车报价模板!B14");
|
||||
region.put("stopAtText", JSONArray.parseArray("[\"交易条件说明\"]"));
|
||||
region.put("grow", "insert_before_stop");
|
||||
JSONObject columns = new JSONObject(true);
|
||||
columns.put("name", "C");
|
||||
columns.put("amount", "H");
|
||||
region.put("columns", columns);
|
||||
regions.add(region);
|
||||
mapping.put("feeRegions", regions);
|
||||
|
||||
JSONObject facts = new JSONObject(true);
|
||||
facts.put("fee_rows", QuoteTemplateExcelSupport.parseFeeRows(
|
||||
"[{\"name\":\"运输费\",\"amount\":\"110.00\",\"unit\":\"票数\"}]"));
|
||||
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
|
||||
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||
Sheet sheet = filled.getSheetAt(0);
|
||||
assertEquals("打包费", read(sheet, 13, 2));
|
||||
assertEquals("明确收费", read(sheet, 13, 7));
|
||||
} finally {
|
||||
Files.deleteIfExists(src);
|
||||
Files.deleteIfExists(out);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void seaDynamicContainerHeaderReplacedWithBoxType() throws Exception {
|
||||
Path src = Files.createTempFile("quote-box-src", ".xlsx");
|
||||
Path out = Files.createTempFile("quote-box-out", ".xlsx");
|
||||
try (Workbook wb = new XSSFWorkbook()) {
|
||||
Sheet sheet = wb.createSheet("海运询价单");
|
||||
row(sheet, 16).createCell(0).setCellValue("始发港费用/Origin Charges");
|
||||
org.apache.poi.ss.usermodel.Row head = row(sheet, 17);
|
||||
head.createCell(0).setCellValue("项目/Items");
|
||||
head.createCell(2).setCellValue("箱型\n(动态填充箱型)");
|
||||
head.createCell(7).setCellValue("金额/Ammount");
|
||||
row(sheet, 18).createCell(0).setCellValue("操作费");
|
||||
row(sheet, 27).createCell(0).setCellValue("始发港费用汇总/POL Local Charges");
|
||||
try (java.io.OutputStream os = Files.newOutputStream(src)) {
|
||||
wb.write(os);
|
||||
}
|
||||
}
|
||||
JSONObject mapping = new JSONObject(true);
|
||||
mapping.put("sheet", "海运询价单");
|
||||
mapping.put("fields", new JSONObject());
|
||||
JSONArray regions = new JSONArray();
|
||||
JSONObject region = new JSONObject(true);
|
||||
region.put("id", "origin_charges");
|
||||
region.put("title", "始发港费用/Origin Charges");
|
||||
region.put("tmsSource", "polCostItems");
|
||||
region.put("headerRow", 18);
|
||||
region.put("anchor", "海运询价单!A19");
|
||||
region.put("dynamicContainerHeader", true);
|
||||
region.put("stopAtText", JSONArray.parseArray("[\"始发港费用汇总\"]"));
|
||||
JSONObject columns = new JSONObject(true);
|
||||
columns.put("name", "A");
|
||||
columns.put("container_rate", "C");
|
||||
columns.put("amount", "H");
|
||||
region.put("columns", columns);
|
||||
regions.add(region);
|
||||
mapping.put("feeRegions", regions);
|
||||
|
||||
JSONObject facts = new JSONObject(true);
|
||||
facts.put("container_type_qty", "20GP*1");
|
||||
facts.put("fee_rows_polCostItems", QuoteTemplateExcelSupport.parseFeeRows(
|
||||
"[{\"name\":\"操作费\",\"amount\":\"680\",\"currency\":\"CNY\",\"unit_price\":\"680\"}]"));
|
||||
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
|
||||
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||
String head = read(filled.getSheetAt(0), 17, 2);
|
||||
assertTrue(head.contains("20GP"), "header=" + head);
|
||||
assertTrue(head.contains("(") && head.contains(")"), "header missing parens: " + head);
|
||||
assertTrue(!head.contains("动态填充"), "header still placeholder: " + head);
|
||||
} finally {
|
||||
Files.deleteIfExists(src);
|
||||
Files.deleteIfExists(out);
|
||||
}
|
||||
}
|
||||
|
||||
private static org.apache.poi.ss.usermodel.Row row(Sheet sheet, int r) {
|
||||
org.apache.poi.ss.usermodel.Row exist = sheet.getRow(r);
|
||||
return exist == null ? sheet.createRow(r) : exist;
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
package org.jeecg.modules.inquiry.template;
|
||||
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.ss.usermodel.Workbook;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* RFQ 纠偏、重新解析智能合并、备注过滤。
|
||||
* <p>
|
||||
* 产品口径:B3 已有单选等内容时,编号写在 A3「RFQ No:」冒号后,不覆盖 B3、不跳到 F3。
|
||||
*/
|
||||
public class QuoteTemplateMappingMergeTest {
|
||||
|
||||
@Test
|
||||
public void b3RadioIsNotAlignedQuoteNo() throws Exception {
|
||||
try (Workbook wb = seaHeaderWorkbook()) {
|
||||
assertFalse(QuoteTemplateExcelSupport.fieldMappingStillAligned(wb, "quote_no", "海运询价单!B3"));
|
||||
assertTrue(QuoteTemplateExcelSupport.fieldMappingStillAligned(wb, "quote_no", "海运询价单!A3"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void relocateQuoteNoSkipsRadioToLabelA3() throws Exception {
|
||||
try (Workbook wb = seaHeaderWorkbook()) {
|
||||
String better = QuoteTemplateExcelSupport.relocateQuoteNoCell(wb, "海运询价单!B3");
|
||||
assertEquals("海运询价单!A3", better);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mergeReplacesB3WithA3() throws Exception {
|
||||
try (Workbook wb = seaHeaderWorkbook()) {
|
||||
JSONObject old = fields("quote_no", "海运询价单!B3");
|
||||
JSONObject neu = fields("quote_no", "海运询价单!B3");
|
||||
JSONObject merged = InquiryQuoteTemplateServiceImpl.mergeMappingPreferExisting(old, neu, wb);
|
||||
assertEquals("海运询价单!A3", merged.getJSONObject("fields").getString("quote_no"));
|
||||
assertTrue(merged.getJSONObject("fieldMeta").getJSONObject("quote_no")
|
||||
.getBooleanValue("sameCellAfterColon"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fillWritesQuoteNoAfterColonOnA3() throws Exception {
|
||||
Path src = Files.createTempFile("quote-rfq-src", ".xlsx");
|
||||
Path out = Files.createTempFile("quote-rfq-out", ".xlsx");
|
||||
try (Workbook wb = seaHeaderWorkbook()) {
|
||||
try (java.io.OutputStream os = Files.newOutputStream(src)) {
|
||||
wb.write(os);
|
||||
}
|
||||
}
|
||||
JSONObject mapping = new JSONObject(true);
|
||||
JSONObject f = new JSONObject(true);
|
||||
f.put("quote_no", "海运询价单!B3");
|
||||
mapping.put("fields", f);
|
||||
// A3 在固定区也应能冒号后写入
|
||||
mapping.put("fixedCells", com.alibaba.fastjson.JSONArray.parseArray("[\"海运询价单!A3\"]"));
|
||||
JSONObject facts = new JSONObject(true);
|
||||
facts.put("quote_no", "WO202609190002");
|
||||
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
|
||||
try (Workbook wb = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||
Sheet sheet = wb.getSheet("海运询价单");
|
||||
assertEquals("RFQ No:WO202609190002", sheet.getRow(2).getCell(0).getStringCellValue());
|
||||
// B3 单选不得被工单号覆盖
|
||||
assertEquals("○ Port to Port\n○ 其它,3工作天回复",
|
||||
sheet.getRow(2).getCell(1).getStringCellValue().replace("\r\n", "\n"));
|
||||
// F3 不应被误写
|
||||
assertTrue(sheet.getRow(2).getCell(5) == null
|
||||
|| org.apache.commons.lang3.StringUtils.isBlank(sheet.getRow(2).getCell(5).getStringCellValue()));
|
||||
} finally {
|
||||
Files.deleteIfExists(src);
|
||||
Files.deleteIfExists(out);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void formatFeeRemarkDropsMarkup() {
|
||||
assertEquals("", QuoteTemplateExcelSupport.formatFeeRemark("计价单位:柜;加点系数:0.0000000"));
|
||||
assertEquals("紧急出运", QuoteTemplateExcelSupport.formatFeeRemark("紧急出运;加点系数:0.1"));
|
||||
}
|
||||
|
||||
private static JSONObject fields(String code, String cell) {
|
||||
JSONObject mapping = new JSONObject(true);
|
||||
JSONObject f = new JSONObject(true);
|
||||
f.put(code, cell);
|
||||
mapping.put("fields", f);
|
||||
mapping.put("feeRegions", new com.alibaba.fastjson.JSONArray());
|
||||
mapping.put("routeRegions", new com.alibaba.fastjson.JSONArray());
|
||||
return mapping;
|
||||
}
|
||||
|
||||
/**
|
||||
* 迷你海运表头:A3=RFQ No:,B3=单选,F3=空(旧逻辑曾误写这里)。
|
||||
*/
|
||||
private static Workbook seaHeaderWorkbook() {
|
||||
Workbook wb = new XSSFWorkbook();
|
||||
Sheet sheet = wb.createSheet("海运询价单");
|
||||
Row row = sheet.createRow(2);
|
||||
row.createCell(0).setCellValue("RFQ No:");
|
||||
row.createCell(1).setCellValue("○ Port to Port\n○ 其它,3工作天回复");
|
||||
row.createCell(5).setCellValue("");
|
||||
return wb;
|
||||
}
|
||||
}
|
||||
+60
-12
@@ -2,11 +2,14 @@ package org.jeecg.modules.inquiry.tms;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
@@ -114,24 +117,69 @@ public class TmsQuoteCurrencyTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toAgentQuoteKeepsRealAirLockIds() {
|
||||
Map<String, Object> option = new HashMap<>();
|
||||
option.put("optionNo", "AIR-OPT01");
|
||||
option.put("originAirportCode", "ZUH");
|
||||
option.put("destinationAirportCode", "CRK");
|
||||
public void toAgentQuoteSplitsSeaFeeBuckets() {
|
||||
Map<String, Object> pol = new HashMap<>();
|
||||
pol.put("feeName", "操作费");
|
||||
pol.put("amount", 680);
|
||||
pol.put("currency", "CNY");
|
||||
Map<String, Object> ocean = new HashMap<>();
|
||||
ocean.put("feeName", "海运费");
|
||||
ocean.put("amount", 100);
|
||||
ocean.put("currency", "USD");
|
||||
Map<String, Object> pod = new HashMap<>();
|
||||
pod.put("feeName", "文件费");
|
||||
pod.put("amount", 450);
|
||||
pod.put("currency", "CNY");
|
||||
List<Map<String, Object>> feeItems = new ArrayList<>();
|
||||
feeItems.add(Collections.singletonMap("polCostItems", Collections.singletonList(pol)));
|
||||
feeItems.add(Collections.singletonMap("oceanCostItems", Collections.singletonList(ocean)));
|
||||
feeItems.add(Collections.singletonMap("podCostItems", Collections.singletonList(pod)));
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("quoteFound", true);
|
||||
data.put("quoteId", "AIR-2094721692181016578-2094721692654972929");
|
||||
data.put("totalSalePrice", "11792.00");
|
||||
data.put("totalSalePrice", "1230");
|
||||
data.put("currency", "CNY");
|
||||
data.put("chargeableWeightKg", 1072);
|
||||
data.put("airOptions", Collections.singletonList(option));
|
||||
data.put("feeItems", feeItems);
|
||||
Map<String, Object> tms = new HashMap<>();
|
||||
tms.put("code", 1);
|
||||
tms.put("tmsData", data);
|
||||
Map<String, String> quote = InquiryTmsQueryService.toAgentQuote(tms);
|
||||
assertEquals("AIR-2094721692181016578-2094721692654972929", quote.get("quoteId"));
|
||||
assertTrue(quote.get("airOptions").contains("AIR-OPT01"));
|
||||
assertEquals("1072", quote.get("chargeableWeightKg"));
|
||||
assertTrue(quote.get("fee_lines").contains("操作费"));
|
||||
assertTrue(quote.get("fee_lines").contains("海运费"));
|
||||
assertTrue(quote.get("fee_lines").contains("文件费"));
|
||||
assertTrue(quote.get("fee_rows_polCostItems").contains("操作费"));
|
||||
assertFalse(quote.get("fee_rows_polCostItems").contains("海运费"));
|
||||
assertTrue(quote.get("fee_rows_oceanCostItems").contains("海运费"));
|
||||
assertTrue(quote.get("fee_rows_podCostItems").contains("文件费"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toAgentQuoteProjectsLandSegments() {
|
||||
Map<String, Object> seg = new HashMap<>();
|
||||
seg.put("segmentSequence", 1);
|
||||
seg.put("routeLegSequence", 1);
|
||||
seg.put("origin", "泉州市");
|
||||
seg.put("destination", "南京市");
|
||||
seg.put("vehicleType", "3T");
|
||||
seg.put("salePrice", "2800");
|
||||
seg.put("currency", "CNY");
|
||||
seg.put("transitDays", "3");
|
||||
seg.put("remark", "普货");
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("quoteFound", true);
|
||||
data.put("totalSalePrice", "2800");
|
||||
data.put("currency", "CNY");
|
||||
data.put("feeItems", Collections.emptyList());
|
||||
data.put("isMulti", 1);
|
||||
data.put("landSegments", Collections.singletonList(seg));
|
||||
Map<String, Object> tms = new HashMap<>();
|
||||
tms.put("code", 1);
|
||||
tms.put("tmsData", data);
|
||||
Map<String, String> quote = InquiryTmsQueryService.toAgentQuote(tms);
|
||||
assertEquals("1", quote.get("isMulti"));
|
||||
assertTrue(quote.get("landSegments").contains("泉州市"));
|
||||
assertTrue(quote.get("route_rows").contains("\"salePrice\":\"2800\""));
|
||||
assertTrue(quote.get("route_rows").contains("\"transit_time\":\"3\""));
|
||||
assertTrue(quote.get("fee_lines").contains("运输费"));
|
||||
assertTrue(quote.get("fee_lines").contains("2800"));
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -4,6 +4,7 @@ import cn.hutool.core.util.RandomUtil;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import com.aliyuncs.exceptions.ClientException;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import io.swagger.annotations.Api;
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
@@ -465,6 +466,18 @@ public class LoginController {
|
||||
// 获取用户部门信息
|
||||
JSONObject obj = new JSONObject(new LinkedHashMap<>());
|
||||
|
||||
// 询价账号管理:写入最后登录时间,列表不再误用 createTime(种子常为 2019)
|
||||
Date loginAt = new Date();
|
||||
sysUser.setLastLoginTime(loginAt);
|
||||
try {
|
||||
sysUserService.update(
|
||||
new LambdaUpdateWrapper<SysUser>()
|
||||
.eq(SysUser::getId, sysUser.getId())
|
||||
.set(SysUser::getLastLoginTime, loginAt));
|
||||
} catch (Exception e) {
|
||||
log.warn("写入最后登录时间失败 username={}", username, e);
|
||||
}
|
||||
|
||||
//1.生成token
|
||||
String token = JwtUtil.sign(username, syspassword);
|
||||
// 设置token缓存有效时间
|
||||
|
||||
+3
@@ -187,6 +187,9 @@ public class SysUserController {
|
||||
baseCommonService.addLog("编辑用户,username: " +sysUser.getUsername() ,CommonConstant.LOG_TYPE_2, 2);
|
||||
if(sysUser==null) {
|
||||
result.error500("未找到对应实体");
|
||||
}else if("admin".equalsIgnoreCase(String.valueOf(sysUser.getUsername()))) {
|
||||
// 询价后台:内置超级管理员不可被运营编辑(角色/资料/开关)
|
||||
result.error500("超级管理员不可在后台编辑");
|
||||
}else {
|
||||
SysUser user = JSON.parseObject(jsonObject.toJSONString(), SysUser.class);
|
||||
user.setUpdateTime(new Date());
|
||||
|
||||
+17
@@ -161,6 +161,16 @@ public class SysUser implements Serializable {
|
||||
* 更新时间
|
||||
*/
|
||||
private Date updateTime;
|
||||
|
||||
/**
|
||||
* 最后登录时间。
|
||||
* 账号管理列表展示;登录成功时写入。禁止用 createTime 冒充(种子账号常为 2019)。
|
||||
*/
|
||||
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@TableField("last_login_time")
|
||||
private Date lastLoginTime;
|
||||
|
||||
/**
|
||||
* 同步工作流引擎1同步0不同步
|
||||
*/
|
||||
@@ -204,4 +214,11 @@ public class SysUser implements Serializable {
|
||||
* 流程状态
|
||||
*/
|
||||
private String bpmStatus;
|
||||
|
||||
/**
|
||||
* 只看本账号看板数据:true/1 时分析看板仅含本人发起/归属工单;超级管理员忽略。
|
||||
* 对应列 dashboard_self_only;原项目同名字段。
|
||||
*/
|
||||
@TableField("dashboard_self_only")
|
||||
private Boolean dashboardSelfOnly;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,10 @@ export type AccountRow = {
|
||||
phoneMasked: string;
|
||||
lastLogin: string;
|
||||
status: 'active' | 'disabled';
|
||||
/** 只看本账号看板数据 */
|
||||
dashboardSelfOnly: boolean;
|
||||
/** 内置超管:列表禁编、展示固定为超级管理员 */
|
||||
isSuperAdmin: boolean;
|
||||
};
|
||||
|
||||
const ROLE_TAG_CYCLE = ['tag-violet', 'tag-blue', 'tag-teal', 'tag-amber'];
|
||||
@@ -39,6 +43,29 @@ export function roleTagFor(index: number) {
|
||||
return ROLE_TAG_CYCLE[index % ROLE_TAG_CYCLE.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* 最后登录展示为 `yyyy-MM-dd HH:mm:ss`;无记录显示 —。
|
||||
* 兼容接口偶发 ISO(带 T)或已格式化字符串。
|
||||
*/
|
||||
export function formatLastLogin(raw: unknown): string {
|
||||
if (raw == null || raw === '') return '—';
|
||||
const s = String(raw).trim();
|
||||
if (!s) return '—';
|
||||
const m = s.match(/^(\d{4}-\d{2}-\d{2})[T\s](\d{2}:\d{2}:\d{2})/);
|
||||
if (m) return `${m[1]} ${m[2]}`;
|
||||
return s;
|
||||
}
|
||||
|
||||
/** username=admin 或角色码/名含超级管理员 → 内置超管 */
|
||||
export function isSuperAdminUser(u: { username?: string; roleLabel?: string; roleCode?: string }) {
|
||||
const name = String(u.username || '').trim().toLowerCase();
|
||||
if (name === 'admin') return true;
|
||||
const code = String(u.roleCode || '').trim().toLowerCase();
|
||||
if (code === 'admin') return true;
|
||||
const label = String(u.roleLabel || '');
|
||||
return label.includes('超级管理员');
|
||||
}
|
||||
|
||||
/** Jeecg status:1 正常 → active;其余视为禁用。 */
|
||||
export function mapUserToAccount(
|
||||
u: any,
|
||||
@@ -46,16 +73,21 @@ export function mapUserToAccount(
|
||||
roleLabel = '未绑定角色',
|
||||
roleTag = 'tag-blue',
|
||||
): AccountRow {
|
||||
const username = u.username || '';
|
||||
const superAdmin = isSuperAdminUser({ username, roleLabel });
|
||||
return {
|
||||
id: String(u.id || ''),
|
||||
username: u.username || '',
|
||||
username,
|
||||
name: u.realname || u.username || '',
|
||||
wecomUserId: u.workNo || u.wecomId || u.thirdId || '',
|
||||
roleId,
|
||||
roleLabel,
|
||||
roleTag,
|
||||
roleLabel: superAdmin ? '超级管理员' : roleLabel,
|
||||
roleTag: superAdmin ? 'tag-violet' : roleTag,
|
||||
phoneMasked: u.phone || '未填写',
|
||||
lastLogin: u.lastLoginTime || u.createTime || '—',
|
||||
// 只用真实最后登录;禁止回退 createTime(Jeecg 种子常为 2019,会误导成「最后登录」)
|
||||
lastLogin: formatLastLogin(u.lastLoginTime),
|
||||
status: Number(u.status) === 1 ? 'active' : 'disabled',
|
||||
dashboardSelfOnly: Boolean(u.dashboardSelfOnly === true || u.dashboardSelfOnly === 1),
|
||||
isSuperAdmin: superAdmin,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ const loading = ref(false);
|
||||
const total = ref(0);
|
||||
const pageNo = ref(1);
|
||||
const pageSize = ref(10);
|
||||
const roleOptions = ref<{ value: string; label: string; tag: string }[]>([]);
|
||||
const roleOptions = ref<{ value: string; label: string; tag: string; roleCode?: string }[]>([]);
|
||||
const filters = reactive({ keyword: "", status: "" as "" | "active" | "disabled" });
|
||||
const modalOpen = ref(false);
|
||||
const editing = ref<AccountRow | null>(null);
|
||||
@@ -37,6 +37,8 @@ const form = reactive({
|
||||
phone: "",
|
||||
status: "active" as AccountRow["status"],
|
||||
password: "",
|
||||
/** 只看本账号看板数据(对齐原项目 dashboardSelfOnly) */
|
||||
dashboardSelfOnly: false,
|
||||
});
|
||||
|
||||
const modalTitle = computed(() => (editing.value ? "编辑账号" : "新建账号"));
|
||||
@@ -52,9 +54,11 @@ async function loadRoles() {
|
||||
value: String(r.id),
|
||||
label: r.roleName || r.roleCode || r.id,
|
||||
tag: roleTagFor(i),
|
||||
roleCode: String(r.roleCode || ""),
|
||||
}));
|
||||
if (!form.roleId && roleOptions.value.length) {
|
||||
form.roleId = roleOptions.value.find((r) => r.label.includes("运营"))?.value || roleOptions.value[0].value;
|
||||
form.roleId =
|
||||
roleOptions.value.find((r) => r.label.includes("运营"))?.value || roleOptions.value[0].value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +101,23 @@ async function loadList() {
|
||||
const hit = roleOptions.value.find((r) => r.value === roleId);
|
||||
roleLabel = hit?.label || roleLabel;
|
||||
roleTag = hit?.tag || roleTag;
|
||||
// 用角色码辅助识别超管
|
||||
if (hit?.roleCode === "admin" || (hit?.label || "").includes("超级管理员")) {
|
||||
roleLabel = hit?.label || "超级管理员";
|
||||
}
|
||||
} else if (String(u.username || "").toLowerCase() === "admin") {
|
||||
// 库里未绑角色时仍展示超管,并尝试选中 admin 角色以便保存时能补绑
|
||||
const adminRole = roleOptions.value.find(
|
||||
(r) => r.roleCode === "admin" || r.label.includes("超级管理员"),
|
||||
);
|
||||
if (adminRole) {
|
||||
roleId = adminRole.value;
|
||||
roleLabel = adminRole.label || "超级管理员";
|
||||
roleTag = adminRole.tag;
|
||||
} else {
|
||||
roleLabel = "超级管理员";
|
||||
roleTag = "tag-violet";
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* 无角色仍展示账号 */
|
||||
@@ -142,11 +163,16 @@ function openCreate() {
|
||||
phone: "",
|
||||
status: "active",
|
||||
password: "",
|
||||
dashboardSelfOnly: false,
|
||||
});
|
||||
modalOpen.value = true;
|
||||
}
|
||||
|
||||
function openEdit(row: AccountRow) {
|
||||
if (row.isSuperAdmin) {
|
||||
message.warning("超级管理员不可在后台编辑");
|
||||
return;
|
||||
}
|
||||
editing.value = row;
|
||||
Object.assign(form, {
|
||||
username: row.username,
|
||||
@@ -156,11 +182,16 @@ function openEdit(row: AccountRow) {
|
||||
phone: row.phoneMasked === "未填写" ? "" : row.phoneMasked,
|
||||
status: row.status,
|
||||
password: "",
|
||||
dashboardSelfOnly: Boolean(row.dashboardSelfOnly),
|
||||
});
|
||||
modalOpen.value = true;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (editing.value?.isSuperAdmin) {
|
||||
message.warning("超级管理员不可在后台编辑");
|
||||
return;
|
||||
}
|
||||
if (!form.username.trim() || !form.name.trim()) {
|
||||
message.warning("请填写账号与姓名");
|
||||
return;
|
||||
@@ -185,6 +216,7 @@ async function save() {
|
||||
phone: form.phone.trim() || undefined,
|
||||
status: form.status === "active" ? 1 : 2,
|
||||
selectedroles: form.roleId,
|
||||
dashboardSelfOnly: form.dashboardSelfOnly ? 1 : 0,
|
||||
};
|
||||
if (!editing.value) payload.password = form.password;
|
||||
try {
|
||||
@@ -321,12 +353,24 @@ function removeAccount(row: AccountRow) {
|
||||
</td>
|
||||
<td>
|
||||
<div class="account-row-actions">
|
||||
<button class="link-action" type="button" @click="openEdit(row)">编辑</button>
|
||||
<button class="link-action link-muted" type="button" @click="resetPassword(row)">
|
||||
<button
|
||||
v-if="!row.isSuperAdmin"
|
||||
class="link-action"
|
||||
type="button"
|
||||
@click="openEdit(row)"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<!-- 超管只隐藏编辑/删除;重置密码仍可用。不展示「不可编辑」占位文案 -->
|
||||
<button
|
||||
class="link-action link-muted"
|
||||
type="button"
|
||||
@click="resetPassword(row)"
|
||||
>
|
||||
重置密码
|
||||
</button>
|
||||
<button
|
||||
v-if="row.username !== 'admin'"
|
||||
v-if="!row.isSuperAdmin"
|
||||
class="link-action link-danger"
|
||||
type="button"
|
||||
@click="removeAccount(row)"
|
||||
@@ -400,6 +444,14 @@ function removeAccount(row: AccountRow) {
|
||||
<option v-for="r in roleOptions" :key="r.value" :value="r.value">{{ r.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="lbl">数据范围</label>
|
||||
<label class="account-self-only">
|
||||
<input v-model="form.dashboardSelfOnly" type="checkbox" />
|
||||
<span>只看自己数据</span>
|
||||
<em>勾选后,分析看板仅统计本账号企微归属的工单</em>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="lbl">状态</label>
|
||||
<div class="status-toggle">
|
||||
@@ -466,4 +518,21 @@ function removeAccount(row: AccountRow) {
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.account-self-only {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #0f172a;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.account-self-only em {
|
||||
flex: 1 1 100%;
|
||||
font-style: normal;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 角色管理:对接 Jeecg /sys/role 与菜单权限树。
|
||||
* 数据权限用线路标签写入角色 description,避免伪造第二套 RBAC。
|
||||
* 数据权限按 Excel 线路目录树勾选,写入角色 description 的 [dataPerms:…],不另造 RBAC。
|
||||
*/
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { message, Modal } from "ant-design-vue";
|
||||
@@ -15,6 +15,14 @@ import {
|
||||
saveRolePermission,
|
||||
type RoleRow,
|
||||
} from "/@/views/inquiry/role/role.api";
|
||||
import {
|
||||
DATA_PERM_TREE,
|
||||
SYSTEM_EXCEPTION_NOTIFY_KEY,
|
||||
SYSTEM_EXCEPTION_NOTIFY_TITLE,
|
||||
dataPermTitle,
|
||||
leafDataPermKeys,
|
||||
normalizeDataPermKeys,
|
||||
} from "/@/views/inquiry/shared/dataPermCatalog";
|
||||
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
|
||||
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
|
||||
|
||||
@@ -30,19 +38,12 @@ const form = reactive({
|
||||
status: "active" as RoleRow["status"],
|
||||
checkedKeys: [] as string[],
|
||||
dataPerms: [] as string[],
|
||||
/** 旧后台独立项:系统异常消息通知 */
|
||||
systemExceptionNotify: false,
|
||||
});
|
||||
|
||||
const dataOptions = [
|
||||
"空运·全部线路",
|
||||
"空运·CGO-KUL",
|
||||
"空运·HKG-KUL",
|
||||
"海运·全部线路",
|
||||
"海运·海运全线",
|
||||
"陆运·全部线路",
|
||||
"陆运·中港/中亚/中欧",
|
||||
"陆运·东南亚",
|
||||
"系统异常·消息通知",
|
||||
];
|
||||
const dataPermTree = DATA_PERM_TREE;
|
||||
const systemExceptionNotifyTitle = SYSTEM_EXCEPTION_NOTIFY_TITLE;
|
||||
|
||||
const modalTitle = computed(() => (editing.value ? "编辑角色" : "新增角色"));
|
||||
const modalOkText = computed(() => "保存");
|
||||
@@ -68,15 +69,29 @@ function parseDataPerms(desc: string | undefined): string[] {
|
||||
if (!desc) return [];
|
||||
const m = desc.match(/\[dataPerms:(.+?)\]/);
|
||||
if (!m) return [];
|
||||
return m[1]
|
||||
.split("|")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
return normalizeDataPermKeys(
|
||||
m[1]
|
||||
.split("|")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
function buildDescription(dataPerms: string[]) {
|
||||
if (!dataPerms.length) return "";
|
||||
return `[dataPerms:${dataPerms.join("|")}]`;
|
||||
function buildDescription(dataPerms: string[], systemExceptionNotify: boolean) {
|
||||
const keys = leafDataPermKeys(dataPerms);
|
||||
if (systemExceptionNotify && !keys.includes(SYSTEM_EXCEPTION_NOTIFY_KEY)) {
|
||||
keys.push(SYSTEM_EXCEPTION_NOTIFY_KEY);
|
||||
}
|
||||
if (!keys.length) return "";
|
||||
return `[dataPerms:${keys.join("|")}]`;
|
||||
}
|
||||
|
||||
function displayDataPerms(keys: string[]): string[] {
|
||||
return keys.map((k) => dataPermTitle(k));
|
||||
}
|
||||
|
||||
function routeDataPermsOnly(keys: string[]): string[] {
|
||||
return keys.filter((k) => k !== SYSTEM_EXCEPTION_NOTIFY_KEY);
|
||||
}
|
||||
|
||||
async function loadMenuTree() {
|
||||
@@ -143,18 +158,21 @@ function openCreate() {
|
||||
status: "active",
|
||||
checkedKeys: [],
|
||||
dataPerms: [],
|
||||
systemExceptionNotify: false,
|
||||
});
|
||||
modalOpen.value = true;
|
||||
}
|
||||
|
||||
async function openEdit(row: RoleRow) {
|
||||
editing.value = row;
|
||||
const allPerms = [...row.dataPerms];
|
||||
Object.assign(form, {
|
||||
name: row.name,
|
||||
roleCode: row.roleCode,
|
||||
status: row.status,
|
||||
checkedKeys: [] as string[],
|
||||
dataPerms: [...row.dataPerms],
|
||||
dataPerms: routeDataPermsOnly(allPerms),
|
||||
systemExceptionNotify: allPerms.includes(SYSTEM_EXCEPTION_NOTIFY_KEY),
|
||||
});
|
||||
try {
|
||||
const perms: any = await queryRolePermission({ roleId: row.id });
|
||||
@@ -179,7 +197,7 @@ async function save() {
|
||||
id: editing.value?.id,
|
||||
roleName: form.name.trim(),
|
||||
roleCode,
|
||||
description: buildDescription(form.dataPerms),
|
||||
description: buildDescription(form.dataPerms, form.systemExceptionNotify),
|
||||
},
|
||||
Boolean(editing.value),
|
||||
);
|
||||
@@ -214,6 +232,12 @@ function removeRole(row: RoleRow) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** a-tree 可能回传对象或数组;统一成叶子 key 列表 */
|
||||
function onDataPermCheck(checked: string[] | { checked: string[] }) {
|
||||
const keys = Array.isArray(checked) ? checked : checked?.checked || [];
|
||||
form.dataPerms = leafDataPermKeys(keys.map(String));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -252,7 +276,7 @@ function removeRole(row: RoleRow) {
|
||||
</td>
|
||||
<td>
|
||||
<div class="role-perm-summary">
|
||||
<span v-for="d in row.dataPerms" :key="d" class="tag">{{ d }}</span>
|
||||
<span v-for="d in displayDataPerms(row.dataPerms)" :key="d" class="tag">{{ d }}</span>
|
||||
<span v-if="!row.dataPerms.length" style="color: #94a3b8">—</span>
|
||||
</div>
|
||||
</td>
|
||||
@@ -306,14 +330,20 @@ function removeRole(row: RoleRow) {
|
||||
|
||||
<div class="admin-modal-field">
|
||||
<label class="admin-modal-label">数据权限</label>
|
||||
<a-select
|
||||
v-model:value="form.dataPerms"
|
||||
mode="multiple"
|
||||
style="width: 100%"
|
||||
:options="dataOptions.map((d) => ({ value: d, label: d }))"
|
||||
placeholder="选择线路 / 通知范围"
|
||||
:max-tag-count="3"
|
||||
/>
|
||||
<div class="perm-tree-box">
|
||||
<a-tree
|
||||
:checkedKeys="form.dataPerms"
|
||||
checkable
|
||||
default-expand-all
|
||||
:tree-data="dataPermTree"
|
||||
:field-names="{ title: 'title', key: 'key', children: 'children' }"
|
||||
@check="onDataPermCheck"
|
||||
/>
|
||||
</div>
|
||||
<label class="notify-check">
|
||||
<input v-model="form.systemExceptionNotify" type="checkbox" />
|
||||
{{ systemExceptionNotifyTitle }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="admin-modal-field">
|
||||
@@ -340,4 +370,14 @@ function removeRole(row: RoleRow) {
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.notify-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
color: #0f172a;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* 运营后台数据权限 / 员工负责线路 单一目录。
|
||||
*
|
||||
* 来源:《询价智能体运营后台数据权限下拉选项.xlsx》(2026-09)。
|
||||
* key = 写入 sys_role.description / inq_staff_role.routes 的稳定码;
|
||||
* title = 界面展示文案。
|
||||
*/
|
||||
|
||||
export type DataPermNode = {
|
||||
key: string;
|
||||
title: string;
|
||||
children?: DataPermNode[];
|
||||
};
|
||||
|
||||
/** 空运具体线路(不含全线) */
|
||||
export const AIR_ROUTE_ITEMS: { key: string; title: string }[] = [
|
||||
{ key: "CGO-KUL", title: "郑州-吉隆坡(CGO-KUL)" },
|
||||
{ key: "SZX-KUL", title: "深圳-吉隆坡(SZX-KUL)" },
|
||||
{ key: "HKG-KUL", title: "香港-吉隆坡(HKG-KUL)" },
|
||||
{ key: "NKG-KUL", title: "南京-吉隆坡(NKG-KUL)" },
|
||||
{ key: "HKG-DEL", title: "香港-新德里(HKG-DEL)" },
|
||||
{ key: "CGO-DEL", title: "郑州-新德里(CGO-DEL)" },
|
||||
{ key: "CGO-BLR", title: "郑州-班加罗尔(CGO-BLR)" },
|
||||
{ key: "CGO-MAA", title: "郑州-金奈(CGO-MAA)" },
|
||||
{ key: "CTU-MAA", title: "成都-金奈(CTU-MAA)" },
|
||||
{ key: "CKG-BOM", title: "重庆-孟买(CKG-BOM)" },
|
||||
{ key: "ZUH-CRK", title: "珠海-克拉克(ZUH-CRK)" },
|
||||
{ key: "SZX-CRK", title: "深圳-克拉克(SZX-CRK)" },
|
||||
{ key: "EHU-ORD", title: "鄂州-芝加哥(EHU-ORD)" },
|
||||
{ key: "WNZ-CGK", title: "温州-雅加达(WNZ-CGK)" },
|
||||
{ key: "国内-曼谷(BKK 外采)", title: "国内-曼谷(BKK 外采)" },
|
||||
{ key: "国外-国外(外采-空运)", title: "国外-国外(外采-空运)" },
|
||||
{ key: "其他线路和快递双清包税(外采-空运)", title: "其他线路和快递双清包税(外采-空运)" },
|
||||
];
|
||||
|
||||
export const LAND_ROUTE_ITEMS: { key: string; title: string }[] = [
|
||||
{ key: "中港/中亚/中欧", title: "中港/中亚/中欧" },
|
||||
{ key: "国内长途/零担", title: "国内长途/零担" },
|
||||
{ key: "国内城配/拖车/打包", title: "国内城配/拖车/打包" },
|
||||
{ key: "东南亚", title: "东南亚" },
|
||||
];
|
||||
|
||||
/** 角色弹窗数据权限树(父节点仅作分组,不写入 description;文案对齐旧后台「空运数据」) */
|
||||
export const DATA_PERM_TREE: DataPermNode[] = [
|
||||
{
|
||||
key: "group:air",
|
||||
title: "空运数据",
|
||||
children: [{ key: "空运全线", title: "空运全线" }, ...AIR_ROUTE_ITEMS],
|
||||
},
|
||||
{
|
||||
key: "group:sea",
|
||||
title: "海运数据",
|
||||
children: [{ key: "海运全线", title: "海运全线" }],
|
||||
},
|
||||
{
|
||||
key: "group:land",
|
||||
title: "陆运数据",
|
||||
children: [{ key: "陆运全线", title: "陆运全线" }, ...LAND_ROUTE_ITEMS],
|
||||
},
|
||||
];
|
||||
|
||||
/** 旧后台独立勾选项:系统异常消息通知(企微) */
|
||||
export const SYSTEM_EXCEPTION_NOTIFY_KEY = "系统异常·消息通知";
|
||||
|
||||
export const SYSTEM_EXCEPTION_NOTIFY_TITLE = "系统异常消息通知(企微)";
|
||||
|
||||
/** 员工岗位 → 可选负责线路 key(与角色数据权限同源) */
|
||||
export const ROUTES_BY_ROLE: Record<"air" | "sea" | "land", string[]> = {
|
||||
air: ["空运全线", ...AIR_ROUTE_ITEMS.map((x) => x.key)],
|
||||
sea: ["海运全线"],
|
||||
land: ["陆运全线", ...LAND_ROUTE_ITEMS.map((x) => x.key)],
|
||||
};
|
||||
|
||||
const TITLE_BY_KEY: Record<string, string> = (() => {
|
||||
const m: Record<string, string> = {
|
||||
空运全线: "空运全线",
|
||||
海运全线: "海运全线",
|
||||
陆运全线: "陆运全线",
|
||||
[SYSTEM_EXCEPTION_NOTIFY_KEY]: SYSTEM_EXCEPTION_NOTIFY_TITLE,
|
||||
};
|
||||
for (const x of AIR_ROUTE_ITEMS) m[x.key] = x.title;
|
||||
for (const x of LAND_ROUTE_ITEMS) m[x.key] = x.title;
|
||||
return m;
|
||||
})();
|
||||
|
||||
/** 已知稳定码集合(不含分组父节点) */
|
||||
export const KNOWN_DATA_PERM_KEYS = new Set(Object.keys(TITLE_BY_KEY));
|
||||
|
||||
/**
|
||||
* 旧写法 → 新稳定码。
|
||||
* 无法识别时返回 trim 后原文(可能仍展示,但不进勾选树)。
|
||||
*/
|
||||
const ALIAS_TO_KEY: Record<string, string> = {
|
||||
空运全线: "空运全线",
|
||||
空运全部线路: "空运全线",
|
||||
"空运·全部线路": "空运全线",
|
||||
"空运·全线": "空运全线",
|
||||
全线: "空运全线",
|
||||
海运全线: "海运全线",
|
||||
"海运·全部线路": "海运全线",
|
||||
"海运·海运全线": "海运全线",
|
||||
陆运全线: "陆运全线",
|
||||
"陆运·全部线路": "陆运全线",
|
||||
"陆运·中港/中亚/中欧": "中港/中亚/中欧",
|
||||
"陆运·国内长途/零担": "国内长途/零担",
|
||||
"陆运·国内城配/拖车/打包": "国内城配/拖车/打包",
|
||||
"陆运·东南亚": "东南亚",
|
||||
BKK: "国内-曼谷(BKK 外采)",
|
||||
"外采-空运": "国外-国外(外采-空运)",
|
||||
[SYSTEM_EXCEPTION_NOTIFY_KEY]: SYSTEM_EXCEPTION_NOTIFY_KEY,
|
||||
系统异常消息通知: SYSTEM_EXCEPTION_NOTIFY_KEY,
|
||||
"系统异常消息通知(企微)": SYSTEM_EXCEPTION_NOTIFY_KEY,
|
||||
};
|
||||
|
||||
for (const item of AIR_ROUTE_ITEMS) {
|
||||
ALIAS_TO_KEY[item.key] = item.key;
|
||||
ALIAS_TO_KEY[item.title] = item.key;
|
||||
ALIAS_TO_KEY[`空运·${item.key}`] = item.key;
|
||||
}
|
||||
for (const item of LAND_ROUTE_ITEMS) {
|
||||
ALIAS_TO_KEY[item.key] = item.key;
|
||||
ALIAS_TO_KEY[`陆运·${item.key}`] = item.key;
|
||||
}
|
||||
|
||||
export function normalizeDataPermKey(raw: string): string {
|
||||
const s = String(raw || "").trim();
|
||||
if (!s) return "";
|
||||
if (ALIAS_TO_KEY[s]) return ALIAS_TO_KEY[s];
|
||||
// 空运·XXX / 陆运·XXX 兜底剥前缀
|
||||
const m = s.match(/^(?:空运|海运|陆运)[·•](.+)$/);
|
||||
if (m) {
|
||||
const inner = m[1].trim();
|
||||
if (ALIAS_TO_KEY[inner]) return ALIAS_TO_KEY[inner];
|
||||
if (inner === "全部线路" || inner === "全线") {
|
||||
if (s.startsWith("空运")) return "空运全线";
|
||||
if (s.startsWith("海运")) return "海运全线";
|
||||
if (s.startsWith("陆运")) return "陆运全线";
|
||||
}
|
||||
if (KNOWN_DATA_PERM_KEYS.has(inner)) return inner;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** 批量归一;去重保序;只保留已知码(角色勾选回显用) */
|
||||
export function normalizeDataPermKeys(rawList: string[]): string[] {
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const raw of rawList || []) {
|
||||
const k = normalizeDataPermKey(raw);
|
||||
if (!k || !KNOWN_DATA_PERM_KEYS.has(k) || seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
out.push(k);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 员工 routes:归一后保留已知码;未知旧值也保留以免丢数据 */
|
||||
export function normalizeStaffRouteKeys(rawList: string[]): string[] {
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const raw of rawList || []) {
|
||||
const k = normalizeDataPermKey(raw);
|
||||
if (!k || seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
out.push(k);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function dataPermTitle(key: string): string {
|
||||
const k = normalizeDataPermKey(key);
|
||||
return TITLE_BY_KEY[k] || key;
|
||||
}
|
||||
|
||||
/** Ant Design Tree 用:过滤掉分组父 key,只交叶子 */
|
||||
export function leafDataPermKeys(checked: string[]): string[] {
|
||||
return (checked || []).filter((k) => KNOWN_DATA_PERM_KEYS.has(k));
|
||||
}
|
||||
@@ -20,6 +20,11 @@ import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
|
||||
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
|
||||
import AdminPagination from "/@/views/inquiry/components/AdminPagination.vue";
|
||||
import { joinRoutes, parseRouteList } from "/@/views/inquiry/staffRole/staffRoutes";
|
||||
import {
|
||||
ROUTES_BY_ROLE,
|
||||
dataPermTitle,
|
||||
normalizeStaffRouteKeys,
|
||||
} from "/@/views/inquiry/shared/dataPermCatalog";
|
||||
|
||||
type StaffRole = "sales" | "air" | "sea" | "land";
|
||||
|
||||
@@ -52,26 +57,7 @@ const roleLabelMap: Record<StaffRole, string> = {
|
||||
land: "产品·陆运",
|
||||
};
|
||||
|
||||
/** 按岗位的负责线路选项(与原型 admin-staff.html ROUTES_BY_ROLE 一致) */
|
||||
const ROUTES_BY_ROLE: Record<Exclude<StaffRole, "sales">, string[]> = {
|
||||
air: [
|
||||
"CGO-KUL",
|
||||
"SZX-KUL",
|
||||
"HKG-KUL",
|
||||
"NKG-KUL",
|
||||
"HKG-DEL",
|
||||
"CGO-MAA",
|
||||
"CGO-BLR",
|
||||
"ZUH-CRK",
|
||||
"WNZ-CGK",
|
||||
"BKK",
|
||||
"外采-空运",
|
||||
"全线",
|
||||
],
|
||||
sea: ["海运全线"],
|
||||
land: ["中港/中亚/中欧", "国内长途/零担", "国内城配/拖车/打包", "东南亚"],
|
||||
};
|
||||
|
||||
/** 负责线路与角色数据权限同源(Excel《数据权限下拉选项》) */
|
||||
const ALL_ROUTE_FILTER_OPTIONS = [
|
||||
...ROUTES_BY_ROLE.air,
|
||||
...ROUTES_BY_ROLE.sea,
|
||||
@@ -120,8 +106,11 @@ const form = reactive({
|
||||
|
||||
/** 当前岗位可选线路;销售为空 */
|
||||
const routeOptions = computed(() => {
|
||||
if (!isProductOrAir(form.role)) return [] as string[];
|
||||
return ROUTES_BY_ROLE[form.role];
|
||||
if (!isProductOrAir(form.role)) return [] as { label: string; value: string }[];
|
||||
return ROUTES_BY_ROLE[form.role].map((key) => ({
|
||||
value: key,
|
||||
label: dataPermTitle(key),
|
||||
}));
|
||||
});
|
||||
|
||||
const showLeaderAndRoutes = computed(() => isProductOrAir(form.role));
|
||||
@@ -367,7 +356,7 @@ watch(
|
||||
return;
|
||||
}
|
||||
const valid = new Set(ROUTES_BY_ROLE[role]);
|
||||
form.routeList = form.routeList.filter((r) => valid.has(r));
|
||||
form.routeList = normalizeStaffRouteKeys(form.routeList).filter((r) => valid.has(r));
|
||||
},
|
||||
);
|
||||
|
||||
@@ -392,7 +381,10 @@ function openCreate() {
|
||||
function openEdit(row: StaffRow) {
|
||||
editing.value = row;
|
||||
const valid = isProductOrAir(row.role) ? new Set(ROUTES_BY_ROLE[row.role]) : null;
|
||||
const routes = parseRouteList(row.routes).filter((r) => (valid ? valid.has(r) : true));
|
||||
// 旧 routes(全线/BKK/外采-空运 等)归一到 Excel 码后再过滤本岗选项
|
||||
const routes = normalizeStaffRouteKeys(parseRouteList(row.routes)).filter((r) =>
|
||||
valid ? valid.has(r) : true,
|
||||
);
|
||||
Object.assign(form, {
|
||||
name: row.name,
|
||||
wecomId: row.wecomId,
|
||||
@@ -432,7 +424,9 @@ async function saveStaff() {
|
||||
departName: form.dept.trim() || undefined,
|
||||
roleCode: form.role,
|
||||
roleLabel: roleLabelMap[form.role],
|
||||
routes: isProductOrAir(form.role) ? joinRoutes(form.routeList) : undefined,
|
||||
routes: isProductOrAir(form.role)
|
||||
? joinRoutes(normalizeStaffRouteKeys(form.routeList))
|
||||
: undefined,
|
||||
isLeader: isProductOrAir(form.role) ? (form.isLeader === "true" ? 1 : 0) : null,
|
||||
notifyEventException: form.notifyEventException ? 1 : 0,
|
||||
status: form.status,
|
||||
@@ -480,7 +474,7 @@ async function syncWecom() {
|
||||
}
|
||||
|
||||
function routeTags(routes: string) {
|
||||
return parseRouteList(routes);
|
||||
return normalizeStaffRouteKeys(parseRouteList(routes)).map((k) => dataPermTitle(k));
|
||||
}
|
||||
|
||||
function filterRouteOption(input: string, option: any) {
|
||||
@@ -535,7 +529,7 @@ function filterRouteOption(input: string, option: any) {
|
||||
</select>
|
||||
<select v-model="filters.route">
|
||||
<option value="">负责线路</option>
|
||||
<option v-for="r in ALL_ROUTE_FILTER_OPTIONS" :key="r" :value="r">{{ r }}</option>
|
||||
<option v-for="r in ALL_ROUTE_FILTER_OPTIONS" :key="r" :value="r">{{ dataPermTitle(r) }}</option>
|
||||
</select>
|
||||
<select v-model="filters.status">
|
||||
<option value="">状态</option>
|
||||
@@ -713,7 +707,7 @@ function filterRouteOption(input: string, option: any) {
|
||||
show-search
|
||||
class="staff-route-select"
|
||||
placeholder="请选择负责线路,可多选"
|
||||
:options="routeOptions.map((r) => ({ label: r, value: r }))"
|
||||
:options="routeOptions"
|
||||
:filter-option="filterRouteOption"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 员工管理「负责线路」拆合。
|
||||
*
|
||||
* 只按逗号/中文逗号/顿号拆整词,禁止把「中港/中亚/中欧」「国内长途/零担」按斜杠切开。
|
||||
* 陆运没有「陆运全线」快捷项。
|
||||
* 选项与角色数据权限同源(含空运全线 / 海运全线 / 陆运全线)。
|
||||
*/
|
||||
|
||||
/** 把库里的逗号串还原成多选项。空或占位「-」当没有。 */
|
||||
|
||||
@@ -129,7 +129,7 @@ async function commitHours(row) {
|
||||
<tr>
|
||||
<th style="width: 88px">角色</th>
|
||||
<th style="width: 160px" title="每条唯一;智能体用此码取超时小时">编码</th>
|
||||
<th>运输类型</th>
|
||||
<th>运输分类</th>
|
||||
<th style="width: 140px">超时设置(小时)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -81,20 +81,15 @@ const parseDetailState = reactive({
|
||||
mapping: emptyMappingSummary() as MappingParseSummary,
|
||||
});
|
||||
|
||||
/** 未解析清单:优先 mappingGaps,其次 AI 自报项 */
|
||||
/** 未解析清单:只认系统 mappingGaps;齐全(空数组)时不展示 AI 自报标签 */
|
||||
const parseDetailUnparsed = computed(() => {
|
||||
const m = parseDetailState.mapping;
|
||||
const list: string[] = [];
|
||||
for (const g of m.gaps || []) {
|
||||
if (g) list.push(g);
|
||||
if (Array.isArray(m.gaps)) {
|
||||
return (m.gaps || []).filter(Boolean).map(String);
|
||||
}
|
||||
if (!list.length) {
|
||||
for (const c of m.unmappedRequired || []) {
|
||||
list.push(`字段未映射:${FIELD_LABELS[c] || c}`);
|
||||
}
|
||||
for (const lab of m.unmappedLabels || []) {
|
||||
if (lab) list.push(`表标签未落点:${lab}`);
|
||||
}
|
||||
const list: string[] = [];
|
||||
for (const c of m.unmappedRequired || []) {
|
||||
list.push(`字段未映射:${FIELD_LABELS[c] || c}`);
|
||||
}
|
||||
return list;
|
||||
});
|
||||
@@ -377,22 +372,22 @@ function openEdit(row: TemplateRow) {
|
||||
formOpen.value = true;
|
||||
}
|
||||
|
||||
/** 运营可见的未解析项:优先 mappingGaps,其次标签/必填未映射 */
|
||||
/** 运营可见的未解析项:只认系统对照清单 mappingGaps;齐全时角标为 0 */
|
||||
function listMappingGaps(row: TemplateRow): string[] {
|
||||
if (row.mappingGaps?.length) return [...row.mappingGaps];
|
||||
const list: string[] = [];
|
||||
for (const lab of row.unmappedLabels || []) {
|
||||
if (lab) list.push(`表标签未落点:${lab}`);
|
||||
// 主账已带 mappingGaps(含空数组)时,不再用 AI 自报 unmapped* 抬角标
|
||||
if (Array.isArray(row.mappingGaps)) {
|
||||
return row.mappingGaps.filter(Boolean).map(String);
|
||||
}
|
||||
const list: string[] = [];
|
||||
for (const c of row.unmappedRequired || []) {
|
||||
if (c) list.push(`字段未映射:${c}`);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 列表角标条数 */
|
||||
/** 列表角标条数:优先主账 unparsedHintCount(与 mappingGaps 同口径) */
|
||||
function unparsedCount(row: TemplateRow): number {
|
||||
if (row.unparsedHintCount && row.unparsedHintCount > 0) return row.unparsedHintCount;
|
||||
if (typeof row.unparsedHintCount === "number") return Math.max(0, row.unparsedHintCount);
|
||||
return listMappingGaps(row).length;
|
||||
}
|
||||
|
||||
@@ -439,6 +434,7 @@ async function toggleStatus(row: TemplateRow) {
|
||||
|
||||
/**
|
||||
* 重新提交 AI 映射解析(已解析也可点;解析中禁用)。
|
||||
* 未换文件:对齐标签的旧坐标保留,对不上的改用新坐标;缺的字段仍补上。
|
||||
* 副作用:主账入队;列表进入解析中并轮询。
|
||||
*/
|
||||
async function reparseRow(row: TemplateRow) {
|
||||
@@ -692,7 +688,11 @@ async function downloadPreviewOrOriginal() {
|
||||
type="button"
|
||||
class="link-action"
|
||||
:disabled="row.parseStatus === '解析中'"
|
||||
:title="row.parseStatus === '解析中' ? '解析中,请稍候' : '重新提交 AI 映射解析'"
|
||||
:title="
|
||||
row.parseStatus === '解析中'
|
||||
? '解析中,请稍候'
|
||||
: '重新解析:对齐的保留,对不上的改坐标;换文件请用编辑上传'
|
||||
"
|
||||
@click.stop="reparseRow(row)"
|
||||
>
|
||||
重新解析
|
||||
|
||||
@@ -91,11 +91,18 @@ export const listTemplateVersions = (templateId: string) =>
|
||||
export const saveTemplateMapping = (versionId: string, mappingJson: string) =>
|
||||
defHttp.put({ url: Api.mapping, data: { versionId, mappingJson } });
|
||||
|
||||
/** 非 GET 时 params 会进 JSON 体,@RequestParam 读不到,必须拼到查询串。 */
|
||||
export const enableTemplate = (templateId: string, versionId: string) =>
|
||||
defHttp.post({ url: Api.enable, params: { templateId, versionId } });
|
||||
defHttp.post(
|
||||
{ url: Api.enable, params: { templateId, versionId } },
|
||||
{ joinParamsToUrl: true },
|
||||
);
|
||||
|
||||
export const disableTemplate = (templateId: string) =>
|
||||
defHttp.post({ url: Api.disable, params: { templateId } });
|
||||
defHttp.post(
|
||||
{ url: Api.disable, params: { templateId } },
|
||||
{ joinParamsToUrl: true },
|
||||
);
|
||||
|
||||
/**
|
||||
* 删除模板逻辑头及全部版本;不可恢复。
|
||||
@@ -109,7 +116,10 @@ export const deleteTemplate = (templateId: string) =>
|
||||
);
|
||||
|
||||
export const setTemplateDefault = (templateId: string, isDefault = true) =>
|
||||
defHttp.post({ url: Api.setDefault, params: { templateId, isDefault } });
|
||||
defHttp.post(
|
||||
{ url: Api.setDefault, params: { templateId, isDefault } },
|
||||
{ joinParamsToUrl: true },
|
||||
);
|
||||
|
||||
/** 只改名称/业务/关键词/默认标记,不换文件;走 JSON 体对接主账 @RequestBody */
|
||||
export const editTemplateMeta = (data) => defHttp.put({ url: Api.editMeta, data });
|
||||
@@ -119,7 +129,7 @@ export const editTemplateMeta = (data) => defHttp.put({ url: Api.editMeta, data
|
||||
*/
|
||||
export const reparseTemplate = (templateId: string) =>
|
||||
defHttp.post(
|
||||
{ url: Api.reparse, params: { id: templateId }, timeout: 60 * 1000 },
|
||||
{ url: Api.reparse, params: { id: templateId, templateId }, timeout: 60 * 1000 },
|
||||
{ joinParamsToUrl: true },
|
||||
);
|
||||
|
||||
|
||||
@@ -48,6 +48,9 @@ type TicketRow = {
|
||||
volumeCbm: string;
|
||||
goodsName: string;
|
||||
updateTime: string;
|
||||
lostReason: string;
|
||||
nextFollowupAt: string;
|
||||
dealNotifySummary: string;
|
||||
};
|
||||
|
||||
const rows = ref<TicketRow[]>([]);
|
||||
@@ -265,6 +268,9 @@ function mapTicket(t: any, quotes: any[] = [], attachments: any[] = [], timeline
|
||||
volumeCbm: fmtMeasure(t.volumeCbm, "CBM"),
|
||||
goodsName: dash(t.goodsName),
|
||||
updateTime: fmtTime(t.updateTime || t.createTime),
|
||||
lostReason: dash(t.lostReason || t.lost_reason),
|
||||
nextFollowupAt: fmtTime(t.nextFollowupAt || t.next_followup_at),
|
||||
dealNotifySummary: dash(t.dealNotifySummary || t.deal_notify_summary),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -630,6 +636,18 @@ function refresh() {
|
||||
<div class="ticket-detail-item"><label>事件异常</label><span>{{ current.eventException }}</span></div>
|
||||
<div class="ticket-detail-item"><label>系统异常</label><span>{{ current.systemException }}</span></div>
|
||||
<div class="ticket-detail-item"><label>系统异常原因</label><span>{{ current.systemExceptionReason }}</span></div>
|
||||
<div v-if="current.status === '已成交'" class="ticket-detail-item">
|
||||
<label>成交时间</label><span>{{ current.updateTime }}</span>
|
||||
</div>
|
||||
<div v-if="current.status === '已成交'" class="ticket-detail-item">
|
||||
<label>成交通知</label><span>{{ current.dealNotifySummary }}</span>
|
||||
</div>
|
||||
<div v-if="current.status === '未成交'" class="ticket-detail-item">
|
||||
<label>未成交原因</label><span>{{ current.lostReason }}</span>
|
||||
</div>
|
||||
<div v-if="current.status === '协商中'" class="ticket-detail-item">
|
||||
<label>下次再问</label><span>{{ current.nextFollowupAt }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user