陆运多条线路按段展示 TMS 费用名和时效,选定后再写回该条成本。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+10
@@ -290,6 +290,16 @@ public class InquiryAgentController {
|
||||
return Result.OK(workOrderService.queryTms(body));
|
||||
}
|
||||
|
||||
@ApiOperation("覆盖本单最新 TMS 报价(选定陆运线路后写回成本)")
|
||||
@PostMapping("/ticket/replaceTmsQuote")
|
||||
public Result<Map<String, Object>> replaceTmsQuote(@RequestBody Map<String, Object> body,
|
||||
HttpServletRequest http) {
|
||||
if (!authed(http)) {
|
||||
return Result.error(401, "unauthorized");
|
||||
}
|
||||
return Result.OK(workOrderService.replaceTmsQuote(body));
|
||||
}
|
||||
|
||||
@ApiOperation("海运拉群:按起运/目的匹配启用的产品·海运")
|
||||
@PostMapping("/staff/matchSea")
|
||||
public Result<Map<String, Object>> matchSea(@RequestBody Map<String, Object> body,
|
||||
|
||||
+6
@@ -78,6 +78,12 @@ public interface IInquiryWorkOrderService extends IService<InquiryTicket> {
|
||||
*/
|
||||
Map<String, Object> queryTms(Map<String, Object> body);
|
||||
|
||||
/**
|
||||
* 覆盖本单最新 TMS 报价快照(选定陆运线路后写回成本/时效)。
|
||||
* 不新插 TMS 行,不改手工/调价行。
|
||||
*/
|
||||
Map<String, Object> replaceTmsQuote(Map<String, Object> body);
|
||||
|
||||
/**
|
||||
* 智能体续办:按工单号取字段/报价/推断停点。只读。
|
||||
* quote 是当前生效价(可能已是销售调价);tms_quote 永远是 TMS 原价,拉群摘要必须用这份。
|
||||
|
||||
+42
-2
@@ -751,7 +751,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
log.warn("报价出单 mappingJson 非法 wo={} err={}", workOrderNo, e.getMessage());
|
||||
return fail;
|
||||
}
|
||||
JSONObject fillFacts = buildFillFacts(workOrderNo, facts, quote, salesWecomId);
|
||||
JSONObject fillFacts = buildFillFacts(workOrderNo, facts, quote, salesWecomId, businessLine);
|
||||
String safeNo = StringUtils.defaultIfBlank(workOrderNo, "WO").replaceAll("[^A-Za-z0-9_-]", "");
|
||||
Path targetDir = Paths.get(StringUtils.defaultIfBlank(uploadPath, "."), BIZ_PATH, "render");
|
||||
Path target = targetDir.resolve(safeNo + "-" + System.currentTimeMillis() + ".xlsx");
|
||||
@@ -782,6 +782,17 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
*/
|
||||
public static JSONObject buildFillFacts(String workOrderNo, Map<String, Object> facts,
|
||||
Map<String, Object> quote, String salesWecomId) {
|
||||
return buildFillFacts(workOrderNo, facts, quote, salesWecomId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 出单填表投影。空运不写时效:业务确认空运没有时效字段。
|
||||
*
|
||||
* @param businessLine 智能体传入的业务线;空时再看询价事实 / 报价快照
|
||||
*/
|
||||
public static JSONObject buildFillFacts(String workOrderNo, Map<String, Object> facts,
|
||||
Map<String, Object> quote, String salesWecomId,
|
||||
String businessLine) {
|
||||
JSONObject out = new JSONObject(true);
|
||||
Map<String, Object> src = facts == null ? new LinkedHashMap<>() : facts;
|
||||
Map<String, Object> q = quote == null ? new LinkedHashMap<>() : quote;
|
||||
@@ -844,8 +855,9 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
out.put("currency", currency);
|
||||
}
|
||||
out.put("sales_name", StringUtils.defaultString(salesWecomId));
|
||||
boolean hideAirEta = looksAirInquiry(src, q, businessLine);
|
||||
String eta = first(q, "eta", "transit_days", "transitDays");
|
||||
if (StringUtils.isNotBlank(eta) && !"-".equals(eta)) {
|
||||
if (!hideAirEta && StringUtils.isNotBlank(eta) && !"-".equals(eta)) {
|
||||
out.put("transit_days", eta);
|
||||
out.put("transit_time", eta);
|
||||
}
|
||||
@@ -1014,6 +1026,14 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
out.put("route_rows", routeRows);
|
||||
}
|
||||
}
|
||||
if (hideAirEta) {
|
||||
// 空运没有时效:询价事实拷进来的、TMS eta、陆运键投影一律拿掉
|
||||
out.remove("transit_days");
|
||||
out.remove("transit_time");
|
||||
out.remove("transitDays");
|
||||
out.remove("时效");
|
||||
out.remove("时效(天)");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -1029,6 +1049,26 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 空运销售面没有时效:出单不把 eta / transit_* 填进模板。
|
||||
* 业务线 AIR / 运输方式含空运 / 报价带 airOptions 都算空运。
|
||||
*/
|
||||
private static boolean looksAirInquiry(Map<String, Object> src, Map<String, Object> quote,
|
||||
String businessLine) {
|
||||
String line = StringUtils.defaultString(businessLine);
|
||||
if (StringUtils.isBlank(line) && src != null) {
|
||||
line = first(src, "业务线", "business_line", "businessLine", "运输方式");
|
||||
}
|
||||
if ("AIR".equalsIgnoreCase(line) || "空".equals(line) || (line != null && line.contains("空运"))) {
|
||||
return true;
|
||||
}
|
||||
if (quote != null && (quote.get("airOptions") != null || quote.get("air_options") != null
|
||||
|| quote.get("selected_air_option") != null)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 询价事实里已有陆运选项/始发站,运费兜底名就不能再写成空运费。
|
||||
*/
|
||||
|
||||
+12
@@ -586,6 +586,18 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
return tmsQueryService.queryFromAgent(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* 选定陆运线路后覆盖 TMS 快照。请求线程只转发,不查 TMS。
|
||||
*/
|
||||
@Override
|
||||
public Map<String, Object> replaceTmsQuote(Map<String, Object> body) {
|
||||
String no = text(body, "workOrderNo", "work_order_no");
|
||||
Object raw = body == null ? null : body.get("quote");
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> quote = raw instanceof Map ? (Map<String, Object>) raw : new LinkedHashMap<String, Object>();
|
||||
return tmsQueryService.replaceTmsQuote(no, quote);
|
||||
}
|
||||
|
||||
private String nextWorkOrderNo() {
|
||||
String day = new SimpleDateFormat("yyyyMMdd").format(new Date());
|
||||
long n = count(new LambdaQueryWrapper<InquiryTicket>().likeRight(InquiryTicket::getWorkOrderNo, "WO" + day));
|
||||
|
||||
+228
-9
@@ -238,12 +238,7 @@ public class InquiryTmsQueryService {
|
||||
row.setTicketId(ticket.getId());
|
||||
row.setWorkOrderNo(workOrderNo);
|
||||
row.setQuoteVersion(1);
|
||||
row.setSource("TMS");
|
||||
row.setSourceLabel("TMS 标准报价");
|
||||
// 后台售价栏必须带 TMS 币种,不能只落纯数字。
|
||||
row.setPriceText(TmsQuoteCurrency.formatDisplay(quote.get("currency"), quote.get("total")));
|
||||
row.setHighlighted(1);
|
||||
row.setPayloadJson(JSON.toJSONString(quote));
|
||||
applySnapshotToQuoteRow(row, quote);
|
||||
row.setCreateTime(new Date());
|
||||
quoteMapper.insert(row);
|
||||
List<InquiryQuote> quotes = quoteMapper.selectList(new LambdaQueryWrapper<InquiryQuote>()
|
||||
@@ -323,6 +318,7 @@ public class InquiryTmsQueryService {
|
||||
quote.put("source", "TMS");
|
||||
quote.put("source_label", "TMS 标准报价");
|
||||
quote.put("fee_lines", buildFeeLinesJson(data, freightMoney.amount, otherMoney.amount, headerCurrency));
|
||||
putFeeItemsRaw(quote, data);
|
||||
// 海运 feeItems 是分段桶:polCostItems / oceanCostItems / podCostItems,单独透出给模板分区回填
|
||||
putSeaFeeBuckets(quote, data);
|
||||
// 锁舱必须带着 TMS 报价编号和舱位选项,不能只落金额。
|
||||
@@ -349,6 +345,8 @@ public class InquiryTmsQueryService {
|
||||
}
|
||||
// 陆运线路段:与空运 airOptions 同级透出,供模板 routeRegions 填项目一
|
||||
putLandSegments(quote, data, tms);
|
||||
// 陆运常无整单总价:一条线路才把各段成本加总;多条备选禁止加在一起
|
||||
applyLandSegmentTotals(quote);
|
||||
String chargeable = firstText(data.get("chargeableWeightKg"), data.get("chargeable_weight_kg"));
|
||||
if (StringUtils.isNotBlank(chargeable)) {
|
||||
quote.put("chargeableWeightKg", chargeable);
|
||||
@@ -385,9 +383,9 @@ public class InquiryTmsQueryService {
|
||||
if (!rows.isEmpty()) {
|
||||
quote.put("route_rows", JSON.toJSONString(rows));
|
||||
}
|
||||
// feeItems 为空时,用各腿售价补一条运输费,避免报价明细全空
|
||||
// feeItems 为空时,用各腿成本补运输费。顶层总价会被误收成「空运费」,陆运要换掉。
|
||||
String feeLines = quote.get("fee_lines");
|
||||
if ((StringUtils.isBlank(feeLines) || "[]".equals(feeLines.trim())) && !rows.isEmpty()) {
|
||||
if (shouldFillLandFeeLines(feeLines) && !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"));
|
||||
@@ -395,11 +393,17 @@ public class InquiryTmsQueryService {
|
||||
continue;
|
||||
}
|
||||
Map<String, String> line = new LinkedHashMap<>();
|
||||
line.put("name", "运输费");
|
||||
line.put("name", firstText(seg.get("feeName"), seg.get("name"), "运输费"));
|
||||
line.put("amount", amount);
|
||||
if (StringUtils.isNotBlank(seg.get("currency"))) {
|
||||
line.put("currency", seg.get("currency"));
|
||||
}
|
||||
if (StringUtils.isNotBlank(seg.get("segmentSequence"))) {
|
||||
line.put("segmentSequence", seg.get("segmentSequence"));
|
||||
}
|
||||
if (StringUtils.isNotBlank(seg.get("routeLegSequence"))) {
|
||||
line.put("routeLegSequence", seg.get("routeLegSequence"));
|
||||
}
|
||||
if (StringUtils.isNotBlank(seg.get("remark"))) {
|
||||
line.put("remark", seg.get("remark"));
|
||||
}
|
||||
@@ -477,6 +481,201 @@ public class InquiryTmsQueryService {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 陆运分段成本收成工单总价。
|
||||
* <p>
|
||||
* 同一 segmentSequence 是一条完整线路。只有 1 条时才把各段 costPrice 相加,
|
||||
* 时效按段原文用「 / 」拼上(如 N+2 / N+3)。多条备选禁止加总,避免 37100 这种假合计。
|
||||
* 顶层已有真实总价时不覆盖。
|
||||
*/
|
||||
static void applyLandSegmentTotals(Map<String, String> quote) {
|
||||
if (quote == null) {
|
||||
return;
|
||||
}
|
||||
List<Map<String, String>> rows = normalizeLandSegmentRows(quote.get("landSegments"));
|
||||
if (rows.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<Integer, List<Map<String, String>>> groups = new LinkedHashMap<>();
|
||||
for (Map<String, String> row : rows) {
|
||||
int seq = 1;
|
||||
try {
|
||||
seq = Integer.parseInt(firstText(row.get("segmentSequence"), "1"));
|
||||
} catch (RuntimeException ex) {
|
||||
seq = 1;
|
||||
}
|
||||
groups.computeIfAbsent(seq, k -> new ArrayList<>()).add(row);
|
||||
}
|
||||
if (groups.size() != 1) {
|
||||
return;
|
||||
}
|
||||
List<Map<String, String>> legs = groups.values().iterator().next();
|
||||
java.math.BigDecimal sum = java.math.BigDecimal.ZERO;
|
||||
boolean any = false;
|
||||
List<String> etas = new ArrayList<>();
|
||||
String ccy = firstText(quote.get("currency"));
|
||||
for (Map<String, String> leg : legs) {
|
||||
String amt = firstText(leg.get("costPrice"), leg.get("salePrice"), leg.get("amount"));
|
||||
if (StringUtils.isNotBlank(amt) && !"-".equals(amt)) {
|
||||
try {
|
||||
sum = sum.add(new java.math.BigDecimal(amt.trim().replace(",", "")));
|
||||
any = true;
|
||||
} catch (RuntimeException ignored) {
|
||||
// 非数字成本不编
|
||||
}
|
||||
}
|
||||
if (StringUtils.isBlank(ccy)) {
|
||||
ccy = firstText(leg.get("currency"));
|
||||
}
|
||||
String days = firstText(leg.get("transitDays"), leg.get("transit_time"));
|
||||
if (StringUtils.isNotBlank(days) && !"-".equals(days)) {
|
||||
etas.add(days);
|
||||
}
|
||||
}
|
||||
if (any && blankOrDash(quote.get("total"))) {
|
||||
quote.put("total", stripPlainNumber(sum));
|
||||
}
|
||||
if (any && blankOrDash(quote.get("cost"))) {
|
||||
quote.put("cost", stripPlainNumber(sum));
|
||||
}
|
||||
if (StringUtils.isNotBlank(ccy) && StringUtils.isBlank(quote.get("currency"))) {
|
||||
quote.put("currency", ccy);
|
||||
}
|
||||
if (!etas.isEmpty() && blankOrDash(quote.get("eta"))) {
|
||||
quote.put("eta", String.join(" / ", etas));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把规范化 quote 写进 TMS 报价行:金额带币种,载荷原样 JSON。
|
||||
* 查价落库与选定线路后覆盖共用,禁止再插一条假 TMS。
|
||||
*/
|
||||
static void applySnapshotToQuoteRow(InquiryQuote row, Map<String, String> quote) {
|
||||
if (row == null || quote == null) {
|
||||
return;
|
||||
}
|
||||
row.setSource("TMS");
|
||||
row.setSourceLabel("TMS 标准报价");
|
||||
row.setPriceText(TmsQuoteCurrency.formatDisplay(quote.get("currency"), quote.get("total")));
|
||||
row.setHighlighted(1);
|
||||
row.setPayloadJson(JSON.toJSONString(quote));
|
||||
}
|
||||
|
||||
/**
|
||||
* 选定陆运线路后覆盖本单最新 TMS 快照,并重算工单成本价。
|
||||
* <p>
|
||||
* 为何:查价当时可能是多条备选,工单总价只能先空着;销售选定后必须把该条成本写回。
|
||||
* 不新插 TMS 行,避免后台出现两条「TMS标准报价」。已冻结工单拒绝。
|
||||
*/
|
||||
public Map<String, Object> replaceTmsQuote(String workOrderNo, Map<String, Object> incoming) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
if (StringUtils.isBlank(workOrderNo) || incoming == null || incoming.isEmpty()) {
|
||||
out.put("ok", false);
|
||||
out.put("error", "quote_blank");
|
||||
return out;
|
||||
}
|
||||
InquiryTicket ticket = workOrderService.getOne(
|
||||
new LambdaQueryWrapper<InquiryTicket>()
|
||||
.eq(InquiryTicket::getWorkOrderNo, workOrderNo)
|
||||
.last("LIMIT 1"));
|
||||
if (ticket == null) {
|
||||
out.put("ok", false);
|
||||
out.put("error", "ticket_not_found");
|
||||
return out;
|
||||
}
|
||||
if (TicketStatuses.isFrozen(ticket.getStatus())) {
|
||||
out.put("ok", false);
|
||||
out.put("error", "frozen");
|
||||
out.put("status", ticket.getStatus());
|
||||
return out;
|
||||
}
|
||||
Map<String, String> quote = stringifyQuote(incoming);
|
||||
applyLandSegmentTotals(quote);
|
||||
InquiryQuote row = quoteMapper.selectOne(new LambdaQueryWrapper<InquiryQuote>()
|
||||
.eq(InquiryQuote::getWorkOrderNo, workOrderNo)
|
||||
.eq(InquiryQuote::getSource, "TMS")
|
||||
.orderByDesc(InquiryQuote::getCreateTime)
|
||||
.last("LIMIT 1"));
|
||||
boolean insert = row == null;
|
||||
if (insert) {
|
||||
row = new InquiryQuote();
|
||||
row.setTicketId(ticket.getId());
|
||||
row.setWorkOrderNo(workOrderNo);
|
||||
row.setQuoteVersion(1);
|
||||
row.setCreateTime(new Date());
|
||||
}
|
||||
applySnapshotToQuoteRow(row, quote);
|
||||
row.setUpdateTime(new Date());
|
||||
if (insert) {
|
||||
quoteMapper.insert(row);
|
||||
} else {
|
||||
quoteMapper.updateById(row);
|
||||
}
|
||||
List<InquiryQuote> quotes = quoteMapper.selectList(new LambdaQueryWrapper<InquiryQuote>()
|
||||
.eq(InquiryQuote::getWorkOrderNo, workOrderNo)
|
||||
.orderByAsc(InquiryQuote::getCreateTime));
|
||||
InquiryTicketPriceSupport.apply(ticket, quotes);
|
||||
ticket.setTmsHit(1);
|
||||
workOrderService.updateById(ticket);
|
||||
if (TicketStatuses.canTransit(ticket.getStatus(), TicketStatuses.QUOTED)) {
|
||||
TicketTransitionRequest tr = new TicketTransitionRequest();
|
||||
tr.setWorkOrderNo(workOrderNo);
|
||||
tr.setToStatus(TicketStatuses.QUOTED);
|
||||
workOrderService.transition(tr, "agent");
|
||||
}
|
||||
out.put("ok", true);
|
||||
out.put("quote_version", row.getQuoteVersion() == null ? 1 : row.getQuoteVersion());
|
||||
out.put("status", ticket.getStatus());
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能体回写的 quote 可能带 list/map,落库前收成字符串。
|
||||
*/
|
||||
static Map<String, String> stringifyQuote(Map<String, Object> incoming) {
|
||||
Map<String, String> quote = new LinkedHashMap<>();
|
||||
if (incoming == null) {
|
||||
return quote;
|
||||
}
|
||||
for (Map.Entry<String, Object> e : incoming.entrySet()) {
|
||||
if (e.getKey() == null || e.getValue() == null) {
|
||||
continue;
|
||||
}
|
||||
Object v = e.getValue();
|
||||
if (v instanceof String) {
|
||||
quote.put(e.getKey(), (String) v);
|
||||
} else if (v instanceof Map || v instanceof List) {
|
||||
quote.put(e.getKey(), JSON.toJSONString(v));
|
||||
} else {
|
||||
quote.put(e.getKey(), String.valueOf(v));
|
||||
}
|
||||
}
|
||||
return quote;
|
||||
}
|
||||
|
||||
private static boolean blankOrDash(String raw) {
|
||||
return StringUtils.isBlank(raw) || "-".equals(raw.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* 陆运分段行能不能盖掉当前 fee_lines。
|
||||
* 空列表、或只有「空运费」兜底(把陆运总价误写成空运)时才盖。
|
||||
*/
|
||||
static boolean shouldFillLandFeeLines(String feeLines) {
|
||||
if (StringUtils.isBlank(feeLines) || "[]".equals(feeLines.trim())) {
|
||||
return true;
|
||||
}
|
||||
String text = feeLines.replace(" ", "");
|
||||
return text.contains("空运费") && !text.contains("运输费");
|
||||
}
|
||||
|
||||
private static String stripPlainNumber(java.math.BigDecimal value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value.stripTrailingZeros().toPlainString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 把本次 TMS 报价写回工单主表,供后台列表/详情展示。
|
||||
* 海运/陆运进成本价,空运进销售价;利润点必须等销售调价后才算。
|
||||
@@ -762,9 +961,29 @@ public class InquiryTmsQueryService {
|
||||
putExtra(extras, "quantity", firstText(row.get("quantity"), row.get("chargeQuantity"), row.get("charge_quantity")));
|
||||
putExtra(extras, "unit_price", firstText(row.get("unitPrice"), row.get("unit_price")));
|
||||
putExtra(extras, "remark", firstText(row.get("remark"), row.get("description"), row.get("note")));
|
||||
putExtra(extras, "segmentSequence", firstText(row.get("segmentSequence"), row.get("segment_sequence")));
|
||||
putExtra(extras, "routeLegSequence", firstText(row.get("routeLegSequence"), row.get("route_leg_sequence")));
|
||||
return extras;
|
||||
}
|
||||
|
||||
/**
|
||||
* 陆运选线要按段过滤费用行:feeItems 原文单独落下,名称保持 TMS 回的 feeName。
|
||||
*/
|
||||
private static void putFeeItemsRaw(Map<String, String> quote, Map<String, Object> data) {
|
||||
if (quote == null || data == null) {
|
||||
return;
|
||||
}
|
||||
Object raw = first(data.get("feeItems"), data.get("fee_items"), data.get("feeList"));
|
||||
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("feeItems", json);
|
||||
}
|
||||
|
||||
private static void putExtra(Map<String, String> extras, String key, String value) {
|
||||
if (StringUtils.isNotBlank(value) && !"-".equals(value) && !"null".equalsIgnoreCase(value)) {
|
||||
extras.put(key, value);
|
||||
|
||||
Reference in New Issue
Block a user