Compare commits

..
34 changed files with 1668 additions and 78 deletions
+2
View File
@@ -82,6 +82,8 @@ xiaoqu-agent-service/
| `agent.list.query` | customer | read | 代理商分页查询。入参 id/name/level/channelNo/province/city/serialnumber/page/rows;出参 id/name/level/channelNo/serialnumber/supervisorName/province/city/countyseat/contact/phone/agencyarea/memo/createTime。行权限:isagent=1 只查直属下级,id 精确查可看自身。supervisorName 批量 IN 富化 |
| `property.list.query` | customer | read | 物业(客户)分页查询(m0 properyt 表)。入参 name/agentId/serialnumber/province/city/contact/phone/page/rows;出参 id/name/serialnumber/agentName/createName/province/city/countyseat/address/contact/phone/createTime。行权限走 PermissionService 完整版 getByIsAgent 语义(不含 enterprise_type=2 限制,与老 /property/listByPage 一致)。agentName/createName 批量 IN 富化 |
| `integration.context.get` | integration | read | 身份与能力自检(REQ-20260911-0012)。无入参;返回 tenant_id(作用域编码映射)/principal_type/subject_id/scopes(可调用工具集合,与强制校验同源实时计算)/data_scope_version(token_scope 绑定最大修改时间 epoch 毫秒)/capability_levels(QUERY/SYNC,按 *.changes.list 数据驱动)/server_time |
| `warehouse.in.list.query` | warehouse | read | 入库单分页查询;入库/申请日期独立过滤,主单代理、涉及仓库、流程参与人三类权限取并集 |
| `warehouse.in.detail.query` | warehouse | read | 入库单详情;支持调拨单,物料汇总、可选 SN、流程、附件和关联出库索引 |
分页统一规范:page 从 1 开始,rows 默认 20、上限 100,超出截断为 100 并在返回 `notice` 字段提示。
@@ -519,6 +519,34 @@
---
## 17. warehouse.in.list.query
分页查询入库单,参考旧系统 `POST /myWarehouseIn/allListByPage`,但将入库日期与申请日期分别过滤,
并按本页主单 ID 批量加载仓库和待办信息。
- 分类:warehouse | 类型:读
- 数据来源:m2 `warehouse_in`、`warehouse_in_details`、`warehouse_in_audit_process`
- 行级权限:总部看全部;代理身份可看所属代理范围、所属或负责仓库涉及、或本人参与处理的入库单。
- 单号支持入库/调拨/关联出库/商城/送货/装机/取货/撤机;另支持代理、仓库、入库类型、申请人、
状态、同步状态、物流和入库/申请日期范围筛选。
- 返回 `total/page/rows/records`;每条记录含 `sourceType`(ALLOCATION/OUTBOUND_LINKED/MALL/NORMAL)、
状态文案、仓库集合、待办人、实际入库人和入库日期。
## 18. warehouse.in.detail.query
按 `id` 或 `inOrderNo` 查询详情,两者至少传一个;同时传入时必须匹配同一条可见记录。调拨入库单可直接查询。
- 行级权限与列表完全一致;不存在和无权统一返回 `NOT_FOUND_OR_FORBIDDEN`。
- 返回 `record/sceneType/warehouses/items/processes/files/relatedOutOrder`。
- `items` 按物料定义、物料索引和仓库汇总;`includeSerials` 默认 false,`serialLimit` 默认 100、上限 500,
同时返回 `serialTotal/serialTruncated`。
- `files.application/files.confirmation` 仅返回 `id/fileName/bucket/objectKey`。
- `relatedOutOrder` 仅给出关联出库单号和查询工具名,不内嵌出库单内容。
详细设计见 `warehouse-in-mcp-tools-design.md`。
---
## 附录:在线查看工具清单
```bash
@@ -3,6 +3,8 @@
> 适用版本:REQ-20260911-0013(增量同步框架)交付后
> 端点:`https://agent.ququclean.com/mcp`(生产)/ `http://192.168.4.236:8088/mcp`(内网)
> 协议:MCP Streamable HTTP(JSON-RPC),所有响应为 SSE,取 `data:` 行
>
> 当前生产可用性、阻塞项和下一步以[对接进度](crm-sync-integration-progress.md)为准;本手册描述目标调用方式,不代表发布门禁已经通过。
## 0. 边界(先读这段)
@@ -43,7 +45,7 @@ tools/call integration.context.get
## 3. 后台通道:每日定时扫描(增量同步)
三个工具:`users.changes.list` / `properties.changes.list` / `contracts.changes.list`。
三个变化流工具:`users.changes.list` / `properties.changes.list` / `contracts.changes.list`。
入参只有三个(**禁止传 tenant_id/scope**,传了直接 PERMISSION_DENIED):
@@ -87,6 +89,20 @@ loop:
- 不要拿 `version` 字段做并发控制——它由 `modify_time` 派生,只用于调试核对。
- 不要把扫描任务和销售在线查询混用同一个 Token——服务 Token 调 QUERY 工具会被拒(scope 白名单),这是设计如此。
### 发送前复核(不能省略)
日同步只负责发现候选。CRM 在晨报、午报或日结真正入推送队列前,必须使用同一个服务 Token 批量调用:
```text
tools/call contracts.current.batch_get
arguments: {"contractIds":["1001","1002"]}
```
- 每批 1—200 个稳定合同 ID,返回 `checked_at/count/items/not_found_ids`。
- `items` 使用与合同变化流相同的公开字段;软删合同返回删除标记,查无记录进入 `not_found_ids`。
- CRM 必须逐项复核状态、到期日、负责人、客户归属、提醒配置和续约结果。任一变化、缺失、删除、响应不完整、证据时间异常或服务不可用,对应合同本次都不得发送。
- 该工具属于 SYNC 能力和限流档,调用方传 `tenant_id/tenantId/scope` 会被拒绝;数据域只能来自服务 Token。
## 4. 前台通道:销售在线查询(实时 ACL)
```
@@ -105,7 +121,7 @@ loop:
| | 前台(销售点查) | 后台(定时扫描) |
|---|---|---|
| Token 类型 | user_delegated | tenant_service |
| 可用工具 | contract.list/detail.query 等 QUERY | *.changes.list + context.get |
| 可用工具 | contract.list/detail.query 等 QUERY | *.changes.list + contracts.current.batch_get + context.get |
| 数据范围 | 实时 ACL 行过滤 | 管理员授权的 sync scope |
| 限流 | 常规档 | SYNC 独立档,互不占配额 |
| tenant_id 入参 | 不允许 | 不允许,从 Token 推导 |
@@ -0,0 +1,140 @@
# SaaS MCP → 小趣 AI 经营系统对接进度
> MCP 系统独立进度入口。最后核对:2026-09-15;代码位于`codex/crm-contract-sync-review`本地评审分支。
>
> 本文只记录 `xiaoqu_mcp_service` 的责任和状态。小趣 AI / CRM 的规则、事项、AI、页面与推送进度,以其仓库 `PROJECT.md` 为准。
## 1. 两个系统的边界
| 系统 | 必须负责 | 明确不负责 |
|---|---|---|
| SaaS MCP | 服务身份、源租户/数据域、工具授权、源系统行级权限、用户/物业/合同标准变化流、稳定游标、审计与标准错误 | 到期规则、经营优先级、AI决策、经营事项状态机、销售页面、APNs推送 |
| 小趣 AI / CRM | 租户连接、数据镜像、身份与客户映射、确定性规则、仅把命中数据交给AI、事项生成/流转、展示、操作、反馈、推送前复核 | 猜测源租户、绕过MCP权限、兼容不符合冻结Schema的错误字段 |
双方唯一连接面是版本化接口契约:`integration.context.get`、三条 `*.changes.list`、`contracts.current.batch_get`、统一错误信封,以及真实端到端验收。任何一侧内部实现都不能成为另一侧的隐式依赖。
## 2. 当前结论
当前状态:**协议可连接,公开字段Schema已在本地修正并通过回归;可靠同步与服务身份仍未通过,不能用于真实销售合同到期推送。**
交付状态:修正位于独立分支`codex/crm-contract-sync-review`,本地提交已形成;当前执行环境没有`wangheng/xiaoqu_mcp_service`写权限,尚未推送、合并或部署,生产服务仍是旧行为。
已经具备:
- MCP Streamable HTTP 会话、SSE响应和Schema v2信封。
- `X-Open-Key` 管理、服务Token签发、Token作用域、工具白名单、审计和限流。
- `integration.context.get`。
- `users.changes.list`、`properties.changes.list`、`contracts.changes.list`及统一分页框架。
- `contracts.current.batch_get`发送前批量回源复核已在本地实现;沿用SYNC身份、权限、限流和审计边界。
- 基于 `(modify_time, id)` 的分页、软删标记、错误码和基础单元测试。
尚未通过的不是“小趣AI页面问题”,而是MCP公开数据契约与可靠同步语义。小趣AI侧不得通过放宽校验来掩盖这些问题。
## 3. 已确认问题与处理顺序
### P0-1 服务身份与源数据域语义混淆
代码核对结论:当前SaaS业务库没有统一的`tenant_id`字段。人员、物业、合同处于同一个业务数据域,在线行权限由代理商、物业和销售关系计算;合同负责人由`contract_new.sale_id`表达。不能为了对接新增一个虚假的行级租户字段。
现状仍有两个独立问题:`integration.context.get.tenant_id`由作用域编码拼接得到,`sync`被误当成数据域;服务Token又按`serviceName + scope`全局复用,创建它的Open Client身份没有进入Token。作用域只代表“能用什么工具”,不是数据来源或调用方身份。
影响:消费者无法稳定识别数据来自哪套SaaS实例;不同Open Client使用相同`serviceName`时可能复用服务Token。它不会替代合同负责人分发,但会影响连接隔离、吊销和审计。
标准改造:
1. 为每套MCP部署配置稳定的`source_domain_id`,例如生产SaaS数据域标识;它来自服务端配置,不由请求体、scope或名称推断。
2. 服务Token持久化`open_client_id + subject_name`,ensure按调用方、服务主体和scope组合幂等签发;不同Open Client不共享Token。
3. `integration.context.get`返回稳定`source_domain_id`。若为保持既有契约继续输出`tenant_id`,必须明确该字段在当前单数据域SaaS中表示“源数据域”,不能再返回`sync`。
4. SYNC服务Token读取该单一数据域的完整变化流;记录按合同`owner_user_id`分发。在线个人查询继续使用现有人员ACL,两条通道不混用。
5. 小趣AI的租户连接负责把自己的`tenant_id`显式绑定到该`source_domain_id`,禁止同一个源数据域被误配给多个目标租户。
验收:两个Open Client即使使用相同`serviceName=crm`也得到不同服务Token;吊销其中一个不影响另一个;上下文返回稳定源数据域;小趣AI连接错配源数据域时在同步前失败关闭。
### P0-2 三条变化流公开字段Schema(本地已修正,待部署联调)
原实现与冻结契约的差异:
| 流 | 当前输出 | 必须输出 |
|---|---|---|
| users | `mobile`;数值型`user_id/organization_ids` | `verified_mobile`;字符串型稳定ID;状态使用公开枚举 |
| properties | 数值型`property_id` | 字符串型稳定ID;状态使用公开枚举 |
| contracts | 数值型`contract_id/property_id/owner_user_id`、数值状态`0..4`、数值`expire_remind`,缺`remind_days` | 字符串型稳定ID、`PENDING/REJECTED/ACTIVE/EXPIRED/TERMINATED/VOID`、布尔提醒标记、实际提醒天数 |
已完成:新增独立公开DTO,将三类稳定ID统一为字符串;`mobile`改为`verified_mobile`;合同状态转换为公开枚举;`expire_remind`转换为布尔值;补齐`remind_days`;变化页使用统一`stream`字段。未知合同状态按`DATA_INTEGRITY_ERROR`失败关闭,不伪造业务语义。
验证:新增发送前复核后,Java编译、管理前端构建及MCP服务全量`225`项测试通过,`0`失败、`0`错误。尚需部署后的真实数据样例和小趣Gateway严格消费者契约联调,完成前仍不算生产通过。
验收:用真实样例对三条流执行消费者契约测试;Gateway无需兼容代码即可全部通过,未知字段、未知状态和错误类型必须失败。
### P0-3 当前“快照”不能保证重放稳定
现状:令牌只保存`cutoff`,每页仍直接查询持续变化的业务表。分页期间,`cutoff`之前的记录被修改、删除或权限变化时,相同`sync_token + cursor + limit`可能得到不同结果;秒级`modify_time`也可能漏掉同一行在边界秒内的再次修改。
影响:合同规模扩大或同步期间发生业务写入时,存在漏数、重复、记录消失和重放不一致风险,达不到文档声称的“不重不漏”。
标准改造:建立通用变更日志/Outbox(每次变更拥有单调递增`change_seq`,保存实体ID、操作类型、版本和必要投影),同步窗口按`change_seq`冻结;游标按`change_seq`推进。若使用CDC,也必须落成可保留、可重放的变化流,不能直接分页扫活表冒充快照。
验收:同步中并发更新、删除、负责人变更、权限变更以及相同请求重放时,旧快照结果不变,新变化只进入下一轮;压测覆盖成千上万合同。
### P0-4 交接Token错误复用了24小时快照TTL
现状:轮内快照Token、页游标和已完成轮的`next_sync_token`统一执行24小时TTL。
影响:每日任务只要稍晚、停机或积压,就可能被迫全量重建;数据量增大后会形成周期性全量压力。
标准改造:只给轮内快照/游标设置短TTL;交接Token按变更日志保留期校验,或保存持久化消费位点。过期时返回可观测的最早可用位置及标准错误,消费者执行受控重建。
验收:间隔超过24小时仍可正常增量;只有位点早于服务端保留期才触发重建。
### P0-5 数据权限版本不能覆盖真实权限变化
现状:`data_scope_version`仅取`token_scope`关系的修改时间;Open Client身份关联、scope-tool变化、源数据域ACL变化不会稳定反映。
影响:权限变化后旧同步位点可能继续使用,消费者无法判断需要重建或停止派发。
标准改造:维护数据域授权版本,任何调用方身份关联、工具授权、行级ACL语义变更都原子递增;上下文与同步Token使用同一个版本。
验收:修改任一影响可见数据的授权后,旧轮立即失败关闭,新轮在新数据域版本下重建。
### P0-6 发送前权威复核(本地已实现,待部署联调)
已实现`contracts.current.batch_get`:一次读取1—200份合同当前事实,按输入顺序返回已找到记录,并明确返回`not_found_ids`;软删记录不会伪装成“查无数据”。该工具沿用SYNC服务身份,禁止调用方覆盖数据域,使用SYNC独立限流档并进入统一审计。
小趣AI侧已经按同一契约实现短时发送凭证:只有状态、到期日、负责人、客户归属、提醒字段及续约结果与本地候选一致才允许R04进入推送;缺失、删除、字段变化、响应不完整或MCP不可用均失败关闭,R03不受影响。
尚未完成:本分支未推送和部署;生产`sync`作用域还必须绑定该新工具;需要用真实合同验证负责人变更、删除、到期日修改和服务不可用四类反向场景。
## 4. 依赖顺序
1. 冻结服务端`source_domain_id`配置、单数据域范围和合同状态/提醒字段字典。
2. 完成Open Client与服务Token身份关联,补迁移、ensure和上下文测试。
3. 建立通用变更日志/Outbox与保留策略,再迁移三条变化流;不继续强化活表分页方案。
4. 使用独立公开DTO完成三条流字段映射和消费者契约测试。
5. 修正交接Token生命周期与统一数据权限版本。
6. 在测试环境用真实用户、物业、合同及变更/删除数据完成MCP侧验收。
7. 交付Open Client、稳定源数据域ID和凭证给小趣AI侧,联合验证“同步→映射→规则→事项→展示→推送前复核”。
## 5. 状态看板
| 工作项 | 状态 | 责任系统 | 下一步 |
|---|---|---|---|
| MCP协议与工具发现 | 已完成 | MCP | 保持兼容并纳入回归 |
| 服务密钥与Token基础生命周期 | 已完成 | MCP | 在租户模型完成后升级幂等键 |
| 源数据域标识与服务主体隔离 | 未完成,P0阻塞 | MCP | 增加稳定部署数据域标识和Open Client关联 |
| 三流公开字段Schema | 本地代码完成,待部署联调 | MCP | 部署后用真实三流样例跑Gateway严格契约测试 |
| 发送前合同当前状态批量复核 | 本地代码完成,待部署联调 | MCP + 小趣AI | 绑定sync作用域并完成四类真实反向用例 |
| 可靠变化日志与稳定快照 | 未完成,P0阻塞 | MCP | 设计Outbox/CDC及保留策略 |
| 权限版本与交接位点生命周期 | 未完成,P0阻塞 | MCP | 与租户/变化日志模型一起实现 |
| 小趣AI连接与消费代码 | 已完成,待部署联调 | 小趣AI | 见小趣AI仓库`PROJECT.md` |
| 真实销售合同到期推送 | 未开放 | 双方联合验收 | 所有P0通过后才允许从Shadow转Live |
## 6. 发布门禁
以下条件全部满足前,不得把MCP同步标记为生产可用,也不得向真实销售发送合同提醒:
- 稳定源数据域、服务主体隔离和连接错配反向用例通过。
- 三条变化流通过冻结Schema及真实数据样例校验。
- 并发写入、删除、断点续传、超过24小时续传和权限变化测试通过。
- 凭证不入库明文、不入日志;轮换和吊销测试通过。
- 小趣AI侧完成负责人、客户双映射和发送前实时复核。
- 完整链路具有`request_id/snapshot_id/source_domain_id`审计证据,可定位每条事项和推送的数据来源。
@@ -0,0 +1,59 @@
# 入库单 MCP 工具设计
## 1. 范围与评审结论
参考旧系统 `POST /myWarehouseIn/allListByPage` 与 `GET /myWarehouseIn/lookDetail`,新增:
| 工具 | 分类 | 用途 |
|---|---|---|
| `warehouse.in.list.query` | warehouse/read | 分页查询入库单 |
| `warehouse.in.detail.query` | warehouse/read | 按主键或入库单号查询详情 |
自评审通过,确认采用以下产品取舍:调拨入库单允许直接查询详情;关联出库单仅返回索引,
不内嵌完整出库信息。工具只读 m2,不新增业务表或 Flyway migration;启动后由
`ToolSyncService` 自动登记,需由管理员绑定 Scope 后才可调用。
## 2. 列表
- 单号:入库、调拨、关联出库、商城、送货、装机、取货、撤机单号。
- 归属:代理、仓库、入库类型、申请人。
- 状态与物流:订单状态、入库同步状态、物流公司和运单号。
- 日期:`inDateFrom/inDateTo` 精确过滤 `in_date`;`applyDateFrom/applyDateTo`
过滤 `apply_date`,结束日期含当天。不会复制旧接口将入库日期改写成申请日期的异常逻辑。
- 分页:page 默认 1,rows 默认 20、上限 100。
返回主单、来源类型、状态文案、涉及仓库、待办人、实际入库人及日期、物流和关联单号。
本页明细与待办按主单 ID 批量查询,避免 N+1。
`sourceType` 的优先级为 `ALLOCATION`、`OUTBOUND_LINKED`、`MALL`、`NORMAL`。
## 3. 详情
`id`、`inOrderNo` 至少传一个;同时传入时必须匹配同一条可见记录。详情返回:
- `record`、`sceneType`、涉及仓库;
- 按物料定义、物料索引和仓库汇总的 `items`;
- 可选 `serials` 以及完整 `serialTotal`、`serialTruncated`;
- `schedule_state=2` 的已处理流程;
- `file_type=0/1` 的申请、确认入库附件,仅返回 OSS 对象定位,不生成签名 URL;
- `relatedOutOrder`:仅出库单号和建议调用的 `warehouse.out.detail.query` 工具名。
## 4. 权限与安全
列表和详情强制复用 `PermissionService.applyWarehouseInRowFilter`:
- 总部身份查看全部;
- 代理身份可见范围是主单所属代理范围、所属或负责仓库涉及、本人参与处理三类并集;
- 代理范围包含自己、直属下级和 `warehouse_agent_auth` 授权代管代理;
- 权限组异常或代理用户未关联代理商时失败关闭;
- 详情不存在与无权访问统一返回 `NOT_FOUND_OR_FORBIDDEN`。
权限使用主查询中的 `EXISTS`,所有业务筛选均只能缩小结果。关联出库不提前判断是否可访问,
避免形成旁路探测;调用方若需要内容,必须再调用出库详情工具并接受其独立鉴权。
## 5. 验收
- 列表分页、日期校验、仓库过滤、批量富化和状态文案;
- 详情双条件、调拨单、物料汇总、SN 默认关闭/截断、附件分类、出库索引;
- 总部、代理范围、仓库负责人、流程参与人和异常配置失败关闭;
- 固定 `TZ=Asia/Shanghai` 执行全量后端测试,不连接生产数据库、不执行部署。
+2 -2
View File
@@ -40,10 +40,10 @@ INSERT INTO agent_scope (name, code, memo, status, create_id)
SELECT '数据同步', 'sync', 'SYNC 增量同步作用域(5628 注册 *.changes.list 工具后绑定,仅服务 Token 使用)', 1, 0
WHERE NOT EXISTS (SELECT 1 FROM agent_scope WHERE code = 'sync');
-- sync 作用域-工具:integration.context.get(服务 Token 自检入口)+ 三个 changes 工具(REQ-20260911-0013)
-- sync 作用域-工具:服务 Token 自检、三条变化流及发送前合同复核
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 = 'sync' AND t.name IN ('integration.context.get', 'users.changes.list', 'properties.changes.list', 'contracts.changes.list')
WHERE s.code = 'sync' AND t.name IN ('integration.context.get', 'users.changes.list', 'properties.changes.list', 'contracts.changes.list', 'contracts.current.batch_get')
AND NOT EXISTS (SELECT 1 FROM scope_tool st WHERE st.scope_id = s.id AND st.tool_id = t.id);
COMMIT;
@@ -0,0 +1,56 @@
package me.iiv.agent.entity.m2;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.io.Serializable;
import java.time.LocalDate;
import java.util.Date;
/** 入库单主表(m2,只读)。 */
@Getter
@Setter
@ToString
@TableName("warehouse_in")
public class WarehouseIn implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private Long createId;
private Date createTime;
private Long modifyId;
private Date modifyTime;
private Integer softDelete;
private String inOrderNo;
private String allocationOrderNo;
private Long agentId;
private String agentName;
private Long warehouseTypeId;
private String warehouseTypeName;
private LocalDate inDate;
private Integer goodsCount;
private Integer inCount;
private Integer orderState;
private Long applyUserId;
private String applyUserName;
private Date applyDate;
private String memo;
private Integer auditLevel;
private Integer currendAuditLevel;
private Integer inSynchronizeState;
private Date inSynchronizeTime;
private String removeUserName;
private Date removeDate;
private String outOrderNo;
private String mallOrderNo;
private Long logisticsId;
private String logisticsName;
private String logisticsWayBill;
private String deliveryOrderNo;
private String installOrderNo;
private String pickupOrderNo;
private String withdrawOrderNo;
}
@@ -0,0 +1,34 @@
package me.iiv.agent.entity.m2;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.io.Serializable;
import java.util.Date;
/** 入库单审批/处理流程(m2,只读)。 */
@Getter
@Setter
@ToString
@TableName("warehouse_in_audit_process")
public class WarehouseInAuditProcess implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private Integer softDelete;
private Long warehouseInId;
private Long warehouseId;
private String warehouseName;
private Long userId;
private String userName;
private Date operationTime;
private Integer scheduleState;
private Integer auditState;
private Integer auditLevel;
private Integer operationTheme;
private Integer operationType;
private String memo;
}
@@ -0,0 +1,63 @@
package me.iiv.agent.entity.m2;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.io.Serializable;
import java.time.LocalDate;
import java.util.Date;
/** 入库单物料/SN 明细(m2,只读)。 */
@Getter
@Setter
@ToString
@TableName("warehouse_in_details")
public class WarehouseInDetail implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private Long createId;
private Date createTime;
private Long modifyId;
private Date modifyTime;
private Integer softDelete;
private Long agentId;
private String agentName;
private String inOrderNo;
private String allocationOrderNo;
private Long warehouseInId;
private Long materielDefinitionId;
private String materialNo;
private String materielFullName;
private String materielSort;
private Long materielIndexId;
private String specifications;
private String snCode;
private Integer inCount;
private Integer currentInventory;
private LocalDate inDate;
private Long warehouseId;
private String warehouseName;
private Long warehouseTypeId;
private String warehouseTypeName;
private Integer orderState;
private Long applyUserId;
private String applyUserName;
private Integer goodInState;
private Date inConfirmTime;
private String inConfirmUserName;
private String outOrderNo;
private String mallOrderNo;
private Integer assetDisposalType;
private Integer synchronizeState;
private Long outDetailsId;
private String deliveryOrderNo;
private String installOrderNo;
private String pickupOrderNo;
private String withdrawOrderNo;
private String memo;
private String lineMemo;
}
@@ -0,0 +1,26 @@
package me.iiv.agent.entity.m2;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.io.Serializable;
/** 出入库类型(m2,只读)。 */
@Getter
@Setter
@ToString
@TableName("warehouse_type")
public class WarehouseType implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private Integer softDelete;
private Integer warehouseType;
private String typeName;
private Integer sceneType;
private String typeNo;
private Integer assetFlag;
}
@@ -0,0 +1,7 @@
package me.iiv.agent.mapper.m2;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import me.iiv.agent.entity.m2.WarehouseInAuditProcess;
public interface WarehouseInAuditProcessMapper extends BaseMapper<WarehouseInAuditProcess> {
}
@@ -0,0 +1,7 @@
package me.iiv.agent.mapper.m2;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import me.iiv.agent.entity.m2.WarehouseInDetail;
public interface WarehouseInDetailMapper extends BaseMapper<WarehouseInDetail> {
}
@@ -0,0 +1,7 @@
package me.iiv.agent.mapper.m2;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import me.iiv.agent.entity.m2.WarehouseIn;
public interface WarehouseInMapper extends BaseMapper<WarehouseIn> {
}
@@ -0,0 +1,7 @@
package me.iiv.agent.mapper.m2;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import me.iiv.agent.entity.m2.WarehouseType;
public interface WarehouseTypeMapper extends BaseMapper<WarehouseType> {
}
@@ -93,12 +93,12 @@ public class GuardedToolCallback implements ToolCallback {
}
/**
* SYNC 工具(*.changes.list)数据域硬约束:tenant_id/scope 入参一律 PERMISSION_DENIED。
* SYNC 数据工具的数据域硬约束:tenant_id/scope 入参一律 PERMISSION_DENIED。
* 工具方法签名本就不含这些参数,这里拦的是调用方在原始 JSON 里多传的键
* (Spring AI 对未知参数默认宽松,不能依赖反序列化兜底)。
*/
private void rejectForbiddenSyncParams(String toolName, String toolInput) {
if (!toolName.endsWith(IntegrationContextService.CHANGES_TOOL_SUFFIX)) {
if (!IntegrationContextService.isSyncDataTool(toolName)) {
return;
}
try {
@@ -35,8 +35,13 @@ public class IntegrationContextService {
public static final String CAPABILITY_QUERY = "QUERY";
public static final String CAPABILITY_SYNC = "SYNC";
/** 增量同步工具命名约定:出现该后缀即视为具备 SYNC 能力(数据驱动,不硬编码工具清单) */
/** 后台数据工具命名约定:变化流与发送前批量复核均属于 SYNC 能力。 */
static final String CHANGES_TOOL_SUFFIX = ".changes.list";
static final String CURRENT_BATCH_TOOL_SUFFIX = ".current.batch_get";
static boolean isSyncDataTool(String name) {
return name.endsWith(CHANGES_TOOL_SUFFIX) || name.endsWith(CURRENT_BATCH_TOOL_SUFFIX);
}
final TokenScopeMapper tokenScopeMapper;
final AgentScopeMapper agentScopeMapper;
@@ -93,10 +98,10 @@ public class IntegrationContextService {
/** 能力等级:能力集合内含 *.changes.list 工具 → SYNC;含其他工具 → QUERY;无能力 → 空 */
List<String> capabilityLevels(Collection<String> allowedTools) {
List<String> levels = new ArrayList<>();
if (allowedTools.stream().anyMatch(name -> !name.endsWith(CHANGES_TOOL_SUFFIX))) {
if (allowedTools.stream().anyMatch(name -> !isSyncDataTool(name))) {
levels.add(CAPABILITY_QUERY);
}
if (allowedTools.stream().anyMatch(name -> name.endsWith(CHANGES_TOOL_SUFFIX))) {
if (allowedTools.stream().anyMatch(IntegrationContextService::isSyncDataTool)) {
levels.add(CAPABILITY_SYNC);
}
return levels;
@@ -28,6 +28,9 @@ public enum McpErrorCode {
/** 调用方声明的 schema_version 不受支持 */
SCHEMA_VERSION_UNSUPPORTED,
/** 工具入参格式、数量或取值不符合公开契约 */
INVALID_ARGUMENT,
/** 数据处理过程中发现不一致或未预期异常(兜底码,内部细节只落服务端日志) */
DATA_INTEGRITY_ERROR
}
@@ -12,7 +12,7 @@ import java.time.Instant;
/**
* 工具调用限流(REQ-20260911-0013 资源隔离):按 Token 分档计数——
* *.changes.list 走 SYNC 档位,其余走 QUERY 档位,互不占配额。
* 后台数据工具走 SYNC 档位,其余走 QUERY 档位,互不占配额。
* Redis 固定窗口(分钟),超额抛 RATE_LIMITED;Redis 不可用时放行并告警
* (限流是保护措施,不应成为可用性单点)。
* 配额 0 或负数 = 关闭该档位。
@@ -38,7 +38,7 @@ public class RateLimitService {
if (tokenId == null) {
return;
}
boolean sync = toolName.endsWith(IntegrationContextService.CHANGES_TOOL_SUFFIX);
boolean sync = IntegrationContextService.isSyncDataTool(toolName);
int limit = sync ? syncPerMinute : queryPerMinute;
if (limit <= 0) {
return;
@@ -121,7 +121,7 @@ public class ChangesEngine {
// 4. 组装响应:中间页回传锚点 + next_cursor;最后一页签发 next_sync_token
String roundToken = codec.encodeRound(entityName, fromTs, cutoffTs, snapshotId, scopeVersion);
Map<String, Object> result = new LinkedHashMap<>();
result.put("entity", entityName);
result.put("stream", entityName);
result.put("snapshot_id", snapshotId);
result.put("data_cutoff_at", iso(new Date(cutoffTs * 1000)));
result.put("limit", pageSize);
@@ -6,12 +6,16 @@ import me.iiv.agent.entity.m0.User;
import me.iiv.agent.entity.m2.ContractNew;
import me.iiv.agent.mapper.m0.ProperytMapper;
import me.iiv.agent.mapper.m2.ContractNewMapper;
import me.iiv.agent.mcp.McpErrorCode;
import me.iiv.agent.mcp.ToolCallException;
import me.iiv.agent.permission.PermissionService;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -74,25 +78,48 @@ public class ContractsSyncEntity extends AbstractSyncEntity implements SyncEntit
return rows;
}
/**
* Reads the current authoritative rows for pre-delivery verification.
* Soft-deleted rows are intentionally retained so consumers can cancel a
* stale recommendation instead of confusing deletion with transport loss.
*/
public List<Map<String, Object>> fetchCurrent(List<Long> contractIds, User user) {
if (contractIds.isEmpty()) {
return List.of();
}
LambdaQueryWrapper<ContractNew> wrapper = new LambdaQueryWrapper<ContractNew>()
.in(ContractNew::getId, contractIds);
if (user != null) {
permissionService.applyContractRowFilter(wrapper, user);
}
List<ContractNew> contracts = contractNewMapper.selectList(wrapper);
List<Long> propertyIds = contracts.stream().map(ContractNew::getPropertyId)
.filter(Objects::nonNull).distinct().collect(Collectors.toList());
Map<Long, String> customerIds = propertyIds.isEmpty() ? Map.of() : customerIdsByProperty(propertyIds);
Map<Long, Integer> inputOrder = new HashMap<>();
for (int index = 0; index < contractIds.size(); index++) {
inputOrder.put(contractIds.get(index), index);
}
contracts.sort(Comparator.comparingInt(row -> inputOrder.getOrDefault(row.getId(), Integer.MAX_VALUE)));
List<Map<String, Object>> items = new ArrayList<>(contracts.size());
for (ContractNew contract : contracts) {
items.add(toItem(contract, ChangesEngine.effectiveTs(contract.getModifyTime(), contract.getCreateTime()),
customerIds));
}
return items;
}
private Map<String, Object> toItem(ContractNew row, long mt, Map<Long, String> customerIds) {
boolean deleted = row.getSoftDelete() != null && row.getSoftDelete() == 1;
Date effective = row.getModifyTime() != null ? row.getModifyTime() : row.getCreateTime();
Map<String, Object> item = new LinkedHashMap<>();
item.put("contract_id", row.getId());
item.put("contract_no", row.getContractNo());
item.put("contract_name", row.getContractName());
item.put("status", row.getContractStatus());
item.put("start_at", ChangesEngine.iso(row.getStartDate()));
item.put("end_at", ChangesEngine.iso(row.getEndDate()));
item.put("amount_minor", toMinor(row.getAmount()));
item.put("customer_id", row.getPropertyId() == null ? null : customerIds.get(row.getPropertyId()));
item.put("property_id", row.getPropertyId());
item.put("owner_user_id", row.getSaleId());
item.put("expire_remind", row.getExpireRemind());
item.put("updated_at", ChangesEngine.iso(effective));
item.put("version", mt);
item.put("deleted_at", deleted ? ChangesEngine.iso(effective) : null);
return item;
return SyncChangeSchemas.toMap(new SyncChangeSchemas.ContractChange(
stableId(row.getId()), row.getContractNo(), row.getContractName(),
publicStatus(row.getContractStatus()), ChangesEngine.iso(row.getStartDate()),
ChangesEngine.iso(row.getEndDate()), toMinor(row.getAmount()),
row.getPropertyId() == null ? null : customerIds.get(row.getPropertyId()),
stableId(row.getPropertyId()), stableId(row.getSaleId()),
Integer.valueOf(1).equals(row.getExpireRemind()), row.getRemindDays(),
ChangesEngine.iso(effective), mt, deleted ? ChangesEngine.iso(effective) : null));
}
/** 元 → 分(amount 为 decimal(14,2),精确换算);null 原样返回 null。 */
@@ -100,6 +127,30 @@ public class ContractsSyncEntity extends AbstractSyncEntity implements SyncEntit
return amount == null ? null : amount.movePointRight(2).longValue();
}
/** Legacy database status code to the stable public contract enum. */
static String publicStatus(Integer status) {
if (status == null) {
throw invalidStatus(null);
}
return switch (status) {
case 0 -> "PENDING";
case 1 -> "REJECTED";
case 2 -> "ACTIVE";
case 3 -> "EXPIRED";
case 4 -> "TERMINATED";
default -> throw invalidStatus(status);
};
}
private static ToolCallException invalidStatus(Integer status) {
return new ToolCallException(McpErrorCode.DATA_INTEGRITY_ERROR,
"合同状态无法映射到公开契约: " + status);
}
private static String stableId(Long id) {
return id == null ? null : id.toString();
}
/**
* property_id → customerId 映射。properyt.customerId 存在 NULL 行(2026-09-14 dev 冒烟命中),
* Collectors.toMap 遇 null 值抛 NPE,必须用 put 逐条装填。
@@ -9,7 +9,6 @@ import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -59,17 +58,13 @@ public class PropertiesSyncEntity extends AbstractSyncEntity implements SyncEnti
private Map<String, Object> toItem(Properyt row, long mt) {
boolean deleted = row.getSoftDelete() != null && row.getSoftDelete() == 1;
Date effective = row.getModifyTime() != null ? row.getModifyTime() : row.getCreateTime();
Map<String, Object> item = new LinkedHashMap<>();
item.put("property_id", row.getId());
item.put("customer_id", row.getCustomerId());
item.put("property_name", row.getName());
item.put("customer_name", row.getJinideName());
item.put("unified_social_credit_code", row.getIdentificationNumber());
item.put("external_customer_no", row.getSerialnumber());
item.put("status", deleted ? "inactive" : "active");
item.put("updated_at", ChangesEngine.iso(effective));
item.put("version", mt);
item.put("deleted_at", deleted ? ChangesEngine.iso(effective) : null);
return item;
return SyncChangeSchemas.toMap(new SyncChangeSchemas.PropertyChange(
stableId(row.getId()), row.getCustomerId(), row.getName(), row.getJinideName(),
row.getIdentificationNumber(), row.getSerialnumber(), deleted ? "INACTIVE" : "ACTIVE",
ChangesEngine.iso(effective), mt, deleted ? ChangesEngine.iso(effective) : null));
}
private static String stableId(Long id) {
return id == null ? null : id.toString();
}
}
@@ -0,0 +1,73 @@
package me.iiv.agent.mcp.sync;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;
import java.util.Map;
/**
* Public schemas for the three change streams.
*
* <p>The source tables are legacy persistence models with numeric identifiers,
* integer flags and database-specific status codes. These DTOs form the stable
* MCP boundary so persistence details cannot leak into consumers.</p>
*/
final class SyncChangeSchemas {
private static final ObjectMapper MAPPER = new ObjectMapper()
.setSerializationInclusion(JsonInclude.Include.NON_NULL);
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() { };
private SyncChangeSchemas() {
}
static Map<String, Object> toMap(Object value) {
return MAPPER.convertValue(value, MAP_TYPE);
}
record UserChange(
@JsonProperty("user_id") String userId,
@JsonProperty("display_name") String displayName,
String status,
@JsonProperty("employee_no") String employeeNo,
@JsonProperty("verified_mobile") String verifiedMobile,
@JsonProperty("organization_ids") List<String> organizationIds,
@JsonProperty("updated_at") String updatedAt,
long version,
@JsonProperty("deleted_at") String deletedAt) {
}
record PropertyChange(
@JsonProperty("property_id") String propertyId,
@JsonProperty("customer_id") String customerId,
@JsonProperty("property_name") String propertyName,
@JsonProperty("customer_name") String customerName,
@JsonProperty("unified_social_credit_code") String unifiedSocialCreditCode,
@JsonProperty("external_customer_no") String externalCustomerNo,
String status,
@JsonProperty("updated_at") String updatedAt,
long version,
@JsonProperty("deleted_at") String deletedAt) {
}
record ContractChange(
@JsonProperty("contract_id") String contractId,
@JsonProperty("contract_no") String contractNo,
@JsonProperty("contract_name") String contractName,
String status,
@JsonProperty("start_at") String startAt,
@JsonProperty("end_at") String endAt,
@JsonProperty("amount_minor") Long amountMinor,
@JsonProperty("customer_id") String customerId,
@JsonProperty("property_id") String propertyId,
@JsonProperty("owner_user_id") String ownerUserId,
@JsonProperty("expire_remind") boolean expireRemind,
@JsonProperty("remind_days") Integer remindDays,
@JsonProperty("updated_at") String updatedAt,
long version,
@JsonProperty("deleted_at") String deletedAt) {
}
}
@@ -7,14 +7,13 @@ import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* users 同步实体(m0.user):人员主数据增量。
* 字段口径(2026-09-14 字段核对):status 对齐 soft_delete(在职状态不用 work_status,那是考勤);
* mobile 即唯一手机号字段(verified_mobile 无对应列,按决策砍掉);organization_ids 取 dept_id;
* verified_mobile 映射唯一手机号字段 mobile;organization_ids 取 dept_id;
* 无人员合并机制,merged_into_user_id 省略。users 表无行级过滤先例,SYNC 工具以 scope 白名单为闸。
*/
@Component
@@ -50,16 +49,13 @@ public class UsersSyncEntity extends AbstractSyncEntity implements SyncEntity {
private Map<String, Object> toItem(User row, long mt) {
boolean deleted = row.getSoftDelete() != null && row.getSoftDelete() == 1;
Date effective = row.getModifyTime() != null ? row.getModifyTime() : row.getCreateTime();
Map<String, Object> item = new LinkedHashMap<>();
item.put("user_id", row.getId());
item.put("display_name", row.getName());
item.put("status", deleted ? "inactive" : "active");
item.put("employee_no", row.getNo());
item.put("mobile", row.getMobile());
item.put("organization_ids", row.getDeptId() == null ? List.of() : List.of(row.getDeptId()));
item.put("updated_at", ChangesEngine.iso(effective));
item.put("version", mt);
item.put("deleted_at", deleted ? ChangesEngine.iso(effective) : null);
return item;
return SyncChangeSchemas.toMap(new SyncChangeSchemas.UserChange(
stableId(row.getId()), row.getName(), deleted ? "INACTIVE" : "ACTIVE", row.getNo(),
row.getMobile(), row.getDeptId() == null ? List.of() : List.of(stableId(row.getDeptId())),
ChangesEngine.iso(effective), mt, deleted ? ChangesEngine.iso(effective) : null));
}
private static String stableId(Long id) {
return id == null ? null : id.toString();
}
}
@@ -1,15 +1,25 @@
package me.iiv.agent.mcp.tool;
import me.iiv.agent.mcp.ToolObject;
import me.iiv.agent.mcp.McpErrorCode;
import me.iiv.agent.mcp.ToolCallException;
import me.iiv.agent.mcp.XqTool;
import me.iiv.agent.mcp.sync.ChangesEngine;
import me.iiv.agent.mcp.sync.ContractsSyncEntity;
import me.iiv.agent.mcp.sync.PropertiesSyncEntity;
import me.iiv.agent.mcp.sync.UsersSyncEntity;
import me.iiv.agent.security.AuthContext;
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 java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
/**
@@ -35,15 +45,17 @@ public class ChangesTools implements ToolObject {
private static final String LIMIT_DESC = "每页条数,默认 100,上限 200(超出截断为 200 并在 notice 提示)";
final ChangesEngine changesEngine;
final ContractsSyncEntity contractsSyncEntity;
public ChangesTools(ChangesEngine changesEngine) {
public ChangesTools(ChangesEngine changesEngine, ContractsSyncEntity contractsSyncEntity) {
this.changesEngine = changesEngine;
this.contractsSyncEntity = contractsSyncEntity;
}
@XqTool(category = "sync", kind = "read")
@Tool(name = "users.changes.list", description = "增量同步人员主数据(m0.user)。" + PROTOCOL_DESC
+ "items 字段:user_id/display_name/status(active=在职 inactive=删除,对齐软删标记)/"
+ "employee_no/mobile/organization_ids(部门 id 列表)/updated_at(带时区)/version/deleted_at。")
+ "employee_no/verified_mobile/organization_ids(部门 id 列表)/updated_at(带时区)/version/deleted_at。")
public Map<String, Object> usersChangesList(
@ToolParam(required = false, description = SYNC_TOKEN_DESC) String syncToken,
@ToolParam(required = false, description = CURSOR_DESC) String cursor,
@@ -65,9 +77,9 @@ public class ChangesTools implements ToolObject {
@XqTool(category = "sync", kind = "read")
@Tool(name = "contracts.changes.list", description = "增量同步合同主数据(m2.contract_new)。" + PROTOCOL_DESC
+ "items 字段:contract_id/contract_no/contract_name/status(0=待审 1=驳回 2=正常 3=过期 4=终止)/"
+ "items 字段:contract_id/contract_no/contract_name/status(PENDING/REJECTED/ACTIVE/EXPIRED/TERMINATED)/"
+ "start_at/end_at(带时区)/amount_minor(金额,分)/customer_id/property_id/"
+ "owner_user_id(负责人稳定 user.id)/expire_remind/updated_at/version/deleted_at。"
+ "owner_user_id(负责人稳定 user.id)/expire_remind/remind_days/updated_at/version/deleted_at。"
+ "行级权限跟随 Token 绑定的人(与 contract.list.query 同一 ACL)。")
public Map<String, Object> contractsChangesList(
@ToolParam(required = false, description = SYNC_TOKEN_DESC) String syncToken,
@@ -75,4 +87,51 @@ public class ChangesTools implements ToolObject {
@ToolParam(required = false, description = LIMIT_DESC) Integer limit) {
return changesEngine.execute(ContractsSyncEntity.NAME, syncToken, cursor, limit);
}
@XqTool(category = "sync", kind = "read")
@Tool(name = "contracts.current.batch_get", description = "发送经营提醒前批量复核合同当前权威状态。"
+ "仅接受合同稳定ID列表,最多200个;返回items及not_found_ids。"
+ "items字段与contracts.changes.list一致,包含当前状态、到期日、负责人、客户、提醒配置、版本和删除标记。"
+ "数据域由Token确定,调用方不得传tenant_id或scope。")
public Map<String, Object> contractsCurrentBatchGet(
@ToolParam(description = "合同稳定ID列表,1至200个;重复ID自动去重并保持首次出现顺序")
List<String> contractIds) {
List<Long> ids = parseContractIds(contractIds);
AuthContext context = AuthContextHolder.get();
List<Map<String, Object>> items = contractsSyncEntity.fetchCurrent(ids,
context == null ? null : context.getUser());
LinkedHashSet<String> found = new LinkedHashSet<>();
for (Map<String, Object> item : items) {
found.add(String.valueOf(item.get("contract_id")));
}
List<String> missing = ids.stream().map(String::valueOf).filter(id -> !found.contains(id)).toList();
Map<String, Object> result = new LinkedHashMap<>();
result.put("checked_at", OffsetDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME));
result.put("count", items.size());
result.put("items", items);
result.put("not_found_ids", missing);
return result;
}
private static List<Long> parseContractIds(List<String> contractIds) {
if (contractIds == null || contractIds.isEmpty()) {
throw new ToolCallException(McpErrorCode.INVALID_ARGUMENT, "contractIds必须包含1至200个合同ID");
}
if (contractIds.size() > 200) {
throw new ToolCallException(McpErrorCode.INVALID_ARGUMENT, "contractIds最多允许200个合同ID");
}
LinkedHashSet<Long> unique = new LinkedHashSet<>();
for (String raw : contractIds) {
try {
long id = Long.parseLong(raw == null ? "" : raw.trim());
if (id <= 0) {
throw new NumberFormatException();
}
unique.add(id);
} catch (NumberFormatException error) {
throw new ToolCallException(McpErrorCode.INVALID_ARGUMENT, "contractIds包含无效合同ID");
}
}
return new ArrayList<>(unique);
}
}
@@ -0,0 +1,480 @@
package me.iiv.agent.mcp.tool;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.support.SFunction;
import me.iiv.agent.entity.m0.User;
import me.iiv.agent.entity.m2.WarehouseIn;
import me.iiv.agent.entity.m2.WarehouseInAuditProcess;
import me.iiv.agent.entity.m2.WarehouseInDetail;
import me.iiv.agent.entity.m2.WarehouseInOutPic;
import me.iiv.agent.entity.m2.WarehouseType;
import me.iiv.agent.mapper.m2.WarehouseInAuditProcessMapper;
import me.iiv.agent.mapper.m2.WarehouseInDetailMapper;
import me.iiv.agent.mapper.m2.WarehouseInMapper;
import me.iiv.agent.mapper.m2.WarehouseInOutPicMapper;
import me.iiv.agent.mapper.m2.WarehouseTypeMapper;
import me.iiv.agent.mcp.McpErrorCode;
import me.iiv.agent.mcp.ToolCallException;
import me.iiv.agent.mcp.ToolObject;
import me.iiv.agent.mcp.XqTool;
import me.iiv.agent.permission.PermissionService;
import me.iiv.agent.security.AuthContextHolder;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.time.format.ResolverStyle;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/** 入库单查询工具,参考老系统 /myWarehouseIn/allListByPage 与 /lookDetail。 */
@Component
public class WarehouseInTools implements ToolObject {
private static final int DEFAULT_ROWS = 20;
private static final int MAX_ROWS = 100;
private static final int DEFAULT_SERIAL_LIMIT = 100;
private static final int MAX_SERIAL_LIMIT = 500;
private static final int IN_TYPE = 0;
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
private static final Pattern DATE_PATTERN = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$");
private static final DateTimeFormatter DATE_FORMATTER =
DateTimeFormatter.ISO_LOCAL_DATE.withResolverStyle(ResolverStyle.STRICT);
private static final DateTimeFormatter TIME_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(BUSINESS_ZONE);
private static final Map<Integer, String> ORDER_STATE_TEXT = Map.of(
0, "待审核", 1, "被驳回", 2, "待入库", 3, "已完成", 4, "已撤销", 5, "作废");
private static final Map<Integer, String> SCHEDULE_STATE_TEXT = Map.of(
0, "无需待办", 1, "待办", 2, "已办");
private static final Map<Integer, String> AUDIT_STATE_TEXT = Map.of(0, "审核驳回", 1, "审核通过");
private static final Map<Integer, String> OPERATION_THEME_TEXT = Map.of(
0, "审核通过", 1, "审核驳回", 2, "撤销入库", 3, "入库确认",
4, "驳回修改提交", 5, "撤销", 6, "入库单确认驳回");
private static final Map<Integer, String> OPERATION_TYPE_TEXT = Map.of(
0, "审批流操作", 1, "确认入库", 2, "本人修改", 3, "驳回或修改记录");
final WarehouseInMapper warehouseInMapper;
final WarehouseInDetailMapper detailMapper;
final WarehouseInAuditProcessMapper processMapper;
final WarehouseInOutPicMapper picMapper;
final WarehouseTypeMapper warehouseTypeMapper;
final PermissionService permissionService;
public WarehouseInTools(WarehouseInMapper warehouseInMapper,
WarehouseInDetailMapper detailMapper,
WarehouseInAuditProcessMapper processMapper,
WarehouseInOutPicMapper picMapper,
WarehouseTypeMapper warehouseTypeMapper,
PermissionService permissionService) {
this.warehouseInMapper = warehouseInMapper;
this.detailMapper = detailMapper;
this.processMapper = processMapper;
this.picMapper = picMapper;
this.warehouseTypeMapper = warehouseTypeMapper;
this.permissionService = permissionService;
}
@XqTool(category = "warehouse", kind = "read")
@Tool(name = "warehouse.in.list.query", description = "分页查询入库单,参考老系统 /myWarehouseIn/allListByPage。"
+ "总部可看全部;代理身份可看所属代理范围、所属或负责仓库涉及、或本人参与处理的入库单。"
+ "入库日期与申请日期分别过滤;日期格式 yyyy-MM-dd,结束日期含当天。"
+ "分页 page 默认1,rows 默认20、上限100。orderState:0待审核、1被驳回、2待入库、3已完成、4已撤销、5作废。")
public Map<String, Object> warehouseInListQuery(
@ToolParam(required = false, description = "入库单号,模糊匹配") String inOrderNo,
@ToolParam(required = false, description = "调拨单号,模糊匹配") String allocationOrderNo,
@ToolParam(required = false, description = "关联出库单号,模糊匹配") String outOrderNo,
@ToolParam(required = false, description = "商城订单号,模糊匹配") String mallOrderNo,
@ToolParam(required = false, description = "送货单号,模糊匹配") String deliveryOrderNo,
@ToolParam(required = false, description = "装机单号,模糊匹配") String installOrderNo,
@ToolParam(required = false, description = "取货工单号,模糊匹配") String pickupOrderNo,
@ToolParam(required = false, description = "撤机工单号,模糊匹配") String withdrawOrderNo,
@ToolParam(required = false, description = "所属代理商 id") Long agentId,
@ToolParam(required = false, description = "明细涉及的仓库 id") Long warehouseId,
@ToolParam(required = false, description = "入库类型 id") Long warehouseTypeId,
@ToolParam(required = false, description = "申请人 id") Long applyUserId,
@ToolParam(required = false, description = "状态:0待审核 1被驳回 2待入库 3已完成 4已撤销 5作废") Integer orderState,
@ToolParam(required = false, description = "入库同步状态:0未同步 1已同步 2无需同步") Integer inSynchronizeState,
@ToolParam(required = false, description = "物流公司 id") Long logisticsId,
@ToolParam(required = false, description = "物流运单号,模糊匹配") String logisticsWayBill,
@ToolParam(required = false, description = "入库日期起,yyyy-MM-dd") String inDateFrom,
@ToolParam(required = false, description = "入库日期止,yyyy-MM-dd,含当天") String inDateTo,
@ToolParam(required = false, description = "申请日期起,yyyy-MM-dd") String applyDateFrom,
@ToolParam(required = false, description = "申请日期止,yyyy-MM-dd,含当天") String applyDateTo,
@ToolParam(required = false, description = "页码,从1开始,默认1") Integer page,
@ToolParam(required = false, description = "每页条数,默认20,上限100") Integer rows) {
LocalDate inStart = parseLocalDate(inDateFrom, "inDateFrom");
LocalDate inEnd = parseLocalDate(inDateTo, "inDateTo");
LocalDate applyStartDate = parseLocalDate(applyDateFrom, "applyDateFrom");
LocalDate applyEndDate = parseLocalDate(applyDateTo, "applyDateTo");
validateRange(inStart, inEnd, "inDateFrom", "inDateTo");
validateRange(applyStartDate, applyEndDate, "applyDateFrom", "applyDateTo");
int pageNo = page == null || page < 1 ? 1 : page;
boolean truncated = rows != null && rows > MAX_ROWS;
int pageSize = rows == null || rows < 1 ? DEFAULT_ROWS : Math.min(rows, MAX_ROWS);
LambdaQueryWrapper<WarehouseIn> wrapper = new LambdaQueryWrapper<WarehouseIn>()
.eq(WarehouseIn::getSoftDelete, 0);
like(wrapper, WarehouseIn::getInOrderNo, inOrderNo);
like(wrapper, WarehouseIn::getAllocationOrderNo, allocationOrderNo);
like(wrapper, WarehouseIn::getOutOrderNo, outOrderNo);
like(wrapper, WarehouseIn::getMallOrderNo, mallOrderNo);
like(wrapper, WarehouseIn::getDeliveryOrderNo, deliveryOrderNo);
like(wrapper, WarehouseIn::getInstallOrderNo, installOrderNo);
like(wrapper, WarehouseIn::getPickupOrderNo, pickupOrderNo);
like(wrapper, WarehouseIn::getWithdrawOrderNo, withdrawOrderNo);
like(wrapper, WarehouseIn::getLogisticsWayBill, logisticsWayBill);
wrapper.eq(agentId != null, WarehouseIn::getAgentId, agentId)
.eq(warehouseTypeId != null, WarehouseIn::getWarehouseTypeId, warehouseTypeId)
.eq(applyUserId != null, WarehouseIn::getApplyUserId, applyUserId)
.eq(orderState != null, WarehouseIn::getOrderState, orderState)
.eq(inSynchronizeState != null, WarehouseIn::getInSynchronizeState, inSynchronizeState)
.eq(logisticsId != null, WarehouseIn::getLogisticsId, logisticsId)
.ge(inStart != null, WarehouseIn::getInDate, inStart)
.le(inEnd != null, WarehouseIn::getInDate, inEnd)
.ge(applyStartDate != null, WarehouseIn::getApplyDate, startOfDay(applyStartDate))
.lt(applyEndDate != null, WarehouseIn::getApplyDate, nextDayStart(applyEndDate));
if (warehouseId != null) {
wrapper.apply("EXISTS (SELECT 1 FROM warehouse_in_details wid_filter "
+ "WHERE wid_filter.warehouse_in_id = warehouse_in.id "
+ "AND wid_filter.soft_delete = 0 AND wid_filter.warehouse_id = {0})", warehouseId);
}
User user = AuthContextHolder.get().getUser();
permissionService.applyWarehouseInRowFilter(wrapper, user);
Long total = warehouseInMapper.selectCount(wrapper);
wrapper.orderByDesc(WarehouseIn::getId)
.last("LIMIT " + (long) (pageNo - 1) * pageSize + "," + pageSize);
List<WarehouseIn> orders = warehouseInMapper.selectList(wrapper);
Map<Long, List<WarehouseInDetail>> details = listDetails(orders);
Map<Long, List<WarehouseInAuditProcess>> schedules = listSchedules(orders);
List<Map<String, Object>> records = orders.stream().map(order -> listRecord(order,
details.getOrDefault(order.getId(), List.of()),
schedules.getOrDefault(order.getId(), List.of()))).collect(Collectors.toList());
Map<String, Object> result = new LinkedHashMap<>();
result.put("total", total);
result.put("page", pageNo);
result.put("rows", pageSize);
if (truncated) {
result.put("notice", "rows 超过上限 100,已截断为 100");
}
result.put("records", records);
return result;
}
@XqTool(category = "warehouse", kind = "read")
@Tool(name = "warehouse.in.detail.query", description = "按 id 或入库单号查询入库单详情。"
+ "行级权限与 warehouse.in.list.query 完全一致,不存在与无权访问统一返回 NOT_FOUND_OR_FORBIDDEN。"
+ "返回主单、场景类型、仓库、按物料和仓库汇总的明细、可选 SN、已处理流程和申请/确认附件;调拨入库单可直接查询。"
+ "关联出库仅返回索引,不内嵌内容。includeSerials 默认 false;serialLimit 默认100、上限500。")
public Map<String, Object> warehouseInDetailQuery(
@ToolParam(required = false, description = "入库单主键 id,与 inOrderNo 至少传一个") Long id,
@ToolParam(required = false, description = "入库单号,与 id 至少传一个") String inOrderNo,
@ToolParam(required = false, description = "是否返回 SN 明细,默认 false") Boolean includeSerials,
@ToolParam(required = false, description = "SN 返回上限,默认100、最大500") Integer serialLimit) {
if ((id == null || id <= 0) && !StringUtils.hasText(inOrderNo)) {
throw new IllegalArgumentException("id、inOrderNo 至少传一个");
}
if (id != null && id <= 0) {
throw new IllegalArgumentException("id 必须为正整数");
}
int limit = serialLimit == null || serialLimit < 1
? DEFAULT_SERIAL_LIMIT : Math.min(serialLimit, MAX_SERIAL_LIMIT);
boolean include = Boolean.TRUE.equals(includeSerials);
LambdaQueryWrapper<WarehouseIn> wrapper = new LambdaQueryWrapper<WarehouseIn>()
.eq(WarehouseIn::getSoftDelete, 0);
wrapper.eq(id != null, WarehouseIn::getId, id);
wrapper.eq(StringUtils.hasText(inOrderNo), WarehouseIn::getInOrderNo,
StringUtils.hasText(inOrderNo) ? inOrderNo.trim() : inOrderNo);
permissionService.applyWarehouseInRowFilter(wrapper, AuthContextHolder.get().getUser());
List<WarehouseIn> visible = warehouseInMapper.selectList(wrapper.orderByDesc(WarehouseIn::getId).last("LIMIT 1"));
if (visible.isEmpty()) {
throw new ToolCallException(McpErrorCode.NOT_FOUND_OR_FORBIDDEN, "入库单不存在或当前身份无权查看");
}
WarehouseIn order = visible.get(0);
List<WarehouseInDetail> detailRows = detailMapper.selectList(new LambdaQueryWrapper<WarehouseInDetail>()
.eq(WarehouseInDetail::getSoftDelete, 0)
.eq(WarehouseInDetail::getWarehouseInId, order.getId())
.orderByAsc(WarehouseInDetail::getId));
List<WarehouseInDetail> serialCandidates = detailRows.stream()
.filter(row -> StringUtils.hasText(row.getSnCode())).collect(Collectors.toList());
List<WarehouseInDetail> serialRows = include
? serialCandidates.stream().limit(limit).collect(Collectors.toList()) : List.of();
List<WarehouseInAuditProcess> processes = processMapper.selectList(
new LambdaQueryWrapper<WarehouseInAuditProcess>()
.eq(WarehouseInAuditProcess::getSoftDelete, 0)
.eq(WarehouseInAuditProcess::getWarehouseInId, order.getId())
.eq(WarehouseInAuditProcess::getScheduleState, 2)
.orderByAsc(WarehouseInAuditProcess::getOperationTime)
.orderByAsc(WarehouseInAuditProcess::getId));
List<WarehouseInOutPic> pics = picMapper.selectList(new LambdaQueryWrapper<WarehouseInOutPic>()
.eq(WarehouseInOutPic::getSoftDelete, 0)
.eq(WarehouseInOutPic::getAggregateId, order.getId())
.eq(WarehouseInOutPic::getInOutAllocationType, IN_TYPE)
.orderByAsc(WarehouseInOutPic::getId));
WarehouseType type = order.getWarehouseTypeId() == null
? null : warehouseTypeMapper.selectById(order.getWarehouseTypeId());
Map<String, Object> result = new LinkedHashMap<>();
result.put("record", record(order));
result.put("sceneType", type == null || !Integer.valueOf(0).equals(type.getSoftDelete()) ? null : type.getSceneType());
result.put("warehouses", warehouseRecords(detailRows));
result.put("items", aggregateItems(detailRows));
result.put("serials", serialRows.stream().map(this::serialRecord).collect(Collectors.toList()));
result.put("serialTotal", (long) serialCandidates.size());
result.put("serialTruncated", include && serialCandidates.size() > serialRows.size());
result.put("processes", processes.stream().map(this::processRecord).collect(Collectors.toList()));
Map<String, Object> files = new LinkedHashMap<>();
files.put("application", fileRecords(pics, 0));
files.put("confirmation", fileRecords(pics, 1));
result.put("files", files);
result.put("relatedOutOrder", relatedOutOrder(order));
return result;
}
private Map<Long, List<WarehouseInDetail>> listDetails(List<WarehouseIn> orders) {
if (orders.isEmpty()) return Map.of();
List<Long> ids = orders.stream().map(WarehouseIn::getId).collect(Collectors.toList());
return detailMapper.selectList(new LambdaQueryWrapper<WarehouseInDetail>()
.eq(WarehouseInDetail::getSoftDelete, 0)
.in(WarehouseInDetail::getWarehouseInId, ids)
.orderByAsc(WarehouseInDetail::getId)).stream()
.collect(Collectors.groupingBy(WarehouseInDetail::getWarehouseInId,
LinkedHashMap::new, Collectors.toList()));
}
private Map<Long, List<WarehouseInAuditProcess>> listSchedules(List<WarehouseIn> orders) {
if (orders.isEmpty()) return Map.of();
List<Long> ids = orders.stream().map(WarehouseIn::getId).collect(Collectors.toList());
return processMapper.selectList(new LambdaQueryWrapper<WarehouseInAuditProcess>()
.eq(WarehouseInAuditProcess::getSoftDelete, 0)
.in(WarehouseInAuditProcess::getWarehouseInId, ids)
.eq(WarehouseInAuditProcess::getScheduleState, 1)
.orderByAsc(WarehouseInAuditProcess::getId)).stream()
.collect(Collectors.groupingBy(WarehouseInAuditProcess::getWarehouseInId,
LinkedHashMap::new, Collectors.toList()));
}
private Map<String, Object> listRecord(WarehouseIn order, List<WarehouseInDetail> details,
List<WarehouseInAuditProcess> schedules) {
Map<String, Object> record = record(order);
LinkedHashMap<Long, String> warehouses = warehouseMap(details);
record.put("warehouseIds", new ArrayList<>(warehouses.keySet()));
record.put("warehouseNames", new ArrayList<>(warehouses.values()));
record.put("schedulePeople", distinctNonBlank(schedules.stream()
.map(WarehouseInAuditProcess::getUserName).collect(Collectors.toList())));
record.put("warehouseInPeople", distinctNonBlank(details.stream()
.map(WarehouseInDetail::getInConfirmUserName).collect(Collectors.toList())));
record.put("warehouseInTimes", details.stream().map(WarehouseInDetail::getInDate)
.filter(Objects::nonNull).map(LocalDate::toString).distinct().collect(Collectors.toList()));
return record;
}
private Map<String, Object> record(WarehouseIn order) {
Map<String, Object> record = new LinkedHashMap<>();
record.put("id", order.getId());
record.put("inOrderNo", order.getInOrderNo());
record.put("allocationOrderNo", order.getAllocationOrderNo());
record.put("outOrderNo", order.getOutOrderNo());
record.put("mallOrderNo", order.getMallOrderNo());
record.put("sourceType", sourceType(order));
record.put("agentId", order.getAgentId());
record.put("agentName", order.getAgentName());
record.put("warehouseTypeId", order.getWarehouseTypeId());
record.put("warehouseTypeName", order.getWarehouseTypeName());
record.put("inDate", order.getInDate() == null ? null : order.getInDate().toString());
record.put("goodsCount", order.getGoodsCount());
record.put("inCount", order.getInCount());
record.put("orderState", order.getOrderState());
record.put("orderStateText", text(ORDER_STATE_TEXT, order.getOrderState()));
record.put("applyUserId", order.getApplyUserId());
record.put("applyUserName", order.getApplyUserName());
record.put("applyDate", formatTime(order.getApplyDate()));
record.put("inSynchronizeState", order.getInSynchronizeState());
record.put("inSynchronizeTime", formatTime(order.getInSynchronizeTime()));
record.put("logisticsId", order.getLogisticsId());
record.put("logisticsName", order.getLogisticsName());
record.put("logisticsWayBill", order.getLogisticsWayBill());
record.put("deliveryOrderNo", order.getDeliveryOrderNo());
record.put("installOrderNo", order.getInstallOrderNo());
record.put("pickupOrderNo", order.getPickupOrderNo());
record.put("withdrawOrderNo", order.getWithdrawOrderNo());
record.put("memo", order.getMemo());
record.put("createTime", formatTime(order.getCreateTime()));
record.put("modifyTime", formatTime(order.getModifyTime()));
return record;
}
private List<Map<String, Object>> aggregateItems(List<WarehouseInDetail> rows) {
Map<String, List<WarehouseInDetail>> groups = rows.stream().collect(Collectors.groupingBy(
row -> Objects.toString(row.getMaterielDefinitionId(), "") + "|"
+ Objects.toString(row.getMaterielIndexId(), "") + "|"
+ Objects.toString(row.getWarehouseId(), ""), LinkedHashMap::new, Collectors.toList()));
return groups.values().stream().map(group -> {
WarehouseInDetail first = group.get(0);
Map<String, Object> item = commonDetailRecord(first);
item.put("inCount", group.stream().map(WarehouseInDetail::getInCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum());
item.put("currentInventory", first.getCurrentInventory());
item.put("inDate", first.getInDate() == null ? null : first.getInDate().toString());
item.put("inConfirmUserName", first.getInConfirmUserName());
item.put("inConfirmTime", formatTime(first.getInConfirmTime()));
item.put("lineMemo", first.getLineMemo());
return item;
}).collect(Collectors.toList());
}
private Map<String, Object> serialRecord(WarehouseInDetail detail) {
Map<String, Object> item = commonDetailRecord(detail);
item.put("snCode", detail.getSnCode());
item.put("goodInState", detail.getGoodInState());
item.put("assetDisposalType", detail.getAssetDisposalType());
item.put("synchronizeState", detail.getSynchronizeState());
item.put("outDetailsId", detail.getOutDetailsId());
return item;
}
private Map<String, Object> commonDetailRecord(WarehouseInDetail detail) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("id", detail.getId());
item.put("materielDefinitionId", detail.getMaterielDefinitionId());
item.put("materielIndexId", detail.getMaterielIndexId());
item.put("materialNo", detail.getMaterialNo());
item.put("materielFullName", detail.getMaterielFullName());
item.put("materielSort", detail.getMaterielSort());
item.put("specifications", detail.getSpecifications());
item.put("warehouseId", detail.getWarehouseId());
item.put("warehouseName", detail.getWarehouseName());
return item;
}
private List<Map<String, Object>> warehouseRecords(List<WarehouseInDetail> rows) {
return warehouseMap(rows).entrySet().stream().map(entry -> {
Map<String, Object> warehouse = new LinkedHashMap<>();
warehouse.put("id", entry.getKey());
warehouse.put("name", entry.getValue());
return warehouse;
}).collect(Collectors.toList());
}
private static LinkedHashMap<Long, String> warehouseMap(List<WarehouseInDetail> rows) {
LinkedHashMap<Long, String> warehouses = new LinkedHashMap<>();
for (WarehouseInDetail row : rows) {
if (row.getWarehouseId() != null) warehouses.putIfAbsent(row.getWarehouseId(), row.getWarehouseName());
}
return warehouses;
}
private Map<String, Object> processRecord(WarehouseInAuditProcess process) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("id", process.getId());
item.put("warehouseId", process.getWarehouseId());
item.put("warehouseName", process.getWarehouseName());
item.put("userId", process.getUserId());
item.put("userName", process.getUserName());
item.put("operationTime", formatTime(process.getOperationTime()));
item.put("scheduleState", process.getScheduleState());
item.put("scheduleStateText", text(SCHEDULE_STATE_TEXT, process.getScheduleState()));
item.put("auditState", process.getAuditState());
item.put("auditStateText", text(AUDIT_STATE_TEXT, process.getAuditState()));
item.put("auditLevel", process.getAuditLevel());
item.put("operationTheme", process.getOperationTheme());
item.put("operationThemeText", text(OPERATION_THEME_TEXT, process.getOperationTheme()));
item.put("operationType", process.getOperationType());
item.put("operationTypeText", text(OPERATION_TYPE_TEXT, process.getOperationType()));
item.put("memo", process.getMemo());
return item;
}
private List<Map<String, Object>> fileRecords(List<WarehouseInOutPic> pics, int fileType) {
return pics.stream().filter(pic -> Integer.valueOf(fileType).equals(pic.getFileType()))
.map(pic -> {
Map<String, Object> file = new LinkedHashMap<>();
file.put("id", pic.getId());
file.put("fileName", pic.getFileName());
file.put("bucket", pic.getOssbucketname());
file.put("objectKey", nullToEmpty(pic.getOssfolder()) + nullToEmpty(pic.getFileName()));
return file;
}).collect(Collectors.toList());
}
private Map<String, Object> relatedOutOrder(WarehouseIn order) {
if (!StringUtils.hasText(order.getOutOrderNo())) return null;
Map<String, Object> related = new LinkedHashMap<>();
related.put("outOrderNo", order.getOutOrderNo());
related.put("queryTool", "warehouse.out.detail.query");
return related;
}
private static void like(LambdaQueryWrapper<WarehouseIn> wrapper,
SFunction<WarehouseIn, ?> column, String value) {
if (StringUtils.hasText(value)) wrapper.like(column, value.trim());
}
private static List<String> distinctNonBlank(List<String> values) {
Set<String> distinct = values.stream().filter(StringUtils::hasText)
.collect(Collectors.toCollection(LinkedHashSet::new));
return new ArrayList<>(distinct);
}
private static String sourceType(WarehouseIn order) {
if (StringUtils.hasText(order.getAllocationOrderNo())) return "ALLOCATION";
if (StringUtils.hasText(order.getOutOrderNo())) return "OUTBOUND_LINKED";
if (StringUtils.hasText(order.getMallOrderNo())) return "MALL";
return "NORMAL";
}
private static String text(Map<Integer, String> texts, Integer code) {
return code == null ? null : texts.getOrDefault(code, "未知");
}
private static String formatTime(Date date) {
return date == null ? null : TIME_FORMATTER.format(date.toInstant());
}
private static String nullToEmpty(String value) {
return value == null ? "" : value;
}
private static LocalDate parseLocalDate(String value, String name) {
if (!StringUtils.hasText(value)) return null;
String trimmed = value.trim();
if (!DATE_PATTERN.matcher(trimmed).matches()) {
throw new IllegalArgumentException(name + " 格式必须为 yyyy-MM-dd");
}
try {
return LocalDate.parse(trimmed, DATE_FORMATTER);
} catch (DateTimeParseException e) {
throw new IllegalArgumentException(name + " 格式必须为 yyyy-MM-dd");
}
}
private static Date startOfDay(LocalDate date) {
return date == null ? null : Date.from(date.atStartOfDay(BUSINESS_ZONE).toInstant());
}
private static Date nextDayStart(LocalDate date) {
return date == null ? null : Date.from(date.plusDays(1).atStartOfDay(BUSINESS_ZONE).toInstant());
}
private static <T extends Comparable<T>> void validateRange(T from, T to, String fromName, String toName) {
if (from != null && to != null && from.compareTo(to) > 0) {
throw new IllegalArgumentException(fromName + " 不能晚于 " + toName);
}
}
}
@@ -11,6 +11,7 @@ import me.iiv.agent.entity.m0.User;
import me.iiv.agent.entity.m1.MallGoodsOrder;
import me.iiv.agent.entity.m2.ContractNew;
import me.iiv.agent.entity.m2.WarehouseAgentAuth;
import me.iiv.agent.entity.m2.WarehouseIn;
import me.iiv.agent.entity.m2.WarehouseOut;
import me.iiv.agent.mapper.m0.AgentManageDistrictAuthMapper;
import me.iiv.agent.mapper.m0.AgentMapper;
@@ -159,6 +160,37 @@ public class PermissionService {
.or().apply(participantExists, user.getId()));
}
/** 入库单权限:主单代理、可管理仓库、本人参与流程三类可见范围取并集。 */
public void applyWarehouseInRowFilter(LambdaQueryWrapper<WarehouseIn> wrapper, User user) {
List<Long> agentIds = visibleAgentIds(user);
if (agentIds == null) {
return;
}
if (agentIds.isEmpty()) {
wrapper.eq(WarehouseIn::getId, 0L);
return;
}
String firstIds = placeholders(0, agentIds.size());
String secondIds = placeholders(agentIds.size(), agentIds.size());
Object[] warehouseArgs = new Object[agentIds.size() * 2];
for (int i = 0; i < agentIds.size(); i++) {
warehouseArgs[i] = agentIds.get(i);
warehouseArgs[i + agentIds.size()] = agentIds.get(i);
}
String visibleWarehouseExists = "EXISTS (SELECT 1 FROM warehouse_in_details wid "
+ "JOIN warehouse w ON w.id = wid.warehouse_id AND w.soft_delete = 0 "
+ "WHERE wid.warehouse_in_id = warehouse_in.id AND wid.soft_delete = 0 "
+ "AND (w.agent_id IN (" + firstIds + ") OR w.manager_agent_id IN (" + secondIds + ")))";
String participantExists = "EXISTS (SELECT 1 FROM warehouse_in_audit_process wiap "
+ "WHERE wiap.warehouse_in_id = warehouse_in.id AND wiap.soft_delete = 0 "
+ "AND wiap.user_id = {0})";
wrapper.and(w -> w.in(WarehouseIn::getAgentId, agentIds)
.or().apply(visibleWarehouseExists, warehouseArgs)
.or().apply(participantExists, user.getId()));
}
private static String placeholders(int offset, int size) {
return IntStream.range(offset, offset + size)
.mapToObj(i -> "{" + i + "}")
@@ -199,6 +199,21 @@ class GuardedToolCallbackTest {
verify(delegate, org.mockito.Mockito.never()).call(anyString());
}
@Test
void currentBatchRejectsCallerSuppliedTenant() throws Exception {
toolDefinition = DefaultToolDefinition.builder()
.name("contracts.current.batch_get").description("d").inputSchema("{}").build();
when(delegate.getToolDefinition()).thenReturn(toolDefinition);
callback = new GuardedToolCallback(delegate, toolGuard, auditService, rateLimitService);
when(toolGuard.isAllowed(42L, "contracts.current.batch_get")).thenReturn(true);
ToolCallException error = assertThrows(ToolCallException.class,
() -> callback.call("{\"contractIds\":[\"1\"],\"tenantId\":\"other\"}"));
assertEquals(McpErrorCode.PERMISSION_DENIED, error.getCode());
verify(delegate, org.mockito.Mockito.never()).call(anyString());
}
@Test
void syncToolWithoutForbiddenParamsPassesThrough() {
toolDefinition = DefaultToolDefinition.builder()
@@ -21,6 +21,8 @@ import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@@ -28,6 +30,12 @@ import static org.mockito.Mockito.when;
@SuppressWarnings("unchecked")
class IntegrationContextServiceTest {
@Test
void currentBatchUsesSyncDataToolConvention() {
assertTrue(IntegrationContextService.isSyncDataTool("contracts.current.batch_get"));
assertFalse(IntegrationContextService.isSyncDataTool("contract.detail.query"));
}
@Mock
TokenScopeMapper tokenScopeMapper;
@Mock
@@ -62,13 +62,15 @@ class RateLimitServiceTest {
when(valueOperations.increment(anyString())).thenReturn(1L);
service.check(42L, "users.changes.list");
service.check(42L, "contracts.current.batch_get");
service.check(42L, "contract.list.query");
ArgumentCaptor<String> keys = ArgumentCaptor.forClass(String.class);
verify(valueOperations, org.mockito.Mockito.times(2)).increment(keys.capture());
verify(valueOperations, org.mockito.Mockito.times(3)).increment(keys.capture());
assertTrue(keys.getAllValues().get(0).contains(":sync:"), "changes 工具走 SYNC 档位");
assertTrue(keys.getAllValues().get(1).contains(":query:"), "查询工具走 QUERY 档位");
assertTrue(!keys.getAllValues().get(0).equals(keys.getAllValues().get(1)), "两档位互不占配额");
assertTrue(keys.getAllValues().get(1).contains(":sync:"), "当前状态复核走 SYNC 档位");
assertTrue(keys.getAllValues().get(2).contains(":query:"), "查询工具走 QUERY 档位");
assertTrue(!keys.getAllValues().get(0).equals(keys.getAllValues().get(2)), "两档位互不占配额");
}
@Test
@@ -94,7 +94,7 @@ class ChangesEngineTest {
Map<String, Object> result = engine.execute("users", null, null, null);
assertEquals("users", result.get("entity"));
assertEquals("users", result.get("stream"));
assertNotNull(result.get("snapshot_id"));
assertNotNull(result.get("data_cutoff_at"));
assertEquals(2, result.get("count"));
@@ -9,6 +9,8 @@ import me.iiv.agent.entity.m2.ContractNew;
import me.iiv.agent.mapper.m0.ProperytMapper;
import me.iiv.agent.mapper.m0.UserMapper;
import me.iiv.agent.mapper.m2.ContractNewMapper;
import me.iiv.agent.mcp.McpErrorCode;
import me.iiv.agent.mcp.ToolCallException;
import me.iiv.agent.permission.PermissionService;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeEach;
@@ -24,6 +26,7 @@ import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
@@ -79,19 +82,19 @@ class SyncEntitiesTest {
assertTrue(sql.contains("COALESCE(modify_time, create_time)"), "窗口必须按生效修改时间过滤: " + sql);
assertEquals(2, rows.size());
assertEquals(1L, rows.get(0).item().get("user_id"));
assertEquals("1", rows.get(0).item().get("user_id"));
assertEquals("张三", rows.get(0).item().get("display_name"));
assertEquals("active", rows.get(0).item().get("status"));
assertEquals("ACTIVE", rows.get(0).item().get("status"));
assertEquals("E001", rows.get(0).item().get("employee_no"));
assertEquals("13800000000", rows.get(0).item().get("mobile"));
assertEquals(List.of(88L), rows.get(0).item().get("organization_ids"));
assertEquals("13800000000", rows.get(0).item().get("verified_mobile"));
assertEquals(List.of("88"), rows.get(0).item().get("organization_ids"));
assertEquals("2026-09-01T10:00:00+08:00", rows.get(0).item().get("updated_at"));
assertEquals(Timestamp.valueOf("2026-09-01 10:00:00").getTime() / 1000,
rows.get(0).item().get("version"));
assertNull(rows.get(0).item().get("deleted_at"));
// tombstone:软删行 deleted_at 非空、status=inactive
assertEquals("inactive", rows.get(1).item().get("status"));
assertEquals("INACTIVE", rows.get(1).item().get("status"));
assertEquals("2026-09-02T10:00:00+08:00", rows.get(1).item().get("deleted_at"));
assertEquals(List.of(), rows.get(1).item().get("organization_ids"), "无部门时为空列表");
}
@@ -131,13 +134,13 @@ class SyncEntitiesTest {
List<SyncRow> rows = entity.fetchPage(null, 2000L, null, null, 3, user);
verify(permissionService).applyPropertyRowFilter(any(), any());
assertEquals(9L, rows.get(0).item().get("property_id"));
assertEquals("9", rows.get(0).item().get("property_id"));
assertEquals("KD10086", rows.get(0).item().get("customer_id"));
assertEquals("望京SOHO", rows.get(0).item().get("property_name"));
assertEquals("金蝶客户名称", rows.get(0).item().get("customer_name"));
assertEquals("91110105XXXXXXXX", rows.get(0).item().get("unified_social_credit_code"));
assertEquals("452542", rows.get(0).item().get("external_customer_no"));
assertEquals("active", rows.get(0).item().get("status"));
assertEquals("ACTIVE", rows.get(0).item().get("status"));
}
@Test
@@ -156,6 +159,7 @@ class SyncEntitiesTest {
ContractsSyncEntity entity = new ContractsSyncEntity(contractNewMapper, properytMapper, permissionService);
ContractNew contract = new ContractNew();
contract.setId(10L);
contract.setContractStatus(2);
contract.setPropertyId(2L);
contract.setSoftDelete(0);
contract.setModifyTime(Timestamp.valueOf("2026-09-01 13:54:21"));
@@ -182,6 +186,7 @@ class SyncEntitiesTest {
contract.setPropertyId(1L);
contract.setSaleId(1001L);
contract.setExpireRemind(1);
contract.setRemindDays(90);
contract.setAmount(new BigDecimal("120000.00"));
contract.setStartDate(java.sql.Date.valueOf("2026-01-01"));
contract.setEndDate(java.sql.Date.valueOf("2026-12-31"));
@@ -198,18 +203,50 @@ class SyncEntitiesTest {
List<SyncRow> rows = entity.fetchPage(null, 2000L, null, null, 3, user);
verify(permissionService).applyContractRowFilter(any(), any());
assertEquals(9L, rows.get(0).item().get("contract_id"));
assertEquals("9", rows.get(0).item().get("contract_id"));
assertEquals("452542C001", rows.get(0).item().get("contract_no"));
assertEquals(2, rows.get(0).item().get("status"));
assertEquals("ACTIVE", rows.get(0).item().get("status"));
assertEquals("2026-01-01T00:00:00+08:00", rows.get(0).item().get("start_at"));
assertEquals("2026-12-31T00:00:00+08:00", rows.get(0).item().get("end_at"));
assertEquals(12000000L, rows.get(0).item().get("amount_minor"), "金额按分输出");
assertEquals("KD10086", rows.get(0).item().get("customer_id"));
assertEquals(1L, rows.get(0).item().get("property_id"));
assertEquals(1001L, rows.get(0).item().get("owner_user_id"), "负责人=稳定 user.id");
assertEquals(1, rows.get(0).item().get("expire_remind"));
assertEquals("1", rows.get(0).item().get("property_id"));
assertEquals("1001", rows.get(0).item().get("owner_user_id"), "负责人=稳定 user.id");
assertEquals(true, rows.get(0).item().get("expire_remind"));
assertEquals(90, rows.get(0).item().get("remind_days"));
assertEquals(Timestamp.valueOf("2026-09-01 13:54:21").getTime() / 1000,
rows.get(0).item().get("version"));
assertEquals("2026-09-01T13:54:21+08:00", rows.get(0).item().get("deleted_at"), "软删行返回 tombstone");
}
@Test
void contractsRejectUnknownLegacyStatus() {
ToolCallException error = assertThrows(ToolCallException.class,
() -> ContractsSyncEntity.publicStatus(99));
assertEquals(McpErrorCode.DATA_INTEGRITY_ERROR, error.getCode());
}
@Test
void contractsCurrentBatchKeepsInputOrderAndDeletedRows() {
ContractsSyncEntity entity = new ContractsSyncEntity(contractNewMapper, properytMapper, permissionService);
ContractNew second = new ContractNew();
second.setId(10L);
second.setContractStatus(2);
second.setSoftDelete(0);
second.setModifyTime(Timestamp.valueOf("2026-09-15 10:00:00"));
ContractNew first = new ContractNew();
first.setId(9L);
first.setContractStatus(4);
first.setSoftDelete(1);
first.setModifyTime(Timestamp.valueOf("2026-09-15 09:00:00"));
when(contractNewMapper.selectList(any())).thenReturn(new java.util.ArrayList<>(List.of(second, first)));
List<java.util.Map<String, Object>> items = entity.fetchCurrent(List.of(9L, 10L), null);
assertEquals(List.of("9", "10"), items.stream().map(item -> item.get("contract_id")).toList());
assertEquals("TERMINATED", items.get(0).get("status"));
assertEquals("2026-09-15T09:00:00+08:00", items.get(0).get("deleted_at"));
verify(permissionService, never()).applyContractRowFilter(any(), any());
}
}
@@ -1,6 +1,9 @@
package me.iiv.agent.mcp.tool;
import me.iiv.agent.mcp.McpErrorCode;
import me.iiv.agent.mcp.ToolCallException;
import me.iiv.agent.mcp.sync.ChangesEngine;
import me.iiv.agent.mcp.sync.ContractsSyncEntity;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
@@ -8,13 +11,16 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.ai.tool.method.MethodToolCallbackProvider;
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.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 三个 changes 工具的注册与路由(REQ-20260911-0013)。
@@ -24,16 +30,18 @@ class ChangesToolsTest {
@Mock
ChangesEngine changesEngine;
@Mock
ContractsSyncEntity contractsSyncEntity;
@Test
void springAiPublishesThreeChangesToolSchemas() {
ChangesTools tools = new ChangesTools(changesEngine);
void springAiPublishesSyncToolSchemas() {
ChangesTools tools = new ChangesTools(changesEngine, contractsSyncEntity);
var callbacks = MethodToolCallbackProvider.builder().toolObjects(tools).build().getToolCallbacks();
Map<String, String> schemas = Arrays.stream(callbacks).collect(Collectors.toMap(
callback -> callback.getToolDefinition().name(),
callback -> callback.getToolDefinition().inputSchema()));
assertEquals(3, schemas.size());
assertEquals(4, schemas.size());
for (String name : new String[]{"users.changes.list", "properties.changes.list", "contracts.changes.list"}) {
assertTrue(schemas.containsKey(name), "缺少工具 " + name);
String schema = schemas.get(name);
@@ -43,11 +51,14 @@ class ChangesToolsTest {
assertFalse(schema.contains("tenant_id"), "SYNC 工具禁止 tenant_id 入参: " + name);
assertFalse(schema.contains("\"scope\""), "SYNC 工具禁止 scope 入参: " + name);
}
assertTrue(schemas.containsKey("contracts.current.batch_get"));
assertTrue(schemas.get("contracts.current.batch_get").contains("contractIds"));
assertFalse(schemas.get("contracts.current.batch_get").contains("tenant_id"));
}
@Test
void toolsRouteToEngineWithEntityName() {
ChangesTools tools = new ChangesTools(changesEngine);
ChangesTools tools = new ChangesTools(changesEngine, contractsSyncEntity);
tools.usersChangesList("t1", "c1", 50);
verify(changesEngine).execute("users", "t1", "c1", 50);
@@ -58,4 +69,31 @@ class ChangesToolsTest {
tools.contractsChangesList("t2", null, 200);
verify(changesEngine).execute("contracts", "t2", null, 200);
}
@Test
void currentBatchNormalizesIdsAndReportsMissingRows() {
ChangesTools tools = new ChangesTools(changesEngine, contractsSyncEntity);
when(contractsSyncEntity.fetchCurrent(List.of(9L, 10L), null))
.thenReturn(List.of(Map.of("contract_id", "9")));
Map<String, Object> result = tools.contractsCurrentBatchGet(List.of("9", "10", "9"));
assertEquals(1, result.get("count"));
assertEquals(List.of("10"), result.get("not_found_ids"));
verify(contractsSyncEntity).fetchCurrent(List.of(9L, 10L), null);
}
@Test
void currentBatchRejectsInvalidOrOversizedIds() {
ChangesTools tools = new ChangesTools(changesEngine, contractsSyncEntity);
ToolCallException invalid = assertThrows(ToolCallException.class,
() -> tools.contractsCurrentBatchGet(List.of("not-an-id")));
assertEquals(McpErrorCode.INVALID_ARGUMENT, invalid.getCode());
List<String> tooMany = java.util.stream.IntStream.rangeClosed(1, 201)
.mapToObj(String::valueOf).toList();
ToolCallException oversized = assertThrows(ToolCallException.class,
() -> tools.contractsCurrentBatchGet(tooMany));
assertEquals(McpErrorCode.INVALID_ARGUMENT, oversized.getCode());
}
}
@@ -0,0 +1,240 @@
package me.iiv.agent.mcp.tool;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import me.iiv.agent.entity.m0.User;
import me.iiv.agent.entity.m2.WarehouseIn;
import me.iiv.agent.entity.m2.WarehouseInAuditProcess;
import me.iiv.agent.entity.m2.WarehouseInDetail;
import me.iiv.agent.entity.m2.WarehouseInOutPic;
import me.iiv.agent.entity.m2.WarehouseType;
import me.iiv.agent.mapper.m2.WarehouseInAuditProcessMapper;
import me.iiv.agent.mapper.m2.WarehouseInDetailMapper;
import me.iiv.agent.mapper.m2.WarehouseInMapper;
import me.iiv.agent.mapper.m2.WarehouseInOutPicMapper;
import me.iiv.agent.mapper.m2.WarehouseTypeMapper;
import me.iiv.agent.mcp.McpErrorCode;
import me.iiv.agent.mcp.ToolCallException;
import me.iiv.agent.permission.PermissionService;
import me.iiv.agent.security.AuthContext;
import me.iiv.agent.security.AuthContextHolder;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDate;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class WarehouseInToolsTest {
@Mock WarehouseInMapper warehouseInMapper;
@Mock WarehouseInDetailMapper detailMapper;
@Mock WarehouseInAuditProcessMapper processMapper;
@Mock WarehouseInOutPicMapper picMapper;
@Mock WarehouseTypeMapper warehouseTypeMapper;
@Mock PermissionService permissionService;
WarehouseInTools tools;
@BeforeEach
void setUp() {
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
TableInfoHelper.initTableInfo(assistant, WarehouseIn.class);
TableInfoHelper.initTableInfo(assistant, WarehouseInDetail.class);
TableInfoHelper.initTableInfo(assistant, WarehouseInAuditProcess.class);
TableInfoHelper.initTableInfo(assistant, WarehouseInOutPic.class);
TableInfoHelper.initTableInfo(assistant, WarehouseType.class);
tools = new WarehouseInTools(warehouseInMapper, detailMapper, processMapper, picMapper,
warehouseTypeMapper, permissionService);
User user = new User();
user.setId(7L);
AuthContextHolder.set(new AuthContext(null, user));
}
@AfterEach
void tearDown() {
AuthContextHolder.clear();
}
@Test
void listAppliesPermissionAndBatchLoadsRelations() {
WarehouseIn order = order();
when(warehouseInMapper.selectCount(any())).thenReturn(1L);
when(warehouseInMapper.selectList(any())).thenReturn(List.of(order));
WarehouseInDetail detail = detail("SN-1", 2);
when(detailMapper.selectList(any())).thenReturn(List.of(detail));
WarehouseInAuditProcess schedule = new WarehouseInAuditProcess();
schedule.setWarehouseInId(20L);
schedule.setUserName("王五");
when(processMapper.selectList(any())).thenReturn(List.of(schedule));
Map<String, Object> result = list(0, 500);
assertEquals(1L, result.get("total"));
assertEquals(1, result.get("page"));
assertEquals(100, result.get("rows"));
@SuppressWarnings("unchecked")
Map<String, Object> record = ((List<Map<String, Object>>) result.get("records")).get(0);
assertEquals("ALLOCATION", record.get("sourceType"));
assertEquals("已完成", record.get("orderStateText"));
assertEquals(List.of(8L), record.get("warehouseIds"));
assertEquals(List.of("一号仓"), record.get("warehouseNames"));
assertEquals(List.of("李四"), record.get("warehouseInPeople"));
assertEquals(List.of("王五"), record.get("schedulePeople"));
verify(permissionService).applyWarehouseInRowFilter(any(), any());
}
@Test
void listRejectsInvalidAndReversedDates() {
assertThrows(IllegalArgumentException.class, () -> listWithDates("2026-02-30", null));
assertThrows(IllegalArgumentException.class, () -> listWithDates("2026-09-16", "2026-09-15"));
verifyNoInteractions(warehouseInMapper, permissionService);
}
@Test
void detailUsesSamePermissionAndHidesExistence() {
when(warehouseInMapper.selectList(any())).thenReturn(List.of());
ToolCallException error = assertThrows(ToolCallException.class,
() -> tools.warehouseInDetailQuery(20L, null, false, null));
assertEquals(McpErrorCode.NOT_FOUND_OR_FORBIDDEN, error.getCode());
verify(permissionService).applyWarehouseInRowFilter(any(), any());
verifyNoInteractions(detailMapper, processMapper, picMapper, warehouseTypeMapper);
}
@Test
void detailAllowsAllocationAndReturnsAggregatesSerialsFilesAndOutIndex() {
WarehouseIn order = order();
order.setOutOrderNo("CK-001");
when(warehouseInMapper.selectList(any())).thenReturn(List.of(order));
WarehouseInDetail first = detail("SN-1", 1);
WarehouseInDetail second = detail("SN-2", 2);
when(detailMapper.selectList(any())).thenReturn(List.of(first, second));
WarehouseInAuditProcess process = new WarehouseInAuditProcess();
process.setOperationTheme(3);
process.setOperationType(1);
when(processMapper.selectList(any())).thenReturn(List.of(process));
when(picMapper.selectList(any())).thenReturn(List.of(pic(1L, 0), pic(2L, 1)));
WarehouseType type = new WarehouseType();
type.setSoftDelete(0);
type.setSceneType(1);
when(warehouseTypeMapper.selectById(3L)).thenReturn(type);
Map<String, Object> result = tools.warehouseInDetailQuery(20L, "RK-001", true, 1);
assertEquals(1, result.get("sceneType"));
@SuppressWarnings("unchecked")
List<Map<String, Object>> items = (List<Map<String, Object>>) result.get("items");
assertEquals(1, items.size());
assertEquals(3, items.get(0).get("inCount"));
assertEquals(2L, result.get("serialTotal"));
assertTrue((Boolean) result.get("serialTruncated"));
assertEquals(1, ((List<?>) result.get("serials")).size());
@SuppressWarnings("unchecked")
Map<String, Object> files = (Map<String, Object>) result.get("files");
assertEquals(1, ((List<?>) files.get("application")).size());
assertEquals(1, ((List<?>) files.get("confirmation")).size());
@SuppressWarnings("unchecked")
Map<String, Object> related = (Map<String, Object>) result.get("relatedOutOrder");
assertEquals("CK-001", related.get("outOrderNo"));
assertEquals("warehouse.out.detail.query", related.get("queryTool"));
}
@Test
void detailDoesNotReturnSerialRowsUnlessRequested() {
WarehouseIn order = order();
order.setOutOrderNo(null);
when(warehouseInMapper.selectList(any())).thenReturn(List.of(order));
when(detailMapper.selectList(any())).thenReturn(List.of(detail("SN-1", 1)));
when(processMapper.selectList(any())).thenReturn(List.of());
when(picMapper.selectList(any())).thenReturn(List.of());
Map<String, Object> result = tools.warehouseInDetailQuery(20L, null, false, null);
assertEquals(List.of(), result.get("serials"));
assertEquals(1L, result.get("serialTotal"));
assertFalse((Boolean) result.get("serialTruncated"));
assertNull(result.get("relatedOutOrder"));
}
@Test
void springAiPublishesBothWarehouseInToolSchemas() {
var callbacks = org.springframework.ai.tool.method.MethodToolCallbackProvider.builder()
.toolObjects(tools).build().getToolCallbacks();
Map<String, String> schemas = Arrays.stream(callbacks).collect(java.util.stream.Collectors.toMap(
callback -> callback.getToolDefinition().name(), callback -> callback.getToolDefinition().inputSchema()));
assertEquals(2, schemas.size());
assertTrue(schemas.get("warehouse.in.list.query").contains("inDateFrom"));
assertTrue(schemas.get("warehouse.in.list.query").contains("withdrawOrderNo"));
assertTrue(schemas.get("warehouse.in.detail.query").contains("includeSerials"));
}
private Map<String, Object> list(Integer page, Integer rows) {
return tools.warehouseInListQuery(null, null, null, null, null, null, null, null,
null, null, null, null, 3, null, null, null,
"2026-09-01", "2026-09-30", null, null, page, rows);
}
private Map<String, Object> listWithDates(String from, String to) {
return tools.warehouseInListQuery(null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null,
from, to, null, null, 1, 20);
}
private static WarehouseIn order() {
WarehouseIn order = new WarehouseIn();
order.setId(20L);
order.setInOrderNo("RK-001");
order.setAllocationOrderNo("DB-001");
order.setWarehouseTypeId(3L);
order.setOrderState(3);
return order;
}
private static WarehouseInDetail detail(String sn, int count) {
WarehouseInDetail detail = new WarehouseInDetail();
detail.setId(100L + count);
detail.setWarehouseInId(20L);
detail.setWarehouseId(8L);
detail.setWarehouseName("一号仓");
detail.setMaterielDefinitionId(9L);
detail.setMaterielIndexId(10L);
detail.setMaterialNo("MAT-1");
detail.setSnCode(sn);
detail.setInCount(count);
detail.setInConfirmUserName("李四");
detail.setInDate(LocalDate.of(2026, 9, 15));
detail.setInConfirmTime(new Date(0));
return detail;
}
private static WarehouseInOutPic pic(long id, int fileType) {
WarehouseInOutPic pic = new WarehouseInOutPic();
pic.setId(id);
pic.setFileType(fileType);
pic.setInOutAllocationType(0);
pic.setFileName("file-" + id + ".jpg");
pic.setOssfolder("warehouse/");
return pic;
}
}
@@ -0,0 +1,104 @@
package me.iiv.agent.permission;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import me.iiv.agent.entity.m0.Agent;
import me.iiv.agent.entity.m0.Authority;
import me.iiv.agent.entity.m0.User;
import me.iiv.agent.entity.m2.WarehouseAgentAuth;
import me.iiv.agent.entity.m2.WarehouseIn;
import me.iiv.agent.mapper.m0.AgentManageDistrictAuthMapper;
import me.iiv.agent.mapper.m0.AgentMapper;
import me.iiv.agent.mapper.m0.AuthorityMapper;
import me.iiv.agent.mapper.m0.ProperytMapper;
import me.iiv.agent.mapper.m2.WarehouseAgentAuthMapper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.Collection;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class PermissionServiceWarehouseInRowFilterTest {
@Mock AuthorityMapper authorityMapper;
@Mock AgentMapper agentMapper;
@Mock WarehouseAgentAuthMapper warehouseAgentAuthMapper;
@Mock AgentManageDistrictAuthMapper agentManageDistrictAuthMapper;
@Mock ProperytMapper properytMapper;
@Mock StringRedisTemplate redisTemplate;
PermissionService service;
@BeforeEach
void setUp() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), WarehouseIn.class);
service = new PermissionService(authorityMapper, agentMapper, warehouseAgentAuthMapper,
agentManageDistrictAuthMapper, properytMapper, redisTemplate);
}
@Test
void headquartersAddsNoFilter() {
User user = user(7L, 9L, null);
when(authorityMapper.selectById(9L)).thenReturn(authority(9L, 0));
LambdaQueryWrapper<WarehouseIn> wrapper = new LambdaQueryWrapper<>();
service.applyWarehouseInRowFilter(wrapper, user);
assertTrue(wrapper.getSqlSegment().isBlank());
}
@Test
void agentFilterCombinesAgentWarehouseAndParticipantVisibility() {
User user = user(7L, 9L, 10L);
when(authorityMapper.selectById(9L)).thenReturn(authority(9L, 1));
Agent child = new Agent();
child.setId(11L);
when(agentMapper.selectList(any())).thenReturn(List.of(child));
WarehouseAgentAuth managed = new WarehouseAgentAuth();
managed.setManagerAgentId(12L);
when(warehouseAgentAuthMapper.selectList(any())).thenReturn(List.of(managed));
LambdaQueryWrapper<WarehouseIn> wrapper = new LambdaQueryWrapper<>();
service.applyWarehouseInRowFilter(wrapper, user);
String sql = wrapper.getSqlSegment();
Collection<Object> values = wrapper.getParamNameValuePairs().values();
assertTrue(sql.contains("agent_id IN"));
assertTrue(sql.contains("warehouse_in_details"));
assertTrue(sql.contains("warehouse_in_audit_process"));
assertTrue(sql.contains("OR"));
assertTrue(values.containsAll(List.of(7L, 10L, 11L, 12L)));
}
@Test
void agentWithoutAgentIdFailsClosed() {
User user = user(7L, 9L, null);
when(authorityMapper.selectById(9L)).thenReturn(authority(9L, 1));
assertThrows(IllegalStateException.class,
() -> service.applyWarehouseInRowFilter(new LambdaQueryWrapper<>(), user));
}
private static User user(Long id, Long authorityId, Long agentId) {
User user = new User();
user.setId(id);
user.setAuthorityId(authorityId);
user.setAgentId(agentId);
return user;
}
private static Authority authority(Long id, int isAgent) {
Authority authority = new Authority();
authority.setId(id);
authority.setIsagent(isAgent);
authority.setSoftDelete(0);
return authority;
}
}