完善报价单填表与币种回填,并补齐账号末次登录、角色按钮权限与陆运全线匹配。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-09-19 22:03:45 +08:00
co-authored by Cursor
parent 1a2e1d6cb2
commit e4743541f7
25 changed files with 2262 additions and 163 deletions
@@ -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")
+14 -4
View File
@@ -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:
+197 -6
View File
@@ -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]] = []
+42 -3
View File
@@ -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] = []
@@ -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;
}
@@ -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());
@@ -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<>();
@@ -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 原文,没有就空着。
*/
@@ -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';
@@ -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;
@@ -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", "海", "东南亚");
@@ -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", "李产品", "上海,宁波");
@@ -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;
@@ -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;
}
}
@@ -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"));
}
}
@@ -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缓存有效时间
@@ -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)"
>