员工岗位可重新启用;调价认空运航线;会话存档跳号补拉;陆运报价按车型列回填

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-10-28 19:48:47 +08:00
co-authored by Cursor
parent 2b528758b4
commit 56ba8e93b5
62 changed files with 2960 additions and 793 deletions
@@ -145,6 +145,19 @@ public class InquiryStaffRoleController extends JeecgController<InquiryStaffRole
return Result.OK("已停用");
}
/**
* 重新启用。权限与停用相同(inquiry:staff:disable),避免再扩一套权限码。
* 查询参数 id,前端非 GET 须 joinParamsToUrl。
*/
@AutoLog(value = "员工岗位-启用")
@ApiOperation("启用")
@RequiresPermissions("inquiry:staff:disable")
@PostMapping("/enable")
public Result<String> enable(@RequestParam String id) {
staffRoleService.enable(id);
return Result.OK("已启用");
}
@ApiOperation("按 id 查询")
@RequiresPermissions("inquiry:staff:list")
@GetMapping("/queryById")
@@ -21,6 +21,11 @@ public interface IInquiryStaffRoleService extends IService<InquiryStaffRole> {
*/
void disable(String id);
/**
* 重新启用员工(status=active)。
*/
void enable(String id);
/**
* 从企微通讯录全量 upsert:按 wecom_id 更新姓名/手机/部门,缺失则新建(默认销售岗)。
* <p>
@@ -32,6 +32,7 @@ import org.jeecg.modules.inquiry.template.QuoteSheetFileName;
import org.jeecg.modules.inquiry.template.QuoteTemplateMatcher;
import org.jeecg.modules.inquiry.template.QuoteTemplateObjectStore;
import org.jeecg.modules.inquiry.template.QuoteTemplateSnapshotRenderer;
import org.jeecg.modules.inquiry.tms.TmsLandSegmentMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@@ -42,6 +43,7 @@ import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
@@ -1172,9 +1174,9 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
out.put("quote_date", new java.text.SimpleDateFormat("yyyy-MM-dd").format(new Date()));
}
out.put("valid_until", first(q, "valid_until"));
// 预估费用必须用 TMS 总销售价。会话 quote 偶发只有 selected_air_option.salePrice、
// 顶层 total 空时,若仍走模板 SUM,会把增点费+拆板费加成 200(WO040/WO026)。
// 群出单还可能把明细之和误写成 total=200,此时仍以线路 salePrice 为准。
// 预估费用:顶层 total 空时用舱位 salePrice。
// 薄报价把附加费之和误写成 total=200(<< salePrice)时盖成销售价(WO040/WO026)。
// 航线在舱位价上再加增值费/增点费时 total=明细之和会大于 salePrice,必须保留,禁止盖回。
String fromOpt = airOptionSalePrice(q);
String totalAmount = first(q, "total", "total_amount", "salePrice", "totalSalePrice");
if (StringUtils.isBlank(totalAmount) || "-".equals(totalAmount)) {
@@ -1182,6 +1184,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
} else if (StringUtils.isNotBlank(fromOpt)
&& looksAirInquiry(src, q, businessLine)
&& totalLooksLikeFeeRowSum(q, totalAmount)
&& totalIsThinVsSalePrice(totalAmount, fromOpt)
&& !sameMoneyAmount(totalAmount, fromOpt)) {
totalAmount = fromOpt;
}
@@ -1261,12 +1264,18 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
}
// 空运:完整明细在 airOptions 分桶 + 顶层 feeItems(空运段)。
// 行数相同也优先分桶解析(fee_lines 常把单价误当金额,缺单位/项目列)。
// 有 selected_air_option 时必须跟选定舱位,禁止写列表首条单价(WO202610270003)。
Object quoteFeeItems = q.get("feeItems");
if (quoteFeeItems == null) {
quoteFeeItems = q.get("fee_items");
}
Object selectedAir = q.get("selected_air_option");
if (selectedAir == null) {
selectedAir = q.get("selectedAirOption");
}
JSONArray fromAir = QuoteTemplateExcelSupport.parseAirDepartureChargeRows(
q.get("airOptions"), q.get("air_options"), quoteFeeItems, chargeable, currency);
q.get("airOptions"), q.get("air_options"), quoteFeeItems, selectedAir,
chargeable, currency);
if (!fromAir.isEmpty()) {
// 分桶优先,但 flat fee_lines 里多出来的人工调价(如「增值费」)必须并进模板,
// 禁止整表替换后把群摘要已展示的费用丢掉(WO202610140006)。
@@ -1378,6 +1387,8 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
out.put("selected_land_route", selectedLand);
}
if (!landSegs.isEmpty()) {
// 产品文字价/分档壳偶发无 vehicleType:用询价「车型/数量」补上,否则整车矩阵车型列空、金额只进合计。
enrichLandRouteVehicleFromInquiry(landSegs, out, src);
out.put("landSegments", landSegs);
out.put("route_rows", landSegs);
// 首段时效/燃油/税率抬到顶层,供侧栏与标签回填(WO006 燃油列不能空着有数却写不上)
@@ -1480,9 +1491,11 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
one.put("amount", landAmt);
one.put("salePrice", landAmt);
}
// WO047:人工「陆运费」无 TMS 段时也必须带上车型,否则境外 10T 列空、金额只进合计。
JSONArray fallbackRows = new JSONArray();
fallbackRows.add(one);
enrichLandRouteVehicleFromInquiry(fallbackRows, out, src);
if (StringUtils.isNotBlank(one.getString("origin")) || StringUtils.isNotBlank(one.getString("destination"))) {
JSONArray fallbackRows = new JSONArray();
fallbackRows.add(one);
out.put("route_rows", fallbackRows);
}
}
@@ -1509,6 +1522,53 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
return "";
}
/**
* 线路行缺 vehicleType 时,从询价「车型/数量」拆出车型补上。
* <p>
* 为何:TMS 无匹配、产品只报「陆运费」时 buildFillFacts 会拼 OD+金额回退行,
* 若不带车型,整车矩阵只能写「金额合计」、10T 等车型列会空(WO202610210047)。
* 副作用:只改传入的 JSONArray 行对象;不写库。线程:出单请求线程。
*/
private static void enrichLandRouteVehicleFromInquiry(JSONArray landSegs, JSONObject out,
Map<String, Object> src) {
if (landSegs == null || landSegs.isEmpty()) {
return;
}
String combo = firstNonBlankLocal(
out == null ? null : out.getString("vehicle_qty"),
first(src, "车型/数量"),
first(src, "车型数量"),
first(src, "车型"),
out == null ? null : out.getString("vehicleType"),
out == null ? null : out.getString("vehicle_type"));
if (StringUtils.isBlank(combo)) {
return;
}
String[] parts = TmsLandSegmentMapper.splitVehicleQty(combo);
String vehicle = parts == null || parts.length < 1 ? "" : StringUtils.trimToEmpty(parts[0]);
String qty = parts == null || parts.length < 2 ? "1" : StringUtils.trimToEmpty(parts[1]);
if (StringUtils.isBlank(vehicle)) {
return;
}
int patched = 0;
for (int i = 0; i < landSegs.size(); i++) {
JSONObject seg = landSegs.getJSONObject(i);
if (seg == null) {
continue;
}
String existing = firstNonBlankLocal(seg.getString("vehicleType"), seg.getString("vehicle_type"));
if (StringUtils.isNotBlank(existing)) {
continue;
}
seg.put("vehicleType", vehicle);
seg.put("vehicle_type", vehicle);
if (seg.get("quantity") == null && StringUtils.isNotBlank(qty)) {
seg.put("quantity", qty);
}
patched++;
}
}
/**
* 空运选定线路 / 首条 airOptions 的 salePrice。
* 会话 quote 有时只投影了线路对象,顶层 total 空,出单预估费用必须能落到这个数。
@@ -1569,6 +1629,19 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
opt.getString("totalSalePrice"), opt.getString("total"));
}
/**
* 薄报价:明细加总明显小于舱位销售价(如 200 &lt; 7918)才允许盖成 salePrice。
* 航线加增值费后 total&gt;salePrice 时返回 false,保留航线总价。
*/
private static boolean totalIsThinVsSalePrice(String totalText, String salePriceText) {
Double totalNum = parseMoneyPlain(totalText);
Double saleNum = parseMoneyPlain(salePriceText);
if (totalNum == null || saleNum == null) {
return false;
}
return totalNum + 0.01 < saleNum;
}
/**
* 顶层 total 是否只是 fee_lines 金额相加(如 100+100=200)。
* 空运群薄报价常把附加费之和误当总价,真总价在 airOptions.salePrice。
@@ -64,6 +64,21 @@ public class InquiryStaffRoleServiceImpl extends ServiceImpl<InquiryStaffRoleMap
updateById(row);
}
/**
* 重新启用:停用后列表可点「启用」恢复 active,智能体匹配只认启用员工。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void enable(String id) {
InquiryStaffRole row = getById(id);
if (row == null) {
throw new IllegalArgumentException("员工不存在: " + id);
}
row.setStatus("active");
row.setUpdateTime(new Date());
updateById(row);
}
/**
* 按 wecom_id 查一条(智能体/后台可复用)。
*/
@@ -45,6 +45,11 @@ import com.alibaba.fastjson.JSONObject;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collections;
@@ -931,6 +936,7 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
return org.jeecg.modules.inquiry.tms.TmsV2RequestMapper.asVolumeCbm(raw);
}
private static Date parseDate(String raw) {
if (StringUtils.isBlank(raw)) {
return null;
@@ -1006,9 +1012,13 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
if (StringUtils.isNotBlank(ticket.getContainerSpec())) {
facts.put("箱型箱量", ticket.getContainerSpec());
}
String storedVolumeBeforeMerge = "";
if (StringUtils.isNotBlank(ticket.getFactsJson())) {
JSONObject stored = JSON.parseObject(ticket.getFactsJson());
if (stored != null) {
if (stored.get("体积") != null) {
storedVolumeBeforeMerge = String.valueOf(stored.get("体积"));
}
for (String key : stored.keySet()) {
if (StringUtils.isBlank(facts.get(key)) && stored.get(key) != null) {
facts.put(key, String.valueOf(stored.get(key)));
@@ -1016,6 +1026,10 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
}
}
}
// 体积列是 TMS 用的相乘 CBM;销售侧展示/群卡必须保留 factsJson 尺寸原文(如 1.1*2.1*2.2)。
if (StringUtils.isNotBlank(storedVolumeBeforeMerge)) {
facts.put("体积", storedVolumeBeforeMerge.trim());
}
// 最新一条=当前生效价;TMS 原价单独留,拉群摘要禁止用调后价冒充 TMS。
Map<String, Object> quote = new LinkedHashMap<>();
Map<String, Object> tmsQuote = new LinkedHashMap<>();
@@ -1,9 +1,16 @@
package org.jeecg.modules.inquiry.support;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import org.apache.commons.lang3.StringUtils;
import org.jeecg.modules.inquiry.entity.InquiryTicket;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
@@ -54,19 +61,19 @@ public final class InquiryTicketDisplaySupport {
if ("AIR".equals(businessLine) || containsChinese(lineLabel, "空运")) {
addLabel(quantities, "件数", ticket.getPieces());
addUnit(quantities, "重量(KG)", ticket.getWeightKg(), "kg");
addUnit(quantities, "体积(CBM)", ticket.getVolumeCbm(), "CBM");
addVolumeDisplay(quantities, ticket);
} else if ("SEA".equals(businessLine) || containsChinese(lineLabel, "海运")) {
addLabel(quantities, "件数", ticket.getPieces());
addUnit(quantities, "重量(KG)", ticket.getWeightKg(), "kg");
addUnit(quantities, "体积(CBM)", ticket.getVolumeCbm(), "CBM");
addVolumeDisplay(quantities, ticket);
} else if ("LAND".equals(businessLine) || containsChinese(lineLabel, "陆运")) {
addUnit(quantities, "体积(CBM)", ticket.getVolumeCbm(), "CBM");
addVolumeDisplay(quantities, ticket);
addUnit(quantities, "重量(KG)", ticket.getWeightKg(), "kg");
addLabel(quantities, "件数", ticket.getPieces());
} else {
addLabel(quantities, "件数", ticket.getPieces());
addUnit(quantities, "重量(KG)", ticket.getWeightKg(), "kg");
addUnit(quantities, "体积(CBM)", ticket.getVolumeCbm(), "CBM");
addVolumeDisplay(quantities, ticket);
}
addLine(lines, join(quantities, " · "));
@@ -191,6 +198,36 @@ public final class InquiryTicketDisplaySupport {
return String.valueOf(value).trim();
}
/**
* 后台摘要体积:优先 factsJson 销售原文(尺寸式),没有再退回相乘后的 CBM 列。
* 原文不加「CBM」后缀,避免 1.1*2.1*2.2CBM。
*/
private static void addVolumeDisplay(List<String> quantities, InquiryTicket ticket) {
String raw = volumeFromFactsJson(ticket);
if (StringUtils.isNotBlank(raw)) {
addLabel(quantities, "体积(CBM)", raw);
return;
}
addUnit(quantities, "体积(CBM)", ticket.getVolumeCbm(), "CBM");
}
/** 从 factsJson 取体积原文;坏 JSON 或没有则空。 */
private static String volumeFromFactsJson(InquiryTicket ticket) {
if (ticket == null || StringUtils.isBlank(ticket.getFactsJson())) {
return "";
}
try {
JSONObject o = JSON.parseObject(ticket.getFactsJson());
if (o == null || o.get("体积") == null) {
return "";
}
return StringUtils.trimToEmpty(String.valueOf(o.get("体积")));
} catch (Exception ignore) {
return "";
}
}
private static String join(List<String> values, String sep) {
if (values == null || values.isEmpty()) {
return null;
@@ -51,6 +51,13 @@ public final class LandQuoteTemplateFields {
add("vehicle_10t_fee", "9.4*2.4*2.55M/10T费用", "vehicle10TFee");
add("vehicle_40hq_fee", "11.8*2.32*2.7M/40'HQ费用", "vehicle40HQFee");
add("vehicle_45hq_fee", "12.8*2.32*2.7M/45'HQ费用", "vehicle45HQFee");
// 跨境整车矩阵短表头:询价写 40HQ,旧模板曾写 40'HQ,两种都认
LABEL_TO_CODE.put("40'HQ", "vehicle_40hq_fee");
LABEL_TO_CODE.put("40HQ", "vehicle_40hq_fee");
LABEL_TO_CODE.put("45'HQ", "vehicle_45hq_fee");
LABEL_TO_CODE.put("45HQ", "vehicle_45hq_fee");
LABEL_TO_CODE.put("11.8*2.32*2.7M/40HQ费用", "vehicle_40hq_fee");
LABEL_TO_CODE.put("12.8*2.32*2.7M/45HQ费用", "vehicle_45hq_fee");
add("vehicle_48rh_fee", "14.6*2.28*2.5M/48RH冷柜费用", "vehicle48RHFee");
add("vehicle_53r_fee", "16.3*2.7*2.8M/53尺费用", "vehicle53RFee");
add("vehicle_1375_fee", "13.5*3M/13.75平板费用", "vehicle13.75TFee");
@@ -178,11 +178,8 @@ public class InquiryTmsCabinService {
if (!properties.lockConfigured()) {
return fail("lock_disabled", "主账未开放锁舱");
}
// 工单报价日空/脏(如事实只有「:」)时,先拦再出站。
// 禁止用舱位起飞日顶过去后打 TMS,再回「选项不匹配」(WO202610140047)。
if (isTicketQuoteDateMissing(ticket)) {
return fail("lock_incomplete", "报价日期为空,请补充报价日期后重新锁舱");
}
// 报价日空/脏:并入下方 missingLockParamReason(可与舱位三项同句补问),
// 禁止用舱位起飞日顶过去后打 TMS(WO202610140047)。
Map<String, Object> req = new LinkedHashMap<>();
req.put("workOrderNo", no);
if (StringUtils.isNotBlank(quoteId)) {
@@ -301,10 +298,14 @@ public class InquiryTmsCabinService {
}
}
String incomplete = missingLockParamReason(
text(req, "optionNo"), text(req, "airline"), text(req, "flightNo"));
text(req, "optionNo"),
text(req, "airline"),
text(req, "flightNo"),
isTicketQuoteDateMissing(ticket));
if (StringUtils.isNotBlank(incomplete)) {
log.info("tms.lock skip incomplete wo={} optionNo={} airline={} flightNo={}",
no, text(req, "optionNo"), text(req, "airline"), text(req, "flightNo"));
log.info("tms.lock skip incomplete wo={} optionNo={} airline={} flightNo={} quoteDateMissing={}",
no, text(req, "optionNo"), text(req, "airline"), text(req, "flightNo"),
isTicketQuoteDateMissing(ticket));
return fail("lock_params_incomplete", incomplete);
}
return invokeLock(ticket, req, quoteId, resolvedOption);
@@ -912,8 +913,13 @@ public class InquiryTmsCabinService {
return "";
}
// 列空、事实只有「:」等脏值,都算报价日缺失(WO047)。
// 与舱位缺项同一句,避免航线先补三项再被单独拦报价日。
if (isTicketQuoteDateMissing(ticket)) {
return "报价日期为空,请补充报价日期后重新锁舱";
return missingLockParamReason(
text(req, "optionNo"),
text(req, "airline"),
text(req, "flightNo"),
true);
}
boolean noMatch = "TMS_NO_QUOTE_1002".equals(StringUtils.trimToEmpty(refreshClass))
|| (StringUtils.isNotBlank(refreshMsg) && refreshMsg.contains("无匹配"));
@@ -1302,10 +1308,20 @@ public class InquiryTmsCabinService {
}
/**
* 锁舱三项没齐时的群回复。不叫锁舱失败,不提 TMS 查价结果。
* 三项都缺时是:锁舱参数不完整,缺少:舱位编号、航司、航班号。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。
* 锁舱参数没齐时的群回复。不叫锁舱失败,不提 TMS 查价结果。
* <p>
* 舱位三项都缺且报价日有值:缺少:舱位编号、航司、航班号。…例如:AIR-OPT01,南航,航班CZ327。
* 报价日也空:缺项末尾加「报价日期」,示例末尾加「,2026-10-18」。
*/
static String missingLockParamReason(String optionNo, String airline, String flightNo) {
return missingLockParamReason(optionNo, airline, flightNo, false);
}
/**
* @param quoteDateMissing 工单报价日空/脏时为 true,并入缺项列表
*/
static String missingLockParamReason(
String optionNo, String airline, String flightNo, boolean quoteDateMissing) {
java.util.List<String> missing = new ArrayList<>();
if (StringUtils.isBlank(optionNo)) {
missing.add("舱位编号");
@@ -1316,11 +1332,17 @@ public class InquiryTmsCabinService {
if (StringUtils.isBlank(flightNo)) {
missing.add("航班号");
}
if (quoteDateMissing) {
missing.add("报价日期");
}
if (missing.isEmpty()) {
return "";
}
String example = quoteDateMissing
? "AIR-OPT01,南航,航班CZ327,2026-10-18"
: "AIR-OPT01,南航,航班CZ327";
return "锁舱参数不完整,缺少:" + String.join("、", missing)
+ "。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。";
+ "。请补充后再锁,例如:" + example + "。";
}
/**
@@ -4,7 +4,7 @@ package org.jeecg.modules.inquiry.tms;
* 陆运运输类型 / 线路类别出站:产品要求原文直传,不再翻成 TMS 英文枚举。
* <p>
* 职责:统一 trim;空串表示未填。工单/卡片/拉人本来就是中文,出站与之一致。
* 中港整车、中港零担/集拼查价只传「中港」,工单仍留细类。
* 中港整车、中港零担/集拼也按细类原文传 landTransportType,不再收成「中港」。
* 禁止:再把「跨境整车」写成 CROSS_BORDER_FTL;把运输分类、中港装载方式塞进出站。
* 历史英文码若仍传入,原样放过(兼容旧快照),不做双向翻译。
*/
@@ -15,15 +15,10 @@ public final class TmsLandEnumMapper {
/**
* @param raw 销售中文(或历史英文码)
* @return 中港两类收成「中港」;其余 trim 后原文;空则 ""
* @return trim 后原文(含中港整车 / 中港零担/集拼);空则 ""
*/
public static String transportType(String raw) {
String text = trimOrEmpty(raw);
if ("中港整车".equals(text) || "中港零担/集拼".equals(text)) {
// zhonggang: 两类细类查价都只传「中港」
return "中港";
}
return text;
return trimOrEmpty(raw);
}
/**
@@ -16,10 +16,10 @@ import java.util.regex.Pattern;
* 陆运 TMS 查价段:按运输类型拼出站字段。
* <p>
* 职责:把工单 facts 里的中文键写成 TMS 段;运输分类只留在工单,不进出站。
* 拼车/跨境集拼带体积重量;国内整车/跨境整车/中港零担带「车型/数量」一项再拆成出站车型与台数;
* 中港整车不带车型/数量。通关口岸有值才放。
* 中港整车、中港零担/集拼:出站 landTransportType 只写「中港」,工单事实仍留细类。
* 类型/线路键名仍是 landTransportType / routeCategory。除中港两类外,值用中文原文。
* 拼车/跨境集拼带体积重量;国内整车/跨境整车/中港整车带「车型/数量」一项再拆成出站车型与台数;
* 仅中港零担/集拼不带车型/数量。通关口岸有值才放。
* 中港整车、中港零担/集拼:出站 landTransportType 按细类原文(不再收成「中港」)。
* 类型/线路键名仍是 landTransportType / routeCategory;值用中文原文。
* 禁止:按空运要包装件数;把分类当查价条件;直连 TMS;再把中文翻成 CROSS_BORDER_*。
*/
public final class TmsLandSegmentMapper {
@@ -27,9 +27,12 @@ public final class TmsLandSegmentMapper {
/** 体积+重量那一套。 */
private static final Set<String> LCL = new HashSet<String>(
Arrays.asList("国内运输拼车", "跨境集拼"));
/** 车型/数量那一套。中港零担走这里;中港整车另判。 */
/** 车型/数量那一套:国内整车、跨境整车、中港整车。 */
private static final Set<String> FTL = new HashSet<String>(
Arrays.asList("国内运输整车", "跨境整车", "中港零担/集拼"));
Arrays.asList("国内运输整车", "跨境整车", "中港整车"));
/** 仅中港零担/集拼:询价不采车型/数量,出站也不带。 */
private static final Set<String> HK_NO_VEHICLE = new HashSet<String>(
Arrays.asList("中港零担/集拼"));
private static final Pattern VEHICLE_SPLIT = Pattern.compile("\\s*[/xX×**]\\s*");
private static final Pattern TAIL_QTY = Pattern.compile("^(.*?)[\\s,,]+(\\d+)\\s*(台|辆)?$");
@@ -54,8 +57,8 @@ public final class TmsLandSegmentMapper {
if (LCL.contains(type)) {
put(seg, "volumeCbm", TmsV2RequestMapper.asVolumeCbm(source.get("体积")));
put(seg, "weightKg", TmsV2RequestMapper.asPositiveDecimal(source.get("毛重")));
} else if ("中港整车".equals(type)) {
// 中港整车:询价不要车型/数量,出站也不带
} else if (HK_NO_VEHICLE.contains(type)) {
// 中港零担/集拼:询价不要车型/数量,出站也不带
} else if (FTL.contains(type)) {
putVehicleQty(source, seg);
}
@@ -68,7 +71,7 @@ public final class TmsLandSegmentMapper {
/**
* 陆运出站拦网:工单号、报价日期、起运目的品名、类型、线路,再加当前类型货物。
* 不要包装、不要运输分类。中港整车不要车型/数量。
* 不要包装、不要运输分类。仅中港零担/集拼不要车型/数量。
*
* @param facts 用于读运输类型;禁止把分类写进 segment
*/
@@ -98,13 +101,13 @@ public final class TmsLandSegmentMapper {
|| LCL.contains(typeOnSeg)
|| "DOMESTIC_LTL".equals(typeOnSeg)
|| "CROSS_BORDER_LTL".equals(typeOnSeg);
boolean hkFull = "中港整车".equals(cnType) || "中港整车".equals(typeOnSeg);
// 中港细类出站已原文直传;仍同时认 facts 与段上的类型,兼容旧「中港」快照。
boolean hkNoVehicle = HK_NO_VEHICLE.contains(cnType)
|| HK_NO_VEHICLE.contains(typeOnSeg);
boolean ftl = FTL.contains(cnType)
|| FTL.contains(typeOnSeg)
|| "DOMESTIC_FTL".equals(typeOnSeg)
|| "CROSS_BORDER_FTL".equals(typeOnSeg)
|| "CHINA_HK".equals(typeOnSeg)
|| "中港零担/集拼".equals(cnType);
|| "CROSS_BORDER_FTL".equals(typeOnSeg);
if (lcl) {
if (seg.get("volumeCbm") == null) {
missing.add("volumeCbm");
@@ -112,8 +115,8 @@ public final class TmsLandSegmentMapper {
if (seg.get("weightKg") == null) {
missing.add("weightKg");
}
} else if (hkFull) {
// 中港整车不拦车型/数量
} else if (hkNoVehicle) {
// 中港零担/集拼:不拦车型/数量
} else if (ftl) {
if (blank(seg.get("vehicleType"))) {
missing.add("vehicleType");
@@ -126,7 +129,7 @@ public final class TmsLandSegmentMapper {
}
/**
* 兼容旧调用:没有 facts 时按段上的类型拦;中港整车必须走四参才能放过车型。
* 兼容旧调用:没有 facts 时按段上的类型拦;中港零担须走四参才能放过车型。
*/
public static List<String> missingQuery(String workOrderNo, String quoteDate,
Map<String, Object> segment) {
@@ -163,9 +166,11 @@ public final class TmsLandSegmentMapper {
}
/**
* 例:9米6/1、9米6*2、9米6 1台。拆不出台数时默认 1。
* 例:9米6/1、9米6*2、9米6 1台、10T车/1。拆不出台数时默认 1。
* <p>
* 出单回填无 TMS 段时,也要按同一规则从询价「车型/数量」拆出 vehicleType。
*/
static String[] splitVehicleQty(String raw) {
public static String[] splitVehicleQty(String raw) {
String text = text(raw);
if (text.isEmpty()) {
return new String[] {"", "1"};
@@ -160,7 +160,7 @@ public final class TmsV2RequestMapper {
}
/**
* 陆运出站:类型+线路用销售中文;中港整车、中港零担/集拼收成「中港」。线路字段名是 routeCategory。
* 陆运出站:类型+线路用销售中文(含中港整车 / 中港零担/集拼细类)。线路字段名是 routeCategory。
* 分类和中港装载即使误放在 source 也不抄走。
*/
private static void applyLandFields(Map<String, Object> source, Map<String, Object> target) {
@@ -166,6 +166,9 @@
"体积": "volume_cbm",
"体积(CBM)": "volume_cbm",
"体 积": "volume_cbm",
"计费重": "chargeable_weight_kg",
"计费重量": "chargeable_weight_kg",
"Chargeable Weight": "chargeable_weight_kg",
"费用总计": "total_amount",
"报价合计": "total_amount",
"RFQ No": "quote_no",
@@ -1,6 +1,6 @@
{
"schemaVersion": "tms-domestic-vehicle-mapping-v1",
"description": "TMS车型/别名 → 国内整车报价单表头车型;来源 tms国内车型映射表 -ai.xlsx",
"description": "TMS车型/别名 → 国内整车报价单表头车型;来源 tms国内车型映射表 -ai.xlsx。domesticHeaderToHkHeader 为国内表头与中港表头对等(填表时双向)。",
"templateToDomesticAliases": {
"4.2米/3T": [
"3T危险品",
@@ -99,6 +99,13 @@
"3.5T"
]
},
"domesticHeaderToHkHeader": {
"6.8/5T": "5米/5T",
"6.8米/5T": "5米/5T",
"7.6米/8T": "6.5米/8T",
"9.6米/10T": "7.8米/10T",
"12.5米/40尺": "12米/40尺"
},
"domesticAliasToTemplate": {
"3T危险品": "4.2米/3T",
"4.2厢车": "4.2米/3T",
@@ -17,6 +17,7 @@ import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import org.apache.commons.lang3.StringUtils;
@@ -25,6 +26,7 @@ import org.apache.poi.ss.usermodel.CellType;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
@@ -119,6 +121,97 @@ public class QuoteTemplateFillTest {
assertEquals("CNY", ((JSONArray) fee).getJSONObject(0).getString("currency"));
}
/**
* 调试 2672ec / 25b094:体积=1.1*2.1*2.2 时,填表事实里计费重不能原样等于体积。
* 同时验证标签回填是否把体积写进「计费重」右侧格。
* WO007:竖排「体积 / 计费重 / 派送地址」+ 映射写计费重 + 两次 writeFactsByLabel,
* 不得把尺寸式盖进计费重、再把 847 挤进派送地址。
*/
@Test
public void airDimensionVolumeMustNotCopyIntoChargeableWeight() throws Exception {
Map<String, Object> facts = new HashMap<>();
facts.put("运输方式", "空运");
facts.put("品名", "茶叶");
facts.put("件数", "21件");
facts.put("毛重", "11 KG");
facts.put("体积", "1.1*2.1*2.2");
Map<String, Object> quote = new HashMap<>();
quote.put("currency", "CNY");
quote.put("total", "100");
quote.put("chargeableWeightKg", "847.1694");
JSONObject fill = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl
.buildFillFacts("WO202610180046", facts, quote, "WuJiLin", "AIR");
assertEquals("1.1*2.1*2.2", fill.getString("volume_cbm"));
assertEquals("847.1694", fill.getString("chargeable_weight_kg"));
assertFalse(QuoteTemplateExcelSupport.looksLikeVehicleDimensionText("1.1*2.1*2.2"),
"空运体积尺寸式不得被当成陆运车型尺寸");
assertTrue(QuoteTemplateExcelSupport.looksLikeVehicleDimensionText("4.1*2.1*2.3M"),
"陆运车型尺寸必须仍识别");
// 空运常见:标签横排「体积 | 计费重」,中间没有独立空值格。
Path dir = Files.createTempDirectory("air-cw-vol");
Path src = dir.resolve("tpl.xlsx");
Path outXlsx = dir.resolve("out.xlsx");
try (XSSFWorkbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("报价单");
Row labels = sheet.createRow(5);
labels.createCell(0).setCellValue("体积");
labels.createCell(1).setCellValue("计费重");
Row values = sheet.createRow(6);
values.createCell(0).setCellValue("");
values.createCell(1).setCellValue("");
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
}
JSONObject mapping = new JSONObject(true);
mapping.put("fields", new JSONObject(true));
QuoteTemplateExcelSupport.fillWorkbook(src, outXlsx, mapping, fill);
try (Workbook got = org.apache.poi.ss.usermodel.WorkbookFactory.create(Files.newInputStream(outXlsx))) {
Sheet sheet = got.getSheetAt(0);
DataFormatter fmt = new DataFormatter();
String cwLabelOrVal = fmt.formatCellValue(sheet.getRow(5).getCell(1));
assertFalse("1.1*2.1*2.2".equals(cwLabelOrVal),
"体积标签回填不得把尺寸式写入计费重格,实际=" + cwLabelOrVal);
}
// 竖排货物信息(空运正式模板 Sheet2):体积 / 计费重 / 派送地址
Path src2 = dir.resolve("tpl-vertical.xlsx");
Path out2 = dir.resolve("out-vertical.xlsx");
try (XSSFWorkbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("Sheet2");
Row r11 = sheet.createRow(10);
r11.createCell(0).setCellValue("体 积:");
r11.createCell(1).setCellValue("");
r11.createCell(2).setCellValue("CBM");
Row r12 = sheet.createRow(11);
r12.createCell(0).setCellValue(" 计 费 重:");
r12.createCell(1).setCellValue("");
r12.createCell(2).setCellValue("KG");
Row r13 = sheet.createRow(12);
r13.createCell(0).setCellValue(" 派送地址:");
r13.createCell(1).setCellValue("");
try (java.io.OutputStream os = Files.newOutputStream(src2)) {
wb.write(os);
}
}
JSONObject mapping2 = new JSONObject(true);
JSONObject fields2 = new JSONObject(true);
fields2.put("chargeable_weight_kg", "Sheet2!B12");
mapping2.put("fields", fields2);
QuoteTemplateExcelSupport.fillWorkbook(src2, out2, mapping2, fill);
try (Workbook got = org.apache.poi.ss.usermodel.WorkbookFactory.create(Files.newInputStream(out2))) {
Sheet sheet = got.getSheet("Sheet2");
DataFormatter fmt = new DataFormatter();
assertEquals("1.1*2.1*2.2", fmt.formatCellValue(sheet.getRow(10).getCell(1)));
assertEquals("847.1694", fmt.formatCellValue(sheet.getRow(11).getCell(1)));
assertFalse("847.1694".equals(fmt.formatCellValue(sheet.getRow(12).getCell(1))),
"计费重不得被挤进派送地址");
assertFalse("1.1*2.1*2.2".equals(fmt.formatCellValue(sheet.getRow(11).getCell(1))),
"计费重格不得被体积尺寸式覆盖");
}
}
/**
* 空运没有时效:即便 TMS 回了 eta,出单也不填 transit_* / 时效。
* 报价有效期仍要留下。
@@ -887,6 +980,52 @@ public class QuoteTemplateFillTest {
assertEquals("7918.25", out.getString("total_amount"));
}
/**
* WO036:航线在舱位价上再加增点费,total=明细之和(4966.40)大于 salePrice(3966.40)。
* 预估费用必须保留航线总价,禁止再盖回 TMS。
*/
@Test
public void buildFillFactsAirKeepsAirlineTotalWhenAboveSalePrice() {
Map<String, Object> facts = new HashMap<>();
facts.put("运输方式", "空运");
facts.put("品名", "普货");
Map<String, Object> option = new HashMap<>();
option.put("optionNo", "AIR-OPT01");
option.put("salePrice", 3966.40);
option.put("currency", "CNY");
Map<String, Object> quote = new HashMap<>();
quote.put("total", "4966.40");
quote.put("currency", "CNY");
quote.put("airOptions", java.util.Collections.singletonList(option));
quote.put("selected_air_option", option);
quote.put("fee_lines", java.util.Arrays.asList(
new HashMap<String, Object>() {{
put("name", "空运费");
put("amount", "3667.40");
put("currency", "CNY");
}},
new HashMap<String, Object>() {{
put("name", "人工费");
put("amount", "100");
put("currency", "CNY");
}},
new HashMap<String, Object>() {{
put("name", "打板费");
put("amount", "199");
put("currency", "CNY");
}},
new HashMap<String, Object>() {{
put("name", "增点费");
put("amount", "1000");
put("currency", "CNY");
}}
));
quote.put("chargeableWeightKg", "333.4");
JSONObject out = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl
.buildFillFacts("WO202610180036", facts, quote, "WuJiLin", "AIR");
assertEquals("4966.40", out.getString("total_amount"));
}
@Test
public void feeQuantityDefaultsToOneWhenBlank() {
assertEquals("1", QuoteTemplateExcelSupport.feeQuantityOrDefault(null, "提送货费"));
@@ -1008,6 +1147,70 @@ public class QuoteTemplateFillTest {
|| "16670.0".equals(rows.getJSONObject(1).getString("quantity")));
}
/**
* WO202610270003:TMS 多航班时顶层 feeItems 含 14.5/15/15;
* 出单必须跟 selected_air_option(TH739 / AIR-OPT03 / 15),禁止写列表首条 14.5。
*/
@Test
public void parseAirChargesPrefersSelectedOptionUnitPrice() {
String airOptions = "["
+ "{\"optionNo\":\"AIR-OPT01\",\"flightNo\":\"TH641\",\"originAirportCode\":\"HKG\","
+ "\"salePrice\":37465.83,\"currency\":\"CNY\",\"chargeableWeightKg\":2583.85,"
+ "\"departureCharges\":[]},"
+ "{\"optionNo\":\"AIR-OPT02\",\"flightNo\":\"TH6187\",\"originAirportCode\":\"SZX\","
+ "\"salePrice\":38757.75,\"currency\":\"CNY\",\"chargeableWeightKg\":2583.85,"
+ "\"departureCharges\":[]},"
+ "{\"optionNo\":\"AIR-OPT03\",\"flightNo\":\"TH739\",\"originAirportCode\":\"NKG\","
+ "\"salePrice\":38757.75,\"currency\":\"CNY\",\"chargeableWeightKg\":2583.85,"
+ "\"departureCharges\":[]}"
+ "]";
String topFees = "["
+ "{\"feeCode\":\"AIR_FREIGHT\",\"feeName\":\"空运费\",\"airOptionNo\":\"AIR-OPT01\","
+ "\"unitPrice\":14.5,\"quantity\":2583.85,\"amount\":37465.83,\"unit\":\"KG\",\"currency\":\"CNY\"},"
+ "{\"feeCode\":\"AIR_FREIGHT\",\"feeName\":\"空运费\",\"airOptionNo\":\"AIR-OPT02\","
+ "\"unitPrice\":15.0,\"quantity\":2583.85,\"amount\":38757.75,\"unit\":\"KG\",\"currency\":\"CNY\"},"
+ "{\"feeCode\":\"AIR_FREIGHT\",\"feeName\":\"空运费\",\"airOptionNo\":\"AIR-OPT03\","
+ "\"unitPrice\":15.0,\"quantity\":2583.85,\"amount\":38757.75,\"unit\":\"KG\",\"currency\":\"CNY\"}"
+ "]";
String selected = "{\"optionNo\":\"AIR-OPT03\",\"flightNo\":\"TH739\",\"originAirportCode\":\"NKG\","
+ "\"salePrice\":38757.75,\"currency\":\"CNY\",\"chargeableWeightKg\":2583.85,"
+ "\"departureCharges\":[]}";
JSONArray rows = QuoteTemplateExcelSupport.parseAirDepartureChargeRows(
airOptions, null, topFees, selected, "2583.85", "CNY");
assertFalse(rows.isEmpty());
assertEquals("空运费", rows.getJSONObject(0).getString("name"));
assertEquals("空运段", rows.getJSONObject(0).getString("project"));
String unitPrice = rows.getJSONObject(0).getString("unit_price");
assertTrue(unitPrice.startsWith("15"), "选定舱位单价应为 15,实际=" + unitPrice);
String amount = rows.getJSONObject(0).getString("amount");
assertTrue(amount.startsWith("38757.75"), "选定舱位金额应为 38757.75,实际=" + amount);
// buildFillFacts 同样带上 selected,报价单事实不得落到 14.5
Map<String, Object> facts = new LinkedHashMap<>();
facts.put("起运港", "南京");
facts.put("目的港", "吉隆坡");
facts.put("计费重", "2583.85");
Map<String, Object> quote = new LinkedHashMap<>();
quote.put("source", "TMS");
quote.put("total", "38757.75");
quote.put("currency", "CNY");
quote.put("chargeableWeightKg", "2583.8500");
quote.put("airOptions", JSON.parseArray(airOptions));
quote.put("feeItems", JSON.parseArray(topFees));
quote.put("selected_air_option", JSON.parseObject(selected));
quote.put("fee_lines", JSON.parseArray(
"[{\"name\":\"空运费\",\"amount\":\"CNY 38757.75\",\"currency\":\"CNY\","
+ "\"unit\":\"KG\",\"unit_price\":15.0,\"quantity\":2583.85}]"));
JSONObject out = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl
.buildFillFacts("WO202610270003", facts, quote, "Tester", "AIR");
JSONArray feeRows = out.getJSONArray("fee_rows");
assertNotNull(feeRows);
assertFalse(feeRows.isEmpty());
String fillPrice = feeRows.getJSONObject(0).getString("unit_price");
assertTrue(fillPrice != null && fillPrice.startsWith("15"),
"出单事实单价应为 15,实际=" + fillPrice);
}
/**
* 人工只报起运港费:必须落到模板「起运港」段,禁止占掉「空运段」首行并把骨架改成起运港。
*/
@@ -3208,6 +3411,27 @@ public class QuoteTemplateFillTest {
}
}
/**
* 中港:TMS 别名经国内映射后再对等中港表头;跨境:吨位列与 3T 族境外 3.5T;禁止 5T 误命中 3.5T。
*/
@Test
public void hkAndCrossBorderVehicleMappingPeers() {
assertEquals("12米/40尺", QuoteTemplateExcelSupport.hkPeerHeaderOf("12.5米/40尺"));
assertEquals("12.5米/40尺", QuoteTemplateExcelSupport.hkPeerHeaderOf("12米/40尺"));
assertEquals("5T", QuoteTemplateExcelSupport.extractVehicleTonnage("6.8/5T"));
assertTrue(QuoteTemplateExcelSupport.vehicleTypeMatchesSection("40HQ", "12米/40尺", ""));
assertTrue(QuoteTemplateExcelSupport.vehicleTypeMatchesSection("6.8厢车", "5米/5T", ""));
assertTrue(QuoteTemplateExcelSupport.vehicleTypeMatchesSection("9.6厢车", "7.8米/10T", ""));
assertTrue(QuoteTemplateExcelSupport.vehicleTypeMatchesSection("6.8厢车", "5T", "domestic"));
assertTrue(QuoteTemplateExcelSupport.vehicleTypeMatchesSection("4.2厢车", "3.5T", "overseas"));
assertEquals("3.5T", QuoteTemplateExcelSupport.mapDomesticVehicleToOverseas("4.2厢车"));
assertFalse(QuoteTemplateExcelSupport.vehicleTypeMatchesSection("5T", "3.5T", "overseas"));
assertTrue(QuoteTemplateExcelSupport.vehicleTypeMatchesSection("5T", "5T", "overseas"));
// 国内整车原路径不回归
assertTrue(QuoteTemplateExcelSupport.vehicleTypeMatchesSection("40HQ", "12.5米/40尺", ""));
assertTrue(QuoteTemplateExcelSupport.vehicleTypeMatchesSection("6.8厢车", "6.8/5T", ""));
}
/**
* 国内整车:车型格「4.2米/3T」是浅蓝表头,出单后不能变成业务员那种深蓝。
*/
@@ -3343,4 +3567,118 @@ public class QuoteTemplateFillTest {
assertEquals("8%", QuoteTemplateExcelSupport.formatLandPercentDisplay("8.0"));
assertEquals("含税", QuoteTemplateExcelSupport.formatLandTaxIncludedDisplay("1"));
}
/**
* 空运起运港费用多于模板预留行时:必须在「起运港」段内(「其他」之前)增行,
* 禁止把多余费目挤到「其他」之后。
*/
@Test
public void airManyOriginFeesGrowInsideOriginBandBeforeOther() throws Exception {
Path src = Files.createTempFile("quote-air-grow-src", ".xlsx");
Path out = Files.createTempFile("quote-air-grow-out", ".xlsx");
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("Sheet2");
org.apache.poi.ss.usermodel.Row head = sheet.createRow(15);
head.createCell(0).setCellValue("项目");
head.createCell(1).setCellValue("费用名称");
head.createCell(2).setCellValue("单位");
head.createCell(4).setCellValue("单价");
head.createCell(5).setCellValue("数量");
head.createCell(6).setCellValue("金额");
// 空运段 1 行
org.apache.poi.ss.usermodel.Row rAir = sheet.createRow(16);
rAir.createCell(0).setCellValue("空运段");
rAir.createCell(1).setCellValue("样例空运费");
// 起运港仅预留 2 行(合并),远少于 5 条起运港费
org.apache.poi.ss.usermodel.Row rPol1 = sheet.createRow(17);
rPol1.createCell(0).setCellValue("起运港");
rPol1.createCell(1).setCellValue("样例1");
org.apache.poi.ss.usermodel.Row rPol2 = sheet.createRow(18);
rPol2.createCell(0).setCellValue("起运港");
rPol2.createCell(1).setCellValue("样例2");
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(17, 18, 0, 0));
org.apache.poi.ss.usermodel.Row rOther = sheet.createRow(19);
rOther.createCell(0).setCellValue("其他");
org.apache.poi.ss.usermodel.Row total = sheet.createRow(22);
total.createCell(0).setCellValue("预估费用");
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
}
JSONObject mapping = new JSONObject();
mapping.put("fields", new JSONObject());
JSONObject region = new JSONObject(true);
region.put("id", "air_fee_detail");
region.put("anchor", "Sheet2!A17");
region.put("stopAtText", java.util.Collections.singletonList("预估费用"));
region.put("grow", "insert_before_stop");
JSONObject cols = new JSONObject(true);
cols.put("project", "A");
cols.put("name", "B");
cols.put("unit", "C");
cols.put("unit_price", "E");
cols.put("quantity", "F");
cols.put("amount", "G");
region.put("columns", cols);
mapping.put("feeRegions", new JSONArray(java.util.Collections.singletonList(region)));
JSONObject facts = new JSONObject(true);
facts.put("total_amount", "5000");
facts.put("currency", "CNY");
JSONArray feeRows = new JSONArray();
JSONObject air = new JSONObject(true);
air.put("project", "空运段");
air.put("name", "空运费");
air.put("unit", "KG");
air.put("unit_price", "10");
air.put("quantity", "100");
air.put("amount", "1000");
feeRows.add(air);
String[] polNames = {"人工费", "打板费", "增点费", "电放费", "文件费", "报关费", "单证费"};
for (String n : polNames) {
JSONObject one = new JSONObject(true);
one.put("project", "起运港");
one.put("name", n);
one.put("unit", "次");
one.put("unit_price", "10");
one.put("quantity", "100");
one.put("amount", "1000");
feeRows.add(one);
}
facts.put("fee_rows", feeRows);
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
try (Workbook got = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = got.getSheet("Sheet2");
DataFormatter fmt = new DataFormatter();
int otherRow = -1;
int lastPolFeeRow = -1;
java.util.List<String> polFees = new java.util.ArrayList<>();
for (int r = 16; r <= sheet.getLastRowNum(); r++) {
org.apache.poi.ss.usermodel.Row row = sheet.getRow(r);
if (row == null) {
continue;
}
String proj = fmt.formatCellValue(row.getCell(0));
String name = fmt.formatCellValue(row.getCell(1));
if ("其他".equals(proj.trim()) || "其它".equals(proj.trim())) {
otherRow = r;
}
if (java.util.Arrays.asList(polNames).contains(name)) {
polFees.add(name);
lastPolFeeRow = r;
// 合并格读原点:物理行可能空白,用骨架工具读
String band = QuoteTemplateExcelSupport.normalizeAirProjectLabel(
QuoteTemplateExcelSupport.projectLabelAt(sheet, r, 0));
assertEquals("起运港", band, "fee " + name + " must stay in 起运港 band at row " + r);
}
}
assertEquals(7, polFees.size(), "all 7 origin fees written");
assertTrue(otherRow > 0, "其他 row present");
assertTrue(lastPolFeeRow < otherRow,
"last origin fee row " + lastPolFeeRow + " must be before 其他 row " + otherRow);
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
}
}
@@ -248,6 +248,12 @@ public class InquiryTmsCabinServiceTest {
"锁舱参数不完整,缺少:航班号。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。",
InquiryTmsCabinService.missingLockParamReason("AIR-OPT01", "CZ", ""));
assertEquals("", InquiryTmsCabinService.missingLockParamReason("AIR-OPT01", "CZ", "CZ327"));
assertEquals(
"锁舱参数不完整,缺少:舱位编号、航司、航班号、报价日期。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327,2026-10-18。",
InquiryTmsCabinService.missingLockParamReason("", "", "", true));
assertEquals(
"锁舱参数不完整,缺少:报价日期。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327,2026-10-18。",
InquiryTmsCabinService.missingLockParamReason("AIR-OPT01", "CZ", "CZ327", true));
}
@Test
@@ -361,7 +367,9 @@ public class InquiryTmsCabinServiceTest {
String raw = "锁舱参数不完整,缺少:有效期validUntil。请按「AIR-OPT01 CZ 航班CZ327」补舱位、航司和航班号后再锁。";
String why = InquiryTmsCabinService.rewriteManualLockIncompleteReason(
raw, ticket, req, "TMS_NO_QUOTE_1002", "标准运价库无匹配报价");
assertEquals("报价日期为空,请补充报价日期后重新锁舱", why);
assertEquals(
"锁舱参数不完整,缺少:舱位编号、航司、航班号、报价日期。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327,2026-10-18。",
why);
}
@Test
@@ -28,9 +28,9 @@ public class TmsLandEnumMapperTest {
}
@Test
public void hkTypesCollapseToZhonggang() {
assertEquals("中港", TmsLandEnumMapper.transportType("中港整车"));
assertEquals("中港", TmsLandEnumMapper.transportType("中港零担/集拼"));
public void hkTypesPassThroughFineClass() {
assertEquals("中港整车", TmsLandEnumMapper.transportType("中港整车"));
assertEquals("中港零担/集拼", TmsLandEnumMapper.transportType("中港零担/集拼"));
assertEquals("中港集拼", TmsLandEnumMapper.transportType("中港集拼"));
}
@@ -37,6 +37,42 @@ public class TmsLandSegmentMapperTest {
@Test
public void landFtlUsesVehicleNotVolume() {
Map<String, Object> facts = new HashMap<String, Object>();
facts.put("运输类型", "国内运输整车");
facts.put("线路类别", "国内长途/零担");
facts.put("起运港", "广州");
facts.put("目的港", "深圳");
facts.put("品名", "衣服");
facts.put("车型/数量", "9米6/1");
facts.put("体积", "99");
Map<String, Object> seg = TmsLandSegmentMapper.build(facts);
assertEquals("国内运输整车", seg.get("landTransportType"));
assertEquals("9米6", seg.get("vehicleType"));
assertNull(seg.get("volumeCbm"));
assertFalse(seg.containsKey("hkLoadType"));
assertEquals(Integer.valueOf(1), seg.get("quantity"));
}
@Test
public void hkFullTruckRequiresVehicle() {
Map<String, Object> facts = new HashMap<String, Object>();
facts.put("运输类型", "中港整车");
facts.put("线路类别", "中港/中亚/中欧");
facts.put("起运港", "广州");
facts.put("目的港", "香港");
facts.put("品名", "衣服");
facts.put("车型/数量", "9米6/1");
Map<String, Object> seg = TmsLandSegmentMapper.build(facts);
assertEquals("中港整车", seg.get("landTransportType"));
assertEquals("9米6", seg.get("vehicleType"));
assertEquals(Integer.valueOf(1), seg.get("quantity"));
java.util.List<String> missing = TmsLandSegmentMapper.missingQuery(
"WO202609170005", "2026-09-17", seg, facts);
assertEquals(java.util.Collections.emptyList(), missing);
}
@Test
public void hkLclOmitsVehicle() {
Map<String, Object> facts = new HashMap<String, Object>();
facts.put("运输类型", "中港零担/集拼");
facts.put("线路类别", "中港/中亚/中欧");
@@ -46,25 +82,11 @@ public class TmsLandSegmentMapperTest {
facts.put("车型/数量", "9米6/1");
facts.put("体积", "99");
Map<String, Object> seg = TmsLandSegmentMapper.build(facts);
assertEquals("中港", seg.get("landTransportType"));
assertEquals("9米6", seg.get("vehicleType"));
assertNull(seg.get("volumeCbm"));
assertFalse(seg.containsKey("hkLoadType"));
assertEquals(Integer.valueOf(1), seg.get("quantity"));
}
@Test
public void hkFullTruckOmitsVehicle() {
Map<String, Object> facts = new HashMap<String, Object>();
facts.put("运输类型", "中港整车");
facts.put("线路类别", "中港/中亚/中欧");
facts.put("起运港", "广州");
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"));
assertNull(seg.get("volumeCbm"));
assertFalse(seg.containsKey("hkLoadType"));
java.util.List<String> missing = TmsLandSegmentMapper.missingQuery(
"WO202609170005", "2026-09-17", seg, facts);
assertEquals(java.util.Collections.emptyList(), missing);