初始化询价智能体新仓:同步本地工程并排除密钥与 .env。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-09-13 14:30:44 +08:00
co-authored by Cursor
commit 2a14def882
2621 changed files with 393165 additions and 0 deletions
@@ -0,0 +1,120 @@
package org.jeecg.modules.inquiry.controller;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
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.jeecg.modules.inquiry.entity.InquiryBotReply;
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。
* 支持按分类拉取全部条目(后台九分类页)与分页查询。
*/
@Slf4j
@Api(tags = "询价-机器人回复语")
@RestController
@RequestMapping("/inquiry/botReply")
public class InquiryBotReplyController extends JeecgController<InquiryBotReply, IInquiryBotReplyService> {
@Autowired
private IInquiryBotReplyService botReplyService;
@AutoLog(value = "回复语-分页列表")
@ApiOperation("分页列表")
@RequiresPermissions("inquiry:reply:list")
@GetMapping("/list")
public Result<IPage<InquiryBotReply>> list(InquiryBotReply query,
@RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
@RequestParam(name = "pageSize", defaultValue = "100") Integer pageSize) {
QueryWrapper<InquiryBotReply> qw = new QueryWrapper<>();
if (StringUtils.isNotBlank(query.getCategoryCode())) {
qw.eq("category_code", query.getCategoryCode());
}
if (query.getEnabled() != null) {
qw.eq("enabled", query.getEnabled());
}
qw.orderByAsc("category_code").orderByAsc("sort_no");
return Result.OK(botReplyService.page(new Page<>(pageNo, pageSize), qw));
}
@ApiOperation("全量列表(按分类排序,供分类页一次加载)")
@RequiresPermissions("inquiry:reply:list")
@GetMapping("/listAll")
public Result<List<InquiryBotReply>> listAll() {
QueryWrapper<InquiryBotReply> qw = new QueryWrapper<>();
qw.orderByAsc("category_code").orderByAsc("sort_no");
return Result.OK(botReplyService.list(qw));
}
@AutoLog(value = "回复语-新增")
@RequiresPermissions("inquiry:reply:add")
@PostMapping("/add")
public Result<String> 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);
return Result.OK("添加成功");
}
@AutoLog(value = "回复语-编辑")
@RequiresPermissions("inquiry:reply:edit")
@PutMapping("/edit")
public Result<String> edit(@RequestBody InquiryBotReply body) {
if (StringUtils.isBlank(body.getId())) {
return Result.error("id 不能为空");
}
validate(body);
body.setUpdateTime(new Date());
botReplyService.updateById(body);
return Result.OK("更新成功");
}
@AutoLog(value = "回复语-启用")
@RequiresPermissions("inquiry:reply:edit")
@PostMapping("/enable")
public Result<String> enable(@RequestParam String id) {
botReplyService.enable(id);
return Result.OK("已启用");
}
@AutoLog(value = "回复语-停用")
@RequiresPermissions("inquiry:reply:disable")
@PostMapping("/disable")
public Result<String> disable(@RequestParam String id) {
botReplyService.disable(id);
return Result.OK("已停用");
}
@RequiresPermissions("inquiry:reply:list")
@GetMapping("/queryById")
public Result<InquiryBotReply> queryById(@RequestParam String id) {
return Result.OK(botReplyService.getById(id));
}
private void validate(InquiryBotReply body) {
if (StringUtils.isBlank(body.getCategoryCode())
|| StringUtils.isBlank(body.getTriggerScene())
|| StringUtils.isBlank(body.getReplyText())) {
throw new IllegalArgumentException("分类、触发场景与回复正文不能为空");
}
}
}
@@ -0,0 +1,35 @@
package org.jeecg.modules.inquiry.controller;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.jeecg.common.api.vo.Result;
import org.jeecg.modules.inquiry.service.IInquiryDashboardService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* 分析看板 API。数字全部来自主账聚合,禁止前端写死生产指标。
*/
@Slf4j
@Api(tags = "询价-分析看板")
@RestController
@RequestMapping("/inquiry/dashboard")
public class InquiryDashboardController {
@Autowired
private IInquiryDashboardService dashboardService;
@ApiOperation("看板指标")
@RequiresPermissions("inquiry:dashboard:view")
@GetMapping("/metrics")
public Result<Map<String, Object>> metrics(@RequestParam(name = "range", defaultValue = "today") String range) {
return Result.OK(dashboardService.metrics(range));
}
}
@@ -0,0 +1,27 @@
package org.jeecg.modules.inquiry.controller;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.api.vo.Result;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* 询价模块业务异常转 Result,避免 Tomcat 500 页。
* 仅处理本模块抛出的非法参数/状态;其它异常仍走 Jeecg 全局处理。
*/
@Slf4j
@RestControllerAdvice(basePackages = "org.jeecg.modules.inquiry")
public class InquiryExceptionAdvice {
@ExceptionHandler(IllegalArgumentException.class)
public Result<?> handleIllegalArgument(IllegalArgumentException e) {
log.warn("询价参数错误: {}", e.getMessage());
return Result.error(e.getMessage());
}
@ExceptionHandler(IllegalStateException.class)
public Result<?> handleIllegalState(IllegalStateException e) {
log.warn("询价状态错误: {}", e.getMessage());
return Result.error(e.getMessage());
}
}
@@ -0,0 +1,139 @@
package org.jeecg.modules.inquiry.controller;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
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.jeecg.modules.inquiry.entity.InquiryStaffRole;
import org.jeecg.modules.inquiry.service.IInquiryStaffRoleService;
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.Map;
/**
* 员工岗位管理 API。
* <p>
* 路径前缀 /inquiry/staffRole;权限码 inquiry:staff:*。
* 不做企微换号,wecom_id 由运营手工绑定。
*/
@Slf4j
@Api(tags = "询价-员工岗位")
@RestController
@RequestMapping("/inquiry/staffRole")
public class InquiryStaffRoleController extends JeecgController<InquiryStaffRole, IInquiryStaffRoleService> {
@Autowired
private IInquiryStaffRoleService staffRoleService;
private static final Map<String, String> ROLE_LABEL = new HashMap<>();
static {
ROLE_LABEL.put("sales", "销售");
ROLE_LABEL.put("air", "航线·空运");
ROLE_LABEL.put("sea", "产品·海运");
ROLE_LABEL.put("land", "产品·陆运");
}
@AutoLog(value = "员工岗位-分页列表")
@ApiOperation("分页列表")
@RequiresPermissions("inquiry:staff:list")
@GetMapping("/list")
public Result<IPage<InquiryStaffRole>> list(InquiryStaffRole query,
@RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
@RequestParam(name = "pageSize", defaultValue = "20") Integer pageSize,
HttpServletRequest req) {
QueryWrapper<InquiryStaffRole> qw = new QueryWrapper<>();
if (StringUtils.isNotBlank(query.getRoleCode())) {
qw.eq("role_code", query.getRoleCode());
}
if (StringUtils.isNotBlank(query.getStatus())) {
qw.eq("status", query.getStatus());
}
String keyword = req.getParameter("keyword");
if (StringUtils.isNotBlank(keyword)) {
qw.and(w -> w.like("name", keyword).or().like("wecom_id", keyword).or().like("mobile", keyword));
}
if (StringUtils.isNotBlank(query.getRoutes())) {
qw.like("routes", query.getRoutes());
}
qw.orderByDesc("create_time");
return Result.OK(staffRoleService.page(new Page<>(pageNo, pageSize), qw));
}
@ApiOperation("角色人数统计")
@RequiresPermissions("inquiry:staff:list")
@GetMapping("/counts")
public Result<Map<String, Long>> counts() {
return Result.OK(staffRoleService.countByRole());
}
@AutoLog(value = "员工岗位-新增")
@ApiOperation("新增")
@RequiresPermissions("inquiry:staff:add")
@PostMapping("/add")
public Result<String> add(@RequestBody InquiryStaffRole body) {
normalize(body);
body.setCreateTime(new Date());
if (StringUtils.isBlank(body.getStatus())) {
body.setStatus("active");
}
staffRoleService.save(body);
return Result.OK("添加成功");
}
@AutoLog(value = "员工岗位-编辑")
@ApiOperation("编辑")
@RequiresPermissions("inquiry:staff:edit")
@PutMapping("/edit")
public Result<String> edit(@RequestBody InquiryStaffRole body) {
if (StringUtils.isBlank(body.getId())) {
return Result.error("id 不能为空");
}
normalize(body);
body.setUpdateTime(new Date());
staffRoleService.updateById(body);
return Result.OK("更新成功");
}
@AutoLog(value = "员工岗位-停用")
@ApiOperation("停用")
@RequiresPermissions("inquiry:staff:disable")
@PostMapping("/disable")
public Result<String> disable(@RequestParam String id) {
staffRoleService.disable(id);
return Result.OK("已停用");
}
@ApiOperation("按 id 查询")
@RequiresPermissions("inquiry:staff:list")
@GetMapping("/queryById")
public Result<InquiryStaffRole> queryById(@RequestParam String id) {
return Result.OK(staffRoleService.getById(id));
}
private void normalize(InquiryStaffRole body) {
if (StringUtils.isBlank(body.getName()) || StringUtils.isBlank(body.getWecomId())) {
throw new IllegalArgumentException("姓名与企微 userid 不能为空");
}
if (StringUtils.isBlank(body.getRoleCode())) {
body.setRoleCode("sales");
}
if (StringUtils.isBlank(body.getRoleLabel())) {
body.setRoleLabel(ROLE_LABEL.getOrDefault(body.getRoleCode(), body.getRoleCode()));
}
if (body.getNotifyEventException() == null) {
body.setNotifyEventException(0);
}
}
}
@@ -0,0 +1,145 @@
package org.jeecg.modules.inquiry.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.SecurityUtils;
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.vo.LoginUser;
import org.jeecg.modules.inquiry.dto.TicketAbnormalRequest;
import org.jeecg.modules.inquiry.dto.TicketTransitionRequest;
import org.jeecg.modules.inquiry.entity.InquiryTicket;
import org.jeecg.modules.inquiry.service.IInquiryWorkOrderService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import java.util.HashMap;
import java.util.Map;
/**
* 询价工单 API:列表、聚合详情、六态流转、异常处理/重试。
* <p>
* 请求线程只做 DB 事务与校验,不调用 LLM / LibreOffice / Graph。
* 权限:inquiry:workorder:list / transition 等。
*/
@Slf4j
@Api(tags = "询价-工单")
@RestController
@RequestMapping("/inquiry/workOrder")
public class InquiryWorkOrderController {
@Autowired
private IInquiryWorkOrderService workOrderService;
@AutoLog(value = "工单-列表视图")
@ApiOperation("后台列表")
@RequiresPermissions("inquiry:workorder:list")
@GetMapping("/view/list")
public Result<IPage<InquiryTicket>> viewList(
@RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
@RequestParam(name = "pageSize", defaultValue = "20") Integer pageSize,
HttpServletRequest req) {
Map<String, String> filters = new HashMap<>();
copyParam(req, filters, "keyword");
copyParam(req, filters, "status");
copyParam(req, filters, "bizType");
copyParam(req, filters, "businessLine");
copyParam(req, filters, "eventException");
copyParam(req, filters, "systemException");
copyParam(req, filters, "dateFrom");
copyParam(req, filters, "dateTo");
return Result.OK(workOrderService.pageView(new Page<>(pageNo, pageSize), filters));
}
@ApiOperation("兼容 list 路径")
@RequiresPermissions("inquiry:workorder:list")
@GetMapping("/list")
public Result<IPage<InquiryTicket>> list(
@RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
@RequestParam(name = "pageSize", defaultValue = "20") Integer pageSize,
HttpServletRequest req) {
return viewList(pageNo, pageSize, req);
}
@AutoLog(value = "工单-聚合详情")
@ApiOperation("聚合详情")
@RequiresPermissions("inquiry:workorder:list")
@GetMapping("/view/detail")
public Result<Map<String, Object>> viewDetail(@RequestParam String workOrderNo) {
return Result.OK(workOrderService.detailView(workOrderNo));
}
@ApiOperation("按工单号查主表")
@RequiresPermissions("inquiry:workorder:list")
@GetMapping("/queryById")
public Result<InquiryTicket> queryById(@RequestParam(required = false) String id,
@RequestParam(required = false) String workOrderNo) {
if (workOrderNo != null && !workOrderNo.isEmpty()) {
Map<String, Object> detail = workOrderService.detailView(workOrderNo);
return Result.OK((InquiryTicket) detail.get("ticket"));
}
return Result.OK(workOrderService.getById(id));
}
@AutoLog(value = "工单-六态流转")
@ApiOperation("六态流转(含关单)")
@RequiresPermissions("inquiry:workorder:transition")
@PostMapping("/transition")
public Result<InquiryTicket> transition(@RequestBody TicketTransitionRequest req) {
try {
return Result.OK(workOrderService.transition(req, currentUser()));
} catch (IllegalArgumentException | IllegalStateException e) {
return Result.error(e.getMessage());
}
}
@AutoLog(value = "工单-异常处理")
@ApiOperation("异常处理")
@RequiresPermissions("inquiry:workorder:transition")
@PostMapping("/abnormal/handle")
public Result<String> abnormalHandle(@RequestBody TicketAbnormalRequest req) {
try {
workOrderService.handleAbnormal(req, currentUser());
return Result.OK("已处理");
} catch (IllegalArgumentException e) {
return Result.error(e.getMessage());
}
}
@AutoLog(value = "工单-异常重试")
@ApiOperation("系统异常重试")
@RequiresPermissions("inquiry:workorder:transition")
@PostMapping("/abnormal/retry")
public Result<String> abnormalRetry(@RequestBody TicketAbnormalRequest req) {
try {
workOrderService.retryAbnormal(req, currentUser());
return Result.OK("已提交重试");
} catch (IllegalArgumentException e) {
return Result.error(e.getMessage());
}
}
private static void copyParam(HttpServletRequest req, Map<String, String> filters, String key) {
String v = req.getParameter(key);
if (v != null && !v.trim().isEmpty()) {
filters.put(key, v.trim());
}
}
private String currentUser() {
try {
LoginUser user = (LoginUser) SecurityUtils.getSubject().getPrincipal();
if (user != null && user.getUsername() != null) {
return user.getUsername();
}
} catch (Exception ignored) {
// 无登录上下文时记为 system(例如服务账号调用)
}
return "system";
}
}
@@ -0,0 +1,76 @@
package org.jeecg.modules.inquiry.domain;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
/**
* 询价工单六态与允许流转规则。
* <p>
* 职责:集中定义状态字面量与 transition 合法性,避免 Controller/Service 散落魔法字符串。
* 副作用:无;纯内存规则。线程安全(不可变 Map)。
* 为何这样写:六态是主账 Owner,非法流转必须在服务端拒绝,前端按钮隐藏不能当真相。
*/
public final class TicketStatuses {
public static final String INQUIRING = "询价中";
public static final String QUOTED = "已报价";
public static final String NEGOTIATING = "协商中";
public static final String DEAL = "已成交";
public static final String LOST = "未成交";
public static final String CLOSED = "已关闭";
private static final Set<String> ALL = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
INQUIRING, QUOTED, NEGOTIATING, DEAL, LOST, CLOSED
)));
/**
* from -> 允许的 to 集合。
* 终态(已成交/未成交/已关闭)默认不可再流转,除非显式「关闭」从非终态进入已关闭。
*/
private static final Map<String, Set<String>> ALLOWED;
static {
Map<String, Set<String>> m = new HashMap<>();
m.put(INQUIRING, setOf(QUOTED, NEGOTIATING, CLOSED));
m.put(QUOTED, setOf(NEGOTIATING, DEAL, LOST, CLOSED));
m.put(NEGOTIATING, setOf(QUOTED, DEAL, LOST, CLOSED));
m.put(DEAL, Collections.emptySet());
m.put(LOST, Collections.emptySet());
m.put(CLOSED, Collections.emptySet());
ALLOWED = Collections.unmodifiableMap(m);
}
private TicketStatuses() {
}
public static boolean isKnown(String status) {
return status != null && ALL.contains(status);
}
/**
* 校验是否允许从 from 转到 to。
*
* @param from 当前状态
* @param to 目标状态
* @return true 表示允许
*/
public static boolean canTransit(String from, String to) {
if (!isKnown(from) || !isKnown(to)) {
return false;
}
Set<String> next = ALLOWED.get(from);
return next != null && next.contains(to);
}
public static Set<String> all() {
return ALL;
}
private static Set<String> setOf(String... values) {
return Collections.unmodifiableSet(new HashSet<>(Arrays.asList(values)));
}
}
@@ -0,0 +1,19 @@
package org.jeecg.modules.inquiry.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
/**
* 异常处理 / 重试请求。
*/
@Data
@ApiModel("异常处理请求")
public class TicketAbnormalRequest {
@ApiModelProperty(value = "工单号", required = true)
private String workOrderNo;
@ApiModelProperty("处理说明")
private String remark;
}
@@ -0,0 +1,23 @@
package org.jeecg.modules.inquiry.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
/**
* 工单六态流转请求。
* 参数:工单号 + 目标状态 + 可选备注;由主账校验合法性后落库。
*/
@Data
@ApiModel("工单状态流转请求")
public class TicketTransitionRequest {
@ApiModelProperty(value = "工单号", required = true)
private String workOrderNo;
@ApiModelProperty(value = "目标六态", required = true)
private String toStatus;
@ApiModelProperty("备注/关单原因")
private String remark;
}
@@ -0,0 +1,40 @@
package org.jeecg.modules.inquiry.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.annotations.ApiModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import org.springframework.format.annotation.DateTimeFormat;
import java.io.Serializable;
import java.util.Date;
/**
* 工单附件元数据,对应 inq_attachment。只存对象存储键,不存文件字节。
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("inq_attachment")
@ApiModel(value = "inq_attachment", description = "工单附件元数据")
public class InquiryAttachment implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.ASSIGN_ID)
private String id;
private String ticketId;
private String workOrderNo;
private String fileName;
private String objectKey;
private String contentType;
private Long fileSize;
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
}
@@ -0,0 +1,63 @@
package org.jeecg.modules.inquiry.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import org.springframework.format.annotation.DateTimeFormat;
import java.io.Serializable;
import java.util.Date;
/**
* 机器人回复语,对应 inq_bot_reply。
* 按分类维护触发场景与正文;智能体出站文案可后续读取本表(本期后台 CRUD)。
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("inq_bot_reply")
@ApiModel(value = "inq_bot_reply", description = "机器人回复语")
public class InquiryBotReply implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.ASSIGN_ID)
private String id;
@ApiModelProperty("分类码")
private String categoryCode;
@ApiModelProperty("分类标题")
private String categoryTitle;
@ApiModelProperty("触发场景")
private String triggerScene;
@ApiModelProperty("回复正文")
private String replyText;
@ApiModelProperty("是否启用 0/1")
private Integer enabled;
@ApiModelProperty("排序")
private Integer sortNo;
private String remark;
private String createBy;
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
private String updateBy;
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime;
}
@@ -0,0 +1,56 @@
package org.jeecg.modules.inquiry.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.annotations.ApiModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import org.springframework.format.annotation.DateTimeFormat;
import java.io.Serializable;
import java.util.Date;
/**
* 询价异常单,对应 inq_exception_case。
* 后台「处理异常 / 重试」写此表,并回写工单上的异常摘要字段。
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("inq_exception_case")
@ApiModel(value = "inq_exception_case", description = "询价异常单")
public class InquiryExceptionCase implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.ASSIGN_ID)
private String id;
private String ticketId;
private String workOrderNo;
private String caseType;
private String title;
private String reason;
private String status;
private Integer retryCount;
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date lastRetryAt;
private String handledBy;
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date handledAt;
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime;
}
@@ -0,0 +1,46 @@
package org.jeecg.modules.inquiry.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.annotations.ApiModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import org.springframework.format.annotation.DateTimeFormat;
import java.io.Serializable;
import java.util.Date;
/**
* 报价快照(只读展示),对应 inq_quote。本期不做模板上传/启停。
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("inq_quote")
@ApiModel(value = "inq_quote", description = "工单报价快照")
public class InquiryQuote implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.ASSIGN_ID)
private String id;
private String ticketId;
private String workOrderNo;
private Integer quoteVersion;
private String source;
private String sourceLabel;
private String priceText;
private Integer highlighted;
private String payloadJson;
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime;
}
@@ -0,0 +1,73 @@
package org.jeecg.modules.inquiry.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import org.springframework.format.annotation.DateTimeFormat;
import java.io.Serializable;
import java.util.Date;
/**
* 询价业务员工岗位,对应 inq_staff_role。
* <p>
* 与 Jeecg sys_user 分离:本表管销售/航线/产品岗位与企微 wecom_id;
* 登录账号走 /sys/user。智能体按入站 sender_id 查本表角色。
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("inq_staff_role")
@ApiModel(value = "inq_staff_role", description = "询价业务员工岗位")
public class InquiryStaffRole implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.ASSIGN_ID)
private String id;
@ApiModelProperty("姓名")
private String name;
@ApiModelProperty("企微 userid")
private String wecomId;
@ApiModelProperty("手机")
private String mobile;
@ApiModelProperty("部门")
private String departName;
@ApiModelProperty("角色码 sales/air/sea/land")
private String roleCode;
@ApiModelProperty("角色展示名")
private String roleLabel;
@ApiModelProperty("负责线路")
private String routes;
@ApiModelProperty("是否接收事件异常提醒 0/1")
private Integer notifyEventException;
@ApiModelProperty("active/disabled")
private String status;
private String createBy;
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
private String updateBy;
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime;
}
@@ -0,0 +1,123 @@
package org.jeecg.modules.inquiry.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import org.springframework.format.annotation.DateTimeFormat;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
/**
* 询价工单实体,对应表 inq_ticket。
* <p>
* 职责:持久化六态工单主事实;列表/详情/看板聚合均以此表为真相源之一。
* 副作用:写库经 Service;禁止前端本地改 status 冒充主账成功。
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("inq_ticket")
@ApiModel(value = "inq_ticket", description = "询价工单")
public class InquiryTicket implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.ASSIGN_ID)
@ApiModelProperty("主键")
private String id;
@ApiModelProperty("工单号")
private String workOrderNo;
@ApiModelProperty("发起销售企微 userid")
private String salesWecomId;
@ApiModelProperty("销售展示名")
private String salesName;
@ApiModelProperty("客户名称")
private String customerName;
@ApiModelProperty("业务线 SEA/AIR/LAND")
private String businessLine;
@ApiModelProperty("展示业务类型")
private String bizTypeLabel;
@ApiModelProperty("起运地")
private String origin;
@ApiModelProperty("目的地")
private String destination;
@ApiModelProperty("需求标题")
private String demandTitle;
@ApiModelProperty("需求详情")
private String demandDetail;
@ApiModelProperty("品名")
private String goodsName;
@ApiModelProperty("件数")
private Integer pieces;
@ApiModelProperty("重量KG")
private BigDecimal weightKg;
@ApiModelProperty("体积CBM")
private BigDecimal volumeCbm;
@ApiModelProperty("六态")
private String status;
@ApiModelProperty("成本价展示")
private String costPrice;
@ApiModelProperty("售价展示")
private String salePrice;
@ApiModelProperty("毛利率展示")
private String profitRate;
@ApiModelProperty("是否命中 TMS")
private Integer tmsHit;
@ApiModelProperty("是否含手工报价")
private Integer manualQuote;
@ApiModelProperty("事件异常摘要")
private String eventException;
@ApiModelProperty("系统异常摘要")
private String systemException;
@ApiModelProperty("系统异常原因")
private String systemExceptionReason;
@ApiModelProperty("关单原因")
private String closeReason;
@ApiModelProperty("业务版本")
private Integer version;
private String createBy;
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
private String updateBy;
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime;
}
@@ -0,0 +1,42 @@
package org.jeecg.modules.inquiry.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.annotations.ApiModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import org.springframework.format.annotation.DateTimeFormat;
import java.io.Serializable;
import java.util.Date;
/**
* 工单状态流转日志,对应 inq_ticket_status_log。
* 用于详情时间线;每次 transition / close / retry / abnormal_handle 写一行。
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("inq_ticket_status_log")
@ApiModel(value = "inq_ticket_status_log", description = "工单状态日志")
public class InquiryTicketStatusLog implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.ASSIGN_ID)
private String id;
private String ticketId;
private String workOrderNo;
private String fromStatus;
private String toStatus;
private String action;
private String operator;
private String remark;
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
}
@@ -0,0 +1,9 @@
package org.jeecg.modules.inquiry.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.jeecg.modules.inquiry.entity.InquiryAttachment;
@Mapper
public interface InquiryAttachmentMapper extends BaseMapper<InquiryAttachment> {
}
@@ -0,0 +1,9 @@
package org.jeecg.modules.inquiry.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.jeecg.modules.inquiry.entity.InquiryBotReply;
@Mapper
public interface InquiryBotReplyMapper extends BaseMapper<InquiryBotReply> {
}
@@ -0,0 +1,9 @@
package org.jeecg.modules.inquiry.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.jeecg.modules.inquiry.entity.InquiryExceptionCase;
@Mapper
public interface InquiryExceptionCaseMapper extends BaseMapper<InquiryExceptionCase> {
}
@@ -0,0 +1,9 @@
package org.jeecg.modules.inquiry.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.jeecg.modules.inquiry.entity.InquiryQuote;
@Mapper
public interface InquiryQuoteMapper extends BaseMapper<InquiryQuote> {
}
@@ -0,0 +1,9 @@
package org.jeecg.modules.inquiry.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
@Mapper
public interface InquiryStaffRoleMapper extends BaseMapper<InquiryStaffRole> {
}
@@ -0,0 +1,9 @@
package org.jeecg.modules.inquiry.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.jeecg.modules.inquiry.entity.InquiryTicket;
@Mapper
public interface InquiryTicketMapper extends BaseMapper<InquiryTicket> {
}
@@ -0,0 +1,9 @@
package org.jeecg.modules.inquiry.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.jeecg.modules.inquiry.entity.InquiryTicketStatusLog;
@Mapper
public interface InquiryTicketStatusLogMapper extends BaseMapper<InquiryTicketStatusLog> {
}
@@ -0,0 +1,8 @@
/**
* 友通达新系统 · 询价主账手写模块(从零实现)。
* <p>
* 本期能力:分析看板、工单六态、员工企微岗位、机器人回复语。
* 排除:报价模板、识别任务、报价生成、存档死信。
* 数据只写 inquiry_robot;禁止连现网 jeecg-boot。
*/
package org.jeecg.modules.inquiry;
@@ -0,0 +1,14 @@
package org.jeecg.modules.inquiry.service;
import com.baomidou.mybatisplus.extension.service.IService;
import org.jeecg.modules.inquiry.entity.InquiryBotReply;
/**
* 机器人回复语服务。
*/
public interface IInquiryBotReplyService extends IService<InquiryBotReply> {
void enable(String id);
void disable(String id);
}
@@ -0,0 +1,16 @@
package org.jeecg.modules.inquiry.service;
import java.util.Map;
/**
* 分析看板聚合。数字一律来自主账库,禁止前端假数据当生产。
*/
public interface IInquiryDashboardService {
/**
* 返回看板指标:metrics 列表 + trend + rankings。
*
* @param range today|7d|30d,默认 today
*/
Map<String, Object> metrics(String range);
}
@@ -0,0 +1,22 @@
package org.jeecg.modules.inquiry.service;
import com.baomidou.mybatisplus.extension.service.IService;
import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
import java.util.Map;
/**
* 询价业务员工岗位服务。
*/
public interface IInquiryStaffRoleService extends IService<InquiryStaffRole> {
/**
* 按角色统计人数(含 all)。
*/
Map<String, Long> countByRole();
/**
* 停用员工(status=disabled),不物理删除。
*/
void disable(String id);
}
@@ -0,0 +1,46 @@
package org.jeecg.modules.inquiry.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import org.jeecg.modules.inquiry.dto.TicketAbnormalRequest;
import org.jeecg.modules.inquiry.dto.TicketTransitionRequest;
import org.jeecg.modules.inquiry.entity.InquiryTicket;
import java.util.Map;
/**
* 询价工单服务:列表、聚合详情、六态流转、异常处理/重试。
* <p>
* 重要:仅按单条工单写库,禁止全局大锁串行所有工单。
*/
public interface IInquiryWorkOrderService extends IService<InquiryTicket> {
/**
* 后台列表视图(支持关键字/状态/业务类型/异常筛选)。
*/
IPage<InquiryTicket> pageView(Page<InquiryTicket> page, Map<String, String> filters);
/**
* 聚合详情:工单 + 报价列表 + 附件 + 时间线 + 开放异常。
*/
Map<String, Object> detailView(String workOrderNo);
/**
* 六态流转(含关单到「已关闭」)。
*
* @param req 请求
* @param operator 当前登录用户名
*/
InquiryTicket transition(TicketTransitionRequest req, String operator);
/**
* 标记异常已处理,并清理工单上对应异常摘要。
*/
void handleAbnormal(TicketAbnormalRequest req, String operator);
/**
* 系统异常重试:累加 retry_count,记日志;真正重跑任务由 Worker 后续认领(本期只落主账状态)。
*/
void retryAbnormal(TicketAbnormalRequest req, String operator);
}
@@ -0,0 +1,40 @@
package org.jeecg.modules.inquiry.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.jeecg.modules.inquiry.entity.InquiryBotReply;
import org.jeecg.modules.inquiry.mapper.InquiryBotReplyMapper;
import org.jeecg.modules.inquiry.service.IInquiryBotReplyService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Date;
/**
* 回复语启停与 CRUD。
*/
@Service
public class InquiryBotReplyServiceImpl extends ServiceImpl<InquiryBotReplyMapper, InquiryBotReply>
implements IInquiryBotReplyService {
@Override
@Transactional(rollbackFor = Exception.class)
public void enable(String id) {
setEnabled(id, 1);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void disable(String id) {
setEnabled(id, 0);
}
private void setEnabled(String id, int enabled) {
InquiryBotReply row = getById(id);
if (row == null) {
throw new IllegalArgumentException("回复语不存在: " + id);
}
row.setEnabled(enabled);
row.setUpdateTime(new Date());
updateById(row);
}
}
@@ -0,0 +1,175 @@
package org.jeecg.modules.inquiry.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.jeecg.modules.inquiry.domain.TicketStatuses;
import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
import org.jeecg.modules.inquiry.entity.InquiryTicket;
import org.jeecg.modules.inquiry.mapper.InquiryStaffRoleMapper;
import org.jeecg.modules.inquiry.mapper.InquiryTicketMapper;
import org.jeecg.modules.inquiry.service.IInquiryDashboardService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 看板实时聚合实现(无快照依赖)。
* <p>
* 计算口径对齐后台原型:询价总数、待处理、TMS 命中率、异常完成率、手工报价占比、
* 已成交、成交率、反馈率;内部协同时长本期无协同表时返回「-」。
* 线程:只读查询,无写锁。
*/
@Service
public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
@Autowired
private InquiryTicketMapper ticketMapper;
@Autowired
private InquiryStaffRoleMapper staffRoleMapper;
@Override
public Map<String, Object> metrics(String range) {
LocalDate today = LocalDate.now(ZONE);
LocalDate fromDate;
if ("7d".equalsIgnoreCase(range)) {
fromDate = today.minusDays(6);
} else if ("30d".equalsIgnoreCase(range)) {
fromDate = today.minusDays(29);
} else {
fromDate = today;
}
Date from = Date.from(fromDate.atStartOfDay(ZONE).toInstant());
Date to = Date.from(LocalDateTime.of(today, LocalTime.MAX).atZone(ZONE).toInstant());
List<InquiryTicket> tickets = ticketMapper.selectList(new LambdaQueryWrapper<InquiryTicket>()
.ge(InquiryTicket::getCreateTime, from)
.le(InquiryTicket::getCreateTime, to));
long total = tickets.size();
long deal = tickets.stream().filter(t -> TicketStatuses.DEAL.equals(t.getStatus())).count();
long lost = tickets.stream().filter(t -> TicketStatuses.LOST.equals(t.getStatus())).count();
long closed = tickets.stream().filter(t -> TicketStatuses.CLOSED.equals(t.getStatus())).count();
long quoted = tickets.stream().filter(t -> TicketStatuses.QUOTED.equals(t.getStatus())
|| TicketStatuses.DEAL.equals(t.getStatus())
|| TicketStatuses.LOST.equals(t.getStatus())
|| TicketStatuses.NEGOTIATING.equals(t.getStatus())).count();
long pending = total - deal - lost - closed;
long tmsHit = tickets.stream().filter(t -> t.getTmsHit() != null && t.getTmsHit() == 1).count();
long manual = tickets.stream().filter(t -> t.getManualQuote() != null && t.getManualQuote() == 1).count();
long hasEventExc = tickets.stream().filter(t -> hasExc(t.getEventException())).count();
long hasSysExc = tickets.stream().filter(t -> hasExc(t.getSystemException())).count();
long abnormalTotal = hasEventExc + hasSysExc;
// 简化:已关闭且曾有异常视为异常处理完成
long abnormalDone = tickets.stream()
.filter(t -> TicketStatuses.CLOSED.equals(t.getStatus()))
.filter(t -> hasExc(t.getEventException()) || hasExc(t.getSystemException())
|| (t.getCloseReason() != null && t.getCloseReason().contains("异常")))
.count();
List<Map<String, Object>> metricCards = new ArrayList<>();
metricCards.add(card("inquiry", "询价总数", "周期内询价工单总数", String.valueOf(total), false));
metricCards.add(card("pending", "待处理工单", "工单总数 − 已成交 − 未成交 − 已关闭", pending + " 单", false));
metricCards.add(card("tms", "标准报价命中率", "命中 TMS 标准价的询价数 ÷ 询价总数 × 100%", pct(tmsHit, total), false));
metricCards.add(card("abnormal", "异常处理完成率", "已关闭异常工单数 ÷ 异常工单总数 × 100%",
abnormalTotal == 0 ? "100%" : pct(abnormalDone, Math.max(abnormalTotal, 1)), false));
metricCards.add(card("manual", "手工报价占比", "人工报价的询价数 ÷ 询价总数 × 100%", pct(manual, total), false));
metricCards.add(card("accepted", "已成交工单", "状态=已成交的工单数量", String.valueOf(deal), false));
metricCards.add(card("dealRate", "报价成交率", "已成交数 ÷ 已报价相关数 × 100%", pct(deal, Math.max(quoted, 1)), false));
long feedbackNumer = deal + lost;
// 反馈率口径:已成交+未成交(相对周期工单)
metricCards.add(card("feedback", "报价反馈率", "(已成交 + 未成交)÷ 工单数 × 100%", pct(feedbackNumer, total), false));
metricCards.add(card("avgResp", "内部协同平均响应时长", "本期无协同明细表,暂无数据", "-", true));
// 近 7 日趋势(不受 range 限制,便于折线)
List<Map<String, Object>> trend = new ArrayList<>();
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("MM-dd");
for (int i = 6; i >= 0; i--) {
LocalDate d = today.minusDays(i);
Date dFrom = Date.from(d.atStartOfDay(ZONE).toInstant());
Date dTo = Date.from(LocalDateTime.of(d, LocalTime.MAX).atZone(ZONE).toInstant());
long cnt = ticketMapper.selectCount(new LambdaQueryWrapper<InquiryTicket>()
.ge(InquiryTicket::getCreateTime, dFrom)
.le(InquiryTicket::getCreateTime, dTo));
Map<String, Object> p = new HashMap<>();
p.put("date", d.format(fmt));
p.put("value", cnt);
trend.add(p);
}
// 人员排名:按销售名聚合询价量
Map<String, Long> bySales = tickets.stream()
.filter(t -> t.getSalesName() != null && !t.getSalesName().isEmpty())
.collect(Collectors.groupingBy(InquiryTicket::getSalesName, Collectors.counting()));
Map<String, InquiryStaffRole> staffByName = staffRoleMapper.selectList(null).stream()
.collect(Collectors.toMap(InquiryStaffRole::getName, s -> s, (a, b) -> a));
List<Map<String, Object>> rankings = bySales.entrySet().stream()
.sorted(Map.Entry.<String, Long>comparingByValue().reversed())
.limit(20)
.map(e -> {
Map<String, Object> row = new LinkedHashMap<>();
row.put("name", e.getKey());
InquiryStaffRole staff = staffByName.get(e.getKey());
if (staff != null) {
row.put("role", mapRankRole(staff.getRoleCode()));
row.put("dept", staff.getDepartName());
} else {
row.put("role", "sales");
row.put("dept", "-");
}
row.put("volumeNum", e.getValue());
return row;
})
.collect(Collectors.toList());
Map<String, Object> result = new LinkedHashMap<>();
result.put("range", range == null ? "today" : range);
result.put("metrics", metricCards);
result.put("trend", trend);
result.put("rankings", rankings);
return result;
}
private static boolean hasExc(String v) {
return v != null && !v.trim().isEmpty() && !"-".equals(v.trim());
}
private static String pct(long numer, long denom) {
if (denom <= 0) {
return "0.0%";
}
BigDecimal p = BigDecimal.valueOf(numer * 100.0 / denom).setScale(1, RoundingMode.HALF_UP);
return p.toPlainString() + "%";
}
private static Map<String, Object> card(String key, String label, String tip, String value, boolean isTime) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("key", key);
m.put("label", label);
m.put("tip", tip);
m.put("value", value);
m.put("isTime", isTime);
return m;
}
private static String mapRankRole(String roleCode) {
if ("air".equals(roleCode) || "sea".equals(roleCode) || "land".equals(roleCode)) {
return "air".equals(roleCode) ? "route" : "product";
}
return "sales";
}
}
@@ -0,0 +1,57 @@
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.jeecg.modules.inquiry.entity.InquiryStaffRole;
import org.jeecg.modules.inquiry.mapper.InquiryStaffRoleMapper;
import org.jeecg.modules.inquiry.service.IInquiryStaffRoleService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 员工岗位 CRUD 实现。
* <p>
* 线程:跟随 HTTP 请求线程;无全局锁。wecom_id 唯一约束由库保证。
*/
@Service
public class InquiryStaffRoleServiceImpl extends ServiceImpl<InquiryStaffRoleMapper, InquiryStaffRole>
implements IInquiryStaffRoleService {
private static final String[] ROLE_CODES = {"sales", "air", "sea", "land"};
@Override
public Map<String, Long> countByRole() {
List<InquiryStaffRole> all = list();
Map<String, Long> map = new HashMap<>();
map.put("all", (long) all.size());
for (String code : ROLE_CODES) {
long c = all.stream().filter(r -> code.equals(r.getRoleCode())).count();
map.put(code, c);
}
return map;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void disable(String id) {
InquiryStaffRole row = getById(id);
if (row == null) {
throw new IllegalArgumentException("员工不存在: " + id);
}
row.setStatus("disabled");
row.setUpdateTime(new Date());
updateById(row);
}
/**
* 按 wecom_id 查一条(智能体/后台可复用)。
*/
public InquiryStaffRole findByWecomId(String wecomId) {
return getOne(new QueryWrapper<InquiryStaffRole>().eq("wecom_id", wecomId).last("LIMIT 1"), false);
}
}
@@ -0,0 +1,240 @@
package org.jeecg.modules.inquiry.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.apache.commons.lang3.StringUtils;
import org.jeecg.modules.inquiry.domain.TicketStatuses;
import org.jeecg.modules.inquiry.dto.TicketAbnormalRequest;
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.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.InquiryTicketMapper;
import org.jeecg.modules.inquiry.mapper.InquiryTicketStatusLogMapper;
import org.jeecg.modules.inquiry.service.IInquiryWorkOrderService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 询价工单主账实现。
* <p>
* 副作用:改 inq_ticket.status、写 inq_ticket_status_log、改 inq_exception_case。
* 线程:单请求事务;只锁当前工单行(DB 行级),不串行其它工单。
* 为何:Graph/前端成功不能冒充主账六态成功,所有 transition 必须过本服务。
*/
@Service
public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper, InquiryTicket>
implements IInquiryWorkOrderService {
@Autowired
private InquiryQuoteMapper quoteMapper;
@Autowired
private InquiryAttachmentMapper attachmentMapper;
@Autowired
private InquiryTicketStatusLogMapper statusLogMapper;
@Autowired
private InquiryExceptionCaseMapper exceptionCaseMapper;
@Override
public IPage<InquiryTicket> pageView(Page<InquiryTicket> page, Map<String, String> filters) {
LambdaQueryWrapper<InquiryTicket> qw = new LambdaQueryWrapper<>();
String keyword = filters.get("keyword");
if (StringUtils.isNotBlank(keyword)) {
qw.and(w -> w.like(InquiryTicket::getWorkOrderNo, keyword)
.or().like(InquiryTicket::getSalesName, keyword)
.or().like(InquiryTicket::getDemandTitle, keyword)
.or().like(InquiryTicket::getOrigin, keyword)
.or().like(InquiryTicket::getDestination, keyword));
}
if (StringUtils.isNotBlank(filters.get("status"))) {
qw.eq(InquiryTicket::getStatus, filters.get("status"));
}
if (StringUtils.isNotBlank(filters.get("bizType"))) {
qw.eq(InquiryTicket::getBizTypeLabel, filters.get("bizType"));
}
if (StringUtils.isNotBlank(filters.get("businessLine"))) {
qw.eq(InquiryTicket::getBusinessLine, filters.get("businessLine"));
}
if (StringUtils.isNotBlank(filters.get("eventException"))) {
if ("-".equals(filters.get("eventException"))) {
qw.and(w -> w.isNull(InquiryTicket::getEventException).or().eq(InquiryTicket::getEventException, "").or().eq(InquiryTicket::getEventException, "-"));
} else {
qw.eq(InquiryTicket::getEventException, filters.get("eventException"));
}
}
if (StringUtils.isNotBlank(filters.get("systemException"))) {
if ("-".equals(filters.get("systemException"))) {
qw.and(w -> w.isNull(InquiryTicket::getSystemException).or().eq(InquiryTicket::getSystemException, "").or().eq(InquiryTicket::getSystemException, "-"));
} else {
qw.eq(InquiryTicket::getSystemException, filters.get("systemException"));
}
}
String dateFrom = filters.get("dateFrom");
String dateTo = filters.get("dateTo");
if (StringUtils.isNotBlank(dateFrom)) {
qw.ge(InquiryTicket::getCreateTime, dateFrom + " 00:00:00");
}
if (StringUtils.isNotBlank(dateTo)) {
qw.le(InquiryTicket::getCreateTime, dateTo + " 23:59:59");
}
qw.orderByDesc(InquiryTicket::getCreateTime);
return page(page, qw);
}
@Override
public Map<String, Object> detailView(String workOrderNo) {
InquiryTicket ticket = findByWorkOrderNo(workOrderNo);
if (ticket == null) {
throw new IllegalArgumentException("工单不存在: " + workOrderNo);
}
List<InquiryQuote> quotes = quoteMapper.selectList(new LambdaQueryWrapper<InquiryQuote>()
.eq(InquiryQuote::getWorkOrderNo, workOrderNo)
.orderByAsc(InquiryQuote::getCreateTime));
List<InquiryAttachment> attachments = attachmentMapper.selectList(new LambdaQueryWrapper<InquiryAttachment>()
.eq(InquiryAttachment::getWorkOrderNo, workOrderNo)
.orderByAsc(InquiryAttachment::getCreateTime));
List<InquiryTicketStatusLog> timeline = statusLogMapper.selectList(new LambdaQueryWrapper<InquiryTicketStatusLog>()
.eq(InquiryTicketStatusLog::getWorkOrderNo, workOrderNo)
.orderByAsc(InquiryTicketStatusLog::getCreateTime));
List<InquiryExceptionCase> exceptions = exceptionCaseMapper.selectList(new LambdaQueryWrapper<InquiryExceptionCase>()
.eq(InquiryExceptionCase::getWorkOrderNo, workOrderNo)
.orderByDesc(InquiryExceptionCase::getCreateTime));
Map<String, Object> result = new HashMap<>();
result.put("ticket", ticket);
result.put("quotes", quotes);
result.put("attachments", attachments);
result.put("timeline", timeline);
result.put("exceptions", exceptions);
return result;
}
@Override
@Transactional(rollbackFor = Exception.class)
public InquiryTicket transition(TicketTransitionRequest req, String operator) {
if (req == null || StringUtils.isBlank(req.getWorkOrderNo()) || StringUtils.isBlank(req.getToStatus())) {
throw new IllegalArgumentException("工单号与目标状态不能为空");
}
InquiryTicket ticket = findByWorkOrderNo(req.getWorkOrderNo());
if (ticket == null) {
throw new IllegalArgumentException("工单不存在: " + req.getWorkOrderNo());
}
String from = ticket.getStatus();
String to = req.getToStatus().trim();
if (!TicketStatuses.canTransit(from, to)) {
throw new IllegalStateException("不允许从「" + from + "」流转到「" + to + "」");
}
ticket.setStatus(to);
if (TicketStatuses.CLOSED.equals(to) && StringUtils.isNotBlank(req.getRemark())) {
ticket.setCloseReason(req.getRemark());
}
ticket.setVersion(ticket.getVersion() == null ? 2 : ticket.getVersion() + 1);
ticket.setUpdateBy(operator);
ticket.setUpdateTime(new Date());
updateById(ticket);
appendLog(ticket, from, to, "transition", operator, req.getRemark());
return ticket;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void handleAbnormal(TicketAbnormalRequest req, String operator) {
InquiryTicket ticket = requireTicket(req);
List<InquiryExceptionCase> openCases = exceptionCaseMapper.selectList(new LambdaQueryWrapper<InquiryExceptionCase>()
.eq(InquiryExceptionCase::getWorkOrderNo, ticket.getWorkOrderNo())
.in(InquiryExceptionCase::getStatus, "OPEN", "RETRYING"));
Date now = new Date();
for (InquiryExceptionCase c : openCases) {
c.setStatus("HANDLED");
c.setHandledBy(operator);
c.setHandledAt(now);
c.setUpdateTime(now);
exceptionCaseMapper.updateById(c);
}
ticket.setEventException(null);
ticket.setSystemException(null);
ticket.setSystemExceptionReason(null);
ticket.setUpdateBy(operator);
ticket.setUpdateTime(now);
updateById(ticket);
appendLog(ticket, ticket.getStatus(), ticket.getStatus(), "abnormal_handle", operator, req.getRemark());
}
@Override
@Transactional(rollbackFor = Exception.class)
public void retryAbnormal(TicketAbnormalRequest req, String operator) {
InquiryTicket ticket = requireTicket(req);
List<InquiryExceptionCase> openCases = exceptionCaseMapper.selectList(new LambdaQueryWrapper<InquiryExceptionCase>()
.eq(InquiryExceptionCase::getWorkOrderNo, ticket.getWorkOrderNo())
.eq(InquiryExceptionCase::getCaseType, "SYSTEM")
.in(InquiryExceptionCase::getStatus, "OPEN", "HANDLED", "RETRYING"));
Date now = new Date();
if (openCases.isEmpty()) {
// 无异常单时仍允许记一次重试意图,便于空库联调
InquiryExceptionCase created = new InquiryExceptionCase();
created.setTicketId(ticket.getId());
created.setWorkOrderNo(ticket.getWorkOrderNo());
created.setCaseType("SYSTEM");
created.setTitle(StringUtils.defaultIfBlank(ticket.getSystemException(), "系统任务重试"));
created.setReason(ticket.getSystemExceptionReason());
created.setStatus("RETRYING");
created.setRetryCount(1);
created.setLastRetryAt(now);
created.setCreateTime(now);
created.setUpdateTime(now);
exceptionCaseMapper.insert(created);
} else {
for (InquiryExceptionCase c : openCases) {
c.setStatus("RETRYING");
c.setRetryCount(c.getRetryCount() == null ? 1 : c.getRetryCount() + 1);
c.setLastRetryAt(now);
c.setUpdateTime(now);
exceptionCaseMapper.updateById(c);
}
}
appendLog(ticket, ticket.getStatus(), ticket.getStatus(), "retry", operator, req.getRemark());
}
private InquiryTicket requireTicket(TicketAbnormalRequest req) {
if (req == null || StringUtils.isBlank(req.getWorkOrderNo())) {
throw new IllegalArgumentException("工单号不能为空");
}
InquiryTicket ticket = findByWorkOrderNo(req.getWorkOrderNo());
if (ticket == null) {
throw new IllegalArgumentException("工单不存在: " + req.getWorkOrderNo());
}
return ticket;
}
private InquiryTicket findByWorkOrderNo(String workOrderNo) {
return getOne(new LambdaQueryWrapper<InquiryTicket>()
.eq(InquiryTicket::getWorkOrderNo, workOrderNo)
.last("LIMIT 1"), false);
}
private void appendLog(InquiryTicket ticket, String from, String to, String action, String operator, String remark) {
InquiryTicketStatusLog log = new InquiryTicketStatusLog();
log.setTicketId(ticket.getId());
log.setWorkOrderNo(ticket.getWorkOrderNo());
log.setFromStatus(from);
log.setToStatus(to);
log.setAction(action);
log.setOperator(operator);
log.setRemark(remark);
log.setCreateTime(new Date());
statusLogMapper.insert(log);
}
}