后台成本价、销售价、利润点按费用逐行展示,费用合计用报价官方总价。选完线路只关页面。报价单文件名带上模板名,空运起运地和转人工跟对应工单走。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-05 12:33:34 +08:00
co-authored by Cursor
parent 7c77e45e03
commit e95e8869a6
30 changed files with 1926 additions and 75 deletions
@@ -0,0 +1,27 @@
package org.jeecg.modules.inquiry.entity;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.experimental.Accessors;
import java.io.Serializable;
/**
* 后台成本价 / 销售价 / 利润点的一行。不入库,列表和详情响应里临时带上。
* <p>
* name 是费用名,最后一行固定为「费用合计」。text 是金额或百分比。
*/
@Data
@Accessors(chain = true)
@ApiModel("报价逐行展示")
public class InquiryPriceLine implements Serializable {
private static final long serialVersionUID = 1L;
@ApiModelProperty("费用名")
private String name;
@ApiModelProperty("金额或百分比")
private String text;
}
@@ -107,6 +107,28 @@ public class InquiryTicket implements Serializable {
@ApiModelProperty("毛利率展示")
private String profitRate;
/**
* 成本价逐行。不入库,列表/详情 enrich 时按报价记录重算。
* 空运整列为空。最后一行是「费用合计」。
*/
@TableField(exist = false)
@ApiModelProperty("成本价逐行(展示)")
private List<InquiryPriceLine> costLines;
/**
* 销售价逐行。不入库。还没调价的海运/陆运整列为空。
*/
@TableField(exist = false)
@ApiModelProperty("销售价逐行(展示)")
private List<InquiryPriceLine> saleLines;
/**
* 利润点逐行。不入库。没调过价为空。最后一行是整单利润点。
*/
@TableField(exist = false)
@ApiModelProperty("利润点逐行(展示)")
private List<InquiryPriceLine> profitLines;
@ApiModelProperty("是否命中 TMS")
private Integer tmsHit;
@@ -18,6 +18,7 @@ import org.jeecg.modules.inquiry.mapper.InquiryQuoteTemplateVersionMapper;
import org.jeecg.modules.inquiry.service.IInquiryQuoteTemplateService;
import org.jeecg.modules.inquiry.template.QuoteTemplateAiParseClient;
import org.jeecg.modules.inquiry.template.QuoteTemplateExcelSupport;
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;
@@ -770,7 +771,8 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
ok.put("templateId", picked.get("templateId"));
ok.put("templateName", picked.get("name"));
ok.put("versionNo", picked.get("versionNo"));
ok.put("fileName", safeNo + "_quote.xlsx");
// 销售看到的名字:工单号 + 后台模板名,不带后缀。磁盘路径仍是带时间戳的 xlsx。
ok.put("fileName", QuoteSheetFileName.of(safeNo, String.valueOf(picked.getOrDefault("name", ""))));
ok.put("fileBase64", java.util.Base64.getEncoder().encodeToString(bytes));
ok.put("fileSize", bytes.length);
return ok;
@@ -0,0 +1,611 @@
package org.jeecg.modules.inquiry.support;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import org.apache.commons.lang3.StringUtils;
import org.jeecg.modules.inquiry.entity.InquiryPriceLine;
import org.jeecg.modules.inquiry.entity.InquiryQuote;
import org.jeecg.modules.inquiry.entity.InquiryTicket;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* 后台成本价 / 销售价 / 利润点逐行。
* <p>
* 职责:从已保存的报价明细铺开展示。不改调价算法,不把各行金额加总冒充费用合计。
* 海运/陆运:成本是调价前生效价(手工同名盖 TMS,多出来的补上);销售是最新调价。
* 空运:成本整列空;销售是当前生效价;利润点只在调过价之后,对调价前销售价。
* 副作用:改 ticket 的三列逐行;手工盖价算出新的官方总价时,同时改成本价和利润点字符串。不写库。
* 调用:{@link InquiryTicketPriceSupport#apply} 末尾。列表 enrich 与保存报价后的 update 都走那里。
*/
public final class InquiryQuoteLineSupport {
static final String TOTAL_NAME = "费用合计";
private static final String[] BUCKETS = {"polCostItems", "oceanCostItems", "podCostItems"};
private InquiryQuoteLineSupport() {
}
/**
* 按已经算好的总价字符串,补上三列逐行。
*
* @param ticket 已写入 costPrice / salePrice / profitRate 的工单
* @param quotes 该单全部报价,顺序不限,内部按来源取最后一条
*/
public static void fill(InquiryTicket ticket, List<InquiryQuote> quotes) {
if (ticket == null) {
return;
}
boolean air = InquiryTicketPriceSupport.isAir(ticket.getBusinessLine());
InquiryQuote lastTms = null;
InquiryQuote lastManual = null;
InquiryQuote lastAdjust = null;
if (quotes != null) {
for (InquiryQuote quote : quotes) {
if (quote == null) {
continue;
}
if (InquiryQuoteDisplaySupport.isSalesAdjust(quote.getSource(), quote.getSourceLabel())) {
lastAdjust = quote;
} else if (InquiryQuoteDisplaySupport.isTms(quote.getSource(), quote.getSourceLabel())) {
lastTms = quote;
} else {
lastManual = quote;
}
}
}
List<FeeRow> tmsFees = feesOf(lastTms);
List<FeeRow> manualFees = feesOf(lastManual);
List<FeeRow> adjustFees = feesOf(lastAdjust);
String costTotal = "";
List<FeeRow> costFees = new ArrayList<FeeRow>();
if (!air) {
costTotal = StringUtils.defaultString(ticket.getCostPrice());
if (!manualFees.isEmpty() && !tmsFees.isEmpty()) {
costFees = overlay(tmsFees, manualFees);
String recomputed = overlayTotal(priceText(lastTms), tmsFees, costFees);
if (StringUtils.isNotBlank(recomputed)) {
costTotal = recomputed;
ticket.setCostPrice(costTotal);
}
} else if (!manualFees.isEmpty()) {
costFees = manualFees;
} else {
costFees = tmsFees;
}
} else {
ticket.setCostPrice("");
}
boolean adjusted = lastAdjust != null;
String saleTotal = "";
List<FeeRow> saleFees = new ArrayList<FeeRow>();
if (air) {
saleTotal = StringUtils.defaultString(ticket.getSalePrice());
InquiryQuote saleQuote = lastAdjust != null ? lastAdjust : (lastManual != null ? lastManual : lastTms);
saleFees = feesOf(saleQuote);
} else if (adjusted) {
saleTotal = StringUtils.defaultString(ticket.getSalePrice());
saleFees = adjustFees;
} else {
ticket.setSalePrice("");
ticket.setProfitRate("");
}
List<FeeRow> baselineFees;
if (air) {
if (!manualFees.isEmpty() && !tmsFees.isEmpty()) {
baselineFees = overlay(tmsFees, manualFees);
} else if (!manualFees.isEmpty()) {
baselineFees = manualFees;
} else {
baselineFees = tmsFees;
}
} else {
baselineFees = costFees;
}
String profitTotal = "";
if (adjusted && StringUtils.isNotBlank(saleTotal)) {
String locked = profitBaseline(lastAdjust);
String baseTotal;
if (StringUtils.isNotBlank(locked)) {
baseTotal = locked;
} else if (air) {
baseTotal = lastManual != null ? priceText(lastManual) : priceText(lastTms);
} else {
baseTotal = costTotal;
}
profitTotal = StringUtils.defaultString(InquiryTicketPriceSupport.profitText(saleTotal, baseTotal));
ticket.setProfitRate(profitTotal);
} else if (!adjusted) {
ticket.setProfitRate("");
}
if (air) {
ticket.setCostLines(Collections.<InquiryPriceLine>emptyList());
ticket.setSaleLines(moneyLines(saleFees, saleTotal));
ticket.setProfitLines(adjusted && StringUtils.isNotBlank(profitTotal)
? profitAgainst(baselineFees, saleFees, profitTotal)
: Collections.<InquiryPriceLine>emptyList());
return;
}
if (!adjusted) {
ticket.setCostLines(moneyLines(costFees, costTotal));
ticket.setSaleLines(Collections.<InquiryPriceLine>emptyList());
ticket.setProfitLines(Collections.<InquiryPriceLine>emptyList());
return;
}
List<Aligned> rows = align(costFees, saleFees);
ticket.setCostLines(project(rows, true, costTotal));
ticket.setSaleLines(project(rows, false, saleTotal));
ticket.setProfitLines(profitFromAligned(rows, profitTotal));
}
/**
* 手工盖 TMS:同名按出现顺序盖金额,没有的保留,手工多出来的补在后面。
* 同名两行不并成一行。
*/
static List<FeeRow> overlay(List<FeeRow> base, List<FeeRow> incoming) {
List<FeeRow> rows = new ArrayList<FeeRow>();
for (FeeRow row : base) {
rows.add(row.copy());
}
boolean[] used = new boolean[rows.size()];
if (incoming == null) {
return rows;
}
for (FeeRow item : incoming) {
int idx = firstUnused(rows, used, item.name);
if (idx < 0) {
rows.add(item.copy());
continue;
}
used[idx] = true;
FeeRow old = rows.get(idx).copy();
old.text = item.text;
old.amount = item.amount;
if (StringUtils.isNotBlank(item.currency)) {
old.currency = item.currency;
}
rows.set(idx, old);
}
return rows;
}
/**
* 费用合计:先用 TMS 官方总价,再加同币种差额。币种不同的行不进差额,也不做汇率。
*/
static String overlayTotal(String tmsPriceText, List<FeeRow> oldRows, List<FeeRow> newRows) {
BigDecimal base = InquiryTicketPriceSupport.parseAmount(tmsPriceText);
if (base == null) {
return StringUtils.trimToEmpty(tmsPriceText);
}
String totalCcy = currencyOf(tmsPriceText);
boolean[] used = new boolean[oldRows == null ? 0 : oldRows.size()];
BigDecimal delta = BigDecimal.ZERO;
if (newRows != null) {
for (FeeRow neu : newRows) {
if (neu.amount == null) {
continue;
}
String lineCcy = StringUtils.isNotBlank(neu.currency) ? neu.currency : totalCcy;
if (StringUtils.isNotBlank(lineCcy) && StringUtils.isNotBlank(totalCcy) && !lineCcy.equals(totalCcy)) {
continue;
}
int idx = firstUnused(oldRows, used, neu.name);
if (idx < 0) {
delta = delta.add(neu.amount);
continue;
}
used[idx] = true;
FeeRow old = oldRows.get(idx);
if (old.amount == null) {
continue;
}
delta = delta.add(neu.amount.subtract(old.amount));
}
}
return formatMoney(base.add(delta), totalCcy);
}
private static List<InquiryPriceLine> moneyLines(List<FeeRow> fees, String totalText) {
List<InquiryPriceLine> lines = new ArrayList<InquiryPriceLine>();
if (fees != null) {
for (FeeRow fee : fees) {
lines.add(line(fee.name, fee.text));
}
}
if (StringUtils.isNotBlank(totalText)) {
lines.add(line(TOTAL_NAME, totalText));
}
return lines;
}
private static List<InquiryPriceLine> project(List<Aligned> rows, boolean costSide, String totalText) {
List<InquiryPriceLine> lines = new ArrayList<InquiryPriceLine>();
for (Aligned row : rows) {
FeeRow side = costSide ? row.left : row.right;
lines.add(line(row.name, side == null ? "" : side.text));
}
if (StringUtils.isNotBlank(totalText)) {
lines.add(line(TOTAL_NAME, totalText));
}
return lines;
}
/**
* 利润点按销售价的费用顺序,对调价前同名行(按出现顺序,不并行)。
* 对不上或调价前金额为 0:这一行留空。费用合计用整单利润点。
*/
private static List<InquiryPriceLine> profitAgainst(List<FeeRow> baseline, List<FeeRow> sale, String totalProfit) {
boolean[] used = new boolean[baseline == null ? 0 : baseline.size()];
List<InquiryPriceLine> lines = new ArrayList<InquiryPriceLine>();
if (sale != null) {
for (FeeRow row : sale) {
int idx = firstUnused(baseline, used, row.name);
FeeRow base = idx < 0 ? null : baseline.get(idx);
if (idx >= 0) {
used[idx] = true;
}
lines.add(line(row.name, percentOf(base, row)));
}
}
if (StringUtils.isNotBlank(totalProfit)) {
lines.add(line(TOTAL_NAME, totalProfit));
}
return lines;
}
private static List<InquiryPriceLine> profitFromAligned(List<Aligned> rows, String totalProfit) {
List<InquiryPriceLine> lines = new ArrayList<InquiryPriceLine>();
for (Aligned row : rows) {
lines.add(line(row.name, percentOf(row.left, row.right)));
}
if (StringUtils.isNotBlank(totalProfit)) {
lines.add(line(TOTAL_NAME, totalProfit));
}
return lines;
}
private static String percentOf(FeeRow before, FeeRow after) {
if (before == null || after == null || before.amount == null || after.amount == null) {
return "";
}
if (before.amount.compareTo(BigDecimal.ZERO) == 0) {
return "";
}
return InquiryTicketPriceSupport.profitText(after.text, before.text);
}
private static List<Aligned> align(List<FeeRow> left, List<FeeRow> right) {
List<FeeRow> safeRight = right == null ? Collections.<FeeRow>emptyList() : right;
boolean[] used = new boolean[safeRight.size()];
List<Aligned> out = new ArrayList<Aligned>();
if (left != null) {
for (FeeRow row : left) {
int idx = firstUnused(safeRight, used, row.name);
FeeRow match = null;
if (idx >= 0) {
used[idx] = true;
match = safeRight.get(idx);
}
out.add(new Aligned(row.name, row, match));
}
}
for (int i = 0; i < safeRight.size(); i++) {
if (!used[i]) {
FeeRow row = safeRight.get(i);
out.add(new Aligned(row.name, null, row));
}
}
return out;
}
private static int firstUnused(List<FeeRow> rows, boolean[] used, String name) {
if (rows == null) {
return -1;
}
for (int i = 0; i < rows.size(); i++) {
if (used != null && i < used.length && used[i]) {
continue;
}
if (sameFee(name, rows.get(i).name)) {
return i;
}
}
return -1;
}
/**
* 费用名对齐:去空格后全等,或只差「费 / 费用 / 用」。操作费不等于码头操作费。
*/
static boolean sameFee(String left, String right) {
String a = feeKey(left);
String b = feeKey(right);
if (a.isEmpty() || b.isEmpty()) {
return false;
}
if (a.equals(b)) {
return true;
}
if (a.equals(b + "用") || a.equals(b + "费用") || b.equals(a + "用") || b.equals(a + "费用")) {
return true;
}
return (a + "费").equals(b) || (b + "费").equals(a);
}
private static String feeKey(String name) {
String raw = StringUtils.trimToEmpty(name).replace(" ", "").replace("(", "(").replace(")", ")");
int slash = raw.indexOf('/');
if (slash < 0) {
slash = raw.indexOf('/');
}
if (slash > 0) {
raw = raw.substring(0, slash);
}
return raw.trim();
}
private static List<FeeRow> feesOf(InquiryQuote quote) {
JSONObject payload = payloadOf(quote);
if (payload == null) {
return new ArrayList<FeeRow>();
}
List<FeeRow> rows = readFees(payload.get("fee_lines"));
if (rows.isEmpty()) {
rows = readFees(payload.get("fee_rows"));
}
if (rows.isEmpty()) {
JSONObject selected = asObject(payload.get("selected_air_option"));
if (selected == null) {
selected = asObject(payload.get("selectedAirOption"));
}
if (selected != null) {
rows = readFees(firstPresent(selected, "departureCharges", "departure_charges", "feeItems"));
}
}
if (rows.isEmpty()) {
JSONArray options = asArray(payload.get("airOptions"));
if (options == null) {
options = asArray(payload.get("air_options"));
}
if (options != null) {
for (int i = 0; i < options.size(); i++) {
JSONObject option = asObject(options.get(i));
if (option == null) {
continue;
}
rows = readFees(firstPresent(option, "departureCharges", "departure_charges", "feeItems"));
if (!rows.isEmpty()) {
break;
}
}
}
}
// 扁平明细为空才读海运三段。某一段有 fee_rows_ 就用它,没有才用原桶,避免同一段加两遍。
if (rows.isEmpty()) {
for (String bucket : BUCKETS) {
List<FeeRow> keyed = readFees(payload.get("fee_rows_" + bucket));
if (!keyed.isEmpty()) {
rows.addAll(keyed);
} else {
rows.addAll(readFees(payload.get(bucket)));
}
}
}
return rows;
}
private static Object firstPresent(JSONObject obj, String... keys) {
for (String key : keys) {
if (obj.get(key) != null) {
return obj.get(key);
}
}
return null;
}
private static List<FeeRow> readFees(Object raw) {
JSONArray array = asArray(raw);
List<FeeRow> rows = new ArrayList<FeeRow>();
if (array == null) {
return rows;
}
for (int i = 0; i < array.size(); i++) {
FeeRow row = toFee(asObject(array.get(i)));
if (row != null) {
rows.add(row);
}
}
return rows;
}
private static FeeRow toFee(JSONObject item) {
if (item == null) {
return null;
}
String name = firstText(item, "name", "chargeName", "feeName", "label");
if (StringUtils.isBlank(name) || isSubtotal(name) || isTotalAlias(name)) {
return null;
}
String amount = firstText(item, "amount", "totalPrice", "value");
if (StringUtils.isBlank(amount) || "-".equals(amount)) {
amount = firstText(item, "unitPrice", "unit_price");
}
if (StringUtils.isBlank(amount) || "-".equals(amount)) {
return null;
}
String currency = firstText(item, "currency", "currencyCode").toUpperCase();
if ("RMB".equals(currency)) {
currency = "CNY";
}
String text = amount;
String upper = amount.toUpperCase();
if (StringUtils.isNotBlank(currency) && !upper.startsWith(currency) && !upper.startsWith("RMB")) {
text = currency + " " + amount;
}
FeeRow row = new FeeRow();
row.name = name;
row.text = text;
row.currency = StringUtils.isNotBlank(currency) ? currency : currencyOf(text);
row.amount = InquiryTicketPriceSupport.parseAmount(text);
return row;
}
private static boolean isSubtotal(String name) {
return feeKey(name).contains("小计");
}
private static boolean isTotalAlias(String name) {
String key = feeKey(name);
return "费用合计".equals(key) || "费用总计".equals(key) || "总销售价".equals(key)
|| "总成本价".equals(key) || "汇总价".equals(key) || "总价".equals(key)
|| "报价合计".equals(key);
}
private static String profitBaseline(InquiryQuote quote) {
JSONObject payload = payloadOf(quote);
if (payload == null) {
return "";
}
String locked = firstText(payload, "profit_baseline", "profitBaseline");
return StringUtils.trimToEmpty(locked);
}
private static JSONObject payloadOf(InquiryQuote quote) {
if (quote == null || StringUtils.isBlank(quote.getPayloadJson())) {
return null;
}
try {
return JSON.parseObject(quote.getPayloadJson());
} catch (Exception ignored) {
return null;
}
}
private static JSONObject asObject(Object raw) {
if (raw instanceof JSONObject) {
return (JSONObject) raw;
}
if (raw instanceof String && StringUtils.isNotBlank((String) raw)) {
try {
Object parsed = JSON.parse((String) raw);
if (parsed instanceof String) {
parsed = JSON.parse((String) parsed);
}
if (parsed instanceof JSONObject) {
return (JSONObject) parsed;
}
} catch (Exception ignored) {
return null;
}
}
return null;
}
private static JSONArray asArray(Object raw) {
if (raw instanceof JSONArray) {
return (JSONArray) raw;
}
if (raw instanceof List) {
JSONArray array = new JSONArray();
for (Object item : (List<?>) raw) {
array.add(item);
}
return array;
}
if (raw instanceof String && StringUtils.isNotBlank((String) raw)) {
try {
Object parsed = JSON.parse((String) raw);
if (parsed instanceof String) {
parsed = JSON.parse((String) parsed);
}
if (parsed instanceof JSONArray) {
return (JSONArray) parsed;
}
} catch (Exception ignored) {
return null;
}
}
return null;
}
private static String firstText(JSONObject obj, String... keys) {
for (String key : keys) {
String text = StringUtils.trimToEmpty(obj.getString(key));
if (StringUtils.isNotBlank(text)) {
return text;
}
}
return "";
}
private static String priceText(InquiryQuote quote) {
return quote == null ? "" : StringUtils.trimToEmpty(quote.getPriceText());
}
private static String currencyOf(String priceText) {
String text = StringUtils.trimToEmpty(priceText).toUpperCase();
if (text.startsWith("RMB")) {
return "CNY";
}
if (text.length() >= 3 && Character.isLetter(text.charAt(0))
&& Character.isLetter(text.charAt(1)) && Character.isLetter(text.charAt(2))) {
return text.substring(0, 3);
}
return "";
}
private static String formatMoney(BigDecimal amount, String currency) {
String body = amount.setScale(2, RoundingMode.HALF_UP).toPlainString();
if (StringUtils.isBlank(currency)) {
return body;
}
return currency + " " + body;
}
private static InquiryPriceLine line(String name, String text) {
InquiryPriceLine row = new InquiryPriceLine();
row.setName(name);
row.setText(StringUtils.defaultString(text));
return row;
}
/** 一条费用的展示金额。amount 是比利润用的数字,text 是原样带币种的写法。 */
static final class FeeRow {
private String name;
private String text;
private String currency;
private BigDecimal amount;
private FeeRow copy() {
FeeRow row = new FeeRow();
row.name = name;
row.text = text;
row.currency = currency;
row.amount = amount;
return row;
}
}
/** 成本侧与销售侧对上的一行。某一侧没有时为 null。 */
private static final class Aligned {
private final String name;
private final FeeRow left;
private final FeeRow right;
private Aligned(String name, FeeRow left, FeeRow right) {
this.name = name;
this.left = left;
this.right = right;
}
}
}
@@ -15,7 +15,7 @@ import java.util.regex.Pattern;
* <p>
* 海运/陆运:TMS、产品报价是成本;销售调价后才有销售价,利润点=(销售-成本)/成本。
* 空运:成本价始终空;TMS/航线/调价都是销售侧;利润点只在销售调过价后算。
* 副作用:只改传入 ticket 的三个展示字段,不写库。调用方决定是否 updateById。
* 副作用:只改传入 ticket 的展示字段(三个总价字符串和三列逐行),不写库。调用方决定是否 updateById。
*/
public final class InquiryTicketPriceSupport {
@@ -65,17 +65,19 @@ public final class InquiryTicketPriceSupport {
ticket.setSalePrice(StringUtils.isNotBlank(manualPrice) ? manualPrice : tmsPrice);
ticket.setProfitRate("");
}
return;
}
String cost = StringUtils.isNotBlank(manualPrice) ? manualPrice : tmsPrice;
ticket.setCostPrice(StringUtils.defaultString(cost));
if (StringUtils.isNotBlank(adjustPrice)) {
ticket.setSalePrice(adjustPrice);
ticket.setProfitRate(profitText(adjustPrice, cost));
} else {
ticket.setSalePrice("");
ticket.setProfitRate("");
String cost = StringUtils.isNotBlank(manualPrice) ? manualPrice : tmsPrice;
ticket.setCostPrice(StringUtils.defaultString(cost));
if (StringUtils.isNotBlank(adjustPrice)) {
ticket.setSalePrice(adjustPrice);
ticket.setProfitRate(profitText(adjustPrice, cost));
} else {
ticket.setSalePrice("");
ticket.setProfitRate("");
}
}
// 三个总价定完后,按同一批报价拆成逐行。有手工明细盖 TMS 时,这里会改写成本价和利润点。
InquiryQuoteLineSupport.fill(ticket, quotes);
}
static boolean isAir(String businessLine) {
@@ -0,0 +1,57 @@
package org.jeecg.modules.inquiry.template;
import org.apache.commons.lang3.StringUtils;
/**
* 系统生成报价单的对外文件名:工单号接后台模板名,再加 .xlsx。
*
* <p>主账填的是 Excel。PDF 由智能体转完后再改成 .pdf。
* 航线、产品上传的附件不走本类。
*/
public final class QuoteSheetFileName {
private static final String[] SUFFIXES = {".xlsx", ".xls", ".xlsm", ".pdf"};
private QuoteSheetFileName() {
}
/**
* 工单号原样接上后台模板名,并带 .xlsx。模板名为空时退回「工单号_quote.xlsx」。
*
* @param workOrderNo 工单号
* @param templateName 后台模板名称
* @return 对外文件名
*/
public static String of(String workOrderNo, String templateName) {
String no = StringUtils.defaultString(workOrderNo).trim();
String name = plain(templateName);
if (StringUtils.isBlank(no)) {
return StringUtils.defaultIfBlank(name, "quote") + ".xlsx";
}
if (StringUtils.isBlank(name)) {
return no + "_quote.xlsx";
}
return no + name + ".xlsx";
}
/** 去掉已知后缀和路径非法字符,中文保留。 */
static String plain(String name) {
String text = StringUtils.defaultString(name).trim();
String lower = text.toLowerCase();
for (String ext : SUFFIXES) {
if (lower.endsWith(ext)) {
text = text.substring(0, text.length() - ext.length()).trim();
break;
}
}
StringBuilder out = new StringBuilder();
for (int i = 0; i < text.length(); i++) {
char ch = text.charAt(i);
if (ch < 32 || "\\/:*?\"<>|".indexOf(ch) >= 0) {
continue;
}
out.append(ch);
}
return out.toString().trim();
}
}
@@ -0,0 +1,216 @@
package org.jeecg.modules.inquiry.support;
import org.jeecg.modules.inquiry.entity.InquiryPriceLine;
import org.jeecg.modules.inquiry.entity.InquiryQuote;
import org.jeecg.modules.inquiry.entity.InquiryTicket;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 后台三列逐行:费用名 + 金额,最后一行费用合计用官方总价。
*/
public class InquiryQuoteLineSupportTest {
@Test
public void seaUnadjustedShowsCostLinesAndLeavesSaleEmpty() {
InquiryTicket ticket = ticket("SEA");
InquiryTicketPriceSupport.apply(ticket, Collections.singletonList(quote(
"TMS", "TMS标准报价", "CNY 1200.00",
"{\"currency\":\"CNY\",\"total\":\"1200.00\",\"fee_lines\":["
+ "{\"name\":\"运输费\",\"amount\":\"1000\",\"currency\":\"CNY\"},"
+ "{\"name\":\"卸货费\",\"amount\":\"200\",\"currency\":\"CNY\"}"
+ "]}")));
assertEquals("CNY 1200.00", ticket.getCostPrice());
assertEquals("", ticket.getSalePrice());
assertEquals("", ticket.getProfitRate());
assertLines(ticket.getCostLines(),
"运输费", "CNY 1000",
"卸货费", "CNY 200",
"费用合计", "CNY 1200.00");
assertTrue(ticket.getSaleLines() == null || ticket.getSaleLines().isEmpty());
assertTrue(ticket.getProfitLines() == null || ticket.getProfitLines().isEmpty());
}
@Test
public void seaAdjustsOneFeeAndKeepsOfficialTotal() {
InquiryTicket ticket = ticket("LAND");
InquiryTicketPriceSupport.apply(ticket, Arrays.asList(
quote("TMS", "TMS标准报价", "CNY 1200.00",
"{\"currency\":\"CNY\",\"total\":\"1200.00\",\"fee_lines\":["
+ "{\"name\":\"运输费\",\"amount\":\"1000\",\"currency\":\"CNY\"},"
+ "{\"name\":\"卸货费\",\"amount\":\"200\",\"currency\":\"CNY\"}]}"),
quote("SALES_ADJUST", "销售调价", "CNY 1220.00",
"{\"currency\":\"CNY\",\"total\":\"1220.00\",\"profit_baseline\":\"CNY 1200.00\",\"fee_lines\":["
+ "{\"name\":\"运输费\",\"amount\":\"1000\",\"currency\":\"CNY\"},"
+ "{\"name\":\"卸货费\",\"amount\":\"220\",\"currency\":\"CNY\"}]}")));
assertLines(ticket.getCostLines(),
"运输费", "CNY 1000",
"卸货费", "CNY 200",
"费用合计", "CNY 1200.00");
assertLines(ticket.getSaleLines(),
"运输费", "CNY 1000",
"卸货费", "CNY 220",
"费用合计", "CNY 1220.00");
assertLines(ticket.getProfitLines(),
"运输费", "0%",
"卸货费", "10%",
"费用合计", "1.67%");
assertEquals("1.67%", ticket.getProfitRate());
}
@Test
public void totalOnlyAdjustLeavesFeePercentsAtZero() {
InquiryTicket ticket = ticket("SEA");
InquiryTicketPriceSupport.apply(ticket, Arrays.asList(
quote("TMS", "TMS标准报价", "CNY 2000.00",
"{\"currency\":\"CNY\",\"total\":\"2000.00\",\"fee_lines\":["
+ "{\"name\":\"增点费\",\"amount\":\"100\",\"currency\":\"CNY\"},"
+ "{\"name\":\"拆板费\",\"amount\":\"100\",\"currency\":\"CNY\"}]}"),
quote("SALES_ADJUST", "销售调价", "CNY 2100.00",
"{\"currency\":\"CNY\",\"total\":\"2100.00\",\"fee_lines\":["
+ "{\"name\":\"增点费\",\"amount\":\"100\",\"currency\":\"CNY\"},"
+ "{\"name\":\"拆板费\",\"amount\":\"100\",\"currency\":\"CNY\"}]}")));
assertLines(ticket.getProfitLines(),
"增点费", "0%",
"拆板费", "0%",
"费用合计", "5%");
assertEquals("费用合计", ticket.getSaleLines().get(2).getName());
assertEquals("CNY 2100.00", ticket.getSaleLines().get(2).getText());
}
@Test
public void secondAdjustComparesToFirstBaseline() {
InquiryTicket ticket = ticket("LAND");
InquiryTicketPriceSupport.apply(ticket, Arrays.asList(
quote("TMS", "TMS标准报价", "CNY 1000.00",
"{\"currency\":\"CNY\",\"total\":\"1000\",\"fee_lines\":["
+ "{\"name\":\"运输费\",\"amount\":\"1000\",\"currency\":\"CNY\"}]}"),
quote("SALES_ADJUST", "销售调价", "CNY 1100.00",
"{\"currency\":\"CNY\",\"total\":\"1100\",\"profit_baseline\":\"1000\",\"fee_lines\":["
+ "{\"name\":\"运输费\",\"amount\":\"1100\",\"currency\":\"CNY\"}]}"),
quote("SALES_ADJUST", "销售调价", "CNY 1300.00",
"{\"currency\":\"CNY\",\"total\":\"1300\",\"profit_baseline\":\"1000\",\"fee_lines\":["
+ "{\"name\":\"运输费\",\"amount\":\"1300\",\"currency\":\"CNY\"}]}")));
assertLines(ticket.getSaleLines(), "运输费", "CNY 1300", "费用合计", "CNY 1300.00");
assertLines(ticket.getProfitLines(), "运输费", "30%", "费用合计", "30%");
}
@Test
public void airCostStaysEmptyUntilAdjustThenProfitUsesSaleBaseline() {
InquiryTicket before = ticket("AIR");
InquiryQuote tms = quote("TMS", "TMS标准报价", "CNY 2000.00",
"{\"currency\":\"CNY\",\"total\":\"2000.00\",\"fee_lines\":["
+ "{\"name\":\"空运费\",\"amount\":\"1800\",\"currency\":\"CNY\"},"
+ "{\"name\":\"增点费\",\"amount\":\"100\",\"currency\":\"CNY\"},"
+ "{\"name\":\"拆板费\",\"amount\":\"100\",\"currency\":\"CNY\"}]}");
InquiryTicketPriceSupport.apply(before, Collections.singletonList(tms));
assertTrue(before.getCostLines() == null || before.getCostLines().isEmpty());
assertEquals("", before.getCostPrice());
assertEquals("CNY 2000.00", before.getSalePrice());
assertTrue(before.getProfitLines() == null || before.getProfitLines().isEmpty());
assertLines(before.getSaleLines(),
"空运费", "CNY 1800",
"增点费", "CNY 100",
"拆板费", "CNY 100",
"费用合计", "CNY 2000.00");
InquiryTicket after = ticket("空运");
InquiryTicketPriceSupport.apply(after, Arrays.asList(
tms,
quote("SALES_ADJUST", "销售调价", "CNY 2100.00",
"{\"currency\":\"CNY\",\"total\":\"2100.00\",\"profit_baseline\":\"CNY 2000.00\",\"fee_lines\":["
+ "{\"name\":\"空运费\",\"amount\":\"1800\",\"currency\":\"CNY\"},"
+ "{\"name\":\"增点费\",\"amount\":\"100\",\"currency\":\"CNY\"},"
+ "{\"name\":\"拆板费\",\"amount\":\"110\",\"currency\":\"CNY\"}]}")));
assertTrue(after.getCostLines() == null || after.getCostLines().isEmpty());
assertLines(after.getProfitLines(),
"空运费", "0%",
"增点费", "0%",
"拆板费", "10%",
"费用合计", "5%");
}
@Test
public void airOfficialTotalIsNotTheSumOfDepartureCharges() {
InquiryTicket ticket = ticket("AIR");
InquiryTicketPriceSupport.apply(ticket, Collections.singletonList(quote(
"TMS", "TMS标准报价", "CNY 7918.25",
"{\"currency\":\"CNY\",\"total\":\"7918.25\",\"selected_air_option\":{"
+ "\"departureCharges\":["
+ "{\"name\":\"增点费\",\"amount\":\"100.0\",\"currency\":\"CNY\"},"
+ "{\"name\":\"拆板费\",\"amount\":\"100.0\",\"currency\":\"USD\"}"
+ "]}}")));
assertLines(ticket.getSaleLines(),
"增点费", "CNY 100.0",
"拆板费", "USD 100.0",
"费用合计", "CNY 7918.25");
}
@Test
public void duplicateFeeNamesStaySeparate() {
InquiryTicket ticket = ticket("LAND");
InquiryTicketPriceSupport.apply(ticket, Arrays.asList(
quote("TMS", "TMS标准报价", "CNY 10000.00",
"{\"currency\":\"CNY\",\"total\":\"10000\",\"fee_lines\":["
+ "{\"name\":\"运输费\",\"amount\":\"7000\",\"currency\":\"CNY\"},"
+ "{\"name\":\"运输费\",\"amount\":\"3000\",\"currency\":\"CNY\"}]}"),
quote("SALES_ADJUST", "销售调价", "CNY 11000.00",
"{\"currency\":\"CNY\",\"total\":\"11000\",\"fee_lines\":["
+ "{\"name\":\"运输费\",\"amount\":\"7700\",\"currency\":\"CNY\"},"
+ "{\"name\":\"运输费\",\"amount\":\"3300\",\"currency\":\"CNY\"}]}")));
assertEquals(3, ticket.getCostLines().size());
assertEquals("CNY 7000", ticket.getCostLines().get(0).getText());
assertEquals("CNY 3000", ticket.getCostLines().get(1).getText());
assertLines(ticket.getProfitLines(), "运输费", "10%", "运输费", "10%", "费用合计", "10%");
}
@Test
public void manualOverlayKeepsUntouchedTmsFee() {
InquiryTicket ticket = ticket("SEA");
InquiryTicketPriceSupport.apply(ticket, Arrays.asList(
quote("TMS", "TMS标准报价", "CNY 1200.00",
"{\"currency\":\"CNY\",\"total\":\"1200.00\",\"fee_lines\":["
+ "{\"name\":\"运输费\",\"amount\":\"1000\",\"currency\":\"CNY\"},"
+ "{\"name\":\"卸货费\",\"amount\":\"200\",\"currency\":\"CNY\"}]}"),
quote("PRODUCT", "手工报价", "CNY 320.00",
"{\"currency\":\"CNY\",\"total\":\"320\",\"fee_lines\":["
+ "{\"name\":\"卸货费\",\"amount\":\"300\",\"currency\":\"CNY\"},"
+ "{\"name\":\"配送费\",\"amount\":\"20\",\"currency\":\"CNY\"}]}")));
assertLines(ticket.getCostLines(),
"运输费", "CNY 1000",
"卸货费", "CNY 300",
"配送费", "CNY 20",
"费用合计", "CNY 1320.00");
assertEquals("CNY 1320.00", ticket.getCostPrice());
assertTrue(ticket.getSaleLines() == null || ticket.getSaleLines().isEmpty());
}
private static void assertLines(List<InquiryPriceLine> lines, String... nameText) {
assertEquals(nameText.length / 2, lines.size());
for (int i = 0; i < nameText.length; i += 2) {
assertEquals(nameText[i], lines.get(i / 2).getName());
assertEquals(nameText[i + 1], lines.get(i / 2).getText());
}
}
private static InquiryTicket ticket(String line) {
InquiryTicket t = new InquiryTicket();
t.setBusinessLine(line);
return t;
}
private static InquiryQuote quote(String source, String label, String price, String payload) {
InquiryQuote q = new InquiryQuote();
q.setSource(source);
q.setSourceLabel(label);
q.setPriceText(price);
q.setPayloadJson(payload);
return q;
}
}
@@ -0,0 +1,30 @@
package org.jeecg.modules.inquiry.template;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* 系统报价单对外文件名:工单号接模板名,并带 .xlsx。
*/
public class QuoteSheetFileNameTest {
@Test
public void joinsWorkOrderAndTemplateNameWithoutSuffix() {
assertEquals(
"WO202609100001陆运国内运输拼车报价单.xlsx",
QuoteSheetFileName.of("WO202609100001", "陆运国内运输拼车报价单"));
}
@Test
public void dropsExtensionOnTemplateName() {
assertEquals(
"WO202609100001海运报价单模板.xlsx",
QuoteSheetFileName.of("WO202609100001", "海运报价单模板.xlsx"));
}
@Test
public void blankTemplateFallsBackWithoutSuffix() {
assertEquals("WO202609100001_quote.xlsx", QuoteSheetFileName.of("WO202609100001", ""));
}
}