From 52bb9faa2722478e2d4245d9aba4706b1395addf Mon Sep 17 00:00:00 2001 From: xiaoqu Date: Fri, 11 Sep 2026 10:21:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20=E6=96=B0=E5=A2=9E=E5=90=88?= =?UTF-8?q?=E5=90=8C=E6=9F=A5=E8=AF=A2=E5=B7=A5=E5=85=B7=20contract.list/d?= =?UTF-8?q?etail.query=20(REQ-20260911-0002)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../doc/agent-mcp-tools-reference.md | 58 ++++ xiaoqu-agent-service/sql/seed-dev.sql | 3 +- .../me/iiv/agent/entity/m2/ContractFile.java | 71 ++++ .../me/iiv/agent/entity/m2/ContractNew.java | 162 +++++++++ .../iiv/agent/entity/m2/ContractProcess.java | 106 ++++++ .../agent/mapper/m2/ContractFileMapper.java | 16 + .../agent/mapper/m2/ContractNewMapper.java | 16 + .../mapper/m2/ContractProcessMapper.java | 16 + .../me/iiv/agent/mcp/tool/ContractTools.java | 321 ++++++++++++++++++ .../agent/permission/PermissionService.java | 25 ++ .../iiv/agent/mcp/tool/ContractToolsTest.java | 258 ++++++++++++++ ...ermissionServiceContractRowFilterTest.java | 197 +++++++++++ 12 files changed, 1248 insertions(+), 1 deletion(-) create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/ContractFile.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/ContractNew.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/ContractProcess.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/ContractFileMapper.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/ContractNewMapper.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/ContractProcessMapper.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/ContractTools.java create mode 100644 xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/ContractToolsTest.java create mode 100644 xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceContractRowFilterTest.java diff --git a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md index 8a167c8..e31f98a 100644 --- a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md +++ b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md @@ -335,6 +335,64 @@ --- +## 12. contract.list.query + +分页查询合同列表,对应老系统 `/contract/listByPage` 合同列表页(权限库 `webpage` id=394 `contractList`,页面字段定义 `ContractListOutput`)。 + +- 分类:customer | 类型:读 +- 数据来源:m2 库 `contract_new` 表,固定 `soft_delete = 0`、`id DESC` +- 行级权限(`PermissionService.applyContractRowFilter`,与站点/出纸机同一 `getByIsAgent(propertyFlag=true)` 变体,agent 维度落 `contract_agent_id`,property 维度落 `property_id`): + - 总部(isagent=0):全部; + - 代理商(isagent=1):`contract_agent_id IN (自己 + 直属下级 + warehouse_agent_auth 代管代理)`,有代管物业授权时再 `OR property_id IN (agent_manage_district_auth 代管物业)`; + - 销售部门非负责人:`property_id IN (自己创建的物业 + 代管代理名下物业)`,与上一条互斥。 +- 富化(按页批量 IN 查询,消除 N+1):`contractAgentName`/`customerAgentName` 查 m0 `agent`,`createName` 查 m0 `user`;`saleName`/`propertyName`/`customerName`/`customerNo` 为合同表冗余快照,直接返回。 + +**入参**: + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| contractNo | string | 否 | 合同编号,模糊匹配 | +| contractName | string | 否 | 合同名称,模糊匹配 | +| propertyName | string | 否 | 物业名称,模糊匹配 | +| customerName | string | 否 | 客户名称(金蝶客户名称快照),模糊匹配 | +| customerNo | string | 否 | 客户编号(=物业 serialnumber),精确匹配 | +| contractAgentId | number | 否 | 合同所属公司 id,精确匹配 | +| customerAgentId | number | 否 | 客户所属公司 id,精确匹配 | +| propertyId | number | 否 | 所属物业 id,精确匹配 | +| contractStatus | number | 否 | 合同状态:0=待审 1=驳回 2=正常 3=过期 4=终止 | +| expireRemind | number | 否 | 到期提醒开关:0=关 1=开 | +| endDateFrom | string | 否 | 合同结束日期起,格式 yyyy-MM-dd(查即将到期合同用) | +| endDateTo | string | 否 | 合同结束日期止,格式 yyyy-MM-dd(含当天) | +| page | number | 否 | 页码,从 1 开始,默认 1 | +| rows | number | 否 | 每页条数,默认 20,上限 100(超出截断为 100 并在 notice 提示) | + +**出参 records 字段**:`contract_new` 主要字段(id、contractNo、contractName、contractAgentId/customerAgentId、propertyId/propertyName、customerNo/customerName、amount、contact、startDate/endDate(`yyyy-MM-dd`)、expireRemind/remindDays/remindTime/reminded、saleId/saleName、contractStatus、stopReasonType/stopReason、createId、createTime)+ 富化字段 `contractAgentName`/`customerAgentName`/`createName` + 状态文案 `contractStatusText`。 + +--- + +## 13. contract.detail.query + +按 id 或 contractNo 查询合同详情(二选一必填),行级权限与 `contract.list.query` 一致。 + +- 分类:customer | 类型:读 +- 数据来源:m2 库 `contract_new` + `contract_file`(附件)+ `contract_process`(流程/待办/操作记录) +- 合同不存在或无权查看时 `record=null` 并在 notice 说明(两者不区分,不泄露合同是否存在);id/contractNo 都未传时 notice 提示"必须传一个"。 + +**入参**: + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| id | number | 条件必填 | 合同 id;与 contractNo 二选一 | +| contractNo | string | 条件必填 | 合同编号,精确匹配;与 id 二选一 | + +**出参**: + +- `record`:与列表同一套字段(含富化和 `contractStatusText`)。 +- `files`:合同附件列表,`id`/`fileName`/`fileUrl`/`createTime`,按 id 正序。 +- `processes`:审批流程记录,按 id 正序,字段 `nodeType`(0=创建 1=部门内审核 2=层级审核 3=终止 4=抄送)、`auditLevel`、`operationType`(0=创建 1=审核通过 2=审核驳回 3=终止 4=修改再提交 5=抄送)、`userId`/`userName`、`scheduleState`(0=无需待办 1=待办 2=已办)、`remark`、`operateTime`,均附 `nodeTypeText`/`operationTypeText`/`scheduleStateText` 文案。 + +--- + ## 附录:在线查看工具清单 ```bash diff --git a/xiaoqu-agent-service/sql/seed-dev.sql b/xiaoqu-agent-service/sql/seed-dev.sql index c2d915b..b6ba167 100644 --- a/xiaoqu-agent-service/sql/seed-dev.sql +++ b/xiaoqu-agent-service/sql/seed-dev.sql @@ -16,9 +16,10 @@ WHERE NOT EXISTS (SELECT 1 FROM agent_scope WHERE code = 'operation'); -- 作用域-工具:sys.ping / agent.count / agent.list.query / property.list.query / agent.detail.query / property.detail.query -- / mall.order.list.query / mall.order.detail.query / mall.order.logistics.query +-- / contract.list.query / contract.detail.query INSERT INTO scope_tool (scope_id, tool_id, create_id) SELECT s.id, t.id, 0 FROM agent_scope s JOIN tool t -WHERE s.code = 'operation' AND t.name IN ('sys.ping', 'agent.count', 'agent.list.query', 'property.list.query', 'agent.detail.query', 'property.detail.query', 'mall.order.list.query', 'mall.order.detail.query', 'mall.order.logistics.query') +WHERE s.code = 'operation' AND t.name IN ('sys.ping', 'agent.count', 'agent.list.query', 'property.list.query', 'agent.detail.query', 'property.detail.query', 'mall.order.list.query', 'mall.order.detail.query', 'mall.order.logistics.query', 'contract.list.query', 'contract.detail.query') AND NOT EXISTS (SELECT 1 FROM scope_tool st WHERE st.scope_id = s.id AND st.tool_id = t.id); -- 作用域:hardware(出纸机查询) diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/ContractFile.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/ContractFile.java new file mode 100644 index 0000000..773c556 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/ContractFile.java @@ -0,0 +1,71 @@ +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; + +/** + *

