diff --git a/inquiry-agent/agent/channel/quote_template_parse.py b/inquiry-agent/agent/channel/quote_template_parse.py index b300a1a..19903fa 100644 --- a/inquiry-agent/agent/channel/quote_template_parse.py +++ b/inquiry-agent/agent/channel/quote_template_parse.py @@ -37,7 +37,7 @@ class QuoteTemplateParseBody(BaseModel): yellow_count: int = Field(default=0, alias="yellowCount") truncated: bool = False request_id: str = Field(default="", alias="requestId") - # 未换文件重新解析:true + existingMapping,回写时旧字段优先补缺 + # 未换文件重新解析:true + existingMapping,回写时智能合并(对齐保留、对不上改坐标) merge_existing: bool = Field(default=False, alias="mergeExisting") existing_mapping: Optional[dict[str, Any]] = Field(default=None, alias="existingMapping") diff --git a/inquiry-agent/agent/jobs/recognition.py b/inquiry-agent/agent/jobs/recognition.py index cf2ac3d..f51a3e3 100644 --- a/inquiry-agent/agent/jobs/recognition.py +++ b/inquiry-agent/agent/jobs/recognition.py @@ -114,8 +114,8 @@ def _process_quote_template_parse(payload: dict[str, Any], *, allow_network: boo """ 跑 sheet_map 并回写主账;失败也必须回写 failed+原因。 - mergeExisting=true(未换文件重新解析)时:与 existingMapping 补缺合并后再回写, - 旧字段优先保留;换文件/首传不传该标记,整份覆盖。 + mergeExisting=true(未换文件重新解析)时:与 existingMapping 智能合并后再回写, + 仍对齐标签的旧坐标保留,对不上的改用新坐标;换文件/首传不传该标记,整份覆盖。 """ from agent.config import get_settings from agent.llm.mode_sheet_map import ( @@ -130,11 +130,14 @@ def _process_quote_template_parse(payload: dict[str, Any], *, allow_network: boo result = invoke_sheet_map(payload, allow_network=net, timeout_seconds=240.0, max_rounds=3) merge_existing = bool(payload.get("mergeExisting") or payload.get("merge_existing")) existing = payload.get("existingMapping") or payload.get("existing_mapping") + inventory = _inventory_lines_from_payload(payload) body: dict[str, Any] if result.get("ok") and isinstance(result.get("mapping"), dict): mapping = dict(result["mapping"]) if merge_existing and isinstance(existing, dict): - mapping = merge_mapping_prefer_existing(existing, mapping) + mapping = merge_mapping_prefer_existing( + existing, mapping, cell_inventory=inventory + ) # 合并后再对照清单复评缺口,避免角标沿用合并前数字 remaining = assess_mapping_gaps(payload, mapping) mapping["mappingGaps"] = [str(g) for g in remaining if g] @@ -153,7 +156,7 @@ def _process_quote_template_parse(payload: dict[str, Any], *, allow_network: boo points = n_fields + n_fee + n_route parse_message = f"已解析 {points} 个映射点" if merge_existing: - parse_message += "(保留原映射并补缺)" + parse_message += "(对齐的保留,对不上的改用新坐标)" if mapping.get("mappingGaps"): preview = ";".join(mapping["mappingGaps"][:8]) more = len(mapping["mappingGaps"]) - 8 @@ -188,6 +191,13 @@ def _process_quote_template_parse(payload: dict[str, Any], *, allow_network: boo return {"ok": bool(body.get("ok")), "posted": posted, "kind": "quote_template_parse"} +def _inventory_lines_from_payload(payload: dict[str, Any]) -> list[str]: + """从解析入队载荷取出单元格清单行。""" + raw = payload.get("cellInventory") or payload.get("cell_inventory") or [] + if isinstance(raw, list): + return [str(x) for x in raw] + return [] + def _post_parse_result(body: dict[str, Any]) -> dict[str, Any]: """PUT /inquiry/agent/quoteTemplate/parseResult。""" try: diff --git a/inquiry-agent/agent/llm/mode_sheet_map.py b/inquiry-agent/agent/llm/mode_sheet_map.py index e936a81..51b650b 100644 --- a/inquiry-agent/agent/llm/mode_sheet_map.py +++ b/inquiry-agent/agent/llm/mode_sheet_map.py @@ -288,11 +288,14 @@ def assess_mapping_gaps(payload: dict[str, Any], mapping: dict[str, Any] | None) def merge_mapping_prefer_existing( old_mapping: dict[str, Any] | None, new_mapping: dict[str, Any] | None, + *, + cell_inventory: list[str] | None = None, ) -> dict[str, Any]: """ - 补缺合并:旧 fields / 区域优先,新结果只补充旧里没有的 code/id。 + 重新解析合并:费用/线路区按 id 旧优先;fields 对照单元格清单智能取舍。 - 用于「未换文件重新解析」:避免已对上的字段被新一轮 AI 冲掉。 + 规则:旧无→用新;新无→留旧;相同→留旧;旧坐标仍贴标签→留旧; + 旧坐标对不上(清单里能看到该标签)→改用新坐标。无清单时保守旧优先。 """ new_mapping = dict(new_mapping or {}) old_mapping = old_mapping if isinstance(old_mapping, dict) else {} @@ -300,11 +303,41 @@ def merge_mapping_prefer_existing( new_fields = new_mapping.get("fields") if isinstance(new_mapping.get("fields"), dict) else {} old_fields = old_mapping.get("fields") if isinstance(old_mapping.get("fields"), dict) else {} - fields: dict[str, Any] = dict(new_fields) - for code, cell in old_fields.items(): - if cell is not None and str(cell).strip(): - fields[str(code)] = cell + new_meta = new_mapping.get("fieldMeta") if isinstance(new_mapping.get("fieldMeta"), dict) else {} + old_meta = old_mapping.get("fieldMeta") if isinstance(old_mapping.get("fieldMeta"), dict) else {} + meta_out: dict[str, Any] = dict(new_meta) + + codes = set(str(k) for k in old_fields.keys()) | set(str(k) for k in new_fields.keys()) + fields: dict[str, Any] = {} + inv = list(cell_inventory or []) + for code in codes: + old_cell = str(old_fields.get(code) or "").strip() + new_cell = str(new_fields.get(code) or "").strip() + kept_old = False + if not old_cell: + chosen = new_cell + elif not new_cell: + chosen = old_cell + kept_old = True + elif old_cell.lower() == new_cell.lower(): + chosen = old_cell + kept_old = True + elif inv and _field_mapping_still_aligned(code, old_cell, inv): + chosen = old_cell + kept_old = True + elif inv: + chosen = new_cell + logger.info("映射坐标纠偏 code=%s old=%s -> new=%s", code, old_cell, new_cell) + else: + chosen = old_cell + kept_old = True + if chosen: + fields[code] = chosen + if kept_old and isinstance(old_meta.get(code), dict): + meta_out[code] = dict(old_meta[code]) out["fields"] = fields + if meta_out: + out["fieldMeta"] = meta_out out["feeRegions"] = _merge_regions_by_id( old_mapping.get("feeRegions"), new_mapping.get("feeRegions") @@ -315,6 +348,164 @@ def merge_mapping_prefer_existing( return out +# 与主账 FILL_BY_LABEL 对齐的常用码→标签(智能合并校验用) +_FIELD_LABELS: dict[str, tuple[str, ...]] = { + "quote_no": ("RFQ No", "报价单编号", "报价编号"), + "quote_date": ("询价日期", "报价日期", "Inquiry Date"), + "pol": ("起运港", "POL"), + "pod": ("目的港", "POD"), + "commodity": ("品名", "货物品名", "Commodity"), + "quantity": ("货物数量", "货量"), + "fcl_or_lcl": ("整柜或拼柜",), + "container_type_qty": ("箱型箱量",), + "valid_until": ("报价有效期", "有效期至"), + "total_amount": ("费用总计", "报价合计"), + "origin": ("始发地", "始发站", "起运地"), + "destination": ("目的地",), +} + + +def _parse_inventory_line(line: str) -> tuple[str, str, str] | None: + """地址 | 原文 | 合并 | 色 → (full_addr, text, merge)。""" + parts = [p.strip() for p in str(line or "").split("|")] + if len(parts) < 2: + return None + return parts[0], parts[1], parts[2] if len(parts) > 2 else "-" + + +def _col_index(letter: str) -> int: + n = 0 + for ch in (letter or "").upper(): + if "A" <= ch <= "Z": + n = n * 26 + (ord(ch) - ord("A") + 1) + return max(n - 1, 0) + + +def _split_addr(full: str) -> tuple[str, int, int] | None: + """Sheet!B3 → (sheet, row0, col0)。""" + text = (full or "").strip() + if "!" not in text: + return None + sheet, addr = text.split("!", 1) + m = re.match(r"^\$?([A-Za-z]+)\$?(\d+)$", addr.strip()) + if not m: + return None + return sheet, int(m.group(2)) - 1, _col_index(m.group(1)) + + +def _merge_end_col(merge: str, fallback_col: int) -> int: + """A3:C3 → 末列 index;无合并返回 fallback。""" + m = (merge or "").strip() + if not m or m == "-": + return fallback_col + # 可能是 A3:C3 或 Sheet!A3:C3 + if "!" in m: + m = m.split("!", 1)[1] + if ":" not in m: + return fallback_col + end = m.split(":", 1)[1].strip() + mm = re.match(r"^\$?([A-Za-z]+)\$?(\d+)$", end) + if not mm: + return fallback_col + return _col_index(mm.group(1)) + + +def _text_looks_like_label(text: str, labels: tuple[str, ...]) -> bool: + norm = (text or "").replace(":", ":").strip() + if not norm: + return False + primary = norm.split("/")[0].strip() + upper = norm.upper() + for lb in labels: + if not lb: + continue + if norm.lower().startswith(lb.lower()) or primary.lower().startswith(lb.lower()): + return True + if len(lb) >= 3 and lb.upper() in upper: + return True + return False + + +def _field_mapping_still_aligned(code: str, cell_ref: str, inventory: list[str]) -> bool: + """ + 旧坐标是否仍可贴标签。 + + quote_no:贴着 RFQ 的 B3 常是 Port to Port / 工作天单选,不算对齐。 + """ + labels = _FIELD_LABELS.get(str(code), ()) + if not labels: + return True + parsed = _split_addr(cell_ref) + if not parsed: + return False + sheet, row, col = parsed + rows: list[tuple[str, int, int, str, str]] = [] + mapped_text = "" + for line in inventory: + one = _parse_inventory_line(line) + if not one: + continue + addr, text, merge = one + sp = _split_addr(addr) + if not sp or sp[0] != sheet: + continue + rows.append((addr, sp[1], sp[2], text, merge)) + if sp[1] == row and sp[2] == col: + mapped_text = text + if _text_looks_like_label(text, labels): + return True + if _is_unsuitable_value_cell(mapped_text): + return False + if str(code) == "quote_no" and mapped_text.strip() and not _looks_like_quote_no(mapped_text): + return False + saw = False + for _addr, r, c, text, merge in rows: + if not _text_looks_like_label(text, labels): + continue + saw = True + if r == row and c == col: + return True + end_col = _merge_end_col(merge, c) + if r == row: + gap = col - end_col + if 1 <= gap <= 3 and (not mapped_text.strip() or _looks_like_quote_no(mapped_text)): + return True + if str(code) == "quote_no" and gap > 3 and ( + not mapped_text.strip() or _looks_like_quote_no(mapped_text) + ): + return True + return not saw + + +def _is_unsuitable_value_cell(text: str) -> bool: + t = (text or "").replace(":", ":").strip() + if not t: + return False + lower = t.lower() + if any(x in t for x in ("○", "●", "⊙", "□", "☑")): + return True + if "port to port" in lower or "door to door" in lower: + return True + if "工作天" in t or "工作日" in t or "回复" in t: + return True + if "其它" in t and ("天" in t or "回复" in t or "Port" in t): + return True + return False + + +def _looks_like_quote_no(text: str) -> bool: + t = (text or "").strip() + if not t or len(t) > 40: + return False + import re as _re + + if _re.match(r"(?i)WO\d{6,}", t) or _re.match(r"(?i)W0\d{6,}", t): + return True + if _re.match(r"(?i)RFQ[-_]?[A-Z0-9]+", t): + return True + return bool(_re.match(r"[A-Z]{1,5}[-_]?\d{8,}", t)) + + def _merge_regions_by_id(old_arr: Any, new_arr: Any) -> list[dict[str, Any]]: """区域按 id 合并:旧整段保留,新 id 追加。""" out: list[dict[str, Any]] = [] diff --git a/inquiry-agent/tests/test_sheet_map_mode.py b/inquiry-agent/tests/test_sheet_map_mode.py index 77d5242..f6c219a 100644 --- a/inquiry-agent/tests/test_sheet_map_mode.py +++ b/inquiry-agent/tests/test_sheet_map_mode.py @@ -179,15 +179,54 @@ class TestSheetMapParse(unittest.TestCase): "routeRegions": [], "mappingGaps": [], } + # 无清单:保守旧优先 merged = merge_mapping_prefer_existing(old, new) - self.assertEqual(merged["fields"]["quote_no"], "S!B3") # 旧优先 - self.assertEqual(merged["fields"]["pol"], "S!B5") # 旧保留 - self.assertEqual(merged["fields"]["pod"], "S!B6") # 新补缺 + self.assertEqual(merged["fields"]["quote_no"], "S!B3") + self.assertEqual(merged["fields"]["pol"], "S!B5") + self.assertEqual(merged["fields"]["pod"], "S!B6") ids = [r["id"] for r in merged["feeRegions"]] self.assertEqual(ids[0], "origin_charges") self.assertEqual(merged["feeRegions"][0]["title"], "旧始发港") self.assertIn("ocean_freight", ids) + def test_merge_replaces_misaligned_quote_no(self) -> None: + """旧 quote_no 落在单选 B3 → 改用新坐标 F3。""" + from agent.llm.mode_sheet_map import merge_mapping_prefer_existing + + inventory = [ + "S!A3 | RFQ No: | A3:A3 | -", + "S!B3 | ○ Port to Port / 其它,3工作天回复 | B3:E3 | -", + "S!F3 | | F3:G3 | -", + ] + old = {"fields": {"quote_no": "S!B3"}, "feeRegions": [], "routeRegions": []} + new = {"fields": {"quote_no": "S!F3"}, "feeRegions": [], "routeRegions": []} + merged = merge_mapping_prefer_existing(old, new, cell_inventory=inventory) + self.assertEqual(merged["fields"]["quote_no"], "S!F3") + + def test_merge_keeps_aligned_quote_no_when_ai_drifts(self) -> None: + """旧已在 RFQ 旁空格、新一轮漂到远处 → 仍保留旧坐标。""" + from agent.llm.mode_sheet_map import merge_mapping_prefer_existing + + inventory = [ + "S!A3 | RFQ No: | - | -", + "S!B3 | | - | -", + ] + old = {"fields": {"quote_no": "S!B3"}, "feeRegions": [], "routeRegions": []} + new = {"fields": {"quote_no": "S!Z99"}, "feeRegions": [], "routeRegions": []} + merged = merge_mapping_prefer_existing(old, new, cell_inventory=inventory) + self.assertEqual(merged["fields"]["quote_no"], "S!B3") + + def test_b3_radio_not_aligned(self) -> None: + from agent.llm.mode_sheet_map import _field_mapping_still_aligned + + inventory = [ + "S!A3 | RFQ No: | - | -", + "S!B3 | ○ Port to Port | - | -", + "S!F3 | WO202609190002 | - | -", + ] + self.assertFalse(_field_mapping_still_aligned("quote_no", "S!B3", inventory)) + self.assertTrue(_field_mapping_still_aligned("quote_no", "S!F3", inventory)) + def test_invoke_retries_when_gaps(self) -> None: calls: list[str] = [] diff --git a/inquiry-api/jeecg-boot-base-core/src/main/java/org/jeecg/common/system/vo/LoginUser.java b/inquiry-api/jeecg-boot-base-core/src/main/java/org/jeecg/common/system/vo/LoginUser.java index 21f2804..f2c82de 100644 --- a/inquiry-api/jeecg-boot-base-core/src/main/java/org/jeecg/common/system/vo/LoginUser.java +++ b/inquiry-api/jeecg-boot-base-core/src/main/java/org/jeecg/common/system/vo/LoginUser.java @@ -138,4 +138,11 @@ public class LoginUser { */ private Boolean dashboardSelfOnly; + /** + * 最后登录时间(账号列表展示;登录成功写入)。 + */ + @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss") + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date lastLoginTime; + } diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java index 928a67f..4391624 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java @@ -846,6 +846,13 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl e : QuoteTemplateExcelSupport.LAND_TMS_KEY_TO_CODE.entrySet()) { String val = first(q, e.getKey(), e.getValue()); @@ -892,9 +899,102 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl - * mergeExisting=true 时:与库中旧 mapping 补缺合并(旧字段/旧区域优先,新结果只补没有的)。 + * mergeExisting=true 时:与库中旧 mapping 智能合并(对齐标签的旧坐标保留,对不上的改用新坐标)。 */ @Override @Transactional(rollbackFor = Exception.class) @@ -1054,13 +1154,34 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl 0) { @@ -1103,10 +1224,23 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl + * 规则:旧无→用新;新无→留旧;两者相同→留旧;旧坐标仍贴标签→留旧; + * 旧坐标对不上标签(有 Workbook 可校验)→改用新坐标。 + * mappingGaps 以新结果为准。 + * + * @param workbook 可空;有则校验旧坐标是否仍对齐标签 + */ + public static JSONObject mergeMappingPreferExisting(JSONObject oldMapping, JSONObject newMapping, + org.apache.poi.ss.usermodel.Workbook workbook) { JSONObject out = new JSONObject(true); if (newMapping != null) { out.putAll(newMapping); @@ -1114,29 +1248,99 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl codes = new LinkedHashSet<>(); if (oldFields != null) { - for (String code : oldFields.keySet()) { - Object cell = oldFields.get(code); - if (cell != null && StringUtils.isNotBlank(String.valueOf(cell))) { - fields.put(code, cell); + codes.addAll(oldFields.keySet()); + } + if (newFields != null) { + codes.addAll(newFields.keySet()); + } + for (String code : codes) { + String oldCell = oldFields == null ? null : StringUtils.trimToNull(oldFields.getString(code)); + String newCell = newFields == null ? null : StringUtils.trimToNull(newFields.getString(code)); + String chosen; + boolean keptOld; + if (StringUtils.isBlank(oldCell)) { + chosen = newCell; + keptOld = false; + } else if (StringUtils.isBlank(newCell)) { + chosen = oldCell; + keptOld = true; + } else if (oldCell.equalsIgnoreCase(newCell)) { + chosen = oldCell; + keptOld = true; + // 新旧都是 B3 单选时仍要纠到 RFQ 标签格 + if ("quote_no".equals(code) && workbook != null + && !QuoteTemplateExcelSupport.fieldMappingStillAligned(workbook, code, oldCell)) { + String better = QuoteTemplateExcelSupport.relocateQuoteNoCell(workbook, oldCell); + if (StringUtils.isNotBlank(better) && !better.equalsIgnoreCase(oldCell)) { + chosen = better; + keptOld = false; + JSONObject qm = metaOut.getJSONObject("quote_no"); + if (qm == null) { + qm = new JSONObject(true); + } + qm.put("sameCellAfterColon", true); + metaOut.put("quote_no", qm); + log.info("映射坐标纠偏 code={} old={} -> new={}", code, oldCell, chosen); + } } + } else if (workbook != null + && QuoteTemplateExcelSupport.fieldMappingStillAligned(workbook, code, oldCell)) { + chosen = oldCell; + keptOld = true; + } else if (workbook != null) { + // 旧坐标对不上标签 → 用新;quote_no 若仍落在单选格则再纠到标签格冒号后 + chosen = newCell; + keptOld = false; + if ("quote_no".equals(code)) { + String better = QuoteTemplateExcelSupport.relocateQuoteNoCell(workbook, newCell); + if (StringUtils.isNotBlank(better)) { + chosen = better; + } + // 纠到标签格后,出单必须同格冒号后写 + JSONObject qm = metaOut.getJSONObject("quote_no"); + if (qm == null) { + qm = new JSONObject(true); + } + qm.put("sameCellAfterColon", true); + metaOut.put("quote_no", qm); + } + log.info("映射坐标纠偏 code={} old={} -> new={}", code, oldCell, chosen); + } else { + // 无表可校验时保守保留旧坐标 + chosen = oldCell; + keptOld = true; + } + if (StringUtils.isNotBlank(chosen)) { + fields.put(code, chosen); + } + if (keptOld && oldMeta != null && oldMeta.getJSONObject(code) != null) { + metaOut.put(code, oldMeta.getJSONObject(code)); } } out.put("fields", fields); + if (!metaOut.isEmpty()) { + out.put("fieldMeta", metaOut); + } out.put("feeRegions", mergeRegionsById( oldMapping.getJSONArray("feeRegions"), newMapping == null ? null : newMapping.getJSONArray("feeRegions"))); out.put("routeRegions", mergeRegionsById( oldMapping.getJSONArray("routeRegions"), newMapping == null ? null : newMapping.getJSONArray("routeRegions"))); - // 说明性标签:齐全时清空;否则保留新的 if (out.getJSONArray("mappingGaps") != null && out.getJSONArray("mappingGaps").isEmpty()) { out.put("unmappedRequired", new JSONArray()); out.put("unmappedLabels", new JSONArray()); diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/staff/LandStaffMatcher.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/staff/LandStaffMatcher.java index 8f830a5..d670d4b 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/staff/LandStaffMatcher.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/staff/LandStaffMatcher.java @@ -12,7 +12,7 @@ import java.util.Map; * 陆运拉群人员匹配:只看员工管理里岗位=产品·陆运且启用的人。 *

