完善空运锁舱与海运报价单单选回填,并增强模板预览与协同字段盖写。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-10-02 22:57:04 +08:00
co-authored by Cursor
parent 5402f94d30
commit d989154485
51 changed files with 4748 additions and 188 deletions
@@ -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);
@@ -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) {
@@ -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 无界面转换;已在文件锁内,禁止再开并行实例。
*/
@@ -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);
@@ -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 盖住「托盘」。
@@ -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"));
@@ -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)) {
// 中港整车:询价不要车型/数量,出站也不带
@@ -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 小数且 &gt;0。销售原文「100KGS」「2CBM」只取数字。
* 毛重等:JSON 小数且 &gt;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;
@@ -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"/>
@@ -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"/>
@@ -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"/>
@@ -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"/>
File diff suppressed because one or more lines are too long
@@ -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>
@@ -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>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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>
@@ -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>
@@ -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>
@@ -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>