空运成交后可在本群继续锁舱;调价同步舱位费用桶;出单按空运段骨架落费并保留陆运车型尺寸;导出文件名乱码修复。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-10-14 15:34:24 +08:00
co-authored by Cursor
parent 92bdf56a4d
commit cc8529f569
29 changed files with 1941 additions and 276 deletions
@@ -12,6 +12,7 @@ import java.util.Set;
* 空运群「当前工单」能不能激活。纯内存规则,不写库。
* <p>
* 可激活:业务线 AIR,且六态询价中 / 已报价 / 协商中。
* 已成交只在锁舱补参数(allowDeal)时允许记到本群。
* 一张单同时只跟一个群;已经绑了别的群就 occupied。
*/
public final class AirGroupActivateRule {
@@ -27,13 +28,23 @@ public final class AirGroupActivateRule {
* @return 空串表示可以激活;否则是给智能体的错误码
*/
public static String rejectReason(InquiryTicket ticket, String chatId) {
return rejectReason(ticket, chatId, false);
}
/**
* 锁舱补参数时允许把已成交单记在本群,方便下一句不再带工单号。
* 未成交、已关闭、转人工仍然不能激活。普通激活不要传 allowDeal。
*/
public static String rejectReason(InquiryTicket ticket, String chatId, boolean allowDeal) {
if (ticket == null) {
return "ticket_not_found";
}
if (!"AIR".equalsIgnoreCase(StringUtils.trimToEmpty(ticket.getBusinessLine()))) {
return "not_air";
}
if (!OPEN.contains(StringUtils.trimToEmpty(ticket.getStatus()))) {
String status = StringUtils.trimToEmpty(ticket.getStatus());
boolean dealBind = allowDeal && TicketStatuses.DEAL.equals(status);
if (!OPEN.contains(status) && !dealBind) {
return "terminal";
}
String bound = StringUtils.trimToEmpty(ticket.getCollabChatId());
@@ -1043,6 +1043,22 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
}
out.put("customer_name", customer);
out.put("客户名称", customer);
// 陆运/中港表头「联系人」「电话」映射码是 contact_*;事实里常是中文键,必须投影否则格子只剩标签
String contactName = first(src, "联系人", "contact_name", "联系人姓名", "客户联系人", "contactName");
if (StringUtils.isNotBlank(contactName)) {
out.put("contact_name", contactName);
out.put("联系人", contactName);
}
String contactPhone = first(src, "电话", "联系电话", "contact_phone", "手机", "手机号", "contactPhone");
if (StringUtils.isNotBlank(contactPhone)) {
out.put("contact_phone", contactPhone);
out.put("电话", contactPhone);
}
String cargoValue = first(src, "货值", "货物价值", "cargo_value", "货值(元)", "cargoValue");
if (StringUtils.isNotBlank(cargoValue)) {
out.put("cargo_value", cargoValue);
out.put("货值", cargoValue);
}
boolean hideAirEta = looksAirInquiry(src, q, businessLine);
String eta = first(q, "eta", "transit_days", "transitDays");
if (!hideAirEta && StringUtils.isNotBlank(eta) && !"-".equals(eta)) {
@@ -1077,12 +1093,18 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
if (rows.isEmpty()) {
rows = QuoteTemplateExcelSupport.parseFeeRows(q.get("fee_rows"));
}
// 空运:已落库 quote 常只有一条 fee_lines,完整明细在 airOptions.departureCharges。
// 行数相同也优先 departureCharges(fee_lines 常把单价误当金额,缺单位)。
// 空运:完整明细在 airOptions 分桶 + 顶层 feeItems(空运段)。
// 行数相同也优先分桶解析(fee_lines 常把单价误当金额,缺单位/项目列)。
Object quoteFeeItems = q.get("feeItems");
if (quoteFeeItems == null) {
quoteFeeItems = q.get("fee_items");
}
JSONArray fromAir = QuoteTemplateExcelSupport.parseAirDepartureChargeRows(
q.get("airOptions"), q.get("air_options"), chargeable, currency);
q.get("airOptions"), q.get("air_options"), quoteFeeItems, chargeable, currency);
if (!fromAir.isEmpty()) {
rows = fromAir;
// 分桶优先,但 flat fee_lines 里多出来的人工调价(如「增值费」)必须并进模板,
// 禁止整表替换后把群摘要已展示的费用丢掉(WO202610140006)。
rows = QuoteTemplateExcelSupport.mergeAirBucketRowsWithFlatManualFees(fromAir, rows);
}
if (rows.isEmpty()) {
String freight = first(q, "freight");
@@ -1104,6 +1126,11 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
}
}
}
// 旧 payload 常丢 unit:从 raw_text 回填后再走默认「票」补全
if (!rows.isEmpty()) {
QuoteTemplateExcelSupport.backfillMissingFeeUnitsFromRawText(
rows, first(q, "raw_text", "rawText", "text"));
}
// 空运:补单位/数量/单价(手工常只有金额,WO020 空运费与增点费)
if (!rows.isEmpty()) {
List<Map<String, String>> lineMaps = new ArrayList<>();
@@ -1307,7 +1307,15 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
String room = text(body, "chatId", "chat_id");
InquiryTicket ticket = findByWorkOrderNo(no);
Map<String, Object> out = new LinkedHashMap<>();
String reject = AirGroupActivateRule.rejectReason(ticket, room);
Object rawAllow = body == null ? null : body.get("allowDeal");
if (rawAllow == null && body != null) {
rawAllow = body.get("allow_deal");
}
// 只有锁舱补参数才带这个标记。普通激活仍拒绝已成交。
boolean allowDeal = Boolean.TRUE.equals(rawAllow)
|| "true".equalsIgnoreCase(String.valueOf(rawAllow))
|| "1".equals(String.valueOf(rawAllow));
String reject = AirGroupActivateRule.rejectReason(ticket, room, allowDeal);
if (StringUtils.isNotBlank(reject)) {
out.put("ok", false);
out.put("error", reject);
@@ -32,11 +32,18 @@ public final class LandQuoteTemplateFields {
add("land_freight", "陆运费", "landFreight");
add("vehicle_42_fee", "4.2米/3T费用", "vehicle42Fee");
add("vehicle_50_fee", "5米/5T费用", "vehicle50Fee");
add("vehicle_68_fee", "6.8/5T费用", "vehicle68Fee");
add("vehicle_65_fee", "6.5米/8T费用", "vehicle65Fee");
add("vehicle_76_fee", "7.6米/8T费用", "vehicle76Fee");
add("vehicle_78_fee", "7.8米/10T费用", "vehicle78Fee");
add("vehicle_82_fee", "8.2米/10T费用", "vehicle82Fee");
add("vehicle_82_12_fee", "8.2米/12T费用", "vehicle8212Fee");
add("vehicle_96_fee", "9.6米/10T费用", "vehicle96Fee");
add("vehicle_59_fee", "5.9米/20尺费用", "vehicle59Fee");
add("vehicle_12_fee", "12米/40尺费用", "vehicle12Fee");
add("vehicle_125_fee", "12.5米/40尺费用", "vehicle125Fee");
add("vehicle_135_fee", "13.5米/45尺费用", "vehicle135Fee");
add("vehicle_15_reefer_fee", "15米/冷柜费用", "vehicle15ReeferFee");
add("vehicle_165_fee", "16.5米/53尺费用", "vehicle165Fee");
add("vehicle_3t_fee", "4.1*2.1*2.3M/3T费用", "vehicle3TFee");
add("vehicle_5t_fee", "6.5*2.4*2.5M/5T费用", "vehicle5TFee");
@@ -51,9 +58,25 @@ public final class LandQuoteTemplateFields {
LABEL_TO_CODE.put("时效", "transit_days");
LABEL_TO_CODE.put("干线费", "land_freight");
LABEL_TO_CODE.put("运费", "land_freight");
// 20261012 国内整车 / 中港:短表头(无「费用」后缀)也要能对上填表码
LABEL_TO_CODE.put("4.2米/3T", "vehicle_42_fee");
LABEL_TO_CODE.put("5米/5T", "vehicle_50_fee");
LABEL_TO_CODE.put("6.8/5T", "vehicle_68_fee");
LABEL_TO_CODE.put("6.8米/5T", "vehicle_68_fee");
LABEL_TO_CODE.put("6.5米/8T", "vehicle_65_fee");
LABEL_TO_CODE.put("7.6米/8T", "vehicle_76_fee");
LABEL_TO_CODE.put("7.8米/10T", "vehicle_78_fee");
LABEL_TO_CODE.put("8.2米/10T", "vehicle_82_fee");
LABEL_TO_CODE.put("8.2米/12T", "vehicle_82_12_fee");
LABEL_TO_CODE.put("9.6米/10T", "vehicle_96_fee");
LABEL_TO_CODE.put("5.9米/20尺", "vehicle_59_fee");
LABEL_TO_CODE.put("12米/40尺", "vehicle_12_fee");
LABEL_TO_CODE.put("12.5米/40尺", "vehicle_125_fee");
LABEL_TO_CODE.put("13.5米/45尺", "vehicle_135_fee");
LABEL_TO_CODE.put("15米/冷柜", "vehicle_15_reefer_fee");
LABEL_TO_CODE.put("16.5米/53尺", "vehicle_165_fee");
LABEL_TO_CODE.put("13.75米平板", "vehicle_1375_fee");
LABEL_TO_CODE.put("17.5米平板", "vehicle_175_fee");
LABEL_TO_CODE.put("元/KG", "price_per_kg");
}
@@ -32,7 +32,7 @@ import java.util.Map;
* 有真实 TMS 编号才带 quoteId。有值才出站,缺航班不拦。
* SYSTEM 出站精简(quoteId + 航班等标量,不带 validUntil/feeItems);
* MANUAL 费用名按 TMS 查价回包原样带回,有效期必须带时区 ISO8601。
* 已成交再释放:本地拦住。测服打开 TMS_V2_LOCK_ENABLED;现网验收期保持关闭。
* 只有已成交才能锁舱。成交并锁上之后仍可释放。测服打开 TMS_V2_LOCK_ENABLED;现网验收期保持关闭。
* 禁止:按 userid 伪造报价;单测打真实 TMS。
*/
@Slf4j
@@ -55,6 +55,27 @@ public class InquiryTmsCabinService {
/** 多段询价锁舱的产品口径。智能体群回复与主账拒绝用同一句。 */
public static final String MULTI_LOCK_UNSUPPORTED = "多段询价,暂时不支持锁舱";
/**
* 锁舱成交门禁。已成交返回空串,表示可以继续拼锁舱正文。
* 协商中、未成交、已关闭/转人工、以及还没回复成交,各自一个错误码。
*/
static String lockDealGate(String status) {
String st = StringUtils.trimToEmpty(status);
if (TicketStatuses.DEAL.equals(st)) {
return "";
}
if (TicketStatuses.NEGOTIATING.equals(st)) {
return "lock_negotiating";
}
if (TicketStatuses.LOST.equals(st)) {
return "lock_lost";
}
if (TicketStatuses.CLOSED.equals(st) || TicketStatuses.HANDOFF.equals(st)) {
return "ticket_closed";
}
return "lock_need_deal";
}
/**
* 还没到 TMS 之前的拒绝原因。空运单返回空,表示可以继续锁。
* 多段整单不是 AIR,但航线会按空运段来锁,单独说明暂不支持。
@@ -86,9 +107,18 @@ public class InquiryTmsCabinService {
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())) {
return fail("ticket_closed", "工单已是「" + ticket.getStatus() + "」,不能锁舱");
String dealGate = lockDealGate(ticket.getStatus());
if ("lock_negotiating".equals(dealGate)) {
return fail(dealGate, "工单还在协商中,成交后才能锁舱");
}
if ("lock_lost".equals(dealGate)) {
return fail(dealGate, "工单已是「未成交」,不能锁舱");
}
if ("ticket_closed".equals(dealGate)) {
return fail(dealGate, "工单已是「" + ticket.getStatus() + "」,不能锁舱");
}
if ("lock_need_deal".equals(dealGate)) {
return fail(dealGate, "请销售先确认工单「已成交」");
}
List<InquiryQuote> rows = quoteRows(no);
if (rows.isEmpty()) {
@@ -279,7 +309,7 @@ public class InquiryTmsCabinService {
}
/**
* 智能体释放入口。已成交不调 TMS。
* 智能体释放入口。已成交仍可取消已锁舱位。没有成功锁舱记录则不调 TMS。
*/
public Map<String, Object> releaseCabin(Map<String, Object> body) {
String no = text(body, "workOrderNo", "work_order_no");
@@ -288,9 +318,6 @@ public class InquiryTmsCabinService {
if (ticket == null) {
return fail("ticket_not_found", "找不到工单");
}
if (TicketStatuses.DEAL.equals(ticket.getStatus())) {
return fail("deal_blocked", "工单已成交");
}
if (StringUtils.isBlank(lockId)) {
return fail("release_need_lock", "本单还没有成功锁舱记录");
}
@@ -1442,6 +1469,10 @@ public class InquiryTmsCabinService {
return out;
}
/**
* 调试会话 3ba460:手工报价锁舱死循环取证。写 NDJSON,不含密钥。
*/
private static String text(Map<String, Object> body, String... keys) {
if (body == null || keys == null) {
return "";
@@ -786,8 +786,9 @@ public class InquiryTmsQueryService {
}
/**
* 空运舱位选项上的 departureCharges → 扁平费用行。
* TMS 字段:chargeName / unitPrice / unit / currency;行金额出单时再按计费重补。
* 空运舱位分桶费用 → 扁平费用行。
* TMS:feeItems=空运段,departureCharges=起运港,destinationCharges=目的地;
* 行金额出单时再按计费重补(仅空运段)。
*/
@SuppressWarnings("unchecked")
static List<Map<String, String>> extractAirDepartureCharges(Map<String, Object> data, String headerCurrency) {
@@ -806,15 +807,21 @@ public class InquiryTmsQueryService {
if (!(raw instanceof List)) {
return out;
}
Object topFeeItems = first(data.get("feeItems"), data.get("fee_items"));
for (Object optObj : (List<?>) raw) {
if (!(optObj instanceof Map)) {
continue;
}
Map<?, ?> opt = (Map<?, ?>) optObj;
// 合并 departureCharges + feeItems,避免只取附加费丢掉空运费
List<Map<?, ?>> charges = mergeAirOptionChargeMaps(opt);
Map<Object, Object> opt = new LinkedHashMap<>((Map<?, ?>) optObj);
// 舱位缺 feeItems 时补整单顶层(TMS 常把空运费放在 data.feeItems)
if (topFeeItems instanceof List
&& !(opt.get("feeItems") instanceof List)
&& !(opt.get("fee_items") instanceof List)) {
opt.put("feeItems", topFeeItems);
}
List<Map<String, Object>> charges = mergeAirOptionChargeMaps(opt);
String optCcy = firstText(opt.get("currency"), headerCurrency);
for (Map<?, ?> ch : charges) {
for (Map<String, Object> ch : charges) {
if (ch == null) {
continue;
}
@@ -823,18 +830,24 @@ public class InquiryTmsQueryService {
if (StringUtils.isBlank(name) || StringUtils.isBlank(unitPrice)) {
continue;
}
String project = firstText(ch.get("project"), "其它");
String amountRaw = firstText(ch.get("amount"), unitPrice);
String lineCcy = firstText(ch.get("currency"), optCcy, headerCurrency);
Map<String, String> extras = new LinkedHashMap<>();
putExtra(extras, "unit", firstText(ch.get("unit"), ch.get("unitName")));
putExtra(extras, "unit_price", unitPrice);
// 固定费无数量时默认 1;运输费 enrich 会改成计费重
putExtra(extras, "project", project);
String qty = firstText(ch.get("quantity"), ch.get("chargeQuantity"), ch.get("charge_quantity"));
putExtra(extras, "quantity", StringUtils.isBlank(qty) ? "1" : qty);
putExtra(extras, "project", isAirWeightFeeName(name) ? "空运段" : "起运港");
// 先把单价写入 amount;运输费会在 enrich 里改成 单价×计费重
addFeeLine(out, name, unitPrice, lineCcy, extras);
boolean airSeg = "空运段".equals(project) && isAirWeightFeeName(name);
if (StringUtils.isNotBlank(qty)) {
putExtra(extras, "quantity", qty);
} else if (!airSeg) {
putExtra(extras, "quantity", "1");
}
// 空运段先写金额原文(或单价);enrich 再按计费重重算
addFeeLine(out, name, amountRaw, lineCcy, extras);
}
// departureCharges 只有附加费时,用舱位 salePrice 补空运费(与出单 parse 对齐)
// 无空运段运费时,用舱位 salePrice 补空运费(与出单 parse 对齐)
injectAirFreightFeeLineIfMissing(out, opt, headerCurrency);
// 通常只用首个舱位选项的费用明细
if (!out.isEmpty()) {
@@ -844,15 +857,31 @@ public class InquiryTmsQueryService {
return out;
}
/** 合并舱位 departureCharges / feeItems,同名去重。 */
/**
* 按 TMS 桶合并:feeItems→空运段,departureCharges→起运港,destinationCharges→目的地。
* 同名去重,先出现的桶优先。
*/
@SuppressWarnings("unchecked")
static List<Map<?, ?>> mergeAirOptionChargeMaps(Map<?, ?> opt) {
List<Map<?, ?>> out = new ArrayList<>();
static List<Map<String, Object>> mergeAirOptionChargeMaps(Map<?, ?> opt) {
List<Map<String, Object>> out = new ArrayList<>();
if (opt == null) {
return out;
}
Set<String> seen = new HashSet<>();
for (Object key : new Object[]{"departureCharges", "departure_charges", "charges", "feeItems", "fee_items"}) {
appendAirBucketMaps(out, seen, opt, "空运段", "feeItems", "fee_items");
appendAirBucketMaps(out, seen, opt, "起运港", "departureCharges", "departure_charges", "charges");
appendAirBucketMaps(out, seen, opt, "目的地",
"destinationCharges", "destination_charges", "arrivalCharges", "destCharges");
return out;
}
@SuppressWarnings("unchecked")
private static void appendAirBucketMaps(List<Map<String, Object>> out, Set<String> seen,
Map<?, ?> opt, String project, String... keys) {
if (out == null || opt == null || keys == null) {
return;
}
for (String key : keys) {
Object raw = opt.get(key);
if (!(raw instanceof List)) {
continue;
@@ -870,22 +899,34 @@ public class InquiryTmsQueryService {
if (!seen.add(dedupe)) {
continue;
}
out.add(ch);
Map<String, Object> copy = new LinkedHashMap<>();
for (Map.Entry<?, ?> e : ch.entrySet()) {
if (e.getKey() != null) {
copy.put(String.valueOf(e.getKey()), e.getValue());
}
}
copy.put("project", project);
out.add(copy);
}
}
return out;
}
/**
* 查价投影 fee_lines:明细无运输/空运费时,用舱位 salePrice/unitPrice 注入。
* 查价投影 fee_lines:明细无空运段运费时,用舱位 salePrice/unitPrice 注入。
* 与 QuoteTemplateExcelSupport.injectAirFreightLineIfMissing 口径一致。
* 起运港「运输费」不算已有空运段。
*/
static void injectAirFreightFeeLineIfMissing(List<Map<String, String>> lines, Map<?, ?> opt, String headerCurrency) {
if (lines == null || opt == null) {
return;
}
for (Map<String, String> line : lines) {
if (line != null && isAirWeightFeeName(line.get("name"))) {
if (line == null) {
continue;
}
String project = StringUtils.trimToEmpty(line.get("project"));
if ((project.isEmpty() || "空运段".equals(project) || "空运".equals(project))
&& isAirWeightFeeName(line.get("name"))) {
return;
}
}
@@ -950,16 +991,22 @@ public class InquiryTmsQueryService {
if (line == null || !isAirWeightFeeName(line.get("name"))) {
continue;
}
// 已标明非空运段(起运港「运输费」等)禁止乘计费重
String project = StringUtils.trimToEmpty(line.get("project"));
if (StringUtils.isNotBlank(project)
&& !"空运段".equals(project) && !"空运".equals(project)) {
continue;
}
String unitPrice = firstText(line.get("unit_price"), line.get("unitPrice"));
String qty = firstText(line.get("quantity"));
String amount = firstText(line.get("amount"));
String unit = firstText(line.get("unit"));
if (StringUtils.isBlank(qty)) {
line.put("quantity", chargeable);
qty = chargeable;
}
// TMS 常给单位「板数」;按计费重计价时统一写成 KG
line.put("unit", "KG");
line.put("project", "空运段");
// 有数量、无单价:当前 amount 当作单价(TMS 把费率塞进 salePrice/amount)
if (StringUtils.isBlank(unitPrice) && StringUtils.isNotBlank(amount)) {
line.put("unit_price", amount);
@@ -26,6 +26,9 @@ public class AirGroupActivateRuleTest {
public void dealRejected() {
InquiryTicket ticket = ticket("AIR", TicketStatuses.DEAL, "wr_air_1");
assertEquals("terminal", AirGroupActivateRule.rejectReason(ticket, "wr_air_1"));
assertEquals("", AirGroupActivateRule.rejectReason(ticket, "wr_air_1", true));
InquiryTicket lost = ticket("AIR", TicketStatuses.LOST, "");
assertEquals("terminal", AirGroupActivateRule.rejectReason(lost, "wr_air_1", true));
}
@Test
@@ -1,5 +1,6 @@
package org.jeecg.modules.inquiry.template;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import org.apache.poi.ss.usermodel.DataFormatter;
@@ -920,6 +921,180 @@ public class QuoteTemplateFillTest {
assertEquals("起运港", rows.getJSONObject(2).getString("project"));
}
/**
* TMS 分桶:feeItems→空运段,departureCharges→起运港(含名称像「运输费」的),
* destinationCharges→目的地;起运「运输费」不得乘计费重把金额算成单价。
*/
@Test
public void parseAirChargesAssignProjectByTmsBucket() {
System.setProperty("ytd.agent.debug.log",
java.nio.file.Paths.get("").toAbsolutePath().getParent() != null
? java.nio.file.Paths.get("debug-4c500b.log").toAbsolutePath().toString()
: "debug-4c500b.log");
System.setProperty("ytd.agent.debug.runId", "pre-fix-bucket");
String airOptions = "["
+ "{\"optionNo\":\"AIR-OPT01\",\"salePrice\":183370.0,\"currency\":\"CNY\","
+ "\"chargeableWeightKg\":16670.0,"
+ "\"feeItems\":[{\"feeCode\":\"AIR_FREIGHT\",\"feeName\":\"空运费\","
+ "\"unitPrice\":11.0,\"quantity\":16670.0,\"amount\":183370.0,\"unit\":\"KG\",\"currency\":\"CNY\"}],"
+ "\"departureCharges\":["
+ "{\"chargeName\":\"运输费\",\"unitPrice\":11.0,\"unit\":\"板数\",\"currency\":\"CNY\"},"
+ "{\"chargeName\":\"提送货费\",\"unitPrice\":6.0,\"quantity\":1,\"unit\":\"板数\",\"currency\":\"CNY\"},"
+ "{\"chargeName\":\"人工费\",\"unitPrice\":123.0,\"quantity\":1,\"unit\":\"柜\",\"currency\":\"CNY\"}"
+ "],"
+ "\"destinationCharges\":["
+ "{\"chargeName\":\"目的港操作费\",\"unitPrice\":50.0,\"quantity\":1,\"unit\":\"票\",\"currency\":\"CNY\"}"
+ "]}]";
JSONArray rows = QuoteTemplateExcelSupport.parseAirDepartureChargeRows(
airOptions, null, "16670", "CNY");
assertEquals(5, rows.size());
// WO006:flat 有增值费、分桶没有 → 合并后必须进起运港,不能被整表替换丢掉
JSONArray flat = JSON.parseArray(
"[{\"name\":\"运输费\",\"amount\":\"11\"},"
+ "{\"name\":\"提送货费\",\"amount\":\"6\"},"
+ "{\"name\":\"人工费\",\"amount\":\"123\"},"
+ "{\"name\":\"增值费\",\"amount\":\"1000\",\"unit\":\"板\",\"quantity\":\"100\","
+ "\"unit_price\":\"10\",\"project\":\"起运港\"}]");
JSONArray merged = QuoteTemplateExcelSupport.mergeAirBucketRowsWithFlatManualFees(rows, flat);
boolean hasZz = false;
for (int i = 0; i < merged.size(); i++) {
JSONObject one = merged.getJSONObject(i);
if (one != null && "增值费".equals(one.getString("name"))) {
hasZz = true;
assertEquals("起运港", one.getString("project"));
assertEquals("1000", one.getString("amount"));
assertEquals("板", one.getString("unit"));
}
}
assertTrue("人工增值费必须并进分桶结果", hasZz);
assertEquals("空运费", rows.getJSONObject(0).getString("name"));
assertEquals("空运段", rows.getJSONObject(0).getString("project"));
assertTrue(rows.getJSONObject(0).getString("amount").startsWith("183370"));
assertEquals("运输费", rows.getJSONObject(1).getString("name"));
assertEquals("起运港", rows.getJSONObject(1).getString("project"));
assertEquals("1", rows.getJSONObject(1).getString("quantity"));
assertTrue(rows.getJSONObject(1).getString("amount").startsWith("11"));
assertEquals("提送货费", rows.getJSONObject(2).getString("name"));
assertEquals("起运港", rows.getJSONObject(2).getString("project"));
assertEquals("人工费", rows.getJSONObject(3).getString("name"));
assertEquals("起运港", rows.getJSONObject(3).getString("project"));
assertEquals("目的港操作费", rows.getJSONObject(4).getString("name"));
assertEquals("目的地", rows.getJSONObject(4).getString("project"));
}
/**
* 顶层 feeItems + 舱位 departureCharges:空运段来自整单 feeItems,起运港来自舱位。
*/
@Test
public void parseAirChargesUsesTopLevelFeeItemsAsAirSegment() {
String airOptions = "["
+ "{\"optionNo\":\"AIR-OPT01\",\"salePrice\":183370.0,\"currency\":\"CNY\","
+ "\"chargeableWeightKg\":16670.0,"
+ "\"departureCharges\":["
+ "{\"chargeName\":\"运输费\",\"unitPrice\":11.0,\"unit\":\"板数\",\"currency\":\"CNY\"},"
+ "{\"chargeName\":\"提送货费\",\"unitPrice\":6.0,\"unit\":\"板数\",\"currency\":\"CNY\"},"
+ "{\"chargeName\":\"人工费\",\"unitPrice\":123.0,\"unit\":\"柜\",\"currency\":\"CNY\"}"
+ "]}]";
String topFees = "[{\"feeCode\":\"AIR_FREIGHT\",\"feeName\":\"空运费\","
+ "\"unitPrice\":11.0,\"quantity\":16670.0,\"amount\":183370.0,\"unit\":\"KG\",\"currency\":\"CNY\"}]";
JSONArray rows = QuoteTemplateExcelSupport.parseAirDepartureChargeRows(
airOptions, null, topFees, "16670", "CNY");
assertEquals("空运费", rows.getJSONObject(0).getString("name"));
assertEquals("空运段", rows.getJSONObject(0).getString("project"));
assertEquals("运输费", rows.getJSONObject(1).getString("name"));
assertEquals("起运港", rows.getJSONObject(1).getString("project"));
// 起运「运输费」不得再被当成空运段去乘计费重
assertFalse("16670".equals(rows.getJSONObject(1).getString("quantity"))
|| "16670.0".equals(rows.getJSONObject(1).getString("quantity")));
}
/**
* 人工只报起运港费:必须落到模板「起运港」段,禁止占掉「空运段」首行并把骨架改成起运港。
*/
@Test
public void airManualOriginFeeWritesIntoOriginBandNotAirSegment() throws Exception {
System.setProperty("ytd.agent.debug.log",
new java.io.File("debug-4c500b.log").getAbsolutePath());
System.setProperty("ytd.agent.debug.runId", "post-fix-place");
Path src = Files.createTempFile("quote-air-band-src", ".xlsx");
Path out = Files.createTempFile("quote-air-band-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(3).setCellValue("币别");
head.createCell(4).setCellValue("单价");
head.createCell(5).setCellValue("数量");
head.createCell(6).setCellValue("金额");
org.apache.poi.ss.usermodel.Row r1 = sheet.createRow(16);
r1.createCell(0).setCellValue("空运段");
r1.createCell(1).setCellValue("样例空运费");
r1.createCell(6).setCellValue(1.0);
org.apache.poi.ss.usermodel.Row r2 = sheet.createRow(17);
r2.createCell(0).setCellValue("起运港");
r2.createCell(1).setCellValue("样例增点");
r2.createCell(6).setCellValue(1.0);
org.apache.poi.ss.usermodel.Row r3 = sheet.createRow(18);
r3.createCell(0).setCellValue("起运港");
r3.createCell(1).setCellValue("样例2");
org.apache.poi.ss.usermodel.Row r4 = sheet.createRow(19);
r4.createCell(0).setCellValue("其他");
org.apache.poi.ss.usermodel.Row total = sheet.createRow(22);
total.createCell(0).setCellValue("预估费用");
total.createCell(6).setCellFormula("SUM(G17:G22)");
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("预估费用"));
JSONObject cols = new JSONObject(true);
cols.put("project", "A");
cols.put("name", "B");
cols.put("unit", "C");
cols.put("currency", "D");
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", "1000");
facts.put("currency", "CNY");
JSONArray feeRows = new JSONArray();
JSONObject one = new JSONObject(true);
one.put("project", "起运港");
one.put("name", "增值费");
one.put("unit", "板");
one.put("unit_price", "10");
one.put("quantity", "100");
one.put("amount", "1000");
one.put("currency", "CNY");
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();
// 空运段骨架保留,样例费目清掉
assertEquals("空运段", fmt.formatCellValue(sheet.getRow(16).getCell(0)));
assertTrue(StringUtils.isBlank(fmt.formatCellValue(sheet.getRow(16).getCell(1))));
// 增值费落在起运港段,不在首行
assertEquals("起运港", fmt.formatCellValue(sheet.getRow(17).getCell(0)));
assertEquals("增值费", fmt.formatCellValue(sheet.getRow(17).getCell(1)));
assertEquals("1000", fmt.formatCellValue(sheet.getRow(17).getCell(6)));
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
}
/**
* 映射未标 project 列时,表头「项目」仍要写入,增点费不能留在模板样例「空运段」。
*/
@@ -2028,11 +2203,12 @@ public class QuoteTemplateFillTest {
Sheet sheet = filled.getSheetAt(0);
assertEquals("4.1*2.1*2.3M", read(sheet, 8, 3));
assertEquals("广东省-深圳市", read(sheet, 9, 1));
assertEquals("", read(sheet, 9, 2));
// 产品口径 A:单腿门到门两段都写完整起讫
assertEquals("谅山-春疆货场", read(sheet, 9, 2));
assertEquals("4000", normalizeMoney(read(sheet, 9, 3)));
assertEquals("", read(sheet, 9, 4));
assertEquals("2.8x1.5x1.6M", read(sheet, 27, 3));
assertEquals("", read(sheet, 28, 1));
assertEquals("广东省-深圳市", read(sheet, 28, 1));
assertEquals("谅山-春疆货场", read(sheet, 28, 2));
assertEquals("", read(sheet, 28, 3));
assertEquals("", read(sheet, 28, 4));
@@ -2079,7 +2255,96 @@ public class QuoteTemplateFillTest {
}
/**
* 跨境整车国内段只写境内起点,境外段只写境外终点,不能两段都写成郑州→吉隆坡。
* WO202610140014 复现:单腿广西→胡志明;模板 3T 有尺寸 vs 无尺寸。
* 用于对照 debug-67d875:切片是否把目的地/起始地留空,以及填表是否清掉 3T 尺寸。
*/
@Test
public void landCrossBorderFtlWo014OdAnd3tDimProbe() throws Exception {
System.setProperty("ytd.agent.debug.runId", "wo014-junit");
Path src = Files.createTempFile("quote-land-wo014-src", ".xlsx");
Path out = Files.createTempFile("quote-land-wo014-out", ".xlsx");
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("跨境整车");
row(sheet, 5).createCell(0).setCellValue("一、国内段报价");
org.apache.poi.ss.usermodel.Row head1 = row(sheet, 7);
head1.createCell(1).setCellValue("起始地");
head1.createCell(2).setCellValue("目的地");
head1.createCell(3).setCellValue("3T");
head1.createCell(4).setCellValue("5T");
org.apache.poi.ss.usermodel.Row dim1 = row(sheet, 8);
// 有尺寸:应保留;同时留一列空尺寸模拟「模板缺 3T 尺寸」时对照
dim1.createCell(3).setCellValue("4.1*2.1*2.3M");
dim1.createCell(4).setCellValue("6.5*2.4*2.5M");
row(sheet, 24).createCell(0).setCellValue("二、境外报价");
org.apache.poi.ss.usermodel.Row head2 = row(sheet, 26);
head2.createCell(1).setCellValue("起始地");
head2.createCell(2).setCellValue("目的地");
head2.createCell(3).setCellValue("3.5T");
org.apache.poi.ss.usermodel.Row dim2 = row(sheet, 27);
dim2.createCell(3).setCellValue("4.7x1.8x1.8M");
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
}
JSONObject mapping = new JSONObject(true);
mapping.put("sheet", "跨境整车");
JSONArray regions = new JSONArray();
JSONObject inland = new JSONObject(true);
inland.put("title", "国内段报价");
inland.put("headerRow", 8);
inland.put("dataStartRow", 9);
JSONObject cols1 = new JSONObject(true);
cols1.put("origin", "B");
cols1.put("destination", "C");
JSONObject rates1 = new JSONObject(true);
rates1.put("3T", "D");
rates1.put("5T", "E");
cols1.put("vehicleRates", rates1);
inland.put("columns", cols1);
regions.add(inland);
JSONObject overseas = new JSONObject(true);
overseas.put("title", "境外报价");
overseas.put("headerRow", 27);
overseas.put("dataStartRow", 28);
JSONObject cols2 = new JSONObject(true);
cols2.put("origin", "B");
cols2.put("destination", "C");
JSONObject rates2 = new JSONObject(true);
rates2.put("3.5T", "D");
cols2.put("vehicleRates", rates2);
overseas.put("columns", cols2);
regions.add(overseas);
mapping.put("routeRegions", regions);
JSONObject facts = new JSONObject(true);
facts.put("origin", "广西壮族自治区");
facts.put("destination", "胡志明");
facts.put("currency", "CNY");
facts.put("route_rows",
"[{\"origin\":\"广西壮族自治区\",\"destination\":\"胡志明\","
+ "\"vehicleType\":\"3T\",\"amount\":\"12500.00\",\"currency\":\"CNY\","
+ "\"segmentSequence\":\"1\",\"routeLegSequence\":\"1\"}]");
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = filled.getSheetAt(0);
// 有尺寸时应保留(排除「填表清掉尺寸」)
// 3T/5T 尺寸副表头必须保留(后台模板有值,出单不能清掉)
assertEquals("4.1*2.1*2.3M", read(sheet, 8, 3));
assertEquals("6.5*2.4*2.5M", read(sheet, 8, 4));
assertEquals("广西壮族自治区", read(sheet, 9, 1));
// 产品口径 A:国内段/境外段都写完整广西→胡志明
assertEquals("胡志明", read(sheet, 9, 2));
assertEquals("12500", normalizeMoney(read(sheet, 9, 3)));
assertEquals("广西壮族自治区", read(sheet, 28, 1));
assertEquals("胡志明", read(sheet, 28, 2));
assertEquals("4.7x1.8x1.8M", read(sheet, 27, 3));
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
}
/**
* 跨境整车单腿门到门:国内段与境外段都写完整起讫(产品口径 A)。
*/
@Test
public void landCrossBorderFtlSplitsDomesticAndOverseasOd() throws Exception {
@@ -2143,9 +2408,9 @@ public class QuoteTemplateFillTest {
Sheet sheet = filled.getSheetAt(0);
assertEquals("起始地", read(sheet, 7, 1));
assertEquals("郑州", read(sheet, 8, 1));
assertEquals("", read(sheet, 8, 2));
assertEquals("吉隆坡", read(sheet, 8, 2));
assertEquals("起始地", read(sheet, 26, 1));
assertEquals("", read(sheet, 27, 1));
assertEquals("郑州", read(sheet, 27, 1));
assertEquals("吉隆坡", read(sheet, 27, 2));
assertEquals("N+3", read(sheet, 8, 15));
assertEquals("CNY", read(sheet, 8, 16));
@@ -2369,9 +2634,9 @@ public class QuoteTemplateFillTest {
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = filled.getSheetAt(0);
assertEquals("起始地", read(sheet, 6, 1));
// 国内段只写境内起点;春疆货场是境外终点,不能被「运输费」盖掉起点
// 产品口径 A:单腿门到门两段都写完整起讫;运输费不能盖掉起点
assertEquals("深圳", read(sheet, 8, 1));
assertEquals("", read(sheet, 8, 2));
assertEquals("春疆货场", read(sheet, 8, 2));
assertEquals("N+1", read(sheet, 8, 13));
assertEquals("CNY", read(sheet, 8, 14));
assertEquals("8%", read(sheet, 8, 15));
@@ -2380,8 +2645,7 @@ public class QuoteTemplateFillTest {
assertFalse("运输费".equals(read(sheet, 8, 1)));
assertEquals("币种", read(sheet, 6, 14));
assertEquals("燃油附加费率", read(sheet, 5, 15));
// 境外段只写境外终点
assertEquals("", read(sheet, 27, 1));
assertEquals("深圳", read(sheet, 27, 1));
assertEquals("春疆货场", read(sheet, 27, 2));
} finally {
Files.deleteIfExists(src);
@@ -15,6 +15,17 @@ import static org.junit.jupiter.api.Assertions.assertNull;
*/
public class InquiryTmsCabinServiceTest {
@Test
public void lockOnlyAfterDeal() {
assertEquals("", InquiryTmsCabinService.lockDealGate("已成交"));
assertEquals("lock_need_deal", InquiryTmsCabinService.lockDealGate("已报价"));
assertEquals("lock_need_deal", InquiryTmsCabinService.lockDealGate("询价中"));
assertEquals("lock_negotiating", InquiryTmsCabinService.lockDealGate("协商中"));
assertEquals("lock_lost", InquiryTmsCabinService.lockDealGate("未成交"));
assertEquals("ticket_closed", InquiryTmsCabinService.lockDealGate("已关闭"));
assertEquals("ticket_closed", InquiryTmsCabinService.lockDealGate("转人工"));
}
@Test
public void multiInquiryCannotLockCabin() {
assertEquals(
@@ -366,14 +366,19 @@ public class TmsQuoteCurrencyTest {
tms.put("tmsData", data);
Map<String, String> quote = InquiryTmsQueryService.toAgentQuote(tms);
String fees = quote.get("fee_lines");
// 起运港桶保留运输费/提送货/人工;空运段由 salePrice 注入空运费
assertTrue(fees.contains("运输费"));
assertTrue(fees.contains("提送货费"));
assertTrue(fees.contains("人工费"));
assertTrue(fees.contains("\"amount\":\"183370\""));
assertTrue(fees.contains("空运费"));
assertTrue(fees.contains("\"project\":\"空运段\""));
assertTrue(fees.contains("\"project\":\"起运港\""));
assertTrue(fees.contains("\"amount\":\"183370\"") || fees.contains("\"amount\":\"183370.0\""));
assertTrue(fees.contains("\"amount\":\"6.0\"") || fees.contains("\"amount\":\"6\""));
assertTrue(fees.contains("\"amount\":\"123.0\"") || fees.contains("\"amount\":\"123\""));
assertTrue(fees.contains("\"unit\":\"KG\""));
assertTrue(fees.contains("\"quantity\":\"16670.0000\""));
assertTrue(fees.contains("\"quantity\":\"16670\"") || fees.contains("\"quantity\":\"16670.0\"")
|| fees.contains("\"quantity\":\"16670.0000\""));
}
@Test