feat(mcp): add warehouse outbound query tools #14

Merged
wangheng merged 1 commits from feat/warehouse-out-mcp-tools into main 2026-09-15 15:30:44 +08:00
16 changed files with 1467 additions and 0 deletions
@@ -450,6 +450,75 @@
---
## 15. warehouse.out.list.query
分页查询出库单,参考旧系统 `POST /myWarehouseOut/allListByPage`,但修复其出库日期误映射为申请日期、
部分渠道未过滤和逐单查询明细的问题。
- 分类:warehouse | 类型:读
- 数据来源:m2 `warehouse_out`、`warehouse_out_details`、`warehouse_out_audit_process`
- 行级权限:总部看全部;代理身份可看主单所属代理为自己/直属下级/授权代管代理的出库单,或明细涉及
上述代理所属/负责管理仓库的出库单,或本人参与审核/处理的出库单。三类条件取并集;筛选条件只能缩小结果。
- 列表关联数据按本页主单 ID 批量查询,不逐单 N+1 查询。
**入参**:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| outOrderNo / allocationOrderNo / mallOrderNo | string | 否 | 出库/调拨/商城单号,模糊匹配 |
| deliveryOrderNo / installOrderNo / pickupOrderNo | string | 否 | 送货/装机/取货工单号,模糊匹配 |
| agentId / warehouseId / warehouseTypeId / applyUserId | number | 否 | 所属代理/涉及仓库/出库类型/申请人 ID,精确匹配 |
| orderState | number | 否 | 0=待审核 1=被驳回 2=待出库 3=已完成 4=已撤销 5=作废 |
| sendMode | number | 否 | 0=快递公司 1=自行派送 |
| outSynchronizeState | number | 否 | 0=未同步 1=已同步 2=无需同步 |
| assetSynchronizeState | number | 否 | -1=无需同步 0=未同步 1=已同步 |
| useCustomerType / useCustomerId | number | 否 | 客户类型(0内部/1渠道商/2直营)和客户 ID |
| useCustomerName | string | 否 | 客户名称,模糊匹配 |
| outDateFrom / outDateTo | string | 否 | 出库日期范围,`yyyy-MM-dd`,结束日期含当天 |
| applyDateFrom / applyDateTo | string | 否 | 申请日期范围,`yyyy-MM-dd`,结束日期含当天 |
| page / rows | number | 否 | 页码默认 1;每页默认 20、上限 100 |
**出参 records 字段**:主单 `id/outOrderNo/allocationOrderNo/mallOrderNo`、`sourceType`
(NORMAL/ALLOCATION/MALL)、代理和出库类型、`warehouseIds/warehouseNames`、出库/商品数量、状态码与文案、
申请人/时间、客户、`schedulePeople`、`warehouseOutPeople/warehouseOutTimes`、发货物流、同步状态、
关联送货/装机/取货单号、备注及创建/修改时间。
---
## 16. warehouse.out.detail.query
按主键或出库单号查询完整出库单详情;调拨出库单可直接查询,以 `sourceType=ALLOCATION` 标识。
- 分类:warehouse | 类型:读
- 行级权限与 `warehouse.out.list.query` 完全一致,并加入同一个主表查询。
- 不存在或无权访问统一返回 `NOT_FOUND_OR_FORBIDDEN`,不泄露记录是否存在。
**入参**:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| id | number | 条件必填 | 出库单主键;与 outOrderNo 至少传一个 |
| outOrderNo | string | 条件必填 | 出库单号;与 id 至少传一个;同时传入时必须匹配同一条记录 |
| includeSerials | boolean | 否 | 是否返回 SN 子行,默认 false |
| serialLimit | number | 否 | SN 返回上限,默认 100、最大 500 |
**出参**:
- `record`:主单、来源类型、状态文案、客户、同步状态及关联工单号。
- `warehouses`:物料汇总行涉及的仓库去重集合。
- `items`:`parent_id=0` 物料汇总行,含物料、仓库、数量、价格和出库确认信息。
- `serials/serialTotal/serialTruncated`:可选 SN 子行、完整总数和截断标记;未请求 SN 时
`serials=[]`、`serialTruncated=false`。
- `processes`:已处理记录(`schedule_state=2`),按操作时间、ID 正序,状态码与文案同时返回。
- `address/logistics`:m1 收货地址及发货物流;无地址时 `address=null`。
- `files.application/files.confirmation`:申请/确认图片,返回 `id/fileName/bucket/objectKey`;服务不持有
OSS 签名密钥,不生成临时访问 URL。
- `certificate`:出库凭证备注及凭证文件;均不存在时为 null。
详细设计与取舍见 `warehouse-out-mcp-tools-design.md`。
---
## 附录:在线查看工具清单
```bash
@@ -0,0 +1,91 @@
# 出库单 MCP 工具设计
## 1. 背景与范围
参考旧系统 `POST /myWarehouseOut/allListByPage` 和 `GET /myWarehouseOut/lookDetail`,在
`xiaoqu-agent-service` 新增两个只读工具:
| 工具 | 分类 | 用途 |
|---|---|---|
| `warehouse.out.list.query` | warehouse/read | 分页查询出库单 |
| `warehouse.out.detail.query` | warehouse/read | 按主键或出库单号查询完整详情 |
只读取 m0/m1/m2,主要数据表为 m2 的 `warehouse_out`、`warehouse_out_details`、
`warehouse`、`warehouse_out_audit_process`、`warehouse_in_out_pic`、
`warehouse_out_certificate`;收货地址和物流信息按需读取 m1。
本次不返回关联入库单详情,不修改旧模块和业务表,不新增 Flyway migration。新工具由现有
`ToolSyncService` 自动登记,初始不属于任何 Scope,发布后需在管理后台授权。
## 2. 列表工具
### 2.1 入参
- 单号模糊查询:`outOrderNo`、`allocationOrderNo`、`mallOrderNo`、`deliveryOrderNo`、
`installOrderNo`、`pickupOrderNo`。
- 归属精确查询:`agentId`、`warehouseId`、`warehouseTypeId`、`applyUserId`。
- 状态精确查询:`orderState`、`sendMode`、`outSynchronizeState`、`assetSynchronizeState`。
- 客户查询:`useCustomerType`、`useCustomerId` 精确查询,`useCustomerName` 模糊查询。
- 日期:`outDateFrom/outDateTo` 过滤 `out_date`;`applyDateFrom/applyDateTo` 过滤
`apply_date`。日期格式固定为 `yyyy-MM-dd`,业务时区为 `Asia/Shanghai`,结束日期包含当天。
- 分页:`page` 默认 1;`rows` 默认 20、上限 100,超出上限截断并返回 `notice`。
旧接口错误地把出库日期条件改写为申请日期条件,本工具不复制该行为。
### 2.2 出参
顶层统一返回 `total/page/rows/records`。每条记录包含主单号、来源类型、代理商、出库类型、
仓库集合、数量、状态及状态文案、申请人及申请时间、客户、待办人、出库确认人及日期、物流、
同步状态、关联工单号和备注。
`sourceType`:有调拨单号为 `ALLOCATION`,否则有商城订单号为 `MALL`,其余为 `NORMAL`。
`orderState`:0=待审核、1=被驳回、2=待出库、3=已完成、4=已撤销、5=作废。
列表页的仓库、待办人、出库人均按本页主单 ID 批量查询和内存聚合,禁止逐单 N+1 查询。
## 3. 详情工具
### 3.1 入参
- `id`、`outOrderNo` 至少传一个;同时传入时必须指向同一条可见记录。
- `includeSerials` 默认 `false`。
- `serialLimit` 默认 100、最大 500;只限制返回数量,`serialTotal` 始终返回完整数量。
### 3.2 出参
- `record`:主单字段、来源、状态文案、客户、同步状态和关联工单号。
- `warehouses`:出库明细涉及的仓库 ID/名称去重集合。
- `items`:`parent_id=0` 的物料汇总行,含物料编号、名称、分类、规格、仓库、数量、单价、
总额、出库人及时间。
- `serials`:仅 `includeSerials=true` 返回 SN 子行;另返回 `serialTotal/serialTruncated`。
- `processes`:`schedule_state=2` 的已处理记录,按操作时间、ID 正序,状态码与文案同时返回。
- `address`:商城订单地址或主单地址。
- `logistics`:发货方式、快递公司、运单号、配送人和收货信息。
- `files.application/files.confirmation`:申请图片和确认出库图片。服务不持有 OSS 签名密钥,
返回文件 ID、文件名及存储对象定位信息,不生成临时访问 URL。
- `certificate`:凭证备注与 `file_type=4/in_out_allocation_type=99` 的凭证文件。
调拨出库单允许直接查询,并以 `sourceType=ALLOCATION` 标识。
## 4. 行级权限
列表和详情使用完全相同的主查询权限条件,业务筛选只能缩小结果:
- 总部身份(权限组 `isagent=0`):不过滤。
- 代理商身份(权限组 `isagent=1`):可见范围为以下条件的并集:
- 主单 `agent_id` 属于自己、直属下级或 `warehouse_agent_auth` 授权代管代理;
- 出库明细涉及的仓库,其 `warehouse.agent_id` 或 `warehouse.manager_agent_id` 属于上述代理范围;
- 当前用户存在该主单的未删除审核/处理记录。
- 代理商用户未关联代理商、权限组不存在或配置异常时失败关闭。
详情不存在与无权访问统一抛出 `NOT_FOUND_OR_FORBIDDEN`,防止通过 ID/单号探测数据。
权限通过主表查询中的关联 `EXISTS` 条件完成,不先查询大量 ID 再拼接 `IN`。
## 5. 测试与验收
- 列表:分页上限、各类日期/状态/仓库条件、批量聚合和状态文案。
- 详情:ID/单号参数校验、双条件一致性、调拨单、SN 默认关闭与截断、附件/凭证分组。
- 权限:总部、代理范围、负责管理仓库、处理人、无代理失败关闭;详情查询必须复用相同过滤器。
- 构建:固定 `TZ=Asia/Shanghai` 执行后端测试;不连接生产数据库、不执行部署。
@@ -0,0 +1,27 @@
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_in_out_pic")
public class WarehouseInOutPic implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private Integer softDelete;
private Long aggregateId;
private String fileName;
private String ossbucketname;
private String ossfolder;
private Integer fileType;
private Integer inOutAllocationType;
}
@@ -0,0 +1,61 @@
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_out")
public class WarehouseOut 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 outOrderNo;
private String allocationOrderNo;
private String mallOrderNo;
private Long agentId;
private String agentName;
private Long warehouseTypeId;
private String warehouseTypeName;
private LocalDate outDate;
private Integer goodsCount;
private Integer outCount;
private Integer orderState;
private Long applyUserId;
private String applyUserName;
private Date applyDate;
private String memo;
private Integer auditLevel;
private Integer currendAuditLevel;
private Integer useCustomerType;
private Long useCustomerId;
private String useCustomerName;
private Integer outSynchronizeState;
private Date outSynchronizeTime;
private String removeUserName;
private Date removeDate;
private Integer sendMode;
private Long logisticsId;
private String logisticsName;
private String logisticsOrderNo;
private String logisticsWayBill;
private Long addressId;
private Integer assetSynchronizeState;
private String deliveryOrderNo;
private String installOrderNo;
private String pickupOrderNo;
}
@@ -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_out_audit_process")
public class WarehouseOutAuditProcess implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private Integer softDelete;
private Long warehouseOutId;
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;
}
@@ -0,0 +1,27 @@
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_out_certificate")
public class WarehouseOutCertificate implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private Long outOrderId;
private String outOrderNo;
private String memo;
private Date createTime;
private Date modifyTime;
private Integer softDelete;
}
@@ -0,0 +1,57 @@
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.math.BigDecimal;
import java.time.LocalDate;
import java.util.Date;
/** 出库单物料/SN 明细(m2,只读)。 */
@Getter
@Setter
@ToString
@TableName("warehouse_out_details")
public class WarehouseOutDetail 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 Long warehouseId;
private String warehouseName;
private Integer outCount;
private String outOrderNo;
private String allocationOrderNo;
private String mallOrderNo;
private Long warehouseOutId;
private Long materielDefinitionId;
private String materialNo;
private String materielFullName;
private String materielSort;
private Long materielIndexId;
private String specifications;
private String snCode;
private LocalDate outDate;
private Long warehouseTypeId;
private String warehouseTypeName;
private Integer orderState;
private Integer queryFlag;
private Long parentId;
private Date outConfirmTime;
private String outConfirmUserName;
private BigDecimal price;
private BigDecimal allMoney;
private String threeAttribute;
private String assetDisposalType;
private Integer assetSynchronizeState;
}
@@ -0,0 +1,7 @@
package me.iiv.agent.mapper.m2;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import me.iiv.agent.entity.m2.WarehouseInOutPic;
public interface WarehouseInOutPicMapper extends BaseMapper<WarehouseInOutPic> {
}
@@ -0,0 +1,7 @@
package me.iiv.agent.mapper.m2;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import me.iiv.agent.entity.m2.WarehouseOutAuditProcess;
public interface WarehouseOutAuditProcessMapper extends BaseMapper<WarehouseOutAuditProcess> {
}
@@ -0,0 +1,7 @@
package me.iiv.agent.mapper.m2;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import me.iiv.agent.entity.m2.WarehouseOutCertificate;
public interface WarehouseOutCertificateMapper extends BaseMapper<WarehouseOutCertificate> {
}
@@ -0,0 +1,7 @@
package me.iiv.agent.mapper.m2;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import me.iiv.agent.entity.m2.WarehouseOutDetail;
public interface WarehouseOutDetailMapper extends BaseMapper<WarehouseOutDetail> {
}
@@ -0,0 +1,7 @@
package me.iiv.agent.mapper.m2;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import me.iiv.agent.entity.m2.WarehouseOut;
public interface WarehouseOutMapper extends BaseMapper<WarehouseOut> {
}
@@ -0,0 +1,616 @@
package me.iiv.agent.mcp.tool;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import me.iiv.agent.entity.m0.User;
import me.iiv.agent.entity.m1.MallGoodsOrder;
import me.iiv.agent.entity.m1.MallOrderLogistics;
import me.iiv.agent.entity.m1.MallShippingAddress;
import me.iiv.agent.entity.m2.WarehouseInOutPic;
import me.iiv.agent.entity.m2.WarehouseOut;
import me.iiv.agent.entity.m2.WarehouseOutAuditProcess;
import me.iiv.agent.entity.m2.WarehouseOutCertificate;
import me.iiv.agent.entity.m2.WarehouseOutDetail;
import me.iiv.agent.mapper.m1.MallGoodsOrderMapper;
import me.iiv.agent.mapper.m1.MallOrderLogisticsMapper;
import me.iiv.agent.mapper.m1.MallShippingAddressMapper;
import me.iiv.agent.mapper.m2.WarehouseInOutPicMapper;
import me.iiv.agent.mapper.m2.WarehouseOutAuditProcessMapper;
import me.iiv.agent.mapper.m2.WarehouseOutCertificateMapper;
import me.iiv.agent.mapper.m2.WarehouseOutDetailMapper;
import me.iiv.agent.mapper.m2.WarehouseOutMapper;
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;
/** 出库单查询工具,参考老系统 /myWarehouseOut/allListByPage 与 /lookDetail。 */
@Component
public class WarehouseOutTools 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 OUT_TYPE = 1;
private static final int CERT_FILE_TYPE = 4;
private static final int CERT_TYPE = 99;
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<Integer, String> ORDER_STATE_TEXT = Map.of(
0, "待审核", 1, "被驳回", 2, "待出库", 3, "已完成", 4, "已撤销", 5, "作废");
private static final Map<Integer, String> CUSTOMER_TYPE_TEXT = Map.of(
0, "内部人员", 1, "渠道商", 2, "直营客户");
private static final Map<Integer, String> SEND_MODE_TEXT = Map.of(0, "快递公司", 1, "自行派送");
private static final Map<Integer, String> SCHEDULE_STATE_TEXT = Map.of(
0, "无需待办", 1, "待办", 2, "已办");
private static final Map<Integer, String> AUDIT_STATE_TEXT = Map.of(0, "审核驳回", 1, "审核通过");
private static final Map<Integer, String> OPERATION_THEME_TEXT = Map.of(
0, "审核通过", 1, "审核驳回", 2, "撤销出库", 3, "出库确认",
4, "驳回修改提交", 5, "撤销", 6, "出库单确认驳回");
private static final Map<Integer, String> OPERATION_TYPE_TEXT = Map.of(
0, "审批流操作", 1, "确认出库", 2, "本人修改", 3, "驳回或修改记录");
final WarehouseOutMapper warehouseOutMapper;
final WarehouseOutDetailMapper detailMapper;
final WarehouseOutAuditProcessMapper processMapper;
final WarehouseInOutPicMapper picMapper;
final WarehouseOutCertificateMapper certificateMapper;
final MallGoodsOrderMapper mallGoodsOrderMapper;
final MallShippingAddressMapper shippingAddressMapper;
final MallOrderLogisticsMapper logisticsMapper;
final PermissionService permissionService;
public WarehouseOutTools(WarehouseOutMapper warehouseOutMapper,
WarehouseOutDetailMapper detailMapper,
WarehouseOutAuditProcessMapper processMapper,
WarehouseInOutPicMapper picMapper,
WarehouseOutCertificateMapper certificateMapper,
MallGoodsOrderMapper mallGoodsOrderMapper,
MallShippingAddressMapper shippingAddressMapper,
MallOrderLogisticsMapper logisticsMapper,
PermissionService permissionService) {
this.warehouseOutMapper = warehouseOutMapper;
this.detailMapper = detailMapper;
this.processMapper = processMapper;
this.picMapper = picMapper;
this.certificateMapper = certificateMapper;
this.mallGoodsOrderMapper = mallGoodsOrderMapper;
this.shippingAddressMapper = shippingAddressMapper;
this.logisticsMapper = logisticsMapper;
this.permissionService = permissionService;
}
@XqTool(category = "warehouse", kind = "read")
@Tool(name = "warehouse.out.list.query", description = "分页查询出库单,参考老系统 /myWarehouseOut/allListByPage。"
+ "数据来自 m2 warehouse_out 及关联明细/流程。总部可看全部;代理身份可看所属代理范围、所属或负责仓库涉及、或本人参与处理的出库单。"
+ "出库日期与申请日期分别过滤;日期格式 yyyy-MM-dd,结束日期含当天。分页 page 默认1,rows 默认20、上限100。"
+ "orderState:0待审核、1被驳回、2待出库、3已完成、4已撤销、5作废。")
public Map<String, Object> warehouseOutListQuery(
@ToolParam(required = false, description = "出库单号,模糊匹配") String outOrderNo,
@ToolParam(required = false, description = "调拨单号,模糊匹配") String allocationOrderNo,
@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 = "所属代理商 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自行派送") Integer sendMode,
@ToolParam(required = false, description = "出库同步状态:0未同步 1已同步 2无需同步") Integer outSynchronizeState,
@ToolParam(required = false, description = "资产同步状态:-1无需同步 0未同步 1已同步") Integer assetSynchronizeState,
@ToolParam(required = false, description = "客户类型:0内部人员 1渠道商 2直营客户") Integer useCustomerType,
@ToolParam(required = false, description = "客户 id") Long useCustomerId,
@ToolParam(required = false, description = "客户名称,模糊匹配") String useCustomerName,
@ToolParam(required = false, description = "出库日期起,yyyy-MM-dd") String outDateFrom,
@ToolParam(required = false, description = "出库日期止,yyyy-MM-dd,含当天") String outDateTo,
@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 outStart = parseLocalDate(outDateFrom, "outDateFrom");
LocalDate outEnd = parseLocalDate(outDateTo, "outDateTo");
LocalDate applyStartDate = parseLocalDate(applyDateFrom, "applyDateFrom");
LocalDate applyEndDate = parseLocalDate(applyDateTo, "applyDateTo");
Date applyStart = startOfDay(applyStartDate);
Date applyEndExclusive = nextDayStart(applyEndDate);
validateRange(outStart, outEnd, "outDateFrom", "outDateTo");
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<WarehouseOut> wrapper = baseWrapper(outOrderNo, allocationOrderNo, mallOrderNo,
deliveryOrderNo, installOrderNo, pickupOrderNo, agentId, warehouseId, warehouseTypeId,
applyUserId, orderState, sendMode, outSynchronizeState, assetSynchronizeState,
useCustomerType, useCustomerId, useCustomerName, outStart, outEnd, applyStart, applyEndExclusive);
User user = AuthContextHolder.get().getUser();
permissionService.applyWarehouseOutRowFilter(wrapper, user);
Long total = warehouseOutMapper.selectCount(wrapper);
wrapper.orderByDesc(WarehouseOut::getId)
.last("LIMIT " + (long) (pageNo - 1) * pageSize + "," + pageSize);
List<WarehouseOut> orders = warehouseOutMapper.selectList(wrapper);
Map<Long, List<WarehouseOutDetail>> details = listDetails(orders);
Map<Long, List<WarehouseOutAuditProcess>> schedules = listSchedules(orders);
List<Map<String, Object>> records = orders.stream()
.map(order -> listRecord(order,
details.getOrDefault(order.getId(), List.of()),
schedules.getOrDefault(order.getId(), List.of())))
.collect(Collectors.toList());
Map<String, Object> 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.out.detail.query", description = "按 id 或出库单号查询出库单详情。"
+ "行级权限与 warehouse.out.list.query 完全一致,不存在与无权访问统一返回 NOT_FOUND_OR_FORBIDDEN。"
+ "返回主单、仓库、物料、可选 SN、已处理流程、地址、物流、申请/确认附件和出库凭证;调拨出库单可直接查询。"
+ "includeSerials 默认 false;serialLimit 默认100、上限500。")
public Map<String, Object> warehouseOutDetailQuery(
@ToolParam(required = false, description = "出库单主键 id,与 outOrderNo 至少传一个") Long id,
@ToolParam(required = false, description = "出库单号,与 id 至少传一个") String outOrderNo,
@ToolParam(required = false, description = "是否返回 SN 子明细,默认 false") Boolean includeSerials,
@ToolParam(required = false, description = "SN 返回上限,默认100、最大500") Integer serialLimit) {
if ((id == null || id <= 0) && !StringUtils.hasText(outOrderNo)) {
throw new IllegalArgumentException("id、outOrderNo 至少传一个");
}
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<WarehouseOut> wrapper = new LambdaQueryWrapper<WarehouseOut>()
.eq(WarehouseOut::getSoftDelete, 0);
if (id != null) {
wrapper.eq(WarehouseOut::getId, id);
}
if (StringUtils.hasText(outOrderNo)) {
wrapper.eq(WarehouseOut::getOutOrderNo, outOrderNo.trim());
}
permissionService.applyWarehouseOutRowFilter(wrapper, AuthContextHolder.get().getUser());
List<WarehouseOut> visible = warehouseOutMapper.selectList(wrapper.orderByDesc(WarehouseOut::getId).last("LIMIT 1"));
if (visible.isEmpty()) {
throw new ToolCallException(McpErrorCode.NOT_FOUND_OR_FORBIDDEN, "出库单不存在或当前身份无权查看");
}
WarehouseOut order = visible.get(0);
List<WarehouseOutDetail> items = detailMapper.selectList(new LambdaQueryWrapper<WarehouseOutDetail>()
.eq(WarehouseOutDetail::getSoftDelete, 0)
.eq(WarehouseOutDetail::getWarehouseOutId, order.getId())
.eq(WarehouseOutDetail::getParentId, 0L)
.orderByAsc(WarehouseOutDetail::getId));
LambdaQueryWrapper<WarehouseOutDetail> serialBase = new LambdaQueryWrapper<WarehouseOutDetail>()
.eq(WarehouseOutDetail::getSoftDelete, 0)
.eq(WarehouseOutDetail::getWarehouseOutId, order.getId())
.ne(WarehouseOutDetail::getParentId, 0L);
long serialTotal = detailMapper.selectCount(serialBase);
List<WarehouseOutDetail> serialRows = include
? detailMapper.selectList(serialBase.orderByAsc(WarehouseOutDetail::getId).last("LIMIT " + limit))
: List.of();
List<WarehouseOutAuditProcess> processes = processMapper.selectList(
new LambdaQueryWrapper<WarehouseOutAuditProcess>()
.eq(WarehouseOutAuditProcess::getSoftDelete, 0)
.eq(WarehouseOutAuditProcess::getWarehouseOutId, order.getId())
.eq(WarehouseOutAuditProcess::getScheduleState, 2)
.orderByAsc(WarehouseOutAuditProcess::getOperationTime)
.orderByAsc(WarehouseOutAuditProcess::getId));
List<WarehouseInOutPic> pics = picMapper.selectList(new LambdaQueryWrapper<WarehouseInOutPic>()
.eq(WarehouseInOutPic::getSoftDelete, 0)
.eq(WarehouseInOutPic::getAggregateId, order.getId())
.orderByAsc(WarehouseInOutPic::getId));
Map<String, Object> result = new LinkedHashMap<>();
result.put("record", detailRecord(order));
result.put("warehouses", warehouseRecords(items));
result.put("items", items.stream().map(this::itemRecord).collect(Collectors.toList()));
result.put("serials", serialRows.stream().map(this::serialRecord).collect(Collectors.toList()));
result.put("serialTotal", serialTotal);
result.put("serialTruncated", include && serialTotal > serialRows.size());
result.put("processes", processes.stream().map(this::processRecord).collect(Collectors.toList()));
result.put("address", addressRecord(order));
result.put("logistics", logisticsRecord(order));
Map<String, Object> files = new LinkedHashMap<>();
files.put("application", fileRecords(pics, 0, OUT_TYPE));
files.put("confirmation", fileRecords(pics, 2, OUT_TYPE));
result.put("files", files);
result.put("certificate", certificateRecord(order, pics));
return result;
}
private LambdaQueryWrapper<WarehouseOut> baseWrapper(
String outOrderNo, String allocationOrderNo, String mallOrderNo,
String deliveryOrderNo, String installOrderNo, String pickupOrderNo,
Long agentId, Long warehouseId, Long warehouseTypeId, Long applyUserId,
Integer orderState, Integer sendMode, Integer outSynchronizeState, Integer assetSynchronizeState,
Integer useCustomerType, Long useCustomerId, String useCustomerName,
LocalDate outStart, LocalDate outEnd, Date applyStart, Date applyEndExclusive) {
LambdaQueryWrapper<WarehouseOut> wrapper = new LambdaQueryWrapper<WarehouseOut>()
.eq(WarehouseOut::getSoftDelete, 0);
like(wrapper, WarehouseOut::getOutOrderNo, outOrderNo);
like(wrapper, WarehouseOut::getAllocationOrderNo, allocationOrderNo);
like(wrapper, WarehouseOut::getMallOrderNo, mallOrderNo);
like(wrapper, WarehouseOut::getDeliveryOrderNo, deliveryOrderNo);
like(wrapper, WarehouseOut::getInstallOrderNo, installOrderNo);
like(wrapper, WarehouseOut::getPickupOrderNo, pickupOrderNo);
wrapper.eq(agentId != null, WarehouseOut::getAgentId, agentId)
.eq(warehouseTypeId != null, WarehouseOut::getWarehouseTypeId, warehouseTypeId)
.eq(applyUserId != null, WarehouseOut::getApplyUserId, applyUserId)
.eq(orderState != null, WarehouseOut::getOrderState, orderState)
.eq(sendMode != null, WarehouseOut::getSendMode, sendMode)
.eq(outSynchronizeState != null, WarehouseOut::getOutSynchronizeState, outSynchronizeState)
.eq(assetSynchronizeState != null, WarehouseOut::getAssetSynchronizeState, assetSynchronizeState)
.eq(useCustomerType != null, WarehouseOut::getUseCustomerType, useCustomerType)
.eq(useCustomerId != null, WarehouseOut::getUseCustomerId, useCustomerId)
.like(StringUtils.hasText(useCustomerName), WarehouseOut::getUseCustomerName,
StringUtils.hasText(useCustomerName) ? useCustomerName.trim() : useCustomerName)
.ge(outStart != null, WarehouseOut::getOutDate, outStart)
.le(outEnd != null, WarehouseOut::getOutDate, outEnd)
.ge(applyStart != null, WarehouseOut::getApplyDate, applyStart)
.lt(applyEndExclusive != null, WarehouseOut::getApplyDate, applyEndExclusive);
if (warehouseId != null) {
wrapper.apply("EXISTS (SELECT 1 FROM warehouse_out_details wod_filter "
+ "WHERE wod_filter.warehouse_out_id = warehouse_out.id "
+ "AND wod_filter.soft_delete = 0 AND wod_filter.warehouse_id = {0})", warehouseId);
}
return wrapper;
}
private static void like(LambdaQueryWrapper<WarehouseOut> wrapper,
com.baomidou.mybatisplus.core.toolkit.support.SFunction<WarehouseOut, ?> column,
String value) {
if (StringUtils.hasText(value)) {
wrapper.like(column, value.trim());
}
}
private Map<Long, List<WarehouseOutDetail>> listDetails(List<WarehouseOut> orders) {
if (orders.isEmpty()) {
return Map.of();
}
List<Long> ids = orders.stream().map(WarehouseOut::getId).collect(Collectors.toList());
return detailMapper.selectList(new LambdaQueryWrapper<WarehouseOutDetail>()
.eq(WarehouseOutDetail::getSoftDelete, 0)
.in(WarehouseOutDetail::getWarehouseOutId, ids)
.eq(WarehouseOutDetail::getParentId, 0L)
.orderByAsc(WarehouseOutDetail::getId)).stream()
.collect(Collectors.groupingBy(WarehouseOutDetail::getWarehouseOutId,
LinkedHashMap::new, Collectors.toList()));
}
private Map<Long, List<WarehouseOutAuditProcess>> listSchedules(List<WarehouseOut> orders) {
if (orders.isEmpty()) {
return Map.of();
}
List<Long> ids = orders.stream().map(WarehouseOut::getId).collect(Collectors.toList());
return processMapper.selectList(new LambdaQueryWrapper<WarehouseOutAuditProcess>()
.eq(WarehouseOutAuditProcess::getSoftDelete, 0)
.in(WarehouseOutAuditProcess::getWarehouseOutId, ids)
.eq(WarehouseOutAuditProcess::getScheduleState, 1)
.orderByAsc(WarehouseOutAuditProcess::getId)).stream()
.collect(Collectors.groupingBy(WarehouseOutAuditProcess::getWarehouseOutId,
LinkedHashMap::new, Collectors.toList()));
}
private Map<String, Object> listRecord(WarehouseOut order, List<WarehouseOutDetail> details,
List<WarehouseOutAuditProcess> schedules) {
Map<String, Object> record = detailRecord(order);
LinkedHashMap<Long, String> warehouses = new LinkedHashMap<>();
for (WarehouseOutDetail detail : details) {
if (detail.getWarehouseId() != null) {
warehouses.putIfAbsent(detail.getWarehouseId(), detail.getWarehouseName());
}
}
record.put("warehouseIds", new ArrayList<>(warehouses.keySet()));
record.put("warehouseNames", new ArrayList<>(warehouses.values()));
record.put("schedulePeople", distinctNonBlank(schedules.stream()
.map(WarehouseOutAuditProcess::getUserName).collect(Collectors.toList())));
record.put("warehouseOutPeople", distinctNonBlank(details.stream()
.map(WarehouseOutDetail::getOutConfirmUserName).collect(Collectors.toList())));
record.put("warehouseOutTimes", details.stream().map(WarehouseOutDetail::getOutDate)
.filter(Objects::nonNull).map(LocalDate::toString).distinct().collect(Collectors.toList()));
return record;
}
private Map<String, Object> detailRecord(WarehouseOut order) {
Map<String, Object> record = new LinkedHashMap<>();
record.put("id", order.getId());
record.put("outOrderNo", order.getOutOrderNo());
record.put("allocationOrderNo", order.getAllocationOrderNo());
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("outDate", order.getOutDate() == null ? null : order.getOutDate().toString());
record.put("goodsCount", order.getGoodsCount());
record.put("outCount", order.getOutCount());
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("useCustomerType", order.getUseCustomerType());
record.put("useCustomerTypeText", text(CUSTOMER_TYPE_TEXT, order.getUseCustomerType()));
record.put("useCustomerId", order.getUseCustomerId());
record.put("useCustomerName", order.getUseCustomerName());
record.put("sendMode", order.getSendMode());
record.put("sendModeText", text(SEND_MODE_TEXT, order.getSendMode()));
record.put("logisticsId", order.getLogisticsId());
record.put("logisticsName", order.getLogisticsName());
record.put("logisticsOrderNo", order.getLogisticsOrderNo());
record.put("logisticsWayBill", order.getLogisticsWayBill());
record.put("outSynchronizeState", order.getOutSynchronizeState());
record.put("outSynchronizeTime", formatTime(order.getOutSynchronizeTime()));
record.put("assetSynchronizeState", order.getAssetSynchronizeState());
record.put("deliveryOrderNo", order.getDeliveryOrderNo());
record.put("installOrderNo", order.getInstallOrderNo());
record.put("pickupOrderNo", order.getPickupOrderNo());
record.put("memo", order.getMemo());
record.put("createTime", formatTime(order.getCreateTime()));
record.put("modifyTime", formatTime(order.getModifyTime()));
return record;
}
private Map<String, Object> itemRecord(WarehouseOutDetail detail) {
Map<String, Object> item = commonDetailRecord(detail);
item.put("outCount", detail.getOutCount());
item.put("price", detail.getPrice());
item.put("allMoney", detail.getAllMoney());
item.put("outConfirmUserName", detail.getOutConfirmUserName());
item.put("outConfirmTime", formatTime(detail.getOutConfirmTime()));
item.put("outDate", detail.getOutDate() == null ? null : detail.getOutDate().toString());
return item;
}
private Map<String, Object> serialRecord(WarehouseOutDetail detail) {
Map<String, Object> item = commonDetailRecord(detail);
item.put("parentId", detail.getParentId());
item.put("snCode", detail.getSnCode());
item.put("assetDisposalType", detail.getAssetDisposalType());
item.put("assetSynchronizeState", detail.getAssetSynchronizeState());
return item;
}
private Map<String, Object> commonDetailRecord(WarehouseOutDetail detail) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("id", detail.getId());
item.put("materielDefinitionId", detail.getMaterielDefinitionId());
item.put("materialNo", detail.getMaterialNo());
item.put("materielFullName", detail.getMaterielFullName());
item.put("materielSort", detail.getMaterielSort());
item.put("specifications", detail.getSpecifications());
item.put("threeAttribute", detail.getThreeAttribute());
item.put("warehouseId", detail.getWarehouseId());
item.put("warehouseName", detail.getWarehouseName());
return item;
}
private List<Map<String, Object>> warehouseRecords(List<WarehouseOutDetail> items) {
LinkedHashMap<Long, String> warehouses = new LinkedHashMap<>();
for (WarehouseOutDetail item : items) {
if (item.getWarehouseId() != null) {
warehouses.putIfAbsent(item.getWarehouseId(), item.getWarehouseName());
}
}
return warehouses.entrySet().stream().map(entry -> {
Map<String, Object> warehouse = new LinkedHashMap<>();
warehouse.put("id", entry.getKey());
warehouse.put("name", entry.getValue());
return warehouse;
}).collect(Collectors.toList());
}
private Map<String, Object> processRecord(WarehouseOutAuditProcess process) {
Map<String, Object> 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 Map<String, Object> addressRecord(WarehouseOut order) {
Long addressId = order.getAddressId();
if (StringUtils.hasText(order.getMallOrderNo())) {
List<MallGoodsOrder> mallOrders = mallGoodsOrderMapper.selectList(new LambdaQueryWrapper<MallGoodsOrder>()
.eq(MallGoodsOrder::getSoftDelete, 0)
.eq(MallGoodsOrder::getOrderNo, order.getMallOrderNo())
.orderByDesc(MallGoodsOrder::getId).last("LIMIT 1"));
if (!mallOrders.isEmpty()) {
addressId = mallOrders.get(0).getAddressId();
}
}
if (addressId == null || addressId == 0) {
return null;
}
MallShippingAddress address = shippingAddressMapper.selectById(addressId);
if (address == null || !Integer.valueOf(0).equals(address.getSoftDelete())) {
return null;
}
Map<String, Object> record = new LinkedHashMap<>();
record.put("id", address.getId());
record.put("reveiveName", address.getReveiveName());
record.put("reveiveNumber", address.getReveiveNumber());
record.put("province", address.getProvince());
record.put("city", address.getCity());
record.put("countyseat", address.getCountyseat());
record.put("address", address.getAddress());
record.put("fullAddress", joinAddress(address));
record.put("memo", address.getMemo());
return record;
}
private Map<String, Object> logisticsRecord(WarehouseOut order) {
String businessNo = StringUtils.hasText(order.getMallOrderNo())
? order.getMallOrderNo() : order.getOutOrderNo();
List<MallOrderLogistics> rows = logisticsMapper.selectList(new LambdaQueryWrapper<MallOrderLogistics>()
.eq(MallOrderLogistics::getSoftDelete, 0)
.eq(MallOrderLogistics::getOrderNo, businessNo)
.orderByDesc(MallOrderLogistics::getId).last("LIMIT 1"));
MallOrderLogistics logistics = rows.isEmpty() ? null : rows.get(0);
Map<String, Object> record = new LinkedHashMap<>();
record.put("businessNo", businessNo);
record.put("sendMode", order.getSendMode());
record.put("sendModeText", text(SEND_MODE_TEXT, order.getSendMode()));
record.put("logisticsId", order.getLogisticsId());
record.put("logisticsName", order.getLogisticsName());
record.put("logisticsOrderNo", order.getLogisticsOrderNo());
record.put("logisticsWayBill", order.getLogisticsWayBill());
if (logistics != null) {
record.put("state", logistics.getState());
record.put("deliverName", logistics.getDeliverName());
record.put("deliverNumber", logistics.getDeliverNumber());
record.put("sendTime", formatTime(logistics.getModifyTime()));
record.put("receivingGoodsName", logistics.getReceivingGoodsName());
record.put("reveiveName", logistics.getReveiveName());
record.put("reveiveNumber", logistics.getReveiveNumber());
record.put("address", logistics.getAddress());
}
return record;
}
private Map<String, Object> certificateRecord(WarehouseOut order, List<WarehouseInOutPic> pics) {
List<WarehouseOutCertificate> rows = certificateMapper.selectList(
new LambdaQueryWrapper<WarehouseOutCertificate>()
.eq(WarehouseOutCertificate::getSoftDelete, 0)
.eq(WarehouseOutCertificate::getOutOrderId, order.getId())
.orderByDesc(WarehouseOutCertificate::getId).last("LIMIT 1"));
List<Map<String, Object>> certFiles = fileRecords(pics, CERT_FILE_TYPE, CERT_TYPE);
if (rows.isEmpty() && certFiles.isEmpty()) {
return null;
}
Map<String, Object> record = new LinkedHashMap<>();
WarehouseOutCertificate certificate = rows.isEmpty() ? null : rows.get(0);
record.put("id", certificate == null ? null : certificate.getId());
record.put("memo", certificate == null ? null : certificate.getMemo());
record.put("createTime", certificate == null ? null : formatTime(certificate.getCreateTime()));
record.put("modifyTime", certificate == null ? null : formatTime(certificate.getModifyTime()));
record.put("files", certFiles);
return record;
}
private List<Map<String, Object>> fileRecords(List<WarehouseInOutPic> pics, int fileType, int aggregateType) {
return pics.stream().filter(pic -> Integer.valueOf(fileType).equals(pic.getFileType())
&& Integer.valueOf(aggregateType).equals(pic.getInOutAllocationType()))
.map(pic -> {
Map<String, Object> 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 static List<String> distinctNonBlank(List<String> values) {
Set<String> distinct = values.stream().filter(StringUtils::hasText)
.collect(Collectors.toCollection(LinkedHashSet::new));
return new ArrayList<>(distinct);
}
private static String sourceType(WarehouseOut order) {
if (StringUtils.hasText(order.getAllocationOrderNo())) {
return "ALLOCATION";
}
return StringUtils.hasText(order.getMallOrderNo()) ? "MALL" : "NORMAL";
}
private static String text(Map<Integer, String> 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 joinAddress(MallShippingAddress address) {
return nullToEmpty(address.getProvince()) + nullToEmpty(address.getCity())
+ nullToEmpty(address.getCountyseat()) + nullToEmpty(address.getAddress());
}
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 <T extends Comparable<T>> void validateRange(T from, T to, String fromName, String toName) {
if (from != null && to != null && from.compareTo(to) > 0) {
throw new IllegalArgumentException(fromName + " 不能晚于 " + toName);
}
}
}
@@ -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.WarehouseOut;
import me.iiv.agent.mapper.m0.AgentManageDistrictAuthMapper;
import me.iiv.agent.mapper.m0.AgentMapper;
import me.iiv.agent.mapper.m0.AuthorityMapper;
@@ -25,6 +26,7 @@ import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.IntStream;
import java.util.stream.Collectors;
/**
@@ -121,6 +123,48 @@ public class PermissionService {
return new ArrayList<>(ids);
}
/**
* 出库单权限:总部看全部;代理身份可看所属代理范围内的主单、其代理范围所属/负责仓库
* 涉及的主单,以及本人参与审批或处理的主单。关联范围使用 EXISTS 落在同一个主查询中,
* 列表和详情必须复用本方法,避免先取主键集合及详情越权。
*/
public void applyWarehouseOutRowFilter(LambdaQueryWrapper<WarehouseOut> wrapper, User user) {
List<Long> agentIds = visibleAgentIds(user);
if (agentIds == null) {
return;
}
if (agentIds.isEmpty()) {
wrapper.eq(WarehouseOut::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_out_details wod "
+ "JOIN warehouse w ON w.id = wod.warehouse_id AND w.soft_delete = 0 "
+ "WHERE wod.warehouse_out_id = warehouse_out.id AND wod.soft_delete = 0 "
+ "AND (w.agent_id IN (" + firstIds + ") OR w.manager_agent_id IN (" + secondIds + ")))";
String participantExists = "EXISTS (SELECT 1 FROM warehouse_out_audit_process woap "
+ "WHERE woap.warehouse_out_id = warehouse_out.id AND woap.soft_delete = 0 "
+ "AND woap.user_id = {0})";
wrapper.and(w -> w.in(WarehouseOut::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 + "}")
.collect(Collectors.joining(","));
}
/** 直属下级代理 id + 自己 agentId(老 :682-695,supervisor_id=自己,只取一层) */
private List<Long> subordinateAndSelfIds(User user) {
List<Long> ids = new ArrayList<>();
@@ -0,0 +1,297 @@
package me.iiv.agent.mcp.tool;
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.User;
import me.iiv.agent.entity.m1.MallGoodsOrder;
import me.iiv.agent.entity.m1.MallOrderLogistics;
import me.iiv.agent.entity.m1.MallShippingAddress;
import me.iiv.agent.entity.m2.WarehouseInOutPic;
import me.iiv.agent.entity.m2.WarehouseOut;
import me.iiv.agent.entity.m2.WarehouseOutAuditProcess;
import me.iiv.agent.entity.m2.WarehouseOutCertificate;
import me.iiv.agent.entity.m2.WarehouseOutDetail;
import me.iiv.agent.mapper.m1.MallGoodsOrderMapper;
import me.iiv.agent.mapper.m1.MallOrderLogisticsMapper;
import me.iiv.agent.mapper.m1.MallShippingAddressMapper;
import me.iiv.agent.mapper.m2.WarehouseInOutPicMapper;
import me.iiv.agent.mapper.m2.WarehouseOutAuditProcessMapper;
import me.iiv.agent.mapper.m2.WarehouseOutCertificateMapper;
import me.iiv.agent.mapper.m2.WarehouseOutDetailMapper;
import me.iiv.agent.mapper.m2.WarehouseOutMapper;
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.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
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 WarehouseOutToolsTest {
@Mock WarehouseOutMapper warehouseOutMapper;
@Mock WarehouseOutDetailMapper detailMapper;
@Mock WarehouseOutAuditProcessMapper processMapper;
@Mock WarehouseInOutPicMapper picMapper;
@Mock WarehouseOutCertificateMapper certificateMapper;
@Mock MallGoodsOrderMapper mallGoodsOrderMapper;
@Mock MallShippingAddressMapper shippingAddressMapper;
@Mock MallOrderLogisticsMapper logisticsMapper;
@Mock PermissionService permissionService;
WarehouseOutTools tools;
@BeforeEach
void setUp() {
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
TableInfoHelper.initTableInfo(assistant, WarehouseOut.class);
TableInfoHelper.initTableInfo(assistant, WarehouseOutDetail.class);
TableInfoHelper.initTableInfo(assistant, WarehouseOutAuditProcess.class);
TableInfoHelper.initTableInfo(assistant, WarehouseInOutPic.class);
TableInfoHelper.initTableInfo(assistant, WarehouseOutCertificate.class);
tools = new WarehouseOutTools(warehouseOutMapper, detailMapper, processMapper, picMapper,
certificateMapper, mallGoodsOrderMapper, shippingAddressMapper, logisticsMapper,
permissionService);
User user = new User();
user.setId(7L);
AuthContextHolder.set(new AuthContext(null, user));
}
@AfterEach
void tearDown() {
AuthContextHolder.clear();
}
@Test
void listAppliesFiltersAndBatchAggregatesRelations() {
WarehouseOut order = order();
when(warehouseOutMapper.selectCount(any())).thenReturn(1L);
when(warehouseOutMapper.selectList(any())).thenReturn(List.of(order));
WarehouseOutDetail detail = detail(20L, 8L, 0L);
detail.setWarehouseName("一号仓");
detail.setOutConfirmUserName("李四");
detail.setOutDate(LocalDate.of(2026, 9, 15));
when(detailMapper.selectList(any())).thenReturn(List.of(detail));
WarehouseOutAuditProcess schedule = new WarehouseOutAuditProcess();
schedule.setWarehouseOutId(20L);
schedule.setUserName("王五");
when(processMapper.selectList(any())).thenReturn(List.of(schedule));
Map<String, Object> result = list(0, 500);
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<String, Object> record = ((List<Map<String, Object>>) 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("warehouseOutPeople"));
assertEquals(List.of("王五"), record.get("schedulePeople"));
verify(permissionService).applyWarehouseOutRowFilter(any(), any());
verify(detailMapper).selectList(any());
verify(processMapper).selectList(any());
}
@Test
void listRejectsInvalidAndReversedDates() {
assertThrows(IllegalArgumentException.class, () -> listWithDates("2026-02-30", null));
assertThrows(IllegalArgumentException.class, () -> listWithDates("2026-09-16", "2026-09-15"));
verifyNoInteractions(warehouseOutMapper, permissionService);
}
@Test
void detailUsesSamePermissionAndHidesExistence() {
when(warehouseOutMapper.selectList(any())).thenReturn(List.of());
ToolCallException error = assertThrows(ToolCallException.class,
() -> tools.warehouseOutDetailQuery(20L, null, false, null));
assertEquals(McpErrorCode.NOT_FOUND_OR_FORBIDDEN, error.getCode());
verify(permissionService).applyWarehouseOutRowFilter(any(), any());
verifyNoInteractions(detailMapper, processMapper, picMapper);
}
@Test
void detailReturnsAllocationItemsSerialsFilesCertificateAddressAndLogistics() {
WarehouseOut order = order();
order.setAddressId(30L);
when(warehouseOutMapper.selectList(any())).thenReturn(List.of(order));
WarehouseOutDetail item = detail(20L, 8L, 0L);
item.setMaterialNo("MAT-1");
item.setMaterielFullName("纸巾机");
item.setOutCount(2);
item.setPrice(new BigDecimal("10.00"));
item.setAllMoney(new BigDecimal("20.00"));
WarehouseOutDetail serial = detail(20L, 8L, 101L);
serial.setSnCode("SN-001");
when(detailMapper.selectList(any())).thenReturn(List.of(item), List.of(serial));
when(detailMapper.selectCount(any())).thenReturn(2L);
WarehouseOutAuditProcess process = new WarehouseOutAuditProcess();
process.setId(4L);
process.setWarehouseOutId(20L);
process.setScheduleState(2);
process.setAuditState(1);
process.setOperationTheme(3);
process.setOperationType(1);
when(processMapper.selectList(any())).thenReturn(List.of(process));
WarehouseInOutPic application = pic(1L, 0, 1, "apply.jpg");
WarehouseInOutPic confirmation = pic(2L, 2, 1, "confirm.jpg");
WarehouseInOutPic certificateFile = pic(3L, 4, 99, "cert.jpg");
when(picMapper.selectList(any())).thenReturn(List.of(application, confirmation, certificateFile));
WarehouseOutCertificate certificate = new WarehouseOutCertificate();
certificate.setId(9L);
certificate.setMemo("签收凭证");
when(certificateMapper.selectList(any())).thenReturn(List.of(certificate));
MallShippingAddress address = new MallShippingAddress();
address.setId(30L);
address.setSoftDelete(0);
address.setProvince("广东省");
address.setCity("深圳市");
address.setCountyseat("南山区");
address.setAddress("科技园");
when(shippingAddressMapper.selectById(30L)).thenReturn(address);
MallOrderLogistics logistics = new MallOrderLogistics();
logistics.setState(7);
logistics.setDeliverName("配送员");
when(logisticsMapper.selectList(any())).thenReturn(List.of(logistics));
Map<String, Object> result = tools.warehouseOutDetailQuery(20L, "CK-001", true, 1);
@SuppressWarnings("unchecked")
Map<String, Object> record = (Map<String, Object>) result.get("record");
assertEquals("ALLOCATION", record.get("sourceType"));
@SuppressWarnings("unchecked")
List<Map<String, Object>> items = (List<Map<String, Object>>) result.get("items");
assertEquals("MAT-1", items.get(0).get("materialNo"));
@SuppressWarnings("unchecked")
List<Map<String, Object>> serials = (List<Map<String, Object>>) result.get("serials");
assertEquals("SN-001", serials.get(0).get("snCode"));
assertEquals(2L, result.get("serialTotal"));
assertTrue((Boolean) result.get("serialTruncated"));
@SuppressWarnings("unchecked")
Map<String, Object> addressMap = (Map<String, Object>) result.get("address");
assertEquals("广东省深圳市南山区科技园", addressMap.get("fullAddress"));
@SuppressWarnings("unchecked")
Map<String, Object> files = (Map<String, Object>) result.get("files");
assertEquals(1, ((List<?>) files.get("application")).size());
assertEquals(1, ((List<?>) files.get("confirmation")).size());
@SuppressWarnings("unchecked")
Map<String, Object> certificateMap = (Map<String, Object>) result.get("certificate");
assertEquals("签收凭证", certificateMap.get("memo"));
assertEquals(1, ((List<?>) certificateMap.get("files")).size());
@SuppressWarnings("unchecked")
List<Map<String, Object>> processes = (List<Map<String, Object>>) result.get("processes");
assertEquals("出库确认", processes.get(0).get("operationThemeText"));
assertEquals("确认出库", processes.get(0).get("operationTypeText"));
verify(permissionService).applyWarehouseOutRowFilter(any(), any());
}
@Test
void detailDoesNotReturnSerialRowsUnlessRequested() {
when(warehouseOutMapper.selectList(any())).thenReturn(List.of(order()));
when(detailMapper.selectList(any())).thenReturn(List.of());
when(detailMapper.selectCount(any())).thenReturn(3L);
when(processMapper.selectList(any())).thenReturn(List.of());
when(picMapper.selectList(any())).thenReturn(List.of());
when(certificateMapper.selectList(any())).thenReturn(List.of());
when(logisticsMapper.selectList(any())).thenReturn(List.of());
Map<String, Object> result = tools.warehouseOutDetailQuery(20L, null, false, 1);
assertEquals(List.of(), result.get("serials"));
assertEquals(3L, result.get("serialTotal"));
assertFalse((Boolean) result.get("serialTruncated"));
assertNull(result.get("address"));
assertNull(result.get("certificate"));
}
@Test
void springAiPublishesBothWarehouseOutToolSchemas() {
var callbacks = org.springframework.ai.tool.method.MethodToolCallbackProvider.builder()
.toolObjects(tools).build().getToolCallbacks();
Map<String, String> 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.out.list.query").contains("outDateFrom"));
assertTrue(schemas.get("warehouse.out.list.query").contains("pickupOrderNo"));
assertTrue(schemas.get("warehouse.out.detail.query").contains("includeSerials"));
}
private Map<String, Object> list(Integer page, Integer rows) {
return tools.warehouseOutListQuery(null, null, null, null, null, null,
null, null, null, null, 3, null, null, null,
null, null, null, "2026-09-01", "2026-09-30",
null, null, page, rows);
}
private Map<String, Object> listWithDates(String from, String to) {
return tools.warehouseOutListQuery(null, null, null, null, null, null,
null, null, null, null, null, null, null, null,
null, null, null, null, null, from, to, 1, 20);
}
private static WarehouseOut order() {
WarehouseOut order = new WarehouseOut();
order.setId(20L);
order.setOutOrderNo("CK-001");
order.setAllocationOrderNo("DB-001");
order.setOrderState(3);
order.setSendMode(0);
return order;
}
private static WarehouseOutDetail detail(Long orderId, Long warehouseId, Long parentId) {
WarehouseOutDetail detail = new WarehouseOutDetail();
detail.setId(parentId == 0 ? 101L : 102L);
detail.setWarehouseOutId(orderId);
detail.setWarehouseId(warehouseId);
detail.setWarehouseName("一号仓");
detail.setParentId(parentId);
return detail;
}
private static WarehouseInOutPic pic(Long id, int fileType, int aggregateType, String fileName) {
WarehouseInOutPic pic = new WarehouseInOutPic();
pic.setId(id);
pic.setFileType(fileType);
pic.setInOutAllocationType(aggregateType);
pic.setFileName(fileName);
pic.setOssbucketname("bucket");
pic.setOssfolder("warehouse/");
return pic;
}
}
@@ -0,0 +1,109 @@
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.WarehouseOut;
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 PermissionServiceWarehouseOutRowFilterTest {
@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(), ""), WarehouseOut.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<WarehouseOut> wrapper = new LambdaQueryWrapper<>();
service.applyWarehouseOutRowFilter(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<WarehouseOut> wrapper = new LambdaQueryWrapper<>();
service.applyWarehouseOutRowFilter(wrapper, user);
String sql = wrapper.getSqlSegment();
Collection<Object> values = wrapper.getParamNameValuePairs().values();
assertTrue(sql.contains("agent_id IN"));
assertTrue(sql.contains("warehouse_out_details"));
assertTrue(sql.contains("warehouse_out_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.applyWarehouseOutRowFilter(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;
}
}