diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryStaffRoleController.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryStaffRoleController.java
index afcc884..31207ac 100644
--- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryStaffRoleController.java
+++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryStaffRoleController.java
@@ -11,21 +11,28 @@ import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.jeecg.common.api.vo.Result;
import org.jeecg.common.aspect.annotation.AutoLog;
import org.jeecg.common.system.base.controller.JeecgController;
+import org.apache.shiro.SecurityUtils;
+import org.jeecg.common.system.vo.LoginUser;
+import org.jeecg.modules.inquiry.dto.StaffWecomSyncResult;
import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
import org.jeecg.modules.inquiry.service.IInquiryStaffRoleService;
+import org.jeecg.modules.inquiry.wecom.WeComAddressBookClient;
+import org.jeecg.modules.inquiry.wecom.WeComDepartment;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import java.util.Date;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
/**
* 员工岗位管理 API。
*
* 路径前缀 /inquiry/staffRole;权限码 inquiry:staff:*。
- * 不做企微换号,wecom_id 由运营手工绑定。
+ * wecom_id 可手工维护,也可经 /syncWecom 从企微通讯录 upsert(姓名/手机/部门)。
+ * 浏览器禁止直连企微;同步只服务展示,不挡询价主路径。
*/
@Slf4j
@Api(tags = "询价-员工岗位")
@@ -36,6 +43,9 @@ public class InquiryStaffRoleController extends JeecgController ROLE_LABEL = new HashMap<>();
static {
@@ -122,6 +132,42 @@ public class InquiryStaffRoleController extends JeecgController syncWecom() {
+ String operator = currentUsername();
+ StaffWecomSyncResult result = staffRoleService.syncFromWeCom(operator);
+ return Result.OK(result.getMessage(), result);
+ }
+
+ /**
+ * 企微部门下拉选项。浏览器禁止直连企微,只打本接口。
+ */
+ @ApiOperation("企微部门列表")
+ @RequiresPermissions("inquiry:staff:list")
+ @GetMapping("/wecomDepartments")
+ public Result> wecomDepartments() {
+ return Result.OK(weComAddressBookClient.listDepartments());
+ }
+
+ private String currentUsername() {
+ try {
+ Object principal = SecurityUtils.getSubject().getPrincipal();
+ if (principal instanceof LoginUser) {
+ return ((LoginUser) principal).getUsername();
+ }
+ } catch (Exception ignored) {
+ // 无登录主体时用系统标记
+ }
+ return "system";
+ }
+
private void normalize(InquiryStaffRole body) {
if (StringUtils.isBlank(body.getName()) || StringUtils.isBlank(body.getWecomId())) {
throw new IllegalArgumentException("姓名与企微 userid 不能为空");
diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/dto/StaffWecomSyncResult.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/dto/StaffWecomSyncResult.java
new file mode 100644
index 0000000..ad9f278
--- /dev/null
+++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/dto/StaffWecomSyncResult.java
@@ -0,0 +1,28 @@
+package org.jeecg.modules.inquiry.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+/**
+ * 企微通讯录同步结果(给后台 toast / 日志)。
+ */
+@Data
+@ApiModel("员工企微同步结果")
+public class StaffWecomSyncResult {
+
+ @ApiModelProperty("企微侧已激活成员数")
+ private int fetched;
+
+ @ApiModelProperty("新建条数")
+ private int created;
+
+ @ApiModelProperty("更新条数")
+ private int updated;
+
+ @ApiModelProperty("跳过条数(无 userid 等)")
+ private int skipped;
+
+ @ApiModelProperty("摘要文案")
+ private String message;
+}
diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryStaffRoleService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryStaffRoleService.java
index 52d3542..6794c80 100644
--- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryStaffRoleService.java
+++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryStaffRoleService.java
@@ -1,6 +1,7 @@
package org.jeecg.modules.inquiry.service;
import com.baomidou.mybatisplus.extension.service.IService;
+import org.jeecg.modules.inquiry.dto.StaffWecomSyncResult;
import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
import java.util.Map;
@@ -19,4 +20,14 @@ public interface IInquiryStaffRoleService extends IService {
* 停用员工(status=disabled),不物理删除。
*/
void disable(String id);
+
+ /**
+ * 从企微通讯录全量 upsert:按 wecom_id 更新姓名/手机/部门,缺失则新建(默认销售岗)。
+ *
+ * 不删、不停用企微侧消失的人;不覆盖已有 roleCode / routes / 提醒开关。
+ *
+ * @param operator 操作人(写入 createBy/updateBy)
+ * @return 同步计数
+ */
+ StaffWecomSyncResult syncFromWeCom(String operator);
}
diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryStaffRoleServiceImpl.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryStaffRoleServiceImpl.java
index 3186faa..ad49692 100644
--- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryStaffRoleServiceImpl.java
+++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryStaffRoleServiceImpl.java
@@ -2,9 +2,15 @@ package org.jeecg.modules.inquiry.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.jeecg.modules.inquiry.dto.StaffWecomSyncResult;
import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
import org.jeecg.modules.inquiry.mapper.InquiryStaffRoleMapper;
import org.jeecg.modules.inquiry.service.IInquiryStaffRoleService;
+import org.jeecg.modules.inquiry.wecom.WeComAddressBookClient;
+import org.jeecg.modules.inquiry.wecom.WeComMember;
+import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -14,15 +20,25 @@ import java.util.List;
import java.util.Map;
/**
- * 员工岗位 CRUD 实现。
+ * 员工岗位 CRUD + 企微通讯录 upsert。
*
- * 线程:跟随 HTTP 请求线程;无全局锁。wecom_id 唯一约束由库保证。
+ * 线程:跟随 HTTP 请求线程;同步方法串行化,避免并发双写同一 wecom_id。
+ * 无全局工单大锁;wecom_id 唯一约束由库保证。
*/
+@Slf4j
@Service
public class InquiryStaffRoleServiceImpl extends ServiceImpl
implements IInquiryStaffRoleService {
private static final String[] ROLE_CODES = {"sales", "air", "sea", "land"};
+ private static final String DEFAULT_ROLE = "sales";
+ private static final String DEFAULT_ROLE_LABEL = "销售";
+
+ @Autowired
+ private WeComAddressBookClient weComAddressBookClient;
+
+ /** 同步互斥:同一进程内禁止两路并发全量 upsert。 */
+ private final Object syncLock = new Object();
@Override
public Map countByRole() {
@@ -54,4 +70,66 @@ public class InquiryStaffRoleServiceImpl extends ServiceImpl().eq("wecom_id", wecomId).last("LIMIT 1"), false);
}
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public StaffWecomSyncResult syncFromWeCom(String operator) {
+ synchronized (syncLock) {
+ List members = weComAddressBookClient.listActiveMembers();
+ int created = 0;
+ int updated = 0;
+ int skipped = 0;
+ Date now = new Date();
+ String op = StringUtils.defaultIfBlank(operator, "wecom-sync");
+
+ for (WeComMember m : members) {
+ if (m == null || StringUtils.isBlank(m.getUserid())) {
+ skipped++;
+ continue;
+ }
+ InquiryStaffRole existing = findByWecomId(m.getUserid());
+ if (existing == null) {
+ InquiryStaffRole row = new InquiryStaffRole();
+ row.setWecomId(m.getUserid());
+ row.setName(StringUtils.defaultIfBlank(m.getName(), m.getUserid()));
+ row.setMobile(StringUtils.trimToNull(m.getMobile()));
+ row.setDepartName(StringUtils.trimToNull(m.getDepartmentName()));
+ row.setRoleCode(DEFAULT_ROLE);
+ row.setRoleLabel(DEFAULT_ROLE_LABEL);
+ row.setNotifyEventException(0);
+ row.setStatus("active");
+ row.setCreateBy(op);
+ row.setCreateTime(now);
+ row.setUpdateBy(op);
+ row.setUpdateTime(now);
+ save(row);
+ created++;
+ } else {
+ // 只刷新展示字段;岗位/线路/提醒开关保留运营手工配置
+ existing.setName(StringUtils.defaultIfBlank(m.getName(), existing.getName()));
+ if (StringUtils.isNotBlank(m.getMobile())) {
+ existing.setMobile(m.getMobile());
+ }
+ if (StringUtils.isNotBlank(m.getDepartmentName())) {
+ existing.setDepartName(m.getDepartmentName());
+ }
+ existing.setUpdateBy(op);
+ existing.setUpdateTime(now);
+ updateById(existing);
+ updated++;
+ }
+ }
+
+ StaffWecomSyncResult result = new StaffWecomSyncResult();
+ result.setFetched(members.size());
+ result.setCreated(created);
+ result.setUpdated(updated);
+ result.setSkipped(skipped);
+ result.setMessage(String.format(
+ "同步完成:企微成员 %d,新建 %d,更新 %d,跳过 %d",
+ members.size(), created, updated, skipped));
+ log.info("企微通讯录同步 operator={} {}", op, result.getMessage());
+ return result;
+ }
+ }
}
diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryWorkOrderServiceImpl.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryWorkOrderServiceImpl.java
index 2b7ed21..ede3b7d 100644
--- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryWorkOrderServiceImpl.java
+++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryWorkOrderServiceImpl.java
@@ -12,14 +12,17 @@ import org.jeecg.modules.inquiry.dto.TicketTransitionRequest;
import org.jeecg.modules.inquiry.entity.InquiryAttachment;
import org.jeecg.modules.inquiry.entity.InquiryExceptionCase;
import org.jeecg.modules.inquiry.entity.InquiryQuote;
+import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
import org.jeecg.modules.inquiry.entity.InquiryTicket;
import org.jeecg.modules.inquiry.entity.InquiryTicketStatusLog;
import org.jeecg.modules.inquiry.mapper.InquiryAttachmentMapper;
import org.jeecg.modules.inquiry.mapper.InquiryExceptionCaseMapper;
import org.jeecg.modules.inquiry.mapper.InquiryQuoteMapper;
+import org.jeecg.modules.inquiry.mapper.InquiryStaffRoleMapper;
import org.jeecg.modules.inquiry.mapper.InquiryTicketMapper;
import org.jeecg.modules.inquiry.mapper.InquiryTicketStatusLogMapper;
import org.jeecg.modules.inquiry.service.IInquiryWorkOrderService;
+import org.jeecg.modules.inquiry.support.InquiryTicketDisplaySupport;
import org.jeecg.modules.inquiry.tms.InquiryTmsQueryService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@@ -29,16 +32,21 @@ import com.alibaba.fastjson.JSONObject;
import java.math.BigDecimal;
import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
+import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.Set;
/**
* 询价工单主账实现。
*
* 副作用:改 inq_ticket.status、写 inq_ticket_status_log、改 inq_exception_case。
+ * 列表/详情会就地补展示字段(salesName、demandTitle/Detail),只影响响应,不写回库。
* 线程:单请求事务;只锁当前工单行(DB 行级),不串行其它工单。
* 为何:Graph/前端成功不能冒充主账六态成功,所有 transition 必须过本服务。
*/
@@ -56,6 +64,8 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl pageView(Page page, Map filters) {
@@ -64,6 +74,7 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl w.like(InquiryTicket::getWorkOrderNo, keyword)
.or().like(InquiryTicket::getSalesName, keyword)
+ .or().like(InquiryTicket::getSalesWecomId, keyword)
.or().like(InquiryTicket::getDemandTitle, keyword)
.or().like(InquiryTicket::getOrigin, keyword)
.or().like(InquiryTicket::getDestination, keyword));
@@ -100,7 +111,9 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl result = page(page, qw);
+ enrichDisplayFields(result.getRecords());
+ return result;
}
@Override
@@ -109,6 +122,7 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl quotes = quoteMapper.selectList(new LambdaQueryWrapper()
.eq(InquiryQuote::getWorkOrderNo, workOrderNo)
.orderByAsc(InquiryQuote::getCreateTime));
@@ -131,6 +145,50 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl tickets) {
+ if (tickets == null || tickets.isEmpty()) {
+ return;
+ }
+ Map staffNameByWecom = loadStaffNames(tickets);
+ for (InquiryTicket ticket : tickets) {
+ if (ticket == null) {
+ continue;
+ }
+ String staffName = staffNameByWecom.get(StringUtils.trimToEmpty(ticket.getSalesWecomId()));
+ ticket.setSalesName(InquiryTicketDisplaySupport.resolveSalesDisplayName(
+ ticket.getSalesName(), ticket.getSalesWecomId(), staffName));
+ InquiryTicketDisplaySupport.applyRequirementSummary(ticket);
+ }
+ }
+
+ /** 批量按企微 userid 取员工姓名,避免列表 N+1。 */
+ private Map loadStaffNames(List tickets) {
+ Set wecomIds = new HashSet<>();
+ for (InquiryTicket ticket : tickets) {
+ if (ticket != null && StringUtils.isNotBlank(ticket.getSalesWecomId())) {
+ wecomIds.add(ticket.getSalesWecomId().trim());
+ }
+ }
+ if (wecomIds.isEmpty()) {
+ return Collections.emptyMap();
+ }
+ List staffRows = staffRoleMapper.selectList(new LambdaQueryWrapper()
+ .in(InquiryStaffRole::getWecomId, new ArrayList<>(wecomIds))
+ .eq(InquiryStaffRole::getStatus, "active"));
+ Map result = new HashMap<>();
+ for (InquiryStaffRole row : staffRows) {
+ if (row == null || StringUtils.isBlank(row.getWecomId()) || StringUtils.isBlank(row.getName())) {
+ continue;
+ }
+ result.put(row.getWecomId().trim(), row.getName().trim());
+ }
+ return result;
+ }
+
@Override
@Transactional(rollbackFor = Exception.class)
public InquiryTicket transition(TicketTransitionRequest req, String operator) {
diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryTicketDisplaySupport.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryTicketDisplaySupport.java
new file mode 100644
index 0000000..b90f448
--- /dev/null
+++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryTicketDisplaySupport.java
@@ -0,0 +1,222 @@
+package org.jeecg.modules.inquiry.support;
+
+import org.apache.commons.lang3.StringUtils;
+import org.jeecg.modules.inquiry.entity.InquiryTicket;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * 工单列表/详情的展示口径,对齐现网 InquiryAggregateService.requirementSummaryLines。
+ *
+ * 职责:把结构化字段拼成「发起人可读名 + 询价需求多行摘要」,禁止把 FP:AIR|… 原文甩到后台。
+ * 副作用:无写库;仅生成展示用字符串。
+ * 为何:inq_ticket.sales_name / demand_* 可能为空或被写入管道原文,后台必须以员工表与字段快照为准。
+ */
+public final class InquiryTicketDisplaySupport {
+
+ private InquiryTicketDisplaySupport() {
+ }
+
+ /**
+ * 询价需求多行摘要:首行标题(业务线 · 路线),后续为货量/品名。
+ * 与旧后台 requirementSummaryLines 同口径,便于对照验收。
+ */
+ public static List requirementSummaryLines(InquiryTicket ticket) {
+ List lines = new ArrayList<>();
+ if (ticket == null) {
+ return lines;
+ }
+ String businessLine = upper(ticket.getBusinessLine());
+
+ List heading = new ArrayList<>();
+ String lineLabel = firstText(ticket.getBizTypeLabel(), businessLineLabel(ticket.getBusinessLine()));
+ if (StringUtils.isNotBlank(lineLabel)) {
+ heading.add(lineLabel);
+ }
+ String route = route(ticket.getOrigin(), ticket.getDestination());
+ if (StringUtils.isNotBlank(route)) {
+ heading.add(route);
+ }
+ addLine(lines, join(heading, " · "));
+
+ List quantities = new ArrayList<>();
+ if ("AIR".equals(businessLine) || containsChinese(lineLabel, "空运")) {
+ addLabel(quantities, "件数", ticket.getPieces());
+ addUnit(quantities, "毛重", ticket.getWeightKg(), "kg");
+ addUnit(quantities, "体积", ticket.getVolumeCbm(), "CBM");
+ } else if ("SEA".equals(businessLine) || containsChinese(lineLabel, "海运")) {
+ addLabel(quantities, "件数", ticket.getPieces());
+ addUnit(quantities, "重量", ticket.getWeightKg(), "kg");
+ addUnit(quantities, "体积", ticket.getVolumeCbm(), "CBM");
+ } else if ("LAND".equals(businessLine) || containsChinese(lineLabel, "陆运")) {
+ addUnit(quantities, "体积", ticket.getVolumeCbm(), "CBM");
+ addUnit(quantities, "重量", ticket.getWeightKg(), "kg");
+ addLabel(quantities, "件数", ticket.getPieces());
+ } else {
+ addLabel(quantities, "件数", ticket.getPieces());
+ addUnit(quantities, "重量", ticket.getWeightKg(), "kg");
+ addUnit(quantities, "体积", ticket.getVolumeCbm(), "CBM");
+ }
+ addLine(lines, join(quantities, " · "));
+
+ List cargo = new ArrayList<>();
+ boolean land = "LAND".equals(businessLine) || containsChinese(lineLabel, "陆运");
+ addLabel(cargo, land ? "货物品名" : "品名", ticket.getGoodsName());
+ if ("AIR".equals(businessLine) || containsChinese(lineLabel, "空运")) {
+ addLabel(cargo, "客户", ticket.getCustomerName());
+ }
+ addLine(lines, join(cargo, " · "));
+ return lines;
+ }
+
+ /**
+ * 把摘要写回 ticket 的 demandTitle/demandDetail,仅用于 API 响应,不落库。
+ */
+ public static void applyRequirementSummary(InquiryTicket ticket) {
+ if (ticket == null) {
+ return;
+ }
+ List lines = requirementSummaryLines(ticket);
+ if (lines.isEmpty()) {
+ // 结构化字段全空时,仍禁止把 FP: 管道原文当需求展示
+ if (looksLikeRawFp(ticket.getDemandTitle())) {
+ ticket.setDemandTitle(null);
+ }
+ if (looksLikeRawFp(ticket.getDemandDetail())) {
+ ticket.setDemandDetail(null);
+ }
+ return;
+ }
+ ticket.setDemandTitle(lines.get(0));
+ if (lines.size() == 1) {
+ ticket.setDemandDetail("");
+ } else {
+ ticket.setDemandDetail(join(lines.subList(1, lines.size()), "\n"));
+ }
+ }
+
+ /**
+ * 发起人展示名:优先已有中文名,其次员工表姓名;企微 userid 不能冒充人名。
+ */
+ public static String resolveSalesDisplayName(String salesName, String salesWecomId, String staffName) {
+ if (StringUtils.isNotBlank(salesName) && !looksLikeWecomUserId(salesName)) {
+ return salesName.trim();
+ }
+ if (StringUtils.isNotBlank(staffName)) {
+ return staffName.trim();
+ }
+ if (StringUtils.isNotBlank(salesName)) {
+ return salesName.trim();
+ }
+ return StringUtils.trimToEmpty(salesWecomId);
+ }
+
+ public static boolean looksLikeRawFp(String value) {
+ String raw = StringUtils.trimToEmpty(value);
+ return raw.startsWith("FP:") || raw.contains("|") && raw.contains(":");
+ }
+
+ /** 纯拼音/企微 userid(无中文)不当作「发起人名称」。 */
+ public static boolean looksLikeWecomUserId(String value) {
+ String raw = StringUtils.trimToEmpty(value);
+ if (raw.isEmpty()) {
+ return true;
+ }
+ if (raw.chars().anyMatch(ch -> Character.UnicodeScript.of(ch) == Character.UnicodeScript.HAN)) {
+ return false;
+ }
+ return raw.matches("^[A-Za-z][A-Za-z0-9._-]{1,64}$");
+ }
+
+ private static String businessLineLabel(String value) {
+ if ("AIR".equalsIgnoreCase(value)) {
+ return "空运";
+ }
+ if ("SEA".equalsIgnoreCase(value)) {
+ return "海运";
+ }
+ if ("LAND".equalsIgnoreCase(value)) {
+ return "陆运";
+ }
+ if ("MULTI".equalsIgnoreCase(value)) {
+ return "多段联运";
+ }
+ return value;
+ }
+
+ private static String route(String origin, String destination) {
+ if (StringUtils.isBlank(origin) && StringUtils.isBlank(destination)) {
+ return null;
+ }
+ return (StringUtils.isNotBlank(origin) ? origin.trim() : "-")
+ + " → "
+ + (StringUtils.isNotBlank(destination) ? destination.trim() : "-");
+ }
+
+ private static void addLine(List lines, String value) {
+ if (StringUtils.isNotBlank(value)) {
+ lines.add(value);
+ }
+ }
+
+ private static void addLabel(List values, String label, Object value) {
+ if (value == null || !StringUtils.isNotBlank(String.valueOf(value))) {
+ return;
+ }
+ values.add(label + " " + plain(value));
+ }
+
+ private static void addUnit(List values, String label, Object value, String unit) {
+ if (value == null || !StringUtils.isNotBlank(String.valueOf(value))) {
+ return;
+ }
+ values.add(label + " " + plain(value) + unit);
+ }
+
+ private static String plain(Object value) {
+ if (value instanceof BigDecimal) {
+ return ((BigDecimal) value).stripTrailingZeros().toPlainString();
+ }
+ return String.valueOf(value).trim();
+ }
+
+ private static String join(List values, String sep) {
+ if (values == null || values.isEmpty()) {
+ return null;
+ }
+ StringBuilder sb = new StringBuilder();
+ for (String v : values) {
+ if (!StringUtils.isNotBlank(v)) {
+ continue;
+ }
+ if (sb.length() > 0) {
+ sb.append(sep);
+ }
+ sb.append(v.trim());
+ }
+ return sb.length() == 0 ? null : sb.toString();
+ }
+
+ private static String firstText(String... values) {
+ if (values == null) {
+ return null;
+ }
+ for (String v : values) {
+ if (StringUtils.isNotBlank(v)) {
+ return v.trim();
+ }
+ }
+ return null;
+ }
+
+ private static String upper(String value) {
+ return value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
+ }
+
+ private static boolean containsChinese(String value, String token) {
+ return StringUtils.isNotBlank(value) && value.contains(token);
+ }
+}
diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/wecom/WeComAddressBookClient.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/wecom/WeComAddressBookClient.java
new file mode 100644
index 0000000..7e7ce9b
--- /dev/null
+++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/wecom/WeComAddressBookClient.java
@@ -0,0 +1,287 @@
+package org.jeecg.modules.inquiry.wecom;
+
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.client.SimpleClientHttpRequestFactory;
+import org.springframework.http.converter.StringHttpMessageConverter;
+import org.springframework.stereotype.Component;
+import org.springframework.web.client.RestTemplate;
+import org.springframework.web.util.UriComponentsBuilder;
+
+import javax.annotation.PostConstruct;
+import java.net.InetSocketAddress;
+import java.net.Proxy;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 企微通讯录只读客户端(主账侧)。
+ *
+ * 职责:gettoken + department/list + user/list,给员工页同步姓名/手机/部门。
+ * 密钥:环境变量 WECOM_CORP_ID / WECOM_SECRET(与询价应用 1000010 同一套,见 prompt/03)。
+ * 本机开发可设 WECOM_SOCKS_HOST/PORT,经测试机 SOCKS 出口,避免本机公网 IP 不在企微白名单(errcode=60020)。
+ * 禁止:浏览器直连;禁止在同步失败时挡询价主路径;禁止把 Secret 写入日志。
+ * 线程:HTTP 请求线程调用;token 缓存带锁;connect/read 超时,禁止无限阻塞。
+ */
+@Slf4j
+@Component
+public class WeComAddressBookClient {
+
+ private static final String DEFAULT_API_BASE = "https://qyapi.weixin.qq.com";
+ /** 根部门 id;fetch_child=1 拉全树成员。 */
+ private static final int ROOT_DEPARTMENT_ID = 1;
+
+ @Value("${WECOM_CORP_ID:}")
+ private String corpId;
+
+ @Value("${WECOM_SECRET:}")
+ private String secret;
+
+ @Value("${WECOM_API_BASE:" + DEFAULT_API_BASE + "}")
+ private String apiBase;
+
+ /** 可选:本机开发经 SOCKS 出站(如 127.0.0.1,配合 ssh -D)。 */
+ @Value("${WECOM_SOCKS_HOST:}")
+ private String socksHost;
+
+ @Value("${WECOM_SOCKS_PORT:1080}")
+ private int socksPort;
+
+ private RestTemplate restTemplate;
+ private final Object tokenLock = new Object();
+ private String cachedToken;
+ private long tokenExpireAtMs;
+
+ @PostConstruct
+ public void initHttpClient() {
+ SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
+ factory.setConnectTimeout(5_000);
+ factory.setReadTimeout(15_000);
+ if (StringUtils.isNotBlank(socksHost)) {
+ factory.setProxy(new Proxy(Proxy.Type.SOCKS, new InetSocketAddress(socksHost.trim(), socksPort)));
+ log.info("企微 HTTP 经 SOCKS {}:{} 出站(绕过本机 IP 白名单限制)", socksHost, socksPort);
+ }
+ RestTemplate rt = new RestTemplate(factory);
+ rt.getMessageConverters().set(1, new StringHttpMessageConverter(StandardCharsets.UTF_8));
+ this.restTemplate = rt;
+ }
+
+ /**
+ * 配置是否齐全(不访问外网)。
+ */
+ public boolean isConfigured() {
+ return StringUtils.isNotBlank(corpId) && StringUtils.isNotBlank(secret);
+ }
+
+ /**
+ * 拉取应用可见范围内的已激活成员(去重 userid)。
+ *
+ * 副作用:出站 HTTP 调企微;失败抛 IllegalStateException,由上层转 Result。
+ */
+ public List listActiveMembers() {
+ if (!isConfigured()) {
+ throw new IllegalStateException("未配置 WECOM_CORP_ID / WECOM_SECRET,无法同步通讯录");
+ }
+ String token = getAccessToken();
+ Map deptNames = loadDepartmentNames(token);
+ String url = UriComponentsBuilder
+ .fromHttpUrl(trimBase(apiBase) + "/cgi-bin/user/list")
+ .queryParam("access_token", token)
+ .queryParam("department_id", ROOT_DEPARTMENT_ID)
+ .queryParam("fetch_child", 1)
+ .toUriString();
+ JSONObject body = getJson(url);
+ assertOk(body, "user/list");
+ JSONArray userlist = body.getJSONArray("userlist");
+ Map byUserid = new LinkedHashMap<>();
+ if (userlist == null) {
+ return new ArrayList<>();
+ }
+ for (int i = 0; i < userlist.size(); i++) {
+ JSONObject u = userlist.getJSONObject(i);
+ if (u == null) {
+ continue;
+ }
+ String userid = StringUtils.trimToEmpty(u.getString("userid"));
+ if (userid.isEmpty()) {
+ continue;
+ }
+ int status = u.getIntValue("status");
+ // 1=已激活;其它状态不同步进业务表,避免半离职账号入主账
+ if (status != 1) {
+ continue;
+ }
+ String name = StringUtils.defaultIfBlank(u.getString("name"), userid);
+ String mobile = StringUtils.trimToEmpty(u.getString("mobile"));
+ String deptName = resolveDepartmentName(u.getJSONArray("department"), deptNames);
+ byUserid.put(userid, new WeComMember(userid, name, mobile, deptName, status));
+ }
+ return new ArrayList<>(byUserid.values());
+ }
+
+ /**
+ * 拉取企微部门树(扁平列表,带 path 便于下拉展示)。
+ *
+ * 副作用:出站 HTTP;失败抛 IllegalStateException。
+ */
+ public List listDepartments() {
+ if (!isConfigured()) {
+ throw new IllegalStateException("未配置 WECOM_CORP_ID / WECOM_SECRET,无法拉取部门");
+ }
+ String token = getAccessToken();
+ String url = UriComponentsBuilder
+ .fromHttpUrl(trimBase(apiBase) + "/cgi-bin/department/list")
+ .queryParam("access_token", token)
+ .toUriString();
+ JSONObject body = getJson(url);
+ assertOk(body, "department/list");
+ JSONArray list = body.getJSONArray("department");
+ if (list == null || list.isEmpty()) {
+ return new ArrayList<>();
+ }
+
+ Map byId = new LinkedHashMap<>();
+ for (int i = 0; i < list.size(); i++) {
+ JSONObject d = list.getJSONObject(i);
+ if (d != null) {
+ byId.put(d.getIntValue("id"), d);
+ }
+ }
+
+ List result = new ArrayList<>();
+ for (JSONObject d : byId.values()) {
+ int id = d.getIntValue("id");
+ String name = StringUtils.defaultIfBlank(d.getString("name"), String.valueOf(id));
+ int parentId = d.getIntValue("parentid");
+ String path = buildDepartmentPath(id, byId);
+ result.add(new WeComDepartment(id, name, parentId, path));
+ }
+ // 按路径排序,下拉更易扫
+ result.sort((a, b) -> StringUtils.defaultString(a.getPath()).compareTo(StringUtils.defaultString(b.getPath())));
+ return result;
+ }
+
+ private String buildDepartmentPath(int id, Map byId) {
+ List parts = new ArrayList<>();
+ int cursor = id;
+ // 防环:最多 32 层
+ for (int depth = 0; depth < 32; depth++) {
+ JSONObject d = byId.get(cursor);
+ if (d == null) {
+ break;
+ }
+ parts.add(0, StringUtils.defaultIfBlank(d.getString("name"), String.valueOf(cursor)));
+ int parentId = d.getIntValue("parentid");
+ if (parentId <= 0 || parentId == cursor || !byId.containsKey(parentId)) {
+ break;
+ }
+ cursor = parentId;
+ }
+ return String.join(" / ", parts);
+ }
+
+ private Map loadDepartmentNames(String token) {
+ String url = UriComponentsBuilder
+ .fromHttpUrl(trimBase(apiBase) + "/cgi-bin/department/list")
+ .queryParam("access_token", token)
+ .toUriString();
+ JSONObject body = getJson(url);
+ assertOk(body, "department/list");
+ Map map = new HashMap<>();
+ JSONArray list = body.getJSONArray("department");
+ if (list == null) {
+ return map;
+ }
+ for (int i = 0; i < list.size(); i++) {
+ JSONObject d = list.getJSONObject(i);
+ if (d == null) {
+ continue;
+ }
+ map.put(d.getIntValue("id"), StringUtils.defaultString(d.getString("name")));
+ }
+ return map;
+ }
+
+ private String resolveDepartmentName(JSONArray departmentIds, Map deptNames) {
+ if (departmentIds == null || departmentIds.isEmpty()) {
+ return "";
+ }
+ List names = new ArrayList<>();
+ for (int i = 0; i < departmentIds.size(); i++) {
+ int id = departmentIds.getIntValue(i);
+ String n = deptNames.get(id);
+ if (StringUtils.isNotBlank(n)) {
+ names.add(n);
+ }
+ }
+ return String.join("/", names);
+ }
+
+ private String getAccessToken() {
+ synchronized (tokenLock) {
+ long now = System.currentTimeMillis();
+ if (StringUtils.isNotBlank(cachedToken) && now < tokenExpireAtMs) {
+ return cachedToken;
+ }
+ String url = UriComponentsBuilder
+ .fromHttpUrl(trimBase(apiBase) + "/cgi-bin/gettoken")
+ .queryParam("corpid", corpId)
+ .queryParam("corpsecret", secret)
+ .toUriString();
+ JSONObject body = getJson(url);
+ assertOk(body, "gettoken");
+ String token = body.getString("access_token");
+ if (StringUtils.isBlank(token)) {
+ throw new IllegalStateException("企微 gettoken 未返回 access_token");
+ }
+ int expiresIn = body.getIntValue("expires_in");
+ if (expiresIn <= 0) {
+ expiresIn = 7200;
+ }
+ // 提前 5 分钟失效,避免边界过期
+ cachedToken = token;
+ tokenExpireAtMs = now + Math.max(60, expiresIn - 300) * 1000L;
+ log.info("企微 access_token 已刷新,expiresIn={}s", expiresIn);
+ return cachedToken;
+ }
+ }
+
+ private JSONObject getJson(String url) {
+ try {
+ String raw = restTemplate.getForObject(url, String.class);
+ if (StringUtils.isBlank(raw)) {
+ throw new IllegalStateException("企微接口返回空响应");
+ }
+ return JSONObject.parseObject(raw);
+ } catch (IllegalStateException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new IllegalStateException("调用企微失败: " + e.getMessage(), e);
+ }
+ }
+
+ private void assertOk(JSONObject body, String api) {
+ if (body == null) {
+ throw new IllegalStateException("企微 " + api + " 响应为空");
+ }
+ int errcode = body.getIntValue("errcode");
+ if (errcode != 0) {
+ String errmsg = body.getString("errmsg");
+ throw new IllegalStateException("企微 " + api + " 失败 errcode=" + errcode + " errmsg=" + errmsg);
+ }
+ }
+
+ private static String trimBase(String base) {
+ if (StringUtils.isBlank(base)) {
+ return DEFAULT_API_BASE;
+ }
+ return base.endsWith("/") ? base.substring(0, base.length() - 1) : base;
+ }
+}
diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/wecom/WeComDepartment.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/wecom/WeComDepartment.java
new file mode 100644
index 0000000..bb6cabb
--- /dev/null
+++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/wecom/WeComDepartment.java
@@ -0,0 +1,36 @@
+package org.jeecg.modules.inquiry.wecom;
+
+/**
+ * 企微部门投影,供员工表单下拉。
+ */
+public class WeComDepartment {
+
+ private final int id;
+ private final String name;
+ private final int parentId;
+ /** 自根到本节点的展示路径,如「友通达 / 销售部」。 */
+ private final String path;
+
+ public WeComDepartment(int id, String name, int parentId, String path) {
+ this.id = id;
+ this.name = name;
+ this.parentId = parentId;
+ this.path = path;
+ }
+
+ public int getId() {
+ return id;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public int getParentId() {
+ return parentId;
+ }
+
+ public String getPath() {
+ return path;
+ }
+}
diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/wecom/WeComMember.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/wecom/WeComMember.java
new file mode 100644
index 0000000..51f84ea
--- /dev/null
+++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/wecom/WeComMember.java
@@ -0,0 +1,48 @@
+package org.jeecg.modules.inquiry.wecom;
+
+/**
+ * 企微通讯录成员投影(仅姓名展示所需字段)。
+ *
+ * 来源:qyapi user/list;不做岗位推断,岗位仍由运营在后台维护。
+ */
+public class WeComMember {
+
+ private final String userid;
+ private final String name;
+ private final String mobile;
+ private final String departmentName;
+ private final int status;
+
+ public WeComMember(String userid, String name, String mobile, String departmentName, int status) {
+ this.userid = userid;
+ this.name = name;
+ this.mobile = mobile;
+ this.departmentName = departmentName;
+ this.status = status;
+ }
+
+ public String getUserid() {
+ return userid;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public String getMobile() {
+ return mobile;
+ }
+
+ public String getDepartmentName() {
+ return departmentName;
+ }
+
+ public int getStatus() {
+ return status;
+ }
+
+ /** 企微 status=1 表示已激活,可入员工表。 */
+ public boolean isActive() {
+ return status == 1;
+ }
+}
diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryTicketDisplaySupportTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryTicketDisplaySupportTest.java
new file mode 100644
index 0000000..29a34d6
--- /dev/null
+++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryTicketDisplaySupportTest.java
@@ -0,0 +1,64 @@
+package org.jeecg.modules.inquiry.support;
+
+import org.jeecg.modules.inquiry.entity.InquiryTicket;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 工单列表展示口径:发起人名称 + 询价需求摘要,对齐旧后台。
+ */
+public class InquiryTicketDisplaySupportTest {
+
+ @Test
+ public void airRequirementSummaryMatchesLegacyShape() {
+ InquiryTicket ticket = new InquiryTicket();
+ ticket.setBusinessLine("AIR");
+ ticket.setBizTypeLabel("空运");
+ ticket.setOrigin("ZUH");
+ ticket.setDestination("CRK");
+ ticket.setPieces(10);
+ ticket.setWeightKg(new BigDecimal("1"));
+ ticket.setVolumeCbm(new BigDecimal("8"));
+ ticket.setGoodsName("茶叶");
+ ticket.setDemandDetail("FP:AIR|ZUH|CRK|茶叶|10件|1KGS|8CBM|2026-09-11");
+
+ List lines = InquiryTicketDisplaySupport.requirementSummaryLines(ticket);
+ assertEquals("空运 · ZUH → CRK", lines.get(0));
+ assertEquals("件数 10 · 毛重 1kg · 体积 8CBM", lines.get(1));
+ assertEquals("品名 茶叶", lines.get(2));
+
+ InquiryTicketDisplaySupport.applyRequirementSummary(ticket);
+ assertEquals("空运 · ZUH → CRK", ticket.getDemandTitle());
+ assertEquals("件数 10 · 毛重 1kg · 体积 8CBM\n品名 茶叶", ticket.getDemandDetail());
+ }
+
+ @Test
+ public void landUsesGoodsNameLabel() {
+ InquiryTicket ticket = new InquiryTicket();
+ ticket.setBusinessLine("LAND");
+ ticket.setBizTypeLabel("陆运");
+ ticket.setOrigin("上海");
+ ticket.setDestination("北宁Bac Ninh");
+ ticket.setGoodsName("设备");
+
+ List lines = InquiryTicketDisplaySupport.requirementSummaryLines(ticket);
+ assertEquals("陆运 · 上海 → 北宁Bac Ninh", lines.get(0));
+ assertEquals("货物品名 设备", lines.get(1));
+ }
+
+ @Test
+ public void salesNamePrefersStaffChineseName() {
+ assertEquals("吴吉林",
+ InquiryTicketDisplaySupport.resolveSalesDisplayName(null, "WuJiLin", "吴吉林"));
+ assertEquals("陈柏成",
+ InquiryTicketDisplaySupport.resolveSalesDisplayName("WuJiLin", "WuJiLin", "陈柏成"));
+ assertFalse(InquiryTicketDisplaySupport.looksLikeWecomUserId("陈柏成"));
+ assertTrue(InquiryTicketDisplaySupport.looksLikeWecomUserId("WuJiLin"));
+ }
+}
diff --git a/inquiry-backend/src/views/inquiry/staffRole/index.vue b/inquiry-backend/src/views/inquiry/staffRole/index.vue
index 4a84201..2263a58 100644
--- a/inquiry-backend/src/views/inquiry/staffRole/index.vue
+++ b/inquiry-backend/src/views/inquiry/staffRole/index.vue
@@ -1,7 +1,8 @@