From e2835362e9edea80e339f9db5fed98e9993a3815 Mon Sep 17 00:00:00 2001 From: jiashumeng <1051000378@qq.com> Date: Tue, 15 Sep 2026 17:59:10 +0930 Subject: [PATCH] feat(mcp): add warehouse inbound query tools --- xiaoqu-agent-service/README.md | 2 + .../doc/agent-mcp-tools-reference.md | 28 + .../doc/warehouse-in-mcp-tools-design.md | 59 +++ .../me/iiv/agent/entity/m2/WarehouseIn.java | 56 ++ .../entity/m2/WarehouseInAuditProcess.java | 34 ++ .../agent/entity/m2/WarehouseInDetail.java | 63 +++ .../me/iiv/agent/entity/m2/WarehouseType.java | 26 + .../m2/WarehouseInAuditProcessMapper.java | 7 + .../mapper/m2/WarehouseInDetailMapper.java | 7 + .../agent/mapper/m2/WarehouseInMapper.java | 7 + .../agent/mapper/m2/WarehouseTypeMapper.java | 7 + .../iiv/agent/mcp/tool/WarehouseInTools.java | 480 ++++++++++++++++++ .../agent/permission/PermissionService.java | 32 ++ .../agent/mcp/tool/WarehouseInToolsTest.java | 240 +++++++++ ...issionServiceWarehouseInRowFilterTest.java | 104 ++++ 15 files changed, 1152 insertions(+) create mode 100644 xiaoqu-agent-service/doc/warehouse-in-mcp-tools-design.md create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseIn.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseInAuditProcess.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseInDetail.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseType.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseInAuditProcessMapper.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseInDetailMapper.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseInMapper.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseTypeMapper.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/WarehouseInTools.java create mode 100644 xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/WarehouseInToolsTest.java create mode 100644 xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceWarehouseInRowFilterTest.java diff --git a/xiaoqu-agent-service/README.md b/xiaoqu-agent-service/README.md index 0c7d4a0..b947345 100644 --- a/xiaoqu-agent-service/README.md +++ b/xiaoqu-agent-service/README.md @@ -82,6 +82,8 @@ xiaoqu-agent-service/ | `agent.list.query` | customer | read | 代理商分页查询。入参 id/name/level/channelNo/province/city/serialnumber/page/rows;出参 id/name/level/channelNo/serialnumber/supervisorName/province/city/countyseat/contact/phone/agencyarea/memo/createTime。行权限:isagent=1 只查直属下级,id 精确查可看自身。supervisorName 批量 IN 富化 | | `property.list.query` | customer | read | 物业(客户)分页查询(m0 properyt 表)。入参 name/agentId/serialnumber/province/city/contact/phone/page/rows;出参 id/name/serialnumber/agentName/createName/province/city/countyseat/address/contact/phone/createTime。行权限走 PermissionService 完整版 getByIsAgent 语义(不含 enterprise_type=2 限制,与老 /property/listByPage 一致)。agentName/createName 批量 IN 富化 | | `integration.context.get` | integration | read | 身份与能力自检(REQ-20260911-0012)。无入参;返回 tenant_id(作用域编码映射)/principal_type/subject_id/scopes(可调用工具集合,与强制校验同源实时计算)/data_scope_version(token_scope 绑定最大修改时间 epoch 毫秒)/capability_levels(QUERY/SYNC,按 *.changes.list 数据驱动)/server_time | +| `warehouse.in.list.query` | warehouse | read | 入库单分页查询;入库/申请日期独立过滤,主单代理、涉及仓库、流程参与人三类权限取并集 | +| `warehouse.in.detail.query` | warehouse | 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 acd46a4..5da27c0 100644 --- a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md +++ b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md @@ -519,6 +519,34 @@ --- +## 17. warehouse.in.list.query + +分页查询入库单,参考旧系统 `POST /myWarehouseIn/allListByPage`,但将入库日期与申请日期分别过滤, +并按本页主单 ID 批量加载仓库和待办信息。 + +- 分类:warehouse | 类型:读 +- 数据来源:m2 `warehouse_in`、`warehouse_in_details`、`warehouse_in_audit_process` +- 行级权限:总部看全部;代理身份可看所属代理范围、所属或负责仓库涉及、或本人参与处理的入库单。 +- 单号支持入库/调拨/关联出库/商城/送货/装机/取货/撤机;另支持代理、仓库、入库类型、申请人、 + 状态、同步状态、物流和入库/申请日期范围筛选。 +- 返回 `total/page/rows/records`;每条记录含 `sourceType`(ALLOCATION/OUTBOUND_LINKED/MALL/NORMAL)、 + 状态文案、仓库集合、待办人、实际入库人和入库日期。 + +## 18. warehouse.in.detail.query + +按 `id` 或 `inOrderNo` 查询详情,两者至少传一个;同时传入时必须匹配同一条可见记录。调拨入库单可直接查询。 + +- 行级权限与列表完全一致;不存在和无权统一返回 `NOT_FOUND_OR_FORBIDDEN`。 +- 返回 `record/sceneType/warehouses/items/processes/files/relatedOutOrder`。 +- `items` 按物料定义、物料索引和仓库汇总;`includeSerials` 默认 false,`serialLimit` 默认 100、上限 500, + 同时返回 `serialTotal/serialTruncated`。 +- `files.application/files.confirmation` 仅返回 `id/fileName/bucket/objectKey`。 +- `relatedOutOrder` 仅给出关联出库单号和查询工具名,不内嵌出库单内容。 + +详细设计见 `warehouse-in-mcp-tools-design.md`。 + +--- + ## 附录:在线查看工具清单 ```bash diff --git a/xiaoqu-agent-service/doc/warehouse-in-mcp-tools-design.md b/xiaoqu-agent-service/doc/warehouse-in-mcp-tools-design.md new file mode 100644 index 0000000..b834408 --- /dev/null +++ b/xiaoqu-agent-service/doc/warehouse-in-mcp-tools-design.md @@ -0,0 +1,59 @@ +# 入库单 MCP 工具设计 + +## 1. 范围与评审结论 + +参考旧系统 `POST /myWarehouseIn/allListByPage` 与 `GET /myWarehouseIn/lookDetail`,新增: + +| 工具 | 分类 | 用途 | +|---|---|---| +| `warehouse.in.list.query` | warehouse/read | 分页查询入库单 | +| `warehouse.in.detail.query` | warehouse/read | 按主键或入库单号查询详情 | + +自评审通过,确认采用以下产品取舍:调拨入库单允许直接查询详情;关联出库单仅返回索引, +不内嵌完整出库信息。工具只读 m2,不新增业务表或 Flyway migration;启动后由 +`ToolSyncService` 自动登记,需由管理员绑定 Scope 后才可调用。 + +## 2. 列表 + +- 单号:入库、调拨、关联出库、商城、送货、装机、取货、撤机单号。 +- 归属:代理、仓库、入库类型、申请人。 +- 状态与物流:订单状态、入库同步状态、物流公司和运单号。 +- 日期:`inDateFrom/inDateTo` 精确过滤 `in_date`;`applyDateFrom/applyDateTo` + 过滤 `apply_date`,结束日期含当天。不会复制旧接口将入库日期改写成申请日期的异常逻辑。 +- 分页:page 默认 1,rows 默认 20、上限 100。 + +返回主单、来源类型、状态文案、涉及仓库、待办人、实际入库人及日期、物流和关联单号。 +本页明细与待办按主单 ID 批量查询,避免 N+1。 + +`sourceType` 的优先级为 `ALLOCATION`、`OUTBOUND_LINKED`、`MALL`、`NORMAL`。 + +## 3. 详情 + +`id`、`inOrderNo` 至少传一个;同时传入时必须匹配同一条可见记录。详情返回: + +- `record`、`sceneType`、涉及仓库; +- 按物料定义、物料索引和仓库汇总的 `items`; +- 可选 `serials` 以及完整 `serialTotal`、`serialTruncated`; +- `schedule_state=2` 的已处理流程; +- `file_type=0/1` 的申请、确认入库附件,仅返回 OSS 对象定位,不生成签名 URL; +- `relatedOutOrder`:仅出库单号和建议调用的 `warehouse.out.detail.query` 工具名。 + +## 4. 权限与安全 + +列表和详情强制复用 `PermissionService.applyWarehouseInRowFilter`: + +- 总部身份查看全部; +- 代理身份可见范围是主单所属代理范围、所属或负责仓库涉及、本人参与处理三类并集; +- 代理范围包含自己、直属下级和 `warehouse_agent_auth` 授权代管代理; +- 权限组异常或代理用户未关联代理商时失败关闭; +- 详情不存在与无权访问统一返回 `NOT_FOUND_OR_FORBIDDEN`。 + +权限使用主查询中的 `EXISTS`,所有业务筛选均只能缩小结果。关联出库不提前判断是否可访问, +避免形成旁路探测;调用方若需要内容,必须再调用出库详情工具并接受其独立鉴权。 + +## 5. 验收 + +- 列表分页、日期校验、仓库过滤、批量富化和状态文案; +- 详情双条件、调拨单、物料汇总、SN 默认关闭/截断、附件分类、出库索引; +- 总部、代理范围、仓库负责人、流程参与人和异常配置失败关闭; +- 固定 `TZ=Asia/Shanghai` 执行全量后端测试,不连接生产数据库、不执行部署。 diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseIn.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseIn.java new file mode 100644 index 0000000..6c09310 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseIn.java @@ -0,0 +1,56 @@ +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.time.LocalDate; +import java.util.Date; + +/** 入库单主表(m2,只读)。 */ +@Getter +@Setter +@ToString +@TableName("warehouse_in") +public class WarehouseIn 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 inOrderNo; + private String allocationOrderNo; + private Long agentId; + private String agentName; + private Long warehouseTypeId; + private String warehouseTypeName; + private LocalDate inDate; + private Integer goodsCount; + private Integer inCount; + private Integer orderState; + private Long applyUserId; + private String applyUserName; + private Date applyDate; + private String memo; + private Integer auditLevel; + private Integer currendAuditLevel; + private Integer inSynchronizeState; + private Date inSynchronizeTime; + private String removeUserName; + private Date removeDate; + private String outOrderNo; + private String mallOrderNo; + private Long logisticsId; + private String logisticsName; + private String logisticsWayBill; + private String deliveryOrderNo; + private String installOrderNo; + private String pickupOrderNo; + private String withdrawOrderNo; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseInAuditProcess.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseInAuditProcess.java new file mode 100644 index 0000000..537eddb --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseInAuditProcess.java @@ -0,0 +1,34 @@ +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("warehouse_in_audit_process") +public class WarehouseInAuditProcess implements Serializable { + @TableId(value = "id", type = IdType.AUTO) + private Long id; + private Integer softDelete; + private Long warehouseInId; + private Long warehouseId; + private String warehouseName; + private Long userId; + private String userName; + private Date operationTime; + private Integer scheduleState; + private Integer auditState; + private Integer auditLevel; + private Integer operationTheme; + private Integer operationType; + private String memo; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseInDetail.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseInDetail.java new file mode 100644 index 0000000..addd140 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseInDetail.java @@ -0,0 +1,63 @@ +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.time.LocalDate; +import java.util.Date; + +/** 入库单物料/SN 明细(m2,只读)。 */ +@Getter +@Setter +@ToString +@TableName("warehouse_in_details") +public class WarehouseInDetail 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 agentId; + private String agentName; + private String inOrderNo; + private String allocationOrderNo; + private Long warehouseInId; + 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 Integer currentInventory; + private LocalDate inDate; + private Long warehouseId; + private String warehouseName; + private Long warehouseTypeId; + private String warehouseTypeName; + private Integer orderState; + private Long applyUserId; + private String applyUserName; + private Integer goodInState; + private Date inConfirmTime; + private String inConfirmUserName; + private String outOrderNo; + private String mallOrderNo; + private Integer assetDisposalType; + private Integer synchronizeState; + private Long outDetailsId; + private String deliveryOrderNo; + private String installOrderNo; + private String pickupOrderNo; + private String withdrawOrderNo; + private String memo; + private String lineMemo; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseType.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseType.java new file mode 100644 index 0000000..ffa329c --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseType.java @@ -0,0 +1,26 @@ +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; + +/** 出入库类型(m2,只读)。 */ +@Getter +@Setter +@ToString +@TableName("warehouse_type") +public class WarehouseType implements Serializable { + @TableId(value = "id", type = IdType.AUTO) + private Long id; + private Integer softDelete; + private Integer warehouseType; + private String typeName; + private Integer sceneType; + private String typeNo; + private Integer assetFlag; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseInAuditProcessMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseInAuditProcessMapper.java new file mode 100644 index 0000000..df89151 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseInAuditProcessMapper.java @@ -0,0 +1,7 @@ +package me.iiv.agent.mapper.m2; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import me.iiv.agent.entity.m2.WarehouseInAuditProcess; + +public interface WarehouseInAuditProcessMapper extends BaseMapper { +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseInDetailMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseInDetailMapper.java new file mode 100644 index 0000000..7e44c32 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseInDetailMapper.java @@ -0,0 +1,7 @@ +package me.iiv.agent.mapper.m2; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import me.iiv.agent.entity.m2.WarehouseInDetail; + +public interface WarehouseInDetailMapper extends BaseMapper { +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseInMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseInMapper.java new file mode 100644 index 0000000..b86c195 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseInMapper.java @@ -0,0 +1,7 @@ +package me.iiv.agent.mapper.m2; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import me.iiv.agent.entity.m2.WarehouseIn; + +public interface WarehouseInMapper extends BaseMapper { +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseTypeMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseTypeMapper.java new file mode 100644 index 0000000..c7e70bc --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseTypeMapper.java @@ -0,0 +1,7 @@ +package me.iiv.agent.mapper.m2; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import me.iiv.agent.entity.m2.WarehouseType; + +public interface WarehouseTypeMapper extends BaseMapper { +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/WarehouseInTools.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/WarehouseInTools.java new file mode 100644 index 0000000..1500350 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/WarehouseInTools.java @@ -0,0 +1,480 @@ +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.WarehouseIn; +import me.iiv.agent.entity.m2.WarehouseInAuditProcess; +import me.iiv.agent.entity.m2.WarehouseInDetail; +import me.iiv.agent.entity.m2.WarehouseInOutPic; +import me.iiv.agent.entity.m2.WarehouseType; +import me.iiv.agent.mapper.m2.WarehouseInAuditProcessMapper; +import me.iiv.agent.mapper.m2.WarehouseInDetailMapper; +import me.iiv.agent.mapper.m2.WarehouseInMapper; +import me.iiv.agent.mapper.m2.WarehouseInOutPicMapper; +import me.iiv.agent.mapper.m2.WarehouseTypeMapper; +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.ArrayList; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** 入库单查询工具,参考老系统 /myWarehouseIn/allListByPage 与 /lookDetail。 */ +@Component +public class WarehouseInTools implements ToolObject { + + private static final int DEFAULT_ROWS = 20; + private static final int MAX_ROWS = 100; + private static final int DEFAULT_SERIAL_LIMIT = 100; + private static final int MAX_SERIAL_LIMIT = 500; + private static final int IN_TYPE = 0; + private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); + private static final Pattern DATE_PATTERN = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$"); + 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, "作废"); + 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.of( + 0, "审核通过", 1, "审核驳回", 2, "撤销入库", 3, "入库确认", + 4, "驳回修改提交", 5, "撤销", 6, "入库单确认驳回"); + private static final Map OPERATION_TYPE_TEXT = Map.of( + 0, "审批流操作", 1, "确认入库", 2, "本人修改", 3, "驳回或修改记录"); + + final WarehouseInMapper warehouseInMapper; + final WarehouseInDetailMapper detailMapper; + final WarehouseInAuditProcessMapper processMapper; + final WarehouseInOutPicMapper picMapper; + final WarehouseTypeMapper warehouseTypeMapper; + final PermissionService permissionService; + + public WarehouseInTools(WarehouseInMapper warehouseInMapper, + WarehouseInDetailMapper detailMapper, + WarehouseInAuditProcessMapper processMapper, + WarehouseInOutPicMapper picMapper, + WarehouseTypeMapper warehouseTypeMapper, + PermissionService permissionService) { + this.warehouseInMapper = warehouseInMapper; + this.detailMapper = detailMapper; + this.processMapper = processMapper; + this.picMapper = picMapper; + this.warehouseTypeMapper = warehouseTypeMapper; + this.permissionService = permissionService; + } + + @XqTool(category = "warehouse", kind = "read") + @Tool(name = "warehouse.in.list.query", description = "分页查询入库单,参考老系统 /myWarehouseIn/allListByPage。" + + "总部可看全部;代理身份可看所属代理范围、所属或负责仓库涉及、或本人参与处理的入库单。" + + "入库日期与申请日期分别过滤;日期格式 yyyy-MM-dd,结束日期含当天。" + + "分页 page 默认1,rows 默认20、上限100。orderState:0待审核、1被驳回、2待入库、3已完成、4已撤销、5作废。") + public Map warehouseInListQuery( + @ToolParam(required = false, description = "入库单号,模糊匹配") String inOrderNo, + @ToolParam(required = false, description = "调拨单号,模糊匹配") String allocationOrderNo, + @ToolParam(required = false, description = "关联出库单号,模糊匹配") String outOrderNo, + @ToolParam(required = false, description = "商城订单号,模糊匹配") String mallOrderNo, + @ToolParam(required = false, description = "送货单号,模糊匹配") String deliveryOrderNo, + @ToolParam(required = false, description = "装机单号,模糊匹配") String installOrderNo, + @ToolParam(required = false, description = "取货工单号,模糊匹配") String pickupOrderNo, + @ToolParam(required = false, description = "撤机工单号,模糊匹配") String withdrawOrderNo, + @ToolParam(required = false, description = "所属代理商 id") Long agentId, + @ToolParam(required = false, description = "明细涉及的仓库 id") Long warehouseId, + @ToolParam(required = false, description = "入库类型 id") Long warehouseTypeId, + @ToolParam(required = false, description = "申请人 id") Long applyUserId, + @ToolParam(required = false, description = "状态:0待审核 1被驳回 2待入库 3已完成 4已撤销 5作废") Integer orderState, + @ToolParam(required = false, description = "入库同步状态:0未同步 1已同步 2无需同步") Integer inSynchronizeState, + @ToolParam(required = false, description = "物流公司 id") Long logisticsId, + @ToolParam(required = false, description = "物流运单号,模糊匹配") String logisticsWayBill, + @ToolParam(required = false, description = "入库日期起,yyyy-MM-dd") String inDateFrom, + @ToolParam(required = false, description = "入库日期止,yyyy-MM-dd,含当天") String inDateTo, + @ToolParam(required = false, description = "申请日期起,yyyy-MM-dd") String applyDateFrom, + @ToolParam(required = false, description = "申请日期止,yyyy-MM-dd,含当天") String applyDateTo, + @ToolParam(required = false, description = "页码,从1开始,默认1") Integer page, + @ToolParam(required = false, description = "每页条数,默认20,上限100") Integer rows) { + LocalDate inStart = parseLocalDate(inDateFrom, "inDateFrom"); + LocalDate inEnd = parseLocalDate(inDateTo, "inDateTo"); + LocalDate applyStartDate = parseLocalDate(applyDateFrom, "applyDateFrom"); + LocalDate applyEndDate = parseLocalDate(applyDateTo, "applyDateTo"); + validateRange(inStart, inEnd, "inDateFrom", "inDateTo"); + validateRange(applyStartDate, applyEndDate, "applyDateFrom", "applyDateTo"); + + 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(WarehouseIn::getSoftDelete, 0); + like(wrapper, WarehouseIn::getInOrderNo, inOrderNo); + like(wrapper, WarehouseIn::getAllocationOrderNo, allocationOrderNo); + like(wrapper, WarehouseIn::getOutOrderNo, outOrderNo); + like(wrapper, WarehouseIn::getMallOrderNo, mallOrderNo); + like(wrapper, WarehouseIn::getDeliveryOrderNo, deliveryOrderNo); + like(wrapper, WarehouseIn::getInstallOrderNo, installOrderNo); + like(wrapper, WarehouseIn::getPickupOrderNo, pickupOrderNo); + like(wrapper, WarehouseIn::getWithdrawOrderNo, withdrawOrderNo); + like(wrapper, WarehouseIn::getLogisticsWayBill, logisticsWayBill); + wrapper.eq(agentId != null, WarehouseIn::getAgentId, agentId) + .eq(warehouseTypeId != null, WarehouseIn::getWarehouseTypeId, warehouseTypeId) + .eq(applyUserId != null, WarehouseIn::getApplyUserId, applyUserId) + .eq(orderState != null, WarehouseIn::getOrderState, orderState) + .eq(inSynchronizeState != null, WarehouseIn::getInSynchronizeState, inSynchronizeState) + .eq(logisticsId != null, WarehouseIn::getLogisticsId, logisticsId) + .ge(inStart != null, WarehouseIn::getInDate, inStart) + .le(inEnd != null, WarehouseIn::getInDate, inEnd) + .ge(applyStartDate != null, WarehouseIn::getApplyDate, startOfDay(applyStartDate)) + .lt(applyEndDate != null, WarehouseIn::getApplyDate, nextDayStart(applyEndDate)); + if (warehouseId != null) { + wrapper.apply("EXISTS (SELECT 1 FROM warehouse_in_details wid_filter " + + "WHERE wid_filter.warehouse_in_id = warehouse_in.id " + + "AND wid_filter.soft_delete = 0 AND wid_filter.warehouse_id = {0})", warehouseId); + } + User user = AuthContextHolder.get().getUser(); + permissionService.applyWarehouseInRowFilter(wrapper, user); + + Long total = warehouseInMapper.selectCount(wrapper); + wrapper.orderByDesc(WarehouseIn::getId) + .last("LIMIT " + (long) (pageNo - 1) * pageSize + "," + pageSize); + List orders = warehouseInMapper.selectList(wrapper); + Map> details = listDetails(orders); + Map> schedules = listSchedules(orders); + List> records = orders.stream().map(order -> listRecord(order, + details.getOrDefault(order.getId(), List.of()), + schedules.getOrDefault(order.getId(), List.of()))).collect(Collectors.toList()); + + 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", records); + return result; + } + + @XqTool(category = "warehouse", kind = "read") + @Tool(name = "warehouse.in.detail.query", description = "按 id 或入库单号查询入库单详情。" + + "行级权限与 warehouse.in.list.query 完全一致,不存在与无权访问统一返回 NOT_FOUND_OR_FORBIDDEN。" + + "返回主单、场景类型、仓库、按物料和仓库汇总的明细、可选 SN、已处理流程和申请/确认附件;调拨入库单可直接查询。" + + "关联出库仅返回索引,不内嵌内容。includeSerials 默认 false;serialLimit 默认100、上限500。") + public Map warehouseInDetailQuery( + @ToolParam(required = false, description = "入库单主键 id,与 inOrderNo 至少传一个") Long id, + @ToolParam(required = false, description = "入库单号,与 id 至少传一个") String inOrderNo, + @ToolParam(required = false, description = "是否返回 SN 明细,默认 false") Boolean includeSerials, + @ToolParam(required = false, description = "SN 返回上限,默认100、最大500") Integer serialLimit) { + if ((id == null || id <= 0) && !StringUtils.hasText(inOrderNo)) { + throw new IllegalArgumentException("id、inOrderNo 至少传一个"); + } + if (id != null && id <= 0) { + throw new IllegalArgumentException("id 必须为正整数"); + } + int limit = serialLimit == null || serialLimit < 1 + ? DEFAULT_SERIAL_LIMIT : Math.min(serialLimit, MAX_SERIAL_LIMIT); + boolean include = Boolean.TRUE.equals(includeSerials); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(WarehouseIn::getSoftDelete, 0); + wrapper.eq(id != null, WarehouseIn::getId, id); + wrapper.eq(StringUtils.hasText(inOrderNo), WarehouseIn::getInOrderNo, + StringUtils.hasText(inOrderNo) ? inOrderNo.trim() : inOrderNo); + permissionService.applyWarehouseInRowFilter(wrapper, AuthContextHolder.get().getUser()); + List visible = warehouseInMapper.selectList(wrapper.orderByDesc(WarehouseIn::getId).last("LIMIT 1")); + if (visible.isEmpty()) { + throw new ToolCallException(McpErrorCode.NOT_FOUND_OR_FORBIDDEN, "入库单不存在或当前身份无权查看"); + } + WarehouseIn order = visible.get(0); + List detailRows = detailMapper.selectList(new LambdaQueryWrapper() + .eq(WarehouseInDetail::getSoftDelete, 0) + .eq(WarehouseInDetail::getWarehouseInId, order.getId()) + .orderByAsc(WarehouseInDetail::getId)); + List serialCandidates = detailRows.stream() + .filter(row -> StringUtils.hasText(row.getSnCode())).collect(Collectors.toList()); + List serialRows = include + ? serialCandidates.stream().limit(limit).collect(Collectors.toList()) : List.of(); + List processes = processMapper.selectList( + new LambdaQueryWrapper() + .eq(WarehouseInAuditProcess::getSoftDelete, 0) + .eq(WarehouseInAuditProcess::getWarehouseInId, order.getId()) + .eq(WarehouseInAuditProcess::getScheduleState, 2) + .orderByAsc(WarehouseInAuditProcess::getOperationTime) + .orderByAsc(WarehouseInAuditProcess::getId)); + List pics = picMapper.selectList(new LambdaQueryWrapper() + .eq(WarehouseInOutPic::getSoftDelete, 0) + .eq(WarehouseInOutPic::getAggregateId, order.getId()) + .eq(WarehouseInOutPic::getInOutAllocationType, IN_TYPE) + .orderByAsc(WarehouseInOutPic::getId)); + WarehouseType type = order.getWarehouseTypeId() == null + ? null : warehouseTypeMapper.selectById(order.getWarehouseTypeId()); + + Map result = new LinkedHashMap<>(); + result.put("record", record(order)); + result.put("sceneType", type == null || !Integer.valueOf(0).equals(type.getSoftDelete()) ? null : type.getSceneType()); + result.put("warehouses", warehouseRecords(detailRows)); + result.put("items", aggregateItems(detailRows)); + result.put("serials", serialRows.stream().map(this::serialRecord).collect(Collectors.toList())); + result.put("serialTotal", (long) serialCandidates.size()); + result.put("serialTruncated", include && serialCandidates.size() > serialRows.size()); + result.put("processes", processes.stream().map(this::processRecord).collect(Collectors.toList())); + Map files = new LinkedHashMap<>(); + files.put("application", fileRecords(pics, 0)); + files.put("confirmation", fileRecords(pics, 1)); + result.put("files", files); + result.put("relatedOutOrder", relatedOutOrder(order)); + return result; + } + + private Map> listDetails(List orders) { + if (orders.isEmpty()) return Map.of(); + List ids = orders.stream().map(WarehouseIn::getId).collect(Collectors.toList()); + return detailMapper.selectList(new LambdaQueryWrapper() + .eq(WarehouseInDetail::getSoftDelete, 0) + .in(WarehouseInDetail::getWarehouseInId, ids) + .orderByAsc(WarehouseInDetail::getId)).stream() + .collect(Collectors.groupingBy(WarehouseInDetail::getWarehouseInId, + LinkedHashMap::new, Collectors.toList())); + } + + private Map> listSchedules(List orders) { + if (orders.isEmpty()) return Map.of(); + List ids = orders.stream().map(WarehouseIn::getId).collect(Collectors.toList()); + return processMapper.selectList(new LambdaQueryWrapper() + .eq(WarehouseInAuditProcess::getSoftDelete, 0) + .in(WarehouseInAuditProcess::getWarehouseInId, ids) + .eq(WarehouseInAuditProcess::getScheduleState, 1) + .orderByAsc(WarehouseInAuditProcess::getId)).stream() + .collect(Collectors.groupingBy(WarehouseInAuditProcess::getWarehouseInId, + LinkedHashMap::new, Collectors.toList())); + } + + private Map listRecord(WarehouseIn order, List details, + List schedules) { + Map record = record(order); + LinkedHashMap warehouses = warehouseMap(details); + record.put("warehouseIds", new ArrayList<>(warehouses.keySet())); + record.put("warehouseNames", new ArrayList<>(warehouses.values())); + record.put("schedulePeople", distinctNonBlank(schedules.stream() + .map(WarehouseInAuditProcess::getUserName).collect(Collectors.toList()))); + record.put("warehouseInPeople", distinctNonBlank(details.stream() + .map(WarehouseInDetail::getInConfirmUserName).collect(Collectors.toList()))); + record.put("warehouseInTimes", details.stream().map(WarehouseInDetail::getInDate) + .filter(Objects::nonNull).map(LocalDate::toString).distinct().collect(Collectors.toList())); + return record; + } + + private Map record(WarehouseIn order) { + Map record = new LinkedHashMap<>(); + record.put("id", order.getId()); + record.put("inOrderNo", order.getInOrderNo()); + record.put("allocationOrderNo", order.getAllocationOrderNo()); + record.put("outOrderNo", order.getOutOrderNo()); + record.put("mallOrderNo", order.getMallOrderNo()); + record.put("sourceType", sourceType(order)); + record.put("agentId", order.getAgentId()); + record.put("agentName", order.getAgentName()); + record.put("warehouseTypeId", order.getWarehouseTypeId()); + record.put("warehouseTypeName", order.getWarehouseTypeName()); + record.put("inDate", order.getInDate() == null ? null : order.getInDate().toString()); + record.put("goodsCount", order.getGoodsCount()); + record.put("inCount", order.getInCount()); + record.put("orderState", order.getOrderState()); + record.put("orderStateText", text(ORDER_STATE_TEXT, order.getOrderState())); + record.put("applyUserId", order.getApplyUserId()); + record.put("applyUserName", order.getApplyUserName()); + record.put("applyDate", formatTime(order.getApplyDate())); + record.put("inSynchronizeState", order.getInSynchronizeState()); + record.put("inSynchronizeTime", formatTime(order.getInSynchronizeTime())); + record.put("logisticsId", order.getLogisticsId()); + record.put("logisticsName", order.getLogisticsName()); + record.put("logisticsWayBill", order.getLogisticsWayBill()); + record.put("deliveryOrderNo", order.getDeliveryOrderNo()); + record.put("installOrderNo", order.getInstallOrderNo()); + record.put("pickupOrderNo", order.getPickupOrderNo()); + record.put("withdrawOrderNo", order.getWithdrawOrderNo()); + record.put("memo", order.getMemo()); + record.put("createTime", formatTime(order.getCreateTime())); + record.put("modifyTime", formatTime(order.getModifyTime())); + return record; + } + + private List> aggregateItems(List rows) { + Map> groups = rows.stream().collect(Collectors.groupingBy( + row -> Objects.toString(row.getMaterielDefinitionId(), "") + "|" + + Objects.toString(row.getMaterielIndexId(), "") + "|" + + Objects.toString(row.getWarehouseId(), ""), LinkedHashMap::new, Collectors.toList())); + return groups.values().stream().map(group -> { + WarehouseInDetail first = group.get(0); + Map item = commonDetailRecord(first); + item.put("inCount", group.stream().map(WarehouseInDetail::getInCount) + .filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); + item.put("currentInventory", first.getCurrentInventory()); + item.put("inDate", first.getInDate() == null ? null : first.getInDate().toString()); + item.put("inConfirmUserName", first.getInConfirmUserName()); + item.put("inConfirmTime", formatTime(first.getInConfirmTime())); + item.put("lineMemo", first.getLineMemo()); + return item; + }).collect(Collectors.toList()); + } + + private Map serialRecord(WarehouseInDetail detail) { + Map item = commonDetailRecord(detail); + item.put("snCode", detail.getSnCode()); + item.put("goodInState", detail.getGoodInState()); + item.put("assetDisposalType", detail.getAssetDisposalType()); + item.put("synchronizeState", detail.getSynchronizeState()); + item.put("outDetailsId", detail.getOutDetailsId()); + return item; + } + + private Map commonDetailRecord(WarehouseInDetail detail) { + Map item = new LinkedHashMap<>(); + item.put("id", detail.getId()); + item.put("materielDefinitionId", detail.getMaterielDefinitionId()); + item.put("materielIndexId", detail.getMaterielIndexId()); + item.put("materialNo", detail.getMaterialNo()); + item.put("materielFullName", detail.getMaterielFullName()); + item.put("materielSort", detail.getMaterielSort()); + item.put("specifications", detail.getSpecifications()); + item.put("warehouseId", detail.getWarehouseId()); + item.put("warehouseName", detail.getWarehouseName()); + return item; + } + + private List> warehouseRecords(List rows) { + return warehouseMap(rows).entrySet().stream().map(entry -> { + Map warehouse = new LinkedHashMap<>(); + warehouse.put("id", entry.getKey()); + warehouse.put("name", entry.getValue()); + return warehouse; + }).collect(Collectors.toList()); + } + + private static LinkedHashMap warehouseMap(List rows) { + LinkedHashMap warehouses = new LinkedHashMap<>(); + for (WarehouseInDetail row : rows) { + if (row.getWarehouseId() != null) warehouses.putIfAbsent(row.getWarehouseId(), row.getWarehouseName()); + } + return warehouses; + } + + private Map processRecord(WarehouseInAuditProcess process) { + Map item = new LinkedHashMap<>(); + item.put("id", process.getId()); + item.put("warehouseId", process.getWarehouseId()); + item.put("warehouseName", process.getWarehouseName()); + item.put("userId", process.getUserId()); + item.put("userName", process.getUserName()); + item.put("operationTime", formatTime(process.getOperationTime())); + item.put("scheduleState", process.getScheduleState()); + item.put("scheduleStateText", text(SCHEDULE_STATE_TEXT, process.getScheduleState())); + item.put("auditState", process.getAuditState()); + item.put("auditStateText", text(AUDIT_STATE_TEXT, process.getAuditState())); + item.put("auditLevel", process.getAuditLevel()); + item.put("operationTheme", process.getOperationTheme()); + item.put("operationThemeText", text(OPERATION_THEME_TEXT, process.getOperationTheme())); + item.put("operationType", process.getOperationType()); + item.put("operationTypeText", text(OPERATION_TYPE_TEXT, process.getOperationType())); + item.put("memo", process.getMemo()); + return item; + } + + private List> fileRecords(List pics, int fileType) { + return pics.stream().filter(pic -> Integer.valueOf(fileType).equals(pic.getFileType())) + .map(pic -> { + Map file = new LinkedHashMap<>(); + file.put("id", pic.getId()); + file.put("fileName", pic.getFileName()); + file.put("bucket", pic.getOssbucketname()); + file.put("objectKey", nullToEmpty(pic.getOssfolder()) + nullToEmpty(pic.getFileName())); + return file; + }).collect(Collectors.toList()); + } + + private Map relatedOutOrder(WarehouseIn order) { + if (!StringUtils.hasText(order.getOutOrderNo())) return null; + Map related = new LinkedHashMap<>(); + related.put("outOrderNo", order.getOutOrderNo()); + related.put("queryTool", "warehouse.out.detail.query"); + return related; + } + + private static void like(LambdaQueryWrapper wrapper, + SFunction column, String value) { + if (StringUtils.hasText(value)) wrapper.like(column, value.trim()); + } + + private static List distinctNonBlank(List values) { + Set distinct = values.stream().filter(StringUtils::hasText) + .collect(Collectors.toCollection(LinkedHashSet::new)); + return new ArrayList<>(distinct); + } + + private static String sourceType(WarehouseIn order) { + if (StringUtils.hasText(order.getAllocationOrderNo())) return "ALLOCATION"; + if (StringUtils.hasText(order.getOutOrderNo())) return "OUTBOUND_LINKED"; + if (StringUtils.hasText(order.getMallOrderNo())) return "MALL"; + return "NORMAL"; + } + + private static String text(Map texts, Integer code) { + return code == null ? null : texts.getOrDefault(code, "未知"); + } + + private static String formatTime(Date date) { + return date == null ? null : TIME_FORMATTER.format(date.toInstant()); + } + + private static String nullToEmpty(String value) { + return value == null ? "" : value; + } + + private static LocalDate parseLocalDate(String value, String name) { + if (!StringUtils.hasText(value)) return null; + String trimmed = value.trim(); + if (!DATE_PATTERN.matcher(trimmed).matches()) { + throw new IllegalArgumentException(name + " 格式必须为 yyyy-MM-dd"); + } + try { + return LocalDate.parse(trimmed, 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(T from, T to, String fromName, String toName) { + if (from != null && to != null && from.compareTo(to) > 0) { + throw new IllegalArgumentException(fromName + " 不能晚于 " + toName); + } + } +} 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 24d3d53..d4d1f80 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 @@ -11,6 +11,7 @@ import me.iiv.agent.entity.m0.User; import me.iiv.agent.entity.m1.MallGoodsOrder; import me.iiv.agent.entity.m2.ContractNew; import me.iiv.agent.entity.m2.WarehouseAgentAuth; +import me.iiv.agent.entity.m2.WarehouseIn; import me.iiv.agent.entity.m2.WarehouseOut; import me.iiv.agent.mapper.m0.AgentManageDistrictAuthMapper; import me.iiv.agent.mapper.m0.AgentMapper; @@ -159,6 +160,37 @@ public class PermissionService { .or().apply(participantExists, user.getId())); } + /** 入库单权限:主单代理、可管理仓库、本人参与流程三类可见范围取并集。 */ + public void applyWarehouseInRowFilter(LambdaQueryWrapper wrapper, User user) { + List agentIds = visibleAgentIds(user); + if (agentIds == null) { + return; + } + if (agentIds.isEmpty()) { + wrapper.eq(WarehouseIn::getId, 0L); + return; + } + + String firstIds = placeholders(0, agentIds.size()); + String secondIds = placeholders(agentIds.size(), agentIds.size()); + Object[] warehouseArgs = new Object[agentIds.size() * 2]; + for (int i = 0; i < agentIds.size(); i++) { + warehouseArgs[i] = agentIds.get(i); + warehouseArgs[i + agentIds.size()] = agentIds.get(i); + } + String visibleWarehouseExists = "EXISTS (SELECT 1 FROM warehouse_in_details wid " + + "JOIN warehouse w ON w.id = wid.warehouse_id AND w.soft_delete = 0 " + + "WHERE wid.warehouse_in_id = warehouse_in.id AND wid.soft_delete = 0 " + + "AND (w.agent_id IN (" + firstIds + ") OR w.manager_agent_id IN (" + secondIds + ")))"; + String participantExists = "EXISTS (SELECT 1 FROM warehouse_in_audit_process wiap " + + "WHERE wiap.warehouse_in_id = warehouse_in.id AND wiap.soft_delete = 0 " + + "AND wiap.user_id = {0})"; + + wrapper.and(w -> w.in(WarehouseIn::getAgentId, agentIds) + .or().apply(visibleWarehouseExists, warehouseArgs) + .or().apply(participantExists, user.getId())); + } + private static String placeholders(int offset, int size) { return IntStream.range(offset, offset + size) .mapToObj(i -> "{" + i + "}") diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/WarehouseInToolsTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/WarehouseInToolsTest.java new file mode 100644 index 0000000..0b5725d --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/WarehouseInToolsTest.java @@ -0,0 +1,240 @@ +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.WarehouseIn; +import me.iiv.agent.entity.m2.WarehouseInAuditProcess; +import me.iiv.agent.entity.m2.WarehouseInDetail; +import me.iiv.agent.entity.m2.WarehouseInOutPic; +import me.iiv.agent.entity.m2.WarehouseType; +import me.iiv.agent.mapper.m2.WarehouseInAuditProcessMapper; +import me.iiv.agent.mapper.m2.WarehouseInDetailMapper; +import me.iiv.agent.mapper.m2.WarehouseInMapper; +import me.iiv.agent.mapper.m2.WarehouseInOutPicMapper; +import me.iiv.agent.mapper.m2.WarehouseTypeMapper; +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.time.LocalDate; +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +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 WarehouseInToolsTest { + + @Mock WarehouseInMapper warehouseInMapper; + @Mock WarehouseInDetailMapper detailMapper; + @Mock WarehouseInAuditProcessMapper processMapper; + @Mock WarehouseInOutPicMapper picMapper; + @Mock WarehouseTypeMapper warehouseTypeMapper; + @Mock PermissionService permissionService; + + WarehouseInTools tools; + + @BeforeEach + void setUp() { + MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), ""); + TableInfoHelper.initTableInfo(assistant, WarehouseIn.class); + TableInfoHelper.initTableInfo(assistant, WarehouseInDetail.class); + TableInfoHelper.initTableInfo(assistant, WarehouseInAuditProcess.class); + TableInfoHelper.initTableInfo(assistant, WarehouseInOutPic.class); + TableInfoHelper.initTableInfo(assistant, WarehouseType.class); + tools = new WarehouseInTools(warehouseInMapper, detailMapper, processMapper, picMapper, + warehouseTypeMapper, permissionService); + User user = new User(); + user.setId(7L); + AuthContextHolder.set(new AuthContext(null, user)); + } + + @AfterEach + void tearDown() { + AuthContextHolder.clear(); + } + + @Test + void listAppliesPermissionAndBatchLoadsRelations() { + WarehouseIn order = order(); + when(warehouseInMapper.selectCount(any())).thenReturn(1L); + when(warehouseInMapper.selectList(any())).thenReturn(List.of(order)); + WarehouseInDetail detail = detail("SN-1", 2); + when(detailMapper.selectList(any())).thenReturn(List.of(detail)); + WarehouseInAuditProcess schedule = new WarehouseInAuditProcess(); + schedule.setWarehouseInId(20L); + schedule.setUserName("王五"); + when(processMapper.selectList(any())).thenReturn(List.of(schedule)); + + Map result = list(0, 500); + + assertEquals(1L, result.get("total")); + assertEquals(1, result.get("page")); + assertEquals(100, result.get("rows")); + @SuppressWarnings("unchecked") + Map record = ((List>) result.get("records")).get(0); + assertEquals("ALLOCATION", record.get("sourceType")); + assertEquals("已完成", record.get("orderStateText")); + assertEquals(List.of(8L), record.get("warehouseIds")); + assertEquals(List.of("一号仓"), record.get("warehouseNames")); + assertEquals(List.of("李四"), record.get("warehouseInPeople")); + assertEquals(List.of("王五"), record.get("schedulePeople")); + verify(permissionService).applyWarehouseInRowFilter(any(), any()); + } + + @Test + void listRejectsInvalidAndReversedDates() { + assertThrows(IllegalArgumentException.class, () -> listWithDates("2026-02-30", null)); + assertThrows(IllegalArgumentException.class, () -> listWithDates("2026-09-16", "2026-09-15")); + verifyNoInteractions(warehouseInMapper, permissionService); + } + + @Test + void detailUsesSamePermissionAndHidesExistence() { + when(warehouseInMapper.selectList(any())).thenReturn(List.of()); + + ToolCallException error = assertThrows(ToolCallException.class, + () -> tools.warehouseInDetailQuery(20L, null, false, null)); + + assertEquals(McpErrorCode.NOT_FOUND_OR_FORBIDDEN, error.getCode()); + verify(permissionService).applyWarehouseInRowFilter(any(), any()); + verifyNoInteractions(detailMapper, processMapper, picMapper, warehouseTypeMapper); + } + + @Test + void detailAllowsAllocationAndReturnsAggregatesSerialsFilesAndOutIndex() { + WarehouseIn order = order(); + order.setOutOrderNo("CK-001"); + when(warehouseInMapper.selectList(any())).thenReturn(List.of(order)); + WarehouseInDetail first = detail("SN-1", 1); + WarehouseInDetail second = detail("SN-2", 2); + when(detailMapper.selectList(any())).thenReturn(List.of(first, second)); + WarehouseInAuditProcess process = new WarehouseInAuditProcess(); + process.setOperationTheme(3); + process.setOperationType(1); + when(processMapper.selectList(any())).thenReturn(List.of(process)); + when(picMapper.selectList(any())).thenReturn(List.of(pic(1L, 0), pic(2L, 1))); + WarehouseType type = new WarehouseType(); + type.setSoftDelete(0); + type.setSceneType(1); + when(warehouseTypeMapper.selectById(3L)).thenReturn(type); + + Map result = tools.warehouseInDetailQuery(20L, "RK-001", true, 1); + + assertEquals(1, result.get("sceneType")); + @SuppressWarnings("unchecked") + List> items = (List>) result.get("items"); + assertEquals(1, items.size()); + assertEquals(3, items.get(0).get("inCount")); + assertEquals(2L, result.get("serialTotal")); + assertTrue((Boolean) result.get("serialTruncated")); + assertEquals(1, ((List) result.get("serials")).size()); + @SuppressWarnings("unchecked") + Map files = (Map) result.get("files"); + assertEquals(1, ((List) files.get("application")).size()); + assertEquals(1, ((List) files.get("confirmation")).size()); + @SuppressWarnings("unchecked") + Map related = (Map) result.get("relatedOutOrder"); + assertEquals("CK-001", related.get("outOrderNo")); + assertEquals("warehouse.out.detail.query", related.get("queryTool")); + } + + @Test + void detailDoesNotReturnSerialRowsUnlessRequested() { + WarehouseIn order = order(); + order.setOutOrderNo(null); + when(warehouseInMapper.selectList(any())).thenReturn(List.of(order)); + when(detailMapper.selectList(any())).thenReturn(List.of(detail("SN-1", 1))); + when(processMapper.selectList(any())).thenReturn(List.of()); + when(picMapper.selectList(any())).thenReturn(List.of()); + + Map result = tools.warehouseInDetailQuery(20L, null, false, null); + + assertEquals(List.of(), result.get("serials")); + assertEquals(1L, result.get("serialTotal")); + assertFalse((Boolean) result.get("serialTruncated")); + assertNull(result.get("relatedOutOrder")); + } + + @Test + void springAiPublishesBothWarehouseInToolSchemas() { + 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("warehouse.in.list.query").contains("inDateFrom")); + assertTrue(schemas.get("warehouse.in.list.query").contains("withdrawOrderNo")); + assertTrue(schemas.get("warehouse.in.detail.query").contains("includeSerials")); + } + + private Map list(Integer page, Integer rows) { + return tools.warehouseInListQuery(null, null, null, null, null, null, null, null, + null, null, null, null, 3, null, null, null, + "2026-09-01", "2026-09-30", null, null, page, rows); + } + + private Map listWithDates(String from, String to) { + return tools.warehouseInListQuery(null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, + from, to, null, null, 1, 20); + } + + private static WarehouseIn order() { + WarehouseIn order = new WarehouseIn(); + order.setId(20L); + order.setInOrderNo("RK-001"); + order.setAllocationOrderNo("DB-001"); + order.setWarehouseTypeId(3L); + order.setOrderState(3); + return order; + } + + private static WarehouseInDetail detail(String sn, int count) { + WarehouseInDetail detail = new WarehouseInDetail(); + detail.setId(100L + count); + detail.setWarehouseInId(20L); + detail.setWarehouseId(8L); + detail.setWarehouseName("一号仓"); + detail.setMaterielDefinitionId(9L); + detail.setMaterielIndexId(10L); + detail.setMaterialNo("MAT-1"); + detail.setSnCode(sn); + detail.setInCount(count); + detail.setInConfirmUserName("李四"); + detail.setInDate(LocalDate.of(2026, 9, 15)); + detail.setInConfirmTime(new Date(0)); + return detail; + } + + private static WarehouseInOutPic pic(long id, int fileType) { + WarehouseInOutPic pic = new WarehouseInOutPic(); + pic.setId(id); + pic.setFileType(fileType); + pic.setInOutAllocationType(0); + pic.setFileName("file-" + id + ".jpg"); + pic.setOssfolder("warehouse/"); + return pic; + } +} diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceWarehouseInRowFilterTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceWarehouseInRowFilterTest.java new file mode 100644 index 0000000..cb376f9 --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceWarehouseInRowFilterTest.java @@ -0,0 +1,104 @@ +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.Agent; +import me.iiv.agent.entity.m0.Authority; +import me.iiv.agent.entity.m0.User; +import me.iiv.agent.entity.m2.WarehouseAgentAuth; +import me.iiv.agent.entity.m2.WarehouseIn; +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 PermissionServiceWarehouseInRowFilterTest { + @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(), ""), WarehouseIn.class); + service = new PermissionService(authorityMapper, agentMapper, warehouseAgentAuthMapper, + agentManageDistrictAuthMapper, properytMapper, redisTemplate); + } + + @Test + void headquartersAddsNoFilter() { + User user = user(7L, 9L, null); + when(authorityMapper.selectById(9L)).thenReturn(authority(9L, 0)); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + service.applyWarehouseInRowFilter(wrapper, user); + assertTrue(wrapper.getSqlSegment().isBlank()); + } + + @Test + void agentFilterCombinesAgentWarehouseAndParticipantVisibility() { + User user = user(7L, 9L, 10L); + when(authorityMapper.selectById(9L)).thenReturn(authority(9L, 1)); + Agent child = new Agent(); + child.setId(11L); + when(agentMapper.selectList(any())).thenReturn(List.of(child)); + WarehouseAgentAuth managed = new WarehouseAgentAuth(); + managed.setManagerAgentId(12L); + when(warehouseAgentAuthMapper.selectList(any())).thenReturn(List.of(managed)); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + service.applyWarehouseInRowFilter(wrapper, user); + + String sql = wrapper.getSqlSegment(); + Collection values = wrapper.getParamNameValuePairs().values(); + assertTrue(sql.contains("agent_id IN")); + assertTrue(sql.contains("warehouse_in_details")); + assertTrue(sql.contains("warehouse_in_audit_process")); + assertTrue(sql.contains("OR")); + assertTrue(values.containsAll(List.of(7L, 10L, 11L, 12L))); + } + + @Test + void agentWithoutAgentIdFailsClosed() { + User user = user(7L, 9L, null); + when(authorityMapper.selectById(9L)).thenReturn(authority(9L, 1)); + assertThrows(IllegalStateException.class, + () -> service.applyWarehouseInRowFilter(new LambdaQueryWrapper<>(), user)); + } + + 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(Long id, int isAgent) { + Authority authority = new Authority(); + authority.setId(id); + authority.setIsagent(isAgent); + authority.setSoftDelete(0); + return authority; + } +}