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

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
@@ -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);
}
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) {
for (String code : oldFields.keySet()) {
Object cell = oldFields.get(code);
if (cell != null && StringUtils.isNotBlank(String.valueOf(cell))) {
fields.put(code, cell);
codes.addAll(oldFields.keySet());
}
if (newFields != null) {
codes.addAll(newFields.keySet());
}
for (String code : codes) {
String oldCell = oldFields == null ? null : StringUtils.trimToNull(oldFields.getString(code));
String newCell = newFields == null ? null : StringUtils.trimToNull(newFields.getString(code));
String chosen;
boolean keptOld;
if (StringUtils.isBlank(oldCell)) {
chosen = newCell;
keptOld = false;
} else if (StringUtils.isBlank(newCell)) {
chosen = oldCell;
keptOld = true;
} else if (oldCell.equalsIgnoreCase(newCell)) {
chosen = oldCell;
keptOld = true;
// 新旧都是 B3 单选时仍要纠到 RFQ 标签格
if ("quote_no".equals(code) && workbook != null
&& !QuoteTemplateExcelSupport.fieldMappingStillAligned(workbook, code, oldCell)) {
String better = QuoteTemplateExcelSupport.relocateQuoteNoCell(workbook, oldCell);
if (StringUtils.isNotBlank(better) && !better.equalsIgnoreCase(oldCell)) {
chosen = better;
keptOld = false;
JSONObject qm = metaOut.getJSONObject("quote_no");
if (qm == null) {
qm = new JSONObject(true);
}
qm.put("sameCellAfterColon", true);
metaOut.put("quote_no", qm);
log.info("映射坐标纠偏 code={} old={} -> new={}", code, oldCell, chosen);
}
}
} else if (workbook != null
&& QuoteTemplateExcelSupport.fieldMappingStillAligned(workbook, code, oldCell)) {
chosen = oldCell;
keptOld = true;
} else if (workbook != null) {
// 旧坐标对不上标签 → 用新;quote_no 若仍落在单选格则再纠到标签格冒号后
chosen = newCell;
keptOld = false;
if ("quote_no".equals(code)) {
String better = QuoteTemplateExcelSupport.relocateQuoteNoCell(workbook, newCell);
if (StringUtils.isNotBlank(better)) {
chosen = better;
}
// 纠到标签格后,出单必须同格冒号后写
JSONObject qm = metaOut.getJSONObject("quote_no");
if (qm == null) {
qm = new JSONObject(true);
}
qm.put("sameCellAfterColon", true);
metaOut.put("quote_no", qm);
}
log.info("映射坐标纠偏 code={} old={} -> new={}", code, oldCell, chosen);
} else {
// 无表可校验时保守保留旧坐标
chosen = oldCell;
keptOld = true;
}
if (StringUtils.isNotBlank(chosen)) {
fields.put(code, chosen);
}
if (keptOld && oldMeta != null && oldMeta.getJSONObject(code) != null) {
metaOut.put(code, oldMeta.getJSONObject(code));
}
}
out.put("fields", fields);
if (!metaOut.isEmpty()) {
out.put("fieldMeta", metaOut);
}
out.put("feeRegions", mergeRegionsById(
oldMapping.getJSONArray("feeRegions"),
newMapping == null ? null : newMapping.getJSONArray("feeRegions")));
out.put("routeRegions", mergeRegionsById(
oldMapping.getJSONArray("routeRegions"),
newMapping == null ? null : newMapping.getJSONArray("routeRegions")));
// 说明性标签:齐全时清空;否则保留新的
if (out.getJSONArray("mappingGaps") != null && out.getJSONArray("mappingGaps").isEmpty()) {
out.put("unmappedRequired", new JSONArray());
out.put("unmappedLabels", new JSONArray());
@@ -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不同步
*/