员工岗位可重新启用;调价认空运航线;会话存档跳号补拉;陆运报价按车型列回填
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+13
@@ -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")
|
||||
|
||||
+5
@@ -21,6 +21,11 @@ public interface IInquiryStaffRoleService extends IService<InquiryStaffRole> {
|
||||
*/
|
||||
void disable(String id);
|
||||
|
||||
/**
|
||||
* 重新启用员工(status=active)。
|
||||
*/
|
||||
void enable(String id);
|
||||
|
||||
/**
|
||||
* 从企微通讯录全量 upsert:按 wecom_id 更新姓名/手机/部门,缺失则新建(默认销售岗)。
|
||||
* <p>
|
||||
|
||||
+79
-6
@@ -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 < 7918)才允许盖成 salePrice。
|
||||
* 航线加增值费后 total>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。
|
||||
|
||||
+15
@@ -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 查一条(智能体/后台可复用)。
|
||||
*/
|
||||
|
||||
+14
@@ -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<>();
|
||||
|
||||
+41
-4
@@ -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;
|
||||
|
||||
+7
@@ -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");
|
||||
|
||||
+670
-90
File diff suppressed because it is too large
Load Diff
+34
-12
@@ -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 + "。";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+3
-8
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+23
-18
@@ -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"};
|
||||
|
||||
+1
-1
@@ -160,7 +160,7 @@ public final class TmsV2RequestMapper {
|
||||
}
|
||||
|
||||
/**
|
||||
* 陆运出站:类型+线路用销售中文;中港整车、中港零担/集拼收成「中港」。线路字段名是 routeCategory。
|
||||
* 陆运出站:类型+线路用销售中文(含中港整车 / 中港零担/集拼细类)。线路字段名是 routeCategory。
|
||||
* 分类和中港装载即使误放在 source 也不抄走。
|
||||
*/
|
||||
private static void applyLandFields(Map<String, Object> source, Map<String, Object> target) {
|
||||
|
||||
+3
@@ -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",
|
||||
|
||||
+8
-1
@@ -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",
|
||||
|
||||
+338
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
-1
@@ -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
|
||||
|
||||
+3
-3
@@ -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("中港集拼"));
|
||||
}
|
||||
|
||||
|
||||
+39
-17
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user