From c3aa1674037472ef5b5de1d6296746cdd74465db Mon Sep 17 00:00:00 2001 From: wangheng Date: Thu, 17 Sep 2026 11:44:21 +0800 Subject: [PATCH] =?UTF-8?q?[REQ-20260917-0003]=20=E5=8F=96=E8=B4=A7?= =?UTF-8?q?=E5=B7=A5=E5=8D=95=20MCP=20=E6=9F=A5=E8=AF=A2=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=EF=BC=88=E5=88=97=E8=A1=A8+=E8=AF=A6=E6=83=85=EF=BC=89=20(#19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- xiaoqu-agent-service/README.md | 2 + .../doc/agent-mcp-tools-reference.md | 32 ++ .../doc/pickup-order-mcp-tools-design.md | 25 + .../me/iiv/agent/entity/m2/PickupOrder.java | 50 ++ .../agent/entity/m2/PickupOrderDetail.java | 39 ++ .../agent/entity/m2/PickupOrderProcess.java | 35 ++ .../mapper/m2/PickupOrderDetailMapper.java | 7 + .../agent/mapper/m2/PickupOrderMapper.java | 7 + .../mapper/m2/PickupOrderProcessMapper.java | 7 + .../iiv/agent/mcp/tool/PickupOrderTools.java | 469 ++++++++++++++++++ .../agent/permission/PermissionService.java | 53 ++ .../agent/mcp/tool/PickupOrderToolsTest.java | 238 +++++++++ ...issionServicePickupOrderRowFilterTest.java | 106 ++++ 13 files changed, 1070 insertions(+) create mode 100644 xiaoqu-agent-service/doc/pickup-order-mcp-tools-design.md create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/PickupOrder.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/PickupOrderDetail.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/PickupOrderProcess.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/PickupOrderDetailMapper.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/PickupOrderMapper.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/PickupOrderProcessMapper.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/PickupOrderTools.java create mode 100644 xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/PickupOrderToolsTest.java create mode 100644 xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServicePickupOrderRowFilterTest.java diff --git a/xiaoqu-agent-service/README.md b/xiaoqu-agent-service/README.md index 96d4b6f..b9e117b 100644 --- a/xiaoqu-agent-service/README.md +++ b/xiaoqu-agent-service/README.md @@ -89,6 +89,8 @@ xiaoqu-agent-service/ | `delivery.order.detail.query` | workorder | read | 送货工单详情;返回物流、配发物品、发货/签收附件、流程及关联单据 | | `install.order.list.query` | workorder | read | 装机工单分页查询;仅保留申请日期范围,返回状态文案与安装数量汇总 | | `install.order.detail.query` | workorder | read | 装机工单详情;返回明细、作业人员、地址、商城订单、流程、配发 SN、出入库关联及凭证 | +| `pickup.order.list.query` | workorder | read | 取货工单分页查询;申请/取货/完成日期独立过滤,物流按页批量富化 | +| `pickup.order.detail.query` | workorder | read | 取货工单详情;返回物流、取货物品、SN 调整、附件、流程及关联单据 | 分页统一规范:page 从 1 开始,rows 默认 20、上限 100,超出截断为 100 并在返回 `notice` 字段提示。 diff --git a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md index 693ac76..2bccbaa 100644 --- a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md +++ b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md @@ -648,6 +648,38 @@ --- +## 24. pickup.order.list.query + +分页查询取货工单,对应旧系统 `POST /pickup/pickupListByPage`。 + +- 分类:workorder | 类型:读 +- 数据来源:m2 `pickup_order`,物流来自 m1 `mall_order_logistics`。 +- 入参:`pickupOrderNo`、`installOrderNo`、`withdrawOrderNo`、`agentId`、`districtName`、 + `type`、`orderState`、`sendMode`、`applyUserName`、申请/取货/完成日期范围及 `page/rows`。 +- `type`:0=装机发起,1=人工发起,2=撤机发起;`orderState`:-2=被驳回,-1=待审核, + 0=待取货,1=待入库,2=已完成,3=已取消;`sendMode`:0=快递公司,1=自行派送。 +- 申请日期按 `apply_time` 查询;三组日期范围可同时过滤,结束日期含当天。 +- 行级权限:总部看全部;代理身份可看申请代理或所属代理在自己、授权代管范围内的工单, + 以及授权代管物业和本人参与流程的工单;销售非负责人按其可见物业及本人参与流程过滤。 +- 返回 `total/page/rows/records`,固定按 ID 倒序;物流按本页单号一次批量查询。 + +## 25. pickup.order.detail.query + +按 `id` 或 `pickupOrderNo` 查询详情,两者至少传一个;同时传入时必须匹配同一条可见记录。 + +- 行级权限与列表完全一致;不存在和无权统一返回 `NOT_FOUND_OR_FORBIDDEN`。 +- `record/logistics`:取货主单、状态/来源文案及物流信息。 +- `pickupGoods`:m2 `pickup_order_details` 中 `type IN (0,2)` 的原取货物品。 +- `serialChanges`:同表 `type=1` 的取货后录入或调整 SN 明细。 +- `shippingFiles`:m1 `mall_logistics_pic` 物流取货图片。 +- `processes`:`schedule_state=2` 的流程记录及 m2 `warehouse_in_out_pic` 取货/入库凭证。 +- `relatedDocuments`:m2 `install_to_out_warehouse` 关联的出库、入库、调拨或划转单。 +- 同时返回关联装机单、撤机单及维修工单号;附件仅返回 `id/fileName/bucket/objectKey`。 + +详细设计见 `pickup-order-mcp-tools-design.md`。 + +--- + ## 附录:在线查看工具清单 ```bash diff --git a/xiaoqu-agent-service/doc/pickup-order-mcp-tools-design.md b/xiaoqu-agent-service/doc/pickup-order-mcp-tools-design.md new file mode 100644 index 0000000..01f9158 --- /dev/null +++ b/xiaoqu-agent-service/doc/pickup-order-mcp-tools-design.md @@ -0,0 +1,25 @@ +# 取货工单 MCP 工具设计 + +需求:REQ-20260917-0003;开发任务:#21964;设计文档:doc 12684。 + +## 工具 + +- `pickup.order.list.query`:分页查询 `m2.pickup_order`,支持工单号、公司、项目、类型、状态、取货方式、 + 申请人和申请/取货/完成日期过滤。固定按 ID 倒序,物流以本页工单号批量读取 m1,避免逐条查询。 +- `pickup.order.detail.query`:按 ID 或工单号查询;返回主单、物流、原取货物品、SN 调整、物流图片、 + 已办流程与凭证、关联出入库/调拨/划转单。 + +## 权限 + +列表和详情统一调用 `PermissionService.applyPickupOrderRowFilter`:总部不加过滤;代理用户可见自己、 +授权代管代理/物业范围及本人参与流程的工单(不扩展到直属下级代理);销售非负责人使用其物业范围并保留本人流程参与范围。 +不存在与无权访问统一返回 `NOT_FOUND_OR_FORBIDDEN`。 + +## 数据约定 + +- 申请日期按业务字段 `apply_time`,不沿用旧列表实际使用的 `create_time`。 +- `pickup_order_details.type IN (0,2)` 为原取货物品,`type=1` 为取货后 SN 明细。 +- 流程只返回 `schedule_state=2` 的已办记录,按 `operation_time,id` 正序。 +- 流程附件限定 `file_type=3`、`in_out_allocation_type=12`。 +- 附件只返回 bucket 与 objectKey,不拼接公开 URL。 +- 不返回旧页面通过姓名字符串推断的 `isReceipt/isPickup` UI 操作标记。 diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/PickupOrder.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/PickupOrder.java new file mode 100644 index 0000000..c64b067 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/PickupOrder.java @@ -0,0 +1,50 @@ +package me.iiv.agent.entity.m2; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import java.io.Serializable; +import java.util.Date; + +/** 取货工单主表(m2,只读)。 */ +@Getter +@Setter +@ToString +@TableName("pickup_order") +public class PickupOrder implements Serializable { + @TableId(value = "id", type = IdType.AUTO) + private Long id; + private Long createId; + private Date createTime; + private Long modifyId; + private Date modifyTime; + private Integer softDelete; + private Long applyAgentId; + private String applyAgentName; + private String pickupOrderNo; + private String installOrderNo; + private Long agentId; + private Long propertyId; + private Long districtId; + private String agentName; + private String propertyName; + private String districtName; + private Integer orderState; + private Long applyUserId; + private String applyUserName; + private Date applyTime; + private Date pickupDate; + private Date finishDate; + private String scheduleUserName; + private Integer sendMode; + private String prePickupDate; + private String pickupUserName; + private String inWarehouseUserName; + private String withdrawOrderNo; + private Integer type; + private String maintainWorkNo; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/PickupOrderDetail.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/PickupOrderDetail.java new file mode 100644 index 0000000..3107f4e --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/PickupOrderDetail.java @@ -0,0 +1,39 @@ +package me.iiv.agent.entity.m2; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import java.io.Serializable; +import java.util.Date; + +/** 取货物品及入库 SN 明细(m2,只读)。 */ +@Getter +@Setter +@ToString +@TableName("pickup_order_details") +public class PickupOrderDetail implements Serializable { + @TableId(value = "id", type = IdType.AUTO) + private Long id; + private Long createId; + private Date createTime; + private Long modifyId; + private Date modifyTime; + private Integer softDelete; + private String pickupOrderNo; + private Long materielDefinitionId; + private String materialNo; + private String materielFullName; + private String materielSort; + private Long materielIndexId; + private String specifications; + private String snCode; + private Integer inCount; + private Long inWarehouseId; + private String inWarehouseName; + private Integer type; + private String productSort; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/PickupOrderProcess.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/PickupOrderProcess.java new file mode 100644 index 0000000..2db8be2 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/PickupOrderProcess.java @@ -0,0 +1,35 @@ +package me.iiv.agent.entity.m2; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import java.io.Serializable; +import java.util.Date; + +/** 取货工单流程(m2,只读)。 */ +@Getter +@Setter +@ToString +@TableName("pickup_order_process") +public class PickupOrderProcess implements Serializable { + @TableId(value = "id", type = IdType.AUTO) + private Long id; + private Long createId; + private Date createTime; + private Long modifyId; + private Date modifyTime; + private Integer softDelete; + private String pickupOrderNo; + private Long userId; + private String userName; + private Date operationTime; + private Integer scheduleState; + private Integer auditState; + private Integer operationTheme; + private Integer operationType; + private String memo; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/PickupOrderDetailMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/PickupOrderDetailMapper.java new file mode 100644 index 0000000..ec4a3d2 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/PickupOrderDetailMapper.java @@ -0,0 +1,7 @@ +package me.iiv.agent.mapper.m2; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import me.iiv.agent.entity.m2.PickupOrderDetail; + +public interface PickupOrderDetailMapper extends BaseMapper { +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/PickupOrderMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/PickupOrderMapper.java new file mode 100644 index 0000000..3907bff --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/PickupOrderMapper.java @@ -0,0 +1,7 @@ +package me.iiv.agent.mapper.m2; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import me.iiv.agent.entity.m2.PickupOrder; + +public interface PickupOrderMapper extends BaseMapper { +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/PickupOrderProcessMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/PickupOrderProcessMapper.java new file mode 100644 index 0000000..9039e6d --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/PickupOrderProcessMapper.java @@ -0,0 +1,7 @@ +package me.iiv.agent.mapper.m2; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import me.iiv.agent.entity.m2.PickupOrderProcess; + +public interface PickupOrderProcessMapper extends BaseMapper { +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/PickupOrderTools.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/PickupOrderTools.java new file mode 100644 index 0000000..6027064 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/PickupOrderTools.java @@ -0,0 +1,469 @@ +package me.iiv.agent.mcp.tool; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.support.SFunction; +import me.iiv.agent.entity.m0.User; +import me.iiv.agent.entity.m1.MallLogisticsPic; +import me.iiv.agent.entity.m1.MallOrderLogistics; +import me.iiv.agent.entity.m2.InstallToOutWarehouse; +import me.iiv.agent.entity.m2.PickupOrder; +import me.iiv.agent.entity.m2.PickupOrderDetail; +import me.iiv.agent.entity.m2.PickupOrderProcess; +import me.iiv.agent.entity.m2.WarehouseInOutPic; +import me.iiv.agent.mapper.m1.MallLogisticsPicMapper; +import me.iiv.agent.mapper.m1.MallOrderLogisticsMapper; +import me.iiv.agent.mapper.m2.InstallToOutWarehouseMapper; +import me.iiv.agent.mapper.m2.PickupOrderDetailMapper; +import me.iiv.agent.mapper.m2.PickupOrderMapper; +import me.iiv.agent.mapper.m2.PickupOrderProcessMapper; +import me.iiv.agent.mapper.m2.WarehouseInOutPicMapper; +import me.iiv.agent.mcp.McpErrorCode; +import me.iiv.agent.mcp.ToolCallException; +import me.iiv.agent.mcp.ToolObject; +import me.iiv.agent.mcp.XqTool; +import me.iiv.agent.permission.PermissionService; +import me.iiv.agent.security.AuthContextHolder; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.format.ResolverStyle; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** 取货工单只读查询,对齐老系统 /pickup/pickupListByPage 与 /pickup/pickupScan。 */ +@Component +public class PickupOrderTools implements ToolObject { + + private static final int DEFAULT_ROWS = 20; + private static final int MAX_ROWS = 100; + private static final int RECEIPT_FILE_TYPE = 3; + private static final int PICKUP_AGGREGATE_TYPE = 12; + private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); + private static final DateTimeFormatter DATE_FORMATTER = + DateTimeFormatter.ISO_LOCAL_DATE.withResolverStyle(ResolverStyle.STRICT); + private static final DateTimeFormatter TIME_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(BUSINESS_ZONE); + + private static final Map ORDER_STATE_TEXT = Map.of( + -2, "被驳回", -1, "待审核", 0, "待取货", 1, "待入库", 2, "已完成", 3, "已取消"); + private static final Map TYPE_TEXT = Map.of( + 0, "装机工单发起", 1, "人工发起", 2, "撤机工单发起"); + private static final Map SEND_MODE_TEXT = Map.of( + 0, "快递公司", 1, "自行派送"); + private static final Map SCHEDULE_STATE_TEXT = Map.of( + 0, "无需待办", 1, "待办", 2, "已办"); + private static final Map OPERATION_THEME_TEXT = Map.of( + 0, "提交取货工单", 1, "取货", 2, "入库", 3, "取消", + 4, "审核通过", 5, "再提交", 6, "审核驳回"); + private static final Map OPERATION_TYPE_TEXT = Map.of( + 0, "提交取货工单", 1, "取货", 2, "入库", 3, "取消", + 4, "部门内审核通过", 5, "部门内审核驳回", 6, "取货驳回"); + private static final Map LOGISTICS_STATE_TEXT = Map.of( + 0, "已出单", 1, "已出库", 2, "已发货", 3, "已揽件", + 4, "运输中", 5, "待取件", 6, "派送中", 7, "已签收"); + private static final Map DOCUMENT_TYPE_TEXT = Map.of( + 1, "出库单", 2, "入库单", 3, "调拨单", 4, "划转单"); + + final PickupOrderMapper pickupOrderMapper; + final PickupOrderDetailMapper detailMapper; + final PickupOrderProcessMapper processMapper; + final MallOrderLogisticsMapper logisticsMapper; + final MallLogisticsPicMapper shippingPicMapper; + final WarehouseInOutPicMapper receiptPicMapper; + final InstallToOutWarehouseMapper relationMapper; + final PermissionService permissionService; + + public PickupOrderTools(PickupOrderMapper pickupOrderMapper, + PickupOrderDetailMapper detailMapper, + PickupOrderProcessMapper processMapper, + MallOrderLogisticsMapper logisticsMapper, + MallLogisticsPicMapper shippingPicMapper, + WarehouseInOutPicMapper receiptPicMapper, + InstallToOutWarehouseMapper relationMapper, + PermissionService permissionService) { + this.pickupOrderMapper = pickupOrderMapper; + this.detailMapper = detailMapper; + this.processMapper = processMapper; + this.logisticsMapper = logisticsMapper; + this.shippingPicMapper = shippingPicMapper; + this.receiptPicMapper = receiptPicMapper; + this.relationMapper = relationMapper; + this.permissionService = permissionService; + } + + @XqTool(category = "workorder", kind = "read") + @Tool(name = "pickup.order.list.query", description = "分页查询取货工单,对齐老系统 /pickup/pickupListByPage。" + + "总部可看全部;代理身份可看自己、直属下级、代管代理/物业及本人参与流程的工单;销售非负责人按其物业范围过滤。" + + "申请日期使用 apply_time,申请、取货、完成日期可同时过滤,结束日期含当天。" + + "type:0装机发起、1人工发起、2撤机发起;orderState:-2被驳回、-1待审核、0待取货、1待入库、2已完成、3已取消。") + public Map pickupOrderListQuery( + @ToolParam(required = false, description = "取货工单号,模糊匹配") String pickupOrderNo, + @ToolParam(required = false, description = "装机工单号,模糊匹配") String installOrderNo, + @ToolParam(required = false, description = "撤机工单号,模糊匹配") String withdrawOrderNo, + @ToolParam(required = false, description = "所属代理商 id") Long agentId, + @ToolParam(required = false, description = "站点名称,模糊匹配") String districtName, + @ToolParam(required = false, description = "来源类型:0装机发起 1人工发起 2撤机发起") Integer type, + @ToolParam(required = false, description = "状态:-2被驳回 -1待审核 0待取货 1待入库 2已完成 3已取消") Integer orderState, + @ToolParam(required = false, description = "取货方式:0快递公司 1自行派送") Integer sendMode, + @ToolParam(required = false, description = "申请人,模糊匹配") String applyUserName, + @ToolParam(required = false, description = "申请日期起,yyyy-MM-dd") String applyDateFrom, + @ToolParam(required = false, description = "申请日期止,yyyy-MM-dd,含当天") String applyDateTo, + @ToolParam(required = false, description = "取货日期起,yyyy-MM-dd") String pickupDateFrom, + @ToolParam(required = false, description = "取货日期止,yyyy-MM-dd,含当天") String pickupDateTo, + @ToolParam(required = false, description = "完成日期起,yyyy-MM-dd") String finishDateFrom, + @ToolParam(required = false, description = "完成日期止,yyyy-MM-dd,含当天") String finishDateTo, + @ToolParam(required = false, description = "页码,从1开始,默认1") Integer page, + @ToolParam(required = false, description = "每页条数,默认20,上限100") Integer rows) { + validateCode(type, Set.of(0, 1, 2), "type"); + validateCode(orderState, Set.of(-2, -1, 0, 1, 2, 3), "orderState"); + validateCode(sendMode, Set.of(0, 1), "sendMode"); + LocalDate applyFrom = parseDate(applyDateFrom, "applyDateFrom"); + LocalDate applyTo = parseDate(applyDateTo, "applyDateTo"); + LocalDate pickupFrom = parseDate(pickupDateFrom, "pickupDateFrom"); + LocalDate pickupTo = parseDate(pickupDateTo, "pickupDateTo"); + LocalDate finishFrom = parseDate(finishDateFrom, "finishDateFrom"); + LocalDate finishTo = parseDate(finishDateTo, "finishDateTo"); + validateRange(applyFrom, applyTo, "applyDateFrom", "applyDateTo"); + validateRange(pickupFrom, pickupTo, "pickupDateFrom", "pickupDateTo"); + validateRange(finishFrom, finishTo, "finishDateFrom", "finishDateTo"); + + int pageNo = page == null || page < 1 ? 1 : page; + boolean truncated = rows != null && rows > MAX_ROWS; + int pageSize = rows == null || rows < 1 ? DEFAULT_ROWS : Math.min(rows, MAX_ROWS); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(PickupOrder::getSoftDelete, 0) + .eq(agentId != null, PickupOrder::getAgentId, agentId) + .eq(type != null, PickupOrder::getType, type) + .eq(orderState != null, PickupOrder::getOrderState, orderState) + .eq(sendMode != null, PickupOrder::getSendMode, sendMode) + .ge(applyFrom != null, PickupOrder::getApplyTime, startOfDay(applyFrom)) + .lt(applyTo != null, PickupOrder::getApplyTime, nextDayStart(applyTo)) + .ge(pickupFrom != null, PickupOrder::getPickupDate, startOfDay(pickupFrom)) + .lt(pickupTo != null, PickupOrder::getPickupDate, nextDayStart(pickupTo)) + .ge(finishFrom != null, PickupOrder::getFinishDate, startOfDay(finishFrom)) + .lt(finishTo != null, PickupOrder::getFinishDate, nextDayStart(finishTo)); + like(wrapper, PickupOrder::getPickupOrderNo, pickupOrderNo); + like(wrapper, PickupOrder::getInstallOrderNo, installOrderNo); + like(wrapper, PickupOrder::getWithdrawOrderNo, withdrawOrderNo); + like(wrapper, PickupOrder::getDistrictName, districtName); + like(wrapper, PickupOrder::getApplyUserName, applyUserName); + permissionService.applyPickupOrderRowFilter(wrapper, currentUser()); + + long total = pickupOrderMapper.selectCount(wrapper); + List orders = pickupOrderMapper.selectList(wrapper + .orderByDesc(PickupOrder::getId) + .last("LIMIT " + (long) (pageNo - 1) * pageSize + "," + pageSize)); + Map logisticsByOrder = logisticsByOrder(orders); + + Map result = new LinkedHashMap<>(); + result.put("total", total); + result.put("page", pageNo); + result.put("rows", pageSize); + if (truncated) { + result.put("notice", "rows 超过上限 100,已截断为 100"); + } + result.put("records", orders.stream() + .map(order -> orderRecord(order, logisticsByOrder.get(order.getPickupOrderNo()))) + .collect(Collectors.toList())); + return result; + } + + @XqTool(category = "workorder", kind = "read") + @Tool(name = "pickup.order.detail.query", description = "按 id 或取货工单号查询取货工单详情。" + + "行级权限与 pickup.order.list.query 完全一致;不存在与无权查看统一返回 NOT_FOUND_OR_FORBIDDEN。" + + "返回主单、物流、原取货物品、取货后 SN 调整、发货附件、已处理流程及凭证、关联出入库/调拨/划转单。附件仅返回 bucket 与 objectKey。") + public Map pickupOrderDetailQuery( + @ToolParam(required = false, description = "取货工单主键 id,与 pickupOrderNo 至少传一个") Long id, + @ToolParam(required = false, description = "取货工单号,与 id 至少传一个") String pickupOrderNo) { + if ((id == null || id <= 0) && !StringUtils.hasText(pickupOrderNo)) { + throw new IllegalArgumentException("id、pickupOrderNo 至少传一个"); + } + if (id != null && id <= 0) { + throw new IllegalArgumentException("id 必须为正整数"); + } + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(PickupOrder::getSoftDelete, 0) + .eq(id != null, PickupOrder::getId, id) + .eq(StringUtils.hasText(pickupOrderNo), PickupOrder::getPickupOrderNo, + StringUtils.hasText(pickupOrderNo) ? pickupOrderNo.trim() : pickupOrderNo); + permissionService.applyPickupOrderRowFilter(wrapper, currentUser()); + List visible = pickupOrderMapper.selectList( + wrapper.orderByDesc(PickupOrder::getId).last("LIMIT 1")); + if (visible.isEmpty()) { + throw new ToolCallException(McpErrorCode.NOT_FOUND_OR_FORBIDDEN, + "取货工单不存在或当前身份无权查看"); + } + PickupOrder order = visible.get(0); + String orderNo = order.getPickupOrderNo(); + List details = detailMapper.selectList( + new LambdaQueryWrapper() + .eq(PickupOrderDetail::getSoftDelete, 0) + .eq(PickupOrderDetail::getPickupOrderNo, orderNo) + .orderByAsc(PickupOrderDetail::getId)); + List processes = processMapper.selectList( + new LambdaQueryWrapper() + .eq(PickupOrderProcess::getSoftDelete, 0) + .eq(PickupOrderProcess::getPickupOrderNo, orderNo) + .eq(PickupOrderProcess::getScheduleState, 2) + .orderByAsc(PickupOrderProcess::getOperationTime) + .orderByAsc(PickupOrderProcess::getId)); + Map> receiptFiles = receiptFiles(processes); + List relations = relationMapper.selectList( + new LambdaQueryWrapper() + .eq(InstallToOutWarehouse::getSoftDelete, 0) + .eq(InstallToOutWarehouse::getPickupOrderNo, orderNo) + .orderByAsc(InstallToOutWarehouse::getId)); + + Map result = new LinkedHashMap<>(); + result.put("record", orderRecord(order, null)); + result.put("logistics", logisticsRecord(orderNo)); + result.put("pickupGoods", details.stream().filter(d -> Integer.valueOf(0).equals(d.getType()) + || Integer.valueOf(2).equals(d.getType())).map(this::detailRecord).collect(Collectors.toList())); + result.put("serialChanges", details.stream().filter(d -> Integer.valueOf(1).equals(d.getType())) + .map(this::detailRecord).collect(Collectors.toList())); + result.put("shippingFiles", shippingFiles(orderNo)); + result.put("processes", processes.stream() + .map(process -> processRecord(process, receiptFiles.getOrDefault(process.getId(), List.of()))) + .collect(Collectors.toList())); + result.put("receiptFiles", receiptFiles.entrySet().stream() + .flatMap(entry -> entry.getValue().stream().map(pic -> { + Map file = fileRecord(pic.getId(), pic.getFileName(), + pic.getOssbucketname(), pic.getOssfolder()); + file.put("processId", entry.getKey()); + return file; + })).collect(Collectors.toList())); + result.put("relatedDocuments", relations.stream().map(this::relationRecord).collect(Collectors.toList())); + result.put("relatedInstallOrderNo", order.getInstallOrderNo()); + result.put("relatedWithdrawOrderNo", order.getWithdrawOrderNo()); + result.put("maintainWorkNo", order.getMaintainWorkNo()); + return result; + } + + private Map logisticsByOrder(List orders) { + if (orders.isEmpty()) { + return Map.of(); + } + List orderNos = orders.stream().map(PickupOrder::getPickupOrderNo) + .filter(StringUtils::hasText).distinct().collect(Collectors.toList()); + if (orderNos.isEmpty()) { + return Map.of(); + } + return logisticsMapper.selectList(new LambdaQueryWrapper() + .eq(MallOrderLogistics::getSoftDelete, 0) + .in(MallOrderLogistics::getOrderNo, orderNos) + .orderByDesc(MallOrderLogistics::getId)).stream() + .collect(Collectors.toMap(MallOrderLogistics::getOrderNo, Function.identity(), + (newest, ignored) -> newest, LinkedHashMap::new)); + } + + private Map orderRecord(PickupOrder order, MallOrderLogistics logistics) { + Map record = new LinkedHashMap<>(); + record.put("id", order.getId()); + record.put("pickupOrderNo", order.getPickupOrderNo()); + record.put("installOrderNo", order.getInstallOrderNo()); + record.put("withdrawOrderNo", order.getWithdrawOrderNo()); + record.put("maintainWorkNo", order.getMaintainWorkNo()); + record.put("type", order.getType()); + record.put("typeText", text(TYPE_TEXT, order.getType())); + record.put("applyAgentId", order.getApplyAgentId()); + record.put("applyAgentName", order.getApplyAgentName()); + record.put("agentId", order.getAgentId()); + record.put("agentName", order.getAgentName()); + record.put("propertyId", order.getPropertyId()); + record.put("propertyName", order.getPropertyName()); + record.put("districtId", order.getDistrictId()); + record.put("districtName", order.getDistrictName()); + record.put("orderState", order.getOrderState()); + record.put("orderStateText", text(ORDER_STATE_TEXT, order.getOrderState())); + record.put("sendMode", order.getSendMode()); + record.put("sendModeText", text(SEND_MODE_TEXT, order.getSendMode())); + record.put("applyUserId", order.getApplyUserId()); + record.put("applyUserName", order.getApplyUserName()); + record.put("applyTime", formatTime(order.getApplyTime())); + record.put("prePickupDate", order.getPrePickupDate()); + record.put("pickupDate", formatTime(order.getPickupDate())); + record.put("finishDate", formatTime(order.getFinishDate())); + record.put("pickupUserName", order.getPickupUserName()); + record.put("inWarehouseUserName", order.getInWarehouseUserName()); + record.put("scheduleUserName", order.getScheduleUserName()); + record.put("createTime", formatTime(order.getCreateTime())); + record.put("modifyTime", formatTime(order.getModifyTime())); + if (logistics != null) { + record.put("logisticsName", logistics.getLogisticsName()); + record.put("logisticsOrderNo", logistics.getLogisticsOrderNo()); + record.put("deliverName", logistics.getDeliverName()); + record.put("deliverNumber", logistics.getDeliverNumber()); + } + return record; + } + + private Map logisticsRecord(String orderNo) { + List rows = logisticsMapper.selectList( + new LambdaQueryWrapper() + .eq(MallOrderLogistics::getSoftDelete, 0) + .eq(MallOrderLogistics::getOrderNo, orderNo) + .orderByDesc(MallOrderLogistics::getId).last("LIMIT 1")); + if (rows.isEmpty()) { + return null; + } + MallOrderLogistics logistics = rows.get(0); + Map record = new LinkedHashMap<>(); + record.put("sendMode", logistics.getSendMode()); + record.put("sendModeText", text(SEND_MODE_TEXT, logistics.getSendMode())); + record.put("logisticsId", logistics.getLogisticsId()); + record.put("logisticsNo", logistics.getLogisticsNo()); + record.put("logisticsName", logistics.getLogisticsName()); + record.put("logisticsOrderNo", logistics.getLogisticsOrderNo()); + record.put("deliverName", logistics.getDeliverName()); + record.put("deliverNumber", logistics.getDeliverNumber()); + record.put("state", logistics.getState()); + record.put("stateText", text(LOGISTICS_STATE_TEXT, logistics.getState())); + record.put("sendTime", formatTime(logistics.getModifyTime())); + return record; + } + + private Map detailRecord(PickupOrderDetail detail) { + Map record = new LinkedHashMap<>(); + record.put("id", detail.getId()); + record.put("type", detail.getType()); + record.put("materielDefinitionId", detail.getMaterielDefinitionId()); + record.put("materialNo", detail.getMaterialNo()); + record.put("materielFullName", detail.getMaterielFullName()); + record.put("materielSort", detail.getMaterielSort()); + record.put("productSort", detail.getProductSort()); + record.put("materielIndexId", detail.getMaterielIndexId()); + record.put("specifications", detail.getSpecifications()); + record.put("snCode", detail.getSnCode()); + record.put("inCount", detail.getInCount()); + record.put("inWarehouseId", detail.getInWarehouseId()); + record.put("inWarehouseName", detail.getInWarehouseName()); + return record; + } + + private List> shippingFiles(String orderNo) { + return shippingPicMapper.selectList(new LambdaQueryWrapper() + .eq(MallLogisticsPic::getSoftDelete, 0) + .eq(MallLogisticsPic::getState, 0) + .eq(MallLogisticsPic::getOrderNo, orderNo) + .orderByAsc(MallLogisticsPic::getId)).stream() + .map(pic -> fileRecord(pic.getId(), pic.getFileName(), pic.getOssbucketname(), pic.getOssfolder())) + .collect(Collectors.toList()); + } + + private Map> receiptFiles(List processes) { + if (processes.isEmpty()) { + return Map.of(); + } + List ids = processes.stream().map(PickupOrderProcess::getId).collect(Collectors.toList()); + return receiptPicMapper.selectList(new LambdaQueryWrapper() + .eq(WarehouseInOutPic::getSoftDelete, 0) + .in(WarehouseInOutPic::getAggregateId, ids) + .eq(WarehouseInOutPic::getFileType, RECEIPT_FILE_TYPE) + .eq(WarehouseInOutPic::getInOutAllocationType, PICKUP_AGGREGATE_TYPE) + .orderByAsc(WarehouseInOutPic::getId)).stream() + .collect(Collectors.groupingBy(WarehouseInOutPic::getAggregateId, + LinkedHashMap::new, Collectors.toList())); + } + + private Map processRecord(PickupOrderProcess process, List files) { + Map record = new LinkedHashMap<>(); + record.put("id", process.getId()); + record.put("userId", process.getUserId()); + record.put("userName", process.getUserName()); + record.put("operationTime", formatTime(process.getOperationTime())); + record.put("scheduleState", process.getScheduleState()); + record.put("scheduleStateText", text(SCHEDULE_STATE_TEXT, process.getScheduleState())); + record.put("auditState", process.getAuditState()); + record.put("operationTheme", process.getOperationTheme()); + record.put("operationThemeText", text(OPERATION_THEME_TEXT, process.getOperationTheme())); + record.put("operationType", process.getOperationType()); + record.put("operationTypeText", text(OPERATION_TYPE_TEXT, process.getOperationType())); + record.put("memo", process.getMemo()); + record.put("files", files.stream().map(pic -> fileRecord(pic.getId(), pic.getFileName(), + pic.getOssbucketname(), pic.getOssfolder())).collect(Collectors.toList())); + return record; + } + + private Map relationRecord(InstallToOutWarehouse relation) { + Map record = new LinkedHashMap<>(); + record.put("id", relation.getId()); + record.put("documentType", relation.getOutWarehouseType()); + record.put("documentTypeText", text(DOCUMENT_TYPE_TEXT, relation.getOutWarehouseType())); + record.put("documentNo", relation.getOrderNo()); + return record; + } + + private static Map fileRecord(Long id, String fileName, String bucket, String folder) { + Map record = new LinkedHashMap<>(); + record.put("id", id); + record.put("fileName", fileName); + record.put("bucket", bucket); + record.put("objectKey", nullToEmpty(folder) + nullToEmpty(fileName)); + return record; + } + + private static User currentUser() { + return AuthContextHolder.get().getUser(); + } + + private static void like(LambdaQueryWrapper wrapper, + SFunction column, String value) { + if (StringUtils.hasText(value)) { + wrapper.like(column, value.trim()); + } + } + + private static void validateCode(Integer value, Set accepted, String name) { + if (value != null && !accepted.contains(value)) { + throw new IllegalArgumentException(name + " 取值不合法"); + } + } + + private static LocalDate parseDate(String value, String name) { + if (!StringUtils.hasText(value)) { + return null; + } + try { + return LocalDate.parse(value.trim(), DATE_FORMATTER); + } catch (DateTimeParseException e) { + throw new IllegalArgumentException(name + " 格式必须为 yyyy-MM-dd"); + } + } + + private static Date startOfDay(LocalDate date) { + return date == null ? null : Date.from(date.atStartOfDay(BUSINESS_ZONE).toInstant()); + } + + private static Date nextDayStart(LocalDate date) { + return date == null ? null : Date.from(date.plusDays(1).atStartOfDay(BUSINESS_ZONE).toInstant()); + } + + private static void validateRange(LocalDate from, LocalDate to, String fromName, String toName) { + if (from != null && to != null && from.isAfter(to)) { + throw new IllegalArgumentException(fromName + " 不能晚于 " + toName); + } + } + + private static String text(Map values, Integer code) { + return code == null ? null : values.getOrDefault(code, "未知"); + } + + private static String formatTime(Date value) { + return value == null ? null : TIME_FORMATTER.format(value.toInstant()); + } + + private static String nullToEmpty(String value) { + return value == null ? "" : value; + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/permission/PermissionService.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/permission/PermissionService.java index 9dabfda..a1c1193 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/permission/PermissionService.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/permission/PermissionService.java @@ -12,6 +12,7 @@ import me.iiv.agent.entity.m1.MallGoodsOrder; import me.iiv.agent.entity.m2.ContractNew; import me.iiv.agent.entity.m2.DeliveryOrder; import me.iiv.agent.entity.m2.InstallOrder; +import me.iiv.agent.entity.m2.PickupOrder; import me.iiv.agent.entity.m2.WarehouseAgentAuth; import me.iiv.agent.entity.m2.WarehouseIn; import me.iiv.agent.entity.m2.WarehouseOut; @@ -222,6 +223,58 @@ public class PermissionService { }); } + /** + * 取货工单权限:总部看全部;销售非负责人按自己创建/代管物业;其余代理身份 + * 可看申请代理、所属代理、代管物业范围,以及本人参与过流程的工单。 + * 列表和详情复用本方法,防止详情接口绕过行权限。 + */ + public void applyPickupOrderRowFilter(LambdaQueryWrapper wrapper, User user) { + if (!isAgentAuthority(user.getAuthorityId())) { + return; + } + if (user.getAgentId() == null) { + throw new IllegalStateException("代理商用户未关联代理商,拒绝访问业务数据"); + } + List auths = managedAuths(user.getId()); + List agentIds = new ArrayList<>(); + agentIds.add(user.getAgentId()); + agentIds.addAll(auths.stream().map(WarehouseAgentAuth::getManagerAgentId) + .collect(Collectors.toList())); + String participantExists = "EXISTS (SELECT 1 FROM pickup_order_process pop " + + "WHERE pop.pickup_order_no = pickup_order.pickup_order_no " + + "AND pop.soft_delete = 0 AND pop.user_id = {0})"; + if (isSalesNonLeader(user)) { + List propertyIds = properytMapper.selectList(new LambdaQueryWrapper() + .eq(Properyt::getSoftDelete, 0) + .eq(Properyt::getCreateId, user.getId()) + .select(Properyt::getId)) + .stream().map(Properyt::getId).collect(Collectors.toList()); + List managedAgentIds = auths.stream().map(WarehouseAgentAuth::getManagerAgentId) + .collect(Collectors.toList()); + if (!managedAgentIds.isEmpty()) { + propertyIds.addAll(properytMapper.selectList(new LambdaQueryWrapper() + .eq(Properyt::getSoftDelete, 0) + .in(Properyt::getAgentId, managedAgentIds) + .select(Properyt::getId)) + .stream().map(Properyt::getId).collect(Collectors.toList())); + } + List visiblePropertyIds = propertyIds.isEmpty() + ? List.of(0L) : new ArrayList<>(new LinkedHashSet<>(propertyIds)); + wrapper.and(w -> w.in(PickupOrder::getPropertyId, visiblePropertyIds) + .or().apply(participantExists, user.getId())); + return; + } + List managedPropertyIds = managedPropertyIds(agentIds); + wrapper.and(w -> { + w.in(PickupOrder::getApplyAgentId, agentIds) + .or().in(PickupOrder::getAgentId, agentIds); + if (!managedPropertyIds.isEmpty()) { + w.or().in(PickupOrder::getPropertyId, managedPropertyIds); + } + w.or().apply(participantExists, user.getId()); + }); + } + /** * 装机工单权限,1:1 对齐老 getWorkOrderAuth(type=0) 的有效业务口径:总部看全部; * 销售非负责人只看自己创建物业及授权代管公司的物业;其他代理用户可看自己、 diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/PickupOrderToolsTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/PickupOrderToolsTest.java new file mode 100644 index 0000000..6ba2c71 --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/PickupOrderToolsTest.java @@ -0,0 +1,238 @@ +package me.iiv.agent.mcp.tool; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import me.iiv.agent.entity.m0.User; +import me.iiv.agent.entity.m1.MallLogisticsPic; +import me.iiv.agent.entity.m1.MallOrderLogistics; +import me.iiv.agent.entity.m2.InstallToOutWarehouse; +import me.iiv.agent.entity.m2.PickupOrder; +import me.iiv.agent.entity.m2.PickupOrderDetail; +import me.iiv.agent.entity.m2.PickupOrderProcess; +import me.iiv.agent.entity.m2.WarehouseInOutPic; +import me.iiv.agent.mapper.m1.MallLogisticsPicMapper; +import me.iiv.agent.mapper.m1.MallOrderLogisticsMapper; +import me.iiv.agent.mapper.m2.InstallToOutWarehouseMapper; +import me.iiv.agent.mapper.m2.PickupOrderDetailMapper; +import me.iiv.agent.mapper.m2.PickupOrderMapper; +import me.iiv.agent.mapper.m2.PickupOrderProcessMapper; +import me.iiv.agent.mapper.m2.WarehouseInOutPicMapper; +import me.iiv.agent.mcp.McpErrorCode; +import me.iiv.agent.mcp.ToolCallException; +import me.iiv.agent.permission.PermissionService; +import me.iiv.agent.security.AuthContext; +import me.iiv.agent.security.AuthContextHolder; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PickupOrderToolsTest { + + @Mock PickupOrderMapper pickupOrderMapper; + @Mock PickupOrderDetailMapper detailMapper; + @Mock PickupOrderProcessMapper processMapper; + @Mock MallOrderLogisticsMapper logisticsMapper; + @Mock MallLogisticsPicMapper shippingPicMapper; + @Mock WarehouseInOutPicMapper receiptPicMapper; + @Mock InstallToOutWarehouseMapper relationMapper; + @Mock PermissionService permissionService; + + PickupOrderTools tools; + + @BeforeEach + void setUp() { + MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), ""); + TableInfoHelper.initTableInfo(assistant, PickupOrder.class); + TableInfoHelper.initTableInfo(assistant, PickupOrderDetail.class); + TableInfoHelper.initTableInfo(assistant, PickupOrderProcess.class); + TableInfoHelper.initTableInfo(assistant, MallOrderLogistics.class); + TableInfoHelper.initTableInfo(assistant, MallLogisticsPic.class); + TableInfoHelper.initTableInfo(assistant, WarehouseInOutPic.class); + TableInfoHelper.initTableInfo(assistant, InstallToOutWarehouse.class); + tools = new PickupOrderTools(pickupOrderMapper, detailMapper, processMapper, logisticsMapper, + shippingPicMapper, receiptPicMapper, relationMapper, permissionService); + User user = new User(); + user.setId(7L); + AuthContextHolder.set(new AuthContext(null, user)); + } + + @AfterEach + void tearDown() { + AuthContextHolder.clear(); + } + + @Test + void listReturnsTextsBatchLogisticsAndClampsRows() { + when(pickupOrderMapper.selectCount(any())).thenReturn(1L); + when(pickupOrderMapper.selectList(any())).thenReturn(List.of(order())); + MallOrderLogistics logistics = new MallOrderLogistics(); + logistics.setOrderNo("QH-001"); + logistics.setLogisticsName("快递公司"); + logistics.setLogisticsOrderNo("KD-001"); + when(logisticsMapper.selectList(any())).thenReturn(List.of(logistics)); + + Map result = tools.pickupOrderListQuery("QH", "AZ", "CX", 10L, "项目", + 0, 2, 0, "申请人", "2026-09-01", "2026-09-30", + "2026-09-01", "2026-09-30", "2026-09-01", "2026-09-30", 0, 101); + + assertEquals(1L, result.get("total")); + assertEquals(1, result.get("page")); + assertEquals(100, result.get("rows")); + assertEquals("rows 超过上限 100,已截断为 100", result.get("notice")); + @SuppressWarnings("unchecked") + Map record = ((List>) result.get("records")).get(0); + assertEquals("已完成", record.get("orderStateText")); + assertEquals("装机工单发起", record.get("typeText")); + assertEquals("快递公司", record.get("sendModeText")); + assertEquals("KD-001", record.get("logisticsOrderNo")); + verify(permissionService).applyPickupOrderRowFilter(any(), any()); + } + + @Test + void listRejectsInvalidCodesAndDateRangesBeforeQuery() { + assertThrows(IllegalArgumentException.class, () -> tools.pickupOrderListQuery(null, null, null, + null, null, 9, null, null, null, null, null, null, null, null, null, null, null)); + assertThrows(IllegalArgumentException.class, () -> tools.pickupOrderListQuery(null, null, null, + null, null, null, null, null, null, "2026-09-30", "2026-09-01", + null, null, null, null, null, null)); + verifyNoInteractions(pickupOrderMapper, permissionService); + } + + @Test + void detailUsesSamePermissionAndHidesExistence() { + when(pickupOrderMapper.selectList(any())).thenReturn(List.of()); + + ToolCallException error = assertThrows(ToolCallException.class, + () -> tools.pickupOrderDetailQuery(20L, null)); + + assertEquals(McpErrorCode.NOT_FOUND_OR_FORBIDDEN, error.getCode()); + verify(permissionService).applyPickupOrderRowFilter(any(), any()); + verifyNoInteractions(detailMapper, processMapper, logisticsMapper, shippingPicMapper); + } + + @Test + void detailSplitsGoodsAndSerialChangesAndReturnsFilesProcessesRelations() { + when(pickupOrderMapper.selectList(any())).thenReturn(List.of(order())); + PickupOrderDetail goods = new PickupOrderDetail(); + goods.setId(3L); + goods.setType(0); + goods.setMaterialNo("MAT-1"); + PickupOrderDetail serial = new PickupOrderDetail(); + serial.setId(4L); + serial.setType(1); + serial.setSnCode("SN-1"); + when(detailMapper.selectList(any())).thenReturn(List.of(goods, serial)); + + PickupOrderProcess process = new PickupOrderProcess(); + process.setId(5L); + process.setScheduleState(2); + process.setOperationTheme(2); + process.setOperationType(2); + when(processMapper.selectList(any())).thenReturn(List.of(process)); + WarehouseInOutPic receipt = new WarehouseInOutPic(); + receipt.setId(6L); + receipt.setAggregateId(5L); + receipt.setFileName("receipt.jpg"); + receipt.setOssbucketname("bucket-a"); + receipt.setOssfolder("receipt/"); + when(receiptPicMapper.selectList(any())).thenReturn(List.of(receipt)); + + InstallToOutWarehouse relation = new InstallToOutWarehouse(); + relation.setId(7L); + relation.setOutWarehouseType(2); + relation.setOrderNo("RK-1"); + when(relationMapper.selectList(any())).thenReturn(List.of(relation)); + MallOrderLogistics logistics = new MallOrderLogistics(); + logistics.setSendMode(0); + logistics.setState(7); + when(logisticsMapper.selectList(any())).thenReturn(List.of(logistics)); + MallLogisticsPic shipping = new MallLogisticsPic(); + shipping.setId(8L); + shipping.setFileName("ship.jpg"); + shipping.setOssbucketname("bucket-b"); + shipping.setOssfolder("shipping/"); + when(shippingPicMapper.selectList(any())).thenReturn(List.of(shipping)); + + Map result = tools.pickupOrderDetailQuery(20L, "QH-001"); + + assertEquals(1, ((List) result.get("pickupGoods")).size()); + assertEquals(1, ((List) result.get("serialChanges")).size()); + @SuppressWarnings("unchecked") + Map logisticsResult = (Map) result.get("logistics"); + assertEquals("已签收", logisticsResult.get("stateText")); + @SuppressWarnings("unchecked") + List> shippingFiles = (List>) result.get("shippingFiles"); + assertEquals("shipping/ship.jpg", shippingFiles.get(0).get("objectKey")); + @SuppressWarnings("unchecked") + List> processes = (List>) result.get("processes"); + assertEquals("入库", processes.get(0).get("operationThemeText")); + assertEquals(1, ((List) processes.get(0).get("files")).size()); + @SuppressWarnings("unchecked") + List> receiptFiles = (List>) result.get("receiptFiles"); + assertEquals(5L, receiptFiles.get(0).get("processId")); + @SuppressWarnings("unchecked") + List> documents = (List>) result.get("relatedDocuments"); + assertEquals("入库单", documents.get(0).get("documentTypeText")); + assertEquals("AZ-001", result.get("relatedInstallOrderNo")); + assertEquals("CX-001", result.get("relatedWithdrawOrderNo")); + } + + @Test + void detailReturnsNullLogisticsWhenNoRecordExists() { + when(pickupOrderMapper.selectList(any())).thenReturn(List.of(order())); + when(detailMapper.selectList(any())).thenReturn(List.of()); + when(processMapper.selectList(any())).thenReturn(List.of()); + when(relationMapper.selectList(any())).thenReturn(List.of()); + when(logisticsMapper.selectList(any())).thenReturn(List.of()); + when(shippingPicMapper.selectList(any())).thenReturn(List.of()); + + Map result = tools.pickupOrderDetailQuery(20L, null); + + assertNull(result.get("logistics")); + assertEquals(List.of(), result.get("processes")); + verifyNoInteractions(receiptPicMapper); + } + + @Test + void springAiPublishesBothSchemas() { + var callbacks = org.springframework.ai.tool.method.MethodToolCallbackProvider.builder() + .toolObjects(tools).build().getToolCallbacks(); + Map schemas = Arrays.stream(callbacks).collect(java.util.stream.Collectors.toMap( + callback -> callback.getToolDefinition().name(), + callback -> callback.getToolDefinition().inputSchema())); + + assertEquals(2, schemas.size()); + assertTrue(schemas.get("pickup.order.list.query").contains("pickupDateTo")); + assertTrue(schemas.get("pickup.order.detail.query").contains("pickupOrderNo")); + } + + private static PickupOrder order() { + PickupOrder order = new PickupOrder(); + order.setId(20L); + order.setPickupOrderNo("QH-001"); + order.setInstallOrderNo("AZ-001"); + order.setWithdrawOrderNo("CX-001"); + order.setType(0); + order.setOrderState(2); + order.setSendMode(0); + return order; + } +} diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServicePickupOrderRowFilterTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServicePickupOrderRowFilterTest.java new file mode 100644 index 0000000..25f2d40 --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServicePickupOrderRowFilterTest.java @@ -0,0 +1,106 @@ +package me.iiv.agent.permission; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import me.iiv.agent.entity.m0.AgentManageDistrictAuth; +import me.iiv.agent.entity.m0.Authority; +import me.iiv.agent.entity.m0.User; +import me.iiv.agent.entity.m2.PickupOrder; +import me.iiv.agent.entity.m2.WarehouseAgentAuth; +import me.iiv.agent.mapper.m0.AgentManageDistrictAuthMapper; +import me.iiv.agent.mapper.m0.AgentMapper; +import me.iiv.agent.mapper.m0.AuthorityMapper; +import me.iiv.agent.mapper.m0.ProperytMapper; +import me.iiv.agent.mapper.m2.WarehouseAgentAuthMapper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.StringRedisTemplate; + +import java.util.Collection; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PermissionServicePickupOrderRowFilterTest { + + @Mock AuthorityMapper authorityMapper; + @Mock AgentMapper agentMapper; + @Mock WarehouseAgentAuthMapper warehouseAgentAuthMapper; + @Mock AgentManageDistrictAuthMapper agentManageDistrictAuthMapper; + @Mock ProperytMapper properytMapper; + @Mock StringRedisTemplate redisTemplate; + + PermissionService service; + + @BeforeEach + void setUp() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), PickupOrder.class); + service = new PermissionService(authorityMapper, agentMapper, warehouseAgentAuthMapper, + agentManageDistrictAuthMapper, properytMapper, redisTemplate); + } + + @Test + void headquartersAddsNoFilter() { + when(authorityMapper.selectById(9L)).thenReturn(authority(0)); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + service.applyPickupOrderRowFilter(wrapper, user(7L, 9L, null)); + + assertTrue(wrapper.getSqlSegment().isBlank()); + } + + @Test + void agentCombinesApplyAgentOwnerManagedPropertyAndParticipant() { + when(authorityMapper.selectById(9L)).thenReturn(authority(1)); + WarehouseAgentAuth managed = new WarehouseAgentAuth(); + managed.setManagerAgentId(12L); + when(warehouseAgentAuthMapper.selectList(any())).thenReturn(List.of(managed)); + AgentManageDistrictAuth property = new AgentManageDistrictAuth(); + property.setPropertyId(30L); + when(agentManageDistrictAuthMapper.selectList(any())).thenReturn(List.of(property)); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + service.applyPickupOrderRowFilter(wrapper, user(7L, 9L, 10L)); + + String sql = wrapper.getSqlSegment(); + Collection values = wrapper.getParamNameValuePairs().values(); + assertTrue(sql.contains("apply_agent_id IN")); + assertTrue(sql.contains("agent_id IN")); + assertTrue(sql.contains("property_id IN")); + assertTrue(sql.contains("pickup_order_process")); + assertTrue(values.containsAll(List.of(7L, 10L, 12L, 30L))); + } + + @Test + void agentWithoutAgentFailsClosed() { + when(authorityMapper.selectById(9L)).thenReturn(authority(1)); + + assertThrows(IllegalStateException.class, () -> service.applyPickupOrderRowFilter( + new LambdaQueryWrapper<>(), user(7L, 9L, null))); + } + + private static User user(Long id, Long authorityId, Long agentId) { + User user = new User(); + user.setId(id); + user.setAuthorityId(authorityId); + user.setAgentId(agentId); + return user; + } + + private static Authority authority(int isAgent) { + Authority authority = new Authority(); + authority.setId(9L); + authority.setIsagent(isAgent); + authority.setSoftDelete(0); + return authority; + } +}