角色业务范围收紧工单与模板;报价日期不当费用;未选舱不迁已报价;另开一票可清违禁 hold

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-10-15 21:00:01 +08:00
co-authored by Cursor
parent a0c705683f
commit 277cdc1c28
39 changed files with 2083 additions and 217 deletions
@@ -13,6 +13,7 @@ import org.jeecg.common.system.vo.LoginUser;
import org.jeecg.modules.inquiry.entity.InquiryQuoteTemplate;
import org.jeecg.modules.inquiry.entity.InquiryQuoteTemplateVersion;
import org.jeecg.modules.inquiry.service.IInquiryQuoteTemplateService;
import org.jeecg.modules.inquiry.support.InquiryQuoteTemplateBizScope;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
@@ -32,7 +33,8 @@ import java.util.Map;
* 报价模板管理 API。
* <p>
* 路径 /inquiry/quoteTemplate;权限 inquiry:template:*。
* 智能体只读列表走 /agent/listEnabled(服务账号 token)。
* 有菜单权限只表示能进页面。列表按角色业务范围收窄:陆运中港只看陆运模板。
* 智能体只读列表走 /agent/listEnabled(服务账号 token),不按登录人的角色过滤。
* 禁止:在本 Controller 调 LLM;LibreOffice 仅允许预览截图接口短等本机 soffice。
*/
@Slf4j
@@ -48,12 +50,22 @@ public class InquiryQuoteTemplateController {
@ApiOperation("分页列表(含当前版本摘要)")
@RequiresPermissions("inquiry:template:view")
@GetMapping("/list")
public Result<IPage<Map<String, Object>>> list(@RequestParam(required = false) String keyword,
@RequestParam(required = false) String bizType,
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "1") Integer pageNo,
@RequestParam(defaultValue = "20") Integer pageSize) {
return Result.OK(quoteTemplateService.pageWithVersion(keyword, bizType, status, pageNo, pageSize));
public Result<Map<String, Object>> list(@RequestParam(required = false) String keyword,
@RequestParam(required = false) String bizType,
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "1") Integer pageNo,
@RequestParam(defaultValue = "20") Integer pageSize) {
IPage<Map<String, Object>> page = quoteTemplateService.pageWithVersion(
keyword, bizType, status, pageNo, pageSize);
Map<String, Object> body = new HashMap<>();
body.put("records", page.getRecords());
body.put("total", page.getTotal());
body.put("size", page.getSize());
body.put("current", page.getCurrent());
body.put("pages", page.getPages());
// 前端业务下拉只渲染这些项。超管是空运、海运、陆运三项。
body.put("allowedBizTypes", InquiryQuoteTemplateBizScope.allowedLabelsForCurrentUser());
return Result.OK(body);
}
@AutoLog(value = "报价模板-上传版本")
@@ -143,7 +155,11 @@ public class InquiryQuoteTemplateController {
@RequiresPermissions("inquiry:template:view")
@GetMapping("/queryById")
public Result<InquiryQuoteTemplate> queryById(@RequestParam String id) {
return Result.OK(quoteTemplateService.getById(id));
InquiryQuoteTemplate row = quoteTemplateService.getById(id);
if (row != null) {
InquiryQuoteTemplateBizScope.assertBizAllowed(row.getBizType());
}
return Result.OK(row);
}
@ApiOperation("编辑逻辑头(名称/业务/关键词/模式/默认标记,不换文件)")
@@ -157,10 +173,12 @@ public class InquiryQuoteTemplateController {
if (db == null) {
return Result.error("模板不存在");
}
InquiryQuoteTemplateBizScope.assertBizAllowed(db.getBizType());
if (StringUtils.isNotBlank(body.getName())) {
db.setName(body.getName());
}
if (StringUtils.isNotBlank(body.getBizType())) {
InquiryQuoteTemplateBizScope.assertBizAllowed(body.getBizType());
db.setBizType(body.getBizType());
}
if (StringUtils.isNotBlank(body.getKeywordsJson())) {
@@ -14,6 +14,7 @@ import org.jeecg.modules.inquiry.dto.TicketAbnormalRequest;
import org.jeecg.modules.inquiry.dto.TicketTransitionRequest;
import org.jeecg.modules.inquiry.entity.InquiryTicket;
import org.jeecg.modules.inquiry.service.IInquiryWorkOrderService;
import org.jeecg.modules.inquiry.support.InquiryDataScopeSupport;
import org.apache.shiro.authz.annotation.Logical;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@@ -28,6 +29,7 @@ import java.util.Map;
* <p>
* 请求线程只做 DB 事务与校验,不调用 LLM / LibreOffice / Graph。
* 权限:inquiry:workorder:list / transition 等。
* 有菜单权限只表示能进页面。列表、导出、详情再按角色业务范围收窄。
*/
@Slf4j
@Api(tags = "询价-工单")
@@ -42,7 +44,7 @@ public class InquiryWorkOrderController {
@ApiOperation("后台列表")
@RequiresPermissions("inquiry:workorder:list")
@GetMapping("/view/list")
public Result<IPage<InquiryTicket>> viewList(
public Result<Map<String, Object>> viewList(
@RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
@RequestParam(name = "pageSize", defaultValue = "20") Integer pageSize,
HttpServletRequest req) {
@@ -55,13 +57,21 @@ public class InquiryWorkOrderController {
copyParam(req, filters, "systemException");
copyParam(req, filters, "dateFrom");
copyParam(req, filters, "dateTo");
return Result.OK(workOrderService.pageView(new Page<>(pageNo, pageSize), filters));
IPage<InquiryTicket> page = workOrderService.pageView(new Page<>(pageNo, pageSize), filters);
Map<String, Object> body = new HashMap<>();
body.put("records", page.getRecords());
body.put("total", page.getTotal());
body.put("size", page.getSize());
body.put("current", page.getCurrent());
body.put("pages", page.getPages());
body.put("allowedBizTypes", InquiryDataScopeSupport.ticketBizOptionsForCurrentUser());
return Result.OK(body);
}
@ApiOperation("兼容 list 路径")
@RequiresPermissions("inquiry:workorder:list")
@GetMapping("/list")
public Result<IPage<InquiryTicket>> list(
public Result<Map<String, Object>> list(
@RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
@RequestParam(name = "pageSize", defaultValue = "20") Integer pageSize,
HttpServletRequest req) {
@@ -105,7 +115,11 @@ public class InquiryWorkOrderController {
Map<String, Object> detail = workOrderService.detailView(workOrderNo);
return Result.OK((InquiryTicket) detail.get("ticket"));
}
return Result.OK(workOrderService.getById(id));
InquiryTicket row = workOrderService.getById(id);
if (row != null) {
InquiryDataScopeSupport.assertTicketBizAllowed(row);
}
return Result.OK(row);
}
@AutoLog(value = "工单-六态流转")
@@ -90,6 +90,7 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
.ge(InquiryTicket::getCreateTime, from)
.le(InquiryTicket::getCreateTime, to);
InquiryDataScopeSupport.applySelfOnly(qw);
InquiryDataScopeSupport.applyRoleBizLines(qw);
List<InquiryTicket> tickets = ticketMapper.selectList(qw);
long total = tickets.size();
@@ -20,6 +20,7 @@ import org.jeecg.modules.inquiry.mapper.InquiryQuoteTemplateMapper;
import org.jeecg.modules.inquiry.mapper.InquiryQuoteTemplateVersionMapper;
import org.jeecg.modules.inquiry.mapper.InquiryStaffRoleMapper;
import org.jeecg.modules.inquiry.mapper.InquiryTicketMapper;
import org.jeecg.modules.inquiry.support.InquiryQuoteTemplateBizScope;
import org.jeecg.modules.inquiry.support.InquiryTicketDisplaySupport;
import org.jeecg.modules.inquiry.service.IInquiryQuoteTemplateService;
import org.jeecg.modules.inquiry.template.QuoteTemplateAiParseClient;
@@ -98,12 +99,28 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
@Value("${inquiry.quoteTemplate.contractPath:}")
private String contractPath;
/**
* 后台列表按当前登录人的角色收业务线。智能体匹配不走这个方法。
* 角色是「陆运中港」时,海运、空运模板不会出现在这一页。
*/
@Override
public IPage<Map<String, Object>> pageWithVersion(String keyword, String bizType, String status,
int pageNo, int pageSize) {
QueryWrapper<InquiryQuoteTemplate> qw = new QueryWrapper<>();
if (StringUtils.isNotBlank(bizType)) {
qw.eq("biz_type", bizType);
Set<String> allowed = InquiryQuoteTemplateBizScope.currentAllowedOrNull();
String asked = StringUtils.trimToEmpty(bizType);
if (allowed != null) {
if (StringUtils.isNotBlank(asked) && !allowed.contains(asked)) {
qw.apply("1 = 0");
} else if (StringUtils.isNotBlank(asked)) {
qw.eq("biz_type", asked);
} else if (allowed.isEmpty()) {
qw.apply("1 = 0");
} else {
qw.in("biz_type", allowed);
}
} else if (StringUtils.isNotBlank(asked)) {
qw.eq("biz_type", asked);
}
if (StringUtils.isNotBlank(status)) {
qw.eq("status", status);
@@ -179,7 +196,10 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
if (creating) {
template = new InquiryQuoteTemplate();
template.setName(StringUtils.defaultIfBlank(name, stripExt(original)));
template.setBizType(StringUtils.defaultIfBlank(bizType, "海运"));
// 没选业务时历史默认是海运;陆运账号不能因此建出一张海运模板。
String biz = StringUtils.defaultIfBlank(bizType, "海运");
InquiryQuoteTemplateBizScope.assertBizAllowed(biz);
template.setBizType(biz);
template.setKeywordsJson(StringUtils.defaultIfBlank(keywordsJson, "[]"));
template.setMode(StringUtils.defaultIfBlank(mode, "auto"));
template.setStatus("disabled");
@@ -194,10 +214,12 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
if (template == null) {
throw new IllegalArgumentException("模板不存在: " + templateId);
}
InquiryQuoteTemplateBizScope.assertBizAllowed(template.getBizType());
if (StringUtils.isNotBlank(name)) {
template.setName(name);
}
if (StringUtils.isNotBlank(bizType)) {
InquiryQuoteTemplateBizScope.assertBizAllowed(bizType);
template.setBizType(bizType);
}
if (StringUtils.isNotBlank(keywordsJson)) {
@@ -395,13 +417,54 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
return "sea";
}
/**
* 后台查看或修改某张模板前,核对它是不是当前角色能碰的业务。
* 模板已经不存在时不在这里报错,留给原方法返回「模板不存在」。
*/
private void requireTemplateBiz(String templateId) {
if (StringUtils.isBlank(templateId)) {
return;
}
InquiryQuoteTemplate template = getById(templateId);
if (template == null) {
return;
}
InquiryQuoteTemplateBizScope.assertBizAllowed(template.getBizType());
}
/** 按版本找到所属模板,再套同一套业务范围。版本不存在时留给原方法处理。 */
private void requireVersionBiz(String versionId) {
if (StringUtils.isBlank(versionId)) {
return;
}
InquiryQuoteTemplateVersion ver = versionMapper.selectById(versionId);
if (ver == null) {
return;
}
requireTemplateBiz(ver.getTemplateId());
}
/** 纠偏任务跳过越权模板时用;模板没了就当成空业务,有范围的账号不会去改它。 */
private String bizTypeOfTemplate(String templateId) {
if (StringUtils.isBlank(templateId)) {
return "";
}
InquiryQuoteTemplate template = getById(templateId);
return template == null ? "" : StringUtils.defaultString(template.getBizType());
}
@Override
public InquiryQuoteTemplateVersion getVersion(String versionId) {
return versionMapper.selectById(versionId);
InquiryQuoteTemplateVersion ver = versionMapper.selectById(versionId);
if (ver != null) {
requireTemplateBiz(ver.getTemplateId());
}
return ver;
}
@Override
public List<InquiryQuoteTemplateVersion> listVersions(String templateId) {
requireTemplateBiz(templateId);
return versionMapper.selectList(new QueryWrapper<InquiryQuoteTemplateVersion>()
.eq("template_id", templateId)
.orderByDesc("version_no"));
@@ -410,6 +473,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
@Override
@Transactional(rollbackFor = Exception.class)
public void saveMapping(String versionId, String mappingJson, String operator) {
requireVersionBiz(versionId);
InquiryQuoteTemplateVersion ver = versionMapper.selectById(versionId);
if (ver == null) {
throw new IllegalArgumentException("版本不存在");
@@ -451,6 +515,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
@Override
@Transactional(rollbackFor = Exception.class)
public void enableTemplate(String templateId, String versionId, String operator) {
requireTemplateBiz(templateId);
InquiryQuoteTemplate template = getById(templateId);
if (template == null) {
throw new IllegalArgumentException("模板不存在");
@@ -492,6 +557,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
@Override
@Transactional(rollbackFor = Exception.class)
public void disableTemplate(String templateId, String operator) {
requireTemplateBiz(templateId);
InquiryQuoteTemplate template = getById(templateId);
if (template == null) {
throw new IllegalArgumentException("模板不存在");
@@ -511,6 +577,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteTemplate(String templateId, String operator) {
requireTemplateBiz(templateId);
InquiryQuoteTemplate template = getById(templateId);
if (template == null) {
throw new IllegalArgumentException("模板不存在");
@@ -533,6 +600,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
@Override
@Transactional(rollbackFor = Exception.class)
public void setDefault(String templateId, boolean isDefault, String operator) {
requireTemplateBiz(templateId);
InquiryQuoteTemplate template = getById(templateId);
if (template == null) {
throw new IllegalArgumentException("模板不存在");
@@ -559,6 +627,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
@Override
public Path buildSamplePreview(String versionId) throws Exception {
requireVersionBiz(versionId);
InquiryQuoteTemplateVersion ver = versionMapper.selectById(versionId);
if (ver == null) {
throw new IllegalArgumentException("版本不存在");
@@ -594,6 +663,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
*/
@Override
public Map<String, Object> buildSheetPreview(String versionId) throws Exception {
requireVersionBiz(versionId);
InquiryQuoteTemplateVersion ver = versionMapper.selectById(versionId);
if (ver == null) {
throw new IllegalArgumentException("版本不存在");
@@ -625,6 +695,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
*/
@Override
public Path buildSnapshotPreview(String versionId) throws Exception {
requireVersionBiz(versionId);
InquiryQuoteTemplateVersion ver = versionMapper.selectById(versionId);
if (ver == null) {
throw new IllegalArgumentException("版本不存在");
@@ -855,6 +926,11 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
}
src.put("customer_name", customer);
src.put("客户名称", customer);
// 报价单「报价日期」格用工单创建日,不拿询价/TMS 报价日期。
if (ticket != null && ticket.getCreateTime() != null) {
src.put("ticket_create_date",
new java.text.SimpleDateFormat("yyyy-MM-dd").format(ticket.getCreateTime()));
}
return src;
}
@@ -960,9 +1036,32 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
if (StringUtils.isNotBlank(vol)) {
out.put("volume_cbm", vol.replaceAll("(?i)\\s*(CBM|立方米|方)\\s*$", "").trim());
}
// 询价/走货/发货/出货/提货的日期或时间,都是报价日期
out.put("quote_date", first(src, "报价日期", "quote_date", "询价日期", "走货日期", "询价时间",
"走货时间", "发货时间", "发货日期", "出货日期", "出货时间", "提货时间", "提货日期"));
// 报价单「报价日期」格:优先工单创建日(产品口径)。
// 询价事实里的报价日期只给 TMS 查价/锁舱,不填进这张表的日期格。
String created = first(src, "ticket_create_date", "createTime", "create_time");
if (StringUtils.isNotBlank(created)) {
out.put("quote_date", created.length() >= 10 ? created.substring(0, 10) : created);
// #region agent log
try {
java.nio.file.Path dbg = java.nio.file.Paths.get(
"C:/Users/ytd_extdev_0903/incoming/debug-717349.log");
String line = "{\"sessionId\":\"717349\",\"runId\":\"post-fix\",\"hypothesisId\":\"T1\","
+ "\"location\":\"InquiryQuoteTemplateServiceImpl.buildFillFacts\","
+ "\"message\":\"template quote_date from create\","
+ "\"data\":{\"wo\":\"" + StringUtils.defaultString(workOrderNo)
+ "\",\"quote_date\":\"" + out.getString("quote_date")
+ "\",\"fact_quote\":\"" + StringUtils.defaultString(first(src, "报价日期", "quote_date"))
+ "\"},\"timestamp\":" + System.currentTimeMillis() + "}\n";
java.nio.file.Files.write(dbg, line.getBytes(java.nio.charset.StandardCharsets.UTF_8),
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.APPEND);
} catch (Exception ignore) {
// 调试埋点失败不影响出单
}
// #endregion
} else {
out.put("quote_date", first(src, "报价日期", "quote_date", "询价日期", "走货日期", "询价时间",
"走货时间", "发货时间", "发货日期", "出货日期", "出货时间", "提货时间", "提货日期"));
}
out.put("trade_terms", first(src, "贸易条款", "trade_terms"));
out.put("运输分类", first(src, "运输分类"));
out.put("cargo_ready_date", first(src, "货好时间", "cargo_ready_date"));
@@ -1002,6 +1101,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
out.put("oil_elec_mag", oem);
out.put("是否含油,电,磁", oem);
}
// 没有创建日、也没有询价日期时,才退到上海当天,避免格子全空。
if (StringUtils.isBlank(out.getString("quote_date"))) {
out.put("quote_date", new java.text.SimpleDateFormat("yyyy-MM-dd").format(new Date()));
}
@@ -1591,6 +1691,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
*/
@Override
public void reparseLatestIfFailed(String templateId) {
requireTemplateBiz(templateId);
InquiryQuoteTemplateVersion ver = latestExcelVersion(templateId);
if (ver == null || !"failed".equals(ver.getParseStatus())) {
return;
@@ -1603,6 +1704,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
*/
@Override
public String reparseLatest(String templateId) {
requireTemplateBiz(templateId);
InquiryQuoteTemplate template = getById(templateId);
if (template == null) {
throw new IllegalArgumentException("模板不存在");
@@ -1942,6 +2044,9 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
.eq("parse_status", "mapping_incomplete"));
int n = 0;
for (InquiryQuoteTemplateVersion ver : list) {
if (!InquiryQuoteTemplateBizScope.isBizAllowed(bizTypeOfTemplate(ver.getTemplateId()))) {
continue;
}
if (ver.getMappingPointCount() != null && ver.getMappingPointCount() > 0) {
ver.setParseStatus("parsed");
ver.setParseMessage("已解析 " + ver.getMappingPointCount() + " 个映射点");
@@ -148,8 +148,9 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
if (StringUtils.isNotBlank(dateTo)) {
qw.le(InquiryTicket::getCreateTime, dateTo + " 23:59:59");
}
// 与看板同源:账号勾「只看自己」时收紧列表(非超管)
// 与看板同源:只看自己,再按角色收海陆空。陆运中港看不到空运、海运工单。
InquiryDataScopeSupport.applySelfOnly(qw);
InquiryDataScopeSupport.applyRoleBizLines(qw);
qw.orderByDesc(InquiryTicket::getCreateTime);
return qw;
}
@@ -160,6 +161,7 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
if (ticket == null) {
throw new IllegalArgumentException("工单不存在: " + workOrderNo);
}
InquiryDataScopeSupport.assertTicketBizAllowed(ticket);
enrichDisplayFields(Collections.singletonList(ticket));
List<InquiryQuote> quotes = quoteMapper.selectList(new LambdaQueryWrapper<InquiryQuote>()
.eq(InquiryQuote::getWorkOrderNo, workOrderNo)
@@ -210,19 +212,77 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
ticket.getSalesName(), ticket.getSalesWecomId(), staffName));
InquiryTicketDisplaySupport.applyRequirementSummary(ticket);
List<InquiryQuote> quoteRows = quotesByNo.get(ticket.getWorkOrderNo());
InquiryTicketPriceSupport.apply(ticket, quoteRows);
if (quoteRows != null) {
for (InquiryQuote q : quoteRows) {
// 空运未选舱的 TMS 备选:不进后台报价列,也不参与销售价汇总。
List<InquiryQuote> displayRows = filterOutPendingAirCabinQuotes(quoteRows);
InquiryTicketPriceSupport.apply(ticket, displayRows);
if (displayRows != null) {
for (InquiryQuote q : displayRows) {
if (q == null) {
continue;
}
q.setSourceLabel(InquiryQuoteDisplaySupport.displayLabel(q.getSource(), q.getSourceLabel()));
}
}
ticket.setQuotes(quoteRows);
ticket.setQuotes(displayRows);
}
}
/**
* 列表/详情:丢掉空运「待选舱」TMS 行,避免未选舱却显示费用。
* 判定与 InquiryTmsQueryService.isAirCabinPendingSelection 对齐。
*/
static List<InquiryQuote> filterOutPendingAirCabinQuotes(List<InquiryQuote> rows) {
if (rows == null || rows.isEmpty()) {
return rows;
}
List<InquiryQuote> out = new ArrayList<>();
for (InquiryQuote q : rows) {
if (q == null) {
continue;
}
if (isPendingAirCabinQuoteRow(q)) {
continue;
}
out.add(q);
}
return out;
}
static boolean isPendingAirCabinQuoteRow(InquiryQuote q) {
if (q == null || StringUtils.isBlank(q.getPayloadJson())) {
return false;
}
try {
JSONObject obj = JSON.parseObject(q.getPayloadJson());
if (obj == null) {
return false;
}
if ("1".equals(StringUtils.trimToEmpty(obj.getString("pending_cabin_pick")))) {
return true;
}
if (obj.get("selected_air_option") != null || obj.get("selectedAirOption") != null) {
return false;
}
Object raw = obj.get("airOptions");
if (raw == null) {
raw = obj.get("air_options");
}
if (raw instanceof List) {
return !((List<?>) raw).isEmpty();
}
if (raw instanceof String) {
String s = ((String) raw).trim();
if (s.startsWith("[")) {
List<?> parsed = JSON.parseArray(s);
return parsed != null && !parsed.isEmpty();
}
}
} catch (Exception ignored) {
return false;
}
return false;
}
/** 列表一次取出本页工单的报价记录,避免按单 N+1。 */
private Map<String, List<InquiryQuote>> loadQuotesByWorkOrder(List<InquiryTicket> tickets) {
List<String> nos = new ArrayList<>();
@@ -300,6 +360,7 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
if (ticket == null) {
throw new IllegalArgumentException("工单不存在: " + req.getWorkOrderNo());
}
InquiryDataScopeSupport.assertTicketBizAllowed(ticket);
String from = ticket.getStatus();
String to = req.getToStatus().trim();
if (!TicketStatuses.canTransit(from, to)) {
@@ -529,16 +590,18 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
throw new IllegalArgumentException("salesWecomId 与 businessLine 不能为空");
}
// 每次出询价确认都新开一单,相同需求不复用未关闭工单。
Map<String, String> facts = req.getFacts() == null ? new HashMap<>() : req.getFacts();
Map<String, String> facts = scrubPlaceholderFacts(
req.getFacts() == null ? new HashMap<>() : new HashMap<>(req.getFacts()));
InquiryTicket ticket = new InquiryTicket();
ticket.setWorkOrderNo(nextWorkOrderNo());
ticket.setSalesWecomId(req.getSalesWecomId());
ticket.setSalesName(resolveStaffDisplayName(req.getSalesWecomId()));
ticket.setBusinessLine(req.getBusinessLine().trim().toUpperCase());
ticket.setBizTypeLabel(bizLabel(ticket.getBusinessLine()));
ticket.setOrigin(facts.get("起运港"));
ticket.setDestination(facts.get("目的港"));
ticket.setGoodsName(facts.get("品名"));
// 摘要占位「-」不落库:空运不起运地应空串出站,横杠会骗 TMS 回 1003。
ticket.setOrigin(placeOrNull(facts.get("起运港")));
ticket.setDestination(placeOrNull(facts.get("目的港")));
ticket.setGoodsName(placeOrNull(facts.get("品名")));
ticket.setPieces(parseInt(facts.get("件数")));
ticket.setWeightKg(parseDecimal(firstFact(facts, "重量", "毛重")));
// 体积常写尺寸 1.1*1.2*3,必须相乘;旧解析剥 * 会变成 1.11.23 直接 NumberFormat 炸建单。
@@ -548,7 +611,9 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
ticket.setFclLcl(StringUtils.trimToNull(facts.get("整柜或拼柜")));
ticket.setContainerSpec(StringUtils.trimToNull(facts.get("箱型箱量")));
ticket.setFactsJson(JSON.toJSONString(facts));
ticket.setDemandTitle(ticket.getOrigin() + " → " + ticket.getDestination());
String originTitle = StringUtils.defaultString(ticket.getOrigin());
String destTitle = StringUtils.defaultString(ticket.getDestination());
ticket.setDemandTitle(originTitle + " → " + destTitle);
ticket.setDemandDetail("FP:" + fingerprint(req));
ticket.setStatus(TicketStatuses.INQUIRING);
ticket.setVersion(1);
@@ -651,7 +716,9 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
Object raw = body == null ? null : body.get("quote");
@SuppressWarnings("unchecked")
Map<String, Object> quote = raw instanceof Map ? (Map<String, Object>) raw : new LinkedHashMap<String, Object>();
return tmsQueryService.replaceTmsQuote(no, quote);
// 智能体可显式传 toStatus=询价中:空运未选舱只落备选,不迁已报价。
String toStatus = text(body, "toStatus", "to_status");
return tmsQueryService.replaceTmsQuote(no, quote, toStatus);
}
private String nextWorkOrderNo() {
@@ -698,13 +765,49 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
}
for (String key : keys) {
String val = facts.get(key);
if (StringUtils.isNotBlank(val)) {
if (StringUtils.isNotBlank(val) && !isPlaceholderDash(val)) {
return val;
}
}
return "";
}
/**
* 摘要占位横杠不当地名落库。
*/
private static boolean isPlaceholderDash(String raw) {
if (StringUtils.isBlank(raw)) {
return false;
}
String t = raw.trim();
return "-".equals(t) || "—".equals(t) || "-".equals(t);
}
private static String placeOrNull(String raw) {
if (StringUtils.isBlank(raw) || isPlaceholderDash(raw)) {
return null;
}
return raw.trim();
}
/**
* 建单前清掉 facts 里的占位横杠,避免 factsJson / 起运港列写进「-」。
*/
static Map<String, String> scrubPlaceholderFacts(Map<String, String> facts) {
if (facts == null || facts.isEmpty()) {
return facts == null ? new HashMap<>() : facts;
}
for (String key : new String[] {
"起运港", "起运地", "目的港", "目的地", "品名", "包装方式", "包装类型", "报价日期"
}) {
String val = facts.get(key);
if (val != null && isPlaceholderDash(val)) {
facts.remove(key);
}
}
return facts;
}
private static Integer parseInt(String raw) {
if (StringUtils.isBlank(raw)) {
return null;
@@ -755,6 +858,7 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
if (ticket == null) {
throw new IllegalArgumentException("工单不存在: " + req.getWorkOrderNo());
}
InquiryDataScopeSupport.assertTicketBizAllowed(ticket);
return ticket;
}
@@ -3,15 +3,21 @@ package org.jeecg.modules.inquiry.support;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.SecurityUtils;
import org.jeecg.common.exception.JeecgBootException;
import org.jeecg.common.system.vo.LoginUser;
import org.jeecg.modules.inquiry.entity.InquiryTicket;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
/**
* 后台数据范围:账号「只看自己数据」时收紧工单查询。
* 后台数据范围:账号「只看自己数据」,以及角色业务范围。
* <p>
* 口径:非超管且 {@code dashboardSelfOnly=true} →
* 只看自己:非超管且 {@code dashboardSelfOnly=true} →
* {@code salesWecomId = 工号} 或 {@code createBy = 登录名}。
* 看板与工单列表共用,避免只看板生效、列表漏看全站。
* 业务范围:角色是陆运中港,或数据权限只有陆运线路时,看板和工单只留陆运。
* 看板与工单列表共用,避免只收一边。没有登录上下文时不限制(智能体内部调用)。
* 线程:只读登录态,无全局锁。
*/
public final class InquiryDataScopeSupport {
@@ -32,13 +38,15 @@ public final class InquiryDataScopeSupport {
if (login == null) {
return;
}
if (isAdministrator(login)) {
return;
}
if (!Boolean.TRUE.equals(login.getDashboardSelfOnly())) {
return;
}
boolean admin = isAdministrator(login);
Boolean selfOnly = login.getDashboardSelfOnly();
String username = StringUtils.trimToEmpty(login.getUsername());
if (admin) {
return;
}
if (!Boolean.TRUE.equals(selfOnly)) {
return;
}
String wecom = wecomId(login);
qw.and(w -> {
boolean any = false;
@@ -60,6 +68,85 @@ public final class InquiryDataScopeSupport {
});
}
/**
* 按角色把工单收成空运 / 海运 / 陆运。不限制时原样返回。
* 有业务线码时只认码;业务线空着才用中文标签兜底,避免海运单被写成「陆运」后漏出来。
*/
public static void applyRoleBizLines(LambdaQueryWrapper<InquiryTicket> qw) {
if (qw == null) {
return;
}
Set<String> allowed = InquiryQuoteTemplateBizScope.currentAllowedOrNull();
if (allowed == null) {
return;
}
List<String> codes = new ArrayList<String>();
List<String> marks = new ArrayList<String>();
if (allowed.contains(InquiryRoleBizScope.LAND)) {
codes.add("LAND");
marks.add("陆");
}
if (allowed.contains(InquiryRoleBizScope.AIR)) {
codes.add("AIR");
marks.add("空");
}
if (allowed.contains(InquiryRoleBizScope.SEA)) {
codes.add("SEA");
marks.add("海");
}
if (codes.isEmpty()) {
qw.apply("1 = 0");
return;
}
StringBuilder sql = new StringBuilder();
sql.append("(UPPER(business_line) IN (");
for (int i = 0; i < codes.size(); i++) {
if (i > 0) {
sql.append(',');
}
sql.append('\'').append(codes.get(i)).append('\'');
}
sql.append(") OR ((business_line IS NULL OR business_line = '') AND (");
for (int i = 0; i < marks.size(); i++) {
if (i > 0) {
sql.append(" OR ");
}
sql.append("biz_type_label LIKE '%").append(marks.get(i)).append("%'");
}
sql.append(")))");
qw.apply(sql.toString());
}
/**
* 详情、流转、异常处理:列表里不该出现的工单,按编号打开也不给看。
*/
public static void assertTicketBizAllowed(InquiryTicket ticket) {
if (ticket == null) {
return;
}
Set<String> allowed = InquiryQuoteTemplateBizScope.currentAllowedOrNull();
if (InquiryRoleBizScope.ticketInScope(ticket.getBusinessLine(), ticket.getBizTypeLabel(), allowed)) {
return;
}
String scope = InquiryRoleBizScope.joinLabels(allowed);
if (StringUtils.isBlank(scope)) {
scope = "已授权业务";
}
throw new JeecgBootException("当前账号只能查看" + scope + "的询价工单和数据看板");
}
/**
* 工单业务下拉。不限制时含多段联运;陆运角色只有陆运。
*/
public static List<String> ticketBizOptionsForCurrentUser() {
Set<String> allowed = InquiryQuoteTemplateBizScope.currentAllowedOrNull();
List<String> labels = new ArrayList<String>(InquiryRoleBizScope.orderedLabels(allowed));
if (allowed == null) {
labels.add("多段联运");
}
return labels;
}
private static LoginUser currentLoginUser() {
try {
Object p = SecurityUtils.getSubject().getPrincipal();
@@ -0,0 +1,153 @@
package org.jeecg.modules.inquiry.support;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.SecurityUtils;
import org.jeecg.common.exception.JeecgBootException;
import org.jeecg.common.system.vo.LoginUser;
import org.jeecg.common.util.SpringContextUtils;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
/**
* 后台报价模板的业务范围:按当前登录人的角色收成空运 / 海运 / 陆运。
* <p>
* 菜单权限 {@code inquiry:template:view} 只决定能不能进「报价模板」页。
* 进了页面之后,陆运中港这类角色只应看到陆运模板,不能把海运、空运模板一并列出来。
* 智能体出单走服务账号的 {@code /agent/listEnabled},不经过这里,避免按某个销售的角色把别的业务模板藏掉。
* <p>
* 线程:只读当前请求的登录态和角色表,无锁。没有登录上下文时不限制(内部任务、单测)。
*/
public final class InquiryQuoteTemplateBizScope {
private InquiryQuoteTemplateBizScope() {
}
/**
* 当前登录人允许的业务线。
*
* @return {@code null} 表示不限制(未登录、超管、角色未标明业务)
*/
public static Set<String> currentAllowedOrNull() {
LoginUser login = currentLoginUser();
if (login == null) {
return null;
}
String username = StringUtils.trimToEmpty(login.getUsername());
List<InquiryRoleBizScope.RoleSnap> roles = loadRoles(username);
return InquiryRoleBizScope.resolve(isAdministrator(login, roles), roles);
}
/**
* 给报价模板页下拉用。不限制时返回空运、海运、陆运三项。
*/
public static List<String> allowedLabelsForCurrentUser() {
return InquiryRoleBizScope.orderedLabels(currentAllowedOrNull());
}
/**
* 当前账号能否操作这条业务的模板。未登录或不限制时放行。
*/
public static boolean isBizAllowed(String bizType) {
Set<String> allowed = currentAllowedOrNull();
if (allowed == null) {
return true;
}
return allowed.contains(StringUtils.trimToEmpty(bizType));
}
/**
* 越权时直接拒绝,避免只藏列表、预览和下载仍能按编号打开海运/空运模板。
*/
public static void assertBizAllowed(String bizType) {
if (isBizAllowed(bizType)) {
return;
}
Set<String> allowed = currentAllowedOrNull();
String scope = InquiryRoleBizScope.joinLabels(allowed);
if (StringUtils.isBlank(scope)) {
scope = "已授权业务";
}
throw new JeecgBootException("当前账号只能查看和维护" + scope + "报价模板");
}
private static LoginUser currentLoginUser() {
try {
Object principal = SecurityUtils.getSubject().getPrincipal();
if (principal instanceof LoginUser) {
return (LoginUser) principal;
}
} catch (Exception ignored) {
// 无 Shiro 上下文:内部调用不按人过滤
}
return null;
}
/**
* 超管看全部模板。只认登录名 admin、角色码 admin、角色名「管理员 / 超级管理员」。
* 「陆运管理员」这种带业务的名字不放行。
*/
private static boolean isAdministrator(LoginUser login, List<InquiryRoleBizScope.RoleSnap> roles) {
if (login == null) {
return false;
}
if ("admin".equalsIgnoreCase(StringUtils.trimToEmpty(login.getUsername()))) {
return true;
}
if (roles != null) {
for (InquiryRoleBizScope.RoleSnap role : roles) {
if (role == null) {
continue;
}
String code = StringUtils.trimToEmpty(role.getRoleCode());
String name = StringUtils.trimToEmpty(role.getRoleName());
if ("admin".equalsIgnoreCase(code) || "管理员".equals(name) || "超级管理员".equals(name)) {
return true;
}
}
}
try {
return SecurityUtils.getSubject().hasRole("admin")
|| SecurityUtils.getSubject().hasRole("管理员");
} catch (Exception e) {
return false;
}
}
/**
* 读账号绑定角色的名称和数据权限。失败时当没有角色,不把异常抛到列表上。
*/
private static List<InquiryRoleBizScope.RoleSnap> loadRoles(String username) {
List<InquiryRoleBizScope.RoleSnap> roles = new ArrayList<>();
if (StringUtils.isBlank(username)) {
return roles;
}
String sql = "SELECT r.role_name, r.role_code, r.description FROM sys_role r "
+ "INNER JOIN sys_user_role ur ON ur.role_id = r.id "
+ "INNER JOIN sys_user u ON u.id = ur.user_id WHERE u.username = ?";
try {
DataSource ds = SpringContextUtils.getBean(DataSource.class);
try (Connection conn = ds.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, username);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
roles.add(new InquiryRoleBizScope.RoleSnap(
rs.getString("role_name"),
rs.getString("role_code"),
rs.getString("description")));
}
}
}
} catch (Exception ignored) {
// 角色表暂时读不到时返回空,列表不因此 500。
// 上层会当成「没配业务范围」,暂不收窄;下次请求再读。
}
return roles;
}
}
@@ -0,0 +1,274 @@
package org.jeecg.modules.inquiry.support;
import org.apache.commons.lang3.StringUtils;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Pattern;
/**
* 把角色名称和角色数据权限,收成报价模板能用的业务线:空运 / 海运 / 陆运。
* <p>
* 数据权限写在 {@code sys_role.description} 的 {@code [dataPerms:…]},目录与后台
* {@code dataPermCatalog.ts} 一致。报价模板只有业务线,没有「中港 / 国内整车」这一层,
* 所以陆运线路(含中港)都归到「陆运」。
* <p>
* 纯函数,不读库、不碰登录态。超管是否放行由调用方传入。
*/
public final class InquiryRoleBizScope {
public static final String AIR = "空运";
public static final String SEA = "海运";
public static final String LAND = "陆运";
/** 角色描述里数据权限的包裹格式,与后台角色保存一致。 */
private static final String PERM_OPEN = "[dataPerms:";
/** 陆运线路叶子(写入时不带「陆运·」前缀)。 */
private static final Set<String> LAND_ROUTE_KEYS = new LinkedHashSet<String>(Arrays.asList(
"陆运全线",
"中港/中亚/中欧",
"国内长途/零担",
"国内城配/拖车/打包",
"东南亚"
));
private static final List<String> BIZ_ORDER = Arrays.asList(AIR, SEA, LAND);
/** 空运线路码,如 CGO-KUL。中港角色名不含这种码。 */
private static final Pattern AIRPORT_PAIR = Pattern.compile(".*[A-Z]{3}-[A-Z]{3}.*");
private InquiryRoleBizScope() {
}
/**
* 一个角色的名称、编码、描述。编码用于识别超管角色,描述用于读数据权限。
*/
public static final class RoleSnap {
private final String roleName;
private final String roleCode;
private final String description;
public RoleSnap(String roleName, String roleCode, String description) {
this.roleName = roleName;
this.roleCode = roleCode;
this.description = description;
}
public String getRoleName() {
return roleName;
}
public String getRoleCode() {
return roleCode;
}
public String getDescription() {
return description;
}
}
/**
* 当前账号能看的报价模板业务。
* <p>
* {@code null} 表示不限制:超管,或所有角色都看不出业务线(老账号不被误锁)。
* 非空则列表、预览、改删都只能落在这些业务里。多个角色取并集。
* 角色名已点明单一业务(如「陆运中港」)时,以角色名为准,避免功能权限开通后仍看到海陆空全部模板。
*
* @param administrator 登录名 admin、角色码 admin、或角色名「管理员」
* @param roles 该账号绑定的角色;可空
*/
public static Set<String> resolve(boolean administrator, List<RoleSnap> roles) {
if (administrator) {
return null;
}
LinkedHashSet<String> allowed = new LinkedHashSet<>();
boolean anyScoped = false;
if (roles != null) {
for (RoleSnap role : roles) {
if (role == null) {
continue;
}
Set<String> fromName = bizFromRoleName(role.getRoleName());
Set<String> fromPerms = bizFromDataPerms(role.getDescription());
// 角色名只指向一条业务线时,数据权限不再把别的业务线加进来。
// 「陆运中港」即使描述里误勾了海运/空运,报价模板也只出陆运。
if (fromName.size() == 1) {
anyScoped = true;
allowed.addAll(fromName);
continue;
}
if (!fromPerms.isEmpty()) {
anyScoped = true;
allowed.addAll(fromPerms);
continue;
}
if (!fromName.isEmpty()) {
anyScoped = true;
allowed.addAll(fromName);
}
}
}
if (!anyScoped) {
return null;
}
return allowed;
}
/**
* 界面下拉用的稳定顺序:空运、海运、陆运。{@code allowed == null} 时三项都返回。
*/
public static List<String> orderedLabels(Set<String> allowed) {
List<String> out = new ArrayList<String>(3);
for (String biz : BIZ_ORDER) {
if (allowed == null || allowed.contains(biz)) {
out.add(biz);
}
}
return out;
}
/** 给销售看的业务名,顿号拼接。空集合返回空串。 */
public static String joinLabels(Collection<String> allowed) {
Collection<String> source = allowed == null ? Collections.<String>emptySet() : allowed;
return String.join("、", orderedLabels(new LinkedHashSet<String>(source)));
}
/**
* 从角色描述取出数据权限稳定码。没有这段标记时返回空列表。
*/
public static List<String> parseDataPermKeys(String description) {
List<String> keys = new ArrayList<>();
if (StringUtils.isBlank(description)) {
return keys;
}
int open = description.indexOf(PERM_OPEN);
if (open < 0) {
return keys;
}
int close = description.indexOf(']', open);
if (close < 0) {
return keys;
}
String body = description.substring(open + PERM_OPEN.length(), close);
for (String part : body.split("\\|")) {
String key = StringUtils.trimToEmpty(part);
if (StringUtils.isNotBlank(key)) {
keys.add(key);
}
}
return keys;
}
/**
* 一条数据权限对应的业务线。系统异常通知、认不出的码返回 null,不扩大可见范围。
*/
public static String bizOfDataPermKey(String raw) {
String key = StringUtils.trimToEmpty(raw);
if (key.isEmpty() || key.contains("系统异常")) {
return null;
}
if (key.contains("空运")) {
return AIR;
}
if (key.contains("海运")) {
return SEA;
}
if (key.contains("陆运") || LAND_ROUTE_KEYS.contains(key)) {
return LAND;
}
// 剥掉「空运· / 陆运·」之后再认一次叶子(旧数据可能带前缀,上面 contains 已覆盖带前缀的)。
String leaf = key;
int dot = Math.max(key.lastIndexOf('·'), key.lastIndexOf('•'));
if (dot >= 0 && dot + 1 < key.length()) {
leaf = key.substring(dot + 1).trim();
}
if (LAND_ROUTE_KEYS.contains(leaf)) {
return LAND;
}
if (AIRPORT_PAIR.matcher(key).matches() || key.contains("外采") || key.contains("曼谷")) {
return AIR;
}
return null;
}
/**
* 角色名称里点名的业务。中港归陆运。名称同时含多条业务时都算上。
*/
public static Set<String> bizFromRoleName(String roleName) {
LinkedHashSet<String> out = new LinkedHashSet<>();
String name = StringUtils.trimToEmpty(roleName);
if (name.isEmpty()) {
return out;
}
if (name.contains("空运")) {
out.add(AIR);
}
if (name.contains("海运")) {
out.add(SEA);
}
if (name.contains("陆运") || name.contains("中港")) {
out.add(LAND);
}
return out;
}
/**
* 工单业务线归到空运 / 海运 / 陆运。
* 已写 SEA/AIR/LAND 时以它为准;空着才看中文标签。多段联运不归进任何一条。
*/
public static String classifyTicketBiz(String businessLine, String bizTypeLabel) {
String line = StringUtils.trimToEmpty(businessLine).toUpperCase(Locale.ROOT);
if ("LAND".equals(line) || line.contains("陆")) {
return LAND;
}
if ("AIR".equals(line) || line.contains("空")) {
return AIR;
}
if ("SEA".equals(line) || line.contains("海")) {
return SEA;
}
if (StringUtils.isNotBlank(line)) {
return "";
}
String label = StringUtils.trimToEmpty(bizTypeLabel);
if (label.contains("陆")) {
return LAND;
}
if (label.contains("空")) {
return AIR;
}
if (label.contains("海")) {
return SEA;
}
return "";
}
/**
* {@code allowed == null} 表示不限制。多段、认不出的工单对有范围的账号不可见。
*/
public static boolean ticketInScope(String businessLine, String bizTypeLabel, Set<String> allowed) {
if (allowed == null) {
return true;
}
String biz = classifyTicketBiz(businessLine, bizTypeLabel);
return StringUtils.isNotBlank(biz) && allowed.contains(biz);
}
private static Set<String> bizFromDataPerms(String description) {
LinkedHashSet<String> out = new LinkedHashSet<>();
for (String key : parseDataPermKeys(description)) {
String biz = bizOfDataPermKey(key);
if (biz != null) {
out.add(biz);
}
}
return out;
}
}
@@ -39,19 +39,20 @@ public final class InquiryTicketFactsSupport {
continue;
}
String val = e.getValue().trim();
if (val.isEmpty()) {
// 摘要占位「-」等于没写,禁止盖进工单起运港。
if (val.isEmpty() || isPlaceholderDash(val)) {
continue;
}
merged.put(e.getKey(), val);
}
ticket.setFactsJson(JSON.toJSONString(merged));
if (StringUtils.isNotBlank(merged.get("起运港"))) {
if (StringUtils.isNotBlank(merged.get("起运港")) && !isPlaceholderDash(merged.get("起运港"))) {
ticket.setOrigin(merged.get("起运港"));
}
if (StringUtils.isNotBlank(merged.get("目的港"))) {
if (StringUtils.isNotBlank(merged.get("目的港")) && !isPlaceholderDash(merged.get("目的港"))) {
ticket.setDestination(merged.get("目的港"));
}
if (StringUtils.isNotBlank(merged.get("品名"))) {
if (StringUtils.isNotBlank(merged.get("品名")) && !isPlaceholderDash(merged.get("品名"))) {
ticket.setGoodsName(merged.get("品名"));
}
Integer pieces = parseInt(merged.get("件数"));
@@ -114,6 +115,15 @@ public final class InquiryTicketFactsSupport {
return null;
}
/** 销售摘要占位横杠,不当真实起运/目的。 */
private static boolean isPlaceholderDash(String raw) {
if (StringUtils.isBlank(raw)) {
return false;
}
String t = raw.trim();
return "-".equals(t) || "—".equals(t) || "-".equals(t);
}
private static Integer parseInt(String raw) {
if (StringUtils.isBlank(raw)) {
return null;
@@ -178,6 +178,11 @@ public class InquiryTmsCabinService {
if (!properties.lockConfigured()) {
return fail("lock_disabled", "主账未开放锁舱");
}
// 工单报价日空/脏(如事实只有「:」)时,先拦再出站。
// 禁止用舱位起飞日顶过去后打 TMS,再回「选项不匹配」(WO202610140047)。
if (isTicketQuoteDateMissing(ticket)) {
return fail("lock_incomplete", "报价日期为空,请补充报价日期后重新锁舱");
}
Map<String, Object> req = new LinkedHashMap<>();
req.put("workOrderNo", no);
if (StringUtils.isNotBlank(quoteId)) {
@@ -187,7 +192,7 @@ public class InquiryTmsCabinService {
req.put("flightDate", firstNonBlank(
text(option, "flightDate"),
flightDateFromDeparture(text(option, "departureTime")),
quoteDate(ticket)));
effectiveTicketQuoteDate(ticket)));
req.put("origin", firstNonBlank(text(option, "originAirportCode"),
fact(ticket, "originCode"),
text(option, "origin"), ticket.getOrigin(),
@@ -241,18 +246,15 @@ public class InquiryTmsCabinService {
// 缺必填仍出站会被 TMS 1001;在此拦住并回清原因。
String incomplete = validateManualLockOutbound(req);
// 现拉仍无舱位时,改成业务补问口径(禁止堆「缺少 optionNo」技术字段名)。
// 报价日期空 → flightDate/validUntil 落不下来:优先提示补报价日期,
// 禁止被「运价库无匹配→补舱位」话术盖掉(运行证据 WO202610140005)。
if (StringUtils.isNotBlank(incomplete)) {
String refreshClass = text(quote, "_lockRefreshClass");
String refreshMsg = text(quote, "_lockRefreshMsg");
boolean noMatch = "TMS_NO_QUOTE_1002".equals(refreshClass)
|| (StringUtils.isNotBlank(refreshMsg) && refreshMsg.contains("无匹配"));
if (noMatch) {
incomplete = "本单是手工报价,且运价库无匹配舱位,无法自动带出航班。"
+ "请按「AIR-OPT01 CZ 航班CZ327」补舱位、航司和航班号后再锁。"
+ "(现拉TMS:" + firstNonBlank(refreshMsg, "标准运价库无匹配报价") + ")";
} else if (StringUtils.isNotBlank(refreshMsg)) {
incomplete = incomplete + "(现拉TMS:" + refreshMsg + ")";
}
incomplete = rewriteManualLockIncompleteReason(
incomplete,
ticket,
req,
text(quote, "_lockRefreshClass"),
text(quote, "_lockRefreshMsg"));
}
if (StringUtils.isNotBlank(incomplete)) {
log.info("tms.lock incomplete wo={} optionNo={} missing={}",
@@ -893,6 +895,39 @@ public class InquiryTmsCabinService {
return "";
}
/**
* 手工锁舱校验失败话术改写。
* <p>
* 报价日期空导致 flightDate/validUntil 落不下来时,优先提示补报价日期;
* 否则现拉无舱位时改成「补舱位三项」业务口径。
*/
static String rewriteManualLockIncompleteReason(
String incomplete,
InquiryTicket ticket,
Map<String, Object> req,
String refreshClass,
String refreshMsg) {
String why = StringUtils.trimToEmpty(incomplete);
if (why.isEmpty()) {
return "";
}
// 列空、事实只有「:」等脏值,都算报价日缺失(WO047)。
if (isTicketQuoteDateMissing(ticket)) {
return "报价日期为空,请补充报价日期后重新锁舱";
}
boolean noMatch = "TMS_NO_QUOTE_1002".equals(StringUtils.trimToEmpty(refreshClass))
|| (StringUtils.isNotBlank(refreshMsg) && refreshMsg.contains("无匹配"));
if (noMatch) {
return "本单是手工报价,且运价库无匹配舱位,无法自动带出航班。"
+ "请按「AIR-OPT01 CZ 航班CZ327」补舱位、航司和航班号后再锁。"
+ "(现拉TMS:" + firstNonBlank(refreshMsg, "标准运价库无匹配报价") + ")";
}
if (StringUtils.isNotBlank(refreshMsg)) {
return why + "(现拉TMS:" + refreshMsg + ")";
}
return why;
}
/**
* MANUAL 出站必填校验。缺字段直接失败,禁止 HMAC 空壳打 TMS(会 1001)。
* 返回中文原因;齐了返回空串。
@@ -1382,6 +1417,27 @@ public class InquiryTmsCabinService {
return new SimpleDateFormat("yyyy-MM-dd").format(ticket.getQuoteDate());
}
/**
* 锁舱用的工单报价日:列优先,其次事实里能规范成 yyyy-MM-dd 的值。
* 「:」、空串、乱码不算日期。
*/
static String effectiveTicketQuoteDate(InquiryTicket ticket) {
String fromCol = quoteDate(ticket);
if (StringUtils.isNotBlank(fromCol)) {
return fromCol;
}
String fromFact = fact(ticket, "报价日期", "quoteDate", "quote_date");
String normalized = TmsV2RequestMapper.normalizeQuoteDate(fromFact);
return normalized == null ? "" : normalized;
}
/**
* 工单是否缺少可用报价日(列空且事实无合法日期)。
*/
static boolean isTicketQuoteDateMissing(InquiryTicket ticket) {
return StringUtils.isBlank(effectiveTicketQuoteDate(ticket));
}
private static String fact(InquiryTicket ticket, String... keys) {
if (ticket == null || StringUtils.isBlank(ticket.getFactsJson())) {
return "";
@@ -70,6 +70,8 @@ public class InquiryTmsQueryService {
if (ticket != null) {
mergeTicketFacts(facts, ticket);
}
// 摘要占位「-」清成空,禁止当起运地出站(运行证据 WO202610150086 → TMS 1003 originCode)。
scrubPlaceholderDashes(facts);
String line = resolveTransportLine(
body,
facts,
@@ -82,9 +84,9 @@ public class InquiryTmsQueryService {
Map<String, Object> segment = new LinkedHashMap<>();
segment.put("sequence", 1);
segment.put("transportMode", line);
String origin = text(facts.get("起运港"));
String destination = text(facts.get("目的港"));
segment.put("cargoName", text(facts.get("品名")));
String origin = placeText(facts.get("起运港"));
String destination = placeText(facts.get("目的港"));
segment.put("cargoName", placeText(facts.get("品名")));
if ("SEA".equals(line)) {
// 海运不拦空运件数/重量/体积/包装;港口只带名称,没有港口字典也不跳过。
segment.put("origin", origin);
@@ -107,9 +109,10 @@ public class InquiryTmsQueryService {
segment.put("destination", destination);
segment.putAll(TmsLandSegmentMapper.build(facts));
} else {
// 空运:地名栏留空,三字码只放 originCode / destinationCode。
segment.put("origin", "");
segment.put("destination", "");
// 空运:地名传销售原文(广西/克拉克),能投影再带 originCode/destinationCode。
// 对不上三字码不拦出站、不编假码;起运地非必填可空串。
segment.put("origin", origin);
segment.put("destination", destination);
segment.put("quantity", TmsV2RequestMapper.asInteger(facts.get("件数")));
segment.put("weightKg", TmsV2RequestMapper.asPositiveDecimal(facts.get("毛重")));
segment.put("volumeCbm", TmsV2RequestMapper.asVolumeCbm(facts.get("体积")));
@@ -173,7 +176,8 @@ public class InquiryTmsQueryService {
}
try {
Map<String, Object> tms = gateway.query(internal, cacheKey);
return classifyAndPersist(workOrderNo, tms);
Map<String, Object> classified = classifyAndPersist(workOrderNo, tms);
return classified;
} catch (RuntimeException ex) {
log.warn("tms.query tech wo={} err={}", workOrderNo, ex.getMessage());
return tech(ex.getMessage());
@@ -242,20 +246,33 @@ public class InquiryTmsQueryService {
log.info("tms.quote skip frozen wo={} status={}", workOrderNo, ticket.getStatus());
return;
}
boolean airPending = isAirCabinPendingSelection(quote);
InquiryQuote row = new InquiryQuote();
row.setTicketId(ticket.getId());
row.setWorkOrderNo(workOrderNo);
row.setQuoteVersion(1);
applySnapshotToQuoteRow(row, quote);
if (airPending) {
// 未选舱:保留 airOptions 供恢复,清空展示金额,禁止后台当成已报价。
sanitizePendingAirCabinRow(row, quote);
}
row.setCreateTime(new Date());
quoteMapper.insert(row);
List<InquiryQuote> quotes = quoteMapper.selectList(new LambdaQueryWrapper<InquiryQuote>()
.eq(InquiryQuote::getWorkOrderNo, workOrderNo)
.orderByAsc(InquiryQuote::getCreateTime));
InquiryTicketPriceSupport.apply(ticket, quotes);
if (airPending) {
ticket.setSalePrice("");
ticket.setCostPrice("");
ticket.setProfitRate("");
}
ticket.setTmsHit(1);
workOrderService.updateById(ticket);
if (TicketStatuses.canTransit(ticket.getStatus(), TicketStatuses.QUOTED)) {
// 空运备选舱位未选:保持询价中,不迁已报价(合同:选定后才出报价)。
boolean willTransit = !airPending
&& TicketStatuses.canTransit(ticket.getStatus(), TicketStatuses.QUOTED);
if (willTransit) {
TicketTransitionRequest tr = new TicketTransitionRequest();
tr.setWorkOrderNo(workOrderNo);
tr.setToStatus(TicketStatuses.QUOTED);
@@ -263,6 +280,87 @@ public class InquiryTmsQueryService {
}
}
/**
* 空运尚在选舱/选线:有 airOptions 且没有 selected_air_option。
* 此时禁止迁已报价、禁止后台展示最终费用。
*/
static boolean isAirCabinPendingSelection(Map<String, String> quote) {
if (quote == null || quote.isEmpty()) {
return false;
}
if (hasSelectedAirOption(quote)) {
return false;
}
if ("1".equals(StringUtils.trimToEmpty(quote.get("pending_cabin_pick")))) {
return true;
}
return countAirOptions(quote) >= 1;
}
static boolean hasSelectedAirOption(Map<String, String> quote) {
if (quote == null) {
return false;
}
return StringUtils.isNotBlank(quote.get("selected_air_option"))
|| StringUtils.isNotBlank(quote.get("selectedAirOption"));
}
static int countAirOptions(Map<String, String> quote) {
if (quote == null) {
return 0;
}
String airRaw = firstText(quote.get("airOptions"), quote.get("air_options"));
if (StringUtils.isBlank(airRaw)) {
return 0;
}
try {
Object parsed = airRaw.trim().startsWith("[") ? JSON.parse(airRaw) : null;
if (parsed instanceof List) {
return ((List<?>) parsed).size();
}
} catch (Exception ignored) {
return 0;
}
return 0;
}
static int countFeeLines(Map<String, String> quote) {
if (quote == null) {
return 0;
}
String fees = firstText(quote.get("fee_lines"), quote.get("fee_rows"));
if (StringUtils.isBlank(fees) || !fees.trim().startsWith("[")) {
return 0;
}
try {
List<?> fl = JSON.parseArray(fees);
return fl == null ? 0 : fl.size();
} catch (Exception ignored) {
return 0;
}
}
/**
* 未选舱落库:payload 保留 airOptions,清空 total/fee_lines/priceText。
*/
static void sanitizePendingAirCabinRow(InquiryQuote row, Map<String, String> quote) {
if (row == null) {
return;
}
row.setPriceText("");
Map<String, String> sanitized = new LinkedHashMap<>();
if (quote != null) {
sanitized.putAll(quote);
}
sanitized.put("pending_cabin_pick", "1");
sanitized.put("fee_lines", "[]");
sanitized.put("total", "");
sanitized.remove("selected_air_option");
sanitized.remove("selectedAirOption");
row.setPayloadJson(JSON.toJSONString(sanitized));
}
/**
* 把 TMS 规范化回包投影成智能体 quote。单测可直接调。
* 金额保持数字原文;币种单独落 currency / fee_lines.currency,禁止默认 CNY。
@@ -619,6 +717,14 @@ public class InquiryTmsQueryService {
* 不新插 TMS 行,避免后台出现两条「TMS标准报价」。已冻结工单拒绝。
*/
public Map<String, Object> replaceTmsQuote(String workOrderNo, Map<String, Object> incoming) {
return replaceTmsQuote(workOrderNo, incoming, null);
}
/**
* 覆盖 TMS 快照。toStatus=询价中 或空运未选舱时,只落备选、不迁已报价。
* 选定后(有 selected_air_option / 投影完成)才迁已报价。
*/
public Map<String, Object> replaceTmsQuote(String workOrderNo, Map<String, Object> incoming, String toStatus) {
Map<String, Object> out = new LinkedHashMap<>();
if (StringUtils.isBlank(workOrderNo) || incoming == null || incoming.isEmpty()) {
out.put("ok", false);
@@ -642,6 +748,8 @@ public class InquiryTmsQueryService {
}
Map<String, String> quote = stringifyQuote(incoming);
applyLandSegmentTotals(quote);
boolean airPending = isAirCabinPendingSelection(quote)
|| TicketStatuses.INQUIRING.equals(StringUtils.trimToEmpty(toStatus));
InquiryQuote row = quoteMapper.selectOne(new LambdaQueryWrapper<InquiryQuote>()
.eq(InquiryQuote::getWorkOrderNo, workOrderNo)
.eq(InquiryQuote::getSource, "TMS")
@@ -656,6 +764,9 @@ public class InquiryTmsQueryService {
row.setCreateTime(new Date());
}
applySnapshotToQuoteRow(row, quote);
if (airPending) {
sanitizePendingAirCabinRow(row, quote);
}
row.setUpdateTime(new Date());
if (insert) {
quoteMapper.insert(row);
@@ -666,13 +777,21 @@ public class InquiryTmsQueryService {
.eq(InquiryQuote::getWorkOrderNo, workOrderNo)
.orderByAsc(InquiryQuote::getCreateTime));
InquiryTicketPriceSupport.apply(ticket, quotes);
if (airPending) {
ticket.setSalePrice("");
ticket.setCostPrice("");
ticket.setProfitRate("");
}
ticket.setTmsHit(1);
workOrderService.updateById(ticket);
if (TicketStatuses.canTransit(ticket.getStatus(), TicketStatuses.QUOTED)) {
boolean wantQuoted = !airPending
&& (StringUtils.isBlank(toStatus) || TicketStatuses.QUOTED.equals(toStatus.trim()));
if (wantQuoted && TicketStatuses.canTransit(ticket.getStatus(), TicketStatuses.QUOTED)) {
TicketTransitionRequest tr = new TicketTransitionRequest();
tr.setWorkOrderNo(workOrderNo);
tr.setToStatus(TicketStatuses.QUOTED);
workOrderService.transition(tr, "agent");
ticket.setStatus(TicketStatuses.QUOTED);
}
out.put("ok", true);
out.put("quote_version", row.getQuoteVersion() == null ? 1 : row.getQuoteVersion());
@@ -704,8 +823,39 @@ public class InquiryTmsQueryService {
return quote;
}
private static boolean blankOrDash(String raw) {
return StringUtils.isBlank(raw) || "-".equals(raw.trim());
/**
* 空串或摘要占位横杠(- / — / -)。
* 横杠只用于销售展示「没写」,不能当起运地/目的地原文出站。
*/
static boolean blankOrDash(String raw) {
if (StringUtils.isBlank(raw)) {
return true;
}
String t = raw.trim();
return "-".equals(t) || "—".equals(t) || "-".equals(t);
}
/**
* 查价 facts 里的占位横杠清掉,避免 merge 工单列后又把「-」打给 TMS。
*/
static void scrubPlaceholderDashes(Map<String, Object> facts) {
if (facts == null || facts.isEmpty()) {
return;
}
for (String key : new String[] {
"起运港", "起运地", "目的港", "目的地", "品名", "包装方式", "包装类型", "报价日期"
}) {
Object raw = facts.get(key);
if (raw != null && blankOrDash(String.valueOf(raw))) {
facts.remove(key);
}
}
}
/** 地名/品名出站:横杠当空串。 */
static String placeText(Object value) {
String t = text(value);
return blankOrDash(t) ? "" : t;
}
/**
@@ -1408,7 +1558,7 @@ public class InquiryTmsQueryService {
}
/**
* 空运出站齐套检查。地名栏允许空串,禁止再拿 destination 拦查价。
* 空运出站齐套检查。起运地/目的地原文可空,禁止拿地名栏拦查价。
* 报价日期非必填,空着也算出站齐套。
*/
static List<String> missingAirQuery(String workOrderNo, String quoteDate, Map<String, Object> segment) {
@@ -1542,7 +1692,8 @@ public class InquiryTmsQueryService {
}
private static void putIfBlank(Map<String, Object> facts, String key, String val) {
if (blank(facts.get(key)) && StringUtils.isNotBlank(val)) {
// 工单列若是「-」,禁止再补进查价 facts。
if (blank(facts.get(key)) && StringUtils.isNotBlank(val) && !blankOrDash(val)) {
facts.put(key, val);
}
}
@@ -79,16 +79,19 @@ public final class TmsV2RequestMapper {
mode = parentMode;
}
put(target, "transportMode", mode);
// 空运/海运/陆运:地名传销售原文;空运另带能投影出的三字码。
// 空运起运地非必填,原文空时仍留空串给 TMS,禁止当「没值」删掉。
Object originRaw = first(source, "origin");
Object destRaw = first(source, "destination");
if ("AIR".equalsIgnoreCase(String.valueOf(mode == null ? "" : mode))) {
// 空运地名栏必须空串,三字码只放 originCode / destinationCode。
target.put("origin", "");
target.put("destination", "");
target.put("origin", originRaw == null ? "" : originRaw);
target.put("destination", destRaw == null ? "" : destRaw);
put(target, "originCode", first(source, "originCode"));
put(target, "destinationCode", first(source, "destinationCode"));
} else {
put(target, "origin", first(source, "origin"));
put(target, "origin", originRaw);
put(target, "originCode", first(source, "originCode"));
put(target, "destination", first(source, "destination"));
put(target, "destination", destRaw);
put(target, "destinationCode", first(source, "destinationCode"));
}
put(target, "cargoName", first(source, "cargoName", "goodsName"));
@@ -330,7 +333,7 @@ public final class TmsV2RequestMapper {
}
private static Map<String, Object> compact(Map<String, Object> value) {
// 空运地名栏要留空串给 TMS,禁止把 origin / destination 当成「没值」删掉。
// 空运起运地可空:空串仍留给 TMS,禁止把 origin / destination 当成「没值」删掉。
value.entrySet().removeIf(e -> {
Object current = e.getValue();
if (current == null) {
@@ -0,0 +1,106 @@
package org.jeecg.modules.inquiry.support;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 报价模板业务范围:陆运中港角色只落到「陆运」,不连库。
*/
public class InquiryRoleBizScopeTest {
@Test
public void landHongKongRoleNameShowsOnlyLandTemplates() {
Set<String> allowed = InquiryRoleBizScope.resolve(false, Collections.singletonList(
new InquiryRoleBizScope.RoleSnap("陆运中港", "land_hk", "")));
assertEquals(Collections.singleton(InquiryRoleBizScope.LAND), allowed);
assertEquals(Collections.singletonList("陆运"), InquiryRoleBizScope.orderedLabels(allowed));
}
@Test
public void landRouteDataPermMapsToLandEvenIfRoleNameIsGeneric() {
Set<String> allowed = InquiryRoleBizScope.resolve(false, Collections.singletonList(
new InquiryRoleBizScope.RoleSnap("销售", "sales",
"[dataPerms:中港/中亚/中欧|东南亚]")));
assertEquals(Collections.singleton(InquiryRoleBizScope.LAND), allowed);
}
@Test
public void namedLandRoleIgnoresExtraSeaAndAirDataPerms() {
Set<String> allowed = InquiryRoleBizScope.resolve(false, Collections.singletonList(
new InquiryRoleBizScope.RoleSnap("陆运中港", "land_hk",
"[dataPerms:中港/中亚/中欧|海运全线|空运全线]")));
assertEquals(Collections.singleton(InquiryRoleBizScope.LAND), allowed);
}
@Test
public void airAndSeaPermsStaySplitWhenRoleNameHasNoBusiness() {
Set<String> allowed = InquiryRoleBizScope.resolve(false, Collections.singletonList(
new InquiryRoleBizScope.RoleSnap("线路专员", "route",
"[dataPerms:CGO-KUL|海运全线]")));
assertEquals(new HashSet<String>(Arrays.asList(
InquiryRoleBizScope.AIR, InquiryRoleBizScope.SEA)), allowed);
assertEquals(Arrays.asList("空运", "海运"), InquiryRoleBizScope.orderedLabels(allowed));
}
@Test
public void twoRolesUnionTheirBusinessLines() {
Set<String> allowed = InquiryRoleBizScope.resolve(false, Arrays.asList(
new InquiryRoleBizScope.RoleSnap("陆运中港", "land_hk", ""),
new InquiryRoleBizScope.RoleSnap("空运专员", "air", "[dataPerms:空运全线]")));
assertEquals(new HashSet<String>(Arrays.asList(
InquiryRoleBizScope.LAND, InquiryRoleBizScope.AIR)), allowed);
}
@Test
public void adminAndUnscopedRoleAreNotFiltered() {
assertNull(InquiryRoleBizScope.resolve(true, Collections.singletonList(
new InquiryRoleBizScope.RoleSnap("陆运中港", "land_hk", ""))));
assertNull(InquiryRoleBizScope.resolve(false, Collections.singletonList(
new InquiryRoleBizScope.RoleSnap("运营", "ops", ""))));
assertEquals(Arrays.asList("空运", "海运", "陆运"), InquiryRoleBizScope.orderedLabels(null));
}
@Test
public void systemNoticeAloneDoesNotOpenAllLines() {
Set<String> allowed = InquiryRoleBizScope.resolve(false, Collections.singletonList(
new InquiryRoleBizScope.RoleSnap("陆运中港", "land_hk",
"[dataPerms:系统异常·消息通知]")));
assertEquals(Collections.singleton(InquiryRoleBizScope.LAND), allowed);
assertNull(InquiryRoleBizScope.resolve(false, Collections.singletonList(
new InquiryRoleBizScope.RoleSnap("运维", "ops",
"[dataPerms:系统异常·消息通知]"))));
}
@Test
public void airportAndBangkokKeysAreAir() {
assertEquals(InquiryRoleBizScope.AIR, InquiryRoleBizScope.bizOfDataPermKey("HKG-KUL"));
assertEquals(InquiryRoleBizScope.AIR, InquiryRoleBizScope.bizOfDataPermKey("国内-曼谷(BKK 外采)"));
assertEquals(InquiryRoleBizScope.SEA, InquiryRoleBizScope.bizOfDataPermKey("海运全线"));
assertEquals(InquiryRoleBizScope.LAND, InquiryRoleBizScope.bizOfDataPermKey("国内长途/零担"));
List<String> keys = InquiryRoleBizScope.parseDataPermKeys("[dataPerms:中港/中亚/中欧|空运全线]");
assertTrue(keys.contains("中港/中亚/中欧"));
}
@Test
public void landRoleCannotSeeAirOrSeaTickets() {
Set<String> land = Collections.singleton(InquiryRoleBizScope.LAND);
assertTrue(InquiryRoleBizScope.ticketInScope("LAND", "陆运", land));
assertTrue(InquiryRoleBizScope.ticketInScope("", "陆运", land));
assertFalse(InquiryRoleBizScope.ticketInScope("AIR", "空运", land));
assertFalse(InquiryRoleBizScope.ticketInScope("SEA", "海运", land));
assertFalse(InquiryRoleBizScope.ticketInScope("MULTI", "多段联运", land));
assertTrue(InquiryRoleBizScope.ticketInScope("AIR", "空运", null));
assertEquals(InquiryRoleBizScope.LAND, InquiryRoleBizScope.classifyTicketBiz("land", ""));
assertEquals("", InquiryRoleBizScope.classifyTicketBiz("MULTI", "陆运"));
}
}
@@ -2683,6 +2683,21 @@ public class QuoteTemplateFillTest {
}
}
/**
* 报价单「报价日期」格用工单创建日,不拿询价事实里的报价日期(TMS 用)。
*/
@Test
public void buildFillFactsQuoteDatePrefersTicketCreateDate() {
Map<String, Object> facts = new HashMap<>();
facts.put("报价日期", "2026-10-18");
facts.put("ticket_create_date", "2026-10-15");
facts.put("起运港", "广西");
facts.put("目的港", "肇庆");
JSONObject out = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl
.buildFillFacts("WO202610150031", facts, new HashMap<>(), "WuJiLin", "AIR");
assertEquals("2026-10-15", out.getString("quote_date"));
}
/**
* 业务员写中文名,不写企微账号。客户名称只留销售填的,账号不能占这一格。
*/
@@ -350,6 +350,33 @@ public class InquiryTmsCabinServiceTest {
assertEquals("", InquiryTmsCabinService.validateManualLockOutbound(ok));
}
@Test
public void rewriteIncompletePrefersEmptyQuoteDateOverNoMatchCabinTip() {
org.jeecg.modules.inquiry.entity.InquiryTicket ticket =
new org.jeecg.modules.inquiry.entity.InquiryTicket();
ticket.setFactsJson("{}");
Map<String, Object> req = new HashMap<>();
req.put("flightDate", "");
req.put("validUntil", "");
String raw = "锁舱参数不完整,缺少:有效期validUntil。请按「AIR-OPT01 CZ 航班CZ327」补舱位、航司和航班号后再锁。";
String why = InquiryTmsCabinService.rewriteManualLockIncompleteReason(
raw, ticket, req, "TMS_NO_QUOTE_1002", "标准运价库无匹配报价");
assertEquals("报价日期为空,请补充报价日期后重新锁舱", why);
}
@Test
public void colonOnlyFactQuoteDateCountsAsMissing() {
org.jeecg.modules.inquiry.entity.InquiryTicket ticket =
new org.jeecg.modules.inquiry.entity.InquiryTicket();
ticket.setFactsJson("{\"报价日期\":\":\"}");
org.junit.jupiter.api.Assertions.assertTrue(InquiryTmsCabinService.isTicketQuoteDateMissing(ticket));
assertEquals("", InquiryTmsCabinService.effectiveTicketQuoteDate(ticket));
ticket.setFactsJson("{\"报价日期\":\"2026-10-14\"}");
org.junit.jupiter.api.Assertions.assertFalse(InquiryTmsCabinService.isTicketQuoteDateMissing(ticket));
assertEquals("2026-10-14", InquiryTmsCabinService.effectiveTicketQuoteDate(ticket));
}
@Test
public void airlineCodeOrBlankRejectsChineseName() {
assertEquals("CZ", InquiryTmsCabinService.airlineCodeOrBlank("CZ"));
@@ -77,7 +77,7 @@ public class InquiryTmsQueryServiceTest {
@Test
public void airQueryAllowsEmptyPlaceNames() {
// 地名栏空串不能拦出站;三字码和包装原文齐了就要打 TMS。
// 起运地可空不能拦出站;包装与货量齐了就要打 TMS。
java.util.Map<String, Object> segment = new java.util.HashMap<String, Object>();
segment.put("origin", "");
segment.put("destination", "");
@@ -93,6 +93,38 @@ public class InquiryTmsQueryServiceTest {
org.junit.jupiter.api.Assertions.assertTrue(missing.isEmpty(), String.valueOf(missing));
}
@Test
public void scrubPlaceholderDashesClearsOriginDash() {
// 摘要「-」必须清掉,否则 TMS 当起飞机场去匹配会 1003。
java.util.Map<String, Object> facts = new java.util.HashMap<String, Object>();
facts.put("起运港", "-");
facts.put("目的港", "KUL");
facts.put("品名", "书籍");
InquiryTmsQueryService.scrubPlaceholderDashes(facts);
org.junit.jupiter.api.Assertions.assertFalse(facts.containsKey("起运港"));
org.junit.jupiter.api.Assertions.assertEquals("KUL", facts.get("目的港"));
org.junit.jupiter.api.Assertions.assertEquals("", InquiryTmsQueryService.placeText("-"));
org.junit.jupiter.api.Assertions.assertEquals("上海", InquiryTmsQueryService.placeText("上海"));
org.junit.jupiter.api.Assertions.assertTrue(InquiryTmsQueryService.blankOrDash("—"));
}
@Test
public void airQueryAllowsGuangxiOriginalWithoutOriginCode() {
// 销售写广西:原文出站,对不上三字码也不拦。
java.util.Map<String, Object> segment = new java.util.HashMap<String, Object>();
segment.put("origin", "广西");
segment.put("destination", "克拉克");
segment.put("destinationCode", "CRK");
segment.put("cargoName", "衣服");
segment.put("quantity", 3);
segment.put("weightKg", "1.5");
segment.put("volumeCbm", "100");
segment.put("packageType", "托盘");
java.util.List<String> missing = InquiryTmsQueryService.missingAirQuery(
"WO202610140054", null, segment);
org.junit.jupiter.api.Assertions.assertTrue(missing.isEmpty(), String.valueOf(missing));
}
@Test
public void airQueryAllowsBlankQuoteDate() {
// 空运没填报价日期也算出站齐套,不能再补成当天才放行。
@@ -187,7 +187,7 @@ public class TmsV2RequestMapperTest {
assertEquals("东莞仓库", out.get("placeOfReceipt"));
assertEquals("马尼拉", out.get("deliveryAddress"));
assertEquals("", out.get("origin"));
assertEquals("", out.get("destination"));
assertEquals("克拉克", out.get("destination"));
assertEquals("CRK", out.get("destinationCode"));
assertFalse(out.containsKey("transportTerms"));
assertFalse(out.containsKey("tradeTerms"));
@@ -221,12 +221,38 @@ public class TmsV2RequestMapperTest {
}
@Test
public void airPlaceNamesStayEmptyCodesOnlyAndRawPackage() {
// 地名栏必须空串;三字码只放 *Code。包装原文原样,禁止编成 2。
public void airPlaceNamesKeepOriginalAndCodesWithRawPackage() {
// 地名传销售原文;三字码另放 *Code。包装原文原样,禁止编成 2。
Map<String, Object> seg = new HashMap<>();
seg.put("origin", "ZUH");
seg.put("origin", "广西");
seg.put("destination", "克拉克");
seg.put("destinationCode", "CRK");
seg.put("cargoName", "衣服");
seg.put("quantity", "3");
seg.put("weightKg", "1.5");
seg.put("volumeCbm", "100");
seg.put("packageType", "托盘");
Map<String, Object> internal = new HashMap<>();
internal.put("transportMode", "AIR");
internal.put("quoteDate", "2026-09-25");
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");
Map<String, Object> out = segs.get(0);
assertEquals("广西", out.get("origin"));
assertNull(out.get("originCode"));
assertEquals("克拉克", out.get("destination"));
assertEquals("CRK", out.get("destinationCode"));
assertEquals("托盘", out.get("packageType"));
}
@Test
public void airCodesStillPassWithOriginalNames() {
Map<String, Object> seg = new HashMap<>();
seg.put("origin", "珠海");
seg.put("originCode", "ZUH");
seg.put("destination", "CRK");
seg.put("destination", "克拉克");
seg.put("destinationCode", "CRK");
seg.put("cargoName", "茶叶");
seg.put("quantity", "1");
@@ -241,9 +267,9 @@ public class TmsV2RequestMapperTest {
@SuppressWarnings("unchecked")
List<Map<String, Object>> segs = (List<Map<String, Object>>) body.get("segments");
Map<String, Object> out = segs.get(0);
assertEquals("", out.get("origin"));
assertEquals("珠海", out.get("origin"));
assertEquals("ZUH", out.get("originCode"));
assertEquals("", out.get("destination"));
assertEquals("克拉克", out.get("destination"));
assertEquals("CRK", out.get("destinationCode"));
assertEquals("托盘", out.get("packageType"));
}