diff --git a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md index 20c7d60..86d96b9 100644 --- a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md +++ b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md @@ -707,6 +707,34 @@ 详细设计见 `relocation-order-mcp-tools-design.md`。 +## 28. withdraw.order.list.query + +分页查询撤机工单,对应旧系统 `POST /withdrawOrder/pageList`。 + +- 分类:workorder | 类型:读 +- 数据来源:m2 `withdraw_order`。 +- 入参:`type`、`orderState`、`withdrawOrderNo`、`pickupOrderNo`、`agentId`、`districtName`、 + `applyUserName`、`maintenanceUserName`、`cooperateUserName`,申请/审核/调度/取货/完成日期范围及 `page/rows`。 +- 类型:0=部分撤机、1=全部撤机;状态:0=待审核、1=被驳回、2=调度中、3=作业中、4=待取回、5=已完成、6=已取消。 +- 日期格式为 `yyyy-MM-dd`,结束日期含当天;分页默认 1/20、上限 100,固定按 ID 倒序。 + 老系统 `finishDate` 过滤误生成不存在的 `finish_date` 列(查询报错),MCP 侧已修正映射到 `finish_time`。 +- 行级权限:总部看全部;代理身份按申请代理/所属代理(本代理+授权代管)、授权代管物业过滤, + 且本人出现在 `order_process(order_type=1)` 的工单可见;销售非负责人按自己创建及代管代理物业过滤。 + +## 29. withdraw.order.detail.query + +按 `id` 或 `withdrawOrderNo` 查询详情,两者至少传一个;同时传入时必须匹配同一条可见记录。 + +- 行级权限与列表完全一致(老系统 lookDetails 无权限校验,MCP 侧已补齐);不存在和无权统一返回 `NOT_FOUND_OR_FORBIDDEN`。 +- `record`:撤机主单(期望/实际撤机日期、撤机理由、联系人、项目地址等)及状态/类型文案。 +- `withdrawDetails`:`withdraw_order_details` 中 `in_count>0` 的实际取回物品明细。 +- `maintenanceUsers/cooperateUsers`:`install_order_user type=1` 的运维/协同人员。 +- `processes`:`order_type=1`、`schedule_state=2` 且非抄送(theme=12)的流程,按操作时间、ID 正序,主题/类型给 code+文案。 +- `certificates`:流程凭证附件,限定 `in_out_allocation_type=12`,仅返回 `id/fileName/bucket/objectKey`。 +- 撤机结单后自动生成取货工单(`pickup_order.type=2`),可用 `pickup.order.list.query` 的 `withdrawOrderNo` 参数串联查取货/入库进度。 + +详细设计见 `withdraw-order-mcp-tools-design.md`。 + --- ## 附录:在线查看工具清单 diff --git a/xiaoqu-agent-service/doc/withdraw-order-mcp-tools-design.md b/xiaoqu-agent-service/doc/withdraw-order-mcp-tools-design.md new file mode 100644 index 0000000..dbccb96 --- /dev/null +++ b/xiaoqu-agent-service/doc/withdraw-order-mcp-tools-design.md @@ -0,0 +1,105 @@ +# 撤机工单 MCP 工具设计(withdraw.order.list.query / withdraw.order.detail.query) + +- 需求:撤机工单查询工具(对齐老系统 `/withdrawOrder/pageList`、`/withdrawOrder/lookDetails`) +- 日期:2026-09-18 +- 状态:已实现,单测 281/281 通过 +- 分类:workorder | 类型:读 + +## 1. 老系统语义来源 + +老系统撤机模块(`WithdrawOrderController`,2025-09 新增): + +- 主表 `withdraw_order`(m2,单号 CJGD 前缀)+ `withdraw_order_details` +- 流程走**共享** `order_process`(`order_type=1` 区分撤机) +- 作业人员走共享 `install_order_user`(`type=1`) +- 状态机 `orderState`:0待审核 / 1被驳回 / 2调度中 / 3作业中 / 4待取回 / 5已完成 / 6已取消 +- `type`:0部分撤机 / 1全部撤机 +- 业务链路:申请 → 调度(通过→作业中)→ 结单 → 自动生成取货工单(`pickup_order.type=2`, + `withdraw_order_no` 回填)→ 取货入库自动生成入库单 + +### 已知老系统缺陷(MCP 侧修正) + +- `pageList` 按 `finishDateStart/End` 过滤时经 `beanToWrapper` 生成不存在的列 `finish_date`, + 实体列实为 `finish_time`——该过滤在老系统会直接 SQL 报错。MCP 侧显式映射到 `finish_time`。 +- `lookDetails` 详情接口**没有**权限校验(只按单号查)——MCP 侧补齐行级过滤(见 §4)。 +- 老系统列表对人名空值补 `-`,MCP 侧不补(保持 null,交由 agent 判断)。 + +## 2. 工具设计 + +### 2.1 withdraw.order.list.query + +入参全部可选,风格对齐 pickup/relocation: + +| 参数 | 说明 | +|---|---| +| type | 0部分撤机 / 1全部撤机(白名单校验) | +| orderState | 7 值白名单,非法拒绝 | +| withdrawOrderNo / pickupOrderNo | 撤机单号 / 关联取货单号,模糊 | +| agentId / districtName | 所属公司 id 精确 / 项目名称模糊 | +| applyUserName / maintenanceUserName / cooperateUserName | 申请人/运维/协同,模糊 | +| applyDateFrom/To ~ finishDateFrom/To | 5 组日期范围(申请/审核/调度/取货/完成),yyyy-MM-dd,结束含当天 | +| page / rows | 默认 1/20,rows>100 截断为 100 并返回 notice | + +返回:`total/page/rows/[notice]/records`,records 同时给 `orderState+orderStateText`、`type+typeText`; +排序固定 `id DESC`。 + +### 2.2 withdraw.order.detail.query + +入参 `(id, withdrawOrderNo)` 二选一;返回信息组: + +- `record`:主单全字段(期望/实际撤机日期、撤机理由 memo、联系人、项目地址等) +- `withdrawDetails`:`withdraw_order_details` 中 `in_count>0` 的行(实际取回明细) +- `maintenanceUsers / cooperateUsers`:来自 `install_order_user`(type=1,user_type 0/1) +- `processes`:`order_process`(order_type=1,schedule_state=2 已办、排除抄送 theme=12, + 按 operationTime+id 升序),主题/类型同时给 code+文案 +- `certificates`:凭证附件 `warehouse_in_out_pic`(in_out_allocation_type=12), + 输出 `{id, fileName, bucket, objectKey}` 三元组(不拼 URL,与现有 4 组工单一致),按流程 id 批量 IN 查询防 N+1 + +不存在或无权统一抛 `NOT_FOUND_OR_FORBIDDEN`(不泄露存在性)。 + +### 凭证口径说明(近似) + +老系统取货/入库凭证(theme 6/7)挂在 `pickup_order_process` 上(经取货单号二次查询), +MCP 侧简化为按本工单 `order_process` 流程 id 批量查 `warehouse_in_out_pic(in_out_allocation_type=12)`。 +两者 aggregate_id 语义不完全等价,若线上凭证挂接方式不同需再校准(可与 +`pickup.order.detail.query` 的 `withdrawOrderNo` 串联作为兜底)。 + +## 3. 与老系统字段映射 + +| 过滤维度 | 老系统参数 | MCP 实体列 | +|---|---|---| +| 申请日期 | applyDateStart/End | apply_time | +| 审核日期 | auditDateStart/End | audit_time | +| 调度日期 | dispatchDateStart/End | dispatch_time | +| 取货日期 | pickupDateStart/End | pickup_time | +| 完成日期 | finishDateStart/End | finish_time(老系统误生成 finish_date) | + +## 4. 行级权限 + +`PermissionService.applyWithdrawOrderRowFilter`,1:1 复刻老系统 `getWorkOrderAuth(type=3)`: + +- 总部(isagent=0)放行 +- 代理商:`apply_agent_id`/`agent_id` ∈ {本代理+授权代管} OR `property_id` ∈ 代管物业 + OR 本人出现在 `order_process`(order_type=1) +- 销售非负责人:只看自己创建物业 + 代管代理名下物业(property_id 过滤),空集塞 0 +- 列表与详情复用同一方法,防详情绕过 + +## 5. 实现清单 + +- 新增:`entity/m2/WithdrawOrder.java`、`entity/m2/WithdrawOrderDetails.java`、 + `mapper/m2/WithdrawOrderMapper.java`、`mapper/m2/WithdrawOrderDetailsMapper.java`、 + `mcp/tool/WithdrawOrderTools.java` +- 修改:`permission/PermissionService.java`(applyWithdrawOrderRowFilter) +- 测试:`WithdrawOrderToolsTest`(6 用例:参数组合/截断 notice/人名不补/非法参数查库前拒绝/ + 详情权限与存在性隐藏/详情组装/Spring AI schema 发布)、 + `PermissionServiceWithdrawOrderRowFilterTest`(4 身份) +- 文档:doc/agent-mcp-tools-reference.md §28/29、本文件(README 不逐工具列举,无需改) + +## 6. 上线检查单 + +- [x] 单测全量通过(281/281) +- [ ] PR 合入 main +- [ ] 远程部署(./deploy/deploy.sh remote-deploy) +- [ ] prod scope 1(code='test')幂等绑定 tool id(ToolSyncService 启动自动同步 tool 表后 + INSERT scope_tool,防 tools/list 不可见——prod 作用域是 code='test' 不是 operation) +- [ ] 线上 tools/list 冒烟(真实 AK,由用户确认) diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WithdrawOrder.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WithdrawOrder.java new file mode 100644 index 0000000..9a9926e --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WithdrawOrder.java @@ -0,0 +1,59 @@ +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("withdraw_order") +public class WithdrawOrder 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 withdrawOrderNo; + private Integer type; + private Long agentId; + private Long propertyId; + private Long districtId; + private String agentName; + private String propertyName; + private String districtName; + private Integer orderState; + private String preWithdrawDate; + private String trueWithdrawDate; + private String customerContact; + private String contactMobile; + private String projectAddress; + private Integer withdrawGoodsCount; + private String projectMemo; + private String memo; + private Long applyUserId; + private String applyUserName; + private Date applyTime; + private Date auditTime; + private Date dispatchTime; + private Date pickupTime; + private Date warehousingTime; + private Date finishTime; + private String auditUserName; + private String dispatchUserName; + private String maintenanceUserName; + private String cooperateUserName; + private String scheduleUserName; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WithdrawOrderDetails.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WithdrawOrderDetails.java new file mode 100644 index 0000000..b1c0f30 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WithdrawOrderDetails.java @@ -0,0 +1,40 @@ +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("withdraw_order_details") +public class WithdrawOrderDetails 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 withdrawOrderNo; + 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; + private String address; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WithdrawOrderDetailsMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WithdrawOrderDetailsMapper.java new file mode 100644 index 0000000..51c93fe --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WithdrawOrderDetailsMapper.java @@ -0,0 +1,7 @@ +package me.iiv.agent.mapper.m2; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import me.iiv.agent.entity.m2.WithdrawOrderDetails; + +public interface WithdrawOrderDetailsMapper extends BaseMapper { +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WithdrawOrderMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WithdrawOrderMapper.java new file mode 100644 index 0000000..a9fc6d6 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WithdrawOrderMapper.java @@ -0,0 +1,7 @@ +package me.iiv.agent.mapper.m2; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import me.iiv.agent.entity.m2.WithdrawOrder; + +public interface WithdrawOrderMapper extends BaseMapper { +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/WithdrawOrderTools.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/WithdrawOrderTools.java new file mode 100644 index 0000000..6ba48e7 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/WithdrawOrderTools.java @@ -0,0 +1,416 @@ +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.m2.InstallOrderUser; +import me.iiv.agent.entity.m2.OrderProcess; +import me.iiv.agent.entity.m2.WarehouseInOutPic; +import me.iiv.agent.entity.m2.WithdrawOrder; +import me.iiv.agent.entity.m2.WithdrawOrderDetails; +import me.iiv.agent.mapper.m2.InstallOrderUserMapper; +import me.iiv.agent.mapper.m2.OrderProcessMapper; +import me.iiv.agent.mapper.m2.WarehouseInOutPicMapper; +import me.iiv.agent.mapper.m2.WithdrawOrderDetailsMapper; +import me.iiv.agent.mapper.m2.WithdrawOrderMapper; +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.stream.Collectors; + +/** 撤机工单查询工具,对齐老系统 /withdrawOrder/pageList 与 /lookDetails。 */ +@Component +public class WithdrawOrderTools implements ToolObject { + + private static final int DEFAULT_ROWS = 20; + private static final int MAX_ROWS = 100; + private static final int WITHDRAW_ORDER_TYPE = 1; + private static final int WITHDRAW_USER_TYPE = 1; + 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( + 0, "待审核", 1, "被驳回", 2, "调度中", 3, "作业中", + 4, "待取回", 5, "已完成", 6, "已取消"); + private static final Map TYPE_TEXT = Map.of(0, "部分撤机", 1, "全部撤机"); + private static final Map SCHEDULE_STATE_TEXT = Map.of( + 0, "无需待办", 1, "待办", 2, "已办"); + private static final Map AUDIT_STATE_TEXT = Map.of(0, "审核驳回", 1, "审核通过"); + private static final Map OPERATION_THEME_TEXT = Map.ofEntries( + Map.entry(0, "撤机申请"), Map.entry(1, "审核通过"), Map.entry(2, "运营调度"), + Map.entry(3, "撤机协同"), Map.entry(4, "送货"), Map.entry(5, "收货确认"), + Map.entry(6, "取货"), Map.entry(7, "入库"), Map.entry(8, "结单"), + Map.entry(9, "驳回修改"), Map.entry(10, "审核驳回"), Map.entry(11, "已取消"), + Map.entry(12, "抄送"), Map.entry(13, "装机协同"), Map.entry(14, "取货结单")); + private static final Map OPERATION_TYPE_TEXT = Map.of( + 0, "审批流操作", 1, "运营调度", 2, "本人修改", 3, "驳回或修改操作", + 4, "配发协同", 5, "送货", 6, "收货确认", 7, "结单"); + + final WithdrawOrderMapper orderMapper; + final WithdrawOrderDetailsMapper detailsMapper; + final OrderProcessMapper processMapper; + final InstallOrderUserMapper userMapper; + final WarehouseInOutPicMapper picMapper; + final PermissionService permissionService; + + public WithdrawOrderTools(WithdrawOrderMapper orderMapper, + WithdrawOrderDetailsMapper detailsMapper, + OrderProcessMapper processMapper, + InstallOrderUserMapper userMapper, + WarehouseInOutPicMapper picMapper, + PermissionService permissionService) { + this.orderMapper = orderMapper; + this.detailsMapper = detailsMapper; + this.processMapper = processMapper; + this.userMapper = userMapper; + this.picMapper = picMapper; + this.permissionService = permissionService; + } + + @XqTool(category = "workorder", kind = "read") + @Tool(name = "withdraw.order.list.query", description = "分页查询当前用户可见的撤机工单,对齐老系统 /withdrawOrder/pageList。" + + "支持撤机类型、状态、撤机单号、取货单号、代理、项目、申请/运维/协同人员及申请/审核/调度/取货/完成日期范围。" + + "日期格式 yyyy-MM-dd,结束日期含当天;page 默认1,rows 默认20、上限100;固定按 id 倒序。" + + "人名字段为空时保持为空,不补占位符。") + public Map withdrawOrderListQuery( + @ToolParam(required = false, description = "撤机类型:0部分撤机 1全部撤机") Integer type, + @ToolParam(required = false, description = "状态:0待审核 1被驳回 2调度中 3作业中 4待取回 5已完成 6已取消") Integer orderState, + @ToolParam(required = false, description = "撤机工单号,模糊匹配") String withdrawOrderNo, + @ToolParam(required = false, description = "取货工单号,模糊匹配") String pickupOrderNo, + @ToolParam(required = false, description = "所属代理商 id,精确匹配") Long agentId, + @ToolParam(required = false, description = "项目名称,模糊匹配") String districtName, + @ToolParam(required = false, description = "申请人,模糊匹配") String applyUserName, + @ToolParam(required = false, description = "运维人员,模糊匹配") String maintenanceUserName, + @ToolParam(required = false, description = "协同人员,模糊匹配") String cooperateUserName, + @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 auditDateFrom, + @ToolParam(required = false, description = "审核日期止,yyyy-MM-dd,含当天") String auditDateTo, + @ToolParam(required = false, description = "调度日期起,yyyy-MM-dd") String dispatchDateFrom, + @ToolParam(required = false, description = "调度日期止,yyyy-MM-dd,含当天") String dispatchDateTo, + @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), "type"); + validateCode(orderState, Set.of(0, 1, 2, 3, 4, 5, 6), "orderState"); + LocalDate applyFrom = parseDate(applyDateFrom, "applyDateFrom"); + LocalDate applyTo = parseDate(applyDateTo, "applyDateTo"); + LocalDate auditFrom = parseDate(auditDateFrom, "auditDateFrom"); + LocalDate auditTo = parseDate(auditDateTo, "auditDateTo"); + LocalDate dispatchFrom = parseDate(dispatchDateFrom, "dispatchDateFrom"); + LocalDate dispatchTo = parseDate(dispatchDateTo, "dispatchDateTo"); + 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(auditFrom, auditTo, "auditDateFrom", "auditDateTo"); + validateRange(dispatchFrom, dispatchTo, "dispatchDateFrom", "dispatchDateTo"); + 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(WithdrawOrder::getSoftDelete, 0) + .eq(type != null, WithdrawOrder::getType, type) + .eq(orderState != null, WithdrawOrder::getOrderState, orderState) + .eq(agentId != null, WithdrawOrder::getAgentId, agentId) + // 老系统 finishDate 过滤误生成 finish_date 列(SQL 报错),MCP 侧映射到正确的 finish_time + .ge(applyFrom != null, WithdrawOrder::getApplyTime, startOfDay(applyFrom)) + .lt(applyTo != null, WithdrawOrder::getApplyTime, nextDayStart(applyTo)) + .ge(auditFrom != null, WithdrawOrder::getAuditTime, startOfDay(auditFrom)) + .lt(auditTo != null, WithdrawOrder::getAuditTime, nextDayStart(auditTo)) + .ge(dispatchFrom != null, WithdrawOrder::getDispatchTime, startOfDay(dispatchFrom)) + .lt(dispatchTo != null, WithdrawOrder::getDispatchTime, nextDayStart(dispatchTo)) + .ge(pickupFrom != null, WithdrawOrder::getPickupTime, startOfDay(pickupFrom)) + .lt(pickupTo != null, WithdrawOrder::getPickupTime, nextDayStart(pickupTo)) + .ge(finishFrom != null, WithdrawOrder::getFinishTime, startOfDay(finishFrom)) + .lt(finishTo != null, WithdrawOrder::getFinishTime, nextDayStart(finishTo)); + like(wrapper, WithdrawOrder::getWithdrawOrderNo, withdrawOrderNo); + like(wrapper, WithdrawOrder::getPickupOrderNo, pickupOrderNo); + like(wrapper, WithdrawOrder::getDistrictName, districtName); + like(wrapper, WithdrawOrder::getApplyUserName, applyUserName); + like(wrapper, WithdrawOrder::getMaintenanceUserName, maintenanceUserName); + like(wrapper, WithdrawOrder::getCooperateUserName, cooperateUserName); + permissionService.applyWithdrawOrderRowFilter(wrapper, currentUser()); + + long total = orderMapper.selectCount(wrapper); + List orders = orderMapper.selectList(wrapper.orderByDesc(WithdrawOrder::getId) + .last("LIMIT " + (long) (pageNo - 1) * pageSize + "," + pageSize)); + 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(this::orderRecord).collect(Collectors.toList())); + return result; + } + + @XqTool(category = "workorder", kind = "read") + @Tool(name = "withdraw.order.detail.query", description = "按 id 或撤机工单号查询详情。" + + "两者至少传一个,同时传入时必须匹配同一条记录;行级权限与列表一致。" + + "不存在或无权查看统一返回 NOT_FOUND_OR_FORBIDDEN。返回主单、撤机物品明细(in_count>0)、" + + "运维/协同人员、已办流程(共享 order_process,order_type=1,排除抄送)与凭证附件;附件仅返回 bucket/objectKey/fileName。") + public Map withdrawOrderDetailQuery( + @ToolParam(required = false, description = "撤机工单主键 id,与 withdrawOrderNo 至少传一个") Long id, + @ToolParam(required = false, description = "撤机工单号,与 id 至少传一个") String withdrawOrderNo) { + if ((id == null || id <= 0) && !StringUtils.hasText(withdrawOrderNo)) { + throw new IllegalArgumentException("id、withdrawOrderNo 至少传一个"); + } + if (id != null && id <= 0) { + throw new IllegalArgumentException("id 必须为正整数"); + } + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(WithdrawOrder::getSoftDelete, 0) + .eq(id != null, WithdrawOrder::getId, id) + .eq(StringUtils.hasText(withdrawOrderNo), WithdrawOrder::getWithdrawOrderNo, + StringUtils.hasText(withdrawOrderNo) ? withdrawOrderNo.trim() : withdrawOrderNo); + permissionService.applyWithdrawOrderRowFilter(wrapper, currentUser()); + List visible = orderMapper.selectList( + wrapper.orderByDesc(WithdrawOrder::getId).last("LIMIT 1")); + if (visible.isEmpty()) { + throw new ToolCallException(McpErrorCode.NOT_FOUND_OR_FORBIDDEN, + "撤机工单不存在或当前身份无权查看"); + } + + WithdrawOrder order = visible.get(0); + String orderNo = order.getWithdrawOrderNo(); + List details = detailsMapper.selectList( + new LambdaQueryWrapper() + .eq(WithdrawOrderDetails::getSoftDelete, 0) + .eq(WithdrawOrderDetails::getWithdrawOrderNo, orderNo) + .gt(WithdrawOrderDetails::getInCount, 0) + .orderByAsc(WithdrawOrderDetails::getId)); + List users = userMapper.selectList(new LambdaQueryWrapper() + .eq(InstallOrderUser::getSoftDelete, 0) + .eq(InstallOrderUser::getInstallOrderNo, orderNo) + .eq(InstallOrderUser::getType, WITHDRAW_USER_TYPE) + .orderByAsc(InstallOrderUser::getId)); + List processes = processMapper.selectList(new LambdaQueryWrapper() + .eq(OrderProcess::getSoftDelete, 0) + .eq(OrderProcess::getOrderNo, orderNo) + .eq(OrderProcess::getOrderType, WITHDRAW_ORDER_TYPE) + .eq(OrderProcess::getScheduleState, 2) + .ne(OrderProcess::getOperationTheme, 12) + .orderByAsc(OrderProcess::getOperationTime) + .orderByAsc(OrderProcess::getId)); + Map> processUsers = processUsers(processes); + List certificates = certificates(processes); + + Map result = new LinkedHashMap<>(); + result.put("record", orderRecord(order)); + result.put("withdrawDetails", details.stream().map(this::detailRecord).collect(Collectors.toList())); + result.put("maintenanceUsers", personnel(users, 0)); + result.put("cooperateUsers", personnel(users, 1)); + result.put("processes", processes.stream().map(process -> processRecord(process, + processUsers.getOrDefault(process.getId(), List.of()))).collect(Collectors.toList())); + result.put("certificates", certificates.stream().map(this::fileRecord) + .collect(Collectors.toList())); + return result; + } + + private Map> processUsers(List processes) { + if (processes.isEmpty()) return Map.of(); + List ids = processes.stream().map(OrderProcess::getId).collect(Collectors.toList()); + return userMapper.selectList(new LambdaQueryWrapper() + .eq(InstallOrderUser::getSoftDelete, 0) + .eq(InstallOrderUser::getType, WITHDRAW_USER_TYPE) + .in(InstallOrderUser::getInstallOrderProcessId, ids) + .orderByAsc(InstallOrderUser::getId)).stream() + .collect(Collectors.groupingBy(InstallOrderUser::getInstallOrderProcessId, + LinkedHashMap::new, Collectors.toList())); + } + + private List certificates(List processes) { + if (processes.isEmpty()) return List.of(); + List ids = processes.stream().map(OrderProcess::getId).collect(Collectors.toList()); + return picMapper.selectList(new LambdaQueryWrapper() + .eq(WarehouseInOutPic::getSoftDelete, 0) + .in(WarehouseInOutPic::getAggregateId, ids) + .eq(WarehouseInOutPic::getInOutAllocationType, PICKUP_AGGREGATE_TYPE) + .orderByAsc(WarehouseInOutPic::getId)); + } + + private Map orderRecord(WithdrawOrder order) { + Map record = new LinkedHashMap<>(); + record.put("id", order.getId()); + record.put("withdrawOrderNo", order.getWithdrawOrderNo()); + record.put("pickupOrderNo", order.getPickupOrderNo()); + record.put("type", order.getType()); + record.put("typeText", text(TYPE_TEXT, order.getType())); + record.put("orderState", order.getOrderState()); + record.put("orderStateText", text(ORDER_STATE_TEXT, order.getOrderState())); + 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("preWithdrawDate", order.getPreWithdrawDate()); + record.put("trueWithdrawDate", order.getTrueWithdrawDate()); + record.put("customerContact", order.getCustomerContact()); + record.put("contactMobile", order.getContactMobile()); + record.put("projectAddress", order.getProjectAddress()); + record.put("withdrawGoodsCount", order.getWithdrawGoodsCount()); + record.put("projectMemo", order.getProjectMemo()); + record.put("memo", order.getMemo()); + record.put("applyUserId", order.getApplyUserId()); + record.put("applyUserName", order.getApplyUserName()); + record.put("auditUserName", order.getAuditUserName()); + record.put("dispatchUserName", order.getDispatchUserName()); + record.put("maintenanceUserName", order.getMaintenanceUserName()); + record.put("cooperateUserName", order.getCooperateUserName()); + record.put("scheduleUserName", order.getScheduleUserName()); + record.put("applyTime", formatTime(order.getApplyTime())); + record.put("auditTime", formatTime(order.getAuditTime())); + record.put("dispatchTime", formatTime(order.getDispatchTime())); + record.put("pickupTime", formatTime(order.getPickupTime())); + record.put("warehousingTime", formatTime(order.getWarehousingTime())); + record.put("finishTime", formatTime(order.getFinishTime())); + record.put("createTime", formatTime(order.getCreateTime())); + record.put("modifyTime", formatTime(order.getModifyTime())); + return record; + } + + private Map detailRecord(WithdrawOrderDetails detail) { + Map record = new LinkedHashMap<>(); + record.put("id", detail.getId()); + record.put("materielDefinitionId", detail.getMaterielDefinitionId()); + record.put("materialNo", detail.getMaterialNo()); + record.put("materielFullName", detail.getMaterielFullName()); + record.put("materielSort", detail.getMaterielSort()); + 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()); + record.put("type", detail.getType()); + record.put("productSort", detail.getProductSort()); + record.put("address", detail.getAddress()); + return record; + } + + private Map processRecord(OrderProcess process, List users) { + 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("auditStateText", text(AUDIT_STATE_TEXT, process.getAuditState())); + record.put("auditLevel", process.getAuditLevel()); + 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("maintenanceUsers", personnel(users, 0)); + record.put("cooperateUsers", personnel(users, 1)); + return record; + } + + private List> personnel(List users, int userType) { + return users.stream().filter(user -> Integer.valueOf(userType).equals(user.getUserType())) + .map(user -> { + Map record = new LinkedHashMap<>(); + record.put("id", user.getId()); + record.put("userId", user.getUserId()); + record.put("userName", user.getUserName()); + record.put("userType", user.getUserType()); + return record; + }).collect(Collectors.toList()); + } + + private Map fileRecord(WarehouseInOutPic pic) { + Map record = new LinkedHashMap<>(); + record.put("id", pic.getId()); + record.put("fileName", pic.getFileName()); + record.put("bucket", pic.getOssbucketname()); + record.put("objectKey", nullToEmpty(pic.getOssfolder()) + nullToEmpty(pic.getFileName())); + 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 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 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 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 857a7b3..983476b 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 @@ -15,6 +15,7 @@ import me.iiv.agent.entity.m2.InstallOrder; import me.iiv.agent.entity.m2.PickupOrder; import me.iiv.agent.entity.m2.RelocationOrder; import me.iiv.agent.entity.m2.WarehouseAgentAuth; +import me.iiv.agent.entity.m2.WithdrawOrder; import me.iiv.agent.entity.m2.WarehouseIn; import me.iiv.agent.entity.m2.WarehouseOut; import me.iiv.agent.entity.m2.WarehouseStockManage; @@ -334,6 +335,59 @@ public class PermissionService { }); } + /** + * 撤机工单权限:1:1 复刻老系统 getWorkOrderAuth(type=3)。总部看全部;其余代理身份 + * 可看申请代理、所属代理(本代理+授权代管)、授权代管物业范围,以及本人出现在 + * order_process(order_type=1) 的工单;销售非负责人只看自己创建及代管代理名下物业, + * 空集塞 0。列表和详情必须复用本方法,老系统 lookDetails 无权限校验,MCP 侧必须补齐。 + */ + public void applyWithdrawOrderRowFilter(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())); + + 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())); + } + wrapper.in(WithdrawOrder::getPropertyId, + propertyIds.isEmpty() ? List.of(0L) : new LinkedHashSet<>(propertyIds)); + return; + } + + String processParticipant = "EXISTS (SELECT 1 FROM order_process op " + + "WHERE op.order_no = withdraw_order.withdraw_order_no AND op.soft_delete = 0 " + + "AND op.order_type = 1 AND op.user_id = {0})"; + List managedPropertyIds = managedPropertyIds(agentIds); + wrapper.and(w -> { + w.in(WithdrawOrder::getApplyAgentId, agentIds) + .or().in(WithdrawOrder::getAgentId, agentIds); + if (!managedPropertyIds.isEmpty()) { + w.or().in(WithdrawOrder::getPropertyId, managedPropertyIds); + } + w.or().apply(processParticipant, user.getId()); + }); + } + /** * 装机工单权限,1:1 对齐老 getWorkOrderAuth(type=0) 的有效业务口径:总部看全部; * 销售非负责人只看自己创建物业及授权代管公司的物业;其他代理用户可看自己、 diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/WithdrawOrderToolsTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/WithdrawOrderToolsTest.java new file mode 100644 index 0000000..9a8d1a8 --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/WithdrawOrderToolsTest.java @@ -0,0 +1,217 @@ +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.m2.InstallOrderUser; +import me.iiv.agent.entity.m2.OrderProcess; +import me.iiv.agent.entity.m2.WarehouseInOutPic; +import me.iiv.agent.entity.m2.WithdrawOrder; +import me.iiv.agent.entity.m2.WithdrawOrderDetails; +import me.iiv.agent.mapper.m2.InstallOrderUserMapper; +import me.iiv.agent.mapper.m2.OrderProcessMapper; +import me.iiv.agent.mapper.m2.WarehouseInOutPicMapper; +import me.iiv.agent.mapper.m2.WithdrawOrderDetailsMapper; +import me.iiv.agent.mapper.m2.WithdrawOrderMapper; +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 WithdrawOrderToolsTest { + + @Mock WithdrawOrderMapper orderMapper; + @Mock WithdrawOrderDetailsMapper detailsMapper; + @Mock OrderProcessMapper processMapper; + @Mock InstallOrderUserMapper userMapper; + @Mock WarehouseInOutPicMapper picMapper; + @Mock PermissionService permissionService; + + WithdrawOrderTools tools; + + @BeforeEach + void setUp() { + MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), ""); + TableInfoHelper.initTableInfo(assistant, WithdrawOrder.class); + TableInfoHelper.initTableInfo(assistant, WithdrawOrderDetails.class); + TableInfoHelper.initTableInfo(assistant, OrderProcess.class); + TableInfoHelper.initTableInfo(assistant, InstallOrderUser.class); + TableInfoHelper.initTableInfo(assistant, WarehouseInOutPic.class); + tools = new WithdrawOrderTools(orderMapper, detailsMapper, processMapper, userMapper, + picMapper, permissionService); + User user = new User(); + user.setId(7L); + AuthContextHolder.set(new AuthContext(null, user)); + } + + @AfterEach + void tearDown() { + AuthContextHolder.clear(); + } + + @Test + void listReturnsStateAndTypeTextAndClampsRows() { + when(orderMapper.selectCount(any())).thenReturn(1L); + when(orderMapper.selectList(any())).thenReturn(List.of(order())); + + Map result = tools.withdrawOrderListQuery(1, 5, "CJGD", "QH", 10L, "项目", + "申请人", "运维员", "协同员", "2026-09-01", "2026-09-30", + null, null, null, null, null, null, null, null, 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("typeText")); + assertEquals("已完成", record.get("orderStateText")); + // 老系统列表为空人名补 "-",MCP 侧保持为空 + assertNull(record.get("cooperateUserName")); + verify(permissionService).applyWithdrawOrderRowFilter(any(), any()); + } + + @Test + void listRejectsInvalidCodeAndDateRangeBeforeQuery() { + assertThrows(IllegalArgumentException.class, () -> tools.withdrawOrderListQuery(null, + 9, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null)); + assertThrows(IllegalArgumentException.class, () -> tools.withdrawOrderListQuery(2, + null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null)); + assertThrows(IllegalArgumentException.class, () -> tools.withdrawOrderListQuery(null, + null, null, null, null, null, null, null, null, "2026-09-30", "2026-09-01", + null, null, null, null, null, null, null, null, null, null)); + assertThrows(IllegalArgumentException.class, () -> tools.withdrawOrderListQuery(null, + null, null, null, null, null, null, null, null, null, "2026-09-01-1", + null, null, null, null, null, null, null, null, null, null)); + verifyNoInteractions(orderMapper, permissionService); + } + + @Test + void detailUsesSamePermissionAndHidesExistence() { + when(orderMapper.selectList(any())).thenReturn(List.of()); + + ToolCallException error = assertThrows(ToolCallException.class, + () -> tools.withdrawOrderDetailQuery(20L, null)); + + assertEquals(McpErrorCode.NOT_FOUND_OR_FORBIDDEN, error.getCode()); + verify(permissionService).applyWithdrawOrderRowFilter(any(), any()); + verifyNoInteractions(detailsMapper, processMapper, userMapper, picMapper); + } + + @Test + void detailRejectsMissingAndNonPositiveId() { + assertThrows(IllegalArgumentException.class, () -> tools.withdrawOrderDetailQuery(null, null)); + assertThrows(IllegalArgumentException.class, () -> tools.withdrawOrderDetailQuery(0L, "CJGD-001")); + assertThrows(IllegalArgumentException.class, () -> tools.withdrawOrderDetailQuery(-1L, null)); + verifyNoInteractions(orderMapper); + } + + @Test + void detailReturnsDetailsPersonnelProcessesAndCertificates() { + when(orderMapper.selectList(any())).thenReturn(List.of(order())); + WithdrawOrderDetails detail = new WithdrawOrderDetails(); + detail.setId(3L); + detail.setMaterialNo("M-001"); + detail.setInCount(2); + when(detailsMapper.selectList(any())).thenReturn(List.of(detail)); + + InstallOrderUser worker = new InstallOrderUser(); + worker.setId(4L); + worker.setUserId(40L); + worker.setUserName("运维员"); + worker.setUserType(0); + worker.setType(1); + worker.setInstallOrderProcessId(5L); + InstallOrderUser collaborator = new InstallOrderUser(); + collaborator.setId(6L); + collaborator.setUserName("协同员"); + collaborator.setUserType(1); + collaborator.setType(1); + collaborator.setInstallOrderProcessId(5L); + when(userMapper.selectList(any())).thenReturn(List.of(worker, collaborator), + List.of(worker, collaborator)); + + OrderProcess process = new OrderProcess(); + process.setId(5L); + process.setOperationTheme(8); + process.setOperationType(7); + process.setScheduleState(2); + process.setOrderType(1); + when(processMapper.selectList(any())).thenReturn(List.of(process)); + WarehouseInOutPic pic = new WarehouseInOutPic(); + pic.setId(7L); + pic.setAggregateId(5L); + pic.setFileName("finish.jpg"); + pic.setOssbucketname("bucket-a"); + pic.setOssfolder("withdraw/"); + pic.setInOutAllocationType(12); + when(picMapper.selectList(any())).thenReturn(List.of(pic)); + + Map result = tools.withdrawOrderDetailQuery(20L, "CJGD-001"); + + @SuppressWarnings("unchecked") + Map record = (Map) result.get("record"); + assertEquals("CJGD-001", record.get("withdrawOrderNo")); + assertEquals("已完成", record.get("orderStateText")); + assertEquals(1, ((List) result.get("withdrawDetails")).size()); + assertEquals(1, ((List) result.get("maintenanceUsers")).size()); + assertEquals(1, ((List) result.get("cooperateUsers")).size()); + @SuppressWarnings("unchecked") + Map processResult = ((List>) result.get("processes")).get(0); + assertEquals("结单", processResult.get("operationThemeText")); + assertEquals("结单", processResult.get("operationTypeText")); + assertEquals(1, ((List) processResult.get("maintenanceUsers")).size()); + @SuppressWarnings("unchecked") + Map certificate = ((List>) result.get("certificates")).get(0); + assertEquals("withdraw/finish.jpg", certificate.get("objectKey")); + assertEquals("bucket-a", certificate.get("bucket")); + } + + @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("withdraw.order.list.query").contains("finishDateTo")); + assertTrue(schemas.get("withdraw.order.list.query").contains("pickupDateFrom")); + assertTrue(schemas.get("withdraw.order.detail.query").contains("withdrawOrderNo")); + } + + private static WithdrawOrder order() { + WithdrawOrder order = new WithdrawOrder(); + order.setId(20L); + order.setWithdrawOrderNo("CJGD-001"); + order.setOrderState(5); + order.setType(1); + order.setWithdrawGoodsCount(2); + return order; + } +} diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceWithdrawOrderRowFilterTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceWithdrawOrderRowFilterTest.java new file mode 100644 index 0000000..906c14c --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceWithdrawOrderRowFilterTest.java @@ -0,0 +1,148 @@ +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.Properyt; +import me.iiv.agent.entity.m0.User; +import me.iiv.agent.entity.m2.WithdrawOrder; +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 me.iiv.agent.entity.m2.WarehouseAgentAuth; +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.HashOperations; +import org.springframework.data.redis.core.StringRedisTemplate; + +import java.util.Collection; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PermissionServiceWithdrawOrderRowFilterTest { + @Mock AuthorityMapper authorityMapper; + @Mock AgentMapper agentMapper; + @Mock WarehouseAgentAuthMapper warehouseAgentAuthMapper; + @Mock AgentManageDistrictAuthMapper agentManageDistrictAuthMapper; + @Mock ProperytMapper properytMapper; + @Mock StringRedisTemplate redisTemplate; + @Mock HashOperations hashOperations; + PermissionService service; + + @BeforeEach + void setUp() { + MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), ""); + TableInfoHelper.initTableInfo(assistant, WithdrawOrder.class); + TableInfoHelper.initTableInfo(assistant, Properyt.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.applyWithdrawOrderRowFilter(wrapper, user(null)); + + assertTrue(wrapper.getSqlSegment().isBlank()); + } + + @Test + void agentCombinesAgentsManagedPropertyAndProcess() { + 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.applyWithdrawOrderRowFilter(wrapper, user(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("order_process")); + assertTrue(sql.contains("order_type = 1")); + // 撤机权限不含 install_order_user 作业人员分支(老 getWorkOrderAuth(type=3) 口径) + assertFalse(sql.contains("install_order_user")); + assertTrue(values.containsAll(List.of(7L, 10L, 12L, 30L))); + } + + @Test + void salesNonLeaderLimitsToOwnAndManagedProperties() { + User user = user(10L); + user.setDeptId(6L); + user.setPostId(8L); + when(authorityMapper.selectById(9L)).thenReturn(authority(1)); + when(warehouseAgentAuthMapper.selectList(any())).thenReturn(List.of()); + when(redisTemplate.opsForHash()).thenReturn(hashOperations); + when(hashOperations.get("Dictionary", "authDeptId")).thenReturn(",6,"); + when(hashOperations.get("Dictionary", "authDeptPostId")).thenReturn(",99,"); + Properyt own = new Properyt(); + own.setId(21L); + when(properytMapper.selectList(any())).thenReturn(List.of(own)); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + service.applyWithdrawOrderRowFilter(wrapper, user); + + String sql = wrapper.getSqlSegment(); + assertTrue(sql.contains("property_id IN")); + assertFalse(sql.contains("order_process")); + assertTrue(wrapper.getParamNameValuePairs().values().contains(21L)); + } + + @Test + void salesNonLeaderWithoutPropertiesSeesNothing() { + User user = user(10L); + user.setDeptId(6L); + user.setPostId(8L); + when(authorityMapper.selectById(9L)).thenReturn(authority(1)); + when(warehouseAgentAuthMapper.selectList(any())).thenReturn(List.of()); + when(redisTemplate.opsForHash()).thenReturn(hashOperations); + when(hashOperations.get("Dictionary", "authDeptId")).thenReturn(",6,"); + when(hashOperations.get("Dictionary", "authDeptPostId")).thenReturn(",99,"); + when(properytMapper.selectList(any())).thenReturn(List.of()); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + service.applyWithdrawOrderRowFilter(wrapper, user); + + String sql = wrapper.getSqlSegment(); + assertTrue(sql.contains("property_id IN")); + // 空集塞 0,保证 SQL 恒假而非全表放行 + assertTrue(wrapper.getParamNameValuePairs().values().contains(0L)); + } + + private static User user(Long agentId) { + User user = new User(); + user.setId(7L); + user.setAuthorityId(9L); + 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; + } +}