补齐成交跟进:按线路推成交通知、收下未成交原因、协商中按工作日再问。

成交走询价消息服务;陆运/海运私聊下一句能记下原因;协商中回执写明顺延三个工作日和下次 9:00。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-19 16:08:48 +08:00
co-authored by Cursor
parent 9bb69ae72e
commit 1a2e1d6cb2
31 changed files with 1638 additions and 30 deletions
@@ -122,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");
@@ -135,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,
@@ -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());
@@ -20,4 +20,13 @@ public class TicketTransitionRequest {
@ApiModelProperty("备注/关单原因")
private String remark;
@ApiModelProperty("未成交原因(仅未成交)")
private String lostReason;
@ApiModelProperty("协商中下次再问时间")
private String nextFollowupAt;
@ApiModelProperty("成交通知结果摘要")
private String dealNotifySummary;
}
@@ -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;
@@ -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();
}
@@ -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;
@@ -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());
@@ -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`;