完善报价单填表与币种回填,并补齐账号末次登录、角色按钮权限与陆运全线匹配。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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")
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]] = []
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
|
||||
+7
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
+220
-16
@@ -846,6 +846,13 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
String eta = first(q, "eta", "transit_days", "transitDays");
|
||||
if (StringUtils.isNotBlank(eta) && !"-".equals(eta)) {
|
||||
out.put("transit_days", eta);
|
||||
out.put("transit_time", eta);
|
||||
}
|
||||
// 空运计费重:TMS chargeableWeightKg → 模板 chargeable_weight_kg
|
||||
String chargeable = first(q, "chargeableWeightKg", "chargeable_weight_kg", "chargeable_weight");
|
||||
if (StringUtils.isNotBlank(chargeable) && !"-".equals(chargeable)) {
|
||||
out.put("chargeable_weight_kg", chargeable);
|
||||
out.put("chargeableWeightKg", chargeable);
|
||||
}
|
||||
for (Map.Entry<String, String> 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<InquiryQuoteTem
|
||||
if (!rows.isEmpty()) {
|
||||
out.put("fee_rows", rows);
|
||||
}
|
||||
// 海运分区:透传 pol/ocean/pod 桶,供 feeRegions 按段回填
|
||||
for (String bucket : new String[]{"polCostItems", "oceanCostItems", "podCostItems"}) {
|
||||
JSONArray part = QuoteTemplateExcelSupport.parseFeeRows(q.get("fee_rows_" + bucket));
|
||||
if (part.isEmpty()) {
|
||||
part = QuoteTemplateExcelSupport.parseFeeRows(q.get(bucket));
|
||||
}
|
||||
if (!part.isEmpty()) {
|
||||
out.put("fee_rows_" + bucket, part);
|
||||
out.put(bucket, part);
|
||||
}
|
||||
}
|
||||
// 陆运线路:透传 landSegments / route_rows,供 routeRegions 填项目一
|
||||
JSONArray landSegs = QuoteTemplateExcelSupport.parseFeeRows(q.get("route_rows"));
|
||||
if (landSegs.isEmpty()) {
|
||||
landSegs = QuoteTemplateExcelSupport.parseFeeRows(q.get("landSegments"));
|
||||
}
|
||||
if (!landSegs.isEmpty()) {
|
||||
out.put("landSegments", landSegs);
|
||||
out.put("route_rows", landSegs);
|
||||
// 首段时效若 facts 尚无,用腿上 transit
|
||||
if (StringUtils.isBlank(out.getString("transit_time")) || "-".equals(out.getString("transit_time"))) {
|
||||
JSONObject firstSeg = landSegs.getJSONObject(0);
|
||||
if (firstSeg != null) {
|
||||
String t = firstNonBlankLocal(firstSeg.getString("transit_time"), firstSeg.getString("transitDays"));
|
||||
if (StringUtils.isNotBlank(t)) {
|
||||
out.put("transit_time", t);
|
||||
out.put("transit_days", t);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (looksLandInquiry(src)) {
|
||||
// 无 landSegments 时:用询价 OD + fee_lines 分项拼一行,尽量填「提货费/运输费/送货费」列
|
||||
JSONObject one = new JSONObject(true);
|
||||
one.put("origin", out.getString("origin"));
|
||||
one.put("destination", out.getString("destination"));
|
||||
one.put("transit_time", out.getString("transit_time"));
|
||||
one.put("transitDays", out.getString("transit_days"));
|
||||
one.put("currency", out.getString("currency"));
|
||||
one.put("pickup_fee", out.getString("pickup_fee"));
|
||||
one.put("delivery_fee", out.getString("delivery_fee"));
|
||||
one.put("price_per_cbm", out.getString("price_per_cbm"));
|
||||
one.put("price_per_kg", out.getString("price_per_kg"));
|
||||
String landAmt = firstNonBlankLocal(out.getString("land_freight"), out.getString("price_per_cbm"));
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
JSONObject fee = rows.getJSONObject(i);
|
||||
if (fee == null) {
|
||||
continue;
|
||||
}
|
||||
String fname = StringUtils.trimToEmpty(fee.getString("name"));
|
||||
String famt = StringUtils.trimToEmpty(fee.getString("amount"));
|
||||
if (StringUtils.isBlank(famt) || "-".equals(famt)) {
|
||||
continue;
|
||||
}
|
||||
if (fname.contains("提货") && StringUtils.isBlank(one.getString("pickup_fee"))) {
|
||||
one.put("pickup_fee", famt);
|
||||
} else if ((fname.contains("送货") || fname.contains("派送")) && StringUtils.isBlank(one.getString("delivery_fee"))) {
|
||||
one.put("delivery_fee", famt);
|
||||
} else if (fname.contains("CBM") && StringUtils.isBlank(one.getString("price_per_cbm"))) {
|
||||
one.put("price_per_cbm", famt);
|
||||
} else if ((fname.contains("KG") || fname.contains("kg") || fname.contains("公斤"))
|
||||
&& StringUtils.isBlank(one.getString("price_per_kg"))) {
|
||||
one.put("price_per_kg", famt);
|
||||
} else if (fname.contains("运输") || fname.contains("陆运") || fname.contains("运费")) {
|
||||
landAmt = famt;
|
||||
one.put("salePrice", famt);
|
||||
one.put("amount", famt);
|
||||
if (StringUtils.isNotBlank(fee.getString("remark"))) {
|
||||
one.put("remark", fee.getString("remark"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (StringUtils.isNotBlank(landAmt) && StringUtils.isBlank(one.getString("amount"))) {
|
||||
one.put("amount", landAmt);
|
||||
one.put("salePrice", landAmt);
|
||||
}
|
||||
if (StringUtils.isNotBlank(one.getString("origin")) || StringUtils.isNotBlank(one.getString("destination"))) {
|
||||
JSONArray routeRows = new JSONArray();
|
||||
routeRows.add(one);
|
||||
out.put("route_rows", routeRows);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String firstNonBlankLocal(String... vals) {
|
||||
if (vals == null) {
|
||||
return "";
|
||||
}
|
||||
for (String v : vals) {
|
||||
if (StringUtils.isNotBlank(v) && !"-".equals(v.trim())) {
|
||||
return v.trim();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 询价事实里已有陆运选项/始发站,运费兜底名就不能再写成空运费。
|
||||
*/
|
||||
@@ -1013,7 +1113,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
/**
|
||||
* 智能体回写 AI 映射。校验白名单与 feeRegion 增行配置;未映射必填 → mapping_incomplete。
|
||||
* <p>
|
||||
* mergeExisting=true 时:与库中旧 mapping 补缺合并(旧字段/旧区域优先,新结果只补没有的)。
|
||||
* mergeExisting=true 时:与库中旧 mapping 智能合并(对齐标签的旧坐标保留,对不上的改用新坐标)。
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@@ -1054,13 +1154,34 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
versionMapper.updateById(ver);
|
||||
return;
|
||||
}
|
||||
// 未换文件重新解析:旧映射优先,避免已对上的字段被新一轮 AI 冲掉
|
||||
// 未换文件重新解析:仍对齐的旧坐标保留;对不上标签的改用新坐标
|
||||
if (mergeExisting && StringUtils.isNotBlank(ver.getMappingJson())) {
|
||||
try {
|
||||
JSONObject old = JSON.parseObject(ver.getMappingJson());
|
||||
if (old != null) {
|
||||
mapping = mergeMappingPreferExisting(old, mapping);
|
||||
log.info("模板映射补缺合并 versionId={} keepFields={}",
|
||||
org.apache.poi.ss.usermodel.Workbook wb = null;
|
||||
try {
|
||||
if (StringUtils.isNotBlank(ver.getObjectKey())) {
|
||||
Path local = resolveObjectPath(ver.getObjectKey());
|
||||
try (InputStream in = Files.newInputStream(local)) {
|
||||
wb = org.apache.poi.ss.usermodel.WorkbookFactory.create(in);
|
||||
}
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
log.warn("合并校验打开模板失败 versionId={} err={}", versionId, ex.getMessage());
|
||||
}
|
||||
try {
|
||||
mapping = mergeMappingPreferExisting(old, mapping, wb);
|
||||
} finally {
|
||||
if (wb != null) {
|
||||
try {
|
||||
wb.close();
|
||||
} catch (Exception ignored) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
log.info("模板映射智能合并 versionId={} oldFields={}",
|
||||
versionId,
|
||||
old.getJSONObject("fields") == null ? 0 : old.getJSONObject("fields").size());
|
||||
}
|
||||
@@ -1088,7 +1209,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
} else if (mergeExisting && !"failed".equals(ver.getParseStatus())) {
|
||||
// 合并后重算摘要点数,避免沿用 AI 单轮字数
|
||||
int pts = ver.getMappingPointCount() == null ? 0 : ver.getMappingPointCount();
|
||||
String base = "已解析 " + pts + " 个映射点(保留原映射并补缺)";
|
||||
String base = "已解析 " + pts + " 个映射点(对齐的保留,对不上的改用新坐标)";
|
||||
if (StringUtils.isNotBlank(parseMessage) && parseMessage.contains("未解析到")) {
|
||||
int idx = parseMessage.indexOf(";未解析到");
|
||||
if (idx > 0) {
|
||||
@@ -1103,10 +1224,23 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
}
|
||||
|
||||
/**
|
||||
* 补缺合并:旧 fields / feeRegions / routeRegions 优先;新结果只补充旧里没有的 code/id。
|
||||
* 不删旧项;mappingGaps 以新结果为准(智能体已按合并后复评时更准,此处兜底保留新 gaps)。
|
||||
* 重新解析合并(无 Workbook 时退化为旧坐标优先,兼容单测)。
|
||||
*/
|
||||
static JSONObject mergeMappingPreferExisting(JSONObject oldMapping, JSONObject newMapping) {
|
||||
public static JSONObject mergeMappingPreferExisting(JSONObject oldMapping, JSONObject newMapping) {
|
||||
return mergeMappingPreferExisting(oldMapping, newMapping, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新解析合并:费用/线路区仍按 id 旧优先;fields 在对照模板后智能取舍。
|
||||
* <p>
|
||||
* 规则:旧无→用新;新无→留旧;两者相同→留旧;旧坐标仍贴标签→留旧;
|
||||
* 旧坐标对不上标签(有 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<InquiryQuoteTem
|
||||
if (oldMapping == null) {
|
||||
return out;
|
||||
}
|
||||
// fields:先放新的,再用旧的覆盖同名 → 旧优先
|
||||
JSONObject fields = new JSONObject(true);
|
||||
JSONObject newFields = newMapping == null ? null : newMapping.getJSONObject("fields");
|
||||
JSONObject oldFields = oldMapping.getJSONObject("fields");
|
||||
JSONObject fields = new JSONObject(true);
|
||||
if (newFields != null) {
|
||||
fields.putAll(newFields);
|
||||
}
|
||||
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);
|
||||
JSONObject newMeta = newMapping == null ? null : newMapping.getJSONObject("fieldMeta");
|
||||
JSONObject oldMeta = oldMapping.getJSONObject("fieldMeta");
|
||||
JSONObject metaOut = new JSONObject(true);
|
||||
if (newMeta != null) {
|
||||
metaOut.putAll(newMeta);
|
||||
}
|
||||
Set<String> codes = new LinkedHashSet<>();
|
||||
if (oldFields != null) {
|
||||
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());
|
||||
|
||||
+4
-2
@@ -12,7 +12,7 @@ import java.util.Map;
|
||||
* 陆运拉群人员匹配:只看员工管理里岗位=产品·陆运且启用的人。
|
||||
* <p>
|
||||
* 用本单线路类别对负责线路:按逗号/顿号拆整词,禁止把「中港/中亚/中欧」按斜杠切开。
|
||||
* 没有「陆运全线」快捷项;四条都要跟就须后台四条都勾。
|
||||
* 负责线路含「陆运全线」时视为命中全部线路类别(与海运「海运全线」对称)。
|
||||
* 禁止:按销售 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<String> tokens = routeTokens(row.getRoutes());
|
||||
// 陆运全线:跟海运全线一样,一条勾选覆盖全部线路类别
|
||||
if (!tokens.contains("陆运全线") && !tokens.contains(want)) {
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
|
||||
+795
-31
File diff suppressed because it is too large
Load Diff
+201
-8
@@ -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。
|
||||
* <p>
|
||||
* 为何:模板 routeRegions 的 tmsSource=landSegments;若不落库/不入 quote,
|
||||
* 出单只能写出始发目的时效,车型价/分腿价全空。
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void putLandSegments(Map<String, String> quote, Map<String, Object> data, Map<String, Object> 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<Map<String, String>> 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<Map<String, String>> lines = new ArrayList<>();
|
||||
for (Map<String, String> seg : rows) {
|
||||
String amount = firstText(seg.get("salePrice"), seg.get("costPrice"), seg.get("amount"));
|
||||
if (StringUtils.isBlank(amount) || "-".equals(amount)) {
|
||||
continue;
|
||||
}
|
||||
Map<String, String> 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<Map<String, String>> normalizeLandSegmentRows(Object raw) {
|
||||
List<Map<String, String>> 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<String, String> 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<String, Object> 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<String, String> quote, Map<String, Object> data) {
|
||||
if (quote == null || data == null) {
|
||||
return;
|
||||
}
|
||||
Object raw = first(data.get("feeItems"), data.get("feeList"));
|
||||
if (!(raw instanceof List)) {
|
||||
return;
|
||||
}
|
||||
Map<String, List<Map<String, String>>> 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<String, List<Map<String, String>>> 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<Map<String, String>> 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 原文,没有就空着。
|
||||
*/
|
||||
|
||||
+76
@@ -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';
|
||||
+27
@@ -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;
|
||||
+9
@@ -14,6 +14,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
* 陆运拉群人员匹配:不连库。
|
||||
* <p>
|
||||
* 只认产品·陆运启用岗;负责线路按逗号整词对线路类别,禁止按斜杠切开「中港/中亚/中欧」。
|
||||
* 「陆运全线」命中任意线路类别。
|
||||
*/
|
||||
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", "海", "东南亚");
|
||||
|
||||
+6
@@ -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", "李产品", "上海,宁波");
|
||||
|
||||
+174
-8
@@ -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<String, Object> facts = new HashMap<>();
|
||||
facts.put("起运港", "泉州市");
|
||||
facts.put("目的港", "南京市");
|
||||
facts.put("运输类型", "国内运输拼车");
|
||||
Map<String, Object> 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;
|
||||
|
||||
+114
@@ -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 纠偏、重新解析智能合并、备注过滤。
|
||||
* <p>
|
||||
* 产品口径: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;
|
||||
}
|
||||
}
|
||||
+60
-12
@@ -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<String, Object> option = new HashMap<>();
|
||||
option.put("optionNo", "AIR-OPT01");
|
||||
option.put("originAirportCode", "ZUH");
|
||||
option.put("destinationAirportCode", "CRK");
|
||||
public void toAgentQuoteSplitsSeaFeeBuckets() {
|
||||
Map<String, Object> pol = new HashMap<>();
|
||||
pol.put("feeName", "操作费");
|
||||
pol.put("amount", 680);
|
||||
pol.put("currency", "CNY");
|
||||
Map<String, Object> ocean = new HashMap<>();
|
||||
ocean.put("feeName", "海运费");
|
||||
ocean.put("amount", 100);
|
||||
ocean.put("currency", "USD");
|
||||
Map<String, Object> pod = new HashMap<>();
|
||||
pod.put("feeName", "文件费");
|
||||
pod.put("amount", 450);
|
||||
pod.put("currency", "CNY");
|
||||
List<Map<String, Object>> 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<String, Object> 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<String, Object> tms = new HashMap<>();
|
||||
tms.put("code", 1);
|
||||
tms.put("tmsData", data);
|
||||
Map<String, String> 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<String, Object> 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<String, Object> 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<String, Object> tms = new HashMap<>();
|
||||
tms.put("code", 1);
|
||||
tms.put("tmsData", data);
|
||||
Map<String, String> 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"));
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -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<SysUser>()
|
||||
.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缓存有效时间
|
||||
|
||||
+10
@@ -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不同步
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -361,9 +361,8 @@ function removeAccount(row: AccountRow) {
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<span v-else class="account-locked" title="超级管理员不可在后台编辑">不可编辑</span>
|
||||
<!-- 超管只隐藏编辑/删除;重置密码仍可用。不展示「不可编辑」占位文案 -->
|
||||
<button
|
||||
v-if="!row.isSuperAdmin"
|
||||
class="link-action link-muted"
|
||||
type="button"
|
||||
@click="resetPassword(row)"
|
||||
@@ -520,11 +519,6 @@ function removeAccount(row: AccountRow) {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.account-locked {
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.account-self-only {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 角色管理:对接 Jeecg /sys/role 与菜单权限树。
|
||||
* 数据权限用线路标签写入角色 description,避免伪造第二套 RBAC。
|
||||
* 数据权限按 Excel 线路目录树勾选,写入角色 description 的 [dataPerms:…],不另造 RBAC。
|
||||
*/
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { message, Modal } from "ant-design-vue";
|
||||
@@ -15,6 +15,14 @@ import {
|
||||
saveRolePermission,
|
||||
type RoleRow,
|
||||
} from "/@/views/inquiry/role/role.api";
|
||||
import {
|
||||
DATA_PERM_TREE,
|
||||
SYSTEM_EXCEPTION_NOTIFY_KEY,
|
||||
SYSTEM_EXCEPTION_NOTIFY_TITLE,
|
||||
dataPermTitle,
|
||||
leafDataPermKeys,
|
||||
normalizeDataPermKeys,
|
||||
} from "/@/views/inquiry/shared/dataPermCatalog";
|
||||
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
|
||||
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
|
||||
|
||||
@@ -30,19 +38,12 @@ const form = reactive({
|
||||
status: "active" as RoleRow["status"],
|
||||
checkedKeys: [] as string[],
|
||||
dataPerms: [] as string[],
|
||||
/** 旧后台独立项:系统异常消息通知 */
|
||||
systemExceptionNotify: false,
|
||||
});
|
||||
|
||||
const dataOptions = [
|
||||
"空运·全部线路",
|
||||
"空运·CGO-KUL",
|
||||
"空运·HKG-KUL",
|
||||
"海运·全部线路",
|
||||
"海运·海运全线",
|
||||
"陆运·全部线路",
|
||||
"陆运·中港/中亚/中欧",
|
||||
"陆运·东南亚",
|
||||
"系统异常·消息通知",
|
||||
];
|
||||
const dataPermTree = DATA_PERM_TREE;
|
||||
const systemExceptionNotifyTitle = SYSTEM_EXCEPTION_NOTIFY_TITLE;
|
||||
|
||||
const modalTitle = computed(() => (editing.value ? "编辑角色" : "新增角色"));
|
||||
const modalOkText = computed(() => "保存");
|
||||
@@ -68,15 +69,29 @@ function parseDataPerms(desc: string | undefined): string[] {
|
||||
if (!desc) return [];
|
||||
const m = desc.match(/\[dataPerms:(.+?)\]/);
|
||||
if (!m) return [];
|
||||
return m[1]
|
||||
return normalizeDataPermKeys(
|
||||
m[1]
|
||||
.split("|")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
function buildDescription(dataPerms: string[]) {
|
||||
if (!dataPerms.length) return "";
|
||||
return `[dataPerms:${dataPerms.join("|")}]`;
|
||||
function buildDescription(dataPerms: string[], systemExceptionNotify: boolean) {
|
||||
const keys = leafDataPermKeys(dataPerms);
|
||||
if (systemExceptionNotify && !keys.includes(SYSTEM_EXCEPTION_NOTIFY_KEY)) {
|
||||
keys.push(SYSTEM_EXCEPTION_NOTIFY_KEY);
|
||||
}
|
||||
if (!keys.length) return "";
|
||||
return `[dataPerms:${keys.join("|")}]`;
|
||||
}
|
||||
|
||||
function displayDataPerms(keys: string[]): string[] {
|
||||
return keys.map((k) => dataPermTitle(k));
|
||||
}
|
||||
|
||||
function routeDataPermsOnly(keys: string[]): string[] {
|
||||
return keys.filter((k) => k !== SYSTEM_EXCEPTION_NOTIFY_KEY);
|
||||
}
|
||||
|
||||
async function loadMenuTree() {
|
||||
@@ -143,18 +158,21 @@ function openCreate() {
|
||||
status: "active",
|
||||
checkedKeys: [],
|
||||
dataPerms: [],
|
||||
systemExceptionNotify: false,
|
||||
});
|
||||
modalOpen.value = true;
|
||||
}
|
||||
|
||||
async function openEdit(row: RoleRow) {
|
||||
editing.value = row;
|
||||
const allPerms = [...row.dataPerms];
|
||||
Object.assign(form, {
|
||||
name: row.name,
|
||||
roleCode: row.roleCode,
|
||||
status: row.status,
|
||||
checkedKeys: [] as string[],
|
||||
dataPerms: [...row.dataPerms],
|
||||
dataPerms: routeDataPermsOnly(allPerms),
|
||||
systemExceptionNotify: allPerms.includes(SYSTEM_EXCEPTION_NOTIFY_KEY),
|
||||
});
|
||||
try {
|
||||
const perms: any = await queryRolePermission({ roleId: row.id });
|
||||
@@ -179,7 +197,7 @@ async function save() {
|
||||
id: editing.value?.id,
|
||||
roleName: form.name.trim(),
|
||||
roleCode,
|
||||
description: buildDescription(form.dataPerms),
|
||||
description: buildDescription(form.dataPerms, form.systemExceptionNotify),
|
||||
},
|
||||
Boolean(editing.value),
|
||||
);
|
||||
@@ -214,6 +232,12 @@ function removeRole(row: RoleRow) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** a-tree 可能回传对象或数组;统一成叶子 key 列表 */
|
||||
function onDataPermCheck(checked: string[] | { checked: string[] }) {
|
||||
const keys = Array.isArray(checked) ? checked : checked?.checked || [];
|
||||
form.dataPerms = leafDataPermKeys(keys.map(String));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -252,7 +276,7 @@ function removeRole(row: RoleRow) {
|
||||
</td>
|
||||
<td>
|
||||
<div class="role-perm-summary">
|
||||
<span v-for="d in row.dataPerms" :key="d" class="tag">{{ d }}</span>
|
||||
<span v-for="d in displayDataPerms(row.dataPerms)" :key="d" class="tag">{{ d }}</span>
|
||||
<span v-if="!row.dataPerms.length" style="color: #94a3b8">—</span>
|
||||
</div>
|
||||
</td>
|
||||
@@ -306,15 +330,21 @@ function removeRole(row: RoleRow) {
|
||||
|
||||
<div class="admin-modal-field">
|
||||
<label class="admin-modal-label">数据权限</label>
|
||||
<a-select
|
||||
v-model:value="form.dataPerms"
|
||||
mode="multiple"
|
||||
style="width: 100%"
|
||||
:options="dataOptions.map((d) => ({ value: d, label: d }))"
|
||||
placeholder="选择线路 / 通知范围"
|
||||
:max-tag-count="3"
|
||||
<div class="perm-tree-box">
|
||||
<a-tree
|
||||
:checkedKeys="form.dataPerms"
|
||||
checkable
|
||||
default-expand-all
|
||||
:tree-data="dataPermTree"
|
||||
:field-names="{ title: 'title', key: 'key', children: 'children' }"
|
||||
@check="onDataPermCheck"
|
||||
/>
|
||||
</div>
|
||||
<label class="notify-check">
|
||||
<input v-model="form.systemExceptionNotify" type="checkbox" />
|
||||
{{ systemExceptionNotifyTitle }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="admin-modal-field">
|
||||
<label class="admin-modal-label">状态</label>
|
||||
@@ -340,4 +370,14 @@ function removeRole(row: RoleRow) {
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.notify-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
color: #0f172a;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* 运营后台数据权限 / 员工负责线路 单一目录。
|
||||
*
|
||||
* 来源:《询价智能体运营后台数据权限下拉选项.xlsx》(2026-09)。
|
||||
* key = 写入 sys_role.description / inq_staff_role.routes 的稳定码;
|
||||
* title = 界面展示文案。
|
||||
*/
|
||||
|
||||
export type DataPermNode = {
|
||||
key: string;
|
||||
title: string;
|
||||
children?: DataPermNode[];
|
||||
};
|
||||
|
||||
/** 空运具体线路(不含全线) */
|
||||
export const AIR_ROUTE_ITEMS: { key: string; title: string }[] = [
|
||||
{ key: "CGO-KUL", title: "郑州-吉隆坡(CGO-KUL)" },
|
||||
{ key: "SZX-KUL", title: "深圳-吉隆坡(SZX-KUL)" },
|
||||
{ key: "HKG-KUL", title: "香港-吉隆坡(HKG-KUL)" },
|
||||
{ key: "NKG-KUL", title: "南京-吉隆坡(NKG-KUL)" },
|
||||
{ key: "HKG-DEL", title: "香港-新德里(HKG-DEL)" },
|
||||
{ key: "CGO-DEL", title: "郑州-新德里(CGO-DEL)" },
|
||||
{ key: "CGO-BLR", title: "郑州-班加罗尔(CGO-BLR)" },
|
||||
{ key: "CGO-MAA", title: "郑州-金奈(CGO-MAA)" },
|
||||
{ key: "CTU-MAA", title: "成都-金奈(CTU-MAA)" },
|
||||
{ key: "CKG-BOM", title: "重庆-孟买(CKG-BOM)" },
|
||||
{ key: "ZUH-CRK", title: "珠海-克拉克(ZUH-CRK)" },
|
||||
{ key: "SZX-CRK", title: "深圳-克拉克(SZX-CRK)" },
|
||||
{ key: "EHU-ORD", title: "鄂州-芝加哥(EHU-ORD)" },
|
||||
{ key: "WNZ-CGK", title: "温州-雅加达(WNZ-CGK)" },
|
||||
{ key: "国内-曼谷(BKK 外采)", title: "国内-曼谷(BKK 外采)" },
|
||||
{ key: "国外-国外(外采-空运)", title: "国外-国外(外采-空运)" },
|
||||
{ key: "其他线路和快递双清包税(外采-空运)", title: "其他线路和快递双清包税(外采-空运)" },
|
||||
];
|
||||
|
||||
export const LAND_ROUTE_ITEMS: { key: string; title: string }[] = [
|
||||
{ key: "中港/中亚/中欧", title: "中港/中亚/中欧" },
|
||||
{ key: "国内长途/零担", title: "国内长途/零担" },
|
||||
{ key: "国内城配/拖车/打包", title: "国内城配/拖车/打包" },
|
||||
{ key: "东南亚", title: "东南亚" },
|
||||
];
|
||||
|
||||
/** 角色弹窗数据权限树(父节点仅作分组,不写入 description;文案对齐旧后台「空运数据」) */
|
||||
export const DATA_PERM_TREE: DataPermNode[] = [
|
||||
{
|
||||
key: "group:air",
|
||||
title: "空运数据",
|
||||
children: [{ key: "空运全线", title: "空运全线" }, ...AIR_ROUTE_ITEMS],
|
||||
},
|
||||
{
|
||||
key: "group:sea",
|
||||
title: "海运数据",
|
||||
children: [{ key: "海运全线", title: "海运全线" }],
|
||||
},
|
||||
{
|
||||
key: "group:land",
|
||||
title: "陆运数据",
|
||||
children: [{ key: "陆运全线", title: "陆运全线" }, ...LAND_ROUTE_ITEMS],
|
||||
},
|
||||
];
|
||||
|
||||
/** 旧后台独立勾选项:系统异常消息通知(企微) */
|
||||
export const SYSTEM_EXCEPTION_NOTIFY_KEY = "系统异常·消息通知";
|
||||
|
||||
export const SYSTEM_EXCEPTION_NOTIFY_TITLE = "系统异常消息通知(企微)";
|
||||
|
||||
/** 员工岗位 → 可选负责线路 key(与角色数据权限同源) */
|
||||
export const ROUTES_BY_ROLE: Record<"air" | "sea" | "land", string[]> = {
|
||||
air: ["空运全线", ...AIR_ROUTE_ITEMS.map((x) => x.key)],
|
||||
sea: ["海运全线"],
|
||||
land: ["陆运全线", ...LAND_ROUTE_ITEMS.map((x) => x.key)],
|
||||
};
|
||||
|
||||
const TITLE_BY_KEY: Record<string, string> = (() => {
|
||||
const m: Record<string, string> = {
|
||||
空运全线: "空运全线",
|
||||
海运全线: "海运全线",
|
||||
陆运全线: "陆运全线",
|
||||
[SYSTEM_EXCEPTION_NOTIFY_KEY]: SYSTEM_EXCEPTION_NOTIFY_TITLE,
|
||||
};
|
||||
for (const x of AIR_ROUTE_ITEMS) m[x.key] = x.title;
|
||||
for (const x of LAND_ROUTE_ITEMS) m[x.key] = x.title;
|
||||
return m;
|
||||
})();
|
||||
|
||||
/** 已知稳定码集合(不含分组父节点) */
|
||||
export const KNOWN_DATA_PERM_KEYS = new Set(Object.keys(TITLE_BY_KEY));
|
||||
|
||||
/**
|
||||
* 旧写法 → 新稳定码。
|
||||
* 无法识别时返回 trim 后原文(可能仍展示,但不进勾选树)。
|
||||
*/
|
||||
const ALIAS_TO_KEY: Record<string, string> = {
|
||||
空运全线: "空运全线",
|
||||
空运全部线路: "空运全线",
|
||||
"空运·全部线路": "空运全线",
|
||||
"空运·全线": "空运全线",
|
||||
全线: "空运全线",
|
||||
海运全线: "海运全线",
|
||||
"海运·全部线路": "海运全线",
|
||||
"海运·海运全线": "海运全线",
|
||||
陆运全线: "陆运全线",
|
||||
"陆运·全部线路": "陆运全线",
|
||||
"陆运·中港/中亚/中欧": "中港/中亚/中欧",
|
||||
"陆运·国内长途/零担": "国内长途/零担",
|
||||
"陆运·国内城配/拖车/打包": "国内城配/拖车/打包",
|
||||
"陆运·东南亚": "东南亚",
|
||||
BKK: "国内-曼谷(BKK 外采)",
|
||||
"外采-空运": "国外-国外(外采-空运)",
|
||||
[SYSTEM_EXCEPTION_NOTIFY_KEY]: SYSTEM_EXCEPTION_NOTIFY_KEY,
|
||||
系统异常消息通知: SYSTEM_EXCEPTION_NOTIFY_KEY,
|
||||
"系统异常消息通知(企微)": SYSTEM_EXCEPTION_NOTIFY_KEY,
|
||||
};
|
||||
|
||||
for (const item of AIR_ROUTE_ITEMS) {
|
||||
ALIAS_TO_KEY[item.key] = item.key;
|
||||
ALIAS_TO_KEY[item.title] = item.key;
|
||||
ALIAS_TO_KEY[`空运·${item.key}`] = item.key;
|
||||
}
|
||||
for (const item of LAND_ROUTE_ITEMS) {
|
||||
ALIAS_TO_KEY[item.key] = item.key;
|
||||
ALIAS_TO_KEY[`陆运·${item.key}`] = item.key;
|
||||
}
|
||||
|
||||
export function normalizeDataPermKey(raw: string): string {
|
||||
const s = String(raw || "").trim();
|
||||
if (!s) return "";
|
||||
if (ALIAS_TO_KEY[s]) return ALIAS_TO_KEY[s];
|
||||
// 空运·XXX / 陆运·XXX 兜底剥前缀
|
||||
const m = s.match(/^(?:空运|海运|陆运)[·•](.+)$/);
|
||||
if (m) {
|
||||
const inner = m[1].trim();
|
||||
if (ALIAS_TO_KEY[inner]) return ALIAS_TO_KEY[inner];
|
||||
if (inner === "全部线路" || inner === "全线") {
|
||||
if (s.startsWith("空运")) return "空运全线";
|
||||
if (s.startsWith("海运")) return "海运全线";
|
||||
if (s.startsWith("陆运")) return "陆运全线";
|
||||
}
|
||||
if (KNOWN_DATA_PERM_KEYS.has(inner)) return inner;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** 批量归一;去重保序;只保留已知码(角色勾选回显用) */
|
||||
export function normalizeDataPermKeys(rawList: string[]): string[] {
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const raw of rawList || []) {
|
||||
const k = normalizeDataPermKey(raw);
|
||||
if (!k || !KNOWN_DATA_PERM_KEYS.has(k) || seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
out.push(k);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 员工 routes:归一后保留已知码;未知旧值也保留以免丢数据 */
|
||||
export function normalizeStaffRouteKeys(rawList: string[]): string[] {
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const raw of rawList || []) {
|
||||
const k = normalizeDataPermKey(raw);
|
||||
if (!k || seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
out.push(k);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function dataPermTitle(key: string): string {
|
||||
const k = normalizeDataPermKey(key);
|
||||
return TITLE_BY_KEY[k] || key;
|
||||
}
|
||||
|
||||
/** Ant Design Tree 用:过滤掉分组父 key,只交叶子 */
|
||||
export function leafDataPermKeys(checked: string[]): string[] {
|
||||
return (checked || []).filter((k) => KNOWN_DATA_PERM_KEYS.has(k));
|
||||
}
|
||||
@@ -20,6 +20,11 @@ import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
|
||||
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
|
||||
import AdminPagination from "/@/views/inquiry/components/AdminPagination.vue";
|
||||
import { joinRoutes, parseRouteList } from "/@/views/inquiry/staffRole/staffRoutes";
|
||||
import {
|
||||
ROUTES_BY_ROLE,
|
||||
dataPermTitle,
|
||||
normalizeStaffRouteKeys,
|
||||
} from "/@/views/inquiry/shared/dataPermCatalog";
|
||||
|
||||
type StaffRole = "sales" | "air" | "sea" | "land";
|
||||
|
||||
@@ -52,26 +57,7 @@ const roleLabelMap: Record<StaffRole, string> = {
|
||||
land: "产品·陆运",
|
||||
};
|
||||
|
||||
/** 按岗位的负责线路选项(与原型 admin-staff.html ROUTES_BY_ROLE 一致) */
|
||||
const ROUTES_BY_ROLE: Record<Exclude<StaffRole, "sales">, string[]> = {
|
||||
air: [
|
||||
"CGO-KUL",
|
||||
"SZX-KUL",
|
||||
"HKG-KUL",
|
||||
"NKG-KUL",
|
||||
"HKG-DEL",
|
||||
"CGO-MAA",
|
||||
"CGO-BLR",
|
||||
"ZUH-CRK",
|
||||
"WNZ-CGK",
|
||||
"BKK",
|
||||
"外采-空运",
|
||||
"全线",
|
||||
],
|
||||
sea: ["海运全线"],
|
||||
land: ["中港/中亚/中欧", "国内长途/零担", "国内城配/拖车/打包", "东南亚"],
|
||||
};
|
||||
|
||||
/** 负责线路与角色数据权限同源(Excel《数据权限下拉选项》) */
|
||||
const ALL_ROUTE_FILTER_OPTIONS = [
|
||||
...ROUTES_BY_ROLE.air,
|
||||
...ROUTES_BY_ROLE.sea,
|
||||
@@ -120,8 +106,11 @@ const form = reactive({
|
||||
|
||||
/** 当前岗位可选线路;销售为空 */
|
||||
const routeOptions = computed(() => {
|
||||
if (!isProductOrAir(form.role)) return [] as string[];
|
||||
return ROUTES_BY_ROLE[form.role];
|
||||
if (!isProductOrAir(form.role)) return [] as { label: string; value: string }[];
|
||||
return ROUTES_BY_ROLE[form.role].map((key) => ({
|
||||
value: key,
|
||||
label: dataPermTitle(key),
|
||||
}));
|
||||
});
|
||||
|
||||
const showLeaderAndRoutes = computed(() => isProductOrAir(form.role));
|
||||
@@ -367,7 +356,7 @@ watch(
|
||||
return;
|
||||
}
|
||||
const valid = new Set(ROUTES_BY_ROLE[role]);
|
||||
form.routeList = form.routeList.filter((r) => valid.has(r));
|
||||
form.routeList = normalizeStaffRouteKeys(form.routeList).filter((r) => valid.has(r));
|
||||
},
|
||||
);
|
||||
|
||||
@@ -392,7 +381,10 @@ function openCreate() {
|
||||
function openEdit(row: StaffRow) {
|
||||
editing.value = row;
|
||||
const valid = isProductOrAir(row.role) ? new Set(ROUTES_BY_ROLE[row.role]) : null;
|
||||
const routes = parseRouteList(row.routes).filter((r) => (valid ? valid.has(r) : true));
|
||||
// 旧 routes(全线/BKK/外采-空运 等)归一到 Excel 码后再过滤本岗选项
|
||||
const routes = normalizeStaffRouteKeys(parseRouteList(row.routes)).filter((r) =>
|
||||
valid ? valid.has(r) : true,
|
||||
);
|
||||
Object.assign(form, {
|
||||
name: row.name,
|
||||
wecomId: row.wecomId,
|
||||
@@ -432,7 +424,9 @@ async function saveStaff() {
|
||||
departName: form.dept.trim() || undefined,
|
||||
roleCode: form.role,
|
||||
roleLabel: roleLabelMap[form.role],
|
||||
routes: isProductOrAir(form.role) ? joinRoutes(form.routeList) : undefined,
|
||||
routes: isProductOrAir(form.role)
|
||||
? joinRoutes(normalizeStaffRouteKeys(form.routeList))
|
||||
: undefined,
|
||||
isLeader: isProductOrAir(form.role) ? (form.isLeader === "true" ? 1 : 0) : null,
|
||||
notifyEventException: form.notifyEventException ? 1 : 0,
|
||||
status: form.status,
|
||||
@@ -480,7 +474,7 @@ async function syncWecom() {
|
||||
}
|
||||
|
||||
function routeTags(routes: string) {
|
||||
return parseRouteList(routes);
|
||||
return normalizeStaffRouteKeys(parseRouteList(routes)).map((k) => dataPermTitle(k));
|
||||
}
|
||||
|
||||
function filterRouteOption(input: string, option: any) {
|
||||
@@ -535,7 +529,7 @@ function filterRouteOption(input: string, option: any) {
|
||||
</select>
|
||||
<select v-model="filters.route">
|
||||
<option value="">负责线路</option>
|
||||
<option v-for="r in ALL_ROUTE_FILTER_OPTIONS" :key="r" :value="r">{{ r }}</option>
|
||||
<option v-for="r in ALL_ROUTE_FILTER_OPTIONS" :key="r" :value="r">{{ dataPermTitle(r) }}</option>
|
||||
</select>
|
||||
<select v-model="filters.status">
|
||||
<option value="">状态</option>
|
||||
@@ -713,7 +707,7 @@ function filterRouteOption(input: string, option: any) {
|
||||
show-search
|
||||
class="staff-route-select"
|
||||
placeholder="请选择负责线路,可多选"
|
||||
:options="routeOptions.map((r) => ({ label: r, value: r }))"
|
||||
:options="routeOptions"
|
||||
:filter-option="filterRouteOption"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 员工管理「负责线路」拆合。
|
||||
*
|
||||
* 只按逗号/中文逗号/顿号拆整词,禁止把「中港/中亚/中欧」「国内长途/零担」按斜杠切开。
|
||||
* 陆运没有「陆运全线」快捷项。
|
||||
* 选项与角色数据权限同源(含空运全线 / 海运全线 / 陆运全线)。
|
||||
*/
|
||||
|
||||
/** 把库里的逗号串还原成多选项。空或占位「-」当没有。 */
|
||||
|
||||
@@ -434,7 +434,7 @@ async function toggleStatus(row: TemplateRow) {
|
||||
|
||||
/**
|
||||
* 重新提交 AI 映射解析(已解析也可点;解析中禁用)。
|
||||
* 未换文件:主账带 mergeExisting,回写时保留已有字段只补缺。
|
||||
* 未换文件:对齐标签的旧坐标保留,对不上的改用新坐标;缺的字段仍补上。
|
||||
* 副作用:主账入队;列表进入解析中并轮询。
|
||||
*/
|
||||
async function reparseRow(row: TemplateRow) {
|
||||
@@ -691,7 +691,7 @@ async function downloadPreviewOrOriginal() {
|
||||
:title="
|
||||
row.parseStatus === '解析中'
|
||||
? '解析中,请稍候'
|
||||
: '重新解析:保留已有映射,只补缺;换文件请用编辑上传'
|
||||
: '重新解析:对齐的保留,对不上的改坐标;换文件请用编辑上传'
|
||||
"
|
||||
@click.stop="reparseRow(row)"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user