落地海运私聊文字询价全流程,海运多项费用改走文本明细,避免企微卡片引用条截断。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+73
-1
@@ -34,6 +34,9 @@ public class InquiryAgentController {
|
||||
@Autowired
|
||||
private IInquiryQuoteTemplateService quoteTemplateService;
|
||||
|
||||
@Autowired
|
||||
private org.jeecg.modules.inquiry.service.IInquiryStaffRoleService staffRoleService;
|
||||
|
||||
@Value("${inquiry.agent.callback-token:${AGENT_CALLBACK_TOKEN:}}")
|
||||
private String agentToken;
|
||||
|
||||
@@ -122,7 +125,12 @@ public class InquiryAgentController {
|
||||
Map<String, Object> facts = asMap(body == null ? null : body.get("facts"));
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> quote = asMap(body == null ? null : body.get("quote"));
|
||||
return Result.OK(quoteTemplateService.renderForAgent(workOrderNo, businessLine, facts, quote, sales));
|
||||
Map<String, Object> rendered = quoteTemplateService.renderForAgent(
|
||||
workOrderNo, businessLine, facts, quote, sales);
|
||||
if (rendered != null && Boolean.TRUE.equals(rendered.get("ok"))) {
|
||||
workOrderService.markQuoteFileIssued(workOrderNo);
|
||||
}
|
||||
return Result.OK(rendered);
|
||||
}
|
||||
|
||||
@ApiOperation("经主账查真 TMS(有价回报价 / 无价回暂无报价 / 技术失败)")
|
||||
@@ -135,6 +143,70 @@ public class InquiryAgentController {
|
||||
return Result.OK(workOrderService.queryTms(body));
|
||||
}
|
||||
|
||||
@ApiOperation("海运拉群:按起运/目的匹配启用的产品·海运")
|
||||
@PostMapping("/staff/matchSea")
|
||||
public Result<Map<String, Object>> matchSea(@RequestBody Map<String, Object> body,
|
||||
HttpServletRequest http) {
|
||||
if (!authed(http)) {
|
||||
return Result.error(401, "unauthorized");
|
||||
}
|
||||
String origin = firstStr(body, "origin", "起运港");
|
||||
String dest = firstStr(body, "destination", "目的港");
|
||||
Map<String, Object> out = new java.util.LinkedHashMap<>();
|
||||
out.put("staff", staffRoleService.matchSeaStaff(origin, dest));
|
||||
return Result.OK(out);
|
||||
}
|
||||
|
||||
@ApiOperation("按企微 userid 取员工展示名")
|
||||
@PostMapping("/staff/byWecom")
|
||||
public Result<Map<String, Object>> staffByWecom(@RequestBody Map<String, Object> body,
|
||||
HttpServletRequest http) {
|
||||
if (!authed(http)) {
|
||||
return Result.error(401, "unauthorized");
|
||||
}
|
||||
return Result.OK(staffRoleService.findDisplayByWecomId(firstStr(body, "wecomId", "wecom_id")));
|
||||
}
|
||||
|
||||
@ApiOperation("按员工姓名取企微 userid(会话存档询价机器人)")
|
||||
@PostMapping("/staff/byName")
|
||||
public Result<Map<String, Object>> staffByName(@RequestBody Map<String, Object> body,
|
||||
HttpServletRequest http) {
|
||||
if (!authed(http)) {
|
||||
return Result.error(401, "unauthorized");
|
||||
}
|
||||
return Result.OK(staffRoleService.findDisplayByName(firstStr(body, "name")));
|
||||
}
|
||||
|
||||
@ApiOperation("记下海运协同群(不改六态)")
|
||||
@PostMapping("/ticket/bindCollabGroup")
|
||||
public Result<Map<String, Object>> bindCollabGroup(@RequestBody Map<String, Object> body,
|
||||
HttpServletRequest http) {
|
||||
if (!authed(http)) {
|
||||
return Result.error(401, "unauthorized");
|
||||
}
|
||||
return Result.OK(workOrderService.bindCollabGroup(body));
|
||||
}
|
||||
|
||||
@ApiOperation("合并协同字段(不改六态、不写报价版本)")
|
||||
@PostMapping("/ticket/patchCollabFacts")
|
||||
public Result<Map<String, Object>> patchCollabFacts(@RequestBody Map<String, Object> body,
|
||||
HttpServletRequest http) {
|
||||
if (!authed(http)) {
|
||||
return Result.error(401, "unauthorized");
|
||||
}
|
||||
return Result.OK(workOrderService.patchCollabFacts(body));
|
||||
}
|
||||
|
||||
@ApiOperation("按协同群 chat_id 找回工单(只读)")
|
||||
@PostMapping("/ticket/byCollabChat")
|
||||
public Result<Map<String, Object>> byCollabChat(@RequestBody Map<String, Object> body,
|
||||
HttpServletRequest http) {
|
||||
if (!authed(http)) {
|
||||
return Result.error(401, "unauthorized");
|
||||
}
|
||||
return Result.OK(workOrderService.findByCollabChat(firstStr(body, "chatId", "chat_id")));
|
||||
}
|
||||
|
||||
private boolean authed(HttpServletRequest http) {
|
||||
String got = http.getHeader("X-Agent-Token");
|
||||
if (StringUtils.isBlank(got)) {
|
||||
|
||||
+21
@@ -118,6 +118,27 @@ public class InquiryTicket implements Serializable {
|
||||
@ApiModelProperty("业务版本")
|
||||
private Integer version;
|
||||
|
||||
@ApiModelProperty("海运货量原文")
|
||||
private String cargoQty;
|
||||
|
||||
@ApiModelProperty("整柜或拼柜")
|
||||
private String fclLcl;
|
||||
|
||||
@ApiModelProperty("箱型箱量原文")
|
||||
private String containerSpec;
|
||||
|
||||
@ApiModelProperty("询价字段快照 JSON")
|
||||
private String factsJson;
|
||||
|
||||
@ApiModelProperty("协同字段快照 JSON")
|
||||
private String collabFactsJson;
|
||||
|
||||
@ApiModelProperty("协同群 chat_id")
|
||||
private String collabChatId;
|
||||
|
||||
@ApiModelProperty("协同群成员 userid JSON")
|
||||
private String collabMemberIds;
|
||||
|
||||
private String createBy;
|
||||
|
||||
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
|
||||
+15
@@ -30,4 +30,19 @@ public interface IInquiryStaffRoleService extends IService<InquiryStaffRole> {
|
||||
* @return 同步计数
|
||||
*/
|
||||
StaffWecomSyncResult syncFromWeCom(String operator);
|
||||
|
||||
/**
|
||||
* 海运拉群:启用的产品·海运,按起运/目的匹配线路。
|
||||
*/
|
||||
java.util.List<java.util.Map<String, Object>> matchSeaStaff(String origin, String destination);
|
||||
|
||||
/**
|
||||
* 按企微 userid 取展示名。
|
||||
*/
|
||||
java.util.Map<String, Object> findDisplayByWecomId(String wecomId);
|
||||
|
||||
/**
|
||||
* 按员工姓名取启用账号(会话存档「询价机器人」用)。
|
||||
*/
|
||||
java.util.Map<String, Object> findDisplayByName(String name);
|
||||
}
|
||||
|
||||
+21
@@ -68,4 +68,25 @@ public interface IInquiryWorkOrderService extends IService<InquiryTicket> {
|
||||
* 必须校验 salesWecomId 与工单发起人一致。
|
||||
*/
|
||||
Map<String, Object> getForAgent(String workOrderNo, String salesWecomId);
|
||||
|
||||
/**
|
||||
* 报价文件已发给销售:写入报价载荷 quoteFileIssued,续办据此回到成交跟进。
|
||||
* 不改六态(仍是已报价,直到点成交/未成交/协商中)。
|
||||
*/
|
||||
void markQuoteFileIssued(String workOrderNo);
|
||||
|
||||
/**
|
||||
* 记下海运协同群。不改六态。
|
||||
*/
|
||||
Map<String, Object> bindCollabGroup(Map<String, Object> body);
|
||||
|
||||
/**
|
||||
* 合并协同字段。不改六态、不写报价版本。
|
||||
*/
|
||||
Map<String, Object> patchCollabFacts(Map<String, Object> body);
|
||||
|
||||
/**
|
||||
* 用协同群 chat_id 找回工单。只读。
|
||||
*/
|
||||
Map<String, Object> findByCollabChat(String chatId);
|
||||
}
|
||||
|
||||
+12
-3
@@ -661,15 +661,24 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读库 keywords_json。合法 JSON 数组优先;非法(如 {@code [海运,整柜,FCL]})
|
||||
* 或数组里套了一整段方括号,都交给 {@link QuoteTemplateMatcher#asKeywords} 摊开。
|
||||
* 禁止把整段原文当成一个关键词,否则海运有模板也对不上。
|
||||
*/
|
||||
private static List<String> parseKeywords(String json) {
|
||||
if (StringUtils.isBlank(json)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
try {
|
||||
return JSON.parseArray(json, String.class);
|
||||
} catch (Exception e) {
|
||||
return Collections.singletonList(json);
|
||||
List<String> parsed = JSON.parseArray(json, String.class);
|
||||
if (parsed != null && !parsed.isEmpty()) {
|
||||
return QuoteTemplateMatcher.asKeywords(parsed);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 非法 JSON:按方括号/逗号拆
|
||||
}
|
||||
return QuoteTemplateMatcher.asKeywords(json);
|
||||
}
|
||||
|
||||
private static List<String> extractUnmapped(String mappingJson) {
|
||||
|
||||
+45
@@ -132,4 +132,49 @@ public class InquiryStaffRoleServiceImpl extends ServiceImpl<InquiryStaffRoleMap
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 海运拉群:库里启用的产品·海运,再按起运/目的过滤线路。
|
||||
*/
|
||||
@Override
|
||||
public List<Map<String, Object>> matchSeaStaff(String origin, String destination) {
|
||||
List<InquiryStaffRole> rows = list(new QueryWrapper<InquiryStaffRole>()
|
||||
.eq("role_code", "sea")
|
||||
.eq("status", "active"));
|
||||
return org.jeecg.modules.inquiry.staff.SeaStaffMatcher.match(rows, origin, destination);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> findDisplayByWecomId(String wecomId) {
|
||||
InquiryStaffRole row = findByWecomId(wecomId);
|
||||
Map<String, Object> out = new HashMap<>();
|
||||
if (row == null) {
|
||||
return out;
|
||||
}
|
||||
out.put("name", row.getName());
|
||||
out.put("wecomId", row.getWecomId());
|
||||
out.put("roleCode", row.getRoleCode());
|
||||
return out;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> findDisplayByName(String name) {
|
||||
Map<String, Object> out = new HashMap<>();
|
||||
if (StringUtils.isBlank(name)) {
|
||||
return out;
|
||||
}
|
||||
InquiryStaffRole row = getOne(new QueryWrapper<InquiryStaffRole>()
|
||||
.eq("name", name.trim())
|
||||
.eq("status", "active")
|
||||
.isNotNull("wecom_id")
|
||||
.ne("wecom_id", "")
|
||||
.last("LIMIT 1"), false);
|
||||
if (row == null) {
|
||||
return out;
|
||||
}
|
||||
out.put("name", row.getName());
|
||||
out.put("wecomId", row.getWecomId());
|
||||
out.put("roleCode", row.getRoleCode());
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
+260
-1
@@ -301,6 +301,10 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
ticket.setWeightKg(parseDecimal(firstFact(facts, "重量", "毛重")));
|
||||
ticket.setVolumeCbm(parseDecimal(facts.get("体积")));
|
||||
ticket.setQuoteDate(parseDate(facts.get("报价日期")));
|
||||
ticket.setCargoQty(StringUtils.trimToNull(firstFact(facts, "货量", "货物数量")));
|
||||
ticket.setFclLcl(StringUtils.trimToNull(facts.get("整柜或拼柜")));
|
||||
ticket.setContainerSpec(StringUtils.trimToNull(facts.get("箱型箱量")));
|
||||
ticket.setFactsJson(JSON.toJSONString(facts));
|
||||
ticket.setDemandTitle(ticket.getOrigin() + " → " + ticket.getDestination());
|
||||
ticket.setDemandDetail("FP:" + fingerprint(req));
|
||||
ticket.setStatus(TicketStatuses.INQUIRING);
|
||||
@@ -422,8 +426,11 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
StringUtils.trimToEmpty(facts.get("目的港")),
|
||||
StringUtils.trimToEmpty(facts.get("品名")),
|
||||
StringUtils.trimToEmpty(facts.get("件数")),
|
||||
StringUtils.trimToEmpty(firstFact(facts, "货量", "货物数量")),
|
||||
StringUtils.trimToEmpty(firstFact(facts, "重量", "毛重")),
|
||||
StringUtils.trimToEmpty(facts.get("体积")),
|
||||
StringUtils.trimToEmpty(facts.get("箱型箱量")),
|
||||
StringUtils.trimToEmpty(facts.get("整柜或拼柜")),
|
||||
StringUtils.trimToEmpty(facts.get("报价日期")));
|
||||
}
|
||||
|
||||
@@ -528,6 +535,25 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
if (ticket.getQuoteDate() != null) {
|
||||
facts.put("报价日期", new SimpleDateFormat("yyyy-MM-dd").format(ticket.getQuoteDate()));
|
||||
}
|
||||
if (StringUtils.isNotBlank(ticket.getCargoQty())) {
|
||||
facts.put("货量", ticket.getCargoQty());
|
||||
}
|
||||
if (StringUtils.isNotBlank(ticket.getFclLcl())) {
|
||||
facts.put("整柜或拼柜", ticket.getFclLcl());
|
||||
}
|
||||
if (StringUtils.isNotBlank(ticket.getContainerSpec())) {
|
||||
facts.put("箱型箱量", ticket.getContainerSpec());
|
||||
}
|
||||
if (StringUtils.isNotBlank(ticket.getFactsJson())) {
|
||||
JSONObject stored = JSON.parseObject(ticket.getFactsJson());
|
||||
if (stored != null) {
|
||||
for (String key : stored.keySet()) {
|
||||
if (StringUtils.isBlank(facts.get(key)) && stored.get(key) != null) {
|
||||
facts.put(key, String.valueOf(stored.get(key)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Map<String, Object> quote = new LinkedHashMap<>();
|
||||
List<InquiryQuote> quotes = quoteMapper.selectList(new LambdaQueryWrapper<InquiryQuote>()
|
||||
.eq(InquiryQuote::getWorkOrderNo, no)
|
||||
@@ -545,7 +571,14 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
|| TicketStatuses.NEGOTIATING.equals(status) || TicketStatuses.CLOSED.equals(status)) {
|
||||
waitPhase = "done";
|
||||
} else if (TicketStatuses.QUOTED.equals(status) && !quote.isEmpty()) {
|
||||
waitPhase = "wait_adopt";
|
||||
// 文件已发出:成交跟进。六态仍是已报价,不能退回协同/出方案卡
|
||||
if (quoteFileAlreadyIssued(no, quote)) {
|
||||
waitPhase = "wait_deal";
|
||||
} else if ("SEA".equalsIgnoreCase(ticket.getBusinessLine())) {
|
||||
waitPhase = "wait_collab";
|
||||
} else {
|
||||
waitPhase = "wait_adopt";
|
||||
}
|
||||
} else {
|
||||
waitPhase = "tms_miss";
|
||||
}
|
||||
@@ -555,9 +588,235 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
out.put("facts", facts);
|
||||
out.put("quote", quote);
|
||||
out.put("wait_phase", waitPhase);
|
||||
out.put("collab_chat_id", StringUtils.defaultString(ticket.getCollabChatId()));
|
||||
Map<String, String> collabFacts = new LinkedHashMap<>();
|
||||
if (StringUtils.isNotBlank(ticket.getCollabFactsJson())) {
|
||||
JSONObject parsed = JSON.parseObject(ticket.getCollabFactsJson());
|
||||
if (parsed != null) {
|
||||
for (String key : parsed.keySet()) {
|
||||
if (parsed.get(key) != null) {
|
||||
collabFacts.put(key, String.valueOf(parsed.get(key)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.put("collab_facts", collabFacts);
|
||||
out.put("collab_member_ids", parseMemberIds(ticket.getCollabMemberIds()));
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 报价文件已发给销售。只打载荷标记,不改六态。
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void markQuoteFileIssued(String workOrderNo) {
|
||||
String no = StringUtils.trimToEmpty(workOrderNo);
|
||||
if (StringUtils.isBlank(no)) {
|
||||
return;
|
||||
}
|
||||
List<InquiryQuote> quotes = quoteMapper.selectList(new LambdaQueryWrapper<InquiryQuote>()
|
||||
.eq(InquiryQuote::getWorkOrderNo, no)
|
||||
.orderByDesc(InquiryQuote::getCreateTime)
|
||||
.last("LIMIT 1"));
|
||||
if (quotes == null || quotes.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
InquiryQuote row = quotes.get(0);
|
||||
JSONObject payload = StringUtils.isBlank(row.getPayloadJson())
|
||||
? new JSONObject(true)
|
||||
: JSON.parseObject(row.getPayloadJson());
|
||||
if (payload == null) {
|
||||
payload = new JSONObject(true);
|
||||
}
|
||||
payload.put("quoteFileIssued", true);
|
||||
row.setPayloadJson(payload.toJSONString());
|
||||
row.setUpdateTime(new Date());
|
||||
quoteMapper.updateById(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* 续办停点:文件已发出或已有附件,就不再回到出方案/协同。
|
||||
*/
|
||||
private boolean quoteFileAlreadyIssued(String workOrderNo, Map<String, Object> quote) {
|
||||
Object flag = quote == null ? null : quote.get("quoteFileIssued");
|
||||
if (Boolean.TRUE.equals(flag) || "true".equalsIgnoreCase(String.valueOf(flag))) {
|
||||
return true;
|
||||
}
|
||||
Long n = attachmentMapper.selectCount(new LambdaQueryWrapper<InquiryAttachment>()
|
||||
.eq(InquiryAttachment::getWorkOrderNo, workOrderNo));
|
||||
return n != null && n > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记下海运协同群 chat_id 与成员。不改六态、不写报价版本。
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> bindCollabGroup(Map<String, Object> body) {
|
||||
String no = text(body, "workOrderNo", "work_order_no");
|
||||
String chatId = text(body, "chatId", "chat_id");
|
||||
InquiryTicket ticket = findByWorkOrderNo(no);
|
||||
Map<String, Object> out = new HashMap<>();
|
||||
if (ticket == null || StringUtils.isBlank(chatId)) {
|
||||
out.put("ok", false);
|
||||
out.put("error", "ticket_or_chat_missing");
|
||||
return out;
|
||||
}
|
||||
ticket.setCollabChatId(chatId);
|
||||
ticket.setCollabMemberIds(memberIdsJson(body));
|
||||
ticket.setUpdateTime(new Date());
|
||||
updateById(ticket);
|
||||
appendLog(ticket, ticket.getStatus(), ticket.getStatus(), "bind_collab_group", "agent", chatId);
|
||||
out.put("ok", true);
|
||||
out.put("chat_id", chatId);
|
||||
out.put("work_order_no", no);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并协同六字段。只写非空键;不改六态。
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> patchCollabFacts(Map<String, Object> body) {
|
||||
String no = text(body, "workOrderNo", "work_order_no");
|
||||
InquiryTicket ticket = findByWorkOrderNo(no);
|
||||
Map<String, Object> out = new HashMap<>();
|
||||
if (ticket == null) {
|
||||
out.put("ok", false);
|
||||
out.put("error", "ticket_not_found");
|
||||
return out;
|
||||
}
|
||||
Map<String, String> merged = new LinkedHashMap<>();
|
||||
if (StringUtils.isNotBlank(ticket.getCollabFactsJson())) {
|
||||
JSONObject old = JSON.parseObject(ticket.getCollabFactsJson());
|
||||
if (old != null) {
|
||||
for (String key : old.keySet()) {
|
||||
if (old.get(key) != null) {
|
||||
merged.put(key, String.valueOf(old.get(key)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Object raw = body == null ? null : body.get("facts");
|
||||
if (raw instanceof Map) {
|
||||
for (Map.Entry<?, ?> e : ((Map<?, ?>) raw).entrySet()) {
|
||||
if (e.getKey() == null || e.getValue() == null) {
|
||||
continue;
|
||||
}
|
||||
String val = String.valueOf(e.getValue()).trim();
|
||||
if (!val.isEmpty()) {
|
||||
merged.put(String.valueOf(e.getKey()), val);
|
||||
}
|
||||
}
|
||||
}
|
||||
ticket.setCollabFactsJson(JSON.toJSONString(merged));
|
||||
ticket.setUpdateTime(new Date());
|
||||
updateById(ticket);
|
||||
out.put("ok", true);
|
||||
out.put("facts", merged);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按协同群找回工单。群里补字段用。只读。
|
||||
*/
|
||||
@Override
|
||||
public Map<String, Object> findByCollabChat(String chatId) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
String id = StringUtils.trimToEmpty(chatId);
|
||||
out.put("found", false);
|
||||
if (StringUtils.isBlank(id)) {
|
||||
return out;
|
||||
}
|
||||
InquiryTicket ticket = getOne(new LambdaQueryWrapper<InquiryTicket>()
|
||||
.eq(InquiryTicket::getCollabChatId, id)
|
||||
.last("LIMIT 1"), false);
|
||||
if (ticket == null) {
|
||||
return out;
|
||||
}
|
||||
out.put("found", true);
|
||||
out.put("ok", true);
|
||||
out.put("work_order_no", ticket.getWorkOrderNo());
|
||||
out.put("sales_wecom_id", StringUtils.defaultString(ticket.getSalesWecomId()));
|
||||
out.put("salesWecomId", StringUtils.defaultString(ticket.getSalesWecomId()));
|
||||
out.put("collab_chat_id", StringUtils.defaultString(ticket.getCollabChatId()));
|
||||
out.put("collab_member_ids", parseMemberIds(ticket.getCollabMemberIds()));
|
||||
out.put("memberIds", parseMemberIds(ticket.getCollabMemberIds()));
|
||||
Map<String, String> collabFacts = new LinkedHashMap<>();
|
||||
if (StringUtils.isNotBlank(ticket.getCollabFactsJson())) {
|
||||
JSONObject parsed = JSON.parseObject(ticket.getCollabFactsJson());
|
||||
if (parsed != null) {
|
||||
for (String key : parsed.keySet()) {
|
||||
if (parsed.get(key) != null) {
|
||||
collabFacts.put(key, String.valueOf(parsed.get(key)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.put("collab_facts", collabFacts);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String memberIdsJson(Map<String, Object> body) {
|
||||
if (body == null) {
|
||||
return "[]";
|
||||
}
|
||||
Object raw = body.get("memberIds");
|
||||
if (raw == null) {
|
||||
raw = body.get("member_ids");
|
||||
}
|
||||
if (raw instanceof java.util.Collection) {
|
||||
return JSON.toJSONString(raw);
|
||||
}
|
||||
if (raw instanceof String && StringUtils.isNotBlank((String) raw)) {
|
||||
return ((String) raw).trim();
|
||||
}
|
||||
return "[]";
|
||||
}
|
||||
|
||||
private static List<String> parseMemberIds(String raw) {
|
||||
List<String> ids = new ArrayList<>();
|
||||
if (StringUtils.isBlank(raw)) {
|
||||
return ids;
|
||||
}
|
||||
try {
|
||||
com.alibaba.fastjson.JSONArray arr = JSON.parseArray(raw);
|
||||
if (arr != null) {
|
||||
for (int i = 0; i < arr.size(); i++) {
|
||||
String uid = StringUtils.trimToEmpty(arr.getString(i));
|
||||
if (!uid.isEmpty()) {
|
||||
ids.add(uid);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
} catch (Exception ignore) {
|
||||
// 兼容逗号分隔旧值
|
||||
}
|
||||
for (String part : raw.split("[,;]")) {
|
||||
String uid = StringUtils.trimToEmpty(part);
|
||||
if (!uid.isEmpty()) {
|
||||
ids.add(uid);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private static String text(Map<String, Object> body, String... keys) {
|
||||
if (body == null || keys == null) {
|
||||
return "";
|
||||
}
|
||||
for (String key : keys) {
|
||||
Object val = body.get(key);
|
||||
if (val != null && StringUtils.isNotBlank(String.valueOf(val))) {
|
||||
return String.valueOf(val).trim();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private InquiryTicket findByWorkOrderNo(String workOrderNo) {
|
||||
return getOne(new LambdaQueryWrapper<InquiryTicket>()
|
||||
.eq(InquiryTicket::getWorkOrderNo, workOrderNo)
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package org.jeecg.modules.inquiry.staff;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 海运拉群人员匹配:只看员工管理里岗位=产品·海运且启用的人。
|
||||
* <p>
|
||||
* 「海运·全部线路 / 海运全线」必拉;写了具体港口的,对得上本单起运港或目的港才拉。
|
||||
* 禁止:按销售 userid 伪造名单;不查企微通讯录。
|
||||
*/
|
||||
public final class SeaStaffMatcher {
|
||||
|
||||
private SeaStaffMatcher() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param rows 员工岗位行
|
||||
* @param origin 本单起运港
|
||||
* @param destination 本单目的港
|
||||
* @return 给智能体的 {name, wecomId, routes}
|
||||
*/
|
||||
public static List<Map<String, Object>> match(List<InquiryStaffRole> rows, String origin, String destination) {
|
||||
List<Map<String, Object>> hits = new ArrayList<>();
|
||||
if (rows == null) {
|
||||
return hits;
|
||||
}
|
||||
String from = StringUtils.trimToEmpty(origin);
|
||||
String to = StringUtils.trimToEmpty(destination);
|
||||
for (InquiryStaffRole row : rows) {
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
if (!"sea".equalsIgnoreCase(StringUtils.trimToEmpty(row.getRoleCode()))) {
|
||||
continue;
|
||||
}
|
||||
if (!"active".equalsIgnoreCase(StringUtils.defaultIfBlank(row.getStatus(), "active"))) {
|
||||
continue;
|
||||
}
|
||||
if (StringUtils.isBlank(row.getWecomId())) {
|
||||
continue;
|
||||
}
|
||||
String routes = StringUtils.defaultString(row.getRoutes());
|
||||
if (routes.contains("海运·全部线路") || routes.contains("海运全线")
|
||||
|| (StringUtils.isNotBlank(from) && routes.contains(from))
|
||||
|| (StringUtils.isNotBlank(to) && routes.contains(to))) {
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("name", StringUtils.defaultString(row.getName()));
|
||||
item.put("wecomId", row.getWecomId().trim());
|
||||
item.put("roleCode", "sea");
|
||||
item.put("routes", routes);
|
||||
item.put("status", "active");
|
||||
hits.add(item);
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
}
|
||||
+7
-1
@@ -130,6 +130,10 @@ public final class QuoteTemplateMatcher {
|
||||
|
||||
/**
|
||||
* 模板 keywordsJson / 列表字段转关键词;空串不参与匹配。
|
||||
* <p>
|
||||
* 后台/seed 偶发把未加引号的 {@code [海运,整柜,FCL]} 整段塞进列表一项,
|
||||
* 或 JSON 解析失败后把原文当成单个词。这两种都要摊开,否则「海运」对不上、
|
||||
* 又没设默认模板时,销售会看到固定失败话术。
|
||||
*/
|
||||
public static List<String> asKeywords(Object raw) {
|
||||
List<String> out = new ArrayList<>();
|
||||
@@ -139,7 +143,9 @@ public final class QuoteTemplateMatcher {
|
||||
if (raw instanceof Collection) {
|
||||
for (Object it : (Collection<?>) raw) {
|
||||
String s = it == null ? "" : String.valueOf(it).trim();
|
||||
if (StringUtils.isNotBlank(s)) {
|
||||
if (s.startsWith("[")) {
|
||||
out.addAll(asKeywords(s));
|
||||
} else if (StringUtils.isNotBlank(s)) {
|
||||
out.add(s);
|
||||
}
|
||||
}
|
||||
|
||||
+133
-10
@@ -1,6 +1,8 @@
|
||||
package org.jeecg.modules.inquiry.tms;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.jeecg.modules.inquiry.domain.TicketStatuses;
|
||||
@@ -25,8 +27,9 @@ import java.util.TimeZone;
|
||||
/**
|
||||
* 智能体查价门面:询价必填齐了就必须打真 TMS,再按 TMS 回包分类。
|
||||
* <p>
|
||||
* 职责:销售侧起运/目的原文、品名、件数、重量、体积、包装、报价日期、工单号齐了就出站。
|
||||
* 职责:询价必填齐了就出站。空运看件数/重量/体积/包装;海运看货量/整柜或拼柜/箱型箱量,不拦空运量尺。
|
||||
* 机场三字码能投影就带上;对不上也必须出站,禁止本地判「暂无报价」。
|
||||
* 业务线以工单为准,禁止海运请求被默认成 AIR。
|
||||
* 分类:1 且有价 → 报价;TMS 已回 1002 / 无 quoteFound / 1003 → 暂无报价;
|
||||
* HTTP 超时/熔断/5xx → 技术失败。
|
||||
* 禁止:因 originCode 对不上就 skip;fixture 假价;LOCK/RELEASE;Python 直连 TMS。
|
||||
@@ -53,8 +56,18 @@ public class InquiryTmsQueryService {
|
||||
*/
|
||||
public Map<String, Object> queryFromAgent(Map<String, Object> body) {
|
||||
Map<String, Object> facts = extractFacts(body);
|
||||
String workOrderNo = text(first(body.get("work_order_no"), facts.get("work_order_no")));
|
||||
String line = text(first(facts.get("业务线"), facts.get("businessLine"), "AIR")).toUpperCase();
|
||||
String workOrderNo = text(first(
|
||||
body == null ? null : body.get("work_order_no"),
|
||||
body == null ? null : body.get("workOrderNo"),
|
||||
facts.get("work_order_no")));
|
||||
InquiryTicket ticket = findTicket(workOrderNo);
|
||||
if (ticket != null) {
|
||||
mergeTicketFacts(facts, ticket);
|
||||
}
|
||||
String line = resolveTransportLine(
|
||||
body,
|
||||
facts,
|
||||
ticket == null ? "" : ticket.getBusinessLine());
|
||||
|
||||
if (!properties.queryConfigured() || !gateway.isConfigured()) {
|
||||
return tech("TMS 未配置,拒绝假价");
|
||||
@@ -68,12 +81,23 @@ public class InquiryTmsQueryService {
|
||||
segment.put("origin", origin);
|
||||
segment.put("destination", destination);
|
||||
segment.put("cargoName", text(facts.get("品名")));
|
||||
segment.put("quantity", TmsV2RequestMapper.asInteger(facts.get("件数")));
|
||||
segment.put("weightKg", TmsV2RequestMapper.asPositiveDecimal(facts.get("毛重")));
|
||||
segment.put("volumeCbm", TmsV2RequestMapper.asPositiveDecimal(facts.get("体积")));
|
||||
fillAirportCode(segment, "originCode", text(first(facts.get("originCode"), origin)));
|
||||
fillAirportCode(segment, "destinationCode", text(first(facts.get("destinationCode"), destination)));
|
||||
fillPackageType(segment, text(first(facts.get("packageType"), facts.get("包装方式"), facts.get("包装类型"))));
|
||||
if ("SEA".equals(line)) {
|
||||
// 海运不拦空运件数/重量/体积/包装;港口只带名称,没有港口字典也不跳过。
|
||||
segment.put("cargoQty", first(facts.get("货量"), facts.get("货物数量")));
|
||||
segment.put("loadType", facts.get("整柜或拼柜"));
|
||||
segment.put("containerSpec", facts.get("箱型箱量"));
|
||||
Object items = facts.get("containerItems");
|
||||
if (items != null) {
|
||||
segment.put("containerItems", items);
|
||||
}
|
||||
} else {
|
||||
segment.put("quantity", TmsV2RequestMapper.asInteger(facts.get("件数")));
|
||||
segment.put("weightKg", TmsV2RequestMapper.asPositiveDecimal(facts.get("毛重")));
|
||||
segment.put("volumeCbm", TmsV2RequestMapper.asPositiveDecimal(facts.get("体积")));
|
||||
fillAirportCode(segment, "originCode", text(first(facts.get("originCode"), origin)));
|
||||
fillAirportCode(segment, "destinationCode", text(first(facts.get("destinationCode"), destination)));
|
||||
fillPackageType(segment, text(first(facts.get("packageType"), facts.get("包装方式"), facts.get("包装类型"))));
|
||||
}
|
||||
|
||||
String quoteDate = TmsV2RequestMapper.normalizeQuoteDate(facts.get("报价日期"));
|
||||
if (quoteDate == null) {
|
||||
@@ -82,7 +106,9 @@ public class InquiryTmsQueryService {
|
||||
quoteDate = fmt.format(new Date());
|
||||
}
|
||||
|
||||
List<String> missing = missingAirQuery(workOrderNo, quoteDate, segment);
|
||||
List<String> missing = "SEA".equals(line)
|
||||
? missingSeaQuery(workOrderNo, quoteDate, segment, facts)
|
||||
: missingAirQuery(workOrderNo, quoteDate, segment);
|
||||
if (!missing.isEmpty()) {
|
||||
log.warn("tms.query skip incomplete wo={} missing={}", workOrderNo, missing);
|
||||
return incomplete(missing);
|
||||
@@ -362,6 +388,40 @@ public class InquiryTmsQueryService {
|
||||
* 出站前只拦销售侧必填真缺。originCode / destinationCode 不是拦网:
|
||||
* 对不上机场也要查,TMS 自己回无价或 1003。
|
||||
*/
|
||||
/**
|
||||
* 海运出站必填:工单号、报价日期、起运/目的名称、品名、货量、整柜或拼柜、箱型箱量。
|
||||
* 不要求机场三字码、件数、重量、体积、包装。
|
||||
*/
|
||||
private static List<String> missingSeaQuery(String workOrderNo, String quoteDate,
|
||||
Map<String, Object> segment, Map<String, Object> facts) {
|
||||
List<String> missing = new ArrayList<>();
|
||||
if (workOrderNo == null || !workOrderNo.matches("WO\\d{12}")) {
|
||||
missing.add("workOrderNo");
|
||||
}
|
||||
if (quoteDate == null || quoteDate.isEmpty()) {
|
||||
missing.add("quoteDate");
|
||||
}
|
||||
for (String key : new String[] {"origin", "destination", "cargoName"}) {
|
||||
if (segment.get(key) == null || "".equals(segment.get(key))) {
|
||||
missing.add(key);
|
||||
}
|
||||
}
|
||||
if (blank(facts.get("货量")) && blank(facts.get("货物数量"))) {
|
||||
missing.add("货量");
|
||||
}
|
||||
if (blank(facts.get("整柜或拼柜"))) {
|
||||
missing.add("整柜或拼柜");
|
||||
}
|
||||
if (blank(facts.get("箱型箱量"))) {
|
||||
missing.add("箱型箱量");
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
private static boolean blank(Object raw) {
|
||||
return raw == null || String.valueOf(raw).trim().isEmpty();
|
||||
}
|
||||
|
||||
private static List<String> missingAirQuery(String workOrderNo, String quoteDate, Map<String, Object> segment) {
|
||||
List<String> missing = new ArrayList<>();
|
||||
if (workOrderNo == null || !workOrderNo.matches("WO\\d{12}")) {
|
||||
@@ -439,6 +499,69 @@ public class InquiryTmsQueryService {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务线:请求体 / 字段快照 / 工单,禁止无业务线时把海运当成空运拦网。
|
||||
*/
|
||||
static String resolveTransportLine(Map<String, Object> body, Map<String, Object> facts, String ticketLine) {
|
||||
String line = text(first(
|
||||
body == null ? null : body.get("businessLine"),
|
||||
body == null ? null : body.get("business_line"),
|
||||
facts == null ? null : facts.get("业务线"),
|
||||
facts == null ? null : facts.get("businessLine"),
|
||||
facts == null ? null : facts.get("business_line"),
|
||||
ticketLine
|
||||
)).toUpperCase();
|
||||
if ("SEA".equals(line) || "AIR".equals(line) || "LAND".equals(line)) {
|
||||
return line;
|
||||
}
|
||||
return "AIR";
|
||||
}
|
||||
|
||||
private InquiryTicket findTicket(String workOrderNo) {
|
||||
if (StringUtils.isBlank(workOrderNo) || workOrderService == null) {
|
||||
return null;
|
||||
}
|
||||
return workOrderService.getOne(new LambdaQueryWrapper<InquiryTicket>()
|
||||
.eq(InquiryTicket::getWorkOrderNo, workOrderNo)
|
||||
.last("LIMIT 1"), false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 工单已落库的海运事实补进查价 facts,避免智能体漏传货量/箱型。
|
||||
*/
|
||||
static void mergeTicketFacts(Map<String, Object> facts, InquiryTicket ticket) {
|
||||
if (facts == null || ticket == null) {
|
||||
return;
|
||||
}
|
||||
putIfBlank(facts, "起运港", ticket.getOrigin());
|
||||
putIfBlank(facts, "目的港", ticket.getDestination());
|
||||
putIfBlank(facts, "品名", ticket.getGoodsName());
|
||||
putIfBlank(facts, "货量", ticket.getCargoQty());
|
||||
putIfBlank(facts, "整柜或拼柜", ticket.getFclLcl());
|
||||
putIfBlank(facts, "箱型箱量", ticket.getContainerSpec());
|
||||
if (ticket.getQuoteDate() != null && blank(facts.get("报价日期"))) {
|
||||
SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd");
|
||||
fmt.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai"));
|
||||
facts.put("报价日期", fmt.format(ticket.getQuoteDate()));
|
||||
}
|
||||
if (StringUtils.isNotBlank(ticket.getFactsJson())) {
|
||||
JSONObject stored = JSON.parseObject(ticket.getFactsJson());
|
||||
if (stored != null) {
|
||||
for (String key : stored.keySet()) {
|
||||
if (blank(facts.get(key)) && stored.get(key) != null) {
|
||||
facts.put(key, stored.get(key));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void putIfBlank(Map<String, Object> facts, String key, String val) {
|
||||
if (blank(facts.get(key)) && StringUtils.isNotBlank(val)) {
|
||||
facts.put(key, val);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Map<String, Object> extractFacts(Map<String, Object> body) {
|
||||
if (body == null) {
|
||||
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
package org.jeecg.modules.inquiry.tms;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 海运出站前把销售口语整理成 TMS V2 字段。
|
||||
* <p>
|
||||
* 对照接口文档 6.3:FCL 必须带 containerType + containerQty(如 40HQ / 1),
|
||||
* loadType 只能是 FCL/LCL,cargoQty 必须是数字。销售卡片仍看原文「整柜 / 1x40HQ / 15件」。
|
||||
* 禁止:把口语原样扔给 TMS;在这里猜港口。
|
||||
*/
|
||||
public final class SeaTmsFieldNormalizer {
|
||||
|
||||
/** 1x40HQ / 2*40高柜 */
|
||||
private static final Pattern QTY_X_TYPE = Pattern.compile(
|
||||
"(?i)(\\d+)\\s*[xX×*]\\s*(20|40|45)\\s*['\"′尺]?(HQ|GP|NOR|RH|HC|高柜|平柜|柜)?");
|
||||
|
||||
/** 40HQ*2 */
|
||||
private static final Pattern TYPE_X_QTY = Pattern.compile(
|
||||
"(?i)(20|40|45)\\s*['\"′尺]?(HQ|GP|NOR|RH|HC|高柜|平柜)?\\s*[xX×*]\\s*(\\d+)");
|
||||
|
||||
/** 2个40尺高柜 */
|
||||
private static final Pattern CN_GE = Pattern.compile(
|
||||
"(?i)(\\d+)\\s*个\\s*(20|40|45)\\s*['\"′尺]?(HQ|GP|高柜|平柜|柜)?");
|
||||
|
||||
/** 单独写 40HQ / 40高柜,箱量按 1 */
|
||||
private static final Pattern BARE_TYPE = Pattern.compile(
|
||||
"(?i)(20|40|45)\\s*['\"′尺]?(HQ|GP|NOR|RH|HC|高柜|平柜)");
|
||||
|
||||
private SeaTmsFieldNormalizer() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 整柜/拼柜 → FCL/LCL。已经是 FCL/LCL 的原样(大写)。
|
||||
*/
|
||||
public static String loadType(Object raw) {
|
||||
if (raw == null) {
|
||||
return null;
|
||||
}
|
||||
String text = String.valueOf(raw).trim();
|
||||
if (text.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
String upper = text.toUpperCase(Locale.ROOT);
|
||||
if ("FCL".equals(upper) || text.contains("整柜") || text.contains("整箱")) {
|
||||
return "FCL";
|
||||
}
|
||||
if ("LCL".equals(upper) || text.contains("拼柜") || text.contains("拼箱")) {
|
||||
return "LCL";
|
||||
}
|
||||
return upper;
|
||||
}
|
||||
|
||||
/**
|
||||
* 货物数量:15件 / 20吨 → 15 / 20。带单位会 1001。
|
||||
*/
|
||||
public static BigDecimal cargoQty(Object raw) {
|
||||
return TmsV2RequestMapper.asPositiveDecimal(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把「1x40HQ」「2个40尺高柜」「1*20GP+1*40HQ」拆成 TMS 箱型列表。
|
||||
*
|
||||
* @return 每项含 containerType、containerQty;拆不出则空列表,不编造。
|
||||
*/
|
||||
public static List<Map<String, Object>> parseContainers(Object raw) {
|
||||
List<Map<String, Object>> items = new ArrayList<>();
|
||||
if (raw == null) {
|
||||
return items;
|
||||
}
|
||||
String text = String.valueOf(raw).trim();
|
||||
if (text.isEmpty()) {
|
||||
return items;
|
||||
}
|
||||
collect(items, QTY_X_TYPE.matcher(text), 1, 2, 3);
|
||||
if (!items.isEmpty()) {
|
||||
return items;
|
||||
}
|
||||
Matcher typeFirst = TYPE_X_QTY.matcher(text);
|
||||
while (typeFirst.find()) {
|
||||
addItem(items, typeFirst.group(1), typeFirst.group(2), typeFirst.group(3));
|
||||
}
|
||||
if (!items.isEmpty()) {
|
||||
return items;
|
||||
}
|
||||
collect(items, CN_GE.matcher(text), 1, 2, 3);
|
||||
if (!items.isEmpty()) {
|
||||
return items;
|
||||
}
|
||||
Matcher bare = BARE_TYPE.matcher(text);
|
||||
if (bare.find()) {
|
||||
addItem(items, bare.group(1), bare.group(2), "1");
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
private static void collect(List<Map<String, Object>> items, Matcher matcher,
|
||||
int qtyGroup, int sizeGroup, int suffixGroup) {
|
||||
while (matcher.find()) {
|
||||
addItem(items, matcher.group(sizeGroup), matcher.group(suffixGroup), matcher.group(qtyGroup));
|
||||
}
|
||||
}
|
||||
|
||||
private static void addItem(List<Map<String, Object>> items, String size, String suffix, String qty) {
|
||||
String type = toTmsType(size, suffix);
|
||||
Integer count = TmsV2RequestMapper.asInteger(qty);
|
||||
if (type == null || count == null || count < 1) {
|
||||
return;
|
||||
}
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("containerType", type);
|
||||
row.put("containerQty", count);
|
||||
items.add(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* 口语箱型落到 TMS 码:40HQ / 40GP / 20GP / 45HQ。
|
||||
*/
|
||||
static String toTmsType(String size, String suffix) {
|
||||
if (size == null) {
|
||||
return null;
|
||||
}
|
||||
String foot = size.trim();
|
||||
String tag = suffix == null ? "" : suffix.trim().toUpperCase(Locale.ROOT);
|
||||
if (tag.contains("高") || "HQ".equals(tag) || "HC".equals(tag)) {
|
||||
return foot + "HQ";
|
||||
}
|
||||
if (tag.contains("NOR")) {
|
||||
return foot + "NOR";
|
||||
}
|
||||
if (tag.contains("RH")) {
|
||||
return foot + "RH";
|
||||
}
|
||||
if (tag.contains("平") || "GP".equals(tag) || "柜".equals(suffix) || tag.isEmpty()) {
|
||||
return foot + "GP";
|
||||
}
|
||||
return foot + tag;
|
||||
}
|
||||
}
|
||||
+35
@@ -15,6 +15,7 @@ import java.util.regex.Pattern;
|
||||
* 对照《友通达询价智能体TMS系统对接开发接口文档V1.0.1》5.2 / 2.1 / 6.3:
|
||||
* quantity 必须是 JSON 整数;weightKg / volumeCbm 必须是 JSON 小数且大于 0;
|
||||
* 带「KGS」「CBM」的字符串会触发业务码 1001(参数格式错误)。
|
||||
* 海运同样:整柜/1x40HQ/15件 必须先整理成 FCL + 40HQ + 1 + 数字货量,否则 TMS 回 1001。
|
||||
* 报价日期:文档漏了该请求字段,实际 TMS 使用 quoteDate,格式 yyyy-MM-dd(如 2026-09-07)。
|
||||
* 禁止:inquiryTime=此刻(会查成无匹配);在这里做字典模糊匹配或猜测机场代码。
|
||||
*/
|
||||
@@ -76,9 +77,43 @@ public final class TmsV2RequestMapper {
|
||||
put(target, "weightKg", asPositiveDecimal(first(source, "weightKg")));
|
||||
put(target, "volumeCbm", asPositiveDecimal(first(source, "volumeCbm")));
|
||||
put(target, "packageType", first(source, "packageType", "packagingType"));
|
||||
// 海运出站:口语必须先整理。FCL 要 containerType+containerQty,loadType 只能 FCL/LCL,
|
||||
// cargoQty 只能是数字。销售侧「整柜 / 1x40HQ / 15件」原样留在工单,不在这里改。
|
||||
applySeaFields(source, target);
|
||||
return compact(target);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把海运口语字段写成 TMS 查询字段。空运段没有这些键则不写。
|
||||
*/
|
||||
private static void applySeaFields(Map<String, Object> source, Map<String, Object> target) {
|
||||
Object cargoQty = first(source, "cargoQty", "货量");
|
||||
Object loadRaw = first(source, "loadType", "整柜或拼柜");
|
||||
Object spec = first(source, "containerSpec", "箱型箱量", "containerTypeQty");
|
||||
if (cargoQty == null && loadRaw == null && spec == null
|
||||
&& first(source, "containerType") == null
|
||||
&& first(source, "containerItems") == null) {
|
||||
return;
|
||||
}
|
||||
put(target, "cargoQty", SeaTmsFieldNormalizer.cargoQty(cargoQty));
|
||||
put(target, "loadType", SeaTmsFieldNormalizer.loadType(loadRaw));
|
||||
Object readyItems = first(source, "containerItems");
|
||||
List<Map<String, Object>> items = SeaTmsFieldNormalizer.parseContainers(spec);
|
||||
if (readyItems instanceof List && !((List<?>) readyItems).isEmpty()) {
|
||||
put(target, "containerItems", readyItems);
|
||||
} else if (items.size() > 1) {
|
||||
put(target, "containerItems", items);
|
||||
}
|
||||
Object type = first(source, "containerType");
|
||||
Object qty = first(source, "containerQty");
|
||||
if (type == null && !items.isEmpty()) {
|
||||
type = items.get(0).get("containerType");
|
||||
qty = items.get(0).get("containerQty");
|
||||
}
|
||||
put(target, "containerType", type);
|
||||
put(target, "containerQty", asInteger(qty));
|
||||
}
|
||||
|
||||
/**
|
||||
* 报价日期规范成 yyyy-MM-dd。解析不了则不传,避免 1001。
|
||||
*/
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package org.jeecg.modules.inquiry.staff;
|
||||
|
||||
import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* 海运拉群人员匹配:不连库、不打企微。
|
||||
* <p>
|
||||
* 「海运·全部线路」必拉;写了具体港口的,对得上本单起运/目的才拉;停用不拉。
|
||||
*/
|
||||
public class SeaStaffMatcherTest {
|
||||
|
||||
@Test
|
||||
public void allRoutesMustPull() {
|
||||
InquiryStaffRole row = staff("sea", "active", "p1", "张产品", "海运·全部线路");
|
||||
List<Map<String, Object>> hits = SeaStaffMatcher.match(
|
||||
Arrays.asList(row), "上海", "洛杉矶");
|
||||
assertEquals(1, hits.size());
|
||||
assertEquals("p1", hits.get(0).get("wecomId"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void specificPortPullsWhenMatch() {
|
||||
InquiryStaffRole row = staff("sea", "active", "p2", "李产品", "上海,宁波");
|
||||
List<Map<String, Object>> hits = SeaStaffMatcher.match(
|
||||
Arrays.asList(row), "上海", "汉堡");
|
||||
assertEquals(1, hits.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void specificPortSkipsWhenMismatch() {
|
||||
InquiryStaffRole row = staff("sea", "active", "p3", "王产品", "深圳");
|
||||
List<Map<String, Object>> hits = SeaStaffMatcher.match(
|
||||
Arrays.asList(row), "上海", "洛杉矶");
|
||||
assertTrue(hits.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void disabledOrWrongRoleSkipped() {
|
||||
InquiryStaffRole disabled = staff("sea", "disabled", "p4", "停用", "海运·全部线路");
|
||||
InquiryStaffRole air = staff("air", "active", "p5", "空运岗", "海运·全部线路");
|
||||
List<Map<String, Object>> hits = SeaStaffMatcher.match(
|
||||
Arrays.asList(disabled, air), "上海", "洛杉矶");
|
||||
assertTrue(hits.isEmpty());
|
||||
}
|
||||
|
||||
private static InquiryStaffRole staff(String role, String status, String wecomId,
|
||||
String name, String routes) {
|
||||
InquiryStaffRole row = new InquiryStaffRole();
|
||||
row.setRoleCode(role);
|
||||
row.setStatus(status);
|
||||
row.setWecomId(wecomId);
|
||||
row.setName(name);
|
||||
row.setRoutes(routes);
|
||||
return row;
|
||||
}
|
||||
}
|
||||
+31
@@ -51,6 +51,37 @@ public class QuoteTemplateMatcherTest {
|
||||
assertNull(QuoteTemplateMatcher.pick(list, "AIR", facts, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void brokenBracketKeywordStillHitsSea() {
|
||||
List<Map<String, Object>> list = new ArrayList<>();
|
||||
Map<String, Object> sea = new HashMap<>();
|
||||
sea.put("name", "海运报价单模板");
|
||||
sea.put("bizType", "海运");
|
||||
sea.put("isDefault", false);
|
||||
List<String> broken = new ArrayList<>();
|
||||
broken.add("[海运,整柜,FCL]");
|
||||
sea.put("keywords", broken);
|
||||
list.add(sea);
|
||||
Map<String, Object> facts = new HashMap<>();
|
||||
facts.put("整柜或拼柜", "整柜");
|
||||
Map<String, Object> hit = QuoteTemplateMatcher.pick(list, "SEA", facts, null);
|
||||
assertNotNull(hit);
|
||||
assertEquals("keyword", hit.get("matchType"));
|
||||
assertEquals("海运报价单模板", hit.get("name"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void seaFallsBackToDefaultWhenKeywordMisses() {
|
||||
List<Map<String, Object>> list = new ArrayList<>();
|
||||
list.add(tpl("海运默认", "海运", true, "洛杉矶"));
|
||||
Map<String, Object> facts = new HashMap<>();
|
||||
facts.put("目的港", "纽约港");
|
||||
Map<String, Object> hit = QuoteTemplateMatcher.pick(list, "SEA", facts, null);
|
||||
assertNotNull(hit);
|
||||
assertEquals("default", hit.get("matchType"));
|
||||
assertEquals("海运默认", hit.get("name"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void seaTemplateIgnoredForAir() {
|
||||
List<Map<String, Object>> list = new ArrayList<>();
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package org.jeecg.modules.inquiry.tms;
|
||||
|
||||
import org.jeecg.modules.inquiry.entity.InquiryTicket;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* 海运查价身份:不打真实 TMS。
|
||||
* <p>
|
||||
* 工单已是 SEA 时,即使请求没带业务线,也不能默认成 AIR。
|
||||
*/
|
||||
public class InquiryTmsQueryServiceTest {
|
||||
|
||||
@Test
|
||||
public void ticketSeaWinsWhenBodyOmitsLine() {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("work_order_no", "WO202609150001");
|
||||
String line = InquiryTmsQueryService.resolveTransportLine(body, new HashMap<>(), "SEA");
|
||||
assertEquals("SEA", line);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bodyBusinessLineWins() {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("businessLine", "SEA");
|
||||
String line = InquiryTmsQueryService.resolveTransportLine(body, new HashMap<>(), "AIR");
|
||||
assertEquals("SEA", line);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mergeTicketFillsSeaFacts() {
|
||||
InquiryTicket ticket = new InquiryTicket();
|
||||
ticket.setOrigin("上海");
|
||||
ticket.setDestination("洛杉矶");
|
||||
ticket.setGoodsName("普货");
|
||||
ticket.setCargoQty("20吨");
|
||||
ticket.setFclLcl("整柜");
|
||||
ticket.setContainerSpec("1x40HQ");
|
||||
Map<String, Object> facts = new HashMap<>();
|
||||
InquiryTmsQueryService.mergeTicketFacts(facts, ticket);
|
||||
assertEquals("上海", facts.get("起运港"));
|
||||
assertEquals("20吨", facts.get("货量"));
|
||||
assertEquals("整柜", facts.get("整柜或拼柜"));
|
||||
assertEquals("1x40HQ", facts.get("箱型箱量"));
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package org.jeecg.modules.inquiry.tms;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* 海运口语 → TMS 字段。不连 TMS。
|
||||
*/
|
||||
public class SeaTmsFieldNormalizerTest {
|
||||
|
||||
@Test
|
||||
public void loadTypeFclLcl() {
|
||||
assertEquals("FCL", SeaTmsFieldNormalizer.loadType("整柜"));
|
||||
assertEquals("LCL", SeaTmsFieldNormalizer.loadType("拼柜"));
|
||||
assertEquals("FCL", SeaTmsFieldNormalizer.loadType("FCL"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cargoQtyStripsUnit() {
|
||||
assertEquals(new BigDecimal("15"), SeaTmsFieldNormalizer.cargoQty("15件"));
|
||||
assertEquals(new BigDecimal("20"), SeaTmsFieldNormalizer.cargoQty("20吨"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parseOneByFortyHq() {
|
||||
List<Map<String, Object>> items = SeaTmsFieldNormalizer.parseContainers("1x40HQ");
|
||||
assertEquals(1, items.size());
|
||||
assertEquals("40HQ", items.get(0).get("containerType"));
|
||||
assertEquals(1, items.get(0).get("containerQty"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parseChineseHighCube() {
|
||||
List<Map<String, Object>> items = SeaTmsFieldNormalizer.parseContainers("2个40尺高柜");
|
||||
assertEquals(1, items.size());
|
||||
assertEquals("40HQ", items.get(0).get("containerType"));
|
||||
assertEquals(2, items.get(0).get("containerQty"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parseTwoTypes() {
|
||||
List<Map<String, Object>> items = SeaTmsFieldNormalizer.parseContainers("1x20GP+1x40HQ");
|
||||
assertEquals(2, items.size());
|
||||
assertEquals("20GP", items.get(0).get("containerType"));
|
||||
assertEquals("40HQ", items.get(1).get("containerType"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unknownTextNotInvented() {
|
||||
assertTrue(SeaTmsFieldNormalizer.parseContainers("随便写").isEmpty());
|
||||
}
|
||||
}
|
||||
+47
@@ -58,4 +58,51 @@ public class TmsV2RequestMapperTest {
|
||||
assertEquals(new java.math.BigDecimal("2"), segs.get(0).get("volumeCbm"));
|
||||
assertFalse(String.valueOf(segs.get(0).get("weightKg")).contains("KGS"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void seaFieldsPassThroughWithoutAirMeasures() {
|
||||
Map<String, Object> seg = new HashMap<>();
|
||||
seg.put("origin", "上海");
|
||||
seg.put("destination", "洛杉矶");
|
||||
seg.put("cargoName", "普货");
|
||||
seg.put("cargoQty", "20吨");
|
||||
seg.put("loadType", "整柜");
|
||||
List<Map<String, Object>> items = Collections.singletonList(
|
||||
Collections.singletonMap("type", "40HQ"));
|
||||
seg.put("containerItems", items);
|
||||
Map<String, Object> internal = new HashMap<>();
|
||||
internal.put("transportMode", "SEA");
|
||||
internal.put("quoteDate", "2026-09-15");
|
||||
internal.put("segments", Collections.singletonList(seg));
|
||||
Map<String, Object> body = TmsV2RequestMapper.quote(internal);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> segs = (List<Map<String, Object>>) body.get("segments");
|
||||
assertEquals(new java.math.BigDecimal("20"), segs.get(0).get("cargoQty"));
|
||||
assertEquals("FCL", segs.get(0).get("loadType"));
|
||||
assertEquals(items, segs.get(0).get("containerItems"));
|
||||
assertFalse(segs.get(0).containsKey("quantity"));
|
||||
assertFalse(segs.get(0).containsKey("weightKg"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void oralSeaSpecBecomesTmsContainerFields() {
|
||||
Map<String, Object> seg = new HashMap<>();
|
||||
seg.put("origin", "上海港");
|
||||
seg.put("destination", "巴生西港");
|
||||
seg.put("cargoName", "机器设备");
|
||||
seg.put("cargoQty", "15件");
|
||||
seg.put("loadType", "整柜");
|
||||
seg.put("containerSpec", "1x40HQ");
|
||||
Map<String, Object> internal = new HashMap<>();
|
||||
internal.put("transportMode", "SEA");
|
||||
internal.put("quoteDate", "2026-09-15");
|
||||
internal.put("segments", Collections.singletonList(seg));
|
||||
Map<String, Object> body = TmsV2RequestMapper.quote(internal);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> segs = (List<Map<String, Object>>) body.get("segments");
|
||||
assertEquals(new java.math.BigDecimal("15"), segs.get(0).get("cargoQty"));
|
||||
assertEquals("FCL", segs.get(0).get("loadType"));
|
||||
assertEquals("40HQ", segs.get(0).get("containerType"));
|
||||
assertEquals(Integer.valueOf(1), segs.get(0).get("containerQty"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user