完善空运锁舱与海运报价单单选回填,并增强模板预览与协同字段盖写。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+209
-5
@@ -643,11 +643,15 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找已缓存预览图。
|
||||
* 只用 v2 文件名:旧版 LibreOffice 直转 PNG 可能非等比拉伸,废弃 *-snapshot.png。
|
||||
*/
|
||||
private Path findCachedSnapshot(String versionId) throws Exception {
|
||||
Path previewDir = Paths.get(StringUtils.defaultIfBlank(uploadPath, "."), BIZ_PATH, "preview");
|
||||
Files.createDirectories(previewDir);
|
||||
Path cachedPng = previewDir.resolve(versionId + "-snapshot.png");
|
||||
Path cachedPdf = previewDir.resolve(versionId + "-snapshot.pdf");
|
||||
Path cachedPng = previewDir.resolve(versionId + "-snapshot-v4.png");
|
||||
Path cachedPdf = previewDir.resolve(versionId + "-snapshot-v4.pdf");
|
||||
if (Files.isRegularFile(cachedPng) && Files.size(cachedPng) > 0) {
|
||||
return cachedPng;
|
||||
}
|
||||
@@ -660,8 +664,8 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
private Path generateAndCacheSnapshot(InquiryQuoteTemplateVersion ver) throws Exception {
|
||||
Path previewDir = Paths.get(StringUtils.defaultIfBlank(uploadPath, "."), BIZ_PATH, "preview");
|
||||
Files.createDirectories(previewDir);
|
||||
Path cachedPng = previewDir.resolve(ver.getId() + "-snapshot.png");
|
||||
Path cachedPdf = previewDir.resolve(ver.getId() + "-snapshot.pdf");
|
||||
Path cachedPng = previewDir.resolve(ver.getId() + "-snapshot-v4.png");
|
||||
Path cachedPdf = previewDir.resolve(ver.getId() + "-snapshot-v4.pdf");
|
||||
Path source = resolveObjectPath(ver.getObjectKey());
|
||||
QuoteTemplateSnapshotRenderer.PreviewBytes rendered = snapshotRenderer.render(source, ver.getId());
|
||||
Path target = rendered.isImage() ? cachedPng : cachedPdf;
|
||||
@@ -871,8 +875,31 @@ 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"));
|
||||
out.put("total_amount", first(q, "total", "total_amount"));
|
||||
// 预估费用必须用 TMS 总销售价。会话 quote 偶发只有 selected_air_option.salePrice、
|
||||
// 顶层 total 空时,若仍走模板 SUM,会把增点费+拆板费加成 200(WO040/WO026)。
|
||||
// 群出单还可能把明细之和误写成 total=200,此时仍以线路 salePrice 为准。
|
||||
String fromOpt = airOptionSalePrice(q);
|
||||
String totalAmount = first(q, "total", "total_amount", "salePrice", "totalSalePrice");
|
||||
if (StringUtils.isBlank(totalAmount) || "-".equals(totalAmount)) {
|
||||
totalAmount = fromOpt;
|
||||
} else if (StringUtils.isNotBlank(fromOpt)
|
||||
&& looksAirInquiry(src, q, businessLine)
|
||||
&& totalLooksLikeFeeRowSum(q, totalAmount)
|
||||
&& !sameMoneyAmount(totalAmount, fromOpt)) {
|
||||
totalAmount = fromOpt;
|
||||
}
|
||||
if (StringUtils.isBlank(totalAmount) || "-".equals(totalAmount)) {
|
||||
// freight 与总价同值时(无分项空运费)也可用
|
||||
String freight = first(q, "freight");
|
||||
if (StringUtils.isNotBlank(freight) && !"-".equals(freight)) {
|
||||
totalAmount = freight;
|
||||
}
|
||||
}
|
||||
out.put("total_amount", totalAmount);
|
||||
String currency = first(q, "currency");
|
||||
if (StringUtils.isBlank(currency)) {
|
||||
currency = airOptionCurrency(q);
|
||||
}
|
||||
if (StringUtils.isNotBlank(currency)) {
|
||||
out.put("currency", currency);
|
||||
}
|
||||
@@ -977,6 +1004,11 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
out.put("fee_rows_" + bucket, part);
|
||||
out.put(bucket, part);
|
||||
}
|
||||
// 三段小计:卡片用 fee_total_*,出单写进「始发港/海运/目的港费用汇总」行
|
||||
String sub = first(q, "fee_total_" + bucket);
|
||||
if (StringUtils.isNotBlank(sub) && !"-".equals(sub)) {
|
||||
out.put("fee_total_" + bucket, sub);
|
||||
}
|
||||
}
|
||||
// 陆运线路:透传 landSegments / route_rows,供 routeRegions 填项目一
|
||||
JSONArray landSegs = QuoteTemplateExcelSupport.parseFeeRows(q.get("route_rows"));
|
||||
@@ -1071,6 +1103,178 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 空运选定线路 / 首条 airOptions 的 salePrice。
|
||||
* 会话 quote 有时只投影了线路对象,顶层 total 空,出单预估费用必须能落到这个数。
|
||||
*/
|
||||
private static String airOptionSalePrice(Map<String, Object> quote) {
|
||||
if (quote == null || quote.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
Object selected = quote.get("selected_air_option");
|
||||
if (selected == null) {
|
||||
selected = quote.get("selectedAirOption");
|
||||
}
|
||||
String fromSelected = salePriceFromOptionObj(selected);
|
||||
if (StringUtils.isNotBlank(fromSelected)) {
|
||||
return fromSelected;
|
||||
}
|
||||
Object raw = quote.get("airOptions");
|
||||
if (raw == null) {
|
||||
raw = quote.get("air_options");
|
||||
}
|
||||
JSONArray opts = QuoteTemplateExcelSupport.parseFeeRows(raw);
|
||||
if (opts.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
return salePriceFromOptionObj(opts.getJSONObject(0));
|
||||
}
|
||||
|
||||
/** 空运线路币种:选定 > 首条 airOptions。 */
|
||||
private static String airOptionCurrency(Map<String, Object> quote) {
|
||||
if (quote == null || quote.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
Object selected = quote.get("selected_air_option");
|
||||
if (selected == null) {
|
||||
selected = quote.get("selectedAirOption");
|
||||
}
|
||||
String ccy = currencyFromOptionObj(selected);
|
||||
if (StringUtils.isNotBlank(ccy)) {
|
||||
return ccy;
|
||||
}
|
||||
Object raw = quote.get("airOptions");
|
||||
if (raw == null) {
|
||||
raw = quote.get("air_options");
|
||||
}
|
||||
JSONArray opts = QuoteTemplateExcelSupport.parseFeeRows(raw);
|
||||
if (opts.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
return currencyFromOptionObj(opts.getJSONObject(0));
|
||||
}
|
||||
|
||||
private static String salePriceFromOptionObj(Object raw) {
|
||||
JSONObject opt = asJsonObject(raw);
|
||||
if (opt == null) {
|
||||
return "";
|
||||
}
|
||||
return firstNonBlankLocal(opt.getString("salePrice"), opt.getString("sale_price"),
|
||||
opt.getString("totalSalePrice"), opt.getString("total"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶层 total 是否只是 fee_lines 金额相加(如 100+100=200)。
|
||||
* 空运群薄报价常把附加费之和误当总价,真总价在 airOptions.salePrice。
|
||||
*/
|
||||
private static boolean totalLooksLikeFeeRowSum(Map<String, Object> quote, String totalText) {
|
||||
Double totalNum = parseMoneyPlain(totalText);
|
||||
if (totalNum == null) {
|
||||
return false;
|
||||
}
|
||||
JSONArray rows = QuoteTemplateExcelSupport.parseFeeRows(quote.get("fee_lines"));
|
||||
if (rows.isEmpty()) {
|
||||
rows = QuoteTemplateExcelSupport.parseFeeRows(quote.get("fee_rows"));
|
||||
}
|
||||
if (rows.isEmpty()) {
|
||||
rows = QuoteTemplateExcelSupport.parseFeeRows(quote.get("feeItems"));
|
||||
}
|
||||
if (rows.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
double sum = 0.0;
|
||||
int hit = 0;
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
JSONObject one = rows.getJSONObject(i);
|
||||
if (one == null) {
|
||||
continue;
|
||||
}
|
||||
Double amt = parseMoneyPlain(firstNonBlankLocal(
|
||||
one.getString("amount"), one.getString("value"),
|
||||
one.getString("salePrice"), one.getString("price")));
|
||||
if (amt == null) {
|
||||
continue;
|
||||
}
|
||||
sum += amt;
|
||||
hit++;
|
||||
}
|
||||
if (hit <= 0) {
|
||||
return false;
|
||||
}
|
||||
return Math.abs(totalNum - sum) < 0.01;
|
||||
}
|
||||
|
||||
/** 去掉币种前缀后比较金额是否相同。 */
|
||||
private static boolean sameMoneyAmount(String left, String right) {
|
||||
Double a = parseMoneyPlain(left);
|
||||
Double b = parseMoneyPlain(right);
|
||||
if (a == null || b == null) {
|
||||
return StringUtils.equals(StringUtils.trimToEmpty(left), StringUtils.trimToEmpty(right));
|
||||
}
|
||||
return Math.abs(a - b) < 0.01;
|
||||
}
|
||||
|
||||
/** 从「CNY 7918.25」抽出数字;解不开返回 null。 */
|
||||
private static Double parseMoneyPlain(String text) {
|
||||
String raw = StringUtils.trimToEmpty(text);
|
||||
if (raw.isEmpty() || "-".equals(raw)) {
|
||||
return null;
|
||||
}
|
||||
java.util.regex.Matcher m = java.util.regex.Pattern
|
||||
.compile("(-?\\d+(?:,\\d{3})*(?:\\.\\d+)?)")
|
||||
.matcher(raw);
|
||||
String last = null;
|
||||
while (m.find()) {
|
||||
last = m.group(1);
|
||||
}
|
||||
if (last == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Double.parseDouble(last.replace(",", ""));
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String currencyFromOptionObj(Object raw) {
|
||||
JSONObject opt = asJsonObject(raw);
|
||||
if (opt == null) {
|
||||
return "";
|
||||
}
|
||||
return StringUtils.trimToEmpty(opt.getString("currency"));
|
||||
}
|
||||
|
||||
private static JSONObject asJsonObject(Object raw) {
|
||||
if (raw == null) {
|
||||
return null;
|
||||
}
|
||||
if (raw instanceof JSONObject) {
|
||||
return (JSONObject) raw;
|
||||
}
|
||||
if (raw instanceof Map) {
|
||||
return JSON.parseObject(JSON.toJSONString(raw));
|
||||
}
|
||||
if (raw instanceof String) {
|
||||
String text = StringUtils.trimToEmpty((String) raw);
|
||||
if (text.isEmpty() || "null".equalsIgnoreCase(text)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
Object parsed = JSON.parse(text);
|
||||
if (parsed instanceof JSONObject) {
|
||||
return (JSONObject) parsed;
|
||||
}
|
||||
if (parsed instanceof Map) {
|
||||
return JSON.parseObject(JSON.toJSONString(parsed));
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 协同项没写,或只写了横杠。横杠是空占位,不能当成已经填过。 */
|
||||
private static boolean collabBlank(String raw) {
|
||||
String val = StringUtils.trimToEmpty(raw);
|
||||
|
||||
+15
-5
@@ -503,7 +503,8 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
ticket.setGoodsName(facts.get("品名"));
|
||||
ticket.setPieces(parseInt(facts.get("件数")));
|
||||
ticket.setWeightKg(parseDecimal(firstFact(facts, "重量", "毛重")));
|
||||
ticket.setVolumeCbm(parseDecimal(facts.get("体积")));
|
||||
// 体积常写尺寸 1.1*1.2*3,必须相乘;旧解析剥 * 会变成 1.11.23 直接 NumberFormat 炸建单。
|
||||
ticket.setVolumeCbm(parseVolumeCbm(facts.get("体积")));
|
||||
ticket.setQuoteDate(parseDate(facts.get("报价日期")));
|
||||
ticket.setCargoQty(StringUtils.trimToNull(firstFact(facts, "货量", "货物数量")));
|
||||
ticket.setFclLcl(StringUtils.trimToNull(facts.get("整柜或拼柜")));
|
||||
@@ -578,7 +579,7 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
Map<String, String> facts = req.getFacts() == null ? new HashMap<>() : req.getFacts();
|
||||
Integer pieces = parseInt(facts.get("件数"));
|
||||
BigDecimal weight = parseDecimal(firstFact(facts, "重量", "毛重"));
|
||||
BigDecimal volume = parseDecimal(facts.get("体积"));
|
||||
BigDecimal volume = parseVolumeCbm(facts.get("体积"));
|
||||
Date quoteDate = parseDate(facts.get("报价日期"));
|
||||
return list(new LambdaQueryWrapper<InquiryTicket>()
|
||||
.eq(InquiryTicket::getBusinessLine, req.getBusinessLine().trim().toUpperCase())
|
||||
@@ -677,15 +678,24 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
return Integer.valueOf(digits);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重量等:只取第一个数字(100KGS → 100)。坏串返回 null,禁止抛到建单接口。
|
||||
*/
|
||||
private static BigDecimal parseDecimal(String raw) {
|
||||
if (StringUtils.isBlank(raw)) {
|
||||
return null;
|
||||
}
|
||||
String cleaned = raw.replaceAll("[^0-9.]", "");
|
||||
if (cleaned.isEmpty()) {
|
||||
return org.jeecg.modules.inquiry.tms.TmsV2RequestMapper.asPositiveDecimal(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* 体积:尺寸式相乘得 CBM;普通「2CBM」取数字。失败返回 null,不炸建单。
|
||||
*/
|
||||
private static BigDecimal parseVolumeCbm(String raw) {
|
||||
if (StringUtils.isBlank(raw)) {
|
||||
return null;
|
||||
}
|
||||
return new BigDecimal(cleaned);
|
||||
return org.jeecg.modules.inquiry.tms.TmsV2RequestMapper.asVolumeCbm(raw);
|
||||
}
|
||||
|
||||
private static Date parseDate(String raw) {
|
||||
|
||||
+1946
-35
File diff suppressed because it is too large
Load Diff
+211
-5
@@ -2,12 +2,23 @@ package org.jeecg.modules.inquiry.template;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.rendering.ImageType;
|
||||
import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
import org.apache.poi.ss.usermodel.PrintSetup;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.ss.usermodel.Workbook;
|
||||
import org.apache.poi.ss.usermodel.WorkbookFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.FileLock;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -20,8 +31,10 @@ import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* 管理端报价模板「原表截图」:本机 LibreOffice 把 xlsx 转 PNG(失败则 PDF)。
|
||||
* 管理端报价模板「原表截图」:本机 LibreOffice 把 xlsx 转 PDF,再用 PDFBox 等比例渲 PNG。
|
||||
* <p>
|
||||
* 为何不用 LibreOffice 直接转 PNG:其 pdf→png 滤镜在部分版本会把页内容非等比拉伸,
|
||||
* 后台预览出现「字被拉高/压扁」。PDFBox 按页 MediaBox 均匀换算 DPI,保证 1:1 像素比例。
|
||||
* 为何放在主账:后台预览不应依赖智能体进程;与出单 PDF 错开靠文件锁互斥。
|
||||
* 禁止:接到企微回调 / 智能体出单热路径;禁止本类起第二台 soffice 并行。
|
||||
* 副作用:写临时目录与预览缓存;短阻塞当前 HTTP 线程(仅管理端预览)。
|
||||
@@ -30,6 +43,9 @@ import java.util.stream.Stream;
|
||||
@Component
|
||||
public class QuoteTemplateSnapshotRenderer {
|
||||
|
||||
/** 预览 PNG 渲染 DPI;只影响清晰度,不改变宽高比。 */
|
||||
private static final float PREVIEW_DPI = 144f;
|
||||
|
||||
@Value("${inquiry.quote-template.soffice-path:${LIBREOFFICE_PATH:C:/Program Files/LibreOffice/program/soffice.exe}}")
|
||||
private String sofficePath;
|
||||
|
||||
@@ -73,15 +89,17 @@ public class QuoteTemplateSnapshotRenderer {
|
||||
try {
|
||||
Path localXlsx = workDir.resolve("source.xlsx");
|
||||
Files.copy(xlsxPath, localXlsx, StandardCopyOption.REPLACE_EXISTING);
|
||||
// 预览专用打印版式:按表内容宽高选横/竖,避免宽表硬塞竖版 A4 显得挤扁
|
||||
preparePreviewPrintLayout(localXlsx);
|
||||
Path pdf = convert(soffice, localXlsx, workDir, "pdf:calc_pdf_Export", ".pdf");
|
||||
if (pdf == null || !Files.isRegularFile(pdf)) {
|
||||
throw new IllegalStateException("LibreOffice 未生成 PDF");
|
||||
}
|
||||
Path png = convert(soffice, pdf, workDir, "png", ".png");
|
||||
if (png != null && Files.isRegularFile(png) && Files.size(png) > 0) {
|
||||
return new PreviewBytes(Files.readAllBytes(png), "image/png");
|
||||
byte[] pngBytes = pdfToPngEqualRatio(pdf);
|
||||
if (pngBytes != null && pngBytes.length > 0) {
|
||||
return new PreviewBytes(pngBytes, "image/png");
|
||||
}
|
||||
log.warn("PNG 未生成,退回 PDF 预览 versionId={}", versionId);
|
||||
log.warn("PDFBox 未生成 PNG,退回 PDF 预览 versionId={}", versionId);
|
||||
return new PreviewBytes(Files.readAllBytes(pdf), "application/pdf");
|
||||
} finally {
|
||||
deleteQuietly(workDir);
|
||||
@@ -94,6 +112,194 @@ public class QuoteTemplateSnapshotRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览前改写打印属性:按已用区域宽高比选横向/纵向 A4,并按页适配。
|
||||
* 只动打印,不改单元格业务内容;写回同一 xlsx 供 soffice 使用。
|
||||
*/
|
||||
private void preparePreviewPrintLayout(Path xlsx) throws IOException {
|
||||
try (InputStream in = Files.newInputStream(xlsx);
|
||||
Workbook wb = WorkbookFactory.create(in)) {
|
||||
for (int s = 0; s < wb.getNumberOfSheets(); s++) {
|
||||
Sheet sheet = wb.getSheetAt(s);
|
||||
if (sheet == null) {
|
||||
continue;
|
||||
}
|
||||
boolean landscape = preferLandscape(sheet);
|
||||
sheet.setMargin(Sheet.LeftMargin, 0.4);
|
||||
sheet.setMargin(Sheet.RightMargin, 0.4);
|
||||
sheet.setMargin(Sheet.TopMargin, 0.5);
|
||||
sheet.setMargin(Sheet.BottomMargin, 0.5);
|
||||
sheet.setHorizontallyCenter(true);
|
||||
sheet.setVerticallyCenter(false);
|
||||
sheet.setAutobreaks(true);
|
||||
sheet.setFitToPage(true);
|
||||
PrintSetup ps = sheet.getPrintSetup();
|
||||
ps.setPaperSize(PrintSetup.A4_PAPERSIZE);
|
||||
ps.setLandscape(landscape);
|
||||
ps.setFitWidth((short) 1);
|
||||
// 只按页宽适配:不把矮表纵向撑满整页,减少页底空白(最终仍靠 PNG 裁白边)
|
||||
ps.setFitHeight((short) 0);
|
||||
}
|
||||
try (OutputStream out = Files.newOutputStream(xlsx, StandardOpenOption.TRUNCATE_EXISTING,
|
||||
StandardOpenOption.WRITE)) {
|
||||
wb.write(out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 粗估工作表内容宽高:列宽合计 vs 行高合计,宽更大则预览用横向。
|
||||
* 单位换算只需相对比较,不要求绝对毫米精度。
|
||||
*/
|
||||
private static boolean preferLandscape(Sheet sheet) {
|
||||
int firstRow = Math.max(sheet.getFirstRowNum(), 0);
|
||||
int lastRow = sheet.getLastRowNum();
|
||||
if (lastRow < firstRow) {
|
||||
return false;
|
||||
}
|
||||
int maxCol = 0;
|
||||
for (int r = firstRow; r <= lastRow && r <= firstRow + 120; r++) {
|
||||
Row row = sheet.getRow(r);
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
short last = row.getLastCellNum();
|
||||
if (last > maxCol) {
|
||||
maxCol = last;
|
||||
}
|
||||
}
|
||||
if (maxCol <= 0) {
|
||||
return false;
|
||||
}
|
||||
double widthPx = 0;
|
||||
for (int c = 0; c < maxCol; c++) {
|
||||
// POI 列宽:1/256 字符宽;约 7px/字符(96dpi 经验值)
|
||||
widthPx += (sheet.getColumnWidth(c) / 256.0) * 7.0;
|
||||
}
|
||||
double heightPx = 0;
|
||||
int rowLimit = Math.min(lastRow, firstRow + 120);
|
||||
for (int r = firstRow; r <= rowLimit; r++) {
|
||||
Row row = sheet.getRow(r);
|
||||
float points = row != null ? row.getHeightInPoints() : sheet.getDefaultRowHeightInPoints();
|
||||
heightPx += points * (96.0 / 72.0);
|
||||
}
|
||||
return widthPx > heightPx * 1.08;
|
||||
}
|
||||
|
||||
/**
|
||||
* PDF 首页等比例渲 PNG:PDFBox 按页尺寸 × DPI 均匀换算,禁止非等比拉伸。
|
||||
* 渲完后裁掉近白页边,避免 A4 页底大块空白占预览高度。
|
||||
*/
|
||||
private byte[] pdfToPngEqualRatio(Path pdf) throws IOException {
|
||||
try (PDDocument doc = PDDocument.load(pdf.toFile())) {
|
||||
if (doc.getNumberOfPages() < 1) {
|
||||
return null;
|
||||
}
|
||||
PDFRenderer renderer = new PDFRenderer(doc);
|
||||
BufferedImage image = renderer.renderImageWithDPI(0, PREVIEW_DPI, ImageType.RGB);
|
||||
image = trimNearWhiteMargins(image, 8);
|
||||
ByteArrayOutputStream buf = new ByteArrayOutputStream();
|
||||
if (!ImageIO.write(image, "png", buf)) {
|
||||
throw new IOException("ImageIO 无法写出 PNG");
|
||||
}
|
||||
return buf.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 裁掉四周近白色边距,四周各留 {@code pad} 像素,避免预览弹窗底下留一大块空白。
|
||||
* <p>
|
||||
* 行/列允许极少量杂点(浅网格线);写出前必须拷贝到新 BufferedImage,
|
||||
* 禁止直接 ImageIO.write(getSubimage())——部分 JDK 会把整页母图写出去,裁切等于没做。
|
||||
*/
|
||||
static BufferedImage trimNearWhiteMargins(BufferedImage src, int pad) {
|
||||
if (src == null) {
|
||||
return null;
|
||||
}
|
||||
int w = src.getWidth();
|
||||
int h = src.getHeight();
|
||||
if (w <= 0 || h <= 0) {
|
||||
return src;
|
||||
}
|
||||
int top = 0;
|
||||
int bottom = h - 1;
|
||||
int left = 0;
|
||||
int right = w - 1;
|
||||
while (top < h && isMostlyWhiteRow(src, top)) {
|
||||
top++;
|
||||
}
|
||||
while (bottom > top && isMostlyWhiteRow(src, bottom)) {
|
||||
bottom--;
|
||||
}
|
||||
while (left < w && isMostlyWhiteCol(src, left, top, bottom)) {
|
||||
left++;
|
||||
}
|
||||
while (right > left && isMostlyWhiteCol(src, right, top, bottom)) {
|
||||
right--;
|
||||
}
|
||||
// 几乎全白:不裁,避免空图
|
||||
if (top >= bottom || left >= right || (bottom - top + 1) < h / 20) {
|
||||
return src;
|
||||
}
|
||||
int padSafe = Math.max(0, pad);
|
||||
int x = Math.max(0, left - padSafe);
|
||||
int y = Math.max(0, top - padSafe);
|
||||
int x2 = Math.min(w - 1, right + padSafe);
|
||||
int y2 = Math.min(h - 1, bottom + padSafe);
|
||||
int cw = x2 - x + 1;
|
||||
int ch = y2 - y + 1;
|
||||
if (cw >= w && ch >= h) {
|
||||
return src;
|
||||
}
|
||||
BufferedImage copy = new BufferedImage(cw, ch, BufferedImage.TYPE_INT_RGB);
|
||||
java.awt.Graphics2D g = copy.createGraphics();
|
||||
try {
|
||||
g.drawImage(src, 0, 0, cw, ch, x, y, x + cw, y + ch, null);
|
||||
} finally {
|
||||
g.dispose();
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
/** 一行里非近白像素不超过约 0.5% 则视为空白行(容忍浅网格)。 */
|
||||
private static boolean isMostlyWhiteRow(BufferedImage img, int y) {
|
||||
int w = img.getWidth();
|
||||
int allow = Math.max(2, w / 200);
|
||||
int dirty = 0;
|
||||
for (int x = 0; x < w; x++) {
|
||||
if (!isNearWhitePixel(img.getRGB(x, y))) {
|
||||
dirty++;
|
||||
if (dirty > allow) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean isMostlyWhiteCol(BufferedImage img, int x, int y0, int y1) {
|
||||
int h = y1 - y0 + 1;
|
||||
int allow = Math.max(2, h / 200);
|
||||
int dirty = 0;
|
||||
for (int y = y0; y <= y1; y++) {
|
||||
if (!isNearWhitePixel(img.getRGB(x, y))) {
|
||||
dirty++;
|
||||
if (dirty > allow) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean isNearWhitePixel(int rgb) {
|
||||
int r = (rgb >> 16) & 0xff;
|
||||
int g = (rgb >> 8) & 0xff;
|
||||
int b = rgb & 0xff;
|
||||
// 略放宽:打印灰底/抗锯齿边也不挡裁切
|
||||
return r >= 240 && g >= 240 && b >= 240;
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 soffice 无界面转换;已在文件锁内,禁止再开并行实例。
|
||||
*/
|
||||
|
||||
+431
-40
@@ -28,7 +28,9 @@ import java.util.Map;
|
||||
* 锁舱按 /air/cabin/lock:用工单起运地/目的地/报价日期/重量/体积拼正文。
|
||||
* 没有报价不能锁舱,禁止编造 QT-{工单号}、AIR-OPT-01。
|
||||
* 只看最新一版:库里 source=TMS(或航线确认 TMS)出站写成 source=SYSTEM;人工报价出站 source=MANUAL。
|
||||
* 有真实 TMS 编号才带 quoteId。有值才出站,缺航班和费用不拦。
|
||||
* 有真实 TMS 编号才带 quoteId。有值才出站,缺航班不拦。
|
||||
* SYSTEM 出站精简(quoteId + 航班等标量,不带 validUntil/feeItems);
|
||||
* MANUAL 费用名按 TMS 查价回包原样带回,有效期必须带时区 ISO8601。
|
||||
* 已成交再释放:本地拦住。测服打开 TMS_V2_LOCK_ENABLED;现网验收期保持关闭。
|
||||
* 禁止:按 userid 伪造报价;单测打真实 TMS。
|
||||
*/
|
||||
@@ -71,37 +73,64 @@ public class InquiryTmsCabinService {
|
||||
return fail("no_quote", "这张单还没有报价,不能锁舱");
|
||||
}
|
||||
Map<String, Object> quote = payloadOf(rows.get(0));
|
||||
boolean manual = isManualQuote(quote);
|
||||
if (!manual && StringUtils.isBlank(resolveQuoteId(quote, no))) {
|
||||
// 「AIR-OPT01 TH」被误落成航线报价不算真正人工价 → 仍走仅 TMS(SYSTEM)。
|
||||
boolean latestManual = isManualQuote(quote) && !isFakeManualLockQuote(quote);
|
||||
if (!latestManual && StringUtils.isBlank(resolveQuoteId(quote, no))) {
|
||||
quote = refreshLockQuote(ticket, quote);
|
||||
}
|
||||
String quoteId = manual ? realTmsQuoteId(rows, no) : resolveQuoteId(quote, no);
|
||||
if (!manual && StringUtils.isBlank(quoteId)) {
|
||||
quoteId = realTmsQuoteId(rows, no);
|
||||
String tmsQuoteId = realTmsQuoteId(rows, no);
|
||||
if (!latestManual && StringUtils.isBlank(tmsQuoteId)) {
|
||||
tmsQuoteId = resolveQuoteId(quote, no);
|
||||
}
|
||||
if (!manual && StringUtils.isBlank(quoteId)) {
|
||||
boolean hasTms = StringUtils.isNotBlank(tmsQuoteId);
|
||||
// 三种锁舱口径(产品):
|
||||
// 1) 仅 TMS → SYSTEM + quoteId
|
||||
// 2) TMS + 人工 → MANUAL + quoteId(引用原 TMS 编号)
|
||||
// 3) 仅人工 → MANUAL,不传 quoteId
|
||||
String lockSource;
|
||||
String quoteId;
|
||||
if (hasTms && !latestManual) {
|
||||
lockSource = "SYSTEM";
|
||||
quoteId = tmsQuoteId;
|
||||
} else if (hasTms) {
|
||||
lockSource = "MANUAL";
|
||||
quoteId = tmsQuoteId;
|
||||
} else if (latestManual) {
|
||||
lockSource = "MANUAL";
|
||||
quoteId = "";
|
||||
} else {
|
||||
return fail("no_quote", "这张单还没有报价,不能锁舱");
|
||||
}
|
||||
Map<String, Object> option = resolveOption(quote, optionNo);
|
||||
if (option == null) {
|
||||
option = new LinkedHashMap<>();
|
||||
}
|
||||
if (manual && StringUtils.isBlank(text(option, "optionNo", "option_no"))) {
|
||||
// 手工价往往只有金额。舱位、航班仍用本单那条 TMS 报价,不另编编号。
|
||||
Map<String, Object> tmsOption = tmsOptionForLock(rows, no, optionNo);
|
||||
if (!tmsOption.isEmpty()) {
|
||||
option = tmsOption;
|
||||
}
|
||||
// 手工价常只有金额/错误抽到的 fee_lines(如把 AIR-OPT01 抽成 OPT)。
|
||||
// 只要本单有真实 TMS 舱位,就用它补航班、费用、有效期,不能只因 stub 已有 optionNo 就跳过。
|
||||
String wantOption = firstNonBlank(text(option, "optionNo", "option_no"), optionNo);
|
||||
Map<String, Object> tmsOption = tmsOptionForLock(rows, no, wantOption);
|
||||
if (!tmsOption.isEmpty()) {
|
||||
option = mergeLockOption(option, tmsOption);
|
||||
}
|
||||
String resolvedOption = text(option, "optionNo", "option_no");
|
||||
Map<String, Object> tmsQuote = tmsQuotePayload(rows, no);
|
||||
if (tmsQuote != null && !tmsQuote.isEmpty()) {
|
||||
quote = enrichLockQuoteFromTms(quote, tmsQuote);
|
||||
}
|
||||
// 航线句中已点名的 optionNo 不能因报价数组对不上就被丢掉。
|
||||
String resolvedOption = firstNonBlank(text(option, "optionNo", "option_no"), optionNo);
|
||||
if (!properties.lockConfigured()) {
|
||||
return fail("lock_disabled", "主账未开放锁舱");
|
||||
}
|
||||
Map<String, Object> req = new LinkedHashMap<>();
|
||||
req.put("workOrderNo", no);
|
||||
req.put("quoteId", quoteId);
|
||||
if (StringUtils.isNotBlank(quoteId)) {
|
||||
req.put("quoteId", quoteId);
|
||||
}
|
||||
req.put("optionNo", resolvedOption);
|
||||
req.put("flightDate", firstNonBlank(text(option, "flightDate"), quoteDate(ticket)));
|
||||
req.put("flightDate", firstNonBlank(
|
||||
text(option, "flightDate"),
|
||||
flightDateFromDeparture(text(option, "departureTime")),
|
||||
quoteDate(ticket)));
|
||||
req.put("origin", firstNonBlank(text(option, "originAirportCode"),
|
||||
fact(ticket, "originCode"),
|
||||
text(option, "origin"), ticket.getOrigin(),
|
||||
@@ -110,7 +139,11 @@ public class InquiryTmsCabinService {
|
||||
fact(ticket, "destinationCode"),
|
||||
text(option, "destination"), ticket.getDestination(),
|
||||
fact(ticket, "目的地", "目的港")));
|
||||
req.put("airline", firstNonBlank(text(option, "airlineCode", "airline"),
|
||||
// 航司:句中两位码(如「AIR-OPT01 TH」)> 舱位/报价字段 > 航班号前两位。
|
||||
req.put("airline", firstNonBlank(
|
||||
text(body, "airline", "airlineCode"),
|
||||
airlineFromInstruction(text(body, "instructionText", "instruction_text")),
|
||||
text(option, "airlineCode", "airline"),
|
||||
text(option, "airlineName"),
|
||||
text(quote, "airlineCode", "airline", "airlineName"),
|
||||
fact(ticket, "航司", "航司代码"),
|
||||
@@ -119,19 +152,39 @@ public class InquiryTmsCabinService {
|
||||
req.put("volumeCbm", ticket.getVolumeCbm());
|
||||
req.put("pieceQty", ticket.getPieces());
|
||||
req.put("chargeableWeightKg", firstNonBlank(
|
||||
text(quote, "chargeableWeightKg", "chargeable_weight_kg"),
|
||||
text(option, "chargeableWeightKg", "chargeable_weight_kg")));
|
||||
text(option, "chargeableWeightKg", "chargeable_weight_kg"),
|
||||
text(quote, "chargeableWeightKg", "chargeable_weight_kg")));
|
||||
req.put("operatorUserId", text(body, "operatorUserId", "sender_id"));
|
||||
req.put("operatorName", text(body, "operatorName", "operator_name"));
|
||||
req.put("instructionText", text(body, "instructionText", "instruction_text"));
|
||||
if (StringUtils.isNotBlank(text(body, "requestedUntil"))) {
|
||||
req.put("requestedUntil", text(body, "requestedUntil"));
|
||||
}
|
||||
if (manual) {
|
||||
fillManualLock(req, quote, option);
|
||||
if ("MANUAL".equals(lockSource)) {
|
||||
// 有 TMS 时保留 quoteId;纯人工才去掉。
|
||||
fillManualLock(req, quote, option, body, StringUtils.isNotBlank(quoteId));
|
||||
} else {
|
||||
// 库里仍是 source=TMS。TMS 锁舱要求纯系统报价带 source=SYSTEM,只写在出站正文。
|
||||
// 仅 TMS:source=SYSTEM + quoteId。禁止再塞 validUntil/feeItems(会 1001)。
|
||||
req.put("source", "SYSTEM");
|
||||
req.remove("validUntil");
|
||||
req.remove("requestedUntil");
|
||||
req.remove("departureCharges");
|
||||
req.remove("feeItems");
|
||||
req.remove("currency");
|
||||
req.remove("totalSalePrice");
|
||||
String transfer = firstNonBlank(text(option, "transferAirport", "transferAirportCode"),
|
||||
text(quote, "transferAirport", "transferAirportCode"));
|
||||
if (StringUtils.isNotBlank(transfer)) {
|
||||
req.put("transferAirport", transfer);
|
||||
}
|
||||
String flightNo = firstNonBlank(
|
||||
text(option, "flightNo", "flight_no"),
|
||||
text(quote, "flightNo", "flight_no"),
|
||||
flightNoFromSelectedAirOption(quote),
|
||||
flightNoFromAirOptions(quote, resolvedOption));
|
||||
if (StringUtils.isNotBlank(flightNo)) {
|
||||
req.put("flightNo", flightNo);
|
||||
}
|
||||
if (StringUtils.isNotBlank(text(body, "requestedUntil"))) {
|
||||
req.put("requestedUntil", text(body, "requestedUntil"));
|
||||
}
|
||||
}
|
||||
return invokeLock(ticket, req, quoteId, resolvedOption);
|
||||
}
|
||||
@@ -342,15 +395,22 @@ public class InquiryTmsCabinService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 人工报价出站:source 只写在 TMS 正文,不回写报价表。
|
||||
* 费用数组用报价里已有的;没有就不放。不用总价编一条空运费。
|
||||
* 人工报价出站:对齐 TMS MANUAL 合同。
|
||||
* <p>
|
||||
* 必带:source=MANUAL、optionNo、validUntil(ISO)、origin/destination、
|
||||
* flightDate、airline、flightNo、重量体积件数、计费重、币种、总价、
|
||||
* departureCharges、feeItems;可选 requestedUntil(缺则用 validUntil)。
|
||||
* {@code withQuoteId=true}:本单还有 TMS 报价时必须带原 quoteId;
|
||||
* {@code withQuoteId=false}:纯人工、无 TMS 时不传 quoteId。
|
||||
*/
|
||||
static void fillManualLock(Map<String, Object> req, Map<String, Object> quote, Map<String, Object> option) {
|
||||
static void fillManualLock(Map<String, Object> req, Map<String, Object> quote,
|
||||
Map<String, Object> option, Map<String, Object> body,
|
||||
boolean withQuoteId) {
|
||||
req.put("source", "MANUAL");
|
||||
String until = firstNonBlank(text(quote, "validUntil", "valid_until"), text(option, "validUntil"));
|
||||
if (StringUtils.isNotBlank(until) && !"-".equals(until)) {
|
||||
req.put("validUntil", until);
|
||||
if (!withQuoteId) {
|
||||
req.remove("quoteId");
|
||||
}
|
||||
fillLockQuoteExtras(req, quote, option);
|
||||
String transfer = firstNonBlank(text(option, "transferAirport", "transferAirportCode"),
|
||||
text(quote, "transferAirport", "transferAirportCode"));
|
||||
if (StringUtils.isNotBlank(transfer)) {
|
||||
@@ -360,14 +420,46 @@ public class InquiryTmsCabinService {
|
||||
if (StringUtils.isNotBlank(flightNo)) {
|
||||
req.put("flightNo", flightNo);
|
||||
}
|
||||
String currency = firstNonBlank(text(quote, "currency", "currencyCode"), text(option, "currency"));
|
||||
String currency = firstNonBlank(text(option, "currency"),
|
||||
text(quote, "currency", "currencyCode"));
|
||||
if (StringUtils.isNotBlank(currency)) {
|
||||
req.put("currency", currency);
|
||||
}
|
||||
String total = firstNonBlank(text(quote, "totalSalePrice", "salePrice", "total"), text(option, "salePrice"));
|
||||
// 舱位售价优先;避免手工误抽的 total=1 盖住 TMS 真价。
|
||||
String total = firstNonBlank(text(option, "salePrice", "totalSalePrice"),
|
||||
text(quote, "totalSalePrice", "salePrice", "total"));
|
||||
if (StringUtils.isNotBlank(total) && !"-".equals(total)) {
|
||||
req.put("totalSalePrice", total);
|
||||
}
|
||||
// requestedUntil:调用方有就用;否则用 validUntil(合同样例两者都有)。
|
||||
String requested = firstNonBlank(text(body, "requestedUntil"),
|
||||
text(req, "validUntil"));
|
||||
String isoReq = normalizeValidUntilIso(requested);
|
||||
if (StringUtils.isNotBlank(isoReq)) {
|
||||
req.put("requestedUntil", isoReq);
|
||||
}
|
||||
}
|
||||
|
||||
static String flightDateFromDeparture(String departureTime) {
|
||||
if (StringUtils.isBlank(departureTime) || departureTime.length() < 10) {
|
||||
return "";
|
||||
}
|
||||
return departureTime.substring(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* 锁舱共用:有效期转 ISO8601,费用名按 TMS 查价回包原样带回。
|
||||
* <p>
|
||||
* 查价落库常是 {@code valid_until=2026-09-30}(仅日期);锁舱要带时区的 ISO,
|
||||
* 否则 TMS 回 1001「参数格式错误」。费用优先 {@code airOptions[].departureCharges} /
|
||||
* {@code feeItems} 原文,禁止用本地改写过的展示名替换。
|
||||
*/
|
||||
static void fillLockQuoteExtras(Map<String, Object> req, Map<String, Object> quote, Map<String, Object> option) {
|
||||
String until = firstNonBlank(text(quote, "validUntil", "valid_until"), text(option, "validUntil"));
|
||||
String isoUntil = normalizeValidUntilIso(until);
|
||||
if (StringUtils.isNotBlank(isoUntil)) {
|
||||
req.put("validUntil", isoUntil);
|
||||
}
|
||||
List<Map<String, Object>> departure = feeRows(quote, option, "departureCharges", "departure_charges");
|
||||
List<Map<String, Object>> fees = feeRows(quote, option, "feeItems", "fee_items");
|
||||
splitFeeRows(quote, departure, fees);
|
||||
@@ -379,6 +471,57 @@ public class InquiryTmsCabinService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 锁舱 validUntil / requestedUntil:规范成带时区的 ISO8601。
|
||||
* <ul>
|
||||
* <li>{@code 2026-09-30} → {@code 2026-09-30T23:59:59+08:00}(有效期到当日结束)</li>
|
||||
* <li>已有 {@code T} 但无时区 → 补 {@code +08:00}</li>
|
||||
* <li>已是完整 ISO → 原样</li>
|
||||
* </ul>
|
||||
*/
|
||||
static String normalizeValidUntilIso(String raw) {
|
||||
if (StringUtils.isBlank(raw) || "-".equals(raw.trim())) {
|
||||
return "";
|
||||
}
|
||||
String text = raw.trim();
|
||||
// 已是完整带时区的 ISO
|
||||
if (text.matches("\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}([.,]\\d+)?(Z|[+-]\\d{2}:?\\d{2})")) {
|
||||
return text;
|
||||
}
|
||||
// 有 T 无时区
|
||||
if (text.matches("\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}(:\\d{2})?([.,]\\d+)?")) {
|
||||
if (text.length() == 16) {
|
||||
// yyyy-MM-ddTHH:mm
|
||||
return text + ":00+08:00";
|
||||
}
|
||||
return text + "+08:00";
|
||||
}
|
||||
// 仅日期:yyyy-MM-dd 或 yyyy/MM/dd
|
||||
java.util.regex.Matcher m = java.util.regex.Pattern
|
||||
.compile("^(\\d{4})[-/](\\d{1,2})[-/](\\d{1,2})$")
|
||||
.matcher(text);
|
||||
if (m.matches()) {
|
||||
int month = Integer.parseInt(m.group(2));
|
||||
int day = Integer.parseInt(m.group(3));
|
||||
return String.format("%s-%02d-%02dT23:59:59+08:00", m.group(1), month, day);
|
||||
}
|
||||
// 空格分隔的本地时间:yyyy-MM-dd HH:mm[:ss]
|
||||
java.util.regex.Matcher local = java.util.regex.Pattern
|
||||
.compile("^(\\d{4})[-/](\\d{1,2})[-/](\\d{1,2})[ T](\\d{1,2}):(\\d{2})(?::(\\d{2}))?$")
|
||||
.matcher(text);
|
||||
if (local.matches()) {
|
||||
String sec = local.group(6) == null ? "00" : local.group(6);
|
||||
return String.format("%s-%02d-%02dT%02d:%02d:%s+08:00",
|
||||
local.group(1),
|
||||
Integer.parseInt(local.group(2)),
|
||||
Integer.parseInt(local.group(3)),
|
||||
Integer.parseInt(local.group(4)),
|
||||
Integer.parseInt(local.group(5)),
|
||||
sec.length() == 1 ? "0" + sec : sec);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 最新一版是不是人工报价。航线只确认沿用 TMS 不算。
|
||||
* 认 payload 里的 source / source_label,因为落库时确认 TMS 也会被写成 PRODUCT。
|
||||
@@ -400,10 +543,26 @@ public class InquiryTmsCabinService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 本单更早报价里的真实 TMS 编号。自编 QT-工单号不算。
|
||||
* 误把锁舱口令落成「航线报价」:raw_text 像 AIR-OPT / 锁舱,或费用名只有 OPT 碎片。
|
||||
* 这种不算「有人工报价」,锁舱应按仅 TMS(SYSTEM)。
|
||||
*/
|
||||
static boolean isFakeManualLockQuote(Map<String, Object> quote) {
|
||||
if (quote == null || quote.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
if (looksLikeOptionTokenFees(quote)) {
|
||||
return true;
|
||||
}
|
||||
String raw = text(quote, "raw_text", "rawText", "instructionText").toUpperCase();
|
||||
if (raw.contains("AIR-OPT") || raw.contains("AIROPT") || raw.contains("锁舱")) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 手工价没有舱位时,用本单 TMS 报价上的那一条。多条且没指定就不猜。
|
||||
* optionNo 大小写、横杠差异按忽略大小写匹配。
|
||||
*/
|
||||
static Map<String, Object> tmsOptionForLock(List<InquiryQuote> rows, String workOrderNo, String optionNo) {
|
||||
if (rows == null) {
|
||||
@@ -423,7 +582,7 @@ public class InquiryTmsCabinService {
|
||||
}
|
||||
if (StringUtils.isNotBlank(optionNo)) {
|
||||
for (Map<String, Object> item : options) {
|
||||
if (optionNo.equals(text(item, "optionNo", "option_no"))) {
|
||||
if (optionNo.equalsIgnoreCase(text(item, "optionNo", "option_no"))) {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
@@ -437,6 +596,101 @@ public class InquiryTmsCabinService {
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
|
||||
/**
|
||||
* 本单最新一版非手工、带真实 quoteId 的 TMS 报价正文。
|
||||
*/
|
||||
static Map<String, Object> tmsQuotePayload(List<InquiryQuote> rows, String workOrderNo) {
|
||||
if (rows == null) {
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
for (InquiryQuote row : rows) {
|
||||
Map<String, Object> payload = payloadOf(row);
|
||||
if (isManualQuote(payload)) {
|
||||
continue;
|
||||
}
|
||||
if (StringUtils.isNotBlank(resolveQuoteId(payload, workOrderNo))) {
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
|
||||
/**
|
||||
* 锁舱舱位:TMS 明细打底,航线点名的 optionNo 盖上去。
|
||||
*/
|
||||
static Map<String, Object> mergeLockOption(Map<String, Object> stub, Map<String, Object> tmsOption) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
if (tmsOption != null) {
|
||||
out.putAll(tmsOption);
|
||||
}
|
||||
String no = text(stub, "optionNo", "option_no");
|
||||
if (StringUtils.isNotBlank(no)) {
|
||||
out.put("optionNo", no);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 手工价缺有效期/计费重时,从本单 TMS 报价补;不覆盖手工已有金额。
|
||||
* 费用明细优先走舱位上的 departureCharges(merge 后),这里只补标量。
|
||||
*/
|
||||
static Map<String, Object> enrichLockQuoteFromTms(Map<String, Object> quote, Map<String, Object> tmsQuote) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
if (quote != null) {
|
||||
out.putAll(quote);
|
||||
}
|
||||
if (tmsQuote == null || tmsQuote.isEmpty()) {
|
||||
return out;
|
||||
}
|
||||
if (StringUtils.isBlank(text(out, "validUntil", "valid_until"))
|
||||
|| "-".equals(text(out, "validUntil", "valid_until"))) {
|
||||
String until = firstNonBlank(text(tmsQuote, "validUntil", "valid_until"));
|
||||
if (StringUtils.isNotBlank(until) && !"-".equals(until)) {
|
||||
out.put("validUntil", until);
|
||||
out.put("valid_until", until);
|
||||
}
|
||||
}
|
||||
if (StringUtils.isBlank(text(out, "chargeableWeightKg", "chargeable_weight_kg"))) {
|
||||
String cw = firstNonBlank(text(tmsQuote, "chargeableWeightKg", "chargeable_weight_kg"));
|
||||
if (StringUtils.isNotBlank(cw)) {
|
||||
out.put("chargeableWeightKg", cw);
|
||||
}
|
||||
}
|
||||
// 手工 fee_lines 若像「OPT」这种从舱位编号误抽的,清掉,避免盖住 TMS departureCharges。
|
||||
if (looksLikeOptionTokenFees(out)) {
|
||||
out.remove("fee_lines");
|
||||
out.remove("fee_rows");
|
||||
out.remove("feeItems");
|
||||
}
|
||||
// 补 TMS 顶层 feeItems(空运费等),手工价常常没有。
|
||||
if (out.get("feeItems") == null && out.get("fee_items") == null
|
||||
&& (tmsQuote.get("feeItems") != null || tmsQuote.get("fee_items") != null)) {
|
||||
Object fees = tmsQuote.get("feeItems");
|
||||
if (fees == null) {
|
||||
fees = tmsQuote.get("fee_items");
|
||||
}
|
||||
out.put("feeItems", fees);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* fee_lines 只有「OPT / AIR-OPT…」这类舱位编号碎片时,不是真费用。
|
||||
*/
|
||||
static boolean looksLikeOptionTokenFees(Map<String, Object> quote) {
|
||||
List<Map<String, Object>> rows = feeRows(quote, null, "fee_lines", "fee_rows", "feeItems");
|
||||
if (rows.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
for (Map<String, Object> row : rows) {
|
||||
String name = feeName(row).toUpperCase().replace("-", "");
|
||||
if (!(name.equals("OPT") || name.startsWith("AIROPT") || name.matches("OPT\\d+"))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static String realTmsQuoteId(List<InquiryQuote> rows, String workOrderNo) {
|
||||
if (rows == null) {
|
||||
return "";
|
||||
@@ -546,8 +800,10 @@ public class InquiryTmsCabinService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 费用行出站改成 TMS 字段:feeName/chargeName、unitPrice、数量和金额用数字。
|
||||
* 库里的 name、unit_price 不原样送出,避免 TMS 报参数格式错误。
|
||||
* 费用行出站:对齐 MANUAL 合同字段。
|
||||
* departureCharges:chargeName/unitPrice/quantity/amount/unit/currency;
|
||||
* feeItems:feeCode/feeName/…/segmentSequence/remark。
|
||||
* 起运港费缺 quantity 时默认 1(合同样例)。
|
||||
*/
|
||||
static List<Map<String, Object>> toLockFees(List<Map<String, Object>> rows, boolean departure) {
|
||||
List<Map<String, Object>> out = new ArrayList<>();
|
||||
@@ -559,7 +815,9 @@ public class InquiryTmsCabinService {
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
String name = feeName(row);
|
||||
String name = departure
|
||||
? firstNonBlank(text(row, "chargeName"), text(row, "feeName"), text(row, "name", "label"))
|
||||
: firstNonBlank(text(row, "feeName"), text(row, "chargeName"), text(row, "name", "label"));
|
||||
if (StringUtils.isNotBlank(name)) {
|
||||
item.put(departure ? "chargeName" : "feeName", name);
|
||||
}
|
||||
@@ -568,8 +826,19 @@ public class InquiryTmsCabinService {
|
||||
item.put("feeCode", code);
|
||||
}
|
||||
putFeeNumber(item, "unitPrice", text(row, "unitPrice", "unit_price"));
|
||||
putFeeNumber(item, "quantity", text(row, "quantity"));
|
||||
String qtyRaw = text(row, "quantity");
|
||||
if (StringUtils.isBlank(qtyRaw) && departure) {
|
||||
qtyRaw = "1";
|
||||
}
|
||||
putFeeNumber(item, "quantity", qtyRaw);
|
||||
putFeeNumber(item, "amount", text(row, "amount"));
|
||||
// 有单价无金额时:金额 = 单价 × 数量
|
||||
if (!item.containsKey("amount") && item.get("unitPrice") instanceof java.math.BigDecimal
|
||||
&& item.get("quantity") instanceof java.math.BigDecimal) {
|
||||
item.put("amount", plainFeeDecimal(
|
||||
((java.math.BigDecimal) item.get("unitPrice"))
|
||||
.multiply((java.math.BigDecimal) item.get("quantity"))));
|
||||
}
|
||||
String currency = text(row, "currency");
|
||||
if (StringUtils.isNotBlank(currency)) {
|
||||
item.put("currency", currency);
|
||||
@@ -578,6 +847,34 @@ public class InquiryTmsCabinService {
|
||||
if (StringUtils.isNotBlank(unit)) {
|
||||
item.put("unit", unit);
|
||||
}
|
||||
if (!departure) {
|
||||
// 合同样例:feeCode 在 feeName 前;上面已先写 feeName,这里保持有值即可。
|
||||
String seg = text(row, "segmentSequence", "segment_sequence");
|
||||
if (StringUtils.isNotBlank(seg)) {
|
||||
try {
|
||||
item.put("segmentSequence", Integer.parseInt(seg.trim()));
|
||||
} catch (NumberFormatException ignore) {
|
||||
// 非整数就不送
|
||||
}
|
||||
}
|
||||
String remark = text(row, "remark", "备注");
|
||||
if (StringUtils.isNotBlank(remark)) {
|
||||
item.put("remark", remark);
|
||||
}
|
||||
}
|
||||
// 合同样例 feeItems:feeCode 在 feeName 前
|
||||
if (!departure && item.containsKey("feeCode") && item.containsKey("feeName")) {
|
||||
Map<String, Object> ordered = new LinkedHashMap<>();
|
||||
ordered.put("feeCode", item.get("feeCode"));
|
||||
ordered.put("feeName", item.get("feeName"));
|
||||
for (Map.Entry<String, Object> e : item.entrySet()) {
|
||||
if ("feeCode".equals(e.getKey()) || "feeName".equals(e.getKey())) {
|
||||
continue;
|
||||
}
|
||||
ordered.put(e.getKey(), e.getValue());
|
||||
}
|
||||
item = ordered;
|
||||
}
|
||||
if (!item.isEmpty()) {
|
||||
out.add(item);
|
||||
}
|
||||
@@ -585,6 +882,18 @@ public class InquiryTmsCabinService {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 费用数字:去掉尾零,但避免 100.000 → 1E+2(TMS 当参数格式错误)。
|
||||
* 与重量字段不同:费用合同样例是 11 / 1333.6,整数千不要写成 11.0。
|
||||
*/
|
||||
static java.math.BigDecimal plainFeeDecimal(java.math.BigDecimal value) {
|
||||
java.math.BigDecimal stripped = value.stripTrailingZeros();
|
||||
if (stripped.scale() < 0) {
|
||||
return stripped.setScale(0);
|
||||
}
|
||||
return stripped;
|
||||
}
|
||||
|
||||
private static void putFeeNumber(Map<String, Object> item, String key, String raw) {
|
||||
if (StringUtils.isBlank(raw) || "-".equals(raw)) {
|
||||
return;
|
||||
@@ -595,7 +904,7 @@ public class InquiryTmsCabinService {
|
||||
text = text.substring(space + 1).trim();
|
||||
}
|
||||
try {
|
||||
item.put(key, new java.math.BigDecimal(text));
|
||||
item.put(key, plainFeeDecimal(new java.math.BigDecimal(text)));
|
||||
} catch (NumberFormatException ignore) {
|
||||
// 不是数字就不送,避免把「99.00元」这类原文塞进 TMS 数字栏。
|
||||
}
|
||||
@@ -630,8 +939,69 @@ public class InquiryTmsCabinService {
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 从报价 airOptions 里按 optionNo 取航班号;对不上则取唯一有值的那条。
|
||||
* SYSTEM 锁舱缺 flightNo 时补用,避免只带了航司码却没航班号。
|
||||
*/
|
||||
static String flightNoFromAirOptions(Map<String, Object> quote, String optionNo) {
|
||||
List<Map<String, Object>> options = airOptions(quote);
|
||||
if (options.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
String want = StringUtils.trimToEmpty(optionNo);
|
||||
String lone = "";
|
||||
int withFlight = 0;
|
||||
for (Map<String, Object> row : options) {
|
||||
String no = firstNonBlank(text(row, "optionNo", "option_no"));
|
||||
String fn = firstNonBlank(text(row, "flightNo", "flight_no"));
|
||||
if (StringUtils.isBlank(fn)) {
|
||||
continue;
|
||||
}
|
||||
if (StringUtils.isNotBlank(want) && want.equalsIgnoreCase(no)) {
|
||||
return fn;
|
||||
}
|
||||
withFlight++;
|
||||
lone = fn;
|
||||
}
|
||||
if (withFlight == 1) {
|
||||
return lone;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 selected_air_option(常为 JSON 字符串)取航班号。
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
static String flightNoFromSelectedAirOption(Map<String, Object> quote) {
|
||||
if (quote == null) {
|
||||
return "";
|
||||
}
|
||||
Object raw = quote.get("selected_air_option");
|
||||
if (raw == null) {
|
||||
raw = quote.get("selectedAirOption");
|
||||
}
|
||||
if (raw instanceof String) {
|
||||
String s = String.valueOf(raw).trim();
|
||||
if (s.startsWith("{")) {
|
||||
try {
|
||||
raw = JSON.parseObject(s);
|
||||
} catch (Exception ignore) {
|
||||
return "";
|
||||
}
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
if (raw instanceof Map) {
|
||||
return firstNonBlank(text((Map<String, Object>) raw, "flightNo", "flight_no"));
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 有舱位按名称挑。没有舱位时,航线已指定的编号原样带上,不编 AIR-OPT-01。
|
||||
* 报价数组对不上时仍保留航线说的 optionNo,避免出站空编号。
|
||||
*/
|
||||
static Map<String, Object> resolveOption(Map<String, Object> quote, String optionNo) {
|
||||
List<Map<String, Object>> options = airOptions(quote);
|
||||
@@ -643,6 +1013,11 @@ public class InquiryTmsCabinService {
|
||||
if (options.size() == 1 && StringUtils.isBlank(optionNo)) {
|
||||
return options.get(0);
|
||||
}
|
||||
if (StringUtils.isNotBlank(optionNo)) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("optionNo", optionNo.trim());
|
||||
return row;
|
||||
}
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
if (StringUtils.isBlank(optionNo)) {
|
||||
@@ -653,6 +1028,22 @@ public class InquiryTmsCabinService {
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从锁舱口令抽出两位航司码,例如「锁AIR-OPT01 TH」→ TH。
|
||||
*/
|
||||
static String airlineFromInstruction(String instruction) {
|
||||
if (StringUtils.isBlank(instruction)) {
|
||||
return "";
|
||||
}
|
||||
java.util.regex.Matcher m = java.util.regex.Pattern
|
||||
.compile("(?i)AIR-?OPT-?\\d+\\s*([A-Za-z]{2})(?![A-Za-z0-9])")
|
||||
.matcher(instruction);
|
||||
if (m.find()) {
|
||||
return m.group(1).toUpperCase();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
static Map<String, Object> pickOption(Map<String, Object> quote, String optionNo) {
|
||||
List<Map<String, Object>> options = airOptions(quote);
|
||||
|
||||
+1
-1
@@ -109,7 +109,7 @@ public class InquiryTmsQueryService {
|
||||
segment.put("destination", "");
|
||||
segment.put("quantity", TmsV2RequestMapper.asInteger(facts.get("件数")));
|
||||
segment.put("weightKg", TmsV2RequestMapper.asPositiveDecimal(facts.get("毛重")));
|
||||
segment.put("volumeCbm", TmsV2RequestMapper.asPositiveDecimal(facts.get("体积")));
|
||||
segment.put("volumeCbm", TmsV2RequestMapper.asVolumeCbm(facts.get("体积")));
|
||||
fillAirportCode(segment, "originCode", text(first(facts.get("originCode"), origin)));
|
||||
fillAirportCode(segment, "destinationCode", text(first(facts.get("destinationCode"), destination)));
|
||||
// 包装优先销售原文,避免旧工单 packageType=2 盖住「托盘」。
|
||||
|
||||
+20
-13
@@ -13,9 +13,11 @@ import java.util.UUID;
|
||||
* <p>
|
||||
* 锁舱唯一键:workOrderNo + quoteId + optionNo。flightDate 用工单报价日期,接口没有单独 quoteDate。
|
||||
* 操作人角色固定 AIRLINE。数字字段保持 Number,禁止带 KGS/CBM。
|
||||
* 只有 TMS 报价:沿用原字段,不带 source、不带费用数组。
|
||||
* 人工报价:调用方放入 source=MANUAL 和已有的费用;空字符串、空数组不写出。
|
||||
* 不在这里编造 quoteId / optionNo。
|
||||
* 系统报价:source=SYSTEM + quoteId。
|
||||
* 人工+TMS:source=MANUAL + 原 quoteId。
|
||||
* 纯人工:source=MANUAL,不传 quoteId;validUntil/requestedUntil 用 ISO8601;
|
||||
* 必带 transferAirport/flightNo/departureCharges/feeItems(有值才写)。
|
||||
* 不在这里编造 optionNo。
|
||||
*/
|
||||
public final class TmsCabinRequestMapper {
|
||||
|
||||
@@ -23,34 +25,39 @@ public final class TmsCabinRequestMapper {
|
||||
}
|
||||
|
||||
/**
|
||||
* 锁舱正文。字段顺序固定,签名按这份 JSON 原文计算。
|
||||
* 锁舱正文。字段顺序按 TMS MANUAL/SYSTEM 样例排,签名按这份 JSON 原文计算。
|
||||
*/
|
||||
public static Map<String, Object> lock(Map<String, Object> internal) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("requestId", firstText(internal.get("requestId"), UUID.randomUUID().toString()));
|
||||
put(body, "workOrderNo", internal.get("workOrderNo"));
|
||||
put(body, "quoteId", internal.get("quoteId"));
|
||||
put(body, "source", internal.get("source"));
|
||||
put(body, "workOrderNo", internal.get("workOrderNo"));
|
||||
put(body, "optionNo", internal.get("optionNo"));
|
||||
Object until = internal.get("requestedUntil");
|
||||
if (until != null && StringUtils.isNotBlank(String.valueOf(until))) {
|
||||
body.put("requestedUntil", String.valueOf(until).trim());
|
||||
Object requestedUntil = internal.get("requestedUntil");
|
||||
if (requestedUntil != null && StringUtils.isNotBlank(String.valueOf(requestedUntil))) {
|
||||
String iso = InquiryTmsCabinService.normalizeValidUntilIso(String.valueOf(requestedUntil).trim());
|
||||
body.put("requestedUntil", StringUtils.isNotBlank(iso) ? iso : String.valueOf(requestedUntil).trim());
|
||||
}
|
||||
Object validUntil = internal.get("validUntil");
|
||||
if (validUntil != null && StringUtils.isNotBlank(String.valueOf(validUntil))) {
|
||||
String iso = InquiryTmsCabinService.normalizeValidUntilIso(String.valueOf(validUntil).trim());
|
||||
put(body, "validUntil", StringUtils.isNotBlank(iso) ? iso : validUntil);
|
||||
}
|
||||
put(body, "origin", first(internal, "origin", "originCode"));
|
||||
put(body, "transferAirport", internal.get("transferAirport"));
|
||||
put(body, "destination", first(internal, "destination", "destinationCode"));
|
||||
put(body, "flightDate", TmsV2RequestMapper.normalizeQuoteDate(internal.get("flightDate")));
|
||||
put(body, "airline", first(internal, "airline", "airlineCode"));
|
||||
put(body, "flightNo", internal.get("flightNo"));
|
||||
putNumber(body, "weightKg", TmsV2RequestMapper.asPositiveDecimal(internal.get("weightKg")));
|
||||
putNumber(body, "volumeCbm", TmsV2RequestMapper.asPositiveDecimal(internal.get("volumeCbm")));
|
||||
putNumber(body, "volumeCbm", TmsV2RequestMapper.asVolumeCbm(internal.get("volumeCbm")));
|
||||
putNumber(body, "pieceQty", TmsV2RequestMapper.asInteger(internal.get("pieceQty")));
|
||||
putNumber(body, "chargeableWeightKg", TmsV2RequestMapper.asPositiveDecimal(internal.get("chargeableWeightKg")));
|
||||
put(body, "operatorUserId", internal.get("operatorUserId"));
|
||||
put(body, "operatorName", internal.get("operatorName"));
|
||||
body.put("operatorRole", "AIRLINE");
|
||||
put(body, "instructionText", internal.get("instructionText"));
|
||||
put(body, "source", internal.get("source"));
|
||||
put(body, "validUntil", internal.get("validUntil"));
|
||||
put(body, "transferAirport", internal.get("transferAirport"));
|
||||
put(body, "flightNo", internal.get("flightNo"));
|
||||
put(body, "currency", internal.get("currency"));
|
||||
putNumber(body, "totalSalePrice", asDecimal(internal.get("totalSalePrice")));
|
||||
putList(body, "departureCharges", internal.get("departureCharges"));
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ public final class TmsLandSegmentMapper {
|
||||
put(seg, "destination", text(source.get("目的港")));
|
||||
put(seg, "cargoName", text(source.get("品名")));
|
||||
if (LCL.contains(type)) {
|
||||
put(seg, "volumeCbm", TmsV2RequestMapper.asPositiveDecimal(source.get("体积")));
|
||||
put(seg, "volumeCbm", TmsV2RequestMapper.asVolumeCbm(source.get("体积")));
|
||||
put(seg, "weightKg", TmsV2RequestMapper.asPositiveDecimal(source.get("毛重")));
|
||||
} else if ("中港整车".equals(type)) {
|
||||
// 中港整车:询价不要车型/数量,出站也不带
|
||||
|
||||
+43
-2
@@ -24,6 +24,13 @@ public final class TmsV2RequestMapper {
|
||||
/** 从「100KGS」「2.5CBM」「10件」里取出第一个数字。 */
|
||||
private static final Pattern FIRST_NUMBER = Pattern.compile("(\\d+(?:\\.\\d+)?)");
|
||||
|
||||
/**
|
||||
* 销售常写尺寸当体积:1.1*1.2*3 / 1.1x1.2x3 / 1.1×1.2×3(米)。
|
||||
* 必须先相乘得 CBM;禁止剥掉 * 后变成 1.11.23 再 new BigDecimal(建单会炸)。
|
||||
*/
|
||||
private static final Pattern DIM_PRODUCT = Pattern.compile(
|
||||
"(\\d+(?:\\.\\d+)?)\\s*[*xX×]\\s*(\\d+(?:\\.\\d+)?)(?:\\s*[*xX×]\\s*(\\d+(?:\\.\\d+)?))?");
|
||||
|
||||
/** 文档 2.1:后缀 Date 的字段格式 yyyy-MM-dd;也容忍斜杠或后面跟时间。 */
|
||||
private static final Pattern ISO_DATE = Pattern.compile("(\\d{4})[-/](\\d{1,2})[-/](\\d{1,2})");
|
||||
|
||||
@@ -88,7 +95,7 @@ public final class TmsV2RequestMapper {
|
||||
// 5.2:quantity=int,weightKg/volumeCbm=decimal>0,禁止带单位字符串
|
||||
put(target, "quantity", asInteger(first(source, "quantity", "pieces")));
|
||||
put(target, "weightKg", asPositiveDecimal(first(source, "weightKg")));
|
||||
put(target, "volumeCbm", asPositiveDecimal(first(source, "volumeCbm")));
|
||||
put(target, "volumeCbm", asVolumeCbm(first(source, "volumeCbm")));
|
||||
put(target, "packageType", first(source, "packageType", "packagingType"));
|
||||
// 空运选填:有值才带。贸易条款不进出站,只给后台模板关键词匹配。
|
||||
applyAirOptionalFields(source, target);
|
||||
@@ -199,7 +206,8 @@ public final class TmsV2RequestMapper {
|
||||
}
|
||||
|
||||
/**
|
||||
* 毛重/体积:JSON 小数且 >0。销售原文「100KGS」「2CBM」只取数字。
|
||||
* 毛重等:JSON 小数且 >0。销售原文「100KGS」「2CBM」只取第一个数字。
|
||||
* 体积请用 {@link #asVolumeCbm},避免把尺寸式写成错误小数。
|
||||
*/
|
||||
public static BigDecimal asPositiveDecimal(Object raw) {
|
||||
if (raw instanceof Number) {
|
||||
@@ -213,6 +221,39 @@ public final class TmsV2RequestMapper {
|
||||
return number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 体积 CBM:尺寸式(长*宽*高)先相乘;否则与 {@link #asPositiveDecimal} 相同。
|
||||
* 例:1.1*1.2*3 → 3.96;2CBM → 2。
|
||||
*/
|
||||
public static BigDecimal asVolumeCbm(Object raw) {
|
||||
if (raw instanceof Number) {
|
||||
return asPositiveDecimal(raw);
|
||||
}
|
||||
if (raw == null) {
|
||||
return null;
|
||||
}
|
||||
String text = String.valueOf(raw).trim();
|
||||
if (text.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
Matcher dims = DIM_PRODUCT.matcher(text);
|
||||
if (dims.find()) {
|
||||
try {
|
||||
BigDecimal a = new BigDecimal(dims.group(1));
|
||||
BigDecimal b = new BigDecimal(dims.group(2));
|
||||
BigDecimal c = dims.group(3) == null ? BigDecimal.ONE : new BigDecimal(dims.group(3));
|
||||
BigDecimal product = a.multiply(b).multiply(c);
|
||||
if (product.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return null;
|
||||
}
|
||||
return product.stripTrailingZeros();
|
||||
} catch (NumberFormatException ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return asPositiveDecimal(raw);
|
||||
}
|
||||
|
||||
private static BigDecimal firstDecimal(Object raw) {
|
||||
if (raw == null) {
|
||||
return null;
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<formControlPr xmlns="http://schemas.microsoft.com/office/spreadsheetml/2009/9/main" objectType="Radio" firstButton="1" val="0"/>
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<formControlPr xmlns="http://schemas.microsoft.com/office/spreadsheetml/2009/9/main" objectType="Radio" firstButton="1" val="0"/>
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<formControlPr xmlns="http://schemas.microsoft.com/office/spreadsheetml/2009/9/main" objectType="Radio" checked="Checked" val="0"/>
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<formControlPr xmlns="http://schemas.microsoft.com/office/spreadsheetml/2009/9/main" objectType="Radio" checked="Checked" val="0"/>
|
||||
+2
File diff suppressed because one or more lines are too long
+2
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><xdr:twoCellAnchor><xdr:from><xdr:col>1</xdr:col><xdr:colOff>43180</xdr:colOff><xdr:row>2</xdr:row><xdr:rowOff>19685</xdr:rowOff></xdr:from><xdr:to><xdr:col>4</xdr:col><xdr:colOff>340995</xdr:colOff><xdr:row>2</xdr:row><xdr:rowOff>431165</xdr:rowOff></xdr:to><xdr:grpSp><xdr:nvGrpSpPr><xdr:cNvPr id="2" name="组合 1"/><xdr:cNvGrpSpPr/></xdr:nvGrpSpPr><xdr:grpSpPr><a:xfrm><a:off x="3018155" y="503555"/><a:ext cx="2124710" cy="342265"/><a:chOff x="4431" y="1131"/><a:chExt cx="3169" cy="649"/></a:xfrm></xdr:grpSpPr><mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:Choice Requires="a14" xmlns:a14="http://schemas.microsoft.com/office/drawing/2010/main"><xdr:sp><xdr:nvSpPr><xdr:cNvPr id="2049" name="Option Button 1" hidden="1"><a:extLst><a:ext uri="{63B3BB69-23CF-44E3-9099-C40C66FF867C}"><a14:compatExt spid="_x0000_s2049"/></a:ext></a:extLst></xdr:cNvPr><xdr:cNvSpPr/></xdr:nvSpPr><xdr:spPr><a:xfrm><a:off x="4434" y="1131"/><a:ext cx="3166" cy="360"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></xdr:spPr><xdr:txBody><a:bodyPr vertOverflow="clip" wrap="square" lIns="27432" tIns="18288" rIns="0" bIns="18288" anchor="ctr" upright="1"/><a:lstStyle/><a:p><a:pPr algn="l" rtl="0"><a:defRPr sz="1000"/></a:pPr><a:r><a:rPr lang="zh-CN" altLang="en-US" sz="900" b="0" i="0" u="none" strike="noStrike" baseline="0"><a:solidFill><a:srgbClr val="000000"/></a:solidFill><a:latin typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/><a:ea typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/></a:rPr><a:t> Port to Port, 1工作天回复</a:t></a:r><a:endParaRPr lang="zh-CN" altLang="en-US" sz="900" b="0" i="0" u="none" strike="noStrike" baseline="0"><a:solidFill><a:srgbClr val="000000"/></a:solidFill><a:latin typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/><a:ea typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/></a:endParaRPr></a:p></xdr:txBody></xdr:sp></mc:Choice><mc:Fallback/></mc:AlternateContent><mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:Choice Requires="a14" xmlns:a14="http://schemas.microsoft.com/office/drawing/2010/main"><xdr:sp><xdr:nvSpPr><xdr:cNvPr id="2050" name="Option Button 2" hidden="1"><a:extLst><a:ext uri="{63B3BB69-23CF-44E3-9099-C40C66FF867C}"><a14:compatExt spid="_x0000_s2050"/></a:ext></a:extLst></xdr:cNvPr><xdr:cNvSpPr/></xdr:nvSpPr><xdr:spPr><a:xfrm><a:off x="4431" y="1421"/><a:ext cx="2987" cy="359"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></xdr:spPr><xdr:txBody><a:bodyPr vertOverflow="clip" wrap="square" lIns="27432" tIns="18288" rIns="0" bIns="18288" anchor="ctr" upright="1"/><a:lstStyle/><a:p><a:pPr algn="l" rtl="0"><a:defRPr sz="1000"/></a:pPr><a:r><a:rPr lang="zh-CN" altLang="en-US" sz="900" b="0" i="0" u="none" strike="noStrike" baseline="0"><a:solidFill><a:srgbClr val="000000"/></a:solidFill><a:latin typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/><a:ea typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/></a:rPr><a:t> 其它, 3工作天回复</a:t></a:r><a:endParaRPr lang="zh-CN" altLang="en-US" sz="900" b="0" i="0" u="none" strike="noStrike" baseline="0"><a:solidFill><a:srgbClr val="000000"/></a:solidFill><a:latin typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/><a:ea typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/></a:endParaRPr></a:p></xdr:txBody></xdr:sp></mc:Choice><mc:Fallback/></mc:AlternateContent></xdr:grpSp><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><xdr:twoCellAnchor><xdr:from><xdr:col>1</xdr:col><xdr:colOff>43180</xdr:colOff><xdr:row>2</xdr:row><xdr:rowOff>19685</xdr:rowOff></xdr:from><xdr:to><xdr:col>4</xdr:col><xdr:colOff>340995</xdr:colOff><xdr:row>2</xdr:row><xdr:rowOff>431165</xdr:rowOff></xdr:to><xdr:grpSp><xdr:nvGrpSpPr><xdr:cNvPr id="2" name="组合 1"/><xdr:cNvGrpSpPr/></xdr:nvGrpSpPr><xdr:grpSpPr><a:xfrm><a:off x="3018155" y="503555"/><a:ext cx="2124710" cy="342265"/><a:chOff x="4431" y="1131"/><a:chExt cx="3169" cy="649"/></a:xfrm></xdr:grpSpPr><mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:Choice xmlns:a14="http://schemas.microsoft.com/office/drawing/2010/main" Requires="a14"><xdr:sp><xdr:nvSpPr><xdr:cNvPr id="2049" name="Option Button 1" hidden="1"><a:extLst><a:ext uri="{63B3BB69-23CF-44E3-9099-C40C66FF867C}"><a14:compatExt spid="_x0000_s2049"/></a:ext></a:extLst></xdr:cNvPr><xdr:cNvSpPr/></xdr:nvSpPr><xdr:spPr><a:xfrm><a:off x="4434" y="1131"/><a:ext cx="3166" cy="360"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></xdr:spPr><xdr:txBody><a:bodyPr vertOverflow="clip" wrap="square" lIns="27432" tIns="18288" rIns="0" bIns="18288" anchor="ctr" upright="1"/><a:lstStyle/><a:p><a:pPr algn="l" rtl="0"><a:defRPr sz="1000"/></a:pPr><a:r><a:rPr lang="zh-CN" altLang="en-US" sz="900" b="0" i="0" u="none" strike="noStrike" baseline="0"><a:solidFill><a:srgbClr val="000000"/></a:solidFill><a:latin typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/><a:ea typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/></a:rPr><a:t> Port to Port, 1工作天回复</a:t></a:r><a:endParaRPr lang="zh-CN" altLang="en-US" sz="900" b="0" i="0" u="none" strike="noStrike" baseline="0"><a:solidFill><a:srgbClr val="000000"/></a:solidFill><a:latin typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/><a:ea typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/></a:endParaRPr></a:p></xdr:txBody></xdr:sp></mc:Choice><mc:Fallback/></mc:AlternateContent><mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:Choice xmlns:a14="http://schemas.microsoft.com/office/drawing/2010/main" Requires="a14"><xdr:sp><xdr:nvSpPr><xdr:cNvPr id="2050" name="Option Button 2" hidden="1"><a:extLst><a:ext uri="{63B3BB69-23CF-44E3-9099-C40C66FF867C}"><a14:compatExt spid="_x0000_s2050"/></a:ext></a:extLst></xdr:cNvPr><xdr:cNvSpPr/></xdr:nvSpPr><xdr:spPr><a:xfrm><a:off x="4431" y="1421"/><a:ext cx="2987" cy="359"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></xdr:spPr><xdr:txBody><a:bodyPr vertOverflow="clip" wrap="square" lIns="27432" tIns="18288" rIns="0" bIns="18288" anchor="ctr" upright="1"/><a:lstStyle/><a:p><a:pPr algn="l" rtl="0"><a:defRPr sz="1000"/></a:pPr><a:r><a:rPr lang="zh-CN" altLang="en-US" sz="900" b="0" i="0" u="none" strike="noStrike" baseline="0"><a:solidFill><a:srgbClr val="000000"/></a:solidFill><a:latin typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/><a:ea typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/></a:rPr><a:t> 其它, 3工作天回复</a:t></a:r><a:endParaRPr lang="zh-CN" altLang="en-US" sz="900" b="0" i="0" u="none" strike="noStrike" baseline="0"><a:solidFill><a:srgbClr val="000000"/></a:solidFill><a:latin typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/><a:ea typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/></a:endParaRPr></a:p></xdr:txBody></xdr:sp></mc:Choice><mc:Fallback/></mc:AlternateContent></xdr:grpSp><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>
|
||||
+4
File diff suppressed because one or more lines are too long
+2
File diff suppressed because one or more lines are too long
+2
File diff suppressed because one or more lines are too long
+2
File diff suppressed because one or more lines are too long
+2
File diff suppressed because one or more lines are too long
+9
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<xml xmlns:oa="urn:schemas-microsoft-com:office:activation" xmlns:p="urn:schemas-microsoft-com:office:powerpoint" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:v="urn:schemas-microsoft-com:vml"><o:shapelayout v:ext="edit"><o:idmap v:ext="edit" data="2"/></o:shapelayout><v:shapetype id="_x0000_t201" coordsize="21600,21600" o:spt="201" path="m,l,21600r21600,l21600,xe"
|
||||
>
|
||||
<v:stroke joinstyle="miter"/>
|
||||
<v:path shadowok="f" o:extrusionok="f" strokeok="f" fillok="f" o:connecttype="rect"/>
|
||||
<o:lock v:ext="edit" shapetype="t"/>
|
||||
</v:shapetype>
|
||||
|
||||
<v:shape id="_x0000_s2049" o:spt="201" type="#_x0000_t201" style="position:absolute;left:0pt;top:0pt;margin-left:206.9pt;margin-top:39.65pt;height:14.65pt;width:107.95pt;" filled="f" stroked="f" o:insetmode="auto" coordsize="21600,21600"><v:path fillok="t" strokeok="t" shadowok="t"/><v:fill on="f" focussize="0,0"/><v:stroke on="f"/><v:imagedata o:title=""/><o:lock v:ext="edit" rotation="t" aspectratio="f"/><v:textbox style="direction:ltr;layout-flow:horizontal;mso-rotate:0;"><div style="text-align:left"><p><font face="宋体" size="180" color="#000000"><span style="mso-spacerun:yes"> Port to Port, 1工作天回复</span></font></p></div></v:textbox><x:ClientData ObjectType="Radio"><x:SizeWithCells/><x:Anchor>1,5,2,2,4,44,2,26</x:Anchor><x:FirstButton>1</x:FirstButton><x:TextVAlign>Center</x:TextVAlign></x:ClientData></v:shape><v:shape id="_x0000_s2050" o:spt="201" type="#_x0000_t201" style="position:absolute;left:0pt;top:0pt;margin-left:206.8pt;margin-top:51.45pt;height:14.65pt;width:101.8pt;" filled="f" stroked="f" o:insetmode="auto" coordsize="21600,21600"><v:path fillok="t" strokeok="t" shadowok="t"/><v:fill on="f" focussize="0,0"/><v:stroke on="f"/><v:imagedata o:title=""/><o:lock v:ext="edit" rotation="t" aspectratio="f"/><v:textbox style="direction:ltr;layout-flow:horizontal;mso-rotate:0;"><div style="text-align:left"><p><font face="宋体" size="180" color="#000000"><span style="mso-spacerun:yes"> 其它, 3工作天回复</span></font></p></div></v:textbox><x:ClientData ObjectType="Radio"><x:SizeWithCells/><x:Anchor>1,5,2,22,4,34,3,0</x:Anchor><x:Checked>1</x:Checked><x:FirstButton>0</x:FirstButton><x:TextVAlign>Center</x:TextVAlign></x:ClientData></v:shape></xml>
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xml xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:x="urn:schemas-microsoft-com:office:excel"><o:shapelayout v:ext="edit"><o:idmap data="2" v:ext="edit"/></o:shapelayout><v:shapetype coordsize="21600,21600" id="_x0000_t201" o:spt="201" path="m,l,21600r21600,l21600,xe">
|
||||
<v:stroke joinstyle="miter"/>
|
||||
<v:path fillok="f" o:connecttype="rect" o:extrusionok="f" shadowok="f" strokeok="f"/>
|
||||
<o:lock shapetype="t" v:ext="edit"/>
|
||||
</v:shapetype><v:shape coordsize="21600,21600" filled="f" id="_x0000_s2049" o:insetmode="auto" o:spt="201" stroked="f" style="position:absolute;left:0pt;top:0pt;margin-left:237.8pt;margin-top:39.65pt;height:14.9pt;width:167.15pt;" type="#_x0000_t201"><v:path fillok="t" shadowok="t" strokeok="t"/><v:fill focussize="0,0" on="f"/><v:stroke on="f"/><v:imagedata o:title=""/><o:lock aspectratio="f" rotation="t" v:ext="edit"/><v:textbox style="direction:ltr;layout-flow:horizontal;mso-rotate:0;"><div style="text-align:left"><p><font color="#000000" face="宋体" size="180"><span style="mso-spacerun:yes"> Port to Port, 1工作天回复</span></font></p></div></v:textbox><x:ClientData ObjectType="Radio"><x:SizeWithCells/><x:Checked>1</x:Checked><x:Anchor>1,5,2,2,4,44,2,27</x:Anchor><x:FirstButton>1</x:FirstButton><x:TextVAlign>Center</x:TextVAlign></x:ClientData></v:shape><v:shape coordsize="21600,21600" filled="f" id="_x0000_s2050" o:insetmode="auto" o:spt="201" stroked="f" style="position:absolute;left:0pt;top:0pt;margin-left:237.65pt;margin-top:51.65pt;height:14.95pt;width:157.65pt;" type="#_x0000_t201"><v:path fillok="t" shadowok="t" strokeok="t"/><v:fill focussize="0,0" on="f"/><v:stroke on="f"/><v:imagedata o:title=""/><o:lock aspectratio="f" rotation="t" v:ext="edit"/><v:textbox style="direction:ltr;layout-flow:horizontal;mso-rotate:0;"><div style="text-align:left"><p><font color="#000000" face="宋体" size="180"><span style="mso-spacerun:yes"> 其它, 3工作天回复</span></font></p></div></v:textbox><x:ClientData ObjectType="Radio"><x:SizeWithCells/><x:Anchor>1,5,2,22,4,28,3,0</x:Anchor><x:FirstButton>0</x:FirstButton><x:TextVAlign>Center</x:TextVAlign></x:ClientData></v:shape></xml>
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xml xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:x="urn:schemas-microsoft-com:office:excel"><o:shapelayout v:ext="edit"><o:idmap data="2" v:ext="edit"/></o:shapelayout><v:shapetype coordsize="21600,21600" id="_x0000_t201" o:spt="201" path="m,l,21600r21600,l21600,xe">
|
||||
<v:stroke joinstyle="miter"/>
|
||||
<v:path fillok="f" o:connecttype="rect" o:extrusionok="f" shadowok="f" strokeok="f"/>
|
||||
<o:lock shapetype="t" v:ext="edit"/>
|
||||
</v:shapetype><v:shape coordsize="21600,21600" filled="f" id="_x0000_s2049" o:insetmode="auto" o:spt="201" stroked="f" style="position:absolute;left:0pt;top:0pt;margin-left:237.8pt;margin-top:39.65pt;height:14.9pt;width:167.15pt;" type="#_x0000_t201"><v:path fillok="t" shadowok="t" strokeok="t"/><v:fill focussize="0,0" on="f"/><v:stroke on="f"/><v:imagedata o:title=""/><o:lock aspectratio="f" rotation="t" v:ext="edit"/><v:textbox style="direction:ltr;layout-flow:horizontal;mso-rotate:0;"><div style="text-align:left"><p><font color="#000000" face="宋体" size="180"><span style="mso-spacerun:yes"> Port to Port, 1工作天回复</span></font></p></div></v:textbox><x:ClientData ObjectType="Radio"><x:SizeWithCells/><x:Anchor>1,5,2,2,4,44,2,27</x:Anchor><x:FirstButton>1</x:FirstButton><x:TextVAlign>Center</x:TextVAlign></x:ClientData></v:shape><v:shape coordsize="21600,21600" filled="f" id="_x0000_s2050" o:insetmode="auto" o:spt="201" stroked="f" style="position:absolute;left:0pt;top:0pt;margin-left:237.65pt;margin-top:51.65pt;height:14.95pt;width:157.65pt;" type="#_x0000_t201"><v:path fillok="t" shadowok="t" strokeok="t"/><v:fill focussize="0,0" on="f"/><v:stroke on="f"/><v:imagedata o:title=""/><o:lock aspectratio="f" rotation="t" v:ext="edit"/><v:textbox style="direction:ltr;layout-flow:horizontal;mso-rotate:0;"><div style="text-align:left"><p><font color="#000000" face="宋体" size="180"><span style="mso-spacerun:yes"> 其它, 3工作天回复</span></font></p></div></v:textbox><x:ClientData ObjectType="Radio"><x:SizeWithCells/><x:Checked>1</x:Checked><x:Anchor>1,5,2,22,4,28,3,0</x:Anchor><x:FirstButton>0</x:FirstButton><x:TextVAlign>Center</x:TextVAlign></x:ClientData></v:shape></xml>
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<xml xmlns:oa="urn:schemas-microsoft-com:office:activation" xmlns:p="urn:schemas-microsoft-com:office:powerpoint" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:v="urn:schemas-microsoft-com:vml"><o:shapelayout v:ext="edit"><o:idmap v:ext="edit" data="2"/></o:shapelayout><v:shapetype id="_x0000_t201" coordsize="21600,21600" o:spt="201" path="m,l,21600r21600,l21600,xe"
|
||||
>
|
||||
<v:stroke joinstyle="miter"/>
|
||||
<v:path shadowok="f" o:extrusionok="f" strokeok="f" fillok="f" o:connecttype="rect"/>
|
||||
<o:lock v:ext="edit" shapetype="t"/>
|
||||
</v:shapetype>
|
||||
|
||||
<v:shape id="_x0000_s2049" o:spt="201" type="#_x0000_t201" style="position:absolute;left:0pt;top:0pt;margin-left:237.8pt;margin-top:39.65pt;height:14.9pt;width:167.15pt;" filled="f" stroked="f" o:insetmode="auto" coordsize="21600,21600"><v:path fillok="t" strokeok="t" shadowok="t"/><v:fill on="f" focussize="0,0"/><v:stroke on="f"/><v:imagedata o:title=""/><o:lock v:ext="edit" rotation="t" aspectratio="f"/><v:textbox style="direction:ltr;layout-flow:horizontal;mso-rotate:0;"><div style="text-align:left"><p><font face="宋体" size="180" color="#000000"><span style="mso-spacerun:yes"> Port to Port, 1工作天回复</span></font></p></div></v:textbox><x:ClientData ObjectType="Radio"><x:SizeWithCells/><x:Anchor>1,5,2,2,4,44,2,27</x:Anchor><x:FirstButton>1</x:FirstButton><x:TextVAlign>Center</x:TextVAlign></x:ClientData></v:shape><v:shape id="_x0000_s2050" o:spt="201" type="#_x0000_t201" style="position:absolute;left:0pt;top:0pt;margin-left:237.65pt;margin-top:51.65pt;height:14.95pt;width:157.65pt;" filled="f" stroked="f" o:insetmode="auto" coordsize="21600,21600"><v:path fillok="t" strokeok="t" shadowok="t"/><v:fill on="f" focussize="0,0"/><v:stroke on="f"/><v:imagedata o:title=""/><o:lock v:ext="edit" rotation="t" aspectratio="f"/><v:textbox style="direction:ltr;layout-flow:horizontal;mso-rotate:0;"><div style="text-align:left"><p><font face="宋体" size="180" color="#000000"><span style="mso-spacerun:yes"> 其它, 3工作天回复</span></font></p></div></v:textbox><x:ClientData ObjectType="Radio"><x:SizeWithCells/><x:Anchor>1,5,2,22,4,28,3,0</x:Anchor><x:Checked>1</x:Checked><x:FirstButton>0</x:FirstButton><x:TextVAlign>Center</x:TextVAlign></x:ClientData></v:shape></xml>
|
||||
Reference in New Issue
Block a user