合并 feat/continue-deal-card-20261009:报价发出后继续工单重发成交卡;空运锁舱补参与看板滚动一并收下。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-10-09 09:06:43 +08:00
co-authored by Cursor
50 changed files with 2173 additions and 320 deletions
@@ -51,6 +51,24 @@ public class InquiryTmsCabinService {
@Lazy
private InquiryTmsQueryService queryService;
/** 多段询价锁舱的产品口径。智能体群回复与主账拒绝用同一句。 */
public static final String MULTI_LOCK_UNSUPPORTED = "多段询价,暂时不支持锁舱";
/**
* 还没到 TMS 之前的拒绝原因。空运单返回空,表示可以继续锁。
* 多段整单不是 AIR,但航线会按空运段来锁,单独说明暂不支持。
*/
static String lockRejectReason(String businessLine) {
String line = StringUtils.trimToEmpty(businessLine);
if ("MULTI".equalsIgnoreCase(line)) {
return MULTI_LOCK_UNSUPPORTED;
}
if (!"AIR".equalsIgnoreCase(line)) {
return "不是空运单,不能锁舱";
}
return "";
}
/**
* 智能体锁舱入口。
*/
@@ -61,8 +79,11 @@ public class InquiryTmsCabinService {
if (ticket == null) {
return fail("ticket_not_found", "找不到工单");
}
if (!"AIR".equalsIgnoreCase(StringUtils.trimToEmpty(ticket.getBusinessLine()))) {
return fail("not_air", "不是空运单,不能锁舱");
// 多段整单业务线是 MULTI。含空运段时航线仍会来锁舱,不能再说「不是空运单」。
String lockReject = lockRejectReason(ticket.getBusinessLine());
if (StringUtils.isNotBlank(lockReject)) {
String code = MULTI_LOCK_UNSUPPORTED.equals(lockReject) ? "multi_no_lock" : "not_air";
return fail(code, lockReject);
}
if (TicketStatuses.DEAL.equals(ticket.getStatus())
|| TicketStatuses.CLOSED.equals(ticket.getStatus())) {
@@ -233,6 +254,26 @@ public class InquiryTmsCabinService {
req.put("requestedUntil", text(body, "requestedUntil"));
}
}
// 航线口令里的航班号盖过报价。三项没齐就地拦住,不去调 TMS 锁舱。
String flightFromCaller = firstNonBlank(
text(body, "flightNo", "flight_no"),
flightNoFromInstruction(text(body, "instructionText", "instruction_text")));
if (StringUtils.isNotBlank(flightFromCaller)) {
req.put("flightNo", flightFromCaller);
}
if (StringUtils.isBlank(text(req, "airline"))) {
String fromFlight = airlineFromFlight(text(req, "flightNo"));
if (StringUtils.isNotBlank(fromFlight)) {
req.put("airline", fromFlight);
}
}
String incomplete = missingLockParamReason(
text(req, "optionNo"), text(req, "airline"), text(req, "flightNo"));
if (StringUtils.isNotBlank(incomplete)) {
log.info("tms.lock skip incomplete wo={} optionNo={} airline={} flightNo={}",
no, text(req, "optionNo"), text(req, "airline"), text(req, "flightNo"));
return fail("lock_params_incomplete", incomplete);
}
return invokeLock(ticket, req, quoteId, resolvedOption);
}
@@ -475,6 +516,14 @@ public class InquiryTmsCabinService {
req.remove("quoteId");
}
fillLockQuoteExtras(req, quote, option);
if (StringUtils.isBlank(text(req, "validUntil"))) {
// 航线手工价经常不写有效期。TMS 人工锁舱必填 validUntil,
// 用工单报价日(调用前已放进 flightDate)的当天结束,不再空着出站。
String iso = normalizeValidUntilIso(text(req, "flightDate"));
if (StringUtils.isNotBlank(iso)) {
req.put("validUntil", iso);
}
}
String transfer = firstNonBlank(text(option, "transferAirport", "transferAirportCode"),
text(quote, "transferAirport", "transferAirportCode"));
if (StringUtils.isNotBlank(transfer)) {
@@ -1185,6 +1234,50 @@ public class InquiryTmsCabinService {
return row;
}
/**
* 锁舱三项没齐时的群回复。不叫锁舱失败,不提 TMS 查价结果。
* 三项都缺时是:锁舱参数不完整,缺少:舱位编号、航司、航班号。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。
*/
static String missingLockParamReason(String optionNo, String airline, String flightNo) {
java.util.List<String> missing = new ArrayList<>();
if (StringUtils.isBlank(optionNo)) {
missing.add("舱位编号");
}
if (StringUtils.isBlank(airline)) {
missing.add("航司");
}
if (StringUtils.isBlank(flightNo)) {
missing.add("航班号");
}
if (missing.isEmpty()) {
return "";
}
return "锁舱参数不完整,缺少:" + String.join("、", missing)
+ "。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。";
}
/**
* 从「AIR-OPT01 CZ 航班CZ327」抽出航班号。没有就不猜。
*/
static String flightNoFromInstruction(String instruction) {
if (StringUtils.isBlank(instruction)) {
return "";
}
java.util.regex.Matcher named = java.util.regex.Pattern
.compile("(?i)(?:航班号|航班)\\s*[::]?\\s*(?:是|为)?\\s*([A-Za-z]{2}\\s*\\d{2,4})")
.matcher(instruction);
if (named.find()) {
return named.group(1).toUpperCase();
}
java.util.regex.Matcher trailed = java.util.regex.Pattern
.compile("(?i)AIR-?OPT-?\\d+\\s+(?:[A-Za-z]{2}\\s+)?([A-Za-z]{2}\\d{2,4})")
.matcher(instruction);
if (trailed.find()) {
return trailed.group(1).toUpperCase();
}
return "";
}
/**
* 从锁舱口令抽出两位航司码,例如「锁AIR-OPT01 TH」→ TH。
*/
@@ -4,6 +4,7 @@ package org.jeecg.modules.inquiry.tms;
* 陆运运输类型 / 线路类别出站:产品要求原文直传,不再翻成 TMS 英文枚举。
* <p>
* 职责:统一 trim;空串表示未填。工单/卡片/拉人本来就是中文,出站与之一致。
* 中港整车、中港零担/集拼查价只传「中港」,工单仍留细类。
* 禁止:再把「跨境整车」写成 CROSS_BORDER_FTL;把运输分类、中港装载方式塞进出站。
* 历史英文码若仍传入,原样放过(兼容旧快照),不做双向翻译。
*/
@@ -14,10 +15,15 @@ public final class TmsLandEnumMapper {
/**
* @param raw 销售中文(或历史英文码)
* @return trim 后原文;空则 ""
* @return 中港两类收成「中港」;其余 trim 后原文;空则 ""
*/
public static String transportType(String raw) {
return trimOrEmpty(raw);
String text = trimOrEmpty(raw);
if ("中港整车".equals(text) || "中港零担/集拼".equals(text)) {
// zhonggang: 两类细类查价都只传「中港」
return "中港";
}
return text;
}
/**
@@ -18,8 +18,8 @@ import java.util.regex.Pattern;
* 职责:把工单 facts 里的中文键写成 TMS 段;运输分类只留在工单,不进出站。
* 拼车/跨境集拼带体积重量;国内整车/跨境整车/中港零担带「车型/数量」一项再拆成出站车型与台数;
* 中港整车不带车型/数量。通关口岸有值才放。
* 中港整车、中港零担/集拼:出站类型仍用销售中文「中港整车」「中港零担/集拼」(产品要求不做英文枚举映射)。
* 类型/线路键名仍是 landTransportType / routeCategory,值用中文原文。
* 中港整车、中港零担/集拼:出站 landTransportType 只写「中港」,工单事实仍留细类。
* 类型/线路键名仍是 landTransportType / routeCategory。除中港两类外,值用中文原文。
* 禁止:按空运要包装件数;把分类当查价条件;直连 TMS;再把中文翻成 CROSS_BORDER_*。
*/
public final class TmsLandSegmentMapper {
@@ -151,7 +151,7 @@ public final class TmsV2RequestMapper {
}
/**
* 陆运出站:类型+线路用销售中文原文(产品要求不做英文枚举映射);线路字段名是 routeCategory。
* 陆运出站:类型+线路用销售中文;中港整车、中港零担/集拼收成「中港」。线路字段名是 routeCategory。
* 分类和中港装载即使误放在 source 也不抄走。
*/
private static void applyLandFields(Map<String, Object> source, Map<String, Object> target) {
@@ -13,7 +13,13 @@
{"source_name": "北京首都", "source_code": "PEK"},
{"source_name": "北京大兴", "source_code": "PKX"},
{"source_name": "珠海", "source_code": "ZUH"},
{"source_name": "克拉克", "source_code": "CRK"}
{"source_name": "克拉克", "source_code": "CRK"},
{"source_name": "南京", "source_code": "NKG"},
{"source_name": "吉隆坡", "source_code": "KUL"},
{"source_name": "北京", "source_code": "PEK"},
{"source_name": "杭州", "source_code": "HGH"},
{"source_name": "萧山", "source_code": "HGH"},
{"source_name": "杭州萧山", "source_code": "HGH"}
],
"air_packaging_type": [
{"source_name": "全部", "source_code": "1"},
@@ -15,6 +15,15 @@ import static org.junit.jupiter.api.Assertions.assertNull;
*/
public class InquiryTmsCabinServiceTest {
@Test
public void multiInquiryCannotLockCabin() {
assertEquals(
"多段询价,暂时不支持锁舱",
InquiryTmsCabinService.lockRejectReason("MULTI"));
assertEquals("不是空运单,不能锁舱", InquiryTmsCabinService.lockRejectReason("SEA"));
assertEquals("", InquiryTmsCabinService.lockRejectReason("AIR"));
}
@Test
public void pickNamedOption() {
Map<String, Object> quote = new HashMap<>();
@@ -186,6 +195,34 @@ public class InquiryTmsCabinServiceTest {
assertEquals("空运费", fees.get(0).get("feeName"));
}
@Test
public void manualLockFillsValidUntilFromFlightDateWhenQuoteHasNone() {
Map<String, Object> req = new HashMap<>();
req.put("flightDate", "2026-10-11");
InquiryTmsCabinService.fillManualLock(req, new HashMap<>(), new HashMap<>(), new HashMap<>(), false);
assertEquals("2026-10-11T23:59:59+08:00", req.get("validUntil"));
assertEquals("2026-10-11T23:59:59+08:00", req.get("requestedUntil"));
}
@Test
public void missingLockParamsAsksWithoutCallingTmsWording() {
assertEquals(
"锁舱参数不完整,缺少:舱位编号、航司、航班号。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。",
InquiryTmsCabinService.missingLockParamReason("", "", ""));
assertEquals(
"锁舱参数不完整,缺少:航班号。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。",
InquiryTmsCabinService.missingLockParamReason("AIR-OPT01", "CZ", ""));
assertEquals("", InquiryTmsCabinService.missingLockParamReason("AIR-OPT01", "CZ", "CZ327"));
}
@Test
public void flightNoFromInstructionReadsSampleFormat() {
assertEquals("CZ327", InquiryTmsCabinService.flightNoFromInstruction(
"AIR-OPT01 CZ 航班CZ327"));
assertEquals("CZ327", InquiryTmsCabinService.flightNoFromInstruction("AIR-OPT01 CZ CZ327"));
assertEquals("", InquiryTmsCabinService.flightNoFromInstruction("WO202610080023锁舱"));
}
@Test
public void airlineFromInstructionReadsCodeAfterOption() {
assertEquals("TH", InquiryTmsCabinService.airlineFromInstruction(
@@ -32,4 +32,11 @@ public class TmsExactProjectorTest {
assertTrue(hit.ok());
assertEquals("SHA", hit.code);
}
@Test
public void hangzhouMapsToXiaoshan() {
TmsExactProjector.Projection hit = projector.resolveAirport("杭州");
assertTrue(hit.ok());
assertEquals("HGH", hit.code);
}
}
@@ -27,6 +27,13 @@ public class TmsLandEnumMapperTest {
assertEquals("", TmsLandEnumMapper.routeCategory(" "));
}
@Test
public void hkTypesCollapseToZhonggang() {
assertEquals("中港", TmsLandEnumMapper.transportType("中港整车"));
assertEquals("中港", TmsLandEnumMapper.transportType("中港零担/集拼"));
assertEquals("中港集拼", TmsLandEnumMapper.transportType("中港集拼"));
}
@Test
public void legacyEnglishCodesPassThrough() {
assertEquals("DOMESTIC_LTL", TmsLandEnumMapper.transportType("DOMESTIC_LTL"));
@@ -46,7 +46,7 @@ public class TmsLandSegmentMapperTest {
facts.put("车型/数量", "9米6/1");
facts.put("体积", "99");
Map<String, Object> seg = TmsLandSegmentMapper.build(facts);
assertEquals("中港零担/集拼", seg.get("landTransportType"));
assertEquals("中港", seg.get("landTransportType"));
assertEquals("9米6", seg.get("vehicleType"));
assertNull(seg.get("volumeCbm"));
assertFalse(seg.containsKey("hkLoadType"));
@@ -62,7 +62,7 @@ public class TmsLandSegmentMapperTest {
facts.put("目的港", "香港");
facts.put("品名", "衣服");
Map<String, Object> seg = TmsLandSegmentMapper.build(facts);
assertEquals("中港整车", seg.get("landTransportType"));
assertEquals("中港", seg.get("landTransportType"));
assertNull(seg.get("vehicleType"));
assertNull(seg.get("quantity"));
java.util.List<String> missing = TmsLandSegmentMapper.missingQuery(