+ * 合同附件 + *

+ * + * @author codegen + * @since 2026-09-11 + */ +@Getter +@Setter +@ToString +@TableName("contract_file") +public class ContractFile implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 创建id + */ + private Long createId; + + /** + * 创建时间 + */ + private Date createTime; + + /** + * 修改id + */ + private Long modifyId; + + /** + * 修改时间 + */ + private Date modifyTime; + + /** + * 数据状态0:正常;1:删除 + */ + private Integer softDelete; + + /** + * 合同ID(0=上传后未提交的孤儿附件,定时清理) + */ + private Long contractId; + + /** + * 原始文件名 + */ + private String fileName; + + /** + * 文件访问地址 + */ + private String fileUrl; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/ContractNew.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/ContractNew.java new file mode 100644 index 0000000..a567150 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/ContractNew.java @@ -0,0 +1,162 @@ +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.util.Date; + +/** + *

+ * 合同主表 + *

+ * + * @author codegen + * @since 2026-09-11 + */ +@Getter +@Setter +@ToString +@TableName("contract_new") +public class ContractNew implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 创建id + */ + private Long createId; + + /** + * 创建时间 + */ + private Date createTime; + + /** + * 修改id + */ + private Long modifyId; + + /** + * 修改时间 + */ + private Date modifyTime; + + /** + * 数据状态0:正常;1:删除 + */ + private Integer softDelete; + + /** + * 合同编号(properyt.serialnumber + "C" + 三位流水,如452542C001) + */ + private String contractNo; + + /** + * 合同名称 + */ + private String contractName; + + /** + * 合同所属公司(=物业所属销售所属公司,审批流按此公司匹配) + */ + private Long contractAgentId; + + /** + * 客户所属公司(=创建人提交时选择的所属公司) + */ + private Long customerAgentId; + + /** + * 所属物业ID(关联权限库properyt) + */ + private Long propertyId; + + /** + * 物业名称(冗余快照) + */ + private String propertyName; + + /** + * 客户编号快照(=properyt.serialnumber,编号流水/按客户归集用) + */ + private String customerNo; + + /** + * 客户名称快照(=properyt.jinide_name 金蝶客户名称) + */ + private String customerName; + + /** + * 合同金额(元) + */ + private BigDecimal amount; + + /** + * 联系人 + */ + private String contact; + + /** + * 合同开始日期 + */ + private Date startDate; + + /** + * 合同结束日期 + */ + private Date endDate; + + /** + * 到期提醒开关0:关;1:开 + */ + private Integer expireRemind; + + /** + * 到期提醒天数 + */ + private Integer remindDays; + + /** + * 到期提醒时间(=end_date-remind_days 当天09:30:00) + */ + private Date remindTime; + + /** + * 到期提醒是否已发送0:未发送;1:已发送 + */ + private Integer reminded; + + /** + * 所属销售ID(=物业所属销售) + */ + private Long saleId; + + /** + * 所属销售姓名(冗余快照) + */ + private String saleName; + + /** + * 合同状态0:待审;1:驳回;2:正常;3:过期;4:终止 + */ + private Integer contractStatus; + + /** + * 终止原因分类(cancel_reason.content快照,type=6) + */ + private String stopReasonType; + + /** + * 终止详细原因 + */ + private String stopReason; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/ContractProcess.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/ContractProcess.java new file mode 100644 index 0000000..fbac222 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/ContractProcess.java @@ -0,0 +1,106 @@ +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; + +/** + *

+ * 合同流程/待办/操作记录表 + *

+ * + * @author codegen + * @since 2026-09-11 + */ +@Getter +@Setter +@ToString +@TableName("contract_process") +public class ContractProcess implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 创建id + */ + private Long createId; + + /** + * 创建时间 + */ + private Date createTime; + + /** + * 修改id + */ + private Long modifyId; + + /** + * 修改时间 + */ + private Date modifyTime; + + /** + * 数据状态0:正常;1:删除 + */ + private Integer softDelete; + + /** + * 合同ID + */ + private Long contractId; + + /** + * 合同编号(冗余) + */ + private String contractNo; + + /** + * 节点类型0:创建;1:部门内审核;2:层级审核;3:终止;4:抄送 + */ + private Integer nodeType; + + /** + * 审核层级(层级审核为第几级,其余0) + */ + private Integer auditLevel; + + /** + * 操作类型0:创建;1:审核通过;2:审核驳回;3:终止;4:修改再提交;5:抄送 + */ + private Integer operationType; + + /** + * 待办人/操作人ID + */ + private Long userId; + + /** + * 待办人/操作人姓名 + */ + private String userName; + + /** + * 待办状态0:无需待办;1:待办;2:已办 + */ + private Integer scheduleState; + + /** + * 备注/驳回原因/终止原因/抄送文案 + */ + private String remark; + + /** + * 办理时间(待办未办为NULL) + */ + private Date operateTime; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/ContractFileMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/ContractFileMapper.java new file mode 100644 index 0000000..da89728 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/ContractFileMapper.java @@ -0,0 +1,16 @@ +package me.iiv.agent.mapper.m2; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import me.iiv.agent.entity.m2.ContractFile; + +/** + *

+ * 合同附件 Mapper 接口 + *

+ * + * @author codegen + * @since 2026-09-11 + */ +public interface ContractFileMapper extends BaseMapper { + +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/ContractNewMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/ContractNewMapper.java new file mode 100644 index 0000000..839411d --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/ContractNewMapper.java @@ -0,0 +1,16 @@ +package me.iiv.agent.mapper.m2; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import me.iiv.agent.entity.m2.ContractNew; + +/** + *

+ * 合同主表 Mapper 接口 + *

+ * + * @author codegen + * @since 2026-09-11 + */ +public interface ContractNewMapper extends BaseMapper { + +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/ContractProcessMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/ContractProcessMapper.java new file mode 100644 index 0000000..8b1afdd --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/ContractProcessMapper.java @@ -0,0 +1,16 @@ +package me.iiv.agent.mapper.m2; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import me.iiv.agent.entity.m2.ContractProcess; + +/** + *

+ * 合同流程/待办/操作记录 Mapper 接口 + *

+ * + * @author codegen + * @since 2026-09-11 + */ +public interface ContractProcessMapper extends BaseMapper { + +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/ContractTools.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/ContractTools.java new file mode 100644 index 0000000..2b93e42 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/ContractTools.java @@ -0,0 +1,321 @@ +package me.iiv.agent.mcp.tool; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import me.iiv.agent.entity.m0.Agent; +import me.iiv.agent.entity.m0.User; +import me.iiv.agent.entity.m2.ContractFile; +import me.iiv.agent.entity.m2.ContractNew; +import me.iiv.agent.entity.m2.ContractProcess; +import me.iiv.agent.mapper.m0.AgentMapper; +import me.iiv.agent.mapper.m0.UserMapper; +import me.iiv.agent.mapper.m2.ContractFileMapper; +import me.iiv.agent.mapper.m2.ContractNewMapper; +import me.iiv.agent.mapper.m2.ContractProcessMapper; +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.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * 合同域工具:contract.list.query / contract.detail.query(对应老系统 /contract/listByPage + * 合同列表页 web_id=394,数据在 m2 库 contract_new/contract_file/contract_process, + * 行权限走 PermissionService.applyContractRowFilter)。 + */ +@Component +public class ContractTools implements ToolObject { + + private static final int DEFAULT_ROWS = 20; + private static final int MAX_ROWS = 100; + + private static final Map CONTRACT_STATUS_TEXT = Map.of( + 0, "待审", 1, "驳回", 2, "正常", 3, "过期", 4, "终止"); + private static final Map NODE_TYPE_TEXT = Map.of( + 0, "创建", 1, "部门内审核", 2, "层级审核", 3, "终止", 4, "抄送"); + private static final Map OPERATION_TYPE_TEXT = Map.of( + 0, "创建", 1, "审核通过", 2, "审核驳回", 3, "终止", 4, "修改再提交", 5, "抄送"); + private static final Map SCHEDULE_STATE_TEXT = Map.of( + 0, "无需待办", 1, "待办", 2, "已办"); + + final ContractNewMapper contractNewMapper; + final ContractFileMapper contractFileMapper; + final ContractProcessMapper contractProcessMapper; + final AgentMapper agentMapper; + final UserMapper userMapper; + final PermissionService permissionService; + + public ContractTools(ContractNewMapper contractNewMapper, ContractFileMapper contractFileMapper, + ContractProcessMapper contractProcessMapper, AgentMapper agentMapper, + UserMapper userMapper, PermissionService permissionService) { + this.contractNewMapper = contractNewMapper; + this.contractFileMapper = contractFileMapper; + this.contractProcessMapper = contractProcessMapper; + this.agentMapper = agentMapper; + this.userMapper = userMapper; + this.permissionService = permissionService; + } + + @XqTool(category = "customer", kind = "read") + @Tool(name = "contract.list.query", description = "查询合同列表(分页),对应老系统 /contract/listByPage 合同列表页," + + "数据来源 m2 库 contract_new 表。" + + "行级权限:代理商身份(权限组 isagent=1)只能看到 自己+直属下级代理+被授权代管代理 作为合同所属公司的合同," + + "以及被授权代管物业名下的合同;销售部门非负责人只能看到 自己创建的物业 及 被授权代管代理名下物业 的合同;" + + "总部身份(isagent=0)可看全部。" + + "分页:page 从 1 开始,rows 默认 20、上限 100,超出自动截断为 100 并在返回的 notice 字段提示。" + + "contractStatus 含义:0=待审 1=驳回 2=正常 3=过期 4=终止;expireRemind 含义:0=关 1=开。" + + "返回 total(符合条件的总数)、records(本页记录,按 id 倒序,含 contractStatusText 状态文案)。") + public Map contractListQuery( + @ToolParam(required = false, description = "合同编号,模糊匹配") String contractNo, + @ToolParam(required = false, description = "合同名称,模糊匹配") String contractName, + @ToolParam(required = false, description = "物业名称,模糊匹配") String propertyName, + @ToolParam(required = false, description = "客户名称(金蝶客户名称快照),模糊匹配") String customerName, + @ToolParam(required = false, description = "客户编号(=物业 serialnumber),精确匹配") String customerNo, + @ToolParam(required = false, description = "合同所属公司 id,精确匹配") Long contractAgentId, + @ToolParam(required = false, description = "客户所属公司 id,精确匹配") Long customerAgentId, + @ToolParam(required = false, description = "所属物业 id,精确匹配") Long propertyId, + @ToolParam(required = false, description = "合同状态:0=待审 1=驳回 2=正常 3=过期 4=终止") Integer contractStatus, + @ToolParam(required = false, description = "到期提醒开关:0=关 1=开") Integer expireRemind, + @ToolParam(required = false, description = "合同结束日期起,格式 yyyy-MM-dd(查即将到期合同用)") String endDateFrom, + @ToolParam(required = false, description = "合同结束日期止,格式 yyyy-MM-dd(含当天)") String endDateTo, + @ToolParam(required = false, description = "页码,从 1 开始,默认 1") Integer page, + @ToolParam(required = false, description = "每页条数,默认 20,上限 100(超出截断为 100)") Integer rows) { + User user = AuthContextHolder.get().getUser(); + int pageNo = page == null || page < 1 ? 1 : page; + boolean truncated = rows != null && rows > MAX_ROWS; + int pageSize = rows == null || rows < 1 ? DEFAULT_ROWS : Math.min(rows, MAX_ROWS); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(ContractNew::getSoftDelete, 0); + if (StringUtils.hasText(contractNo)) { + wrapper.like(ContractNew::getContractNo, contractNo); + } + if (StringUtils.hasText(contractName)) { + wrapper.like(ContractNew::getContractName, contractName); + } + if (StringUtils.hasText(propertyName)) { + wrapper.like(ContractNew::getPropertyName, propertyName); + } + if (StringUtils.hasText(customerName)) { + wrapper.like(ContractNew::getCustomerName, customerName); + } + if (StringUtils.hasText(customerNo)) { + wrapper.eq(ContractNew::getCustomerNo, customerNo); + } + if (contractAgentId != null) { + wrapper.eq(ContractNew::getContractAgentId, contractAgentId); + } + if (customerAgentId != null) { + wrapper.eq(ContractNew::getCustomerAgentId, customerAgentId); + } + if (propertyId != null) { + wrapper.eq(ContractNew::getPropertyId, propertyId); + } + if (contractStatus != null) { + wrapper.eq(ContractNew::getContractStatus, contractStatus); + } + if (expireRemind != null) { + wrapper.eq(ContractNew::getExpireRemind, expireRemind); + } + if (StringUtils.hasText(endDateFrom)) { + wrapper.ge(ContractNew::getEndDate, endDateFrom); + } + if (StringUtils.hasText(endDateTo)) { + wrapper.le(ContractNew::getEndDate, endDateTo); + } + // 行级权限(getByIsAgent propertyFlag 变体,统一走 PermissionService) + permissionService.applyContractRowFilter(wrapper, user); + + Long total = contractNewMapper.selectCount(wrapper); + wrapper.orderByDesc(ContractNew::getId) + .last("LIMIT " + (long) (pageNo - 1) * pageSize + "," + pageSize); + List contracts = contractNewMapper.selectList(wrapper); + + // 合同所属公司 / 客户所属公司 / 创建人 名称批量富化(一次 IN 查询,消除 N+1) + List agentIds = contracts.stream() + .flatMap(c -> Stream.of(c.getContractAgentId(), c.getCustomerAgentId())) + .filter(Objects::nonNull).distinct().collect(Collectors.toList()); + Map agentNames = agentIds.isEmpty() + ? Map.of() + : agentMapper.selectBatchIds(agentIds).stream() + .collect(Collectors.toMap(Agent::getId, Agent::getName, (a, b) -> a)); + List creatorIds = contracts.stream().map(ContractNew::getCreateId) + .filter(Objects::nonNull).distinct().collect(Collectors.toList()); + Map creatorNames = creatorIds.isEmpty() + ? Map.of() + : userMapper.selectBatchIds(creatorIds).stream() + .collect(Collectors.toMap(User::getId, User::getName, (a, b) -> a)); + + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd"); + List> records = new ArrayList<>(); + for (ContractNew contract : contracts) { + records.add(toRecord(contract, agentNames, creatorNames, sdf, df)); + } + + 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; + } + + /** + * 合同详情:行权限与 contract.list.query 一致 + 精确 id/contractNo 叠加。 + * files 来自 m2.contract_file,processes 来自 m2.contract_process(按 id 正序)。 + */ + @XqTool(category = "customer", kind = "read") + @Tool(name = "contract.detail.query", description = "按 id 或 contractNo 查询合同详情(二选一必填),数据来源 m2 库 contract_new 表。" + + "行级权限与 contract.list.query 一致;合同不存在或当前身份无权查看时 record 为 null 并在 notice 字段说明(两者不区分)。" + + "返回 record(合同全字段,含终止原因和状态文案 contractStatusText)、" + + "files(合同附件列表,含 fileName/fileUrl)、" + + "processes(审批流程/待办/操作记录,按 id 正序;nodeType 含义:0=创建 1=部门内审核 2=层级审核 3=终止 4=抄送;" + + "operationType 含义:0=创建 1=审核通过 2=审核驳回 3=终止 4=修改再提交 5=抄送;" + + "scheduleState 含义:0=无需待办 1=待办 2=已办,均附对应文案字段)。") + public Map contractDetailQuery( + @ToolParam(required = false, description = "合同 id;与 contractNo 二选一必填") Long id, + @ToolParam(required = false, description = "合同编号,精确匹配;与 id 二选一必填") String contractNo) { + User user = AuthContextHolder.get().getUser(); + Map result = new LinkedHashMap<>(); + if (id == null && !StringUtils.hasText(contractNo)) { + result.put("record", null); + result.put("files", List.of()); + result.put("processes", List.of()); + result.put("notice", "id 和 contractNo 必须传一个"); + return result; + } + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(ContractNew::getSoftDelete, 0); + if (id != null) { + wrapper.eq(ContractNew::getId, id); + } else { + wrapper.eq(ContractNew::getContractNo, contractNo); + } + permissionService.applyContractRowFilter(wrapper, user); + ContractNew contract = contractNewMapper.selectOne(wrapper); + + if (contract == null) { + result.put("record", null); + result.put("files", List.of()); + result.put("processes", List.of()); + result.put("notice", (id != null ? "id=" + id : "contractNo=" + contractNo) + " 不存在或当前身份无权查看"); + return result; + } + + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd"); + Map agentNames = agentNamesOf(contract); + String createName = null; + if (contract.getCreateId() != null) { + User creator = userMapper.selectById(contract.getCreateId()); + createName = creator == null ? null : creator.getName(); + } + result.put("record", toRecord(contract, agentNames, + Map.of(contract.getCreateId() == null ? 0L : contract.getCreateId(), + createName == null ? "未查询到" : createName), + sdf, df)); + + List files = contractFileMapper.selectList(new LambdaQueryWrapper() + .eq(ContractFile::getSoftDelete, 0) + .eq(ContractFile::getContractId, contract.getId()) + .orderByAsc(ContractFile::getId)); + List> fileList = new ArrayList<>(); + for (ContractFile file : files) { + Map item = new LinkedHashMap<>(); + item.put("id", file.getId()); + item.put("fileName", file.getFileName()); + item.put("fileUrl", file.getFileUrl()); + item.put("createTime", file.getCreateTime() == null ? null : sdf.format(file.getCreateTime())); + fileList.add(item); + } + result.put("files", fileList); + + List processes = contractProcessMapper.selectList(new LambdaQueryWrapper() + .eq(ContractProcess::getSoftDelete, 0) + .eq(ContractProcess::getContractId, contract.getId()) + .orderByAsc(ContractProcess::getId)); + List> processList = new ArrayList<>(); + for (ContractProcess process : processes) { + Map item = new LinkedHashMap<>(); + item.put("id", process.getId()); + item.put("nodeType", process.getNodeType()); + item.put("nodeTypeText", NODE_TYPE_TEXT.getOrDefault(process.getNodeType(), "未知")); + item.put("auditLevel", process.getAuditLevel()); + item.put("operationType", process.getOperationType()); + item.put("operationTypeText", OPERATION_TYPE_TEXT.getOrDefault(process.getOperationType(), "未知")); + item.put("userId", process.getUserId()); + item.put("userName", process.getUserName()); + item.put("scheduleState", process.getScheduleState()); + item.put("scheduleStateText", SCHEDULE_STATE_TEXT.getOrDefault(process.getScheduleState(), "未知")); + item.put("remark", process.getRemark()); + item.put("operateTime", process.getOperateTime() == null ? null : sdf.format(process.getOperateTime())); + processList.add(item); + } + result.put("processes", processList); + return result; + } + + private Map toRecord(ContractNew contract, Map agentNames, + Map creatorNames, + SimpleDateFormat sdf, SimpleDateFormat df) { + Map record = new LinkedHashMap<>(); + record.put("id", contract.getId()); + record.put("contractNo", contract.getContractNo()); + record.put("contractName", contract.getContractName()); + record.put("contractAgentId", contract.getContractAgentId()); + record.put("contractAgentName", contract.getContractAgentId() == null + ? "未查询到" : agentNames.getOrDefault(contract.getContractAgentId(), "未查询到")); + record.put("customerAgentId", contract.getCustomerAgentId()); + record.put("customerAgentName", contract.getCustomerAgentId() == null + ? "未查询到" : agentNames.getOrDefault(contract.getCustomerAgentId(), "未查询到")); + record.put("propertyId", contract.getPropertyId()); + record.put("propertyName", contract.getPropertyName()); + record.put("customerNo", contract.getCustomerNo()); + record.put("customerName", contract.getCustomerName()); + record.put("amount", contract.getAmount()); + record.put("contact", contract.getContact()); + record.put("startDate", contract.getStartDate() == null ? null : df.format(contract.getStartDate())); + record.put("endDate", contract.getEndDate() == null ? null : df.format(contract.getEndDate())); + record.put("expireRemind", contract.getExpireRemind()); + record.put("remindDays", contract.getRemindDays()); + record.put("remindTime", contract.getRemindTime() == null ? null : sdf.format(contract.getRemindTime())); + record.put("reminded", contract.getReminded()); + record.put("saleId", contract.getSaleId()); + record.put("saleName", contract.getSaleName()); + record.put("contractStatus", contract.getContractStatus()); + record.put("contractStatusText", CONTRACT_STATUS_TEXT.getOrDefault(contract.getContractStatus(), "未知")); + record.put("stopReasonType", contract.getStopReasonType()); + record.put("stopReason", contract.getStopReason()); + record.put("createId", contract.getCreateId()); + record.put("createName", contract.getCreateId() == null + ? "未查询到" : creatorNames.getOrDefault(contract.getCreateId(), "未查询到")); + record.put("createTime", contract.getCreateTime() == null ? null : sdf.format(contract.getCreateTime())); + return record; + } + + private Map agentNamesOf(ContractNew contract) { + List agentIds = Stream.of(contract.getContractAgentId(), contract.getCustomerAgentId()) + .filter(Objects::nonNull).distinct().collect(Collectors.toList()); + if (agentIds.isEmpty()) { + return Map.of(); + } + return agentMapper.selectBatchIds(agentIds).stream() + .collect(Collectors.toMap(Agent::getId, Agent::getName, (a, b) -> a)); + } +} 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 a63ef49..16dcbb4 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 @@ -9,6 +9,7 @@ import me.iiv.agent.entity.m0.HardwareView; import me.iiv.agent.entity.m0.Properyt; 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.mapper.m0.AgentManageDistrictAuthMapper; import me.iiv.agent.mapper.m0.AgentMapper; @@ -400,4 +401,28 @@ public class PermissionService { wrapper.in(HardwareView::getAgentId, scope.ids); } } + + /** + * 合同(m2 contract_new)行权限:与站点/出纸机同一 getByIsAgent 变体 + * (propertyFlag=true),agent 维度落到 contract_agent_id(合同所属公司, + * =物业所属销售所属公司),property 维度落到 property_id。 + * 老系统 /contract/listByPage 源码不在本工作区,按合同表字段语义对齐 + * district/hardware 的过滤组合(2026-09-11 决策)。 + */ + public void applyContractRowFilter(LambdaQueryWrapper wrapper, User user) { + RowScope scope = resolveRowScope(user); + if (scope.headquarters) { + return; + } + if (scope.salesPropertyIds != null) { + wrapper.in(ContractNew::getPropertyId, scope.salesPropertyIds); + return; + } + if (!scope.managedPropertyIds.isEmpty()) { + wrapper.and(w -> w.in(ContractNew::getContractAgentId, scope.ids) + .or().in(ContractNew::getPropertyId, scope.managedPropertyIds)); + } else { + wrapper.in(ContractNew::getContractAgentId, scope.ids); + } + } } diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/ContractToolsTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/ContractToolsTest.java new file mode 100644 index 0000000..19cf6d6 --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/ContractToolsTest.java @@ -0,0 +1,258 @@ +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.Agent; +import me.iiv.agent.entity.m0.User; +import me.iiv.agent.entity.m2.ContractFile; +import me.iiv.agent.entity.m2.ContractNew; +import me.iiv.agent.entity.m2.ContractProcess; +import me.iiv.agent.mapper.m0.AgentMapper; +import me.iiv.agent.mapper.m0.UserMapper; +import me.iiv.agent.mapper.m2.ContractFileMapper; +import me.iiv.agent.mapper.m2.ContractNewMapper; +import me.iiv.agent.mapper.m2.ContractProcessMapper; +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.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ContractToolsTest { + + @Mock + ContractNewMapper contractNewMapper; + @Mock + ContractFileMapper contractFileMapper; + @Mock + ContractProcessMapper contractProcessMapper; + @Mock + AgentMapper agentMapper; + @Mock + UserMapper userMapper; + @Mock + PermissionService permissionService; + + ContractTools tools; + User user; + + @BeforeEach + void setUp() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), + ContractNew.class); + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), + ContractFile.class); + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), + ContractProcess.class); + tools = new ContractTools(contractNewMapper, contractFileMapper, contractProcessMapper, + agentMapper, userMapper, permissionService); + user = new User(); + user.setId(7L); + AuthContextHolder.set(new AuthContext(null, user)); + } + + @AfterEach + void tearDown() { + AuthContextHolder.clear(); + } + + @Test + @SuppressWarnings("unchecked") + void listBuildsLegacyFiltersAndTruncatesRows() { + ContractNew contract = sampleContract(); + when(contractNewMapper.selectCount(any())).thenReturn(1L); + when(contractNewMapper.selectList(any())).thenReturn(List.of(contract)); + Agent contractAgent = new Agent(); + contractAgent.setId(17L); + contractAgent.setName("合同所属公司A"); + Agent customerAgent = new Agent(); + customerAgent.setId(18L); + customerAgent.setName("客户所属公司B"); + when(agentMapper.selectBatchIds(anyList())).thenReturn(List.of(contractAgent, customerAgent)); + User creator = new User(); + creator.setId(1000L); + creator.setName("张三"); + when(userMapper.selectBatchIds(anyList())).thenReturn(List.of(creator)); + + Map result = tools.contractListQuery( + "452542C", "服务合同", "望京", "金蝶客户", "452542", + 17L, 18L, 1L, 2, 1, "2026-01-01", "2026-12-31", 1, 500); + + assertEquals(1, result.get("page")); + assertEquals(100, result.get("rows")); + assertEquals("rows 超过上限 100,已截断为 100", result.get("notice")); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(contractNewMapper).selectList(captor.capture()); + String sql = captor.getValue().getSqlSegment(); + assertTrue(sql.contains("contract_no LIKE")); + assertTrue(sql.contains("contract_name LIKE")); + assertTrue(sql.contains("property_name LIKE")); + assertTrue(sql.contains("customer_name LIKE")); + assertTrue(sql.contains("customer_no =")); + assertTrue(sql.contains("contract_agent_id =")); + assertTrue(sql.contains("customer_agent_id =")); + assertTrue(sql.contains("property_id =")); + assertTrue(sql.contains("contract_status =")); + assertTrue(sql.contains("expire_remind =")); + assertTrue(sql.contains("end_date >=")); + assertTrue(sql.contains("end_date <=")); + assertTrue(sql.contains("soft_delete =")); + assertTrue(sql.contains("ORDER BY id DESC")); + verify(permissionService).applyContractRowFilter(any(), any()); + + Map record = ((List>) result.get("records")).get(0); + assertEquals("452542C001", record.get("contractNo")); + assertEquals("合同所属公司A", record.get("contractAgentName")); + assertEquals("客户所属公司B", record.get("customerAgentName")); + assertEquals("张三", record.get("createName")); + assertEquals(2, record.get("contractStatus")); + assertEquals("正常", record.get("contractStatusText")); + assertEquals(new BigDecimal("120000.00"), record.get("amount")); + assertEquals("2026-01-01", record.get("startDate")); + assertEquals("2026-12-31", record.get("endDate")); + } + + @Test + void detailRequiresIdOrContractNo() { + Map result = tools.contractDetailQuery(null, " "); + + assertNull(result.get("record")); + assertEquals("id 和 contractNo 必须传一个", result.get("notice")); + verifyNoInteractions(contractNewMapper); + } + + @Test + void detailReturnsNullRecordWithNoticeWhenMissingOrUnauthorized() { + when(contractNewMapper.selectOne(any())).thenReturn(null); + + Map result = tools.contractDetailQuery(404L, null); + + assertNull(result.get("record")); + assertEquals("id=404 不存在或当前身份无权查看", result.get("notice")); + verify(permissionService).applyContractRowFilter(any(), any()); + } + + @Test + @SuppressWarnings("unchecked") + void detailByContractNoReturnsRecordFilesAndProcesses() { + ContractNew contract = sampleContract(); + when(contractNewMapper.selectOne(any())).thenReturn(contract); + Agent contractAgent = new Agent(); + contractAgent.setId(17L); + contractAgent.setName("合同所属公司A"); + when(agentMapper.selectBatchIds(anyList())).thenReturn(List.of(contractAgent)); + User creator = new User(); + creator.setId(1000L); + creator.setName("张三"); + when(userMapper.selectById(1000L)).thenReturn(creator); + + ContractFile file = new ContractFile(); + file.setId(1L); + file.setContractId(9L); + file.setFileName("合同扫描件.pdf"); + file.setFileUrl("https://oss.example.com/contract/9/scan.pdf"); + when(contractFileMapper.selectList(any())).thenReturn(List.of(file)); + + ContractProcess process = new ContractProcess(); + process.setId(1L); + process.setContractId(9L); + process.setNodeType(1); + process.setAuditLevel(0); + process.setOperationType(1); + process.setUserId(2000L); + process.setUserName("李四"); + process.setScheduleState(2); + process.setRemark("同意"); + when(contractProcessMapper.selectList(any())).thenReturn(List.of(process)); + + Map result = tools.contractDetailQuery(null, "452542C001"); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(contractNewMapper).selectOne(captor.capture()); + assertTrue(captor.getValue().getSqlSegment().contains("contract_no =")); + verify(permissionService).applyContractRowFilter(any(), any()); + + Map record = (Map) result.get("record"); + assertEquals("452542C001", record.get("contractNo")); + assertEquals("合同所属公司A", record.get("contractAgentName")); + assertEquals("未查询到", record.get("customerAgentName")); + assertEquals("张三", record.get("createName")); + assertEquals("正常", record.get("contractStatusText")); + + List> files = (List>) result.get("files"); + assertEquals(1, files.size()); + assertEquals("合同扫描件.pdf", files.get(0).get("fileName")); + assertEquals("https://oss.example.com/contract/9/scan.pdf", files.get(0).get("fileUrl")); + + List> processes = (List>) result.get("processes"); + assertEquals(1, processes.size()); + assertEquals("部门内审核", processes.get(0).get("nodeTypeText")); + assertEquals("审核通过", processes.get(0).get("operationTypeText")); + assertEquals("已办", processes.get(0).get("scheduleStateText")); + assertEquals("李四", processes.get(0).get("userName")); + assertEquals("同意", processes.get(0).get("remark")); + } + + @Test + void springAiPublishesBothReviewedToolSchemas() { + var callbacks = org.springframework.ai.tool.method.MethodToolCallbackProvider.builder() + .toolObjects(tools).build().getToolCallbacks(); + Map schemas = Arrays.stream(callbacks).collect(Collectors.toMap( + callback -> callback.getToolDefinition().name(), + callback -> callback.getToolDefinition().inputSchema())); + + assertEquals(2, schemas.size()); + assertTrue(schemas.containsKey("contract.list.query")); + assertTrue(schemas.containsKey("contract.detail.query")); + assertTrue(schemas.get("contract.list.query").contains("contractStatus")); + assertTrue(schemas.get("contract.detail.query").contains("contractNo")); + } + + private static ContractNew sampleContract() { + ContractNew contract = new ContractNew(); + contract.setId(9L); + contract.setContractNo("452542C001"); + contract.setContractName("望京SOHO厕纸服务合同"); + contract.setContractAgentId(17L); + contract.setCustomerAgentId(18L); + contract.setPropertyId(1L); + contract.setPropertyName("望京SOHO"); + contract.setCustomerNo("452542"); + contract.setCustomerName("金蝶客户名称"); + contract.setAmount(new BigDecimal("120000.00")); + contract.setContact("王五"); + contract.setStartDate(java.sql.Date.valueOf("2026-01-01")); + contract.setEndDate(java.sql.Date.valueOf("2026-12-31")); + contract.setExpireRemind(1); + contract.setRemindDays(30); + contract.setSaleId(1001L); + contract.setSaleName("销售甲"); + contract.setContractStatus(2); + contract.setCreateId(1000L); + return contract; + } +} diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceContractRowFilterTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceContractRowFilterTest.java new file mode 100644 index 0000000..eac3a6c --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceContractRowFilterTest.java @@ -0,0 +1,197 @@ +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.AgentManageDistrictAuth; +import me.iiv.agent.entity.m0.Authority; +import me.iiv.agent.entity.m0.Properyt; +import me.iiv.agent.entity.m0.User; +import me.iiv.agent.entity.m2.ContractNew; +import me.iiv.agent.entity.m2.WarehouseAgentAuth; +import me.iiv.agent.mapper.m0.AgentManageDistrictAuthMapper; +import me.iiv.agent.mapper.m0.AgentMapper; +import me.iiv.agent.mapper.m0.AuthorityMapper; +import me.iiv.agent.mapper.m0.ProperytMapper; +import me.iiv.agent.mapper.m2.WarehouseAgentAuthMapper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.HashOperations; +import org.springframework.data.redis.core.StringRedisTemplate; + +import java.util.Collection; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** PermissionService.applyContractRowFilter 直接单测:contract_new 行级权限各分支(与站点/出纸机同一 getByIsAgent 变体)。 */ +@ExtendWith(MockitoExtension.class) +class PermissionServiceContractRowFilterTest { + + @Mock + AuthorityMapper authorityMapper; + @Mock + AgentMapper agentMapper; + @Mock + WarehouseAgentAuthMapper warehouseAgentAuthMapper; + @Mock + AgentManageDistrictAuthMapper agentManageDistrictAuthMapper; + @Mock + ProperytMapper properytMapper; + @Mock + StringRedisTemplate redisTemplate; + @Mock + HashOperations hashOperations; + + PermissionService permissionService; + + @BeforeEach + void setUp() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), + ContractNew.class); + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), + Properyt.class); + permissionService = new PermissionService(authorityMapper, agentMapper, + warehouseAgentAuthMapper, agentManageDistrictAuthMapper, properytMapper, redisTemplate); + } + + @Test + void headquartersContractFilterAddsNoBoundary() { + User user = user(46L, 1L, null); + when(authorityMapper.selectById(1L)).thenReturn(authority(1L, 0)); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + permissionService.applyContractRowFilter(wrapper, user); + + assertTrue(wrapper.getSqlSegment().isBlank()); + } + + @Test + void agentContractFilterUsesSelfSubordinateAndManagedAgents() { + User user = user(1000L, 108L, 1L); + when(authorityMapper.selectById(108L)).thenReturn(authority(108L, 1)); + Agent subordinate = new Agent(); + subordinate.setId(2L); + when(agentMapper.selectList(any())).thenReturn(List.of(subordinate)); + WarehouseAgentAuth managedAuth = new WarehouseAgentAuth(); + managedAuth.setManagerAgentId(3L); + when(warehouseAgentAuthMapper.selectList(any())).thenReturn(List.of(managedAuth)); + when(agentManageDistrictAuthMapper.selectList(any())).thenReturn(List.of()); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + permissionService.applyContractRowFilter(wrapper, user); + + String sql = wrapper.getSqlSegment(); + Collection values = wrapper.getParamNameValuePairs().values(); + assertTrue(sql.contains("contract_agent_id IN")); + assertFalse(sql.contains("property_id")); + assertTrue(values.contains(1L)); + assertTrue(values.contains(2L)); + assertTrue(values.contains(3L)); + } + + @Test + void agentContractFilterAddsManagedPropertyOrBranch() { + User user = user(1000L, 108L, 1L); + when(authorityMapper.selectById(108L)).thenReturn(authority(108L, 1)); + when(agentMapper.selectList(any())).thenReturn(List.of()); + when(warehouseAgentAuthMapper.selectList(any())).thenReturn(List.of()); + AgentManageDistrictAuth districtAuth = new AgentManageDistrictAuth(); + districtAuth.setPropertyId(44L); + when(agentManageDistrictAuthMapper.selectList(any())).thenReturn(List.of(districtAuth)); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + permissionService.applyContractRowFilter(wrapper, user); + + String sql = wrapper.getSqlSegment(); + Collection values = wrapper.getParamNameValuePairs().values(); + assertTrue(sql.contains("contract_agent_id IN")); + assertTrue(sql.contains("property_id IN")); + assertTrue(sql.contains("OR")); + assertTrue(values.contains(1L)); + assertTrue(values.contains(44L)); + } + + @Test + void salesNonLeaderContractFilterUsesOwnCreatedPropertyIds() { + User user = user(1000L, 108L, 1L); + user.setDeptId(5L); + user.setPostId(7L); + when(authorityMapper.selectById(108L)).thenReturn(authority(108L, 1)); + when(agentMapper.selectList(any())).thenReturn(List.of()); + when(warehouseAgentAuthMapper.selectList(any())).thenReturn(List.of()); + when(redisTemplate.opsForHash()).thenReturn(hashOperations); + when(hashOperations.get("Dictionary", "authDeptId")).thenReturn(",5,"); + when(hashOperations.get("Dictionary", "authDeptPostId")).thenReturn("8,"); + Properyt property = new Properyt(); + property.setId(55L); + when(properytMapper.selectList(any())).thenReturn(List.of(property)); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + permissionService.applyContractRowFilter(wrapper, user); + + String sql = wrapper.getSqlSegment(); + assertTrue(sql.contains("property_id IN")); + assertFalse(sql.contains("contract_agent_id")); + assertTrue(wrapper.getParamNameValuePairs().values().contains(55L)); + verifyNoInteractions(agentManageDistrictAuthMapper); + } + + @Test + void salesNonLeaderWithoutVisiblePropertiesFiltersToZero() { + User user = user(1000L, 108L, 1L); + user.setDeptId(5L); + user.setPostId(7L); + when(authorityMapper.selectById(108L)).thenReturn(authority(108L, 1)); + when(agentMapper.selectList(any())).thenReturn(List.of()); + when(warehouseAgentAuthMapper.selectList(any())).thenReturn(List.of()); + when(redisTemplate.opsForHash()).thenReturn(hashOperations); + when(hashOperations.get("Dictionary", "authDeptId")).thenReturn(",5,"); + when(hashOperations.get("Dictionary", "authDeptPostId")).thenReturn("8,"); + when(properytMapper.selectList(any())).thenReturn(List.of()); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + permissionService.applyContractRowFilter(wrapper, user); + + String sql = wrapper.getSqlSegment(); + assertTrue(sql.contains("property_id IN")); + assertFalse(sql.contains("contract_agent_id")); + assertTrue(wrapper.getParamNameValuePairs().values().contains(0L)); + verifyNoInteractions(agentManageDistrictAuthMapper); + } + + @Test + void agentUserWithoutAgentIdIsDeniedForContractFilter() { + User user = user(1000L, 108L, null); + when(authorityMapper.selectById(108L)).thenReturn(authority(108L, 1)); + + assertThrows(IllegalStateException.class, + () -> permissionService.applyContractRowFilter(new LambdaQueryWrapper<>(), user)); + } + + private static Authority authority(Long id, int isAgent) { + Authority authority = new Authority(); + authority.setId(id); + authority.setIsagent(isAgent); + authority.setSoftDelete(0); + return authority; + } + + 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; + } +} -- 2.54.0