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

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();
}
}