* 用本单线路类别对负责线路:按逗号/顿号拆整词,禁止把「中港/中亚/中欧」按斜杠切开。 - * 没有「陆运全线」快捷项;四条都要跟就须后台四条都勾。 + * 负责线路含「陆运全线」时视为命中全部线路类别(与海运「海运全线」对称)。 * 禁止:按销售 userid 伪造名单;不查企微通讯录。 */ public final class LandStaffMatcher { @@ -47,7 +47,9 @@ public final class LandStaffMatcher { if (StringUtils.isBlank(row.getWecomId())) { continue; } - if (!routeTokens(row.getRoutes()).contains(want)) { + List tokens = routeTokens(row.getRoutes()); + // 陆运全线:跟海运全线一样,一条勾选覆盖全部线路类别 + if (!tokens.contains("陆运全线") && !tokens.contains(want)) { continue; } Map item = new LinkedHashMap<>(); diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateExcelSupport.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateExcelSupport.java index d82e021..d7947d4 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateExcelSupport.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateExcelSupport.java @@ -542,11 +542,13 @@ public final class QuoteTemplateExcelSupport { JSONObject fieldMeta = mapping.getJSONObject("fieldMeta"); JSONObject fields = mapping.getJSONObject("fields"); if (fields != null) { + // 陆运拼车等模板常把「报价日期」与「有效期」误绑同一格(如 H5「报价日期:」)。 + // 同格时只写出报价日期,避免 TMS valid_until 盖掉日期标签含义。 + String quoteDateRef = StringUtils.trimToEmpty(fields.getString("quote_date")); + String validUntilRef = StringUtils.trimToEmpty(fields.getString("valid_until")); + boolean datesShareCell = StringUtils.isNotBlank(quoteDateRef) && quoteDateRef.equals(validUntilRef); for (String code : fields.keySet()) { String cellRef = fields.getString(code); - if (StringUtils.isBlank(cellRef) || yellow.contains(cellRef)) { - continue; - } Object val = facts.get(code); if ("fee_rows".equals(code)) { continue; @@ -554,10 +556,23 @@ public final class QuoteTemplateExcelSupport { if (val == null || StringUtils.isBlank(String.valueOf(val))) { continue; } + // quote_no:B3 已有单选等内容时,写回 RFQ 标签格冒号后(如 A3「RFQ No:WO…」) + // 黄区/固定区对「同格冒号后」放行,避免标签格被跳过 + if ("quote_no".equals(code)) { + writeQuoteNo(wb, cellRef, String.valueOf(val), yellow); + continue; + } + if ("valid_until".equals(code) && datesShareCell) { + continue; + } boolean afterColon = false; if (fieldMeta != null && fieldMeta.getJSONObject(code) != null) { afterColon = Boolean.TRUE.equals(fieldMeta.getJSONObject(code).getBoolean("sameCellAfterColon")); } + // 同格冒号后(报价日期等)允许写固定区:模板把标签格标黄,但仍要回填冒号后的值 + if (StringUtils.isBlank(cellRef) || (yellow.contains(cellRef) && !afterColon)) { + continue; + } if (afterColon) { writeCellAfterColon(wb, cellRef, String.valueOf(val)); } else { @@ -570,8 +585,10 @@ public final class QuoteTemplateExcelSupport { writeFeeRegions(wb, mapping, facts, yellow); writeRouteRegions(wb, mapping, facts, yellow); writeFeeRows(wb, mapping, facts, yellow); - // 后台常未配 fee_rows:按「空运费」等名称写入金额列,SUM 才能加算 - writeFeeAmountsByName(wb, facts, yellow); + // 已按 feeRegions 分区写入时,禁止再按费目名全表兜底,否则海运后段会错行/错列 + if (!hasFeeRegions(mapping)) { + writeFeeAmountsByName(wb, facts, yellow); + } // 模板「预估总价」是 SUM。POI 写完数字不会重算,缓存仍是 0。 // 企微/WPS 预览常不重算,销售会看到汇总为 0。这里把公式缓存刷成当前值。 refreshFormulas(wb); @@ -637,9 +654,66 @@ public final class QuoteTemplateExcelSupport { } } + /** + * 工单号回填规则(产品口径): + *

    + *
  • 映射格已是「RFQ No:」类标签 → 同格冒号后写编号
  • + *
  • 映射格(常 B3)已有单选/其它内容 → 不覆盖,改到标签格冒号后(如 A3「RFQ No:WO…」)
  • + *
  • 映射格空白 → 直接写入该格
  • + *
+ * 黄区对「标签冒号后」放行,否则 A3 固定区会把编号挡掉。 + */ + static void writeQuoteNo(Workbook wb, String mappedRef, String value, Set yellow) { + if (wb == null || StringUtils.isBlank(value)) { + return; + } + String target = relocateQuoteNoCell(wb, mappedRef); + if (StringUtils.isBlank(target)) { + target = mappedRef; + } + ParsedRef ref; + try { + ref = ParsedRef.parse(target.trim()); + } catch (Exception e) { + return; + } + Sheet sheet = wb.getSheet(ref.sheet); + if (sheet == null && wb.getNumberOfSheets() > 0) { + sheet = wb.getSheetAt(0); + } + if (sheet == null) { + return; + } + DataFormatter fmt = new DataFormatter(); + Row row = sheet.getRow(ref.row); + Cell cell = row == null ? null : row.getCell(ref.col); + String text = StringUtils.trimToEmpty(fmt.formatCellValue(cell)); + boolean labelCell = textLooksLikeFieldLabel(text, labelsForFieldCode("quote_no")) + || text.toUpperCase(Locale.ROOT).contains("RFQ"); + boolean afterColon = labelCell || text.contains(":") || text.contains(":"); + // 黄区:只允许冒号后改标签,禁止整格覆盖固定版式 + if (yellow != null && yellow.contains(target) && !afterColon) { + String labelRef = findQuoteNoLabelCell(sheet); + if (StringUtils.isNotBlank(labelRef)) { + writeCellAfterColon(wb, labelRef, value); + } + return; + } + if (afterColon) { + writeCellAfterColon(wb, target, value); + } else { + writeCell(wb, target, value); + } + } + /** * 按 AI feeRegions 写入费用行;支持 insert_before_stop 增行。 * facts 可带 fee_rows / fee_rows_{tmsSource} / fee_rows_{regionId}。 + *

+ * 为何这样写: + * 1) 每段用标题/列头现场定位数据起点,不靠「原锚点+累计偏移」,避免写进列头; + * 2) 产品口径:单元格标黄 = 固定占位、出单不赋值。写入时同时看 mapping 黄名单与格子真实底色 + * (不用空黄名单绕过;增行后以现场底色为准,避免旧坐标误伤,也避免陆运项目二黄区被改写)。 */ private static void writeFeeRegions(Workbook wb, JSONObject mapping, JSONObject facts, Set yellow) { JSONArray regions = mapping.getJSONArray("feeRegions"); @@ -665,16 +739,35 @@ public final class QuoteTemplateExcelSupport { if (sheet == null) { sheet = wb.getSheetAt(0); } - int startRow = anchorRef.row; + int startRow = resolveFeeDataStartRow(sheet, region, anchorRef.row); + // 数据起点整行已标黄:本段是模板固定占位(如陆运「项目二」),整段跳过 + if (rowLooksYellowFixed(sheet, startRow)) { + log.info("费用区起点为黄底固定区,跳过赋值 region={} startRow={}", region.getString("id"), startRow); + continue; + } int stopRow = findStopRow(sheet, region.getJSONArray("stopAtText"), startRow); int styleCopy = region.getIntValue("styleCopyRows"); if (styleCopy <= 0) { styleCopy = 1; } ensureRowsBeforeStop(sheet, startRow, rows.size(), stopRow, styleCopy); + // 增行后 stop 下移,再取一次,保证写入不越过汇总/下段标题 + stopRow = findStopRow(sheet, region.getJSONArray("stopAtText"), startRow); + // 海运:表头「箱型(动态填充箱型)」换成真实箱型(如 20GP) + if (Boolean.TRUE.equals(region.getBoolean("dynamicContainerHeader"))) { + applyDynamicContainerHeader(sheet, region, columns, facts, yellow); + } for (int r = 0; r < rows.size(); r++) { - JSONObject rowObj = rows.getJSONObject(r); int rowIdx = startRow + r; + if (rowIdx >= stopRow) { + log.warn("费用区行数仍越过 stop,截断 region={} wrote={}/{}", region.getString("id"), r, rows.size()); + break; + } + // 单行黄底:保留模板占位文案,不覆盖 + if (rowLooksYellowFixed(sheet, rowIdx)) { + continue; + } + JSONObject rowObj = rows.getJSONObject(r); writeFeeCol(sheet, rowIdx, columns.getString("name"), rowObj.getString("name"), yellow, sheet.getSheetName()); writeFeeCol(sheet, rowIdx, columns.getString("unit"), rowObj.getString("unit"), yellow, sheet.getSheetName()); writeFeeCol(sheet, rowIdx, columns.getString("quantity"), rowObj.getString("quantity"), yellow, sheet.getSheetName()); @@ -690,15 +783,214 @@ public final class QuoteTemplateExcelSupport { formatFeeRemark(rowObj.getString("remark")), yellow, sheet.getSheetName()); } if (columns.containsKey("container_rate")) { - writeFeeCol(sheet, rowIdx, columns.getString("container_rate"), - firstNonBlank(rowObj.getString("container_rate"), rowObj.getString("containerType"), - rowObj.getString("unit_price")), - yellow, sheet.getSheetName()); + // 数据行只要箱型码「20GP」,不要括号;括号只用于表头占位替换 + String boxCode = firstNonBlank(rowObj.getString("containerType"), rowObj.getString("container_type")); + String box = primaryContainerTypeFromRaw(boxCode); + if (StringUtils.isBlank(box)) { + box = primaryContainerType(facts); + } + if (StringUtils.isNotBlank(box)) { + writeFeeCol(sheet, rowIdx, columns.getString("container_rate"), box, yellow, sheet.getSheetName()); + } else { + String rate = firstNonBlank(rowObj.getString("unit_price"), rowObj.getString("unitPrice"), + rowObj.getString("container_rate")); + if (StringUtils.isNotBlank(rate) && !rate.matches("^[0-9]+(\\.[0-9]+)?$")) { + String fromRate = primaryContainerTypeFromRaw(rate); + if (StringUtils.isNotBlank(fromRate)) { + writeFeeCol(sheet, rowIdx, columns.getString("container_rate"), fromRate, + yellow, sheet.getSheetName()); + } + } + } } } } } + /** + * 把费用区表头「箱型(动态填充箱型)」替换为真实箱型码(20GP / 40HQ…)。 + */ + static void applyDynamicContainerHeader(Sheet sheet, JSONObject region, JSONObject columns, + JSONObject facts, Set yellow) { + if (sheet == null || columns == null) { + return; + } + String colLetter = columns.getString("container_rate"); + if (StringUtils.isBlank(colLetter)) { + return; + } + String box = primaryContainerType(facts); + if (StringUtils.isBlank(box)) { + return; + } + int headerRow1 = region.getIntValue("headerRow"); + int header0 = headerRow1 > 0 ? headerRow1 - 1 : -1; + if (header0 < 0) { + // 回退:数据起点上一行 + int start = resolveFeeDataStartRow(sheet, region, 0); + header0 = Math.max(0, start - 1); + } + int col = CellReference.convertColStringToIndex(colLetter.trim()); + Row row = sheet.getRow(header0); + if (row == null) { + row = sheet.createRow(header0); + } + Cell cell = row.getCell(col); + if (cell == null) { + cell = row.createCell(col); + } + String text = readCellText(cell); + String ref = sheet.getSheetName() + "!" + new CellReference(header0, col).formatAsString(); + // 占位或空:写成箱型;已是 20GP 等则保留 + if (StringUtils.isBlank(text) || text.contains("动态填充") || text.contains("动态箱型") + || "箱型".equals(feePrimaryName(text))) { + if (yellow != null && yellow.contains(ref) && !text.contains("动态填充") && !text.contains("动态箱型")) { + return; + } + // 与模板占位「(动态填充箱型)」同形:写成「箱型\n(20GP)」 + if (text.contains("箱型") && !isContainerTypeHeader(text)) { + cell.setCellValue("箱型\n(" + box + ")"); + } else { + cell.setCellValue("(" + box + ")"); + } + } + } + + /** 箱型展示:统一全角括号,与模板「(动态填充箱型)」一致。 */ + static String formatContainerTypeDisplay(String box) { + if (StringUtils.isBlank(box)) { + return ""; + } + String t = box.trim(); + if ((t.startsWith("(") && t.endsWith(")")) || (t.startsWith("(") && t.endsWith(")"))) { + return t; + } + return "(" + t + ")"; + } + + /** + * 从 container_type_qty / 箱型箱量 抽出主箱型码:20GP*1 → 20GP;1x40HQ → 40HQ。 + */ + static String primaryContainerType(JSONObject facts) { + if (facts == null) { + return ""; + } + String raw = firstNonBlank(facts.getString("container_type_qty"), facts.getString("箱型箱量"), + facts.getString("containerType")); + return primaryContainerTypeFromRaw(raw); + } + + static String primaryContainerTypeFromRaw(String raw) { + if (StringUtils.isBlank(raw)) { + return ""; + } + String u = raw.trim().toUpperCase(Locale.ROOT).replace(" ", "") + .replace("(", "").replace(")", "").replace("(", "").replace(")", ""); + String[] keys = {"45HQ", "40HQ", "40HC", "40GP", "40NOR", "20HQ", "20GP"}; + for (String k : keys) { + if (u.contains(k)) { + return k; + } + } + java.util.regex.Matcher m = java.util.regex.Pattern.compile("([0-9]{2}[A-Z]{2,3})").matcher(u); + if (m.find()) { + return m.group(1); + } + return ""; + } + + /** + * 行内任一单元格为黄底,即视为黄区固定行(产品:标黄不赋值)。 + */ + static boolean rowLooksYellowFixed(Sheet sheet, int rowIdx) { + if (sheet == null || rowIdx < 0) { + return false; + } + Row row = sheet.getRow(rowIdx); + if (row == null) { + return false; + } + for (Cell cell : row) { + if (cell == null) { + continue; + } + if (isYellowFixed(cell)) { + return true; + } + // 合并区:取左上角样式 + int[] origin = resolveMergeOrigin(sheet, cell.getRowIndex(), cell.getColumnIndex()); + if (origin[0] != cell.getRowIndex() || origin[1] != cell.getColumnIndex()) { + Row or = sheet.getRow(origin[0]); + Cell oc = or == null ? null : or.getCell(origin[1]); + if (oc != null && isYellowFixed(oc)) { + return true; + } + } + } + return false; + } + + /** + * 费用段数据起点:优先「段标题下一行列头」之下;找不到再用 mapping.anchor。 + */ + static int resolveFeeDataStartRow(Sheet sheet, JSONObject region, int anchorFallback) { + if (sheet == null) { + return anchorFallback; + } + String title = StringUtils.trimToEmpty(region.getString("title")); + int titleRow = -1; + if (StringUtils.isNotBlank(title)) { + String primary = title; + int slash = title.indexOf('/'); + if (slash > 0) { + primary = title.substring(0, slash).trim(); + } + titleRow = findRowContainingText(sheet, primary, 0); + if (titleRow < 0 && !primary.equals(title)) { + titleRow = findRowContainingText(sheet, title, 0); + } + } + if (titleRow < 0) { + return Math.max(0, anchorFallback); + } + for (int r = titleRow + 1; r <= titleRow + 6 && r <= sheet.getLastRowNum() + 2; r++) { + Row row = sheet.getRow(r); + if (row == null) { + continue; + } + String head = StringUtils.trimToEmpty(readCellText(row.getCell(0))); + if (head.contains("项目") || head.toLowerCase(Locale.ROOT).contains("items")) { + return r + 1; + } + } + return titleRow + 2; + } + + /** 从 fromRow 起找整行文本包含 needle 的行(0-based)。 */ + static int findRowContainingText(Sheet sheet, String needle, int fromRow) { + if (sheet == null || StringUtils.isBlank(needle)) { + return -1; + } + DataFormatter fmt = new DataFormatter(); + int last = sheet.getLastRowNum(); + for (int r = Math.max(0, fromRow); r <= last; r++) { + Row row = sheet.getRow(r); + if (row == null) { + continue; + } + for (Cell cell : row) { + if (cell == null) { + continue; + } + String text = StringUtils.trimToEmpty(fmt.formatCellValue(cell)); + if (text.contains(needle)) { + return r; + } + } + } + return -1; + } + /** * 陆运 routeRegions:按 origin/destination/vehicleRates 等列写入。 */ @@ -751,6 +1043,8 @@ public final class QuoteTemplateExcelSupport { if (sheet == null) { sheet = wb.getSheetAt(0); } + // 中港等模板:dataStartRow 常误指「4.2米/3T」车型表头行,下移到真正数据行 + rowIdx0 = resolveRouteDataStartRow(sheet, rowIdx0, region.getIntValue("headerRow")); int stopRow = findStopRow(sheet, region.getJSONArray("stopAtText"), rowIdx0); int styleCopy = region.getIntValue("styleCopyRows"); if (styleCopy <= 0) { @@ -772,17 +1066,27 @@ public final class QuoteTemplateExcelSupport { firstNonBlank(rowObj.getString("transit_time"), rowObj.getString("transitDays")), yellow, sheet.getSheetName()); writeFeeCol(sheet, ri, columns.getString("remark"), rowObj.getString("remark"), yellow, sheet.getSheetName()); + if (columns.containsKey("tax_rate")) { + writeFeeCol(sheet, ri, columns.getString("tax_rate"), + firstNonBlank(rowObj.getString("tax_rate"), rowObj.getString("taxRate")), + yellow, sheet.getSheetName()); + } + // 小计/金额列(中港 K 列等) + if (columns.containsKey("amount")) { + writeFeeCol(sheet, ri, columns.getString("amount"), + firstNonBlank(rowObj.getString("amount"), rowObj.getString("salePrice"), + rowObj.getString("costPrice")), + yellow, sheet.getSheetName()); + } JSONObject vehicleRates = columns.getJSONObject("vehicleRates"); if (vehicleRates != null) { - String vehicleType = firstNonBlank(rowObj.getString("vehicleType"), rowObj.getString("vehicle_type")); - String price = firstNonBlank(rowObj.getString("salePrice"), rowObj.getString("costPrice"), - rowObj.getString("amount")); for (String vk : vehicleRates.keySet()) { String col = vehicleRates.getString(vk); if (StringUtils.isBlank(col)) { continue; } - if (StringUtils.isBlank(vehicleType) || vehicleTypeEquals(vehicleType, vk)) { + String price = resolveRouteVehicleRate(rowObj, facts, vk, vehicleRates.size()); + if (StringUtils.isNotBlank(price) && !"-".equals(price)) { writeFeeCol(sheet, ri, col, price, yellow, sheet.getSheetName()); } } @@ -791,6 +1095,116 @@ public final class QuoteTemplateExcelSupport { } } + /** + * 线路数据起点:若 mapping 的 dataStartRow 落在「4.2米/3T」类车型表头,下移一行。 + */ + static int resolveRouteDataStartRow(Sheet sheet, int rowIdx0, int headerRow1Based) { + if (sheet == null || rowIdx0 < 0) { + return rowIdx0; + } + if (rowLooksLikeVehicleHeader(sheet, rowIdx0)) { + return rowIdx0 + 1; + } + // headerRow 下一行若是车型副表头,再下一行才是数据 + int header0 = headerRow1Based > 0 ? headerRow1Based - 1 : -1; + if (header0 >= 0 && rowIdx0 == header0 + 1 && rowLooksLikeVehicleHeader(sheet, rowIdx0)) { + return rowIdx0 + 1; + } + return rowIdx0; + } + + /** 行内出现「米/」「尺」「3T」「5T」等车型表头文案。 */ + static boolean rowLooksLikeVehicleHeader(Sheet sheet, int rowIdx) { + if (sheet == null || rowIdx < 0) { + return false; + } + Row row = sheet.getRow(rowIdx); + if (row == null) { + return false; + } + DataFormatter fmt = new DataFormatter(); + int hits = 0; + for (Cell cell : row) { + if (cell == null) { + continue; + } + String text = StringUtils.trimToEmpty(fmt.formatCellValue(cell)); + if (text.isEmpty()) { + continue; + } + String u = text.toUpperCase(Locale.ROOT); + if (text.contains("米/") || text.contains("尺") || u.matches(".*\\d+(\\.\\d+)?T.*") + || u.contains("40HQ") || u.contains("45HQ") || text.contains("车型")) { + hits++; + } + } + return hits >= 2; + } + + /** + * 线路矩阵金额列:优先费种列(提货费/运输费/元/CBM),再按车型列匹配 salePrice。 + * 禁止「无车型时把同一售价灌进所有费种列」。 + */ + static String resolveRouteVehicleRate(JSONObject rowObj, JSONObject facts, String headerKey, int headerCount) { + if (StringUtils.isBlank(headerKey)) { + return ""; + } + String key = headerKey.trim(); + // 1) 费种 / 单价列名 → 行内或 facts 分项 + String code = landFactCodeForFeeName(key); + if (StringUtils.isNotBlank(code)) { + String fromRow = firstNonBlank( + rowObj == null ? null : rowObj.getString(code), + rowObj == null ? null : rowObj.getString(headerKey)); + if (StringUtils.isNotBlank(fromRow)) { + return fromRow; + } + if (facts != null && StringUtils.isNotBlank(facts.getString(code))) { + return facts.getString(code); + } + } + if (key.contains("提货")) { + return firstNonBlank(rowObj == null ? null : rowObj.getString("pickup_fee"), + rowObj == null ? null : rowObj.getString("pickupFee"), + facts == null ? null : facts.getString("pickup_fee")); + } + if (key.contains("送货") || key.contains("派送")) { + return firstNonBlank(rowObj == null ? null : rowObj.getString("delivery_fee"), + rowObj == null ? null : rowObj.getString("deliveryFee"), + facts == null ? null : facts.getString("delivery_fee")); + } + if (key.toUpperCase(Locale.ROOT).contains("CBM")) { + return firstNonBlank(rowObj == null ? null : rowObj.getString("price_per_cbm"), + rowObj == null ? null : rowObj.getString("pricePerCbm"), + facts == null ? null : facts.getString("price_per_cbm")); + } + if (key.toUpperCase(Locale.ROOT).contains("KG") || key.contains("公斤")) { + return firstNonBlank(rowObj == null ? null : rowObj.getString("price_per_kg"), + rowObj == null ? null : rowObj.getString("pricePerKg"), + facts == null ? null : facts.getString("price_per_kg")); + } + if (key.contains("运输") || key.contains("运费") || key.contains("陆运")) { + return firstNonBlank(rowObj == null ? null : rowObj.getString("salePrice"), + rowObj == null ? null : rowObj.getString("amount"), + rowObj == null ? null : rowObj.getString("land_freight"), + facts == null ? null : facts.getString("land_freight")); + } + // 2) 车型列:仅当 vehicleType 对得上才写售价 + String vehicleType = firstNonBlank(rowObj == null ? null : rowObj.getString("vehicleType"), + rowObj == null ? null : rowObj.getString("vehicle_type")); + String price = firstNonBlank(rowObj == null ? null : rowObj.getString("salePrice"), + rowObj == null ? null : rowObj.getString("costPrice"), + rowObj == null ? null : rowObj.getString("amount")); + if (StringUtils.isNotBlank(vehicleType) && vehicleTypeEquals(vehicleType, key)) { + return price; + } + // 3) 仅一列且无车型信息时,允许写入该列(整车单列模板) + if (headerCount == 1 && StringUtils.isBlank(vehicleType)) { + return price; + } + return ""; + } + private static boolean vehicleTypeEquals(String actual, String headerKey) { if (StringUtils.isBlank(actual) || StringUtils.isBlank(headerKey)) { return false; @@ -813,12 +1227,63 @@ public final class QuoteTemplateExcelSupport { rows = parseFeeRows(facts.get(tms)); } } - if (rows.isEmpty()) { + // 海运三段桶:禁止整表 fee_rows 灌入(否则三段重复、增行冲列头) + boolean seaBucket = "polCostItems".equals(tms) || "oceanCostItems".equals(tms) + || "podCostItems".equals(tms); + if (rows.isEmpty() && seaBucket) { + rows = partitionFlatFeeRows(parseFeeRows(facts.get("fee_rows")), tms); + } + // 空运 mixed / 单区 feeRegions / 无 tmsSource:用扁平 fee_rows(TMS fee_lines 投影) + if (rows.isEmpty() && !seaBucket) { rows = parseFeeRows(facts.get("fee_rows")); } return rows; } + /** + * 仅有扁平 fee_rows 时按费目名粗分到 pol/ocean/pod,避免三段全空。 + */ + static JSONArray partitionFlatFeeRows(JSONArray all, String tmsSource) { + JSONArray out = new JSONArray(); + if (all == null || all.isEmpty() || StringUtils.isBlank(tmsSource)) { + return out; + } + String tms = tmsSource.trim(); + for (int i = 0; i < all.size(); i++) { + JSONObject row = all.getJSONObject(i); + if (row == null) { + continue; + } + String name = StringUtils.defaultString(row.getString("name")); + String bucket = guessSeaFeeBucket(name); + if (tms.equalsIgnoreCase(bucket) || tms.equalsIgnoreCase("fee_rows_" + bucket) + || ("polCostItems".equals(tms) && "polCostItems".equals(bucket)) + || ("oceanCostItems".equals(tms) && "oceanCostItems".equals(bucket)) + || ("podCostItems".equals(tms) && "podCostItems".equals(bucket))) { + out.add(row); + } + } + return out; + } + + /** 扁平费目名 → 海运三段桶。 */ + static String guessSeaFeeBucket(String feeName) { + String n = StringUtils.defaultString(feeName).toLowerCase(Locale.ROOT); + if (n.contains("海运") || n.contains("ocean") || n.contains("燃油") || n.contains("baf") + || n.contains("bunker") || n.contains("of/") || n.equals("of")) { + return "oceanCostItems"; + } + if (n.contains("目的港") || n.contains("pod") || n.contains("destination") + || n.contains("dest ")) { + return "podCostItems"; + } + // 文件费/设备费等常见在目的港段;操作/封条/报关/舱单常见始发港 + if (n.contains("文件") || n.contains("doc") || n.contains("设备")) { + return "podCostItems"; + } + return "polCostItems"; + } + private static int findStopRow(Sheet sheet, JSONArray stopAtText, int fromRow) { if (sheet == null || stopAtText == null || stopAtText.isEmpty()) { return Integer.MAX_VALUE; @@ -860,19 +1325,21 @@ public final class QuoteTemplateExcelSupport { /** * 需要的行数若会撞到 stop 行,则在 stop 前插入并复制样式行。 + * + * @return 实际插入行数(供后续 feeRegion 锚点累计偏移) */ - private static void ensureRowsBeforeStop(Sheet sheet, int startRow, int needCount, int stopRow, int styleCopyRows) { + private static int ensureRowsBeforeStop(Sheet sheet, int startRow, int needCount, int stopRow, int styleCopyRows) { if (sheet == null || needCount <= 0) { - return; + return 0; } int endNeeded = startRow + needCount; if (stopRow == Integer.MAX_VALUE || endNeeded <= stopRow) { - return; + return 0; } int insertAt = Math.max(startRow, stopRow); int insertCount = endNeeded - stopRow; if (insertCount <= 0) { - return; + return 0; } sheet.shiftRows(insertAt, Math.max(sheet.getLastRowNum(), insertAt), insertCount, true, false); int styleFrom = Math.max(0, startRow); @@ -901,6 +1368,7 @@ public final class QuoteTemplateExcelSupport { } } } + return insertCount; } private static String firstNonBlank(String... vals) { @@ -958,7 +1426,7 @@ public final class QuoteTemplateExcelSupport { } int col = CellReference.convertColStringToIndex(colLetter.trim()); String ref = sheetName + "!" + new CellReference(rowIdx, col).formatAsString(); - if (yellow.contains(ref)) { + if (yellow != null && yellow.contains(ref)) { return; } Row row = sheet.getRow(rowIdx); @@ -969,6 +1437,10 @@ public final class QuoteTemplateExcelSupport { if (cell == null) { cell = row.createCell(col); } + // 产品:格子标黄 = 固定占位,不赋值(含费用列) + if (isYellowFixed(cell)) { + return; + } writeNumericOrText(cell, value); // 映射表写出的备注同样要换行,否则只看得见半截 applyRemarkWrap(sheet, row, cell, value); @@ -1525,19 +1997,36 @@ public final class QuoteTemplateExcelSupport { } /** - * 海运备注常是「计价单位:柜;加点系数:0.0000000」。 - * 模板备注列很窄(约 13 个英文字宽),整句挤一行只能看见半截; - * 按分号拆成两行,销售一眼能看完两段。 + * 海运备注常夹带内部计价元数据「计价单位:柜;加点系数:0.000000」。 + * 销售报价单备注列不展示这些;若去掉后还有业务说明则保留,否则整格留空。 */ static String formatFeeRemark(String raw) { if (StringUtils.isBlank(raw)) { - return raw; + return ""; } String text = raw.trim(); - if (!text.contains("计价") && !text.contains("加点") && !text.contains("计费")) { - return text; + // 去掉加点/计价单位/计费单位类内部字段(中英文分号、换行都认) + String cleaned = text + .replaceAll("(?i)计价单位\\s*[::][^;;\\n]*", "") + .replaceAll("(?i)计费单位\\s*[::][^;;\\n]*", "") + .replaceAll("(?i)加点系数\\s*[::][^;;\\n]*", "") + .replaceAll("(?i)markup\\s*[::=]?[^;;\\n]*", "") + .replace(';', '\n') + .replace(';', '\n') + .replaceAll("[\n\\s]+", "\n") + .trim(); + if (StringUtils.isBlank(cleaned) || cleaned.matches("[::\\-_/\\\\.\\s]+")) { + return ""; } - return text.replace(';', '\n').replace(';', '\n').replaceAll("\n+", "\n").trim(); + return cleaned; + } + + private static boolean hasFeeRegions(JSONObject mapping) { + if (mapping == null) { + return false; + } + JSONArray regions = mapping.getJSONArray("feeRegions"); + return regions != null && !regions.isEmpty(); } /** @@ -1744,9 +2233,17 @@ 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(':')); - if (idx >= 0 && idx < text.length() - 1 && StringUtils.isNotBlank(text.substring(idx + 1).trim())) { - // RFQ No:样例 / 报价编号:WO 这类:值在本格冒号后 - return sheetName + "!" + new CellReference(labelCell).formatAsString(); + // 「RFQ No:」或冒号后已有样例:值写在本格冒号后,不跨到右侧单选/标题合并格 + 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(); + } + if (idx < text.length() - 1 && StringUtils.isNotBlank(text.substring(idx + 1).trim())) { + return sheetName + "!" + new CellReference(labelCell).formatAsString(); + } } int rowIdx = labelCell.getRowIndex(); int startCol = labelCell.getColumnIndex() + 1; @@ -1768,7 +2265,8 @@ public final class QuoteTemplateExcelSupport { int[] dest = resolveMergeOrigin(sheet, rowIdx, c); Cell cand = row.getCell(dest[1]); String exist = readCellText(cand); - if (isFeeTableHeader(exist) || isLabelLike(exist) || isSectionTitle(sheet, cand)) { + if (isFeeTableHeader(exist) || isLabelLike(exist) || isSectionTitle(sheet, cand) + || isUnsuitableValueCell(exist)) { continue; } String ref = sheetName + "!" + new CellReference(dest[0], dest[1]).formatAsString(); @@ -1834,19 +2332,36 @@ public final class QuoteTemplateExcelSupport { continue; } String fillRef = resolveFillTarget(sheet, cell, sheetName, text, fmt); - if (StringUtils.isBlank(fillRef) || (yellow != null && yellow.contains(fillRef))) { + if (StringUtils.isBlank(fillRef)) { continue; } - pending.add(new String[]{fillRef, val}); + boolean labelAfterColon = textContainsColon(text) + && ("quote_no".equals(code) || "quote_date".equals(code) || "valid_until".equals(code)); + // 编号/日期类:标签同格冒号后允许写黄区;其它字段仍跳过固定区 + if (yellow != null && yellow.contains(fillRef) && !labelAfterColon) { + continue; + } + pending.add(new String[]{fillRef, val, code, labelAfterColon ? "1" : "0"}); } } } // 先扫完再写:POI 遍历行时 createCell 会 ConcurrentModificationException for (String[] one : pending) { - writeCell(wb, one[0], one[1]); + if (one.length >= 4 && "1".equals(one[3])) { + writeCellAfterColon(wb, one[0], one[1]); + } else if (one.length >= 3 && "quote_no".equals(one[2])) { + writeCellAfterColon(wb, one[0], one[1]); + } else { + writeCell(wb, one[0], one[1]); + } } } + /** 是否含中英文冒号(标签同格回填用)。 */ + private static boolean textContainsColon(String text) { + return text != null && (text.contains(":") || text.contains(":")); + } + private static String matchLabel(String normalized, Map dict) { if (dict == null || dict.isEmpty() || StringUtils.isBlank(normalized)) { return null; @@ -2182,6 +2697,255 @@ public final class QuoteTemplateExcelSupport { return ""; } + /** + * 重新解析合并用:旧坐标是否仍可作为该字段的可填格。 + *

+ * quote_no 特例:贴着 RFQ 的 B3 常是「Port to Port / 工作天」单选,不算对齐。 + * 对不上时合并逻辑改用新坐标;出单时还会 relocateQuoteNoCell 纠偏。 + */ + public static boolean fieldMappingStillAligned(Workbook wb, String code, String cellRef) { + if (wb == null || StringUtils.isBlank(code) || StringUtils.isBlank(cellRef)) { + return true; + } + ParsedRef ref; + try { + ref = ParsedRef.parse(cellRef.trim()); + } catch (Exception e) { + return false; + } + Sheet sheet = wb.getSheet(ref.sheet); + if (sheet == null && wb.getNumberOfSheets() > 0) { + sheet = wb.getSheetAt(0); + } + if (sheet == null) { + return true; + } + List labels = labelsForFieldCode(code); + if (labels.isEmpty()) { + return true; + } + DataFormatter fmt = new DataFormatter(); + Row mappedRow = sheet.getRow(ref.row); + Cell mappedCell = mappedRow == null ? null : mappedRow.getCell(ref.col); + String mappedText = StringUtils.trimToEmpty(fmt.formatCellValue(mappedCell)); + if (textLooksLikeFieldLabel(mappedText, labels)) { + return true; + } + if (isUnsuitableValueCell(mappedText) || isFeeTableHeader(mappedText) || isSectionTitle(sheet, mappedCell)) { + return false; + } + // quote_no:目标格须空或像工单号;单选/其它业务字一律不算对齐 + if ("quote_no".equals(code) && StringUtils.isNotBlank(mappedText) && !looksLikeQuoteNoValue(mappedText)) { + return false; + } + boolean sawLabel = false; + int lastRow = sheet.getLastRowNum(); + for (int r = 0; r <= lastRow; r++) { + Row row = sheet.getRow(r); + if (row == null) { + continue; + } + short lastCol = row.getLastCellNum(); + for (int c = 0; c < lastCol; c++) { + Cell cell = row.getCell(c); + if (cell == null) { + continue; + } + String text = StringUtils.trimToEmpty(fmt.formatCellValue(cell)); + if (!textLooksLikeFieldLabel(text, labels)) { + continue; + } + sawLabel = true; + int[] origin = resolveMergeOrigin(sheet, r, c); + if (ref.row == origin[0] && ref.col == origin[1]) { + return true; + } + int labelEndCol = c; + for (int i = 0; i < sheet.getNumMergedRegions(); i++) { + org.apache.poi.ss.util.CellRangeAddress range = sheet.getMergedRegion(i); + if (range != null && range.isInRange(r, c)) { + labelEndCol = range.getLastColumn(); + break; + } + } + if (ref.row == r) { + int gap = ref.col - labelEndCol; + if (gap >= 1 && gap <= 3) { + // 近邻但是单选格 → 仍不算对齐(上面已拦非空非工单号) + if (StringUtils.isBlank(mappedText) || looksLikeQuoteNoValue(mappedText)) { + return true; + } + } + // 同行更右侧空格不再算「对齐」:产品要求写在 RFQ 标签格冒号后 + } + } + } + return !sawLabel; + } + + /** + * 映射点落到单选/时效选项时,改找 RFQ 标签格(同格冒号后写编号)。 + * 产品口径:编号写在 A3「RFQ No:WO…」;B3 有无内容都不把编号单独写到 B3。 + */ + public static String relocateQuoteNoCell(Workbook wb, String cellRef) { + if (wb == null || StringUtils.isBlank(cellRef)) { + return cellRef; + } + ParsedRef ref; + try { + ref = ParsedRef.parse(cellRef.trim()); + } catch (Exception e) { + return cellRef; + } + Sheet sheet = wb.getSheet(ref.sheet); + if (sheet == null && wb.getNumberOfSheets() > 0) { + sheet = wb.getSheetAt(0); + } + if (sheet == null) { + return cellRef; + } + DataFormatter fmt = new DataFormatter(); + Row row = sheet.getRow(ref.row); + Cell cell = row == null ? null : row.getCell(ref.col); + String text = StringUtils.trimToEmpty(fmt.formatCellValue(cell)); + // 本格已是 RFQ 标签 → 留在本格(冒号后写) + if (textLooksLikeFieldLabel(text, labelsForFieldCode("quote_no")) + || text.toUpperCase(Locale.ROOT).contains("RFQ")) { + return cellRef; + } + // 表上有 RFQ 标签格 → 一律写标签冒号后(即使映射格空白也不写 B3) + String labelRef = findQuoteNoLabelCell(sheet); + if (StringUtils.isNotBlank(labelRef)) { + return labelRef; + } + // 无标签时:空白或已是工单号样才可写映射格 + if (!isUnsuitableValueCell(text) && (StringUtils.isBlank(text) || looksLikeQuoteNoValue(text))) { + return cellRef; + } + return cellRef; + } + + /** + * 找「RFQ No: / 报价编号」标签格,供同格冒号后写入。 + */ + static String findQuoteNoLabelCell(Sheet sheet) { + if (sheet == null) { + return null; + } + List labels = labelsForFieldCode("quote_no"); + DataFormatter fmt = new DataFormatter(); + String sheetName = sheet.getSheetName(); + int lastRow = sheet.getLastRowNum(); + for (int r = 0; r <= Math.min(lastRow, 25); r++) { + Row row = sheet.getRow(r); + if (row == null) { + continue; + } + short lastCol = row.getLastCellNum(); + for (int c = 0; c < lastCol; c++) { + Cell cell = row.getCell(c); + String text = StringUtils.trimToEmpty(fmt.formatCellValue(cell)); + if (!textLooksLikeFieldLabel(text, labels) && !text.toUpperCase(Locale.ROOT).contains("RFQ")) { + continue; + } + return sheetName + "!" + new CellReference(r, c).formatAsString(); + } + } + return null; + } + + /** + * @deprecated 保留给旧调用;现统一走 {@link #findQuoteNoLabelCell}。 + */ + static String findQuoteNoFillCell(Sheet sheet) { + return findQuoteNoLabelCell(sheet); + } + + /** + * 单选、时效选项、港口类型等:不能当 quote_no / 普通标量可填格。 + */ + static boolean isUnsuitableValueCell(String text) { + if (StringUtils.isBlank(text)) { + return false; + } + String t = text.replace(":", ":").trim(); + String lower = t.toLowerCase(Locale.ROOT); + if (t.contains("○") || t.contains("●") || t.contains("⊙") || t.contains("□") || t.contains("☑")) { + return true; + } + if (lower.contains("port to port") || lower.contains("door to door")) { + return true; + } + if (t.contains("工作天") || t.contains("工作日") || t.contains("回复")) { + return true; + } + if (t.contains("其它") && (t.contains("天") || t.contains("回复") || t.contains("Port"))) { + return true; + } + return false; + } + + /** 工单号 / 报价编号样:WO…、纯数字较长、或 RFQ- 前缀。 */ + static boolean looksLikeQuoteNoValue(String text) { + if (StringUtils.isBlank(text)) { + return false; + } + String t = text.trim(); + if (t.length() > 40) { + return false; + } + if (t.matches("(?i)WO\\d{6,}")) { + return true; + } + if (t.matches("(?i)W0\\d{6,}")) { + return true; + } + if (t.matches("(?i)RFQ[-_]?[A-Z0-9]+")) { + return true; + } + return t.matches("[A-Z]{1,5}[-_]?\\d{8,}"); + } + + /** + * 字段码对应的表上标签原文(与 FILL_BY_LABEL 反向)。 + */ + static List labelsForFieldCode(String code) { + List out = new ArrayList<>(); + if (StringUtils.isBlank(code)) { + return out; + } + for (Map.Entry e : FILL_BY_LABEL.entrySet()) { + if (code.equals(e.getValue())) { + out.add(e.getKey()); + } + } + return out; + } + + private static boolean textLooksLikeFieldLabel(String text, List labels) { + if (StringUtils.isBlank(text) || labels == null || labels.isEmpty()) { + return false; + } + String norm = text.replace(":", ":").trim(); + String primary = feePrimaryName(norm); + for (String label : labels) { + if (StringUtils.isBlank(label)) { + continue; + } + String lb = label.replace(":", ":").trim(); + if (norm.equalsIgnoreCase(lb) || primary.equalsIgnoreCase(lb) + || norm.regionMatches(true, 0, lb, 0, lb.length()) + || primary.regionMatches(true, 0, lb, 0, lb.length())) { + return true; + } + if (lb.length() >= 3 && (norm.toUpperCase(Locale.ROOT).contains(lb.toUpperCase(Locale.ROOT)) + || primary.toUpperCase(Locale.ROOT).contains(lb.toUpperCase(Locale.ROOT)))) { + return true; + } + } + return false; + } + /** sheet!A1 解析。 */ private static final class ParsedRef { final String sheet; diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java index 940a59c..fc020b9 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java @@ -300,6 +300,8 @@ public class InquiryTmsQueryService { quote.put("source", "TMS"); quote.put("source_label", "TMS 标准报价"); quote.put("fee_lines", buildFeeLinesJson(data, freightMoney.amount, otherMoney.amount, headerCurrency)); + // 海运 feeItems 是分段桶:polCostItems / oceanCostItems / podCostItems,单独透出给模板分区回填 + putSeaFeeBuckets(quote, data); // 锁舱必须带着 TMS 报价编号和舱位选项,不能只落金额。 // 真实 TMS 的 quoteId 形如 AIR-数字-数字,optionNo 是 AIR-OPT01(中间没有第二根横杠)。 String quoteId = firstText(data.get("quoteId"), data.get("quote_id"), @@ -318,6 +320,12 @@ public class InquiryTmsQueryService { ? String.valueOf(airOptions) : JSON.toJSONString(airOptions)); } + // 陆运线路段:与空运 airOptions 同级透出,供模板 routeRegions 填项目一 + putLandSegments(quote, data, tms); + String isMulti = firstText(data.get("isMulti"), data.get("is_multi")); + if (StringUtils.isNotBlank(isMulti)) { + quote.put("isMulti", isMulti); + } String chargeable = firstText(data.get("chargeableWeightKg"), data.get("chargeable_weight_kg")); if (StringUtils.isNotBlank(chargeable)) { quote.put("chargeableWeightKg", chargeable); @@ -325,6 +333,127 @@ public class InquiryTmsQueryService { return quote; } + /** + * 陆运 landSegments 原样+规范化写入 quote。 + *

+ * 为何:模板 routeRegions 的 tmsSource=landSegments;若不落库/不入 quote, + * 出单只能写出始发目的时效,车型价/分腿价全空。 + */ + @SuppressWarnings("unchecked") + private static void putLandSegments(Map quote, Map data, Map tms) { + if (quote == null) { + return; + } + Object raw = first( + data == null ? null : data.get("landSegments"), + data == null ? null : data.get("land_segments"), + tms == null ? null : tms.get("landSegments"), + tms == null ? null : tms.get("land_segments")); + if (raw == null) { + return; + } + String json = raw instanceof String ? String.valueOf(raw) : JSON.toJSONString(raw); + if (StringUtils.isBlank(json) || "null".equalsIgnoreCase(json) || "[]".equals(json.trim())) { + return; + } + quote.put("landSegments", json); + // 同步一份 route_rows,字段名对齐填表(transit_time / salePrice 等) + List> rows = normalizeLandSegmentRows(raw); + if (!rows.isEmpty()) { + quote.put("route_rows", JSON.toJSONString(rows)); + } + // feeItems 为空时,用各腿售价补一条运输费,避免报价明细全空 + String feeLines = quote.get("fee_lines"); + if ((StringUtils.isBlank(feeLines) || "[]".equals(feeLines.trim())) && !rows.isEmpty()) { + List> lines = new ArrayList<>(); + for (Map seg : rows) { + String amount = firstText(seg.get("salePrice"), seg.get("costPrice"), seg.get("amount")); + if (StringUtils.isBlank(amount) || "-".equals(amount)) { + continue; + } + Map line = new LinkedHashMap<>(); + line.put("name", "运输费"); + line.put("amount", amount); + if (StringUtils.isNotBlank(seg.get("currency"))) { + line.put("currency", seg.get("currency")); + } + if (StringUtils.isNotBlank(seg.get("remark"))) { + line.put("remark", seg.get("remark")); + } + lines.add(line); + } + if (!lines.isEmpty()) { + quote.put("fee_lines", JSON.toJSONString(lines)); + } + } + } + + /** + * landSegments[] → 填表行:统一 origin/destination/salePrice/transit_time/vehicleType。 + */ + @SuppressWarnings("unchecked") + static List> normalizeLandSegmentRows(Object raw) { + List> out = new ArrayList<>(); + List list; + if (raw instanceof List) { + list = (List) raw; + } else if (raw instanceof String) { + try { + Object parsed = JSON.parse(String.valueOf(raw)); + if (!(parsed instanceof List)) { + return out; + } + list = (List) parsed; + } catch (Exception e) { + return out; + } + } else { + return out; + } + for (Object item : list) { + if (!(item instanceof Map)) { + continue; + } + Map seg = (Map) item; + Map row = new LinkedHashMap<>(); + putExtra(row, "segmentSequence", firstText(seg.get("segmentSequence"), seg.get("sequence"))); + putExtra(row, "routeLegSequence", firstText(seg.get("routeLegSequence"), seg.get("legSequence"))); + putExtra(row, "origin", firstText(seg.get("origin"), seg.get("originName"), seg.get("originCode"))); + putExtra(row, "originCode", firstText(seg.get("originCode"))); + putExtra(row, "destination", firstText(seg.get("destination"), seg.get("destinationName"), seg.get("destinationCode"))); + putExtra(row, "destinationCode", firstText(seg.get("destinationCode"))); + putExtra(row, "vehicleType", firstText(seg.get("vehicleType"), seg.get("vehicle_type"), seg.get("truckType"))); + putExtra(row, "landTransportType", firstText(seg.get("landTransportType"))); + putExtra(row, "routeCategory", firstText(seg.get("routeCategory"))); + putExtra(row, "currency", firstText(seg.get("currency"), seg.get("settlementCurrency"))); + String sale = firstText(seg.get("salePrice"), seg.get("sale_price")); + String cost = firstText(seg.get("costPrice"), seg.get("cost_price")); + putExtra(row, "salePrice", sale); + putExtra(row, "costPrice", cost); + putExtra(row, "amount", firstText(sale, cost)); + String transit = firstText(seg.get("transitDays"), seg.get("transit_time"), seg.get("transitTime"), + seg.get("aging")); + putExtra(row, "transitDays", transit); + putExtra(row, "transit_time", transit); + putExtra(row, "remark", firstText(seg.get("remark"), seg.get("note"), seg.get("description"))); + // 腿上若带分项费率扩展字段,原样带上供「提货费/元/CBM」列匹配 + String[][] rateKeys = { + {"pickupFee", "pickup_fee"}, {"deliveryFee", "delivery_fee"}, + {"pricePerCbm", "price_per_cbm"}, {"pricePerKg", "price_per_kg"}, + {"minCharge", "min_charge"}, {"landFreight", "land_freight"} + }; + for (String[] pair : rateKeys) { + putExtra(row, pair[1], firstText(seg.get(pair[0]), seg.get(pair[1]))); + putExtra(row, pair[0], firstText(seg.get(pair[0]))); + } + if (StringUtils.isNotBlank(row.get("origin")) || StringUtils.isNotBlank(row.get("destination")) + || StringUtils.isNotBlank(row.get("amount"))) { + out.add(row); + } + } + return out; + } + /** * 把本次 TMS 报价写回工单主表,供后台列表/详情展示。 * 没有的成本/毛利率不编;售价用销售合计。 @@ -351,6 +480,8 @@ public class InquiryTmsQueryService { /** * 抽出销售侧价格明细行。优先 TMS 费用清单,没有再用常见费用字段兜底。 * 每行带自己的币种;没有就继承表头,仍然不编造。 + * 海运 feeItems 常是 [{polCostItems:[...]},{oceanCostItems:[...]},{podCostItems:[...]}], + * 需展开各桶再扁平,否则顶层无 feeName 会抽成空列表。 */ @SuppressWarnings("unchecked") private static String buildFeeLinesJson(Map data, String freight, String other, String headerCurrency) { @@ -362,14 +493,22 @@ public class InquiryTmsQueryService { continue; } Map row = (Map) item; - String name = firstText(row.get("feeName"), row.get("name"), row.get("chargeName"), - row.get("itemName"), row.get("feeType")); - String amount = firstText(row.get("salePrice"), row.get("amount"), row.get("price"), - row.get("feeAmount"), row.get("totalPrice")); - String lineCurrency = TmsQuoteCurrency.fromFeeRow(row); - addFeeLine(lines, name, amount, - StringUtils.isNotBlank(lineCurrency) ? lineCurrency : headerCurrency, - extrasFromFee(row)); + // 海运分段桶:展开子列表 + boolean nested = false; + for (String bucket : new String[]{"polCostItems", "oceanCostItems", "podCostItems"}) { + Object sub = row.get(bucket); + if (sub instanceof List) { + nested = true; + for (Object one : (List) sub) { + if (one instanceof Map) { + appendFeeLineFromRow(lines, (Map) one, headerCurrency); + } + } + } + } + if (!nested) { + appendFeeLineFromRow(lines, row, headerCurrency); + } } } if (lines.isEmpty()) { @@ -384,6 +523,60 @@ public class InquiryTmsQueryService { return JSON.toJSONString(lines); } + /** + * 海运三段费用桶单独落 quote,供模板 feeRegions 按 tmsSource 分区写,禁止三段共用扁平 fee_lines。 + */ + @SuppressWarnings("unchecked") + private static void putSeaFeeBuckets(Map quote, Map data) { + if (quote == null || data == null) { + return; + } + Object raw = first(data.get("feeItems"), data.get("feeList")); + if (!(raw instanceof List)) { + return; + } + Map>> buckets = new LinkedHashMap<>(); + buckets.put("polCostItems", new ArrayList<>()); + buckets.put("oceanCostItems", new ArrayList<>()); + buckets.put("podCostItems", new ArrayList<>()); + String headerCurrency = TmsQuoteCurrency.fromHeader(data); + for (Object item : (List) raw) { + if (!(item instanceof Map)) { + continue; + } + Map row = (Map) item; + for (String bucket : buckets.keySet()) { + Object sub = row.get(bucket); + if (!(sub instanceof List)) { + continue; + } + for (Object one : (List) sub) { + if (one instanceof Map) { + appendFeeLineFromRow(buckets.get(bucket), (Map) one, headerCurrency); + } + } + } + } + for (Map.Entry>> e : buckets.entrySet()) { + if (!e.getValue().isEmpty()) { + String json = JSON.toJSONString(e.getValue()); + quote.put(e.getKey(), json); + quote.put("fee_rows_" + e.getKey(), json); + } + } + } + + private static void appendFeeLineFromRow(List> lines, Map row, String headerCurrency) { + String name = firstText(row.get("feeName"), row.get("name"), row.get("chargeName"), + row.get("itemName"), row.get("feeType")); + String amount = firstText(row.get("salePrice"), row.get("amount"), row.get("price"), + row.get("feeAmount"), row.get("totalPrice")); + String lineCurrency = TmsQuoteCurrency.fromFeeRow(row); + addFeeLine(lines, name, amount, + StringUtils.isNotBlank(lineCurrency) ? lineCurrency : headerCurrency, + extrasFromFee(row)); + } + /** * 费用行上的单位 / 数量 / 单价 / 备注只取 TMS 原文,没有就空着。 */ diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_inquiry_menu_button_detail_v1.sql b/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_inquiry_menu_button_detail_v1.sql new file mode 100644 index 0000000..5f40d34 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_inquiry_menu_button_detail_v1.sql @@ -0,0 +1,76 @@ +-- 询价后台菜单按钮:让角色树展开「查看/导出」等明细(对齐旧运营后台) +-- 库:inquiry_robot;幂等。 +-- 根因:子菜单 is_leaf=1 时 Jeecg queryTreeList 不递归 menu_type=2 按钮。 + +-- 1) 有子节点的菜单必须非叶子 +UPDATE sys_permission + SET is_leaf = 0 + WHERE id IN ( + 'b1000000000000000000000000000001', + 'b1000000000000000000000000000002', + 'b1000000000000000000000000000003', + 'b1000000000000000000000000000004', + 'b1000000000000000000000000000005', + 'b1000000000000000000000000000006', + 'b1000000000000000000000000000010', + 'b1000000000000000000000000000011', + 'b1000000000000000000000000000012', + 'b1000000000000000000000000000013' + ); + +-- 2) 页面菜单不要挂 perms(权限码只在按钮上) +UPDATE sys_permission + SET perms = NULL + WHERE id IN ( + 'b1000000000000000000000000000002', + 'b1000000000000000000000000000003', + 'b1000000000000000000000000000004', + 'b1000000000000000000000000000005', + 'b1000000000000000000000000000006' + ); + +-- 3) 已有按钮改名为旧后台口径 +UPDATE sys_permission SET name='查看' WHERE id='b1000000000000000000000000000021'; +UPDATE sys_permission SET name='查看' WHERE id='b1000000000000000000000000000031'; +UPDATE sys_permission SET name='关闭工单' WHERE id='b1000000000000000000000000000032'; +UPDATE sys_permission SET name='查看' WHERE id='b1000000000000000000000000000041'; +UPDATE sys_permission SET name='新增' WHERE id='b1000000000000000000000000000042'; +UPDATE sys_permission SET name='编辑' WHERE id='b1000000000000000000000000000043'; +UPDATE sys_permission SET name='停用' WHERE id='b1000000000000000000000000000044'; +UPDATE sys_permission SET name='查看' WHERE id='b1000000000000000000000000000051'; +UPDATE sys_permission SET name='新增' WHERE id='b1000000000000000000000000000052'; +UPDATE sys_permission SET name='编辑' WHERE id='b1000000000000000000000000000053'; +UPDATE sys_permission SET name='停用' WHERE id='b1000000000000000000000000000054'; +UPDATE sys_permission SET name='查看' WHERE id='b1000000000000000000000000000061'; +UPDATE sys_permission SET name='上传' WHERE id='b1000000000000000000000000000062'; +UPDATE sys_permission SET name='编辑' WHERE id='b1000000000000000000000000000063'; +UPDATE sys_permission SET name='启停' WHERE id='b1000000000000000000000000000064'; +UPDATE sys_permission SET name='查看' WHERE id='b1000000000000000000000000000071'; +UPDATE sys_permission SET name='编辑' WHERE id='b1000000000000000000000000000072'; + +-- 4) 补旧后台有、当前缺的按钮 +INSERT IGNORE INTO sys_permission +(id,parent_id,name,is_route,menu_type,perms,perms_type,sort_no,always_show,is_leaf,keep_alive,hidden,hide_tab,create_by,create_time,del_flag,rule_flag,status,internal_or_external) +VALUES +-- 分析看板:导出 / 更新数据 +('b1000000000000000000000000000022','b1000000000000000000000000000002','导出',0,2,'inquiry:dashboard:export','1',2,0,1,0,0,0,'admin',NOW(),0,0,'1',0), +('b1000000000000000000000000000023','b1000000000000000000000000000002','更新数据',0,2,'inquiry:dashboard:refresh','1',3,0,1,0,0,0,'admin',NOW(),0,0,'1',0), +-- 询价工单:导出 / 查看详情 +('b1000000000000000000000000000033','b1000000000000000000000000000003','导出',0,2,'inquiry:workorder:export','1',2,0,1,0,0,0,'admin',NOW(),0,0,'1',0), +('b1000000000000000000000000000034','b1000000000000000000000000000003','查看详情',0,2,'inquiry:workorder:detail','1',3,0,1,0,0,0,'admin',NOW(),0,0,'1',0), +-- 账号管理 +('b1000000000000000000000000000081','b1000000000000000000000000000011','查看',0,2,'inquiry:account:view','1',1,0,1,0,0,0,'admin',NOW(),0,0,'1',0), +('b1000000000000000000000000000082','b1000000000000000000000000000011','编辑',0,2,'inquiry:account:edit','1',2,0,1,0,0,0,'admin',NOW(),0,0,'1',0), +-- 角色管理 +('b1000000000000000000000000000083','b1000000000000000000000000000012','查看',0,2,'inquiry:role:view','1',1,0,1,0,0,0,'admin',NOW(),0,0,'1',0), +('b1000000000000000000000000000084','b1000000000000000000000000000012','编辑',0,2,'inquiry:role:edit','1',2,0,1,0,0,0,'admin',NOW(),0,0,'1',0); + +-- 关闭工单排序靠后(详情后) +UPDATE sys_permission SET sort_no = 4 WHERE id = 'b1000000000000000000000000000032'; + +-- 5) admin 角色补授权全部 b100 权限 +INSERT IGNORE INTO sys_role_permission (id, role_id, permission_id) +SELECT MD5(CONCAT('ytd-inq-menu:', r.id, ':', p.id)), r.id, p.id + FROM sys_role r + JOIN sys_permission p ON p.id LIKE 'b100%' AND p.del_flag = 0 + WHERE r.role_code = 'admin'; diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_sys_user_last_login_time_v1.sql b/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_sys_user_last_login_time_v1.sql new file mode 100644 index 0000000..727c616 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_sys_user_last_login_time_v1.sql @@ -0,0 +1,27 @@ +-- 账号「最后登录时间」:列表展示真实登录,不再误用 createTime。 +-- 库:inquiry_robot;幂等。 + +-- 1) 列 +SET @has_col := ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'sys_user' + AND column_name = 'last_login_time' +); +SET @sql := IF(@has_col = 0, + 'ALTER TABLE sys_user ADD COLUMN last_login_time datetime NULL DEFAULT NULL COMMENT ''最后登录时间'' AFTER update_time', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 2) 用登录日志回填(log_type=1);已有值不覆盖 +UPDATE sys_user u +INNER JOIN ( + SELECT COALESCE(NULLIF(userid, ''), username) AS uname, MAX(create_time) AS last_t + FROM sys_log + WHERE log_type = 1 + AND (userid IS NOT NULL OR username IS NOT NULL) + GROUP BY COALESCE(NULLIF(userid, ''), username) +) x ON x.uname = u.username +SET u.last_login_time = x.last_t +WHERE u.last_login_time IS NULL + AND x.last_t IS NOT NULL; diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/staff/LandStaffMatcherTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/staff/LandStaffMatcherTest.java index 3a80235..908a93c 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/staff/LandStaffMatcherTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/staff/LandStaffMatcherTest.java @@ -14,6 +14,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; * 陆运拉群人员匹配:不连库。 *

* 只认产品·陆运启用岗;负责线路按逗号整词对线路类别,禁止按斜杠切开「中港/中亚/中欧」。 + * 「陆运全线」命中任意线路类别。 */ public class LandStaffMatcherTest { @@ -37,6 +38,14 @@ public class LandStaffMatcherTest { assertEquals(0, LandStaffMatcher.match(Arrays.asList(row), "东南亚").size()); } + @Test + public void landAllRoutesHitsAnyCategory() { + InquiryStaffRole all = staff("land", "active", "z", "全线岗", "陆运全线"); + assertEquals(1, LandStaffMatcher.match(Arrays.asList(all), "东南亚").size()); + assertEquals(1, LandStaffMatcher.match(Arrays.asList(all), "中港/中亚/中欧").size()); + assertEquals(1, LandStaffMatcher.match(Arrays.asList(all), "国内长途/零担").size()); + } + @Test public void ignoreSeaRole() { InquiryStaffRole sea = staff("sea", "active", "s", "海", "东南亚"); diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/staff/SeaStaffMatcherTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/staff/SeaStaffMatcherTest.java index 507943c..b52ec1d 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/staff/SeaStaffMatcherTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/staff/SeaStaffMatcherTest.java @@ -26,6 +26,12 @@ public class SeaStaffMatcherTest { assertEquals("p1", hits.get(0).get("wecomId")); } + @Test + public void seaAllLineAliasPulls() { + InquiryStaffRole row = staff("sea", "active", "p1b", "全线产品", "海运全线"); + assertEquals(1, SeaStaffMatcher.match(Arrays.asList(row), "上海", "洛杉矶").size()); + } + @Test public void specificPortPullsWhenMatch() { InquiryStaffRole row = staff("sea", "active", "p2", "李产品", "上海,宁波"); diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateFillTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateFillTest.java index f4256c0..45549ff 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateFillTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateFillTest.java @@ -132,7 +132,7 @@ public class QuoteTemplateFillTest { } @Test - public void seaFeeRemarkSplitsAndWidensColumn() throws Exception { + public void seaFeeRemarkStripsInternalMarkup() throws Exception { Path src = Files.createTempFile("quote-remark-src", ".xlsx"); Path out = Files.createTempFile("quote-remark-out", ".xlsx"); try (Workbook wb = new XSSFWorkbook()) { @@ -156,13 +156,9 @@ public class QuoteTemplateFillTest { try (Workbook wb = new XSSFWorkbook(Files.newInputStream(out))) { Sheet sheet = wb.getSheetAt(0); org.apache.poi.ss.usermodel.Row row = sheet.getRow(18); - String remark = row.getCell(8).getStringCellValue(); - assertTrue(remark.contains("计价单位:柜")); - assertTrue(remark.contains("加点系数:0.0000000")); - assertTrue(remark.contains("\n")); - assertTrue(row.getCell(8).getCellStyle().getWrapText()); - assertTrue(sheet.getColumnWidth(8) >= 18 * 256); - assertTrue(row.getHeightInPoints() >= 30f); + String remark = row.getCell(8) == null ? "" : row.getCell(8).getStringCellValue(); + assertEquals("", remark == null ? "" : remark.trim()); + assertEquals(15.0, row.getCell(7).getNumericCellValue(), 0.01); } finally { Files.deleteIfExists(src); Files.deleteIfExists(out); @@ -591,6 +587,176 @@ public class QuoteTemplateFillTest { assertEquals(2, out.getJSONArray("fee_rows").size()); } + @Test + public void buildFillFactsProjectsLandRouteFromFeeLinesWhenNoSegments() { + Map facts = new HashMap<>(); + facts.put("起运港", "泉州市"); + facts.put("目的港", "南京市"); + facts.put("运输类型", "国内运输拼车"); + Map quote = new HashMap<>(); + quote.put("total", "110.00"); + quote.put("currency", "CNY"); + quote.put("eta", "48天"); + quote.put("fee_lines", "[{\"name\":\"运输费\",\"amount\":\"110.00\",\"currency\":\"CNY\",\"remark\":\"票结\"}]"); + JSONObject out = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl + .buildFillFacts("WO202609190017", facts, quote, "LinMianXun"); + assertEquals("泉州市", out.getString("origin")); + assertEquals("南京市", out.getString("destination")); + assertEquals("48天", out.getString("transit_time")); + assertTrue(out.getJSONArray("route_rows") != null && !out.getJSONArray("route_rows").isEmpty()); + assertEquals("110.00", out.getJSONArray("route_rows").getJSONObject(0).getString("salePrice")); + } + + /** + * 陆运拼车:H5「报价日期:」常被标进 fixedCells;同格冒号后必须仍能写出日期。 + * 与有效期误绑同一格时,只写报价日期,不让 TMS valid_until 盖掉标签含义。 + */ + @Test + public void quoteDateWritesAfterColonEvenWhenFixedYellow() throws Exception { + Path src = Files.createTempFile("quote-date-src", ".xlsx"); + Path out = Files.createTempFile("quote-date-out", ".xlsx"); + try (Workbook wb = new XSSFWorkbook()) { + Sheet sheet = wb.createSheet("国内运输拼车报价模板"); + row(sheet, 4).createCell(7).setCellValue("报价日期:"); + try (java.io.OutputStream os = Files.newOutputStream(src)) { + wb.write(os); + } + } + JSONObject mapping = new JSONObject(true); + JSONObject fields = new JSONObject(true); + fields.put("valid_until", "国内运输拼车报价模板!H5"); + fields.put("quote_date", "国内运输拼车报价模板!H5"); + mapping.put("fields", fields); + JSONObject meta = new JSONObject(true); + JSONObject after = new JSONObject(true); + after.put("sameCellAfterColon", true); + meta.put("quote_date", after); + meta.put("valid_until", after); + mapping.put("fieldMeta", meta); + mapping.put("fixedCells", JSONArray.parseArray("[\"国内运输拼车报价模板!H5\"]")); + mapping.put("fixedYellowCells", JSONArray.parseArray("[\"国内运输拼车报价模板!H5\"]")); + + JSONObject facts = new JSONObject(true); + facts.put("quote_date", "2026-09-19"); + facts.put("valid_until", "2026-04-30"); + QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts); + try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) { + assertEquals("报价日期:2026-09-19", read(filled.getSheetAt(0), 4, 7)); + } finally { + Files.deleteIfExists(src); + Files.deleteIfExists(out); + } + } + + /** + * 产品口径:单元格标黄 = 固定占位,出单不赋值。 + * 陆运「项目二」整段黄底时,即使 mapping 配了 feeRegions,也不得改写模板占位行。 + */ + @Test + public void yellowFeeRegionRowsAreNotOverwritten() throws Exception { + Path src = Files.createTempFile("quote-yellow-src", ".xlsx"); + Path out = Files.createTempFile("quote-yellow-out", ".xlsx"); + try (Workbook wb = new XSSFWorkbook()) { + Sheet sheet = wb.createSheet("国内运输拼车报价模板"); + row(sheet, 11).createCell(1).setCellValue("项目二:其它费用"); + org.apache.poi.ss.usermodel.CellStyle yellow = wb.createCellStyle(); + yellow.setFillForegroundColor(org.apache.poi.ss.usermodel.IndexedColors.YELLOW.getIndex()); + yellow.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND); + org.apache.poi.ss.usermodel.Row data = row(sheet, 13); + org.apache.poi.ss.usermodel.Cell cName = data.createCell(2); + cName.setCellValue("打包费"); + cName.setCellStyle(yellow); + org.apache.poi.ss.usermodel.Cell cAmt = data.createCell(7); + cAmt.setCellValue("明确收费"); + cAmt.setCellStyle(yellow); + row(sheet, 18).createCell(1).setCellValue("交易条件说明"); + try (java.io.OutputStream os = Files.newOutputStream(src)) { + wb.write(os); + } + } + JSONObject mapping = new JSONObject(true); + mapping.put("fields", new JSONObject()); + JSONArray regions = new JSONArray(); + JSONObject region = new JSONObject(true); + region.put("id", "other_charges"); + region.put("title", "项目二:其它费用"); + region.put("anchor", "国内运输拼车报价模板!B14"); + region.put("stopAtText", JSONArray.parseArray("[\"交易条件说明\"]")); + region.put("grow", "insert_before_stop"); + JSONObject columns = new JSONObject(true); + columns.put("name", "C"); + columns.put("amount", "H"); + region.put("columns", columns); + regions.add(region); + mapping.put("feeRegions", regions); + + JSONObject facts = new JSONObject(true); + facts.put("fee_rows", QuoteTemplateExcelSupport.parseFeeRows( + "[{\"name\":\"运输费\",\"amount\":\"110.00\",\"unit\":\"票数\"}]")); + QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts); + try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) { + Sheet sheet = filled.getSheetAt(0); + assertEquals("打包费", read(sheet, 13, 2)); + assertEquals("明确收费", read(sheet, 13, 7)); + } finally { + Files.deleteIfExists(src); + Files.deleteIfExists(out); + } + } + + @Test + public void seaDynamicContainerHeaderReplacedWithBoxType() throws Exception { + Path src = Files.createTempFile("quote-box-src", ".xlsx"); + Path out = Files.createTempFile("quote-box-out", ".xlsx"); + try (Workbook wb = new XSSFWorkbook()) { + Sheet sheet = wb.createSheet("海运询价单"); + row(sheet, 16).createCell(0).setCellValue("始发港费用/Origin Charges"); + org.apache.poi.ss.usermodel.Row head = row(sheet, 17); + head.createCell(0).setCellValue("项目/Items"); + head.createCell(2).setCellValue("箱型\n(动态填充箱型)"); + head.createCell(7).setCellValue("金额/Ammount"); + row(sheet, 18).createCell(0).setCellValue("操作费"); + row(sheet, 27).createCell(0).setCellValue("始发港费用汇总/POL Local Charges"); + try (java.io.OutputStream os = Files.newOutputStream(src)) { + wb.write(os); + } + } + JSONObject mapping = new JSONObject(true); + mapping.put("sheet", "海运询价单"); + mapping.put("fields", new JSONObject()); + JSONArray regions = new JSONArray(); + JSONObject region = new JSONObject(true); + region.put("id", "origin_charges"); + region.put("title", "始发港费用/Origin Charges"); + region.put("tmsSource", "polCostItems"); + region.put("headerRow", 18); + region.put("anchor", "海运询价单!A19"); + region.put("dynamicContainerHeader", true); + region.put("stopAtText", JSONArray.parseArray("[\"始发港费用汇总\"]")); + JSONObject columns = new JSONObject(true); + columns.put("name", "A"); + columns.put("container_rate", "C"); + columns.put("amount", "H"); + region.put("columns", columns); + regions.add(region); + mapping.put("feeRegions", regions); + + JSONObject facts = new JSONObject(true); + facts.put("container_type_qty", "20GP*1"); + facts.put("fee_rows_polCostItems", QuoteTemplateExcelSupport.parseFeeRows( + "[{\"name\":\"操作费\",\"amount\":\"680\",\"currency\":\"CNY\",\"unit_price\":\"680\"}]")); + QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts); + try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) { + String head = read(filled.getSheetAt(0), 17, 2); + assertTrue(head.contains("20GP"), "header=" + head); + assertTrue(head.contains("(") && head.contains(")"), "header missing parens: " + head); + assertTrue(!head.contains("动态填充"), "header still placeholder: " + head); + } finally { + Files.deleteIfExists(src); + Files.deleteIfExists(out); + } + } + private static org.apache.poi.ss.usermodel.Row row(Sheet sheet, int r) { org.apache.poi.ss.usermodel.Row exist = sheet.getRow(r); return exist == null ? sheet.createRow(r) : exist; diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateMappingMergeTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateMappingMergeTest.java new file mode 100644 index 0000000..35cbc4d --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateMappingMergeTest.java @@ -0,0 +1,114 @@ +package org.jeecg.modules.inquiry.template; + +import com.alibaba.fastjson.JSONObject; +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.xssf.usermodel.XSSFWorkbook; +import org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * RFQ 纠偏、重新解析智能合并、备注过滤。 + *

+ * 产品口径:B3 已有单选等内容时,编号写在 A3「RFQ No:」冒号后,不覆盖 B3、不跳到 F3。 + */ +public class QuoteTemplateMappingMergeTest { + + @Test + public void b3RadioIsNotAlignedQuoteNo() throws Exception { + try (Workbook wb = seaHeaderWorkbook()) { + assertFalse(QuoteTemplateExcelSupport.fieldMappingStillAligned(wb, "quote_no", "海运询价单!B3")); + assertTrue(QuoteTemplateExcelSupport.fieldMappingStillAligned(wb, "quote_no", "海运询价单!A3")); + } + } + + @Test + public void relocateQuoteNoSkipsRadioToLabelA3() throws Exception { + try (Workbook wb = seaHeaderWorkbook()) { + String better = QuoteTemplateExcelSupport.relocateQuoteNoCell(wb, "海运询价单!B3"); + assertEquals("海运询价单!A3", better); + } + } + + @Test + public void mergeReplacesB3WithA3() throws Exception { + try (Workbook wb = seaHeaderWorkbook()) { + JSONObject old = fields("quote_no", "海运询价单!B3"); + JSONObject neu = fields("quote_no", "海运询价单!B3"); + JSONObject merged = InquiryQuoteTemplateServiceImpl.mergeMappingPreferExisting(old, neu, wb); + assertEquals("海运询价单!A3", merged.getJSONObject("fields").getString("quote_no")); + assertTrue(merged.getJSONObject("fieldMeta").getJSONObject("quote_no") + .getBooleanValue("sameCellAfterColon")); + } + } + + @Test + public void fillWritesQuoteNoAfterColonOnA3() throws Exception { + Path src = Files.createTempFile("quote-rfq-src", ".xlsx"); + Path out = Files.createTempFile("quote-rfq-out", ".xlsx"); + try (Workbook wb = seaHeaderWorkbook()) { + try (java.io.OutputStream os = Files.newOutputStream(src)) { + wb.write(os); + } + } + JSONObject mapping = new JSONObject(true); + JSONObject f = new JSONObject(true); + f.put("quote_no", "海运询价单!B3"); + mapping.put("fields", f); + // A3 在固定区也应能冒号后写入 + mapping.put("fixedCells", com.alibaba.fastjson.JSONArray.parseArray("[\"海运询价单!A3\"]")); + JSONObject facts = new JSONObject(true); + facts.put("quote_no", "WO202609190002"); + QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts); + try (Workbook wb = new XSSFWorkbook(Files.newInputStream(out))) { + Sheet sheet = wb.getSheet("海运询价单"); + assertEquals("RFQ No:WO202609190002", sheet.getRow(2).getCell(0).getStringCellValue()); + // B3 单选不得被工单号覆盖 + assertEquals("○ Port to Port\n○ 其它,3工作天回复", + sheet.getRow(2).getCell(1).getStringCellValue().replace("\r\n", "\n")); + // F3 不应被误写 + assertTrue(sheet.getRow(2).getCell(5) == null + || org.apache.commons.lang3.StringUtils.isBlank(sheet.getRow(2).getCell(5).getStringCellValue())); + } finally { + Files.deleteIfExists(src); + Files.deleteIfExists(out); + } + } + + @Test + public void formatFeeRemarkDropsMarkup() { + assertEquals("", QuoteTemplateExcelSupport.formatFeeRemark("计价单位:柜;加点系数:0.0000000")); + assertEquals("紧急出运", QuoteTemplateExcelSupport.formatFeeRemark("紧急出运;加点系数:0.1")); + } + + private static JSONObject fields(String code, String cell) { + JSONObject mapping = new JSONObject(true); + JSONObject f = new JSONObject(true); + f.put(code, cell); + mapping.put("fields", f); + mapping.put("feeRegions", new com.alibaba.fastjson.JSONArray()); + mapping.put("routeRegions", new com.alibaba.fastjson.JSONArray()); + return mapping; + } + + /** + * 迷你海运表头:A3=RFQ No:,B3=单选,F3=空(旧逻辑曾误写这里)。 + */ + private static Workbook seaHeaderWorkbook() { + Workbook wb = new XSSFWorkbook(); + Sheet sheet = wb.createSheet("海运询价单"); + Row row = sheet.createRow(2); + row.createCell(0).setCellValue("RFQ No:"); + row.createCell(1).setCellValue("○ Port to Port\n○ 其它,3工作天回复"); + row.createCell(5).setCellValue(""); + return wb; + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsQuoteCurrencyTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsQuoteCurrencyTest.java index 4de1ef3..19f1c84 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsQuoteCurrencyTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsQuoteCurrencyTest.java @@ -2,11 +2,14 @@ package org.jeecg.modules.inquiry.tms; import org.junit.jupiter.api.Test; +import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -114,24 +117,69 @@ public class TmsQuoteCurrencyTest { } @Test - public void toAgentQuoteKeepsRealAirLockIds() { - Map option = new HashMap<>(); - option.put("optionNo", "AIR-OPT01"); - option.put("originAirportCode", "ZUH"); - option.put("destinationAirportCode", "CRK"); + public void toAgentQuoteSplitsSeaFeeBuckets() { + Map pol = new HashMap<>(); + pol.put("feeName", "操作费"); + pol.put("amount", 680); + pol.put("currency", "CNY"); + Map ocean = new HashMap<>(); + ocean.put("feeName", "海运费"); + ocean.put("amount", 100); + ocean.put("currency", "USD"); + Map pod = new HashMap<>(); + pod.put("feeName", "文件费"); + pod.put("amount", 450); + pod.put("currency", "CNY"); + List> feeItems = new ArrayList<>(); + feeItems.add(Collections.singletonMap("polCostItems", Collections.singletonList(pol))); + feeItems.add(Collections.singletonMap("oceanCostItems", Collections.singletonList(ocean))); + feeItems.add(Collections.singletonMap("podCostItems", Collections.singletonList(pod))); Map data = new HashMap<>(); data.put("quoteFound", true); - data.put("quoteId", "AIR-2094721692181016578-2094721692654972929"); - data.put("totalSalePrice", "11792.00"); + data.put("totalSalePrice", "1230"); data.put("currency", "CNY"); - data.put("chargeableWeightKg", 1072); - data.put("airOptions", Collections.singletonList(option)); + data.put("feeItems", feeItems); Map tms = new HashMap<>(); tms.put("code", 1); tms.put("tmsData", data); Map quote = InquiryTmsQueryService.toAgentQuote(tms); - assertEquals("AIR-2094721692181016578-2094721692654972929", quote.get("quoteId")); - assertTrue(quote.get("airOptions").contains("AIR-OPT01")); - assertEquals("1072", quote.get("chargeableWeightKg")); + assertTrue(quote.get("fee_lines").contains("操作费")); + assertTrue(quote.get("fee_lines").contains("海运费")); + assertTrue(quote.get("fee_lines").contains("文件费")); + assertTrue(quote.get("fee_rows_polCostItems").contains("操作费")); + assertFalse(quote.get("fee_rows_polCostItems").contains("海运费")); + assertTrue(quote.get("fee_rows_oceanCostItems").contains("海运费")); + assertTrue(quote.get("fee_rows_podCostItems").contains("文件费")); + } + + @Test + public void toAgentQuoteProjectsLandSegments() { + Map seg = new HashMap<>(); + seg.put("segmentSequence", 1); + seg.put("routeLegSequence", 1); + seg.put("origin", "泉州市"); + seg.put("destination", "南京市"); + seg.put("vehicleType", "3T"); + seg.put("salePrice", "2800"); + seg.put("currency", "CNY"); + seg.put("transitDays", "3"); + seg.put("remark", "普货"); + Map data = new HashMap<>(); + data.put("quoteFound", true); + data.put("totalSalePrice", "2800"); + data.put("currency", "CNY"); + data.put("feeItems", Collections.emptyList()); + data.put("isMulti", 1); + data.put("landSegments", Collections.singletonList(seg)); + Map tms = new HashMap<>(); + tms.put("code", 1); + tms.put("tmsData", data); + Map quote = InquiryTmsQueryService.toAgentQuote(tms); + assertEquals("1", quote.get("isMulti")); + assertTrue(quote.get("landSegments").contains("泉州市")); + assertTrue(quote.get("route_rows").contains("\"salePrice\":\"2800\"")); + assertTrue(quote.get("route_rows").contains("\"transit_time\":\"3\"")); + assertTrue(quote.get("fee_lines").contains("运输费")); + assertTrue(quote.get("fee_lines").contains("2800")); } } diff --git a/inquiry-api/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/LoginController.java b/inquiry-api/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/LoginController.java index 676ab9f..274507e 100644 --- a/inquiry-api/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/LoginController.java +++ b/inquiry-api/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/LoginController.java @@ -4,6 +4,7 @@ import cn.hutool.core.util.RandomUtil; import com.alibaba.fastjson.JSONObject; import com.aliyuncs.exceptions.ClientException; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; @@ -465,6 +466,18 @@ public class LoginController { // 获取用户部门信息 JSONObject obj = new JSONObject(new LinkedHashMap<>()); + // 询价账号管理:写入最后登录时间,列表不再误用 createTime(种子常为 2019) + Date loginAt = new Date(); + sysUser.setLastLoginTime(loginAt); + try { + sysUserService.update( + new LambdaUpdateWrapper() + .eq(SysUser::getId, sysUser.getId()) + .set(SysUser::getLastLoginTime, loginAt)); + } catch (Exception e) { + log.warn("写入最后登录时间失败 username={}", username, e); + } + //1.生成token String token = JwtUtil.sign(username, syspassword); // 设置token缓存有效时间 diff --git a/inquiry-api/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/entity/SysUser.java b/inquiry-api/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/entity/SysUser.java index 7c72f1b..0b83f67 100644 --- a/inquiry-api/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/entity/SysUser.java +++ b/inquiry-api/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/entity/SysUser.java @@ -161,6 +161,16 @@ public class SysUser implements Serializable { * 更新时间 */ private Date updateTime; + + /** + * 最后登录时间。 + * 账号管理列表展示;登录成功时写入。禁止用 createTime 冒充(种子账号常为 2019)。 + */ + @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss") + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @TableField("last_login_time") + private Date lastLoginTime; + /** * 同步工作流引擎1同步0不同步 */ diff --git a/inquiry-backend/src/views/inquiry/account/account.api.ts b/inquiry-backend/src/views/inquiry/account/account.api.ts index a1d89c2..2461e70 100644 --- a/inquiry-backend/src/views/inquiry/account/account.api.ts +++ b/inquiry-backend/src/views/inquiry/account/account.api.ts @@ -43,6 +43,19 @@ export function roleTagFor(index: number) { return ROLE_TAG_CYCLE[index % ROLE_TAG_CYCLE.length]; } +/** + * 最后登录展示为 `yyyy-MM-dd HH:mm:ss`;无记录显示 —。 + * 兼容接口偶发 ISO(带 T)或已格式化字符串。 + */ +export function formatLastLogin(raw: unknown): string { + if (raw == null || raw === '') return '—'; + const s = String(raw).trim(); + if (!s) return '—'; + const m = s.match(/^(\d{4}-\d{2}-\d{2})[T\s](\d{2}:\d{2}:\d{2})/); + if (m) return `${m[1]} ${m[2]}`; + return s; +} + /** username=admin 或角色码/名含超级管理员 → 内置超管 */ export function isSuperAdminUser(u: { username?: string; roleLabel?: string; roleCode?: string }) { const name = String(u.username || '').trim().toLowerCase(); @@ -71,7 +84,8 @@ export function mapUserToAccount( roleLabel: superAdmin ? '超级管理员' : roleLabel, roleTag: superAdmin ? 'tag-violet' : roleTag, phoneMasked: u.phone || '未填写', - lastLogin: u.lastLoginTime || u.createTime || '—', + // 只用真实最后登录;禁止回退 createTime(Jeecg 种子常为 2019,会误导成「最后登录」) + lastLogin: formatLastLogin(u.lastLoginTime), status: Number(u.status) === 1 ? 'active' : 'disabled', dashboardSelfOnly: Boolean(u.dashboardSelfOnly === true || u.dashboardSelfOnly === 1), isSuperAdmin: superAdmin, diff --git a/inquiry-backend/src/views/inquiry/account/index.vue b/inquiry-backend/src/views/inquiry/account/index.vue index e086a48..93ef2e2 100644 --- a/inquiry-backend/src/views/inquiry/account/index.vue +++ b/inquiry-backend/src/views/inquiry/account/index.vue @@ -361,9 +361,8 @@ function removeAccount(row: AccountRow) { > 编辑 - +