diff --git a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md index 6827120..acd46a4 100644 --- a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md +++ b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md @@ -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 diff --git a/xiaoqu-agent-service/doc/warehouse-out-mcp-tools-design.md b/xiaoqu-agent-service/doc/warehouse-out-mcp-tools-design.md new file mode 100644 index 0000000..ca43008 --- /dev/null +++ b/xiaoqu-agent-service/doc/warehouse-out-mcp-tools-design.md @@ -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` 执行后端测试;不连接生产数据库、不执行部署。 diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseInOutPic.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseInOutPic.java new file mode 100644 index 0000000..242abe2 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseInOutPic.java @@ -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; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseOut.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseOut.java new file mode 100644 index 0000000..8655735 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseOut.java @@ -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; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseOutAuditProcess.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseOutAuditProcess.java new file mode 100644 index 0000000..e5d61d8 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseOutAuditProcess.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_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; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseOutCertificate.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseOutCertificate.java new file mode 100644 index 0000000..6e23b94 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseOutCertificate.java @@ -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; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseOutDetail.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseOutDetail.java new file mode 100644 index 0000000..20774e4 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseOutDetail.java @@ -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; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseInOutPicMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseInOutPicMapper.java new file mode 100644 index 0000000..f43d05a --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseInOutPicMapper.java @@ -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 { +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseOutAuditProcessMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseOutAuditProcessMapper.java new file mode 100644 index 0000000..5a185ef --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseOutAuditProcessMapper.java @@ -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 { +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseOutCertificateMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseOutCertificateMapper.java new file mode 100644 index 0000000..3a29bde --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseOutCertificateMapper.java @@ -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 { +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseOutDetailMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseOutDetailMapper.java new file mode 100644 index 0000000..58e55d1 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseOutDetailMapper.java @@ -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 { +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseOutMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseOutMapper.java new file mode 100644 index 0000000..859c2fb --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseOutMapper.java @@ -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 { +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/WarehouseOutTools.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/WarehouseOutTools.java new file mode 100644 index 0000000..5ee2d83 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/WarehouseOutTools.java @@ -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 ORDER_STATE_TEXT = Map.of( + 0, "待审核", 1, "被驳回", 2, "待出库", 3, "已完成", 4, "已撤销", 5, "作废"); + private static final Map CUSTOMER_TYPE_TEXT = Map.of( + 0, "内部人员", 1, "渠道商", 2, "直营客户"); + private static final Map SEND_MODE_TEXT = Map.of(0, "快递公司", 1, "自行派送"); + private static final Map SCHEDULE_STATE_TEXT = Map.of( + 0, "无需待办", 1, "待办", 2, "已办"); + private static final Map 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 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 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 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 orders = warehouseOutMapper.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.out.detail.query", description = "按 id 或出库单号查询出库单详情。" + + "行级权限与 warehouse.out.list.query 完全一致,不存在与无权访问统一返回 NOT_FOUND_OR_FORBIDDEN。" + + "返回主单、仓库、物料、可选 SN、已处理流程、地址、物流、申请/确认附件和出库凭证;调拨出库单可直接查询。" + + "includeSerials 默认 false;serialLimit 默认100、上限500。") + public Map 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 wrapper = new LambdaQueryWrapper() + .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 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 items = detailMapper.selectList(new LambdaQueryWrapper() + .eq(WarehouseOutDetail::getSoftDelete, 0) + .eq(WarehouseOutDetail::getWarehouseOutId, order.getId()) + .eq(WarehouseOutDetail::getParentId, 0L) + .orderByAsc(WarehouseOutDetail::getId)); + LambdaQueryWrapper serialBase = new LambdaQueryWrapper() + .eq(WarehouseOutDetail::getSoftDelete, 0) + .eq(WarehouseOutDetail::getWarehouseOutId, order.getId()) + .ne(WarehouseOutDetail::getParentId, 0L); + long serialTotal = detailMapper.selectCount(serialBase); + List serialRows = include + ? detailMapper.selectList(serialBase.orderByAsc(WarehouseOutDetail::getId).last("LIMIT " + limit)) + : List.of(); + + List processes = processMapper.selectList( + new LambdaQueryWrapper() + .eq(WarehouseOutAuditProcess::getSoftDelete, 0) + .eq(WarehouseOutAuditProcess::getWarehouseOutId, order.getId()) + .eq(WarehouseOutAuditProcess::getScheduleState, 2) + .orderByAsc(WarehouseOutAuditProcess::getOperationTime) + .orderByAsc(WarehouseOutAuditProcess::getId)); + List pics = picMapper.selectList(new LambdaQueryWrapper() + .eq(WarehouseInOutPic::getSoftDelete, 0) + .eq(WarehouseInOutPic::getAggregateId, order.getId()) + .orderByAsc(WarehouseInOutPic::getId)); + + Map 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 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 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 wrapper = new LambdaQueryWrapper() + .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 wrapper, + com.baomidou.mybatisplus.core.toolkit.support.SFunction column, + String value) { + if (StringUtils.hasText(value)) { + wrapper.like(column, value.trim()); + } + } + + private Map> listDetails(List orders) { + if (orders.isEmpty()) { + return Map.of(); + } + List ids = orders.stream().map(WarehouseOut::getId).collect(Collectors.toList()); + return detailMapper.selectList(new LambdaQueryWrapper() + .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> listSchedules(List orders) { + if (orders.isEmpty()) { + return Map.of(); + } + List ids = orders.stream().map(WarehouseOut::getId).collect(Collectors.toList()); + return processMapper.selectList(new LambdaQueryWrapper() + .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 listRecord(WarehouseOut order, List details, + List schedules) { + Map record = detailRecord(order); + LinkedHashMap 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 detailRecord(WarehouseOut order) { + Map 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 itemRecord(WarehouseOutDetail detail) { + Map 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 serialRecord(WarehouseOutDetail detail) { + Map 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 commonDetailRecord(WarehouseOutDetail detail) { + Map 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> warehouseRecords(List items) { + LinkedHashMap warehouses = new LinkedHashMap<>(); + for (WarehouseOutDetail item : items) { + if (item.getWarehouseId() != null) { + warehouses.putIfAbsent(item.getWarehouseId(), item.getWarehouseName()); + } + } + return warehouses.entrySet().stream().map(entry -> { + Map warehouse = new LinkedHashMap<>(); + warehouse.put("id", entry.getKey()); + warehouse.put("name", entry.getValue()); + return warehouse; + }).collect(Collectors.toList()); + } + + private Map processRecord(WarehouseOutAuditProcess 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 Map addressRecord(WarehouseOut order) { + Long addressId = order.getAddressId(); + if (StringUtils.hasText(order.getMallOrderNo())) { + List mallOrders = mallGoodsOrderMapper.selectList(new LambdaQueryWrapper() + .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 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 logisticsRecord(WarehouseOut order) { + String businessNo = StringUtils.hasText(order.getMallOrderNo()) + ? order.getMallOrderNo() : order.getOutOrderNo(); + List rows = logisticsMapper.selectList(new LambdaQueryWrapper() + .eq(MallOrderLogistics::getSoftDelete, 0) + .eq(MallOrderLogistics::getOrderNo, businessNo) + .orderByDesc(MallOrderLogistics::getId).last("LIMIT 1")); + MallOrderLogistics logistics = rows.isEmpty() ? null : rows.get(0); + Map 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 certificateRecord(WarehouseOut order, List pics) { + List rows = certificateMapper.selectList( + new LambdaQueryWrapper() + .eq(WarehouseOutCertificate::getSoftDelete, 0) + .eq(WarehouseOutCertificate::getOutOrderId, order.getId()) + .orderByDesc(WarehouseOutCertificate::getId).last("LIMIT 1")); + List> certFiles = fileRecords(pics, CERT_FILE_TYPE, CERT_TYPE); + if (rows.isEmpty() && certFiles.isEmpty()) { + return null; + } + Map 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> fileRecords(List 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 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 distinctNonBlank(List values) { + Set 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 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 > 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 16dcbb4..24d3d53 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.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 wrapper, User user) { + List 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 subordinateAndSelfIds(User user) { List ids = new ArrayList<>(); diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/WarehouseOutToolsTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/WarehouseOutToolsTest.java new file mode 100644 index 0000000..fe09575 --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/WarehouseOutToolsTest.java @@ -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 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 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("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 result = tools.warehouseOutDetailQuery(20L, "CK-001", true, 1); + + @SuppressWarnings("unchecked") + Map record = (Map) result.get("record"); + assertEquals("ALLOCATION", record.get("sourceType")); + @SuppressWarnings("unchecked") + List> items = (List>) result.get("items"); + assertEquals("MAT-1", items.get(0).get("materialNo")); + @SuppressWarnings("unchecked") + List> serials = (List>) result.get("serials"); + assertEquals("SN-001", serials.get(0).get("snCode")); + assertEquals(2L, result.get("serialTotal")); + assertTrue((Boolean) result.get("serialTruncated")); + @SuppressWarnings("unchecked") + Map addressMap = (Map) result.get("address"); + assertEquals("广东省深圳市南山区科技园", addressMap.get("fullAddress")); + @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 certificateMap = (Map) result.get("certificate"); + assertEquals("签收凭证", certificateMap.get("memo")); + assertEquals(1, ((List) certificateMap.get("files")).size()); + @SuppressWarnings("unchecked") + List> processes = (List>) 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 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 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 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 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; + } +} diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceWarehouseOutRowFilterTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceWarehouseOutRowFilterTest.java new file mode 100644 index 0000000..3307d20 --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceWarehouseOutRowFilterTest.java @@ -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 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 wrapper = new LambdaQueryWrapper<>(); + + service.applyWarehouseOutRowFilter(wrapper, user); + + String sql = wrapper.getSqlSegment(); + Collection 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; + } +}