seaDrawParts = seaTemplate ? readSeaDrawingParts(sourceXlsx) : null;
try (InputStream in = Files.newInputStream(sourceXlsx); Workbook wb = WorkbookFactory.create(in)) {
+ if (!seaTemplate) {
+ for (int i = 0; i < wb.getNumberOfSheets(); i++) {
+ if (sheetLooksLikeSeaQuote(wb.getSheetAt(i))) {
+ seaTemplate = true;
+ break;
+ }
+ }
+ if (seaTemplate && seaDrawParts == null) {
+ seaDrawParts = readSeaDrawingParts(sourceXlsx);
+ }
+ }
JSONObject fieldMeta = mapping.getJSONObject("fieldMeta");
JSONObject fields = mapping.getJSONObject("fields");
if (fields != null) {
@@ -591,6 +644,29 @@ public final class QuoteTemplateExcelSupport {
if (afterColon) {
writeCellAfterColon(wb, cellRef, String.valueOf(val));
} else {
+ // #region agent log
+ if ("pol".equals(code) || "起运港".equals(code)
+ || String.valueOf(val).contains("广州") || String.valueOf(val).contains("Guangzhou")) {
+ agentDbg("H2", "fillWorkbook.fields", "mapping field write",
+ "{\"code\":\"" + code + "\",\"cellRef\":\"" + cellRef
+ + "\",\"val\":\"" + String.valueOf(val).replace("\"", "'") + "\"}");
+ }
+ // #endregion
+ // 映射若误指费用名称列:跳过,避免无 TMS 时把起运港写入费用名称
+ if (isPortFactCode(code)) {
+ ParsedRef pref = ParsedRef.parse(cellRef);
+ Sheet sh = pref == null ? null : wb.getSheet(pref.sheet);
+ if (sh == null && pref != null && wb.getNumberOfSheets() > 0) {
+ sh = wb.getSheetAt(0);
+ }
+ if (sh != null && isFeeNameColumnRef(sh, cellRef)) {
+ // #region agent log
+ agentDbg("H2", "fillWorkbook.fields.blockFeeName", "blocked mapping pol into fee name",
+ "{\"code\":\"" + code + "\",\"cellRef\":\"" + cellRef + "\"}");
+ // #endregion
+ continue;
+ }
+ }
writeCell(wb, cellRef, String.valueOf(val));
}
}
@@ -604,13 +680,34 @@ public final class QuoteTemplateExcelSupport {
if (!hasFeeRegions(mapping)) {
writeFeeAmountsByName(wb, facts, yellow);
}
- // 模板「预估总价」是 SUM。POI 写完数字不会重算,缓存仍是 0。
- // 企微/WPS 预览常不重算,销售会看到汇总为 0。这里把公式缓存刷成当前值。
+ // 模板合计格常是 SUM。先刷缓存,再按 TMS 汇总盖掉公式(有 TMS 汇总就必须去公式)。
refreshFormulas(wb);
// 其它标签可再写一遍;合计单独处理,避免「标签旁一个数 + SUM 格另一个数」。
writeFactsByLabel(wb, facts, yellow);
- // 预估费用以 TMS 总销售价为准:盖掉 SUM(常把提送货/人工费加进去),本行只留一个数。
+ // 海运运输分类:
+ // WPS 证据(WO030 vs WO029/031/032):VML 必须与 WO011 字节级一致才画得出原控件;
+ // 一改 Checked/文案整组空白。因此 zip 阶段只盖回 WO011 原件,不改 VML。
+ // WO033 vs WO030:VML/drawing/styles 相同,唯 B3 有「其它…」明文时 WPS 仍空白——
+ // B3:J3 是原控件锚点合并区,禁止往里写任何单元格字。
+ clearSyntheticSeaTransportClassCell(wb);
+ writeSeaOtherClassFallbackLabel(wb, facts);
+ // #region agent log
+ agentDbg("T1", "fillWorkbook.facts", "sea subtotal facts present",
+ "{\"fee_total_pol\":\"" + String.valueOf(facts.getString("fee_total_polCostItems")).replace("\"", "'")
+ + "\",\"fee_total_ocean\":\"" + String.valueOf(facts.getString("fee_total_oceanCostItems")).replace("\"", "'")
+ + "\",\"fee_total_pod\":\"" + String.valueOf(facts.getString("fee_total_podCostItems")).replace("\"", "'")
+ + "\",\"total_amount\":\"" + String.valueOf(facts.getString("total_amount")).replace("\"", "'")
+ + "\",\"currency\":\"" + String.valueOf(facts.getString("currency")).replace("\"", "'")
+ + "\",\"sea_class\":\"" + String.valueOf(firstNonBlank(
+ facts.getString("运输分类"), facts.getString("transport_class"),
+ facts.getString("sea_class"))).replace("\"", "'") + "\"}");
+ // #endregion
+ // 产品口径:TMS 已返回汇总金额 → Excel 汇总/合计格去掉公式,改写 TMS 数。
+ // 海运三段汇总/小计:fee_total_*;整单:total_amount(预估费用/费用总计等)。
+ writeSeaSectionSubtotals(wb, facts, yellow);
overwriteEstimateTotal(wb, facts);
+ // 无费用明细时:清掉误写入费用名称列的起运港/目的港(WO202609280006)
+ clearPortSpillInFeeNameColumn(wb, facts);
// 转 PDF 前把纸张改成 A4 居中,避免顶部裁字、整页偏左。
applyQuotePrintLayout(wb);
Files.createDirectories(targetXlsx.getParent());
@@ -618,6 +715,1464 @@ public final class QuoteTemplateExcelSupport {
wb.write(out);
}
}
+ // 只对海运模板做控件包/骨架盖回。空运到港等模板禁止走 WO011,否则标题变成「国际海运询价单」。
+ if (!seaTemplate) {
+ // #region agent log
+ agentDbg("H_SEA", "fillWorkbook.skipSeaScaffold", "non-sea template skip WO011 scaffold",
+ "{\"path\":\"" + String.valueOf(sourceXlsx).replace("\\", "/").replace("\"", "'") + "\"}");
+ // #endregion
+ return;
+ }
+ // 按运输分类盖回 WPS 可显示的控件包:
+ // Port=WO011 原件;其它=同 drawing/styles + 次钮 Checked(VML/ctrl 一致)。
+ // 禁止文案对调 VML:drawing 仍写 Port 且首钮 Checked,界面勾在 Port 或整组空白(WO020)。
+ // 禁止 Excel 重写 drawing(3983) 的 other_excel 包(WO013)。
+ // 再盖回 WO011 sheet1 骨架,单选才稳定可见(WO030 vs POI 瘦 sheet)。
+ syncSeaTransportClassFormControls(targetXlsx, facts, seaDrawParts);
+ boolean scaffoldOk = restoreSeaSheet1Scaffold(targetXlsx, seaDrawParts);
+ String seaCls = canonicalizeSeaTransportClass(facts == null ? null : firstNonBlank(
+ facts.getString("运输分类"), facts.getString("transport_class"), facts.getString("sea_class")));
+ if ("其他".equals(seaCls)) {
+ // 有 Excel/WPS COM 时再点一次次钮并保存,字节与本机金样一致
+ switchSeaTransportClassToOtherViaExcel(targetXlsx);
+ }
+ if (!scaffoldOk) {
+ // 骨架失败时至少用 ●/○ 明文让销售看到选中态
+ writeSeaTransportClassVisibleLabelToFile(targetXlsx, facts);
+ }
+ }
+
+ /**
+ * 源模板是否海运询价单:看 sheet/SST 是否含「国际海运/海运询价」,或已有运输分类 Form Control。
+ *
+ * 空运到港/到门模板禁止据此误判:它们没有海运标题与海运单选控件。
+ */
+ static boolean sourceLooksLikeSeaTemplate(Path sourceXlsx) {
+ if (sourceXlsx == null || !Files.isRegularFile(sourceXlsx)) {
+ return false;
+ }
+ try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(sourceXlsx.toFile())) {
+ boolean hasCtrl = zf.getEntry("xl/ctrlProps/ctrlProp1.xml") != null;
+ boolean hasSeaTitle = false;
+ boolean hasAirTitle = false;
+ for (String name : new String[]{
+ "xl/sharedStrings.xml",
+ "xl/worksheets/sheet1.xml",
+ "xl/worksheets/sheet2.xml"
+ }) {
+ java.util.zip.ZipEntry e = zf.getEntry(name);
+ if (e == null) {
+ continue;
+ }
+ String text;
+ try (InputStream in = zf.getInputStream(e)) {
+ text = new String(readStreamBytes(in), StandardCharsets.UTF_8);
+ }
+ if (text.contains("国际海运") || text.contains("海运询价")) {
+ hasSeaTitle = true;
+ }
+ if (text.contains("空运报价") || text.contains("空运到港") || text.contains("空运到门")) {
+ hasAirTitle = true;
+ }
+ }
+ if (hasSeaTitle && !hasAirTitle) {
+ return true;
+ }
+ // 海运单选:有 ctrlProp 且 VML 里有 Port to Port
+ if (hasCtrl) {
+ java.util.zip.ZipEntry ve = zf.getEntry("xl/drawings/vmlDrawing1.vml");
+ if (ve != null) {
+ String vml;
+ try (InputStream in = zf.getInputStream(ve)) {
+ vml = new String(readStreamBytes(in), StandardCharsets.UTF_8);
+ }
+ if (vml.contains("Port to Port") || vml.contains("工作天回复")) {
+ return true;
+ }
+ }
+ }
+ return false;
+ } catch (Exception e) {
+ log.warn("判断海运模板失败 path={} err={}", sourceXlsx, e.getMessage());
+ return false;
+ }
+ }
+
+ /**
+ * drawing + VML 均强制换成 WO011 标准件(自测:仅换 drawing 不够,VML 命名空间也不同会导致 WPS 空白)。
+ *
+ * WO011 vs WO026:drawing 已相同,但 VML 头分别为 xmlns:o vs xmlns:oa,norm 后仍 DIFF。
+ */
+ static Map readSeaDrawingParts(Path sourceXlsx) {
+ Map out = new LinkedHashMap<>();
+ if (sourceXlsx == null || !Files.isRegularFile(sourceXlsx)) {
+ return out;
+ }
+ try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(sourceXlsx.toFile())) {
+ java.util.Enumeration extends java.util.zip.ZipEntry> en = zf.entries();
+ while (en.hasMoreElements()) {
+ java.util.zip.ZipEntry entry = en.nextElement();
+ String name = entry.getName();
+ if (name == null) {
+ continue;
+ }
+ boolean keep = "xl/drawings/drawing1.xml".equals(name)
+ || (name.startsWith("xl/drawings/") && name.endsWith(".vml"))
+ // 骨架:POI 重写 sheet1 后 WPS 常不画原单选(WO034);出单后再盖回并合并单元格值
+ || "xl/worksheets/sheet1.xml".equals(name);
+ if (!keep) {
+ continue;
+ }
+ try (InputStream in = zf.getInputStream(entry)) {
+ out.put(name, readStreamBytes(in));
+ }
+ }
+ } catch (Exception e) {
+ log.warn("读取海运模板 drawing 失败 path={} err={}", sourceXlsx, e.getMessage());
+ }
+ byte[] srcDraw = out.get("xl/drawings/drawing1.xml");
+ byte[] srcVml = null;
+ for (Map.Entry e : out.entrySet()) {
+ if (e.getKey() != null && e.getKey().endsWith(".vml")) {
+ srcVml = e.getValue();
+ break;
+ }
+ }
+ byte[] stdDraw = loadWo011SeaDrawing1();
+ byte[] stdVml = loadWo011SeaVml();
+ boolean usedDraw = false;
+ boolean usedVml = false;
+ if (stdDraw != null && stdDraw.length > 0) {
+ out.put("xl/drawings/drawing1.xml", stdDraw);
+ usedDraw = true;
+ }
+ if (stdVml != null && stdVml.length > 0) {
+ boolean replaced = false;
+ for (String key : new ArrayList<>(out.keySet())) {
+ if (key != null && key.endsWith(".vml")) {
+ out.put(key, stdVml);
+ replaced = true;
+ usedVml = true;
+ }
+ }
+ if (!replaced) {
+ out.put("xl/drawings/vmlDrawing1.vml", stdVml);
+ usedVml = true;
+ }
+ }
+ // #region agent log
+ agentDbg("R2", "readSeaDrawingParts", "preserved template drawings",
+ "{\"count\":" + out.size()
+ + ",\"srcDrawBytes\":" + (srcDraw == null ? 0 : srcDraw.length)
+ + ",\"srcVmlBytes\":" + (srcVml == null ? 0 : srcVml.length)
+ + ",\"usedWo011Draw\":" + usedDraw
+ + ",\"usedWo011Vml\":" + usedVml
+ + ",\"finalDrawBytes\":" + (out.get("xl/drawings/drawing1.xml") == null
+ ? 0 : out.get("xl/drawings/drawing1.xml").length)
+ + ",\"mode\":\"wo011-draw+vml+styles\"}");
+ // #endregion
+ return out;
+ }
+
+ /**
+ * 加载 WO011 标准 styles(POI 填表会改瘦 styles,WPS 即不画原单选;盖回后与 WO011 对齐)。
+ */
+ static byte[] loadWo011SeaStyles() {
+ return loadBundledBytes(new String[]{
+ "inquiry/template/sea_styles_wo011.xml",
+ "sea_styles_wo011.xml"
+ }, new String[]{
+ "C:/Users/ytd_extdev_0903/incoming/quote_hot2/sea_styles_wo011.xml",
+ "d:/own/友通达询价智能体(新)/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_styles_wo011.xml",
+ "d:/own/友通达询价智能体(新)/inquiry-agent/scripts/_verify_quote/sea_styles_wo011.xml"
+ }, 20000);
+ }
+
+ /**
+ * 加载 WO011 标准瘦 drawing(约 3966,WPS 可显原 Form Control)。
+ */
+ static byte[] loadWo011SeaDrawing1() {
+ return loadBundledBytes(new String[]{
+ "inquiry/template/sea_drawing1_wo011.xml",
+ "sea_drawing1_wo011.xml"
+ }, new String[]{
+ "C:/Users/ytd_extdev_0903/incoming/quote_hot2/sea_drawing1_wo011.xml",
+ "d:/own/友通达询价智能体(新)/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_drawing1_wo011.xml",
+ "d:/own/友通达询价智能体(新)/inquiry-agent/scripts/_verify_quote/sea_drawing1_wo011.xml"
+ }, 3000);
+ }
+
+ /**
+ * 加载 WO011 标准 VML(xmlns:o,与标准 drawing 配套;线上源件常被改成 xmlns:oa)。
+ */
+ static byte[] loadWo011SeaVml() {
+ return loadBundledBytes(new String[]{
+ "inquiry/template/sea_vmlDrawing1_wo011.vml",
+ "sea_vmlDrawing1_wo011.vml"
+ }, new String[]{
+ "C:/Users/ytd_extdev_0903/incoming/quote_hot2/sea_vmlDrawing1_wo011.vml",
+ "d:/own/友通达询价智能体(新)/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_vmlDrawing1_wo011.vml",
+ "d:/own/友通达询价智能体(新)/inquiry-agent/scripts/_verify_quote/sea_vmlDrawing1_wo011.vml"
+ }, 1500);
+ }
+
+ /**
+ * 「其它」可见件:ClientData 与 {@link #loadWo011SeaVml()} 字节级一致(首钮 Checked),
+ * 仅对调两颗单选文案,使勾选看起来是「其它」。改 Checked 会导致 WPS 空白(WO040/044)。
+ */
+ /**
+ * 「其它」勾选 VML:WO011 文案顺序不变,仅次钮 {@code }(与 demo_v3 金样一致)。
+ * 禁止再放文案对调件。
+ */
+ static byte[] loadWo011SeaVmlOther() {
+ return loadBundledBytes(new String[]{
+ "inquiry/template/sea_vmlDrawing1_wo011_other.vml",
+ "sea_vmlDrawing1_wo011_other.vml"
+ }, new String[]{
+ "C:/Users/ytd_extdev_0903/incoming/quote_hot2/sea_vmlDrawing1_wo011_other.vml",
+ "d:/own/友通达询价智能体(新)/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_vmlDrawing1_wo011_other.vml",
+ "d:/own/友通达询价智能体(新)/inquiry-agent/scripts/_verify_quote/sea_vmlDrawing1_wo011_other.vml"
+ }, 1500);
+ }
+
+ /**
+ * Excel 在 WO011 Port 勾选文件上点选「其它」后导出的 VML(次钮 Checked,测服无 Excel 时用此件)。
+ */
+ static byte[] loadWo011SeaVmlOtherExcel() {
+ return loadBundledBytes(new String[]{
+ "inquiry/template/sea_vmlDrawing1_wo011_other_excel.vml",
+ "sea_vmlDrawing1_wo011_other_excel.vml"
+ }, new String[]{
+ "C:/Users/ytd_extdev_0903/incoming/quote_hot2/sea_vmlDrawing1_wo011_other_excel.vml",
+ "d:/own/友通达询价智能体(新)/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_vmlDrawing1_wo011_other_excel.vml",
+ "d:/own/友通达询价智能体(新)/inquiry-agent/scripts/_verify_quote/sea_vmlDrawing1_wo011_other_excel.vml"
+ }, 1500);
+ }
+
+ /** Excel 切「其它」后配套 drawing(约 3983;与裸 WO011 3966 不同,缺则 WPS 空白)。 */
+ static byte[] loadWo011SeaDrawingOtherExcel() {
+ return loadBundledBytes(new String[]{
+ "inquiry/template/sea_drawing1_wo011_other_excel.xml",
+ "sea_drawing1_wo011_other_excel.xml"
+ }, new String[]{
+ "C:/Users/ytd_extdev_0903/incoming/quote_hot2/sea_drawing1_wo011_other_excel.xml",
+ "d:/own/友通达询价智能体(新)/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_drawing1_wo011_other_excel.xml",
+ "d:/own/友通达询价智能体(新)/inquiry-agent/scripts/_verify_quote/sea_drawing1_wo011_other_excel.xml"
+ }, 3000);
+ }
+
+ static byte[] loadWo011SeaCtrlProp1OtherExcel() {
+ return loadBundledBytes(new String[]{
+ "inquiry/template/sea_ctrlProp1_wo011_other_excel.xml",
+ "sea_ctrlProp1_wo011_other_excel.xml"
+ }, new String[]{
+ "C:/Users/ytd_extdev_0903/incoming/quote_hot2/sea_ctrlProp1_wo011_other_excel.xml",
+ "d:/own/友通达询价智能体(新)/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_ctrlProp1_wo011_other_excel.xml"
+ }, 50);
+ }
+
+ static byte[] loadWo011SeaCtrlProp2OtherExcel() {
+ return loadBundledBytes(new String[]{
+ "inquiry/template/sea_ctrlProp2_wo011_other_excel.xml",
+ "sea_ctrlProp2_wo011_other_excel.xml"
+ }, new String[]{
+ "C:/Users/ytd_extdev_0903/incoming/quote_hot2/sea_ctrlProp2_wo011_other_excel.xml",
+ "d:/own/友通达询价智能体(新)/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_ctrlProp2_wo011_other_excel.xml"
+ }, 50);
+ }
+
+ /**
+ * 加载 WO011 标准 sheet1(含可显示的 Form Control AC,无本地 xmlns:mc)。
+ *
+ * WO036 证据:线上源模板 sheet1 的 AlternateContent 自带 xmlns:mc 且缺 printOptions,
+ * 与能显示的 WO030/WO011 不同;盖回源模板骨架后 WPS 仍空白。必须用 WO011 sheet1 作骨架。
+ */
+ static byte[] loadWo011SeaSheet1() {
+ return loadBundledBytes(new String[]{
+ "inquiry/template/sea_sheet1_wo011.xml",
+ "sea_sheet1_wo011.xml"
+ }, new String[]{
+ "C:/Users/ytd_extdev_0903/incoming/quote_hot2/sea_sheet1_wo011.xml",
+ "d:/own/友通达询价智能体(新)/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_sheet1_wo011.xml",
+ "d:/own/友通达询价智能体(新)/inquiry-agent/scripts/_verify_quote/sea_sheet1_wo011.xml"
+ }, 15000);
+ }
+
+ /**
+ * 瘦 drawing:缺东亚字体/列表样式声明,WPS 常不画单选旁文字(WO019=3983 vs 完整≈8489)。
+ */
+ static boolean isThinSeaDrawing(byte[] drawingXml) {
+ if (drawingXml == null || drawingXml.length < 100) {
+ return true;
+ }
+ if (drawingXml.length >= 6000) {
+ return false;
+ }
+ String s = new String(drawingXml, StandardCharsets.UTF_8);
+ // 完整件含多级 lstStyle + a:cs;POI/误存瘦件只有短 txBody
+ return !s.contains("= minBytes) {
+ return b;
+ }
+ }
+ }
+ } catch (Exception ignored) {
+ // 试下一路径
+ }
+ }
+ }
+ if (files != null) {
+ for (String f : files) {
+ try {
+ Path p = Paths.get(f);
+ if (Files.isRegularFile(p)) {
+ try (InputStream in = Files.newInputStream(p)) {
+ byte[] b = readStreamBytes(in);
+ if (b != null && b.length >= minBytes) {
+ return b;
+ }
+ }
+ }
+ } catch (Exception ignored) {
+ // 试下一路径
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * 海运三段汇总/小计行:TMS fee_total_pol/ocean/pod →「始发港/海运/目的港费用汇总|小计」。
+ *
+ * 产品口径:TMS 已返回该段汇总时,金额列若是 SUM 等公式必须删掉再写数(writeAmountCell);
+ * 币别进币别列、金额进金额列。缺小计则跳过该行,保留模板公式。
+ */
+ static void writeSeaSectionSubtotals(Workbook wb, JSONObject facts, Set yellow) {
+ if (wb == null || facts == null) {
+ return;
+ }
+ // marker[0]=中文主标签,marker[1]=中文别名/英文,marker[2]=facts 键
+ String[][] markers = {
+ {"始发港费用汇总", "POL Local", "fee_total_polCostItems"},
+ {"始发港费用小计", "Origin Charges", "fee_total_polCostItems"},
+ {"海运费用汇总", "/OF", "fee_total_oceanCostItems"},
+ {"海运费用小计", "Ocean Freight", "fee_total_oceanCostItems"},
+ {"目的港费用汇总", "POD Local", "fee_total_podCostItems"},
+ {"目的港费用小计", "Destination Charges", "fee_total_podCostItems"}
+ };
+ String headerCcy = firstNonBlank(facts.getString("currency"), "CNY");
+ for (int s = 0; s < wb.getNumberOfSheets(); s++) {
+ Sheet sheet = wb.getSheetAt(s);
+ if (sheet == null) {
+ continue;
+ }
+ for (int r = 0; r <= sheet.getLastRowNum(); r++) {
+ Row row = sheet.getRow(r);
+ if (row == null) {
+ continue;
+ }
+ String rowText = "";
+ for (Cell cell : row) {
+ if (cell != null) {
+ rowText += readCellText(cell);
+ }
+ }
+ if (StringUtils.isBlank(rowText)
+ || !(rowText.contains("汇总") || rowText.contains("小计"))) {
+ continue;
+ }
+ // 整单「费用总计」由 overwriteEstimateTotal 独占,避免与分段小计抢写
+ if (rowText.contains("费用总计") || rowText.toLowerCase(Locale.ROOT).contains("total cost")
+ || rowText.contains("预估费用") || rowText.contains("预估总价")
+ || rowText.contains("报价合计")) {
+ continue;
+ }
+ for (String[] mk : markers) {
+ if (!(rowText.contains(mk[0]) || rowText.contains(mk[1]))) {
+ continue;
+ }
+ String raw = facts.getString(mk[2]);
+ if (StringUtils.isBlank(raw) || "-".equals(raw.trim())) {
+ // #region agent log
+ agentDbg("T2", "writeSeaSectionSubtotals.skip", "no subtotal fact",
+ "{\"row\":" + r + ",\"marker\":\"" + mk[0] + "\",\"key\":\"" + mk[2] + "\"}");
+ // #endregion
+ continue;
+ }
+ String ccy = headerCcy;
+ String amt = stripMoneyPrefix(raw.trim());
+ String upper = raw.trim().toUpperCase(Locale.ROOT);
+ if (upper.startsWith("CNY") || upper.startsWith("USD") || upper.startsWith("RMB")
+ || upper.startsWith("EUR") || upper.startsWith("HKD")) {
+ ccy = raw.trim().split("\\s+")[0].toUpperCase(Locale.ROOT);
+ if ("RMB".equals(ccy)) {
+ ccy = "CNY";
+ }
+ }
+ Map cols = findNearbyFeeColumns(sheet, r);
+ int ccyCol = cols.containsKey("currency") ? cols.get("currency") : 1;
+ int amtCol = cols.containsKey("amount") ? cols.get("amount") : 7;
+ writeMappedText(sheet, r, ccyCol, ccy, yellow);
+ // 有 TMS 汇总:必须去掉公式再写数字
+ writeAmountCell(sheet, r, amtCol, amt, yellow);
+ clearOtherSummaryFormulasOnRow(row, amtCol, ccyCol);
+ // #region agent log
+ agentDbg("T2", "writeSeaSectionSubtotals.write", "wrote section subtotal",
+ "{\"row\":" + r + ",\"marker\":\"" + mk[0] + "\",\"ccy\":\"" + ccy
+ + "\",\"amt\":\"" + amt.replace("\"", "'")
+ + "\",\"ccyCol\":" + ccyCol + ",\"amtCol\":" + amtCol + "}");
+ // #endregion
+ break;
+ }
+ }
+ }
+ }
+
+ /**
+ * 汇总行写入 TMS 金额后,清掉同行其它 SUM/公式格,避免保留模板计算。
+ */
+ private static void clearOtherSummaryFormulasOnRow(Row row, int keepAmtCol, int keepCcyCol) {
+ if (row == null) {
+ return;
+ }
+ short last = row.getLastCellNum();
+ if (last < 0) {
+ return;
+ }
+ for (int c = 0; c < last; c++) {
+ if (c == keepAmtCol || c == keepCcyCol) {
+ continue;
+ }
+ Cell cell = row.getCell(c);
+ if (cell == null) {
+ continue;
+ }
+ if (cell.getCellType() != CellType.FORMULA) {
+ continue;
+ }
+ String formula = StringUtils.trimToEmpty(cell.getCellFormula()).toUpperCase(Locale.ROOT);
+ if (!(formula.contains("SUM") || formula.contains("*") || formula.contains("+"))) {
+ continue;
+ }
+ clearEstimateExtraCell(row, cell);
+ }
+ }
+
+ /**
+ * 从当前行向上找最近费用表头列(币别/金额),供汇总行对齐写入。
+ */
+ static Map findNearbyFeeColumns(Sheet sheet, int rowIdx) {
+ Map empty = new LinkedHashMap<>();
+ if (sheet == null || rowIdx < 0) {
+ return empty;
+ }
+ for (int r = rowIdx; r >= Math.max(0, rowIdx - 25); r--) {
+ Map cols = parseFeeHeaderRow(sheet, r);
+ if (!cols.isEmpty() && cols.containsKey("amount")) {
+ return cols;
+ }
+ }
+ return empty;
+ }
+
+ /**
+ * 清掉历史上误写入单元格的运输分类文案,避免与模板原 Form Control 叠成两套。
+ * B3:J3 是控件锚点:任何单元格字(含旧版「其它, 3工作天回复」)都会让 WPS 不画原单选。
+ */
+ static void clearSyntheticSeaTransportClassCell(Workbook wb) {
+ if (wb == null) {
+ return;
+ }
+ for (int s = 0; s < wb.getNumberOfSheets(); s++) {
+ Sheet sheet = wb.getSheetAt(s);
+ if (sheet == null) {
+ continue;
+ }
+ Cell target = findSeaTransportClassRadioCell(sheet);
+ if (target == null) {
+ continue;
+ }
+ String text = StringUtils.trimToEmpty(readCellText(target));
+ boolean synthetic = text.contains("●") || text.contains("○")
+ || text.contains("工作天回复")
+ || text.contains("Port to Port")
+ || text.contains("其它") || text.contains("其他");
+ if (synthetic) {
+ target.setCellValue("");
+ // #region agent log
+ agentDbg("H_B3", "clearSyntheticSeaTransportClassCell", "cleared radio-anchor cell text",
+ "{\"ref\":\"" + new CellReference(target).formatAsString()
+ + "\",\"was\":\"" + text.replace("\"", "'") + "\"}");
+ // #endregion
+ }
+ return;
+ }
+ }
+
+ /**
+ * 海运模板顶部运输分类:按 facts 盖回 WPS 可显示的 Form Control 套件。
+ *
+ * Port to Port → WO011 原件(首钮 Checked,文案 Port / 其它)。
+ * 其它 → 仍用 WO011 drawing/styles(3966/50650),VML 与 ctrlProp 同步改为次钮 Checked;
+ * 文案顺序不变。本机 WPS 对 {@code sea_other_radio_demo_v3}/{@code wo_other_wo011_com} 可画且勾在「其它」。
+ *
+ * 禁止文案对调 VML(WO020:drawing 仍 Port、首钮 Checked,界面勾错或空白)。
+ * 禁止 Excel 重写 drawing≈3983 的 other_excel 整包(WO013 整组空白)。
+ */
+ static void syncSeaTransportClassFormControls(Path targetXlsx, JSONObject facts) {
+ syncSeaTransportClassFormControls(targetXlsx, facts, null);
+ }
+
+ static void syncSeaTransportClassFormControls(Path targetXlsx, JSONObject facts,
+ Map preservedDrawParts) {
+ if (targetXlsx == null || !Files.isRegularFile(targetXlsx)) {
+ return;
+ }
+ String raw = facts == null ? null : firstNonBlank(
+ facts.getString("运输分类"),
+ facts.getString("transport_class"),
+ facts.getString("sea_class"));
+ String cls = canonicalizeSeaTransportClass(raw);
+ // 空分类默认 Port;其它=次钮 Checked(VML+ctrl 一致,drawing 仍 WO011)
+ boolean wantOther = "其他".equals(cls);
+ Path tmp = null;
+ try {
+ tmp = Files.createTempFile(targetXlsx.getParent(), "sea-radio-", ".xlsx");
+ boolean touchedCtrl = false;
+ boolean touchedVml = false;
+ boolean restoredDrawing = false;
+ boolean restoredStyles = false;
+ byte[] stdStyles = loadWo011SeaStyles();
+ byte[] portDraw = loadWo011SeaDrawing1();
+ byte[] portVml = loadWo011SeaVml();
+ byte[] otherVmlChecked2 = loadWo011SeaVmlOther();
+ boolean useOtherChecked2 = wantOther && otherVmlChecked2 != null && otherVmlChecked2.length > 1000;
+ byte[] pickDraw = portDraw;
+ byte[] pickVml = useOtherChecked2 ? otherVmlChecked2 : portVml;
+ String mode = useOtherChecked2 ? "wo011-other-checked2" : "wo011-port-pack";
+ try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(targetXlsx.toFile());
+ OutputStream fos = Files.newOutputStream(tmp);
+ java.util.zip.ZipOutputStream zos = new java.util.zip.ZipOutputStream(fos)) {
+ java.util.Enumeration extends java.util.zip.ZipEntry> en = zf.entries();
+ while (en.hasMoreElements()) {
+ java.util.zip.ZipEntry entry = en.nextElement();
+ String name = entry.getName();
+ zos.putNextEntry(new java.util.zip.ZipEntry(name));
+ byte[] data;
+ try (InputStream in = zf.getInputStream(entry)) {
+ data = readStreamBytes(in);
+ }
+ if ("xl/drawings/drawing1.xml".equals(name)) {
+ if (pickDraw != null && pickDraw.length > 1000) {
+ data = pickDraw;
+ restoredDrawing = true;
+ } else if (preservedDrawParts != null && preservedDrawParts.containsKey(name)) {
+ data = preservedDrawParts.get(name);
+ restoredDrawing = true;
+ }
+ } else if ("xl/styles.xml".equals(name) && stdStyles != null && stdStyles.length > 10000) {
+ data = stdStyles;
+ restoredStyles = true;
+ } else if ("xl/ctrlProps/ctrlProp1.xml".equals(name)) {
+ // Port=首钮 Checked;其它=首钮不勾(次钮在 ctrlProp2)
+ data = buildSeaRadioCtrlPropXml(true, !wantOther).getBytes(StandardCharsets.UTF_8);
+ touchedCtrl = true;
+ } else if ("xl/ctrlProps/ctrlProp2.xml".equals(name)) {
+ data = buildSeaRadioCtrlPropXml(false, wantOther).getBytes(StandardCharsets.UTF_8);
+ touchedCtrl = true;
+ } else if (name != null && name.startsWith("xl/drawings/") && name.endsWith(".vml")) {
+ byte[] vmlPick = null;
+ if (pickVml != null && pickVml.length > 1000) {
+ vmlPick = pickVml;
+ } else if (preservedDrawParts != null && preservedDrawParts.containsKey(name)) {
+ vmlPick = preservedDrawParts.get(name);
+ }
+ if (vmlPick != null) {
+ data = vmlPick;
+ touchedVml = true;
+ restoredDrawing = true;
+ }
+ }
+ zos.write(data);
+ zos.closeEntry();
+ }
+ }
+ if (touchedCtrl || touchedVml || restoredDrawing || restoredStyles) {
+ Files.move(tmp, targetXlsx, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
+ tmp = null;
+ }
+ // #region agent log
+ agentDbg("R1", "syncSeaTransportClassFormControls", "patched form control pack by transport class",
+ "{\"cls\":\"" + String.valueOf(cls).replace("\"", "'")
+ + "\",\"wantOther\":" + wantOther
+ + ",\"useOtherChecked2\":" + useOtherChecked2
+ + ",\"touchedCtrl\":" + touchedCtrl + ",\"touchedVml\":" + touchedVml
+ + ",\"restoredDrawing\":" + restoredDrawing
+ + ",\"restoredStyles\":" + restoredStyles
+ + ",\"stylesBytes\":" + (stdStyles == null ? 0 : stdStyles.length)
+ + ",\"drawBytes\":" + (pickDraw == null ? 0 : pickDraw.length)
+ + ",\"vmlBytes\":" + (pickVml == null ? 0 : pickVml.length)
+ + ",\"mode\":\"" + mode + "\"}");
+ // #endregion
+ } catch (Exception e) {
+ // #region agent log
+ agentDbg("R1", "syncSeaTransportClassFormControls.err", "patch failed",
+ "{\"err\":\"" + String.valueOf(e.getMessage()).replace("\"", "'") + "\"}");
+ // #endregion
+ log.warn("海运运输分类 Form Control 勾选同步失败 path={}", targetXlsx, e);
+ } finally {
+ if (tmp != null) {
+ try {
+ Files.deleteIfExists(tmp);
+ } catch (Exception ignored) {
+ // 临时文件清不掉不影响出单
+ }
+ }
+ }
+ }
+
+ /**
+ * 「其它」:在已可显示的 WO011 Port 勾选文件上,用 Excel COM 点选第二颗 Option Button 再保存。
+ * 直接改 OOXML Checked 会导致 WPS 空白;Excel 切勾后仍能显示(本机已验证)。
+ *
+ * @return true=已切到其它且 zip 内次钮 Checked
+ */
+ static boolean switchSeaTransportClassToOtherViaExcel(Path targetXlsx) {
+ if (targetXlsx == null || !Files.isRegularFile(targetXlsx)) {
+ return false;
+ }
+ Path ps1 = null;
+ try {
+ ps1 = Files.createTempFile("sea-radio-other-", ".ps1");
+ String pathEsc = targetXlsx.toAbsolutePath().toString().replace("'", "''");
+ String script = ""
+ + "$ErrorActionPreference='Stop'\n"
+ + "$path = '" + pathEsc + "'\n"
+ + "$app = $null\n"
+ + "foreach($id in @('Excel.Application','Ket.Application')){\n"
+ + " try { $app = New-Object -ComObject $id; break } catch {}\n"
+ + "}\n"
+ + "if(-not $app){ Write-Output 'NO_EXCEL'; exit 2 }\n"
+ + "try {\n"
+ + " $app.Visible = $false\n"
+ + " try { $app.DisplayAlerts = $false } catch {}\n"
+ + " $wb = $app.Workbooks.Open($path)\n"
+ + " $ws = $wb.Worksheets.Item(1)\n"
+ + " $g = $ws.Shapes.Item(1)\n"
+ + " $btn = $g.GroupItems.Item(2)\n"
+ + " $btn.ControlFormat.Value = 1\n"
+ + " $wb.Save()\n"
+ + " $wb.Close($false)\n"
+ + " Write-Output 'SWITCH_OK'\n"
+ + "} finally {\n"
+ + " try { $app.Quit() } catch {}\n"
+ + "}\n";
+ Files.write(ps1, script.getBytes(StandardCharsets.UTF_8));
+ ProcessBuilder pb = new ProcessBuilder(
+ "powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", ps1.toAbsolutePath().toString());
+ pb.redirectErrorStream(true);
+ Process p = pb.start();
+ byte[] outBytes = readStreamBytes(p.getInputStream());
+ boolean finished = p.waitFor(120, java.util.concurrent.TimeUnit.SECONDS);
+ if (!finished) {
+ p.destroyForcibly();
+ agentDbg("R1", "switchSeaTransportClassToOtherViaExcel.timeout", "excel com timeout", "{}");
+ return false;
+ }
+ String out = new String(outBytes, StandardCharsets.UTF_8);
+ if (!out.contains("SWITCH_OK")) {
+ agentDbg("R1", "switchSeaTransportClassToOtherViaExcel.fail", "excel com failed",
+ "{\"out\":\"" + out.replace("\"", "'").replace("\n", " ").replace("\r", "") + "\"}");
+ return false;
+ }
+ // 校验 zip:次钮 Checked
+ try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(targetXlsx.toFile())) {
+ java.util.zip.ZipEntry ve = zf.getEntry("xl/drawings/vmlDrawing1.vml");
+ java.util.zip.ZipEntry c2e = zf.getEntry("xl/ctrlProps/ctrlProp2.xml");
+ if (ve == null || c2e == null) {
+ return false;
+ }
+ String vml;
+ String c2;
+ try (InputStream in = zf.getInputStream(ve)) {
+ vml = new String(readStreamBytes(in), StandardCharsets.UTF_8);
+ }
+ try (InputStream in = zf.getInputStream(c2e)) {
+ c2 = new String(readStreamBytes(in), StandardCharsets.UTF_8);
+ }
+ java.util.regex.Pattern cd = java.util.regex.Pattern.compile(
+ "([\\s\\S]*?)",
+ java.util.regex.Pattern.CASE_INSENSITIVE);
+ java.util.regex.Matcher m = cd.matcher(vml);
+ java.util.List bodies = new java.util.ArrayList<>();
+ while (m.find()) {
+ bodies.add(m.group(1));
+ }
+ boolean ok = bodies.size() == 2
+ && !bodies.get(0).contains("")
+ && bodies.get(1).contains("")
+ && c2.contains("checked=\"Checked\"");
+ agentDbg("R1", "switchSeaTransportClassToOtherViaExcel", "excel switched other radio",
+ "{\"ok\":" + ok + ",\"vmlChecked2\":" + (bodies.size() > 1 && bodies.get(1).contains(""))
+ + ",\"ctrl2\":" + c2.contains("checked=\"Checked\"") + "}");
+ return ok;
+ }
+ } catch (Exception e) {
+ agentDbg("R1", "switchSeaTransportClassToOtherViaExcel.err", "excel com exception",
+ "{\"err\":\"" + String.valueOf(e.getMessage()).replace("\"", "'") + "\"}");
+ log.warn("海运「其它」Excel 切勾失败 path={}", targetXlsx, e);
+ return false;
+ } finally {
+ if (ps1 != null) {
+ try {
+ Files.deleteIfExists(ps1);
+ } catch (Exception ignored) {
+ // ignore
+ }
+ }
+ }
+ }
+
+ /**
+ * 选「其它」时的处理:故意不写 B3。
+ *
+ * B3:J3 是 Form Control 锚点合并区,往里写单元格字会与原控件叠层。
+ * 「其它」无法在不改坏 VML 的前提下勾选第二钮;单选可见性靠 {@link #restoreSeaSheet1Scaffold}。
+ */
+ static void writeSeaOtherClassFallbackLabel(Workbook wb, JSONObject facts) {
+ if (wb == null || facts == null) {
+ return;
+ }
+ String raw = firstNonBlank(
+ facts.getString("运输分类"),
+ facts.getString("transport_class"),
+ facts.getString("sea_class"));
+ String cls = canonicalizeSeaTransportClass(raw);
+ if (!"其他".equals(cls)) {
+ return;
+ }
+ // #region agent log
+ agentDbg("H_B3", "writeSeaOtherClassFallbackLabel", "skip B3 write keep radio anchors empty",
+ "{\"cls\":\"其他\",\"reason\":\"B3 is radio anchor; scaffold restore keeps radios\"}");
+ // #endregion
+ }
+
+ /**
+ * 海运出单后盖回 WO011 sheet1 骨架,并把 POI 填好的单元格值合并进去。
+ *
+ * Port / 其它共用同一骨架(与 WO030 一致),避免「其它」换种子 sheet/styles 导致红字变黑、控件长相漂移。
+ *
+ * @return true=骨架盖回且合并成功;false=跳过/失败(调用方应写 B3 明文兜底)
+ */
+ static boolean restoreSeaSheet1Scaffold(Path targetXlsx, Map preservedParts) {
+ return restoreSeaSheet1Scaffold(targetXlsx, preservedParts, null);
+ }
+
+ static boolean restoreSeaSheet1Scaffold(Path targetXlsx, Map preservedParts,
+ String seaTransportClass) {
+ if (targetXlsx == null || !Files.isRegularFile(targetXlsx) || preservedParts == null) {
+ return false;
+ }
+ // seaTransportClass 保留入参兼容旧调用;骨架一律 WO011。
+ byte[] scaffold = loadWo011SeaSheet1();
+ String scaffoldSrc = "wo011-bundled";
+ if (scaffold == null || scaffold.length < 1000) {
+ // #region agent log
+ agentDbg("H_SHEET", "restoreSeaSheet1Scaffold.skip", "no scaffold sheet1",
+ "{\"scaffoldBytes\":" + (scaffold == null ? 0 : scaffold.length)
+ + ",\"scaffoldSrc\":\"" + scaffoldSrc + "\"}");
+ // #endregion
+ return false;
+ }
+ Path tmp = null;
+ try {
+ byte[] poiSheet;
+ byte[] poiSst;
+ try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(targetXlsx.toFile())) {
+ java.util.zip.ZipEntry sh = zf.getEntry("xl/worksheets/sheet1.xml");
+ java.util.zip.ZipEntry sst = zf.getEntry("xl/sharedStrings.xml");
+ if (sh == null) {
+ return false;
+ }
+ try (InputStream in = zf.getInputStream(sh)) {
+ poiSheet = readStreamBytes(in);
+ }
+ if (sst != null) {
+ try (InputStream in = zf.getInputStream(sst)) {
+ poiSst = readStreamBytes(in);
+ }
+ } else {
+ poiSst = new byte[0];
+ }
+ }
+ Map poiValues = parseSheetCellTexts(poiSheet, poiSst);
+ byte[] scaffoldSst = loadWo011SeaSharedStrings();
+ SeaSheetMerge merged = mergeValuesOntoSeaScaffold(scaffold, scaffoldSst, poiValues);
+ int rowCount = countSheetRows(merged.sheetXml);
+ // WO035 证据:错误正则把自闭合 的 / 吃进属性,一次匹配跨越多行,
+ // sheet 从 17KB 缩成 7KB、只剩 2 行。合并结果过瘦则放弃,保留 POI 原 sheet。
+ if (merged.sheetXml.length() < scaffold.length / 2 || rowCount < 20) {
+ // #region agent log
+ agentDbg("H_SHEET", "restoreSeaSheet1Scaffold.abort", "merged sheet too thin, keep POI sheet",
+ "{\"scaffoldBytes\":" + scaffold.length
+ + ",\"mergedSheetBytes\":" + merged.sheetXml.length()
+ + ",\"rowCount\":" + rowCount
+ + ",\"poiCells\":" + poiValues.size() + "}");
+ // #endregion
+ return false;
+ }
+ // WO011 骨架合并后若仍带本地 mc xmlns,则放弃(WO036)
+ if (merged.sheetXml.contains("AlternateContent xmlns:mc=")) {
+ agentDbg("H_SHEET", "restoreSeaSheet1Scaffold.abort", "scaffold still has local mc xmlns",
+ "{\"scaffoldSrc\":\"" + scaffoldSrc + "\"}");
+ return false;
+ }
+ tmp = Files.createTempFile(targetXlsx.getParent(), "sea-sheet-", ".xlsx");
+ try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(targetXlsx.toFile());
+ OutputStream fos = Files.newOutputStream(tmp);
+ java.util.zip.ZipOutputStream zos = new java.util.zip.ZipOutputStream(fos)) {
+ java.util.Enumeration extends java.util.zip.ZipEntry> en = zf.entries();
+ boolean wroteSst = false;
+ while (en.hasMoreElements()) {
+ java.util.zip.ZipEntry entry = en.nextElement();
+ String name = entry.getName();
+ zos.putNextEntry(new java.util.zip.ZipEntry(name));
+ byte[] data;
+ try (InputStream in = zf.getInputStream(entry)) {
+ data = readStreamBytes(in);
+ }
+ if ("xl/worksheets/sheet1.xml".equals(name)) {
+ data = merged.sheetXml.getBytes(StandardCharsets.UTF_8);
+ } else if ("xl/sharedStrings.xml".equals(name)) {
+ data = merged.sstXml.getBytes(StandardCharsets.UTF_8);
+ wroteSst = true;
+ }
+ zos.write(data);
+ zos.closeEntry();
+ }
+ if (!wroteSst && merged.sstXml != null) {
+ zos.putNextEntry(new java.util.zip.ZipEntry("xl/sharedStrings.xml"));
+ zos.write(merged.sstXml.getBytes(StandardCharsets.UTF_8));
+ zos.closeEntry();
+ }
+ }
+ Files.move(tmp, targetXlsx, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
+ tmp = null;
+ // #region agent log
+ agentDbg("H_SHEET", "restoreSeaSheet1Scaffold", "restored template sheet1 scaffold",
+ "{\"scaffoldSrc\":\"" + scaffoldSrc + "\""
+ + ",\"scaffoldBytes\":" + scaffold.length
+ + ",\"poiCells\":" + poiValues.size()
+ + ",\"mergedSheetBytes\":" + merged.sheetXml.length()
+ + ",\"sstCount\":" + merged.sstCount
+ + ",\"patchedCells\":" + merged.patchedCells
+ + ",\"rowCount\":" + rowCount
+ + ",\"acHasLocalMc\":" + merged.sheetXml.contains("AlternateContent xmlns:mc=") + "}");
+ // #endregion
+ return true;
+ } catch (Exception e) {
+ // #region agent log
+ agentDbg("H_SHEET", "restoreSeaSheet1Scaffold.err", "scaffold restore failed",
+ "{\"err\":\"" + String.valueOf(e.getMessage()).replace("\"", "'") + "\"}");
+ // #endregion
+ log.warn("海运 sheet1 骨架还原失败 path={}", targetXlsx, e);
+ return false;
+ } finally {
+ if (tmp != null) {
+ try {
+ Files.deleteIfExists(tmp);
+ } catch (Exception ignored) {
+ // ignore
+ }
+ }
+ }
+ }
+
+ /** sheet1 骨架合并结果。 */
+ static final class SeaSheetMerge {
+ final String sheetXml;
+ final String sstXml;
+ final int sstCount;
+ final int patchedCells;
+
+ SeaSheetMerge(String sheetXml, String sstXml, int sstCount, int patchedCells) {
+ this.sheetXml = sheetXml;
+ this.sstXml = sstXml;
+ this.sstCount = sstCount;
+ this.patchedCells = patchedCells;
+ }
+ }
+
+ /**
+ * 匹配单元格:必须先认自闭合 {@code },属性里不能吞掉 {@code /},
+ * 否则会变成「开标签 + 一直找到下一个 </c>」,把多行揉进一个格子(WO035)。
+ */
+ private static final java.util.regex.Pattern SEA_CELL_PATTERN = java.util.regex.Pattern.compile(
+ "/]*)\\s*/>|]*)>([\\s\\S]*?)");
+
+ static int countSheetRows(String sheetXml) {
+ if (sheetXml == null || sheetXml.isEmpty()) {
+ return 0;
+ }
+ int n = 0;
+ java.util.regex.Matcher m = java.util.regex.Pattern.compile(" parseSheetCellTexts(byte[] sheetXml, byte[] sstXml) {
+ Map out = new LinkedHashMap<>();
+ if (sheetXml == null || sheetXml.length == 0) {
+ return out;
+ }
+ List sst = parseSharedStrings(sstXml);
+ String sheet = new String(sheetXml, StandardCharsets.UTF_8);
+ java.util.regex.Matcher m = SEA_CELL_PATTERN.matcher(sheet);
+ while (m.find()) {
+ String ref;
+ String attrs;
+ String body;
+ if (m.group(1) != null) {
+ // 自闭合
+ ref = m.group(1);
+ attrs = m.group(2) == null ? "" : m.group(2);
+ body = "";
+ } else {
+ ref = m.group(3);
+ attrs = m.group(4) == null ? "" : m.group(4);
+ body = m.group(5) == null ? "" : m.group(5);
+ }
+ String val = "";
+ if (attrs.contains("t=\"s\"") || attrs.contains("t='s'")) {
+ java.util.regex.Matcher vm = java.util.regex.Pattern.compile("(\\d+)").matcher(body);
+ if (vm.find()) {
+ int idx = Integer.parseInt(vm.group(1));
+ if (idx >= 0 && idx < sst.size()) {
+ val = sst.get(idx);
+ }
+ }
+ } else {
+ java.util.regex.Matcher vm = java.util.regex.Pattern.compile("([^<]*)").matcher(body);
+ if (vm.find()) {
+ val = vm.group(1);
+ }
+ }
+ out.put(ref, val == null ? "" : val);
+ }
+ return out;
+ }
+
+ static List parseSharedStrings(byte[] sstXml) {
+ List out = new ArrayList<>();
+ if (sstXml == null || sstXml.length == 0) {
+ return out;
+ }
+ String xml = new String(sstXml, StandardCharsets.UTF_8);
+ java.util.regex.Matcher si = java.util.regex.Pattern.compile("([\\s\\S]*?)").matcher(xml);
+ while (si.find()) {
+ String body = si.group(1);
+ StringBuilder sb = new StringBuilder();
+ java.util.regex.Matcher t = java.util.regex.Pattern.compile("]*>([^<]*)").matcher(body);
+ while (t.find()) {
+ sb.append(t.group(1));
+ }
+ out.add(sb.toString());
+ }
+ return out;
+ }
+
+ /**
+ * 加载 WO011 共享字符串(与 sea_sheet1_wo011.xml 配套;合并时保留原索引,避免瘦表)。
+ */
+ static byte[] loadWo011SeaSharedStrings() {
+ return loadBundledBytes(new String[]{
+ "inquiry/template/sea_sharedStrings_wo011.xml",
+ "sea_sharedStrings_wo011.xml"
+ }, new String[]{
+ "C:/Users/ytd_extdev_0903/incoming/quote_hot2/sea_sharedStrings_wo011.xml",
+ "d:/own/友通达询价智能体(新)/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_sharedStrings_wo011.xml",
+ "d:/own/友通达询价智能体(新)/inquiry-agent/scripts/_verify_quote/sea_sharedStrings_wo011.xml"
+ }, 1000);
+ }
+
+ /**
+ * 以模板 sheet1 为骨架,把 POI 填好的值写回对应格子。
+ *
+ * 硬约束(WO010 vs WO030):控件包相同时,若重建整表 SST、把空值盖掉骨架格,
+ * sheetData 会从约 20KB 瘦到 17KB,WPS 界面可能不画顶部单选。
+ * 策略:保留 WO011 原 SST 词序与索引,只追加新词;只改 POI 非空且与骨架不同的格;
+ * B3:J3 单选锚点区一律不动。
+ */
+ static SeaSheetMerge mergeValuesOntoSeaScaffold(byte[] scaffoldSheet, Map poiValues) {
+ return mergeValuesOntoSeaScaffold(scaffoldSheet, loadWo011SeaSharedStrings(), poiValues);
+ }
+
+ static SeaSheetMerge mergeValuesOntoSeaScaffold(byte[] scaffoldSheet, byte[] scaffoldSst,
+ Map poiValues) {
+ String sheet = new String(scaffoldSheet, StandardCharsets.UTF_8);
+ List newSst = new ArrayList<>(parseSharedStrings(scaffoldSst));
+ Map sstIndex = new LinkedHashMap<>();
+ for (int i = 0; i < newSst.size(); i++) {
+ sstIndex.put(newSst.get(i), i);
+ }
+ final int[] patched = {0};
+
+ java.util.function.Function idxOf = (s) -> {
+ String key = s == null ? "" : s;
+ Integer hit = sstIndex.get(key);
+ if (hit != null) {
+ return hit;
+ }
+ int i = newSst.size();
+ newSst.add(key);
+ sstIndex.put(key, i);
+ return i;
+ };
+
+ Map scaffoldVals = parseSheetCellTexts(scaffoldSheet, scaffoldSst);
+
+ java.util.regex.Matcher m = SEA_CELL_PATTERN.matcher(sheet);
+ StringBuffer sb = new StringBuffer();
+ while (m.find()) {
+ String original = m.group(0);
+ String ref;
+ String attrs;
+ if (m.group(1) != null) {
+ ref = m.group(1);
+ attrs = m.group(2) == null ? "" : m.group(2);
+ } else {
+ ref = m.group(3);
+ attrs = m.group(4) == null ? "" : m.group(4);
+ }
+ // 单选锚点合并区:任何写入都会让 WPS 不画原控件
+ if (ref != null && ref.matches("^[B-J]3$")) {
+ m.appendReplacement(sb, java.util.regex.Matcher.quoteReplacement(original));
+ continue;
+ }
+ if (poiValues == null || !poiValues.containsKey(ref)) {
+ m.appendReplacement(sb, java.util.regex.Matcher.quoteReplacement(original));
+ continue;
+ }
+ String val = poiValues.get(ref);
+ if (val == null || val.isEmpty()) {
+ // 空值不覆盖骨架(标签/样式格)
+ m.appendReplacement(sb, java.util.regex.Matcher.quoteReplacement(original));
+ continue;
+ }
+ // 去掉 POI/骨架可能带来的双重转义
+ String norm = val.replace("&", "&").replace("<", "<").replace(">", ">");
+ String scaffoldVal = scaffoldVals.get(ref);
+ String scaffoldNorm = scaffoldVal == null ? ""
+ : scaffoldVal.replace("&", "&").replace("<", "<").replace(">", ">");
+ if (norm.equals(scaffoldNorm)) {
+ m.appendReplacement(sb, java.util.regex.Matcher.quoteReplacement(original));
+ continue;
+ }
+ java.util.regex.Matcher sm = java.util.regex.Pattern.compile("\\bs=\"(\\d+)\"").matcher(attrs);
+ String style = sm.find() ? (" s=\"" + sm.group(1) + "\"") : "";
+ String repl;
+ if (norm.matches("^-?\\d+(\\.\\d+)?$")) {
+ repl = "" + norm + "";
+ } else {
+ int idx = idxOf.apply(norm);
+ repl = "" + idx + "";
+ }
+ m.appendReplacement(sb, java.util.regex.Matcher.quoteReplacement(repl));
+ patched[0]++;
+ }
+ m.appendTail(sb);
+
+ StringBuilder sstXml = new StringBuilder();
+ sstXml.append("");
+ sstXml.append("");
+ for (String t : newSst) {
+ sstXml.append("").append(xmlEscape(t)).append("");
+ }
+ sstXml.append("");
+ return new SeaSheetMerge(sb.toString(), sstXml.toString(), newSst.size(), patched[0]);
+ }
+
+ static String xmlEscape(String s) {
+ if (s == null) {
+ return "";
+ }
+ return s.replace("&", "&").replace("<", "<").replace(">", ">")
+ .replace("\"", """);
+ }
+
+ /**
+ * @deprecated 已废弃:改 VML Checked/文案会导致 WPS 空白(WO029/032)。
+ */
+ @Deprecated
+ static String patchSeaRadioVmlForSelection(String vml, boolean portSelected) {
+ return vml;
+ }
+
+ /**
+ * @deprecated 见 {@link #patchSeaRadioVmlForSelection}。
+ */
+ @Deprecated
+ static String swapSeaRadioLabels(String xml) {
+ return xml;
+ }
+
+ /**
+ * @deprecated 已改为 {@link #patchSeaRadioVmlForSelection};保留供旧测试编译。
+ */
+ @Deprecated
+ static String patchSeaRadioVmlChecked(String vml, boolean portSelected) {
+ return vml;
+ }
+
+ /**
+ * POI 重写 sheet 后 <mc:AlternateContent> 常丢 xmlns:mc,补回与模板一致。
+ */
+ static String ensureSeaControlsMcNamespace(String sheetXml) {
+ if (StringUtils.isBlank(sheetXml) || !sheetXml.contains("")
+ || !sheetXml.contains("mc:AlternateContent")) {
+ return sheetXml;
+ }
+ if (sheetXml.contains("",
+ "");
+ }
+
+ /**
+ * 生成海运 Form Control 单选 ctrlProp XML。
+ * firstButton / checked 应同真同假:WPS 要求当前勾选钮带 firstButton,否则整组空白。
+ */
+ static String buildSeaRadioCtrlPropXml(boolean firstButton, boolean checked) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("\r\n");
+ sb.append("");
+ return sb.toString();
+ }
+
+ /** Java 8 兼容:把输入流读成 byte[](测服 javac 无 InputStream#readAllBytes)。 */
+ static byte[] readStreamBytes(InputStream in) throws Exception {
+ java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
+ byte[] buf = new byte[4096];
+ int n;
+ while ((n = in.read(buf)) >= 0) {
+ bos.write(buf, 0, n);
+ }
+ return bos.toByteArray();
+ }
+
+ /**
+ * @deprecated 可见文案已迁到 {@link #writeSeaTransportClassVisibleLabel};原控件勾选仍走 sync。
+ */
+ @Deprecated
+ static void applySeaTransportClassRadio(Workbook wb, JSONObject facts) {
+ writeSeaTransportClassVisibleLabel(wb, facts);
+ }
+
+ /**
+ * WPS 证据(WO020/021):zip 内 VML/drawing 有字但界面空白。
+ * 必须写到 B3(RFQ 行合并区),禁止误写到 B9 贸易条款(WO021:find 命中含 Port 的 B9)。
+ *
+ * 注意:B3 有字时原 Form Control 不画(WO033)。仅在 {@link #restoreSeaSheet1Scaffold} 失败时作兜底。
+ */
+ static void writeSeaTransportClassVisibleLabel(Workbook wb, JSONObject facts) {
+ if (wb == null || facts == null) {
+ return;
+ }
+ String raw = firstNonBlank(
+ facts.getString("运输分类"),
+ facts.getString("transport_class"),
+ facts.getString("sea_class"));
+ String cls = canonicalizeSeaTransportClass(raw);
+ if (StringUtils.isBlank(cls)) {
+ return;
+ }
+ boolean port = "Port to Port".equalsIgnoreCase(cls);
+ String label = port
+ ? "● Port to Port, 1工作天回复 ○ 其它, 3工作天回复"
+ : "○ Port to Port, 1工作天回复 ● 其它, 3工作天回复";
+ for (int s = 0; s < wb.getNumberOfSheets(); s++) {
+ Sheet sheet = wb.getSheetAt(s);
+ if (sheet == null) {
+ continue;
+ }
+ String name = sheet.getSheetName();
+ boolean seaSheet = name != null
+ && (name.contains("海运") || name.toLowerCase(Locale.ROOT).contains("sea"));
+ if (!seaSheet) {
+ continue;
+ }
+ // 固定 B3:模板 B3:J3 即运输分类单选所在合并区
+ Row row = sheet.getRow(2);
+ if (row == null) {
+ row = sheet.createRow(2);
+ }
+ Cell target = row.getCell(1);
+ if (target == null) {
+ target = row.createCell(1);
+ }
+ target.setCellValue(label);
+ // #region agent log
+ agentDbg("R3", "writeSeaTransportClassVisibleLabel", "wrote visible radio label for WPS",
+ "{\"ref\":\"" + new CellReference(target).formatAsString()
+ + "\",\"cls\":\"" + cls.replace("\"", "'") + "\",\"port\":" + port + "}");
+ // #endregion
+ return;
+ }
+ }
+
+ /**
+ * 骨架还原失败时的明文兜底:只改 zip 内 sheet1/sharedStrings 的 B3,不经 POI,
+ * 避免再次瘦身 drawing(WO017)。销售侧至少能看到 ●/○ 选中态。
+ */
+ static void writeSeaTransportClassVisibleLabelToFile(Path targetXlsx, JSONObject facts) {
+ if (targetXlsx == null || !Files.isRegularFile(targetXlsx) || facts == null) {
+ return;
+ }
+ String raw = firstNonBlank(
+ facts.getString("运输分类"),
+ facts.getString("transport_class"),
+ facts.getString("sea_class"));
+ String cls = canonicalizeSeaTransportClass(raw);
+ if (StringUtils.isBlank(cls)) {
+ // #region agent log
+ agentDbg("R3", "writeSeaTransportClassVisibleLabelToFile.skip", "no transport class for fallback",
+ "{}");
+ // #endregion
+ return;
+ }
+ boolean port = "Port to Port".equalsIgnoreCase(cls);
+ String label = port
+ ? "● Port to Port, 1工作天回复 ○ 其它, 3工作天回复"
+ : "○ Port to Port, 1工作天回复 ● 其它, 3工作天回复";
+ Path tmp = null;
+ try {
+ byte[] sheetBytes;
+ byte[] sstBytes = new byte[0];
+ try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(targetXlsx.toFile())) {
+ java.util.zip.ZipEntry sh = zf.getEntry("xl/worksheets/sheet1.xml");
+ if (sh == null) {
+ return;
+ }
+ try (InputStream in = zf.getInputStream(sh)) {
+ sheetBytes = readStreamBytes(in);
+ }
+ java.util.zip.ZipEntry sst = zf.getEntry("xl/sharedStrings.xml");
+ if (sst != null) {
+ try (InputStream in = zf.getInputStream(sst)) {
+ sstBytes = readStreamBytes(in);
+ }
+ }
+ }
+ Map cells = parseSheetCellTexts(sheetBytes, sstBytes);
+ cells.put("B3", label);
+ // 用当前 sheet 当骨架,只把 B3 换成明文;不动 drawing/VML/styles
+ SeaSheetMerge merged = mergeValuesOntoSeaScaffold(sheetBytes, cells);
+ tmp = Files.createTempFile(targetXlsx.getParent(), "sea-label-", ".xlsx");
+ try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(targetXlsx.toFile());
+ OutputStream fos = Files.newOutputStream(tmp);
+ java.util.zip.ZipOutputStream zos = new java.util.zip.ZipOutputStream(fos)) {
+ java.util.Enumeration extends java.util.zip.ZipEntry> en = zf.entries();
+ boolean wroteSst = false;
+ while (en.hasMoreElements()) {
+ java.util.zip.ZipEntry entry = en.nextElement();
+ String name = entry.getName();
+ zos.putNextEntry(new java.util.zip.ZipEntry(name));
+ byte[] data;
+ try (InputStream in = zf.getInputStream(entry)) {
+ data = readStreamBytes(in);
+ }
+ if ("xl/worksheets/sheet1.xml".equals(name)) {
+ data = merged.sheetXml.getBytes(StandardCharsets.UTF_8);
+ } else if ("xl/sharedStrings.xml".equals(name)) {
+ data = merged.sstXml.getBytes(StandardCharsets.UTF_8);
+ wroteSst = true;
+ }
+ zos.write(data);
+ zos.closeEntry();
+ }
+ if (!wroteSst) {
+ zos.putNextEntry(new java.util.zip.ZipEntry("xl/sharedStrings.xml"));
+ zos.write(merged.sstXml.getBytes(StandardCharsets.UTF_8));
+ zos.closeEntry();
+ }
+ }
+ Files.move(tmp, targetXlsx, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
+ tmp = null;
+ // #region agent log
+ agentDbg("R3", "writeSeaTransportClassVisibleLabelToFile", "B3 visible radio text for other or scaffold-fail",
+ "{\"cls\":\"" + cls.replace("\"", "'") + "\",\"port\":" + port + "}");
+ // #endregion
+ } catch (Exception e) {
+ // #region agent log
+ agentDbg("R3", "writeSeaTransportClassVisibleLabelToFile.err", "fallback failed",
+ "{\"err\":\"" + String.valueOf(e.getMessage()).replace("\"", "'") + "\"}");
+ // #endregion
+ log.warn("海运运输分类明文兜底失败 path={}", targetXlsx, e);
+ } finally {
+ if (tmp != null) {
+ try {
+ Files.deleteIfExists(tmp);
+ } catch (Exception ignored) {
+ // ignore
+ }
+ }
+ }
+ }
+
+ /**
+ * 海运运输分类标准词:Port to Port / 其他(其它→其他)。
+ */
+ static String canonicalizeSeaTransportClass(String raw) {
+ if (StringUtils.isBlank(raw)) {
+ return "";
+ }
+ String t = raw.replace('\u00a0', ' ').replaceAll("\\s+", " ").trim();
+ String lower = t.toLowerCase(Locale.ROOT);
+ if (lower.contains("port to port") || t.contains("港到港") || lower.equals("p2p")) {
+ return "Port to Port";
+ }
+ if (t.contains("其它") || t.contains("其他") || lower.contains("other")) {
+ return "其他";
+ }
+ return "";
+ }
+
+ /**
+ * 是否海运询价单工作表(按表名;无表名时看是否含 Form Control 锚点区)。
+ */
+ static boolean sheetLooksLikeSeaQuote(Sheet sheet) {
+ if (sheet == null) {
+ return false;
+ }
+ String name = sheet.getSheetName();
+ if (name != null) {
+ String lower = name.toLowerCase(Locale.ROOT);
+ if (name.contains("海运") || lower.contains("sea") || name.contains("国际海运")) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * 找运输分类单选格:海运表固定 B3;不再扫描含 Port 的贸易条款格(WO021 误伤 B9)。
+ */
+ static Cell findSeaTransportClassRadioCell(Sheet sheet) {
+ if (sheet == null || !sheetLooksLikeSeaQuote(sheet)) {
+ return null;
+ }
+ Row row = sheet.getRow(2);
+ if (row == null) {
+ row = sheet.createRow(2);
+ }
+ Cell b3 = row.getCell(1);
+ if (b3 == null) {
+ b3 = row.createCell(1);
+ }
+ return b3;
}
/**
@@ -1497,6 +3052,13 @@ public final class QuoteTemplateExcelSupport {
}
int col = CellReference.convertColStringToIndex(colLetter.trim());
String ref = sheetName + "!" + new CellReference(rowIdx, col).formatAsString();
+ // #region agent log
+ if (StringUtils.isNotBlank(value) && (value.contains("广州") || "B".equalsIgnoreCase(colLetter.trim()))) {
+ agentDbg("H3", "writeFeeCol", "fee col write",
+ "{\"ref\":\"" + ref + "\",\"col\":\"" + colLetter
+ + "\",\"val\":\"" + value.replace("\"", "'") + "\"}");
+ }
+ // #endregion
if (yellow != null && yellow.contains(ref)) {
return;
}
@@ -1752,11 +3314,11 @@ public final class QuoteTemplateExcelSupport {
}
/**
- * 预估费用 / 预估总价行只保留一个总价。
+ * 预估费用 / 预估总价 / 费用总计行只保留一个总价。
*
- * 为何:模板金额列是 SUM,刷公式后常把「发生时收取」的提送货/人工费加进去;
- * 若再按标签把 TMS total 写到标签右侧空格,销售会看到同行两个数(如 183370 与 183499)。
- * 有 total_amount 时以它为准,写入原 SUM 格(或本行最右金额格),并清空同行其它多余数字。
+ * 产品口径:TMS 已返回总销售价(total_amount)时,Excel 若仍是 SUM 等公式必须删掉再写数;
+ * 禁止用明细行加总冒充总价(WO040:增点费+拆板费 SUM=200,真总价 7918.25)。
+ * 有 total_amount 时以它为准,写入原 SUM 格(或金额列),并清空同行其它多余数字。
*/
static void overwriteEstimateTotal(Workbook wb, JSONObject facts) {
if (wb == null || facts == null) {
@@ -1798,59 +3360,151 @@ public final class QuoteTemplateExcelSupport {
if (labelCol < 0) {
continue;
}
- Cell target = null;
- List| extras = new ArrayList<>();
short last = row.getLastCellNum();
- for (int c = labelCol + 1; c < last; c++) {
+ // 海运「费用总计」行:币别进币别列、金额进金额列(与费目表头对齐),禁止写到标签旁 B 列
+ boolean seaTotalRow = textLooksSeaFeeTotalLabel(row, labelCol);
+ if (seaTotalRow) {
+ Map cols = findNearbyFeeColumns(sheet, r);
+ int ccyCol = cols.containsKey("currency") ? cols.get("currency") : 1;
+ int amtCol = cols.containsKey("amount") ? cols.get("amount") : 7;
+ // 模板常把 C44:J44 整行合并,金额只能进合并原点;拆开后才能分写币别/金额列
+ unmergeRowCellsSpanning(sheet, r, ccyCol, amtCol);
+ String ccy = firstNonBlank(facts.getString("currency"), "CNY");
+ String upper = total.trim().toUpperCase(Locale.ROOT);
+ if (upper.startsWith("CNY") || upper.startsWith("USD") || upper.startsWith("RMB")) {
+ ccy = total.trim().split("\\s+")[0].toUpperCase(Locale.ROOT);
+ if ("RMB".equals(ccy)) {
+ ccy = "CNY";
+ }
+ }
+ writeMappedText(sheet, r, ccyCol, ccy, null);
+ if (num != null) {
+ writeAmountCell(sheet, r, amtCol, String.valueOf(num), null);
+ } else {
+ writeMappedText(sheet, r, amtCol, norm, null);
+ }
+ // 清掉标签右侧误写的金额(旧逻辑写在 B);勿清币别/金额列及其合并原点
+ int[] amtOrigin = resolveMergeOrigin(sheet, r, amtCol);
+ int[] ccyOrigin = resolveMergeOrigin(sheet, r, ccyCol);
+ for (int c = labelCol + 1; c < Math.min((int) last, Math.max(amtCol, 10)); c++) {
+ int[] dest = resolveMergeOrigin(sheet, r, c);
+ if ((dest[0] == amtOrigin[0] && dest[1] == amtOrigin[1])
+ || (dest[0] == ccyOrigin[0] && dest[1] == ccyOrigin[1])
+ || c == ccyCol || c == amtCol) {
+ continue;
+ }
+ Cell cell = row.getCell(dest[1]);
+ if (cell == null) {
+ continue;
+ }
+ String exist = readCellText(cell);
+ if (looksLikeMoneyText(exist) || cell.getCellType() == CellType.NUMERIC
+ || cell.getCellType() == CellType.FORMULA) {
+ clearEstimateExtraCell(row, cell);
+ }
+ }
+ // #region agent log
+ agentDbg("T3", "overwriteEstimateTotal.sea", "wrote total at fee columns",
+ "{\"row\":" + r + ",\"ccyCol\":" + ccyCol + ",\"amtCol\":" + amtCol
+ + ",\"ccy\":\"" + ccy + "\",\"amt\":\"" + norm.replace("\"", "'") + "\"}");
+ // #endregion
+ continue;
+ }
+ // 空运:优先写到费目表「金额」列(常为 G),避免写到标签旁 B 而 SUM 仍在 G
+ Map feeCols = findNearbyFeeColumns(sheet, r);
+ int preferAmtCol = feeCols.containsKey("amount") ? feeCols.get("amount") : -1;
+ int scanLast = Math.max(last < 0 ? 0 : (int) last, Math.max(preferAmtCol + 1, 12));
+ int targetCol = -1;
+ List extraCols = new ArrayList<>();
+ for (int c = labelCol + 1; c < scanLast; c++) {
int[] dest = resolveMergeOrigin(sheet, r, c);
Cell cell = row.getCell(dest[1]);
if (cell == null) {
continue;
}
String exist = readCellText(cell);
+ String formulaText = cell.getCellType() == CellType.FORMULA
+ ? StringUtils.trimToEmpty(cell.getCellFormula())
+ : "";
if (isFeeTableHeader(exist) || isLabelLike(exist) || isSectionTitle(sheet, cell)) {
continue;
}
- if (cell.getCellType() == CellType.FORMULA) {
- if (target == null) {
- target = cell;
+ boolean sumLike = cell.getCellType() == CellType.FORMULA
+ || formulaText.toUpperCase(Locale.ROOT).contains("SUM")
+ || (exist.startsWith("=") && exist.toUpperCase(Locale.ROOT).contains("SUM"));
+ if (sumLike) {
+ if (targetCol < 0 || dest[1] == preferAmtCol) {
+ if (targetCol >= 0 && targetCol != dest[1]) {
+ extraCols.add(targetCol);
+ }
+ targetCol = dest[1];
} else {
- extras.add(cell);
+ extraCols.add(dest[1]);
}
continue;
}
- if (cell.getCellType() == CellType.NUMERIC
- || looksLikeMoneyText(exist)) {
- extras.add(cell);
+ if (cell.getCellType() == CellType.NUMERIC || looksLikeMoneyText(exist)) {
+ extraCols.add(dest[1]);
}
}
- if (target == null && !extras.isEmpty()) {
+ if (targetCol < 0 && preferAmtCol >= 0) {
+ targetCol = preferAmtCol;
+ }
+ if (targetCol < 0 && !extraCols.isEmpty()) {
// 无公式时取最右金额格作为唯一总价格
- target = extras.remove(extras.size() - 1);
+ targetCol = extraCols.remove(extraCols.size() - 1);
}
- if (target == null) {
- // 标签右侧尚无格:落在金额列常见位置(与费目表头对齐时由调用方保证)
- target = row.getCell(labelCol + 1);
- if (target == null) {
- target = row.createCell(labelCol + 1);
- }
+ if (targetCol < 0) {
+ targetCol = labelCol + 1;
}
if (num != null) {
- target.setCellValue(num.doubleValue());
+ // 与 writeAmountCell 相同:先删公式再写,避免 XSSF 残留 SUM
+ writeAmountCell(sheet, r, targetCol, String.valueOf(num), null);
} else {
- target.setCellValue(norm);
+ writeMappedText(sheet, r, targetCol, norm, null);
}
- for (Cell extra : extras) {
- if (extra == null || extra == target) {
+ int[] targetOrigin = resolveMergeOrigin(sheet, r, targetCol);
+ for (Integer extraCol : extraCols) {
+ if (extraCol == null || extraCol == targetCol || extraCol == targetOrigin[1]) {
continue;
}
- // POI 旧版无 setBlank;清空即可,避免同行残留第二份总价
- extra.setCellValue("");
+ int[] dest = resolveMergeOrigin(sheet, r, extraCol);
+ if (dest[1] == targetOrigin[1]) {
+ continue;
+ }
+ Cell extra = row.getCell(dest[1]);
+ clearEstimateExtraCell(row, extra);
}
+ // #region agent log
+ agentDbg("T3", "overwriteEstimateTotal.air", "wrote tms total over SUM",
+ "{\"row\":" + r + ",\"col\":" + targetCol
+ + ",\"amt\":\"" + norm.replace("\"", "'") + "\"}");
+ // #endregion
}
}
}
+ /**
+ * 清掉预估费用行上多余金额/公式格。公式格必须先 remove,否则只改缓存仍显示 SUM。
+ */
+ private static void clearEstimateExtraCell(Row row, Cell extra) {
+ if (row == null || extra == null) {
+ return;
+ }
+ if (extra.getCellType() == CellType.FORMULA) {
+ int col = extra.getColumnIndex();
+ CellStyle style = extra.getCellStyle();
+ row.removeCell(extra);
+ Cell blank = row.createCell(col);
+ if (style != null) {
+ blank.setCellStyle(style);
+ }
+ blank.setCellValue("");
+ return;
+ }
+ extra.setCellValue("");
+ }
+
/** 是否像金额文本(含小数或纯数字),用于清掉预估费用行上误写的第二份数。 */
private static boolean looksLikeMoneyText(String raw) {
if (StringUtils.isBlank(raw) || "-".equals(raw.trim())) {
@@ -1868,6 +3522,58 @@ public final class QuoteTemplateExcelSupport {
}
}
+ /**
+ * 海运费用总计行:标签含「费用总计/Total Costs」,且同行或上方有费用表头结构。
+ * 空运「预估总价」仍走原 SUM/标签旁逻辑。
+ */
+ static boolean textLooksSeaFeeTotalLabel(Row row, int labelCol) {
+ if (row == null || labelCol < 0) {
+ return false;
+ }
+ Cell label = row.getCell(labelCol);
+ String t = readCellText(label);
+ if (StringUtils.isBlank(t)) {
+ return false;
+ }
+ if (!(t.contains("费用总计") || t.toLowerCase(Locale.ROOT).contains("total cost"))) {
+ return false;
+ }
+ // 预估费用/预估总价是空运模板用语,不走海运双列写入
+ if (t.contains("预估费用") || t.contains("预估总价")) {
+ return false;
+ }
+ Sheet sheet = row.getSheet();
+ Map cols = findNearbyFeeColumns(sheet, row.getRowNum());
+ return cols.containsKey("amount") || cols.containsKey("currency");
+ }
+
+ /**
+ * 拆掉覆盖 [fromCol, toCol] 的同行合并区,使币别列与金额列可分别写入。
+ */
+ static void unmergeRowCellsSpanning(Sheet sheet, int rowIdx, int fromCol, int toCol) {
+ if (sheet == null || rowIdx < 0) {
+ return;
+ }
+ int lo = Math.min(fromCol, toCol);
+ int hi = Math.max(fromCol, toCol);
+ for (int i = sheet.getNumMergedRegions() - 1; i >= 0; i--) {
+ org.apache.poi.ss.util.CellRangeAddress range = sheet.getMergedRegion(i);
+ if (range == null) {
+ continue;
+ }
+ if (range.getFirstRow() > rowIdx || range.getLastRow() < rowIdx) {
+ continue;
+ }
+ if (range.getLastColumn() < lo || range.getFirstColumn() > hi) {
+ continue;
+ }
+ // 只拆横向跨币别~金额的合并,保留标签自身纵向合并
+ if (range.getLastColumn() - range.getFirstColumn() >= 1) {
+ sheet.removeMergedRegion(i);
+ }
+ }
+ }
+
/**
* 回填后重算公式,把 SUM 等缓存从 0 刷成真实合计。
* 失败不抛:出单仍要发出,只记日志。
@@ -1945,6 +3651,13 @@ public final class QuoteTemplateExcelSupport {
}
String name = StringUtils.trimToEmpty(rowObj.getString("name"));
String amount = StringUtils.trimToEmpty(rowObj.getString("amount"));
+ // #region agent log
+ if (StringUtils.isNotBlank(name)) {
+ agentDbg("H3", "writeFeeAmountsByName", "fee row candidate",
+ "{\"name\":\"" + name.replace("\"", "'") + "\",\"amount\":\""
+ + amount.replace("\"", "'") + "\"}");
+ }
+ // #endregion
if (StringUtils.isBlank(name) || StringUtils.isBlank(amount) || "-".equals(amount)) {
continue;
}
@@ -2519,6 +4232,14 @@ public final class QuoteTemplateExcelSupport {
}
private static void writeCell(Workbook wb, String cellRef, String value) {
+ // #region agent log
+ if (StringUtils.isNotBlank(value) && (value.contains("广州") || value.contains("Guangzhou")
+ || "广州".equals(value.trim()))) {
+ agentDbg("H5", "writeCell", "cell write pol-like",
+ "{\"cellRef\":\"" + String.valueOf(cellRef).replace("\"", "'")
+ + "\",\"val\":\"" + value.replace("\"", "'") + "\"}");
+ }
+ // #endregion
ParsedRef ref = ParsedRef.parse(cellRef);
Sheet sheet = wb.getSheet(ref.sheet);
if (sheet == null && wb.getNumberOfSheets() > 0) {
@@ -2645,15 +4366,16 @@ public final class QuoteTemplateExcelSupport {
private static String resolveFillTarget(Sheet sheet, Cell labelCell, String sheetName, String text, DataFormatter fmt) {
int idx = Math.max(text.indexOf(':'), text.indexOf(':'));
// 「RFQ No:」或冒号后已有样例:值写在本格冒号后,不跨到右侧单选/标题合并格
+ // 注意:CellReference(Cell) 的 formatAsString 可能已带 sheet 名,禁止再拼 sheetName! 否则变成 Sheet2!Sheet2!B6
if (idx >= 0) {
String code = matchLabel(text.replace(":", ":").trim(), FILL_BY_LABEL);
if ("quote_no".equals(code)
|| text.toUpperCase(Locale.ROOT).contains("RFQ")
|| text.contains("编号")) {
- return sheetName + "!" + new CellReference(labelCell).formatAsString();
+ return sheetName + "!" + new CellReference(labelCell.getRowIndex(), labelCell.getColumnIndex()).formatAsString();
}
if (idx < text.length() - 1 && StringUtils.isNotBlank(text.substring(idx + 1).trim())) {
- return sheetName + "!" + new CellReference(labelCell).formatAsString();
+ return sheetName + "!" + new CellReference(labelCell.getRowIndex(), labelCell.getColumnIndex()).formatAsString();
}
}
int rowIdx = labelCell.getRowIndex();
@@ -2678,6 +4400,11 @@ public final class QuoteTemplateExcelSupport {
String exist = readCellText(cand);
if (isFeeTableHeader(exist) || isLabelLike(exist) || isSectionTitle(sheet, cand)
|| isUnsuitableValueCell(exist)) {
+ // 左右双栏:已找到左侧候选后,遇到右侧标签必须停,禁止再跨到送货地址值区
+ if ((firstEmpty != null || wideEmpty != null)
+ && (isLabelLike(exist) || isSectionTitle(sheet, cand))) {
+ break;
+ }
continue;
}
String ref = sheetName + "!" + new CellReference(dest[0], dest[1]).formatAsString();
@@ -2722,8 +4449,21 @@ public final class QuoteTemplateExcelSupport {
}
boolean feeHeader = !parseFeeHeaderRow(sheet, r).isEmpty();
// 费用明细数据行:「起运港/目的港」是项目列值,不是询价标签;
- // 否则会把 pol=ZUH 写进右侧「费用名称」,盖掉提送货费/人工费。
+ // 否则会把 pol 写进右侧「费用名称」(WO202609230026:广州写入 B18)。
if (!feeHeader && isFeeDetailDataRow(sheet, r)) {
+ // #region agent log
+ Row peek = sheet.getRow(r);
+ String peek0 = peek == null ? "" : StringUtils.trimToEmpty(
+ new DataFormatter().formatCellValue(peek.getCell(0)));
+ String peek1 = peek == null ? "" : StringUtils.trimToEmpty(
+ new DataFormatter().formatCellValue(peek.getCell(1)));
+ if (peek0.contains("起运港") || peek1.contains("起运港")
+ || peek0.contains("空运段") || peek1.contains("空运段")) {
+ agentDbg("H1", "writeFactsByLabel.skipFeeDetail", "skipped fee detail row",
+ "{\"row\":" + r + ",\"c0\":\"" + peek0.replace("\"", "'")
+ + "\",\"c1\":\"" + peek1.replace("\"", "'") + "\"}");
+ }
+ // #endregion
continue;
}
short lastCol = row.getLastCellNum();
@@ -2744,6 +4484,10 @@ public final class QuoteTemplateExcelSupport {
if ("total_amount".equals(code)) {
continue;
}
+ // 海运运输分类由顶部 Form Control 勾选,禁止再写单元格字(写进 B3 锚点会空白)
+ if ("运输分类".equals(code) && sheetLooksLikeSeaQuote(sheet)) {
+ continue;
+ }
if ("currency".equals(code) && feeHeader) {
continue;
}
@@ -2755,6 +4499,30 @@ public final class QuoteTemplateExcelSupport {
if (StringUtils.isBlank(fillRef)) {
continue;
}
+ // 硬防护:pol/pod 等港口字段禁止写入费用「名称」列(无 TMS 时曾把「广州」写进费用名称)
+ if (isPortFactCode(code) && isFeeNameColumnRef(sheet, fillRef)) {
+ // #region agent log
+ agentDbg("H1", "writeFactsByLabel.blockFeeName", "blocked pol/pod into fee name col",
+ "{\"row\":" + r + ",\"code\":\"" + code + "\",\"fillRef\":\"" + fillRef
+ + "\",\"text\":\"" + text.replace("\"", "'")
+ + "\",\"val\":\"" + val.replace("\"", "'") + "\"}");
+ // #endregion
+ continue;
+ }
+ // #region agent log
+ if ("pol".equals(code) || text.contains("起运港")
+ || "fcl_or_lcl".equals(code) || text.contains("整柜")
+ || "运输分类".equals(code) || text.contains("送货地址")) {
+ boolean feeDetail = isFeeDetailDataRow(sheet, r);
+ agentDbg("S1", "writeFactsByLabel.sea", "label matched sea-sensitive",
+ "{\"sheet\":\"" + sheetName + "\",\"row\":" + r + ",\"col\":" + c
+ + ",\"text\":\"" + text.replace("\"", "'")
+ + "\",\"code\":\"" + code + "\",\"fillRef\":\"" + fillRef
+ + "\",\"feeHeader\":" + feeHeader
+ + ",\"feeDetailDataRow\":" + feeDetail
+ + ",\"val\":\"" + val.replace("\"", "'") + "\"}");
+ }
+ // #endregion
boolean labelAfterColon = textContainsColon(text)
&& ("quote_no".equals(code) || "quote_date".equals(code) || "valid_until".equals(code));
// 编号/日期类:标签同格冒号后允许写黄区;其它字段仍跳过固定区
@@ -2777,6 +4545,146 @@ public final class QuoteTemplateExcelSupport {
}
}
+ /** 港口/OD 类事实码:禁止误写入费用名称列。 */
+ static boolean isPortFactCode(String code) {
+ return "pol".equals(code) || "pod".equals(code) || "origin".equals(code) || "destination".equals(code)
+ || "起运港".equals(code) || "目的港".equals(code) || "起运地".equals(code) || "目的地".equals(code);
+ }
+
+ /**
+ * 出单收尾:若费用名称格内容等于起运港/目的港事实值,清空。
+ * 覆盖「跳过逻辑偶发未命中」或模板样例残留;有真实费目名(运输费等)不会被清。
+ */
+ static void clearPortSpillInFeeNameColumn(Workbook wb, JSONObject facts) {
+ if (wb == null || facts == null) {
+ return;
+ }
+ Set ports = new LinkedHashSet<>();
+ for (String k : new String[]{"pol", "pod", "origin", "destination", "起运港", "目的港"}) {
+ String v = StringUtils.trimToEmpty(facts.getString(k));
+ if (StringUtils.isNotBlank(v) && !"-".equals(v)) {
+ ports.add(v);
+ }
+ }
+ if (ports.isEmpty()) {
+ return;
+ }
+ DataFormatter fmt = new DataFormatter();
+ int cleared = 0;
+ for (int s = 0; s < wb.getNumberOfSheets(); s++) {
+ Sheet sheet = wb.getSheetAt(s);
+ if (sheet == null) {
+ continue;
+ }
+ for (int r = 0; r <= sheet.getLastRowNum(); r++) {
+ // 与 isFeeNameColumnRef 一致:表头下方即费用区,不单独依赖 isFeeDetailDataRow
+ boolean underHeader = false;
+ for (int hr = r - 1; hr >= Math.max(0, r - 40); hr--) {
+ if (!parseFeeHeaderRow(sheet, hr).isEmpty()) {
+ underHeader = true;
+ break;
+ }
+ }
+ if (!underHeader) {
+ continue;
+ }
+ // 汇总行不清
+ Row row = sheet.getRow(r);
+ if (row == null) {
+ continue;
+ }
+ boolean totalRow = false;
+ for (Cell cell : row) {
+ if (cell == null) {
+ continue;
+ }
+ String t = StringUtils.trimToEmpty(fmt.formatCellValue(cell));
+ if (t.contains("预估费用") || t.contains("预估总价") || t.contains("费用总计")
+ || t.contains("报价合计")) {
+ totalRow = true;
+ break;
+ }
+ }
+ if (totalRow) {
+ continue;
+ }
+ Map cols = findFeeTableColumnsAbove(sheet, r);
+ Integer nameCol = cols.get("name");
+ if (nameCol == null) {
+ Integer projectCol = cols.get("project");
+ if (projectCol != null) {
+ nameCol = projectCol + 1;
+ }
+ }
+ if (nameCol == null) {
+ continue;
+ }
+ Cell cell = row.getCell(nameCol);
+ if (cell == null) {
+ continue;
+ }
+ String text = StringUtils.trimToEmpty(fmt.formatCellValue(cell));
+ if (ports.contains(text)) {
+ cell.setCellValue("");
+ cleared++;
+ }
+ }
+ }
+ // #region agent log
+ if (cleared > 0) {
+ agentDbg("H1", "clearPortSpillInFeeNameColumn", "cleared port spill in fee names",
+ "{\"cleared\":" + cleared + "}");
+ }
+ // #endregion
+ }
+
+ /**
+ * fillRef 是否落在费用表「费用名称/name」列(含仅有「项目」当 name 兜底的海运表)。
+ * 用于挡住无 TMS 时把起运港名写进费用名称。
+ */
+ static boolean isFeeNameColumnRef(Sheet sheet, String fillRef) {
+ if (sheet == null || StringUtils.isBlank(fillRef)) {
+ return false;
+ }
+ try {
+ ParsedRef ref = ParsedRef.parse(fillRef);
+ if (ref == null || ref.row < 0 || ref.col < 0) {
+ return false;
+ }
+ // 不依赖 isFeeDetailDataRow:表头识别偶发失败时仍要挡住 name 列
+ Map cols = findFeeTableColumnsAbove(sheet, ref.row);
+ if (cols.isEmpty()) {
+ // 向上 40 行内找不到表头则再全表扫一次
+ cols = findFeeTableColumns(sheet);
+ }
+ if (cols.isEmpty()) {
+ return false;
+ }
+ // 目标行必须在某个费用表头之下,避免误伤货物区 B 列
+ boolean underHeader = false;
+ for (int r = ref.row - 1; r >= Math.max(0, ref.row - 40); r--) {
+ if (!parseFeeHeaderRow(sheet, r).isEmpty()) {
+ underHeader = true;
+ break;
+ }
+ }
+ if (!underHeader) {
+ return false;
+ }
+ Integer nameCol = cols.get("name");
+ if (nameCol != null && nameCol == ref.col) {
+ return true;
+ }
+ Integer projectCol = cols.get("project");
+ if (projectCol != null && ref.col == projectCol + 1) {
+ return true;
+ }
+ return false;
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
/** 是否含中英文冒号(标签同格回填用)。 */
private static boolean textContainsColon(String text) {
return text != null && (text.contains(":") || text.contains(":"));
@@ -2841,7 +4749,10 @@ public final class QuoteTemplateExcelSupport {
if (dk.equalsIgnoreCase(compact) || compact.equals(dk)) {
return e.getValue();
}
- if (dk.length() >= 3 && (compact.startsWith(dk) || dk.startsWith(compact))) {
+ // 只认「格子文字以词典标签开头」(如「整柜或拼柜/FCL…」→ 整柜或拼柜)。
+ // 禁止反向:词典标签以格子开头(「整柜或拼柜」startsWith「整柜」),
+ // 否则第一次写入的「整柜」会被二次扫描当成标签,跨栏写进送货地址(WO202609240027)。
+ if (dk.length() >= 3 && compact.length() >= dk.length() && compact.startsWith(dk)) {
return e.getValue();
}
}
diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateSnapshotRenderer.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateSnapshotRenderer.java
index 3f99e39..33b206d 100644
--- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateSnapshotRenderer.java
+++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateSnapshotRenderer.java
@@ -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。
*
+ * 为何不用 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} 像素,避免预览弹窗底下留一大块空白。
+ *
+ * 行/列允许极少量杂点(浅网格线);写出前必须拷贝到新 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 无界面转换;已在文件锁内,禁止再开并行实例。
*/
diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java
index e0a9643..d902bc3 100644
--- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java
+++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java
@@ -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 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 option = resolveOption(quote, optionNo);
if (option == null) {
option = new LinkedHashMap<>();
}
- if (manual && StringUtils.isBlank(text(option, "optionNo", "option_no"))) {
- // 手工价往往只有金额。舱位、航班仍用本单那条 TMS 报价,不另编编号。
- Map 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 tmsOption = tmsOptionForLock(rows, no, wantOption);
+ if (!tmsOption.isEmpty()) {
+ option = mergeLockOption(option, tmsOption);
}
- String resolvedOption = text(option, "optionNo", "option_no");
+ Map 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 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 合同。
+ *
+ * 必带: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 req, Map quote, Map option) {
+ static void fillManualLock(Map req, Map quote,
+ Map option, Map 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 查价回包原样带回。
+ *
+ * 查价落库常是 {@code valid_until=2026-09-30}(仅日期);锁舱要带时区的 ISO,
+ * 否则 TMS 回 1001「参数格式错误」。费用优先 {@code airOptions[].departureCharges} /
+ * {@code feeItems} 原文,禁止用本地改写过的展示名替换。
+ */
+ static void fillLockQuoteExtras(Map req, Map quote, Map 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 |