diff --git a/inquiry-agent/agent/policy/quote_adjust_ops.py b/inquiry-agent/agent/policy/quote_adjust_ops.py index bb9d9d3..e97c2f4 100644 --- a/inquiry-agent/agent/policy/quote_adjust_ops.py +++ b/inquiry-agent/agent/policy/quote_adjust_ops.py @@ -319,7 +319,12 @@ def apply_group_adjust( flow=flow, chat_id=room, work_order_no=no, - facts=_facts_for_fill(facts, collab, business_line=line), + facts=_facts_for_fill( + facts, + collab, + business_line=line, + sales_name=str(view.get("sales_name") or view.get("salesName") or ""), + ), quote=result.quote, sales_id=sender_id, business_line=line, diff --git a/inquiry-agent/agent/policy/quote_overlay.py b/inquiry-agent/agent/policy/quote_overlay.py index 5477a13..bff6293 100644 --- a/inquiry-agent/agent/policy/quote_overlay.py +++ b/inquiry-agent/agent/policy/quote_overlay.py @@ -232,6 +232,19 @@ def overlay_labeled_facts( return {"filepath": str(tmp), "file_b64": out_b64} +def _band_value_goes_below(ws, label_cell) -> bool: + """同一行凑齐业务员、客户名称、始发地,就是集拼询价条带。""" + text = str(label_cell.value or "").replace(" ", "") + if not any(key in text for key in ("客户名称", "业务员", "始发地", "货物类型")): + return False + hits = 0 + for cell in ws[label_cell.row]: + compact = str(cell.value or "").replace(" ", "").replace(":", "").replace(":", "") + if compact in {"业务员", "客户名称", "始发地", "货物类型", "条款", "发货日期"}: + hits += 1 + return hits >= 3 + + def _match_collab_label(text: str) -> tuple[str, ...] | None: raw = (text or "").replace(":", ":").strip() for marker, keys in _COLLAB_LABEL_MARKERS: @@ -241,7 +254,23 @@ def _match_collab_label(text: str) -> tuple[str, ...] | None: def _label_value_cell(ws, label_cell): - """标签右侧格子;合并标签则从合并区右边开始。""" + """标签右侧格子;合并标签则从合并区右边开始。 + + 跨境集拼蓝条是「业务员 | 客户名称 | 始发地」,值在下一行。 + 写到右侧会把客户名称填进「始发地」表头。 + """ + if _band_value_goes_below(ws, label_cell): + col = label_cell.column + merged = getattr(ws, "merged_cells", None) + if merged is not None: + for rng in merged.ranges: + if ( + rng.min_row <= label_cell.row <= rng.max_row + and rng.min_col <= label_cell.column <= rng.max_col + ): + col = rng.min_col + break + return ws.cell(row=label_cell.row + 1, column=col) row_idx = label_cell.row start_col = label_cell.column + 1 merged = getattr(ws, "merged_cells", None) diff --git a/inquiry-agent/agent/policy/sea_group_ops.py b/inquiry-agent/agent/policy/sea_group_ops.py index fd62f19..e71b376 100644 --- a/inquiry-agent/agent/policy/sea_group_ops.py +++ b/inquiry-agent/agent/policy/sea_group_ops.py @@ -678,7 +678,12 @@ def send_filled_sheet( flow=flow, chat_id=room, work_order_no=no, - facts=_facts_for_fill(facts, collab, business_line=line or "SEA"), + facts=_facts_for_fill( + facts, + collab, + business_line=line or "SEA", + sales_name=str(view.get("sales_name") or view.get("salesName") or ""), + ), quote=quote, sales_id=str( (sess.sender_id if sess else "") @@ -724,6 +729,7 @@ def _facts_for_fill( facts: dict[str, str], collab: dict[str, str], business_line: str = "SEA", + sales_name: str = "", ) -> dict[str, str]: """ 询价字段 + 本单协同项。中文键和填表英文码一起带上。 @@ -744,6 +750,11 @@ def _facts_for_fill( if oem: merged["oil_elec_mag"] = oem merged["是否含油,电,磁"] = oem + # 报价单业务员写中文名。企微账号留给主账去对员工表,不要盖掉已有姓名。 + name = (sales_name or "").strip() + if name and any("\u4e00" <= ch <= "\u9fff" for ch in name): + merged["sales_name"] = name + merged["业务员"] = name return merged @@ -1056,7 +1067,12 @@ def _persist_quote_sheet( result = renderer( work_order_no=no, business_line=line, - facts=_facts_for_fill(facts, collab, business_line=line or "SEA"), + facts=_facts_for_fill( + facts, + collab, + business_line=line or "SEA", + sales_name=str(view.get("sales_name") or view.get("salesName") or ""), + ), quote=quote, format="xlsx", sales_wecom_id=str( diff --git a/inquiry-agent/agent/redis_coord/flow_bookmark.py b/inquiry-agent/agent/redis_coord/flow_bookmark.py index 4136620..b4ea59b 100644 --- a/inquiry-agent/agent/redis_coord/flow_bookmark.py +++ b/inquiry-agent/agent/redis_coord/flow_bookmark.py @@ -16,8 +16,6 @@ from agent.redis_coord.runtime import get_redis_runtime logger = logging.getLogger(__name__) -_TTL_SEC = 24 * 3600 - class BookmarkStore(Protocol): """单测可注入;生产走 Redis。""" @@ -72,10 +70,10 @@ def save_flow_bookmark( return try: client = get_redis_runtime().client + # 核对卡不设过期。确定前一直留着,离开核对或建单后由 delete 清掉。 client.raw.set( client.key(FLOW_BOOKMARK, name, sid), json.dumps(payload, ensure_ascii=False), - ex=_TTL_SEC, ) except Exception: logger.exception("写询价书签失败 kind=%s sender=%s", name, sid) diff --git a/inquiry-agent/agent/redis_coord/pending_land_confirm.py b/inquiry-agent/agent/redis_coord/pending_land_confirm.py index aa56b69..237c0d3 100644 --- a/inquiry-agent/agent/redis_coord/pending_land_confirm.py +++ b/inquiry-agent/agent/redis_coord/pending_land_confirm.py @@ -16,8 +16,6 @@ from agent.redis_coord.runtime import get_redis_runtime logger = logging.getLogger(__name__) -_TTL_SEC = 24 * 3600 - def save_pending_land_confirm( sender_id: str, @@ -42,10 +40,10 @@ def save_pending_land_confirm( } try: client = get_redis_runtime().client + # 核对卡不设过期。建单成功后由 clear 清掉。 client.raw.set( client.key(PENDING_LAND_CONFIRM, sid), json.dumps(payload, ensure_ascii=False), - ex=_TTL_SEC, ) logger.info( "land.pending saved sender=%s phase=%s wo=%s keys=%s", @@ -59,7 +57,7 @@ def save_pending_land_confirm( def load_pending_land_confirm(sender_id: str) -> dict[str, Any]: - """取出待确定陆运字段。没有或过期返回空。""" + """取出待确定陆运字段。没有则空。""" sid = (sender_id or "").strip() if not sid: return {} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryBotReplyController.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryBotReplyController.java index 0093c45..a2dd9ff 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryBotReplyController.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryBotReplyController.java @@ -16,12 +16,12 @@ import org.jeecg.modules.inquiry.service.IInquiryBotReplyService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; -import java.util.Date; import java.util.List; /** * 机器人回复语 API。 * 支持按分类拉取全部条目(后台九分类页)与分页查询。 + * 调用编码 replyCode 给智能体日后按码取文案;本期只维护、不向智能体下发。 */ @Slf4j @Api(tags = "询价-机器人回复语") @@ -50,13 +50,20 @@ public class InquiryBotReplyController extends JeecgController(pageNo, pageSize), qw)); } + /** + * 全量列表,供分类页一次加载。 + * 必须显式大分页:Jeecg 分页拦截器可能把无 Page 的 list() 收成前 10 条,后台会看起来像没显示全。 + */ @ApiOperation("全量列表(按分类排序,供分类页一次加载)") @RequiresPermissions("inquiry:reply:list") @GetMapping("/listAll") public Result> listAll() { QueryWrapper qw = new QueryWrapper<>(); - qw.orderByAsc("category_code").orderByAsc("sort_no"); - return Result.OK(botReplyService.list(qw)); + qw.orderByAsc("category_code").orderByAsc("sort_no").orderByAsc("id"); + Page page = new Page<>(1, 2000); + page.setSearchCount(false); + page.setMaxLimit(2000L); + return Result.OK(botReplyService.page(page, qw).getRecords()); } @AutoLog(value = "回复语-新增") @@ -64,14 +71,7 @@ public class InquiryBotReplyController extends JeecgController add(@RequestBody InquiryBotReply body) { validate(body); - body.setCreateTime(new Date()); - if (body.getEnabled() == null) { - body.setEnabled(1); - } - if (body.getSortNo() == null) { - body.setSortNo(0); - } - botReplyService.save(body); + botReplyService.createReply(body); return Result.OK("添加成功"); } @@ -83,8 +83,7 @@ public class InquiryBotReplyController extends JeecgController { + + /** + * 后台账号中文名。工号 work_no 与企微 userid 相同。 + * 只读,查不到返回空。 + */ + @Select("SELECT realname FROM sys_user WHERE del_flag = 0 AND status = 1 AND work_no = #{wecomId} " + + "AND realname IS NOT NULL AND TRIM(realname) <> '' LIMIT 1") + String findRealnameByWorkNo(@Param("wecomId") String wecomId); } diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryBotReplyService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryBotReplyService.java index 88dbd9f..8abaec7 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryBotReplyService.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryBotReplyService.java @@ -5,10 +5,21 @@ import org.jeecg.modules.inquiry.entity.InquiryBotReply; /** * 机器人回复语服务。 + * 预置调用编码不可改;编码全局唯一(空值允许多条未接入项)。 */ public interface IInquiryBotReplyService extends IService { void enable(String id); void disable(String id); + + /** + * 新增。校验调用编码格式与唯一;空白编码存 null。 + */ + void createReply(InquiryBotReply body); + + /** + * 编辑正文/启停。已有调用编码不可被请求覆盖。 + */ + void updateReply(InquiryBotReply body); } diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryBotReplyServiceImpl.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryBotReplyServiceImpl.java index 29809b0..9a89d78 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryBotReplyServiceImpl.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryBotReplyServiceImpl.java @@ -1,6 +1,8 @@ package org.jeecg.modules.inquiry.service.impl; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import org.apache.commons.lang3.StringUtils; import org.jeecg.modules.inquiry.entity.InquiryBotReply; import org.jeecg.modules.inquiry.mapper.InquiryBotReplyMapper; import org.jeecg.modules.inquiry.service.IInquiryBotReplyService; @@ -8,14 +10,19 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.Date; +import java.util.regex.Pattern; /** * 回复语启停与 CRUD。 + * 副作用:写 inq_bot_reply;调用编码唯一。不打 TMS、不唤醒智能体。 */ @Service public class InquiryBotReplyServiceImpl extends ServiceImpl implements IInquiryBotReplyService { + /** 调用编码:小写字母开头,点分段,供智能体日后按码查找。 */ + private static final Pattern REPLY_CODE = Pattern.compile("^[a-z][a-z0-9_.]{1,62}$"); + @Override @Transactional(rollbackFor = Exception.class) public void enable(String id) { @@ -28,6 +35,39 @@ public class InquiryBotReplyServiceImpl extends ServiceImpl qw = new QueryWrapper<>(); + qw.eq("reply_code", replyCode); + if (StringUtils.isNotBlank(excludeId)) { + qw.ne("id", excludeId); + } + if (count(qw) > 0) { + throw new IllegalArgumentException("调用编码已存在: " + replyCode); + } + } } diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java index e34920b..12f53b7 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java @@ -3,6 +3,7 @@ package org.jeecg.modules.inquiry.service.impl; import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONObject; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; @@ -13,8 +14,13 @@ import org.apache.commons.lang3.StringUtils; import org.jeecg.common.util.CommonUtils; import org.jeecg.modules.inquiry.entity.InquiryQuoteTemplate; import org.jeecg.modules.inquiry.entity.InquiryQuoteTemplateVersion; +import org.jeecg.modules.inquiry.entity.InquiryStaffRole; +import org.jeecg.modules.inquiry.entity.InquiryTicket; import org.jeecg.modules.inquiry.mapper.InquiryQuoteTemplateMapper; import org.jeecg.modules.inquiry.mapper.InquiryQuoteTemplateVersionMapper; +import org.jeecg.modules.inquiry.mapper.InquiryStaffRoleMapper; +import org.jeecg.modules.inquiry.mapper.InquiryTicketMapper; +import org.jeecg.modules.inquiry.support.InquiryTicketDisplaySupport; import org.jeecg.modules.inquiry.service.IInquiryQuoteTemplateService; import org.jeecg.modules.inquiry.template.QuoteTemplateAiParseClient; import org.jeecg.modules.inquiry.template.QuoteTemplateExcelSupport; @@ -65,6 +71,14 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl + * 业务员:员工管理姓名优先,其次工单上已有的中文名。企微 userid 不写进报价单。 + * 客户名称:询价事实或工单客户。没填就空着,不用业务员账号去占。 + * 副作用:只读员工表和工单,不改库。 + */ + private Map enrichQuotePartyFacts(String workOrderNo, Map facts, + String salesWecomId) { + Map src = facts == null ? new LinkedHashMap<>() : new LinkedHashMap<>(facts); + InquiryTicket ticket = findTicket(workOrderNo); + String wecomId = StringUtils.defaultIfBlank(salesWecomId, ticket == null ? "" : ticket.getSalesWecomId()); + String staffName = lookupStaffName(wecomId); + String fromTicket = chineseName(ticket == null ? "" : ticket.getSalesName()); + String fromFacts = chineseName(first(src, "sales_name", "业务员", "salesName")); + String fromJson = chineseName(factFromTicketJson(ticket, "业务员", "sales_name", "salesName")); + String named = StringUtils.defaultIfBlank(staffName, StringUtils.defaultIfBlank(fromTicket, + StringUtils.defaultIfBlank(fromFacts, fromJson))); + if (StringUtils.isNotBlank(named)) { + src.put("sales_name", named); + src.put("业务员", named); + } else { + src.put("sales_name", ""); + src.put("业务员", ""); + } + String customer = chineseName(first(src, "customer_name", "客户名称", "customerName")); + if (StringUtils.isBlank(customer) && ticket != null) { + customer = chineseName(ticket.getCustomerName()); + } + if (StringUtils.isBlank(customer)) { + customer = chineseName(factFromTicketJson(ticket, "客户名称", "customer_name", "customerName")); + } + if (customer.equals(named) || customer.equals(StringUtils.trimToEmpty(wecomId))) { + customer = ""; + } + src.put("customer_name", customer); + src.put("客户名称", customer); + return src; + } + + /** 按工单号读主单。没有表或没有单号时返回空,出单仍继续。 */ + private InquiryTicket findTicket(String workOrderNo) { + if (ticketMapper == null || StringUtils.isBlank(workOrderNo)) { + return null; + } + return ticketMapper.selectOne(new LambdaQueryWrapper() + .eq(InquiryTicket::getWorkOrderNo, workOrderNo.trim()) + .last("LIMIT 1")); + } + + /** + * 员工中文名。先员工管理,没有再读后台账号的真实姓名。 + * 企微账号、纯英文拼音不算姓名。查不到返回空,报价单不回退成账号。 + */ + private String lookupStaffName(String salesWecomId) { + if (staffRoleMapper == null || StringUtils.isBlank(salesWecomId)) { + return ""; + } + String uid = salesWecomId.trim(); + List rows = staffRoleMapper.selectList(new LambdaQueryWrapper() + .eq(InquiryStaffRole::getWecomId, uid) + .eq(InquiryStaffRole::getStatus, "active")); + if (rows != null) { + for (InquiryStaffRole row : rows) { + String name = chineseName(row == null ? "" : row.getName()); + if (StringUtils.isNotBlank(name)) { + return name; + } + } + } + return chineseName(staffRoleMapper.findRealnameByWorkNo(uid)); + } + + /** 工单事实里的中文名。factsJson 不是合法 JSON 时当没有。 */ + private static String factFromTicketJson(InquiryTicket ticket, String... keys) { + if (ticket == null || StringUtils.isBlank(ticket.getFactsJson())) { + return ""; + } + try { + JSONObject obj = JSON.parseObject(ticket.getFactsJson()); + return obj == null ? "" : first(obj, keys); + } catch (Exception e) { + return ""; + } + } + + /** 报价单只收中文姓名。企微账号和纯英文拼音返回空。 */ + private static String chineseName(String raw) { + String text = StringUtils.trimToEmpty(raw); + if (StringUtils.isBlank(text) || InquiryTicketDisplaySupport.looksLikeWecomUserId(text)) { + return ""; + } + boolean han = false; + for (int i = 0; i < text.length(); i++) { + if (Character.UnicodeScript.of(text.charAt(i)) == Character.UnicodeScript.HAN) { + han = true; + break; + } + } + return han ? text : ""; + } + /** * 出单填表投影。空运不写时效:业务确认空运没有时效字段。 * @@ -905,7 +1022,15 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl= 0 && rowLooksLikeDeliveryPriceHeader(sheet, deliveryHeader)) { + continue; + } // 中港等模板:dataStartRow 常误指「4.2米/3T」车型表头行,下移到真正数据行 rowIdx0 = resolveRouteDataStartRow(sheet, rowIdx0, region.getIntValue("headerRow")); int stopRow = findStopRow(sheet, region.getJSONArray("stopAtText"), rowIdx0); @@ -2724,7 +2740,8 @@ public final class QuoteTemplateExcelSupport { } int cur = rowIdx0; for (int i = 0; i < 3; i++) { - if (rowLooksLikeVehicleHeader(sheet, cur) || rowLooksLikeLandRouteMatrixHeader(sheet, cur)) { + if (rowLooksLikeVehicleHeader(sheet, cur) || rowLooksLikeLandRouteMatrixHeader(sheet, cur) + || rowLooksLikeDeliveryPriceHeader(sheet, cur)) { cur++; continue; } @@ -2733,7 +2750,8 @@ public final class QuoteTemplateExcelSupport { int header0 = headerRow1Based > 0 ? headerRow1Based - 1 : -1; if (header0 >= 0 && cur == header0) { cur = header0 + 1; - if (rowLooksLikeVehicleHeader(sheet, cur) || rowLooksLikeLandRouteMatrixHeader(sheet, cur)) { + if (rowLooksLikeVehicleHeader(sheet, cur) || rowLooksLikeLandRouteMatrixHeader(sheet, cur) + || rowLooksLikeDeliveryPriceHeader(sheet, cur)) { cur++; } } @@ -2792,6 +2810,55 @@ public final class QuoteTemplateExcelSupport { || t.contains("提货费") || t.contains("口岸核放"); } + /** + * 配送费用表头:城市类别、覆盖区域、单价。时效、燃油附加费率已算运价列头。 + * 这些字是列名,值在下一行,不能写到列名右侧的空白格。 + */ + static boolean isDeliveryPriceHeaderCell(String text) { + if (StringUtils.isBlank(text)) { + return false; + } + String t = text.replaceAll("\\s+", "").trim(); + return "城市类别".equals(t) || "覆盖区域".equals(t) || t.startsWith("单价"); + } + + /** + * 配送费用那一行:必须有城市类别、覆盖区域或「单价(元/票)」。 + * 运价表自己也有燃油附加费率和时效,只凭这两列会把整段公斤运价跳过,始发地和目的地就空着。 + * 已经认出是运价表头(始发地、目的地、元/KG、报关费)时,不当配送费。 + */ + static boolean rowLooksLikeDeliveryPriceHeader(Sheet sheet, int rowIdx) { + if (sheet == null || rowIdx < 0) { + return false; + } + Row row = sheet.getRow(rowIdx); + if (row == null) { + return false; + } + DataFormatter fmt = new DataFormatter(); + boolean deliveryLabel = false; + boolean rateMatrix = false; + for (Cell cell : row) { + if (cell == null) { + continue; + } + String text = StringUtils.trimToEmpty(fmt.formatCellValue(cell)).replaceAll("\\s+", ""); + if (text.isEmpty()) { + continue; + } + if (isDeliveryPriceHeaderCell(text)) { + deliveryLabel = true; + } + if ("始发地".equals(text) || "始发站".equals(text) || "目的地".equals(text) + || "币种".equals(text) || "最低收费".equals(text) + || text.contains("元/KG") || text.contains("元/kg") + || text.contains("报关费") || text.contains("提货费")) { + rateMatrix = true; + } + } + return deliveryLabel && !rateMatrix; + } + /** 行内出现「米/」「尺」「3T」「5T」等车型表头文案。 */ static boolean rowLooksLikeVehicleHeader(Sheet sheet, int rowIdx) { if (sheet == null || rowIdx < 0) { @@ -2857,6 +2924,10 @@ public final class QuoteTemplateExcelSupport { rowObj == null ? null : rowObj.getString("pricePerCbm"), facts == null ? null : facts.getString("price_per_cbm")); } + // 跨境集拼是分档公斤价(2-500KG=12 …),不是把同一个 price_per_kg 灌进每一列。 + if (looksLikeKgBracketHeader(key)) { + return matchWeightBracketPrice(rowObj, key); + } if (key.toUpperCase(Locale.ROOT).contains("KG") || key.contains("公斤")) { return firstNonBlank(rowObj == null ? null : rowObj.getString("price_per_kg"), rowObj == null ? null : rowObj.getString("pricePerKg"), @@ -2884,6 +2955,156 @@ public final class QuoteTemplateExcelSupport { return ""; } + /** + * 表头是「2-500KG」「5001KG以上」这类分档,不是「元/KG」单价列。 + */ + static boolean looksLikeKgBracketHeader(String headerKey) { + return parseKgBracket(headerKey) != null; + } + + /** + * 用 landSegments.weightPrices 对上分档表头。对不上就空着,禁止把一档价抄进所有列。 + */ + static String matchWeightBracketPrice(JSONObject rowObj, String headerKey) { + JSONArray tiers = weightPricesOf(rowObj); + int[] range = parseKgBracket(headerKey); + if (tiers == null || tiers.isEmpty() || range == null) { + return ""; + } + for (int i = 0; i < tiers.size(); i++) { + JSONObject tier = tiers.getJSONObject(i); + if (tier == null) { + continue; + } + int min = intOf(tier.get("min")); + boolean open = tier.get("max") == null || StringUtils.isBlank(String.valueOf(tier.get("max"))); + int max = open ? -1 : intOf(tier.get("max")); + if (min == range[0] && max == range[1]) { + return formatTierPrice(tier.get("price")); + } + } + return ""; + } + + /** + * 规范化 route_rows 会丢掉 weightPrices。出单前从原始 landSegments 补回。 + */ + public static void copyMissingWeightPrices(JSONArray rows, JSONArray rawLand) { + if (rows == null || rawLand == null || rawLand.isEmpty()) { + return; + } + for (int i = 0; i < rows.size(); i++) { + JSONObject row = rows.getJSONObject(i); + if (row == null) { + continue; + } + JSONArray existing = weightPricesOf(row); + if (existing != null && !existing.isEmpty()) { + continue; + } + JSONObject src = i < rawLand.size() ? rawLand.getJSONObject(i) : null; + if (weightPricesOf(src) == null) { + src = findLandSegmentByOriginDest(rawLand, row); + } + JSONArray tiers = weightPricesOf(src); + if (tiers != null && !tiers.isEmpty()) { + row.put("weightPrices", tiers); + } + } + } + + private static JSONObject findLandSegmentByOriginDest(JSONArray rawLand, JSONObject row) { + if (rawLand == null || row == null) { + return null; + } + String origin = StringUtils.trimToEmpty(firstNonBlank(row.getString("origin"), row.getString("originCode"))); + String dest = StringUtils.trimToEmpty(firstNonBlank(row.getString("destination"), row.getString("destinationCode"))); + for (int i = 0; i < rawLand.size(); i++) { + JSONObject one = rawLand.getJSONObject(i); + if (one == null) { + continue; + } + String o = StringUtils.trimToEmpty(firstNonBlank(one.getString("origin"), one.getString("originCode"))); + String d = StringUtils.trimToEmpty(firstNonBlank(one.getString("destination"), one.getString("destinationCode"))); + if (origin.equals(o) && dest.equals(d) && weightPricesOf(one) != null) { + return one; + } + } + return null; + } + + static JSONArray weightPricesOf(JSONObject row) { + if (row == null) { + return null; + } + Object raw = row.get("weightPrices"); + if (raw == null) { + raw = row.get("weight_prices"); + } + if (raw instanceof JSONArray) { + return ((JSONArray) raw).isEmpty() ? null : (JSONArray) raw; + } + if (raw instanceof java.util.List) { + JSONArray parsed = JSON.parseArray(JSON.toJSONString(raw)); + return parsed == null || parsed.isEmpty() ? null : parsed; + } + if (raw instanceof String && StringUtils.isNotBlank((String) raw)) { + JSONArray parsed = parseFeeRows(raw); + return parsed.isEmpty() ? null : parsed; + } + return null; + } + + /** @return [min, max],无上限时 max 为 -1;不是分档表头则 null */ + static int[] parseKgBracket(String headerKey) { + if (StringUtils.isBlank(headerKey)) { + return null; + } + String t = headerKey.replaceAll("\\s+", "").toUpperCase(Locale.ROOT); + java.util.regex.Matcher ranged = java.util.regex.Pattern.compile("(\\d+)[-~~至到](\\d+)KG").matcher(t); + if (ranged.find()) { + return new int[]{Integer.parseInt(ranged.group(1)), Integer.parseInt(ranged.group(2))}; + } + java.util.regex.Matcher open = java.util.regex.Pattern.compile("(\\d+)KG以上").matcher(t); + if (open.find()) { + return new int[]{Integer.parseInt(open.group(1)), -1}; + } + java.util.regex.Matcher openCn = java.util.regex.Pattern.compile("(\\d+)以上").matcher(t); + if (openCn.find() && t.contains("KG")) { + return new int[]{Integer.parseInt(openCn.group(1)), -1}; + } + return null; + } + + private static int intOf(Object value) { + if (value == null) { + return -1; + } + if (value instanceof Number) { + return ((Number) value).intValue(); + } + try { + return new java.math.BigDecimal(String.valueOf(value).trim()).intValue(); + } catch (RuntimeException ex) { + return -1; + } + } + + private static String formatTierPrice(Object price) { + if (price == null) { + return ""; + } + String text = String.valueOf(price).trim(); + if (text.isEmpty() || "-".equals(text) || "null".equalsIgnoreCase(text)) { + return ""; + } + try { + return new java.math.BigDecimal(text).stripTrailingZeros().toPlainString(); + } catch (RuntimeException ex) { + return text; + } + } + private static boolean vehicleTypeEquals(String actual, String headerKey) { if (StringUtils.isBlank(actual) || StringUtils.isBlank(headerKey)) { return false; @@ -3114,6 +3335,10 @@ public final class QuoteTemplateExcelSupport { return; } int col = CellReference.convertColStringToIndex(colLetter.trim()); + // 两行合并的列头(燃油附加费率、时效)值在左上角。写到合并区下沿会把列头样式撑乱。 + int[] origin = resolveMergeOrigin(sheet, rowIdx, col); + rowIdx = origin[0]; + col = origin[1]; String ref = sheetName + "!" + new CellReference(rowIdx, col).formatAsString(); // #region agent log if (StringUtils.isNotBlank(value) && (value.contains("广州") || "B".equalsIgnoreCase(colLetter.trim()))) { @@ -3141,16 +3366,407 @@ public final class QuoteTemplateExcelSupport { return; } String exist = readCellText(cell); + String header = columnHeaderText(sheet, rowIdx, col); // 蓝底/费用表头格禁止当数据写:跨境集拼「始发地」被写成「上海」会拆掉主题蓝条 if (isFeeTableHeader(exist) || isLandInquiryHeaderBandLabel(exist) - || isLandRouteMatrixHeaderCell(exist) || isSectionTitle(sheet, cell)) { + || isLandRouteMatrixHeaderCell(exist) || isDeliveryPriceHeaderCell(exist) + || isSectionTitle(sheet, cell) + || rowLooksLikeLandRouteMatrixHeader(sheet, rowIdx) + || rowLooksLikeDeliveryPriceHeader(sheet, rowIdx)) { + return; + } + // 报关费、提货费、口岸、重抛比、当日达是模板印好的,空值或运价不能清掉。 + if (isTemplateFixedNote(exist)) { + return; + } + // 配送费「单价(元/票)」已印好 200/450 时,不要改成公斤运价。其它表的单价列仍可填。 + if (header.contains("元/票") && StringUtils.isNotBlank(value) && StringUtils.isNotBlank(exist)) { + return; + } + // 币种只进「币种/币别」列。配送费的覆盖区域、燃油附加费率不是币种,CNY 写进去会盖掉城市。 + if (looksLikeCurrencyCode(value) && columnRejectsCurrency(header)) { return; } writeNumericOrText(cell, value); + // 始发地/目的地是城市。模板空格常套用「币种/燃油」的红字+顶对齐, + // 长地名一换行就会画进蓝表头,看起来像列头被改了颜色。 + if (isCityRouteColumn(sheet, rowIdx, col, header)) { + restyleCityDataCell(cell); + return; + } // 映射表写出的备注同样要换行,否则只看得见半截 applyRemarkWrap(sheet, row, cell, value); } + /** + * 数据格正上方的列头(向上最多 3 行,合并格看左上角)。 + * 用来区分「币种」列和「燃油附加费率 / 覆盖区域」列,避免币种写错列。 + */ + static String columnHeaderText(Sheet sheet, int rowIdx, int col) { + if (sheet == null || rowIdx <= 0 || col < 0) { + return ""; + } + DataFormatter fmt = new DataFormatter(); + for (int r = rowIdx - 1; r >= Math.max(0, rowIdx - 3); r--) { + int[] origin = resolveMergeOrigin(sheet, r, col); + Row row = sheet.getRow(origin[0]); + if (row == null) { + continue; + } + Cell cell = row.getCell(origin[1]); + String text = StringUtils.trimToEmpty(fmt.formatCellValue(cell)).replaceAll("\\s+", ""); + if (StringUtils.isNotBlank(text)) { + return text; + } + } + return ""; + } + + /** CNY / USD 这类币种代码。城市名、时效不算。 */ + static boolean looksLikeCurrencyCode(String value) { + if (StringUtils.isBlank(value)) { + return false; + } + String t = value.trim().toUpperCase(Locale.ROOT); + return "CNY".equals(t) || "USD".equals(t) || "RMB".equals(t) || "EUR".equals(t) + || "HKD".equals(t) || "VND".equals(t); + } + + /** + * 这些列头不是币种:写 CNY 会把覆盖区域的城市、或燃油附加费率的空格盖掉。 + * 表头是「币种 / 币别」,或上面没有列头时,仍允许写币种。 + */ + static boolean columnRejectsCurrency(String header) { + if (StringUtils.isBlank(header)) { + return false; + } + if (header.contains("币种") || header.contains("币别")) { + return false; + } + return header.contains("燃油") || header.contains("覆盖") || header.contains("城市") + || header.contains("单价") || header.contains("时效") || header.contains("税率") + || header.contains("始发") || header.contains("目的") || header.contains("备注") + || header.contains("报关") || header.contains("提货") || header.contains("口岸"); + } + + /** + * 运价表「始发地 / 目的地」列:列头文案,或向上几行合并格就是这两个字。 + */ + static boolean isCityRouteColumn(Sheet sheet, int rowIdx, int col, String header) { + if (header != null && (header.contains("始发") || header.contains("目的"))) { + return true; + } + if (sheet == null || rowIdx <= 0 || col < 0) { + return false; + } + DataFormatter fmt = new DataFormatter(); + for (int r = rowIdx - 1; r >= Math.max(0, rowIdx - 4); r--) { + int[] origin = resolveMergeOrigin(sheet, r, col); + Row row = sheet.getRow(origin[0]); + if (row == null) { + continue; + } + String text = StringUtils.trimToEmpty(fmt.formatCellValue(row.getCell(origin[1]))).replaceAll("\\s+", ""); + if ("始发地".equals(text) || "始发站".equals(text) || "目的地".equals(text)) { + return true; + } + } + return false; + } + + /** + * 城市写在白格里:黑字、垂直居中。 + * 不沿用币种列的红字,也不顶对齐,避免字画进上面的蓝表头。 + * 不改填充和边框,蓝表头本身不动。 + */ + private static void restyleCityDataCell(Cell cell) { + if (cell == null) { + return; + } + Workbook wb = cell.getSheet().getWorkbook(); + Font old = wb.getFontAt(cell.getCellStyle().getFontIndex()); + CellStyle copy = wb.createCellStyle(); + copy.cloneStyleFrom(cell.getCellStyle()); + Font black = wb.createFont(); + if (old != null) { + if (StringUtils.isNotBlank(old.getFontName())) { + black.setFontName(old.getFontName()); + } + if (old.getFontHeight() > 0) { + black.setFontHeight(old.getFontHeight()); + } + black.setBold(false); + } + black.setColor(IndexedColors.BLACK.getIndex()); + copy.setFont(black); + copy.setWrapText(false); + copy.setShrinkToFit(true); + copy.setVerticalAlignment(VerticalAlignment.CENTER); + cell.setCellStyle(copy); + } + + /** + * 列头底色/字色用 RGB 记下来。cloneStyleFrom 会丢掉主题色,运价表会变成「业务员」那种深蓝, + * 配送费「燃油附加费率」蓝底也会没。 + */ + static final class LandTemplateLook { + private final List paints = new ArrayList<>(); + private final List rowHeights = new ArrayList<>(); + private final List colWidths = new ArrayList<>(); + } + + static final class HeaderPaint { + final int sheet; + final int row; + final int col; + final byte[] fillRgb; + final byte[] fontRgb; + final boolean bold; + final String fontName; + final short fontHeight; + final boolean wrap; + + HeaderPaint(int sheet, int row, int col, byte[] fillRgb, byte[] fontRgb, + boolean bold, String fontName, short fontHeight, boolean wrap) { + this.sheet = sheet; + this.row = row; + this.col = col; + this.fillRgb = fillRgb; + this.fontRgb = fontRgb; + this.bold = bold; + this.fontName = fontName; + this.fontHeight = fontHeight; + this.wrap = wrap; + } + } + + static LandTemplateLook snapshotLandTemplateLook(Workbook wb) { + LandTemplateLook look = new LandTemplateLook(); + if (wb == null) { + return look; + } + for (int s = 0; s < wb.getNumberOfSheets(); s++) { + Sheet sheet = wb.getSheetAt(s); + if (sheet == null) { + continue; + } + boolean[] keep = markLandHeaderRows(sheet); + int lastRow = Math.min(sheet.getLastRowNum(), 40); + for (int r = 0; r <= lastRow; r++) { + if (!keep[r]) { + continue; + } + Row row = sheet.getRow(r); + if (row == null) { + continue; + } + look.rowHeights.add(new int[]{s, r, row.getHeight()}); + int lastCol = Math.max(row.getLastCellNum(), 18); + for (int c = 0; c < lastCol; c++) { + Cell cell = row.getCell(c); + if (cell == null) { + continue; + } + look.paints.add(new HeaderPaint(s, r, c, readFillRgb(cell), readFontRgb(cell), + fontBold(cell), fontName(cell), fontHeight(cell), + cell.getCellStyle() != null && cell.getCellStyle().getWrapText())); + } + } + int cols = 18; + for (int r = 0; r <= lastRow; r++) { + Row row = sheet.getRow(r); + if (row != null) { + cols = Math.max(cols, row.getLastCellNum()); + } + } + for (int c = 0; c < cols; c++) { + look.colWidths.add(new int[]{s, c, sheet.getColumnWidth(c)}); + } + } + return look; + } + + static void restoreLandTemplateLook(Workbook wb, LandTemplateLook look) { + if (wb == null || look == null) { + return; + } + for (HeaderPaint paint : look.paints) { + Sheet sheet = wb.getSheetAt(paint.sheet); + if (sheet == null) { + continue; + } + Row row = sheet.getRow(paint.row); + if (row == null) { + continue; + } + Cell cell = row.getCell(paint.col); + if (cell == null) { + continue; + } + applyHeaderPaint(cell, paint); + } + for (int[] rh : look.rowHeights) { + Sheet sheet = wb.getSheetAt(rh[0]); + Row row = sheet == null ? null : sheet.getRow(rh[1]); + if (row != null) { + row.setHeight((short) rh[2]); + } + } + for (int[] cw : look.colWidths) { + Sheet sheet = wb.getSheetAt(cw[0]); + if (sheet != null) { + sheet.setColumnWidth(cw[1], cw[2]); + } + } + } + + static void applyHeaderPaint(Cell cell, HeaderPaint paint) { + Workbook wb = cell.getSheet().getWorkbook(); + CellStyle copy = wb.createCellStyle(); + if (cell.getCellStyle() != null) { + copy.cloneStyleFrom(cell.getCellStyle()); + } + if (paint.fillRgb != null && copy instanceof org.apache.poi.xssf.usermodel.XSSFCellStyle) { + org.apache.poi.xssf.usermodel.XSSFCellStyle xs = (org.apache.poi.xssf.usermodel.XSSFCellStyle) copy; + xs.setFillForegroundColor(new XSSFColor(paint.fillRgb, null)); + xs.setFillPattern(FillPatternType.SOLID_FOREGROUND); + } else if (paint.fillRgb != null) { + copy.setFillPattern(FillPatternType.SOLID_FOREGROUND); + } + XSSFFont font = (XSSFFont) wb.createFont(); + if (StringUtils.isNotBlank(paint.fontName)) { + font.setFontName(paint.fontName); + } + if (paint.fontHeight > 0) { + font.setFontHeight(paint.fontHeight); + } + font.setBold(paint.bold); + if (paint.fontRgb != null) { + font.setColor(new XSSFColor(paint.fontRgb, null)); + } + copy.setFont(font); + copy.setWrapText(paint.wrap); + cell.setCellStyle(copy); + } + + static byte[] readFillRgb(Cell cell) { + if (!(cell instanceof XSSFCell)) { + return null; + } + org.apache.poi.xssf.usermodel.XSSFCellStyle style = + (org.apache.poi.xssf.usermodel.XSSFCellStyle) cell.getCellStyle(); + if (style == null) { + return null; + } + XSSFColor color = style.getFillForegroundXSSFColor(); + return xssfRgb(color); + } + + static byte[] readFontRgb(Cell cell) { + if (!(cell instanceof XSSFCell)) { + return null; + } + XSSFFont font = ((XSSFCell) cell).getCellStyle().getFont(); + if (font == null) { + return null; + } + return xssfRgb(font.getXSSFColor()); + } + + static byte[] xssfRgb(XSSFColor color) { + if (color == null) { + return null; + } + byte[] rgb = color.getRGBWithTint(); + if (rgb == null || rgb.length < 3) { + rgb = color.getRGB(); + } + if (rgb == null || rgb.length < 3) { + return null; + } + byte[] out = new byte[3]; + System.arraycopy(rgb, rgb.length - 3, out, 0, 3); + return out; + } + + static boolean fontBold(Cell cell) { + if (cell == null || cell.getCellStyle() == null) { + return false; + } + Font font = cell.getSheet().getWorkbook().getFontAt(cell.getCellStyle().getFontIndex()); + return font != null && font.getBold(); + } + + static String fontName(Cell cell) { + if (cell == null || cell.getCellStyle() == null) { + return ""; + } + Font font = cell.getSheet().getWorkbook().getFontAt(cell.getCellStyle().getFontIndex()); + return font == null ? "" : font.getFontName(); + } + + static short fontHeight(Cell cell) { + if (cell == null || cell.getCellStyle() == null) { + return 0; + } + Font font = cell.getSheet().getWorkbook().getFontAt(cell.getCellStyle().getFontIndex()); + return font == null ? 0 : font.getFontHeight(); + } + + static boolean[] markLandHeaderRows(Sheet sheet) { + int last = Math.min(sheet.getLastRowNum(), 40); + boolean[] keep = new boolean[last + 2]; + for (int r = 0; r <= last; r++) { + if (rowLooksLikeLandRouteMatrixHeader(sheet, r)) { + keep[r] = true; + if (r + 1 < keep.length) { + keep[r + 1] = true; + } + } + if (rowLooksLikeDeliveryPriceHeader(sheet, r)) { + keep[r] = true; + } + } + return keep; + } + + /** + * 跨境集拼模板里印好的说明和配送时效,不是待填数据。 + * 报关费、提货费、口岸、重抛比、当日达被空备注或运价盖掉后,销售对不上原模板。 + */ + static boolean isTemplateFixedNote(String text) { + if (StringUtils.isBlank(text)) { + return false; + } + String t = text.replaceAll("\\s+", ""); + return t.contains("重抛比") || t.contains("实报实销") || t.contains("当日达") + || t.contains("续票") || t.contains("续页") || t.contains("另议") + || t.contains("客户交货"); + } + + /** 模板里币种、燃油附加费率、税率的红字(显式 RGB 或索引红)。 */ + static boolean fontIsRed(Cell cell) { + if (!(cell instanceof XSSFCell)) { + return false; + } + XSSFFont font = ((XSSFCell) cell).getCellStyle().getFont(); + if (font == null) { + return false; + } + if (font.getColor() == Font.COLOR_RED) { + return true; + } + XSSFColor color = font.getXSSFColor(); + if (color == null || color.getRGB() == null || color.getRGB().length < 3) { + return false; + } + byte[] rgb = color.getRGB(); + int r = rgb[rgb.length - 3] & 0xFF; + int g = rgb[rgb.length - 2] & 0xFF; + int b = rgb[rgb.length - 1] & 0xFF; + return r >= 180 && g <= 40 && b <= 40; + } + /** * AI mapping 常只标 name/amount/currency;空运表头有「单位/单价/数量」时现场补列字母。 * 不覆盖 mapping 已有列,避免改错已对齐的金额列。 @@ -4331,7 +4947,10 @@ public final class QuoteTemplateExcelSupport { String existing = readCellText(cell); // 映射点常落在合并标签格 / 20GP 表头上,不能把原字盖掉 if (isFeeTableHeader(existing) || isLabelLike(existing) || isSectionTitle(sheet, cell) - || isLandInquiryHeaderBandLabel(existing)) { + || isLandInquiryHeaderBandLabel(existing) || isLandRouteMatrixHeaderCell(existing) + || isDeliveryPriceHeaderCell(existing) + || rowLooksLikeLandRouteMatrixHeader(sheet, dest[0]) + || rowLooksLikeDeliveryPriceHeader(sheet, dest[0])) { return; } int colon = Math.max(existing.indexOf(':'), existing.indexOf(':')); @@ -4454,6 +5073,12 @@ public final class QuoteTemplateExcelSupport { } } int rowIdx = labelCell.getRowIndex(); + // 运价表、配送费表的列头(时效、燃油附加费率、城市类别)不是「标签右侧填值」。 + // 否则 N+3 会写到时效列右边的空白格,列头样式也被带乱。 + if (rowLooksLikeLandRouteMatrixHeader(sheet, rowIdx) + || rowLooksLikeDeliveryPriceHeader(sheet, rowIdx)) { + return null; + } int startCol = labelCell.getColumnIndex() + 1; for (int i = 0; i < sheet.getNumMergedRegions(); i++) { org.apache.poi.ss.util.CellRangeAddress range = sheet.getMergedRegion(i); @@ -4474,7 +5099,7 @@ public final class QuoteTemplateExcelSupport { Cell cand = row.getCell(dest[1]); String exist = readCellText(cand); if (isFeeTableHeader(exist) || isLabelLike(exist) || isSectionTitle(sheet, cand) - || isUnsuitableValueCell(exist)) { + || isUnsuitableValueCell(exist) || isLandRouteMatrixHeaderCell(exist)) { // 左右双栏:已找到左侧候选后,遇到右侧标签必须停,禁止再跨到送货地址值区 if ((firstEmpty != null || wideEmpty != null) && (isLabelLike(exist) || isSectionTitle(sheet, cand))) { @@ -4510,10 +5135,13 @@ public final class QuoteTemplateExcelSupport { if (sheet == null || labelCell == null || StringUtils.isBlank(text)) { return null; } - if (!isLandInquiryHeaderBandLabel(text)) { + int rowIdx = labelCell.getRowIndex(); + // 「时效」不参与条带判定(运价矩阵也有这列),但条带行上的时效值仍在下一行。 + boolean bandLabel = isLandInquiryHeaderBandLabel(text); + boolean transitOnBand = isInquiryBandTransitLabel(text) && rowLooksLikeLandInquiryHeaderBand(sheet, rowIdx); + if (!bandLabel && !transitOnBand) { return null; } - int rowIdx = labelCell.getRowIndex(); if (!rowLooksLikeLandInquiryHeaderBand(sheet, rowIdx)) { return null; } @@ -4556,6 +5184,45 @@ public final class QuoteTemplateExcelSupport { || "货物类型".equals(compact) || "条款".equals(compact) || "发货日期".equals(compact); } + /** 询价条带上的时效。矩阵同名列不走这里,调用方必须先确认该行已是询价条带。 */ + static boolean isInquiryBandTransitLabel(String text) { + if (StringUtils.isBlank(text)) { + return false; + } + String compact = text.replace(":", "").replace(":", "").replaceAll("\\s+", "").trim(); + return "时效".equals(compact) || "时效(天)".equals(compact); + } + + /** + * 映射格落在询价条带表头上,且这列表头对应的字段不是正在写的字段。 + * 例如 sales_name 被指到「客户名称」:跳过,改由标签「业务员」去写。 + */ + static boolean mappingPointsAtForeignBandLabel(Workbook wb, String cellRef, String fieldCode) { + ParsedRef pref = ParsedRef.parse(cellRef); + if (pref == null || wb == null || StringUtils.isBlank(fieldCode)) { + return false; + } + Sheet sh = wb.getSheet(pref.sheet); + if (sh == null && wb.getNumberOfSheets() > 0) { + sh = wb.getSheetAt(0); + } + if (sh == null) { + return false; + } + int[] origin = resolveMergeOrigin(sh, pref.row, pref.col); + Row row = sh.getRow(origin[0]); + Cell cell = row == null ? null : row.getCell(origin[1]); + String text = readCellText(cell); + if (!rowLooksLikeLandInquiryHeaderBand(sh, origin[0])) { + return false; + } + if (!isLandInquiryHeaderBandLabel(text) && !isInquiryBandTransitLabel(text)) { + return false; + } + String labelCode = matchLabel(text.replace(":", ":").trim(), FILL_BY_LABEL); + return StringUtils.isNotBlank(labelCode) && !labelCode.equals(fieldCode); + } + /** * 一行里出现不少于 3 个询价条带标签,视为陆运蓝底表头行(截图正确版第 5 行)。 */ @@ -4670,6 +5337,9 @@ public final class QuoteTemplateExcelSupport { continue; } String val = firstFact(facts, code, text.replace(":", "").replace(":", "").trim()); + if ((StringUtils.isBlank(val) || "-".equals(val)) && "transit_days".equals(code)) { + val = firstFact(facts, "transit_time"); + } if (StringUtils.isBlank(val) || "-".equals(val)) { continue; } diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java index 1ab13b8..3021806 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java @@ -463,6 +463,17 @@ public class InquiryTmsQueryService { putExtra(row, "transitDays", transit); putExtra(row, "transit_time", transit); putExtra(row, "remark", firstText(seg.get("remark"), seg.get("note"), seg.get("description"))); + Object tiers = seg.get("weightPrices"); + if (tiers == null) { + tiers = seg.get("weight_prices"); + } + if (tiers != null) { + String tierJson = tiers instanceof String ? String.valueOf(tiers) : JSON.toJSONString(tiers); + if (StringUtils.isNotBlank(tierJson) && !"null".equalsIgnoreCase(tierJson) + && !"[]".equals(tierJson.trim())) { + row.put("weightPrices", tierJson); + } + } // 腿上若带分项费率扩展字段,原样带上供「提货费/元/CBM」列匹配 String[][] rateKeys = { {"pickupFee", "pickup_fee"}, {"deliveryFee", "delivery_fee"}, diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_inq_bot_reply_replace_v1.sql b/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_inq_bot_reply_replace_v1.sql new file mode 100644 index 0000000..dd4ccd5 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_inq_bot_reply_replace_v1.sql @@ -0,0 +1,86 @@ +-- 机器人回复语:用现网整理表覆盖旧原型数据,并写入调用编码 reply_code。 +-- 仅改 inquiry_robot.inq_bot_reply。可重复执行(先清空再插入)。 +-- 智能体本期不读本表;编码先落库供后台展示与日后按码取文案。 +-- 若尚无 reply_code 列会先加;列已存在时本句报 1060,可忽略后继续 DELETE/INSERT。 +SET NAMES utf8mb4; + +ALTER TABLE `inq_bot_reply` + ADD COLUMN `reply_code` varchar(64) NULL COMMENT '智能体调用编码' AFTER `category_title`; + +DELETE FROM `inq_bot_reply`; + +INSERT INTO `inq_bot_reply` +(`id`,`category_code`,`category_title`,`reply_code`,`trigger_scene`,`reply_text`,`enabled`,`sort_no`,`create_by`,`create_time`) +VALUES +('brv10000000000000000000000000001','inquiring','询价中','inquiring.first_chat','销售首次单聊机器人','你好,我是询价机器人。请直接描述客户需求,例如「上海到洛杉矶空运,3 件 180kg,下周三前到」,我会解析需求并查询 TMS 标准报价。',1,1,'admin',NOW()), +('brv10000000000000000000000000002','inquiring','询价中','inquiring.ask_transport','未识别运输方式','请回复数字或文字,告知我询价的运输方式: +1.陆运 +2.海运 +3.空运',1,2,'admin',NOW()), +('brv10000000000000000000000000003','inquiring','询价中','inquiring.continue_need_wo','续办但未提供工单号','请告诉我要继续的工单号,例如 WO202609140034。',1,3,'admin',NOW()), +('brv10000000000000000000000000004','inquiring','询价中','inquiring.continue_not_found','续办找不到工单','找不到工单 {{ticket_no}},请核对工单号后再试。',1,4,'admin',NOW()), +('brv10000000000000000000000000005','inquiring','询价中','inquiring.image_recognizing','销售发送图片','正在识别图片',1,5,'admin',NOW()), +('brv10000000000000000000000000006','inquiring','询价中','inquiring.image_tech_fail','图片识别失败','图片暂时没认出来,请再发一张或直接打字。',1,6,'admin',NOW()), +('brv10000000000000000000000000007','inquiring','询价中','inquiring.attach_recognizing','销售发送附件','正在识别附件',1,7,'admin',NOW()), +('brv10000000000000000000000000008','inquiring','询价中','inquiring.attach_tech_fail','附件识别失败','附件暂时没认出来,请再传一次或直接打字。',1,8,'admin',NOW()), +('brv10000000000000000000000000009','inquiring','询价中','inquiring.attach_h5_expired','H5 上传链接过期','已过期,请回企微重新说附件询价。',1,9,'admin',NOW()), +('brv10000000000000000000000000010','inquiring','询价中','inquiring.attach_h5_ok','H5 上传成功','已收到,请回企微查看识别结果。',1,10,'admin',NOW()), +('brv10000000000000000000000000011','inquiring','询价中','inquiring.pull_group_manual','请手动拉人进群并发送工单号','请手动拉相关人员和智能体到原有群,并在群聊发送工单编号{{ticket_no}}',1,11,'admin',NOW()), +('brv10000000000000000000000000012','inquiring','询价中','inquiring.skip_collab_ack','销售跳过内部协同','好的,已跳过内部协同。正在按 TMS 标准报价生成报价单草稿…',1,12,'admin',NOW()), +('brv10000000000000000000000000013','inquiring','询价中','inquiring.group_created','协同群创建成功','协同群已创建,群名与工单号一致:{{ticket_no}}。已邀请:销售{{sales_name}}、产品{{product_names}}。请在群聊中继续确认报价。',1,13,'admin',NOW()), +('brv10000000000000000000000000014','inquiring','询价中','inquiring.group_exists','协同群已存在','协同群已在,群名与工单号一致:{{ticket_no}}。请到该群继续确认报价。',1,14,'admin',NOW()), +('brv10000000000000000000000000015','inquiring','询价中','inquiring.sea_no_staff','海运没有可拉的产品人员','没有可拉的产品人员。请先在后台「员工管理」维护本线路的海运产品岗后再试。',1,15,'admin',NOW()), +('brv10000000000000000000000000016','inquiring','询价中','inquiring.land_no_staff','陆运没有可拉的产品人员','没有可拉的产品人员。请先在后台「员工管理」维护本线路的陆运产品岗后再试。',1,16,'admin',NOW()), +('brv10000000000000000000000000017','inquiring','询价中','inquiring.group_create_fail','建群失败','建群失败,请稍后重试。',1,17,'admin',NOW()), +('brv10000000000000000000000000018','inquiring','询价中','inquiring.quote_generating','报价单生成中','报价单正在生成,请稍候。',1,18,'admin',NOW()), +('brv10000000000000000000000000019','inquiring','询价中','inquiring.air_ticket_not_found','空运群找不到工单','找不到工单{{ticket_no}},请核对工单号后再试。',1,19,'admin',NOW()), +('brv10000000000000000000000000020','inquiring','询价中','inquiring.air_ticket_not_air','工单不是空运单','工单{{ticket_no}}不是空运单,不能在空运群里跟。',1,20,'admin',NOW()), +('brv10000000000000000000000000021','inquiring','询价中','inquiring.air_ticket_occupied','空运工单已在另一个群','工单{{ticket_no}}已经在另一个群跟了,请到那个群继续。',1,21,'admin',NOW()), +('brv10000000000000000000000000022','inquiring','询价中','inquiring.group_only_product','非产品人员在群里报价','只有产品能报价格,请产品在群里发价格或 Excel/PDF',1,22,'admin',NOW()), +('brv10000000000000000000000000023','inquiring','询价中','inquiring.group_file_saved','群附件已保存','附件已保存后台',1,23,'admin',NOW()), +('brv10000000000000000000000000024','inquiring','询价中','inquiring.air_lock_need_wo','空运锁舱/释放未带工单号','锁舱或释放舱位请带上工单号,例如 WO202609160001锁舱。',1,24,'admin',NOW()), +('brv10000000000000000000000000025','inquiring','询价中','inquiring.air_lock_need_quote','没有报价不能锁舱','工单{{ticket_no}}还没有报价,不能锁舱。',1,25,'admin',NOW()), +('brv10000000000000000000000000026','inquiring','询价中','inquiring.air_airline_only_cabin','只有航线能锁舱或释放','只有岗位为「航线·空运」的人能锁舱或释放舱位。',1,26,'admin',NOW()), +('brv10000000000000000000000000027','inquiring','询价中','inquiring.air_lock_ok','空运锁舱成功','工单{{ticket_no}} 已通知TMS执行空运锁舱,执行成功。 +操作人:{{operator}} +舱位选项:{{option_no}} +执行编号:{{exec_no}} +报价编号:{{quote_no}}',1,27,'admin',NOW()), +('brv10000000000000000000000000028','inquiring','询价中','inquiring.air_lock_fail','空运锁舱失败','工单{{ticket_no}} 空运锁舱失败。 +操作人:{{operator}} +锁舱失败的原因:{{reason}} +已通知IT运维排查,请稍等。',1,28,'admin',NOW()), +('brv10000000000000000000000000029','inquiring','询价中','inquiring.air_release_ok','空运释放成功','工单{{ticket_no}} 已通知TMS执行空运释放舱位成功 +操作人:{{operator}} +舱位选项:{{option_no}} +执行编号:{{exec_no}} +报价编号:{{quote_no}}',1,29,'admin',NOW()), +('brv10000000000000000000000000030','inquiring','询价中','inquiring.air_release_fail','空运释放失败','工单{{ticket_no}} 空运释放舱位失败。 +操作人:{{operator}} +释放舱位失败的原因:{{reason}} +已通知IT运维排查,请稍等。',1,30,'admin',NOW()), +('brv10000000000000000000000000031','inquiring','询价中','inquiring.air_release_deal_blocked','已成交不能释放舱位','释放舱位失败,工单{{ticket_no}}已成交。',1,31,'admin',NOW()), +('brv10000000000000000000000000032','inquiring','询价中','inquiring.air_release_need_lock','没有有效锁舱不能释放','工单{{ticket_no}}还没有有效锁舱,不能释放。',1,32,'admin',NOW()), +('brv10000000000000000000000000033','quoted','已报价','quoted.reject_ack','销售选择「不采用」','请问如何调整报价单,以满足您的需求?请说明要调哪一项费用,例如「提货费加 10%」。',1,1,'admin',NOW()), +('brv10000000000000000000000000034','quoted','已报价','quoted.adopt_excel','销售确认采用 → 生成 Excel','已确认采用该方案,正在生成 Excel 最终报价单…',1,2,'admin',NOW()), +('brv10000000000000000000000000035','quoted','已报价','quoted.adopt_pdf','销售确认采用 → 生成 PDF','已确认采用该方案,正在生成PDF最终报价单…',1,3,'admin',NOW()), +('brv10000000000000000000000000036','quoted','已报价','quoted.template_missing','后台未维护报价模板','生成报价单失败,请联系IT运维维护后台报价模板。',1,4,'admin',NOW()), +('brv10000000000000000000000000037','quoted','已报价','quoted.file_ready','最终报价单生成完成','已生成{{kind}}最终报价单:{{filename}}(工单 {{ticket_no}})',1,5,'admin',NOW()), +('brv10000000000000000000000000038','deal','已成交','deal.recorded_won','销售选择「成交」','已记录为「已成交」。工单 {{ticket_no}} 已关闭智能体跟进。',1,1,'admin',NOW()), +('brv10000000000000000000000000039','negotiating','协商中','negotiating.recorded','成交跟进中选择「协商中」','已记录为「协商中」。已顺延三个工作日,{{followup_when}}的9:00再次发起成交状态询问。',1,1,'admin',NOW()), +('brv10000000000000000000000000040','lost','未成交','lost.ask_reason','成交跟进中选择「未成交」且未写原因','已记录为「未成交」。请补充未成交原因。',1,1,'admin',NOW()), +('brv10000000000000000000000000041','lost','未成交','lost.reason_ack','销售回复未成交原因','已记下未成交原因:{{lost_reason}}。',1,2,'admin',NOW()), +('brv10000000000000000000000000042','closed','已关闭','closed.air_cannot_activate','空运工单已是终态仍要激活','工单{{ticket_no}}已是「{{status}}」,不能再激活。',1,1,'admin',NOW()), +('brv10000000000000000000000000043','sys-ex','系统异常','sys.llm_fail','大模型调用失败(销售侧)','大模型服务异常,IT排查中...',1,1,'admin',NOW()), +('brv10000000000000000000000000044','sys-ex','系统异常','sys.tms_fail','TMS 接口失败(销售侧)','TMS接口异常,IT运维排查中...',1,2,'admin',NOW()), +('brv10000000000000000000000000045','sys-ex','系统异常','sys.group_add_fail','企微添加成员失败','无法添加 {{staff_name}} 同事,请联系管理员检查企业微信配置。',1,3,'admin',NOW()), +('brv10000000000000000000000000046','sys-ex','系统异常','sys.tms_recovery','TMS 已恢复','TMS已恢复正常使用,感谢您的耐心等待。',1,4,'admin',NOW()), +('brv10000000000000000000000000047','sys-ex','系统异常','sys.llm_recovery','大模型已恢复','大模型已恢复正常使用,感谢您的耐心等待。',1,5,'admin',NOW()), +('brv10000000000000000000000000048','sys-ex','系统异常','sys.cabin_recovery','空运舱位接口已恢复','空运舱位接口已恢复正常使用,感谢您的耐心等待。',1,6,'admin',NOW()), +('brv10000000000000000000000000049','sys-ex','系统异常','sys.group_recovery','建群已恢复','建群已恢复正常,感谢您的耐心等待。',1,7,'admin',NOW()), +('brv10000000000000000000000000050','transfer','转人工','transfer.already_collab','该工单已拉过产品进群','该工单已拉产品进群协同。',1,1,'admin',NOW()), +('brv10000000000000000000000000051','transfer','转人工','transfer.air_hint','空运转人工(不自动建群)','触发转人工,请手动把航线和智能体拉进原有空运群,并发送工单号 {{ticket_no}}。',1,2,'admin',NOW()); + +ALTER TABLE `inq_bot_reply` + ADD UNIQUE INDEX `uk_inq_bot_reply_reply_code` (`reply_code`); + diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_inq_bot_reply_reply_code_v2.sql b/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_inq_bot_reply_reply_code_v2.sql new file mode 100644 index 0000000..9f89bac --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_inq_bot_reply_reply_code_v2.sql @@ -0,0 +1,63 @@ +-- 为 inq_bot_reply 增加智能体调用编码 reply_code,并回填现网 51 条。 +-- 仅改 inquiry_robot。列已存在时 ADD 会报 1060,可忽略后继续执行 UPDATE。 +-- 本期智能体不读本表。 +SET NAMES utf8mb4; + +ALTER TABLE `inq_bot_reply` + ADD COLUMN `reply_code` varchar(64) NULL COMMENT '智能体调用编码' AFTER `category_title`; + +UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.first_chat' WHERE `id` = 'brv10000000000000000000000000001' OR (`reply_code` IS NULL AND `trigger_scene` = '销售首次单聊机器人'); +UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.ask_transport' WHERE `id` = 'brv10000000000000000000000000002' OR (`reply_code` IS NULL AND `trigger_scene` = '未识别运输方式'); +UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.continue_need_wo' WHERE `id` = 'brv10000000000000000000000000003' OR (`reply_code` IS NULL AND `trigger_scene` = '续办但未提供工单号'); +UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.continue_not_found' WHERE `id` = 'brv10000000000000000000000000004' OR (`reply_code` IS NULL AND `trigger_scene` = '续办找不到工单'); +UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.image_recognizing' WHERE `id` = 'brv10000000000000000000000000005' OR (`reply_code` IS NULL AND `trigger_scene` = '销售发送图片'); +UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.image_tech_fail' WHERE `id` = 'brv10000000000000000000000000006' OR (`reply_code` IS NULL AND `trigger_scene` = '图片识别失败'); +UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.attach_recognizing' WHERE `id` = 'brv10000000000000000000000000007' OR (`reply_code` IS NULL AND `trigger_scene` = '销售发送附件'); +UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.attach_tech_fail' WHERE `id` = 'brv10000000000000000000000000008' OR (`reply_code` IS NULL AND `trigger_scene` = '附件识别失败'); +UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.attach_h5_expired' WHERE `id` = 'brv10000000000000000000000000009' OR (`reply_code` IS NULL AND `trigger_scene` = 'H5 上传链接过期'); +UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.attach_h5_ok' WHERE `id` = 'brv10000000000000000000000000010' OR (`reply_code` IS NULL AND `trigger_scene` = 'H5 上传成功'); +UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.pull_group_manual' WHERE `id` = 'brv10000000000000000000000000011' OR (`reply_code` IS NULL AND `trigger_scene` = '请手动拉人进群并发送工单号'); +UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.skip_collab_ack' WHERE `id` = 'brv10000000000000000000000000012' OR (`reply_code` IS NULL AND `trigger_scene` = '销售跳过内部协同'); +UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.group_created' WHERE `id` = 'brv10000000000000000000000000013' OR (`reply_code` IS NULL AND `trigger_scene` = '协同群创建成功'); +UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.group_exists' WHERE `id` = 'brv10000000000000000000000000014' OR (`reply_code` IS NULL AND `trigger_scene` = '协同群已存在'); +UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.sea_no_staff' WHERE `id` = 'brv10000000000000000000000000015' OR (`reply_code` IS NULL AND `trigger_scene` = '海运没有可拉的产品人员'); +UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.land_no_staff' WHERE `id` = 'brv10000000000000000000000000016' OR (`reply_code` IS NULL AND `trigger_scene` = '陆运没有可拉的产品人员'); +UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.group_create_fail' WHERE `id` = 'brv10000000000000000000000000017' OR (`reply_code` IS NULL AND `trigger_scene` = '建群失败'); +UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.quote_generating' WHERE `id` = 'brv10000000000000000000000000018' OR (`reply_code` IS NULL AND `trigger_scene` = '报价单生成中'); +UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.air_ticket_not_found' WHERE `id` = 'brv10000000000000000000000000019' OR (`reply_code` IS NULL AND `trigger_scene` = '空运群找不到工单'); +UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.air_ticket_not_air' WHERE `id` = 'brv10000000000000000000000000020' OR (`reply_code` IS NULL AND `trigger_scene` = '工单不是空运单'); +UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.air_ticket_occupied' WHERE `id` = 'brv10000000000000000000000000021' OR (`reply_code` IS NULL AND `trigger_scene` = '空运工单已在另一个群'); +UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.group_only_product' WHERE `id` = 'brv10000000000000000000000000022' OR (`reply_code` IS NULL AND `trigger_scene` = '非产品人员在群里报价'); +UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.group_file_saved' WHERE `id` = 'brv10000000000000000000000000023' OR (`reply_code` IS NULL AND `trigger_scene` = '群附件已保存'); +UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.air_lock_need_wo' WHERE `id` = 'brv10000000000000000000000000024' OR (`reply_code` IS NULL AND `trigger_scene` = '空运锁舱/释放未带工单号'); +UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.air_lock_need_quote' WHERE `id` = 'brv10000000000000000000000000025' OR (`reply_code` IS NULL AND `trigger_scene` = '没有报价不能锁舱'); +UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.air_airline_only_cabin' WHERE `id` = 'brv10000000000000000000000000026' OR (`reply_code` IS NULL AND `trigger_scene` = '只有航线能锁舱或释放'); +UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.air_lock_ok' WHERE `id` = 'brv10000000000000000000000000027' OR (`reply_code` IS NULL AND `trigger_scene` = '空运锁舱成功'); +UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.air_lock_fail' WHERE `id` = 'brv10000000000000000000000000028' OR (`reply_code` IS NULL AND `trigger_scene` = '空运锁舱失败'); +UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.air_release_ok' WHERE `id` = 'brv10000000000000000000000000029' OR (`reply_code` IS NULL AND `trigger_scene` = '空运释放成功'); +UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.air_release_fail' WHERE `id` = 'brv10000000000000000000000000030' OR (`reply_code` IS NULL AND `trigger_scene` = '空运释放失败'); +UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.air_release_deal_blocked' WHERE `id` = 'brv10000000000000000000000000031' OR (`reply_code` IS NULL AND `trigger_scene` = '已成交不能释放舱位'); +UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.air_release_need_lock' WHERE `id` = 'brv10000000000000000000000000032' OR (`reply_code` IS NULL AND `trigger_scene` = '没有有效锁舱不能释放'); +UPDATE `inq_bot_reply` SET `reply_code` = 'quoted.reject_ack' WHERE `id` = 'brv10000000000000000000000000033' OR (`reply_code` IS NULL AND `trigger_scene` = '销售选择「不采用」'); +UPDATE `inq_bot_reply` SET `reply_code` = 'quoted.adopt_excel' WHERE `id` = 'brv10000000000000000000000000034' OR (`reply_code` IS NULL AND `trigger_scene` = '销售确认采用 → 生成 Excel'); +UPDATE `inq_bot_reply` SET `reply_code` = 'quoted.adopt_pdf' WHERE `id` = 'brv10000000000000000000000000035' OR (`reply_code` IS NULL AND `trigger_scene` = '销售确认采用 → 生成 PDF'); +UPDATE `inq_bot_reply` SET `reply_code` = 'quoted.template_missing' WHERE `id` = 'brv10000000000000000000000000036' OR (`reply_code` IS NULL AND `trigger_scene` = '后台未维护报价模板'); +UPDATE `inq_bot_reply` SET `reply_code` = 'quoted.file_ready' WHERE `id` = 'brv10000000000000000000000000037' OR (`reply_code` IS NULL AND `trigger_scene` = '最终报价单生成完成'); +UPDATE `inq_bot_reply` SET `reply_code` = 'deal.recorded_won' WHERE `id` = 'brv10000000000000000000000000038' OR (`reply_code` IS NULL AND `trigger_scene` = '销售选择「成交」'); +UPDATE `inq_bot_reply` SET `reply_code` = 'negotiating.recorded' WHERE `id` = 'brv10000000000000000000000000039' OR (`reply_code` IS NULL AND `trigger_scene` = '成交跟进中选择「协商中」'); +UPDATE `inq_bot_reply` SET `reply_code` = 'lost.ask_reason' WHERE `id` = 'brv10000000000000000000000000040' OR (`reply_code` IS NULL AND `trigger_scene` = '成交跟进中选择「未成交」且未写原因'); +UPDATE `inq_bot_reply` SET `reply_code` = 'lost.reason_ack' WHERE `id` = 'brv10000000000000000000000000041' OR (`reply_code` IS NULL AND `trigger_scene` = '销售回复未成交原因'); +UPDATE `inq_bot_reply` SET `reply_code` = 'closed.air_cannot_activate' WHERE `id` = 'brv10000000000000000000000000042' OR (`reply_code` IS NULL AND `trigger_scene` = '空运工单已是终态仍要激活'); +UPDATE `inq_bot_reply` SET `reply_code` = 'sys.llm_fail' WHERE `id` = 'brv10000000000000000000000000043' OR (`reply_code` IS NULL AND `trigger_scene` = '大模型调用失败(销售侧)'); +UPDATE `inq_bot_reply` SET `reply_code` = 'sys.tms_fail' WHERE `id` = 'brv10000000000000000000000000044' OR (`reply_code` IS NULL AND `trigger_scene` = 'TMS 接口失败(销售侧)'); +UPDATE `inq_bot_reply` SET `reply_code` = 'sys.group_add_fail' WHERE `id` = 'brv10000000000000000000000000045' OR (`reply_code` IS NULL AND `trigger_scene` = '企微添加成员失败'); +UPDATE `inq_bot_reply` SET `reply_code` = 'sys.tms_recovery' WHERE `id` = 'brv10000000000000000000000000046' OR (`reply_code` IS NULL AND `trigger_scene` = 'TMS 已恢复'); +UPDATE `inq_bot_reply` SET `reply_code` = 'sys.llm_recovery' WHERE `id` = 'brv10000000000000000000000000047' OR (`reply_code` IS NULL AND `trigger_scene` = '大模型已恢复'); +UPDATE `inq_bot_reply` SET `reply_code` = 'sys.cabin_recovery' WHERE `id` = 'brv10000000000000000000000000048' OR (`reply_code` IS NULL AND `trigger_scene` = '空运舱位接口已恢复'); +UPDATE `inq_bot_reply` SET `reply_code` = 'sys.group_recovery' WHERE `id` = 'brv10000000000000000000000000049' OR (`reply_code` IS NULL AND `trigger_scene` = '建群已恢复'); +UPDATE `inq_bot_reply` SET `reply_code` = 'transfer.already_collab' WHERE `id` = 'brv10000000000000000000000000050' OR (`reply_code` IS NULL AND `trigger_scene` = '该工单已拉过产品进群'); +UPDATE `inq_bot_reply` SET `reply_code` = 'transfer.air_hint' WHERE `id` = 'brv10000000000000000000000000051' OR (`reply_code` IS NULL AND `trigger_scene` = '空运转人工(不自动建群)'); + +ALTER TABLE `inq_bot_reply` + ADD UNIQUE INDEX `uk_inq_bot_reply_reply_code` (`reply_code`); + diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateFillTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateFillTest.java index 7ae0e22..858318e 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateFillTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateFillTest.java @@ -1251,6 +1251,385 @@ public class QuoteTemplateFillTest { } } + /** + * 现网跨境集拼:第 5 行蓝字是表头(客户名称/始发地/货物类型被合并), + * 第 8–9 行是「始发地 | 目的地 | 最低收费 | 元/KG」。 + * 城市和品名不得盖掉这些字。 + */ + @Test + public void landGroupageKeepsTemplateHeaderWords() throws Exception { + Path src = Files.createTempFile("quote-land-groupage-real", ".xlsx"); + Path out = Files.createTempFile("quote-land-groupage-real-out", ".xlsx"); + try (Workbook wb = new XSSFWorkbook()) { + Sheet sheet = wb.createSheet("中越集拼报价"); + org.apache.poi.ss.usermodel.Row band = row(sheet, 4); + band.createCell(1).setCellValue("业务员"); + band.createCell(2).setCellValue("客户名称"); + band.createCell(4).setCellValue("始发地"); + band.createCell(6).setCellValue("条款"); + band.createCell(10).setCellValue("发货日期"); + band.createCell(11).setCellValue("时效"); + band.createCell(13).setCellValue("货物类型"); + sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(4, 4, 2, 3)); + sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(4, 4, 4, 5)); + sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(4, 4, 13, 14)); + row(sheet, 5); + org.apache.poi.ss.usermodel.Row matrix = row(sheet, 7); + matrix.createCell(1).setCellValue("始发地"); + matrix.createCell(2).setCellValue("目的地"); + matrix.createCell(4).setCellValue("2-500KG"); + matrix.createCell(9).setCellValue("币种"); + matrix.createCell(10).setCellValue("燃油附加费率"); + org.apache.poi.ss.usermodel.Row sub = row(sheet, 8); + sub.createCell(3).setCellValue("最低收费"); + sub.createCell(4).setCellValue("元/KG"); + sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(7, 8, 1, 1)); + sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(7, 8, 2, 2)); + row(sheet, 9); + try (java.io.OutputStream os = Files.newOutputStream(src)) { + wb.write(os); + } + } + JSONObject mapping = new JSONObject(true); + mapping.put("sheet", "中越集拼报价"); + JSONObject fields = new JSONObject(true); + fields.put("sales_name", "中越集拼报价!C5"); + fields.put("origin", "中越集拼报价!F5"); + fields.put("commodity", "中越集拼报价!O5"); + fields.put("quote_date", "中越集拼报价!K5"); + mapping.put("fields", fields); + JSONArray regions = new JSONArray(); + JSONObject region = new JSONObject(true); + region.put("headerRow", 8); + region.put("dataStartRow", 9); + JSONObject columns = new JSONObject(true); + columns.put("origin", "B"); + columns.put("destination", "C"); + region.put("columns", columns); + regions.add(region); + mapping.put("routeRegions", regions); + JSONObject facts = new JSONObject(true); + facts.put("sales_name", "LinMianXun"); + facts.put("origin", "上海"); + facts.put("destination", "胡志明"); + facts.put("commodity", "茶叶"); + facts.put("quote_date", "2026-10-09"); + facts.put("route_rows", QuoteTemplateExcelSupport.parseFeeRows( + "[{\"origin\":\"上海\",\"destination\":\"胡志明\"}]")); + QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts); + try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) { + Sheet sheet = filled.getSheetAt(0); + assertEquals("客户名称", read(sheet, 4, 2)); + assertEquals("始发地", read(sheet, 4, 4)); + assertEquals("货物类型", read(sheet, 4, 13)); + assertEquals("发货日期", read(sheet, 4, 10)); + assertEquals("LinMianXun", read(sheet, 5, 1)); + assertEquals("", read(sheet, 5, 2)); + assertEquals("上海", read(sheet, 5, 4)); + assertEquals("茶叶", read(sheet, 5, 13)); + assertEquals("2026-10-09", read(sheet, 5, 10)); + assertEquals("始发地", read(sheet, 7, 1)); + assertEquals("目的地", read(sheet, 7, 2)); + assertEquals("最低收费", read(sheet, 8, 3)); + assertEquals("", read(sheet, 7, 3)); + assertEquals("上海", read(sheet, 9, 1)); + assertEquals("胡志明", read(sheet, 9, 2)); + } finally { + Files.deleteIfExists(src); + Files.deleteIfExists(out); + } + } + + /** + * 销售没填客户名称时,客户名称格保持空白,不能写成业务员。 + * 询价条带的时效写在表头下一行;公斤分档价按 weightPrices 落入各列。 + */ + @Test + public void landGroupageLeavesCustomerBlankAndFillsTransitAndKgPrices() throws Exception { + Path src = Files.createTempFile("quote-land-groupage-price", ".xlsx"); + Path out = Files.createTempFile("quote-land-groupage-price-out", ".xlsx"); + try (Workbook wb = new XSSFWorkbook()) { + Sheet sheet = wb.createSheet("中越集拼报价"); + org.apache.poi.ss.usermodel.Row band = row(sheet, 4); + band.createCell(1).setCellValue("业务员"); + band.createCell(2).setCellValue("客户名称"); + band.createCell(4).setCellValue("始发地"); + band.createCell(10).setCellValue("发货日期"); + band.createCell(11).setCellValue("时效"); + band.createCell(13).setCellValue("货物类型"); + sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(4, 4, 2, 3)); + sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(4, 4, 4, 5)); + sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(4, 4, 11, 12)); + sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(4, 4, 13, 14)); + row(sheet, 5); + sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(5, 5, 11, 12)); + org.apache.poi.ss.usermodel.Row matrix = row(sheet, 7); + matrix.createCell(1).setCellValue("始发地"); + matrix.createCell(2).setCellValue("目的地"); + matrix.createCell(4).setCellValue("2-500KG"); + matrix.createCell(5).setCellValue("501-1000KG"); + matrix.createCell(6).setCellValue("1001-3000KG"); + matrix.createCell(7).setCellValue("3001-5000KG"); + matrix.createCell(8).setCellValue("5001KG以上"); + matrix.createCell(15).setCellValue("时效"); + org.apache.poi.ss.usermodel.Row sub = row(sheet, 8); + sub.createCell(3).setCellValue("最低收费"); + sub.createCell(4).setCellValue("元/KG"); + sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(7, 8, 1, 1)); + sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(7, 8, 2, 2)); + row(sheet, 9); + try (java.io.OutputStream os = Files.newOutputStream(src)) { + wb.write(os); + } + } + JSONArray routeRows = new JSONArray(); + JSONObject route = new JSONObject(true); + route.put("origin", "广西壮族自治区"); + route.put("destination", "胡志明"); + route.put("currency", "CNY"); + route.put("transit_time", "N+3"); + routeRows.add(route); + JSONArray rawLand = new JSONArray(); + JSONObject seg = new JSONObject(true); + seg.put("origin", "广西壮族自治区"); + seg.put("destination", "胡志明"); + seg.put("weightPrices", JSONArray.parseArray( + "[{\"min\":2,\"max\":500,\"price\":12.0},{\"min\":501,\"max\":1000,\"price\":11.5}," + + "{\"min\":1001,\"max\":3000,\"price\":11.0},{\"min\":3001,\"max\":5000,\"price\":10.5}," + + "{\"min\":5001,\"price\":10.0}]")); + rawLand.add(seg); + QuoteTemplateExcelSupport.copyMissingWeightPrices(routeRows, rawLand); + JSONObject mapping = new JSONObject(true); + mapping.put("sheet", "中越集拼报价"); + JSONObject fields = new JSONObject(true); + fields.put("sales_name", "中越集拼报价!C5"); + fields.put("customer_name", "中越集拼报价!D5"); + fields.put("transit_time", "中越集拼报价!M5"); + mapping.put("fields", fields); + JSONArray regions = new JSONArray(); + JSONObject region = new JSONObject(true); + region.put("headerRow", 8); + region.put("dataStartRow", 9); + JSONObject columns = new JSONObject(true); + columns.put("origin", "B"); + columns.put("destination", "C"); + columns.put("transit_time", "P"); + JSONObject vehicleRates = new JSONObject(true); + vehicleRates.put("2-500KG", "E"); + vehicleRates.put("501-1000KG", "F"); + vehicleRates.put("1001-3000KG", "G"); + vehicleRates.put("3001-5000KG", "H"); + vehicleRates.put("5001KG以上", "I"); + columns.put("vehicleRates", vehicleRates); + region.put("columns", columns); + regions.add(region); + mapping.put("routeRegions", regions); + JSONObject facts = new JSONObject(true); + facts.put("sales_name", "LuoXiaoHua"); + facts.put("transit_time", "N+3"); + facts.put("transit_days", "N+3"); + facts.put("route_rows", routeRows); + QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts); + try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) { + Sheet sheet = filled.getSheetAt(0); + assertEquals("客户名称", read(sheet, 4, 2)); + assertEquals("时效", read(sheet, 4, 11)); + assertEquals("LuoXiaoHua", read(sheet, 5, 1)); + assertEquals("", read(sheet, 5, 2)); + assertEquals("N+3", read(sheet, 5, 11)); + assertEquals("2-500KG", read(sheet, 7, 4)); + assertEquals("时效", read(sheet, 7, 15)); + assertEquals("12.0", read(sheet, 9, 4)); + assertEquals("11.5", read(sheet, 9, 5)); + assertEquals("11.0", read(sheet, 9, 6)); + assertEquals("10.5", read(sheet, 9, 7)); + assertEquals("10.0", read(sheet, 9, 8)); + assertEquals("N+3", read(sheet, 9, 15)); + } finally { + Files.deleteIfExists(src); + Files.deleteIfExists(out); + } + } + + /** + * 配送费用表头上的「时效」不能把 N+3 写到右边空白格,列头蓝底和文字都要留着。 + */ + @Test + public void landGroupageDoesNotSpillTransitBesideDeliveryHeader() throws Exception { + Path src = Files.createTempFile("quote-land-delivery-src", ".xlsx"); + Path out = Files.createTempFile("quote-land-delivery-out", ".xlsx"); + try (Workbook wb = new XSSFWorkbook()) { + Sheet sheet = wb.createSheet("中越集拼报价"); + org.apache.poi.ss.usermodel.CellStyle blue = wb.createCellStyle(); + blue.setFillForegroundColor(org.apache.poi.ss.usermodel.IndexedColors.ROYAL_BLUE.getIndex()); + blue.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND); + org.apache.poi.ss.usermodel.Row band = row(sheet, 4); + band.createCell(1).setCellValue("业务员"); + band.createCell(2).setCellValue("客户名称"); + band.createCell(4).setCellValue("始发地"); + band.createCell(11).setCellValue("时效"); + band.getCell(11).setCellStyle(blue); + org.apache.poi.ss.usermodel.Row head = row(sheet, 18); + String[] labels = {"城市类别", "覆盖区域", "单价(元/票)", "燃油附加费率", "时效"}; + int[] cols = {8, 10, 12, 13, 14}; + for (int i = 0; i < labels.length; i++) { + org.apache.poi.ss.usermodel.Cell c = head.createCell(cols[i]); + c.setCellValue(labels[i]); + c.setCellStyle(blue); + } + row(sheet, 19); + try (java.io.OutputStream os = Files.newOutputStream(src)) { + wb.write(os); + } + } + JSONObject facts = new JSONObject(true); + facts.put("transit_time", "N+3"); + facts.put("transit_days", "N+3"); + QuoteTemplateExcelSupport.fillWorkbook(src, out, new JSONObject(), facts); + try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) { + Sheet sheet = filled.getSheetAt(0); + assertEquals("时效", read(sheet, 18, 14)); + assertEquals("燃油附加费率", read(sheet, 18, 13)); + assertEquals("", read(sheet, 18, 15)); + assertEquals("", read(sheet, 19, 15)); + assertEquals("N+3", read(sheet, 5, 11)); + assertEquals(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND, + sheet.getRow(18).getCell(14).getCellStyle().getFillPattern()); + assertEquals(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND, + sheet.getRow(18).getCell(13).getCellStyle().getFillPattern()); + } finally { + Files.deleteIfExists(src); + Files.deleteIfExists(out); + } + } + + /** + * 目的地长地名不能按备注换行顶进蓝表头,红字要改回黑字。 + * 配送费的覆盖区域、燃油附加费率不能写成 CNY,币种只留在运价表「币种」列。 + */ + @Test + public void landGroupageKeepsCityOutOfHeaderAndCurrencyOutOfFuel() throws Exception { + Path src = Files.createTempFile("quote-land-city-src", ".xlsx"); + Path out = Files.createTempFile("quote-land-city-out", ".xlsx"); + try (Workbook wb = new XSSFWorkbook()) { + Sheet sheet = wb.createSheet("中越集拼报价"); + org.apache.poi.ss.usermodel.Font white = wb.createFont(); + white.setColor(org.apache.poi.ss.usermodel.IndexedColors.WHITE.getIndex()); + white.setBold(true); + org.apache.poi.ss.usermodel.CellStyle band = wb.createCellStyle(); + band.setFillForegroundColor(org.apache.poi.ss.usermodel.IndexedColors.ROYAL_BLUE.getIndex()); + band.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND); + band.setFont(white); + org.apache.poi.ss.usermodel.Row bandRow = row(sheet, 4); + org.apache.poi.ss.usermodel.Cell sales = bandRow.createCell(1); + sales.setCellValue("业务员"); + sales.setCellStyle(band); + org.apache.poi.ss.usermodel.CellStyle matrixBlue = wb.createCellStyle(); + matrixBlue.setFillForegroundColor(org.apache.poi.ss.usermodel.IndexedColors.SKY_BLUE.getIndex()); + matrixBlue.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND); + matrixBlue.setFont(white); + org.apache.poi.ss.usermodel.Row matrix = row(sheet, 7); + org.apache.poi.ss.usermodel.Cell originHead = matrix.createCell(1); + originHead.setCellValue("始发地"); + originHead.setCellStyle(matrixBlue); + org.apache.poi.ss.usermodel.Cell destHead = matrix.createCell(2); + destHead.setCellValue("目的地"); + destHead.setCellStyle(matrixBlue); + org.apache.poi.ss.usermodel.Cell currencyHead = matrix.createCell(9); + currencyHead.setCellValue("币种"); + // 运价表头同样有燃油附加费率和时效。不能因此整段跳过,否则始发地、目的地都不写。 + matrix.createCell(10).setCellValue("燃油附加费率"); + matrix.createCell(15).setCellValue("时效"); + matrix.getCell(15).setCellStyle(matrixBlue); + sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(7, 8, 2, 2)); + sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(7, 8, 9, 9)); + org.apache.poi.ss.usermodel.Font red = wb.createFont(); + red.setColor(org.apache.poi.ss.usermodel.IndexedColors.RED.getIndex()); + org.apache.poi.ss.usermodel.CellStyle redOnSky = wb.createCellStyle(); + redOnSky.cloneStyleFrom(matrixBlue); + redOnSky.setFont(red); + redOnSky.setWrapText(true); + redOnSky.setVerticalAlignment(org.apache.poi.ss.usermodel.VerticalAlignment.TOP); + currencyHead.setCellStyle(redOnSky); + matrix.getCell(10).setCellStyle(redOnSky); + org.apache.poi.ss.usermodel.Row data = row(sheet, 9); + org.apache.poi.ss.usermodel.Cell dest = data.createCell(2); + dest.setCellStyle(redOnSky); + org.apache.poi.ss.usermodel.Row delivery = row(sheet, 18); + delivery.createCell(8).setCellValue("城市类别"); + delivery.getCell(8).setCellStyle(matrixBlue); + delivery.createCell(10).setCellValue("覆盖区域"); + delivery.getCell(10).setCellStyle(matrixBlue); + delivery.createCell(12).setCellValue("单价(元/票)"); + delivery.getCell(12).setCellStyle(matrixBlue); + delivery.createCell(13).setCellValue("燃油附加费率"); + delivery.getCell(13).setCellStyle(redOnSky); + delivery.createCell(14).setCellValue("时效"); + delivery.getCell(14).setCellStyle(matrixBlue); + row(sheet, 19).createCell(12).setCellValue(200); + try (java.io.OutputStream os = Files.newOutputStream(src)) { + wb.write(os); + } + } + JSONArray routeRows = new JSONArray(); + JSONObject route = new JSONObject(true); + route.put("origin", "广西壮族自治区"); + route.put("destination", "胡志明Hồ Chí Minh"); + route.put("currency", "CNY"); + routeRows.add(route); + JSONObject mapping = new JSONObject(true); + mapping.put("sheet", "中越集拼报价"); + JSONArray regions = new JSONArray(); + JSONObject rate = new JSONObject(true); + rate.put("headerRow", 8); + rate.put("dataStartRow", 10); + JSONObject rateCols = new JSONObject(true); + rateCols.put("origin", "B"); + rateCols.put("destination", "C"); + rateCols.put("currency", "J"); + rate.put("columns", rateCols); + regions.add(rate); + JSONObject deliveryRegion = new JSONObject(true); + deliveryRegion.put("headerRow", 19); + deliveryRegion.put("dataStartRow", 20); + JSONObject deliveryCols = new JSONObject(true); + deliveryCols.put("origin", "I"); + deliveryCols.put("destination", "K"); + deliveryCols.put("currency", "N"); + deliveryRegion.put("columns", deliveryCols); + regions.add(deliveryRegion); + mapping.put("routeRegions", regions); + JSONObject facts = new JSONObject(true); + facts.put("route_rows", routeRows); + QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts); + try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) { + Sheet sheet = filled.getSheetAt(0); + assertEquals("目的地", read(sheet, 7, 2)); + assertEquals("胡志明Hồ Chí Minh", read(sheet, 9, 2)); + assertEquals("CNY", read(sheet, 9, 9)); + assertEquals(org.apache.poi.ss.usermodel.VerticalAlignment.CENTER, + sheet.getRow(9).getCell(2).getCellStyle().getVerticalAlignment()); + assertFalse(QuoteTemplateExcelSupport.fontIsRed(sheet.getRow(9).getCell(2))); + assertEquals("始发地", read(sheet, 7, 1)); + assertEquals("币种", read(sheet, 7, 9)); + byte[] originFill = QuoteTemplateExcelSupport.readFillRgb(sheet.getRow(7).getCell(1)); + byte[] currencyFill = QuoteTemplateExcelSupport.readFillRgb(sheet.getRow(7).getCell(9)); + byte[] salesFill = QuoteTemplateExcelSupport.readFillRgb(sheet.getRow(4).getCell(1)); + byte[] deliveryFuelFill = QuoteTemplateExcelSupport.readFillRgb(sheet.getRow(18).getCell(13)); + assertArrayEquals(originFill, currencyFill); + assertFalse(java.util.Arrays.equals(originFill, salesFill)); + assertArrayEquals(originFill, deliveryFuelFill); + assertEquals("", read(sheet, 19, 8)); + assertEquals("", read(sheet, 19, 10)); + assertEquals("燃油附加费率", read(sheet, 18, 13)); + assertEquals("", read(sheet, 19, 13)); + assertEquals("200.0", read(sheet, 19, 12)); + } finally { + Files.deleteIfExists(src); + Files.deleteIfExists(out); + } + } + /** * 陆运国内拼车模板是标签栏,不是「费用名称+金额」表。 * TMS 的 元/CBM、送货费、时效必须写到标签右侧,不能落到 G 列或整张空着。 @@ -1329,6 +1708,27 @@ public class QuoteTemplateFillTest { } } + /** + * 业务员写中文名,不写企微账号。客户名称只留销售填的,账号不能占这一格。 + */ + @Test + public void buildFillFactsUsesStaffNameAndKeepsCustomer() { + Map facts = new HashMap<>(); + facts.put("sales_name", "林绵勋"); + facts.put("客户名称", "南宁茶业"); + JSONObject out = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl + .buildFillFacts("WO1", facts, new HashMap<>(), "LinMianXun"); + assertEquals("林绵勋", out.getString("sales_name")); + assertEquals("南宁茶业", out.getString("customer_name")); + + facts.put("customer_name", "LinMianXun"); + facts.remove("客户名称"); + JSONObject cleared = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl + .buildFillFacts("WO1", facts, new HashMap<>(), "LinMianXun"); + assertEquals("林绵勋", cleared.getString("sales_name")); + assertEquals("", cleared.getString("customer_name")); + } + /** * TMS 陆运 fee_lines 要投影成模板标签码,写出单时才能对上 元/CBM。 */ diff --git a/inquiry-backend/src/views/inquiry/botReply/botReply.data.ts b/inquiry-backend/src/views/inquiry/botReply/botReply.data.ts index 9e280f9..c7a9aaf 100644 --- a/inquiry-backend/src/views/inquiry/botReply/botReply.data.ts +++ b/inquiry-backend/src/views/inquiry/botReply/botReply.data.ts @@ -3,6 +3,7 @@ import { BasicColumn, FormSchema } from '/@/components/Table'; export const columns: BasicColumn[] = [ { title: '分类', dataIndex: 'categoryTitle', width: 100 }, { title: '分类码', dataIndex: 'categoryCode', width: 120 }, + { title: '调用编码', dataIndex: 'replyCode', width: 180 }, { title: '触发场景', dataIndex: 'triggerScene', width: 220 }, { title: '回复正文', dataIndex: 'replyText', align: 'left' }, { @@ -28,6 +29,9 @@ export const searchFormSchema: FormSchema[] = [ { label: '协商中', value: 'negotiating' }, { label: '未成交', value: 'lost' }, { label: '已关闭', value: 'closed' }, + { label: '事件异常', value: 'event-ex' }, + { label: '系统异常', value: 'sys-ex' }, + { label: '转人工', value: 'transfer' }, ], }, }, @@ -48,10 +52,19 @@ export const formSchema: FormSchema[] = [ { label: '协商中 negotiating', value: 'negotiating' }, { label: '未成交 lost', value: 'lost' }, { label: '已关闭 closed', value: 'closed' }, + { label: '事件异常 event-ex', value: 'event-ex' }, + { label: '系统异常 sys-ex', value: 'sys-ex' }, + { label: '转人工 transfer', value: 'transfer' }, ], }, }, { field: 'categoryTitle', label: '分类标题', component: 'Input', required: true }, + { + field: 'replyCode', + label: '调用编码', + component: 'Input', + componentProps: { placeholder: '如 lost.ask_reason,已有编码不可改' }, + }, { field: 'triggerScene', label: '触发场景', component: 'Input', required: true }, { field: 'replyText', diff --git a/inquiry-backend/src/views/inquiry/botReply/index.vue b/inquiry-backend/src/views/inquiry/botReply/index.vue index 0d55345..f2a5afb 100644 --- a/inquiry-backend/src/views/inquiry/botReply/index.vue +++ b/inquiry-backend/src/views/inquiry/botReply/index.vue @@ -2,6 +2,7 @@ /** * 机器人回复语:对接主账 /inquiry/botReply。 * 分类壳用本地元数据;条目内容全部来自库。 + * 调用编码仅展示/预置,本期不向智能体下发。 */ import { onMounted, reactive, ref } from "vue"; import { message } from "ant-design-vue"; @@ -21,12 +22,14 @@ import AdminModal from "/@/views/inquiry/components/AdminModal.vue"; const categories = ref(emptyCategories()); const loading = ref(false); +const totalCount = ref(0); const modalOpen = ref(false); const editing = ref<{ categoryId: string; item: ReplyItem | null }>({ categoryId: "", item: null, }); const form = reactive({ + replyCode: "", trigger: "", text: "", enabled: true, @@ -52,6 +55,7 @@ function groupByCategory(list: any[]) { if (row.categoryTitle) cat.title = row.categoryTitle; cat.items.push({ id: String(row.id), + replyCode: row.replyCode || "", trigger: row.triggerScene || "", text: row.replyText || "", enabled: row.enabled === 1 || row.enabled === true, @@ -64,7 +68,14 @@ async function loadList() { loading.value = true; try { const res: any = await getReplyListAll(); - const list = Array.isArray(res) ? res : res?.result || res?.records || []; + const list = Array.isArray(res) + ? res + : Array.isArray(res?.result) + ? res.result + : Array.isArray(res?.records) + ? res.records + : []; + totalCount.value = list.length; categories.value = groupByCategory(list as any[]); } catch (e: any) { message.error(e?.message || "加载回复语失败"); @@ -93,13 +104,14 @@ async function toggle(item: ReplyItem) { function openCreate(categoryId?: string) { editing.value = { categoryId: categoryId || categories.value[0]?.id || "inquiring", item: null }; - Object.assign(form, { trigger: "", text: "", enabled: true }); + Object.assign(form, { replyCode: "", trigger: "", text: "", enabled: true }); modalOpen.value = true; } function openEdit(categoryId: string, item: ReplyItem) { editing.value = { categoryId, item }; Object.assign(form, { + replyCode: item.replyCode || "", trigger: item.trigger, text: item.text, enabled: item.enabled, @@ -114,7 +126,7 @@ async function save() { } const cat = categories.value.find((c) => c.id === editing.value.categoryId); if (!cat) return; - const body = { + const body: Record = { id: editing.value.item?.id, categoryCode: cat.id, categoryTitle: cat.title, @@ -122,6 +134,11 @@ async function save() { replyText: form.text.trim(), enabled: form.enabled ? 1 : 0, }; + if (editing.value.item?.replyCode) { + body.replyCode = editing.value.item.replyCode; + } else if (form.replyCode.trim()) { + body.replyCode = form.replyCode.trim(); + } try { await saveReply(body, Boolean(editing.value.item)); message.success(editing.value.item ? "话术已更新" : "话术已添加"); @@ -141,6 +158,7 @@ async function save() {
加载中…
+

共 {{ totalCount }} 条(按现网整理表应为 51 条;事件异常本期无条目)

@@ -154,6 +172,7 @@ async function save() {
触发:{{ item.trigger }}
+
调用编码:{{ item.replyCode }}
{{ item.text }}
@@ -172,7 +191,7 @@ async function save() { @@ -182,13 +201,28 @@ async function save() {
+
+ + +
-
+
-