Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
025ee7ffa6 | ||
|
|
6d8093fa14 | ||
|
|
5c73751889 | ||
|
|
15620bb4bf | ||
|
|
dfe14b29e3 | ||
|
|
8b5d531f3a | ||
|
|
78e319c2f9 | ||
|
|
a1c6ac10e5 | ||
|
|
e3b6edeb7b | ||
|
|
4cd95c61ab |
@@ -84,6 +84,7 @@ xiaoqu-agent-service/
|
||||
| `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、流程、附件和关联出库索引 |
|
||||
| `warehouse.todo.list.query` | warehouse | read | 当前用户仓库待办统一查询(REQ-20260915-0008),合并老系统出入库/调拨三个待办接口;流程视图 user_id 过滤,不按代理商过滤;调拨派生出入库单带 derivedFrom 标记 |
|
||||
|
||||
分页统一规范:page 从 1 开始,rows 默认 20、上限 100,超出截断为 100 并在返回 `notice` 字段提示。
|
||||
|
||||
|
||||
@@ -547,6 +547,42 @@
|
||||
|
||||
---
|
||||
|
||||
## 19. warehouse.todo.list.query
|
||||
|
||||
统一查询当前登录人的仓库待办,参考老系统 `/myWarehouseOut/needToBeDealtWith`、
|
||||
`/myWarehouseIn/needToBeDealtWith`、`/warehouseAllocation/warehouseAllocationProcessListByPage` 三个待办接口。
|
||||
|
||||
- 分类:warehouse | 类型:读
|
||||
- 数据来源:m2 流程视图 `warehouse_out_process` / `warehouse_in_process` / `warehouse_allocation_process`
|
||||
- 权限:待办是"人"的语义,只按流程表 `user_id = 当前登录人` 过滤,不叠加代理商行级权限。
|
||||
- 待办判定:`user_id = 当前登录人 AND soft_delete = 0 AND operation_type IN (0,1,2)`
|
||||
(3=纯日志排除)。`todoOnly` 默认 true 再限定 `schedule_state=1` 真待办。
|
||||
- 同一笔调拨最多以调拨审批/确认、出库执行、入库执行 3 个视角出现,不去重;
|
||||
派生自调拨的真实出入库单带 `derivedFrom="allocation"`,可按 `allocationOrderNo` 归并。
|
||||
- 分页实现:先全量查符合条件流程行按单据 id 内存去重 → 内存分页 → 按页 id 批量查视图行组装,防 N+1。
|
||||
|
||||
**入参**:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| docType | string | 否 | out=出库单 in=入库单 allocation=调拨/采购/划转单 all=全部,默认 all |
|
||||
| todoOnly | boolean | 否 | true=只返回真待办(默认);false=当前用户参与过的全部单据 |
|
||||
| orderState | number | 否 | 出库/入库单 0待审核 1被驳回 2待出库或待入库 3已完成 4已撤销 5作废;调拨类 0待审核 1被驳回 2待出库 3待入库 4已完成 5已撤销 6作废 |
|
||||
| orderNo | string | 否 | 单据主单号模糊匹配 |
|
||||
| page / rows | number | 否 | 页码默认 1;每页默认 20、上限 100,超出截断并在 `notice` 提示 |
|
||||
|
||||
**出参 records 字段**:`docType/docTypeText`(出库单/入库单/调拨单/采购单/划转单,
|
||||
按调拨视图 `op_type` 区分)、`docId/docNo`、`orderState/orderStateText`、
|
||||
`scheduleState/scheduleStateText`、`operationType/operationTypeText`(当前待办环节)、
|
||||
仓库名(allocation 为转出/转入仓库)、`agentId/agentName`、申请人/申请时间、
|
||||
`allocationOrderNo` 等关联单号、派生单 `derivedFrom="allocation"`;
|
||||
调拨类另含 `opType/opTypeText/allocationCount/nextUserName/inUserName/outUserName`。
|
||||
按申请时间倒序排列。
|
||||
|
||||
详细设计与老系统差异见 `warehouse-todo-mcp-tool-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,21 @@ 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`。
|
||||
- 合同展示上下文包含`contract_no/contract_name/property_name/customer_no/customer_name/owner_name`;这些字段用于页面、AI解释和人工核对,关联、授权和接收人选择只能使用稳定ID。
|
||||
- CRM 必须逐项复核状态、到期日、负责人、客户归属、提醒配置和续约结果。任一变化、缺失、删除、响应不完整、证据时间异常或服务不可用,对应合同本次都不得发送。
|
||||
- 该工具属于 SYNC 能力和限流档,调用方传 `tenant_id/tenantId/scope` 会被拒绝;数据域只能来自服务 Token。
|
||||
|
||||
## 4. 前台通道:销售在线查询(实时 ACL)
|
||||
|
||||
```
|
||||
@@ -105,7 +122,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/mcp-source-domain-identity`分支。
|
||||
>
|
||||
> 本文只记录 `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已部署;合同展示上下文字段已完成代码和回归、待合并部署;可靠同步与服务身份仍未通过,不能用于真实销售合同到期推送。**
|
||||
|
||||
交付状态:三条变化流和发送前复核代码已经合入主线并部署;生产`sync`作用域尚未绑定`contracts.current.batch_get`。本轮新增的`property_name/customer_no/customer_name/owner_name`仍待合并部署。
|
||||
|
||||
已经具备:
|
||||
|
||||
- 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`、布尔提醒标记、实际提醒天数,以及`property_name/customer_no/customer_name/owner_name`展示字段 |
|
||||
|
||||
已完成:新增独立公开DTO,将三类稳定ID统一为字符串;`mobile`改为`verified_mobile`;合同状态转换为公开枚举;`expire_remind`转换为布尔值;补齐`remind_days`;变化页使用统一`stream`字段。合同DTO继续补齐`property_name/customer_no/customer_name/owner_name`,这些字段只用于展示、AI解释和人工核对,身份关联与权限仍只认稳定ID。未知合同状态按`DATA_INTEGRITY_ERROR`失败关闭,不伪造业务语义。
|
||||
|
||||
验证:合并最新主分支后,Java编译、管理前端构建及MCP服务全量`229`项测试通过,`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严格契约测试 |
|
||||
| 发送前合同当前状态批量复核 | 代码已部署、生产scope未开放 | 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,103 @@
|
||||
# 仓库待办 MCP 工具设计(REQ-20260915-0008)
|
||||
|
||||
## 1. 范围与评审结论
|
||||
|
||||
老系统有 4 个"待办"接口,本需求统一为一个工具:
|
||||
|
||||
| 老系统接口 | 数据来源 |
|
||||
|---|---|
|
||||
| `POST /myWarehouseOut/needToBeDealtWith` | m2 视图 `warehouse_out_process` |
|
||||
| `POST /myWarehouseIn/needToBeDealtWith` | m2 视图 `warehouse_in_process` |
|
||||
| `POST /warehouseAllocation/warehouseAllocationProcessListByPage`(调拨/划转/采购共用) | m2 视图 `warehouse_allocation_process` |
|
||||
|
||||
新增工具:`warehouse.todo.list.query`(warehouse/read),统一查询当前登录人的仓库待办。
|
||||
|
||||
## 2. 待办语义(以老系统为准)
|
||||
|
||||
流程视图一行 = 单据 × 处理人 × 待办环节。待办判定:
|
||||
|
||||
```
|
||||
user_id = 当前登录人 AND soft_delete = 0 AND operation_type IN (0, 1, 2)
|
||||
```
|
||||
|
||||
- `operation_type=3` 为"驳回或修改记录",纯日志不需要待办,排除(出入库视图);
|
||||
调拨视图 `operation_type=4`(撤销)同为纯日志,也被 IN (0,1,2) 排除。
|
||||
- 不按代理商过滤:待办是"人"的语义,不是代理商数据权限语义。
|
||||
老系统出入库待办同样只按 `user_id` 过滤(`input.setUserId(user.getId())`),
|
||||
只有调拨待办显式 `eq("user_id", user.getId())` 且代理行级过滤被注释掉,
|
||||
与本实现一致。
|
||||
|
||||
`operation_type` 枚举(老系统视图字段注释,xiaoqu-publicbeanandmap beans/b2):
|
||||
|
||||
| 值 | 出库/入库视图 | 调拨视图 |
|
||||
|---|---|---|
|
||||
| 0 | 审批流操作 | 审批流操作 |
|
||||
| 1 | 确认出库 / 确认入库 | 确认入库操作 |
|
||||
| 2 | 本人修改(需申请人待办) | 确认出库操作 |
|
||||
| 3 | 驳回或修改操作(纯日志,排除) | 本人修改 |
|
||||
| 4 | — | 撤销(纯日志,排除) |
|
||||
|
||||
`schedule_state`:0=无需待办,1=待办,2=已办。
|
||||
|
||||
`order_state`:出库/入库单 0=待审核 1=被驳回 2=待出库/待入库 3=已完成 4=已撤销 5=作废;
|
||||
调拨类 0=待审核 1=被驳回 2=待出库 3=待入库 4=已完成 5=已撤销 6=作废。调拨视图 `op_type`:
|
||||
0=调拨(调拨单),1=采购(采购单),2=划转(划转单)。
|
||||
|
||||
## 3. 单据类型与调拨三视角
|
||||
|
||||
- 出库单(24 开头单号)、入库单(23 开头)是独立表。
|
||||
- 调拨/采购/划转是同一张 `warehouse_allocation` 表,`op_type` 区分。
|
||||
- 调拨/划转审批通过后由 `WarehouseServiceImpl.procAfterAllocation` 生成真实入库单 +
|
||||
出库单并回填 `allocation_order_no`,同一笔调拨最多以 3 个视角出现在待办里
|
||||
(调拨审批/确认、出库执行、入库执行)。这是正常业务,**不去重**;
|
||||
派生出入库单带 `derivedFrom="allocation"` 标记,客户端可按 `allocationOrderNo` 归并。
|
||||
|
||||
## 4. 工具契约
|
||||
|
||||
入参全部可选:
|
||||
|
||||
| 参数 | 说明 |
|
||||
|---|---|
|
||||
| docType | out / in / allocation / all,默认 all;allocation 涵盖调拨/采购/划转 |
|
||||
| todoOnly | 默认 true 只返回 `schedule_state=1` 真待办;false 返回当前用户参与过的全部单据 |
|
||||
| orderState | 按单据状态过滤(枚举值见上,写入 description) |
|
||||
| orderNo | 单据主单号模糊匹配(出库/入库/调拨单号) |
|
||||
| page / rows | 默认 1 / 20,rows >100 截断为 100 并在 notice 提示 |
|
||||
|
||||
出参:`total/page/rows/records`。每条 record 统一结构:`docType/docTypeText`
|
||||
(出库单/入库单/调拨单/采购单/划转单)、`docId/docNo`、`orderState/orderStateText`、
|
||||
`scheduleState/scheduleStateText`、`operationType/operationTypeText`(当前待办环节)、
|
||||
仓库名(allocation 为 `warehouseOutName/warehouseInName`)、`agentId/agentName`、
|
||||
申请人/申请时间、`allocationOrderNo` 等关联单号(out/in 另含 `mallOrderNo`,
|
||||
in 另含关联 `outOrderNo`)、派生单 `derivedFrom="allocation"`;
|
||||
allocation 记录另含 `opType/opTypeText/allocationCount/allocationDate/nextUserName/inUserName/outUserName`。
|
||||
|
||||
排序:申请时间倒序(null 在后),同时间按单据 id 倒序。
|
||||
|
||||
## 5. 实现要点
|
||||
|
||||
- **两阶段分页**:阶段一按过滤条件全量查流程行(只 select 单据 id + 申请时间),
|
||||
内存按单据 id 去重;内存分页;阶段二按页内 id 批量查视图整行组装。
|
||||
避开老系统 `GROUP BY` 下 `selectCount` 不可靠的坑,天然防 N+1。
|
||||
(注意:阶段一只取 id 列时若同一单据多行需内存去重,本实现用 `putIfAbsent`。)
|
||||
- **多环节取行**:一个单据对同一用户可能有多行(已办 + 待办),`todoOnly=false` 时
|
||||
优先取 `schedule_state=1` 的行作为"当前待办环节";`todoOnly=true` 时全部行均为待办,取首行。
|
||||
- 权限:只用 `AuthContextHolder` 当前用户 id 做 `user_id` 过滤,
|
||||
不叠加 `PermissionService` 行级过滤。
|
||||
|
||||
## 6. 与老系统的已知差异
|
||||
|
||||
- 老系统出入库待办接口按 `GROUP BY (单据id, schedule_state, operation_type)` 分页,
|
||||
同一单据可能按环节拆成多行返回;本实现按单据聚合为一条记录,
|
||||
`operationType/scheduleState` 取当前待办环节。更符合"待办列表"语义。
|
||||
- 老系统调拨待办不过滤 `operation_type`(会带入撤销日志行);本实现按统一语义
|
||||
IN (0,1,2) 排除,同时把调拨视图被驳回后"本人修改"(type=3)一并排除
|
||||
(与出入库视图 type=3 语义对齐的取舍)。如需申请人修改待办可再评估。
|
||||
- 老系统出入库待办前端可传 `schedule_state` 筛选"已办";本实现用 `todoOnly=false` 表达。
|
||||
|
||||
## 7. 验收
|
||||
|
||||
- 三类型合并排序分页、rows 截断 notice、派生单 derivedFrom 标记;
|
||||
- todoOnly=true/false 两态、orderState/orderNo/docType 过滤、非法 docType 拒绝;
|
||||
- Spring AI schema 发布(@Tool + @XqTool 自动注册,ToolSyncService 同步 tool 表);
|
||||
- 固定 `TZ=Asia/Shanghai` 执行全量后端测试,不连接生产数据库、不执行部署。
|
||||
@@ -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;
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package me.iiv.agent.entity.m2;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
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;
|
||||
|
||||
/** 调拨/采购/划转流程视图 warehouse_allocation_process(m2,只读):一行 = 单据 × 处理人 × 待办环节。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@ToString
|
||||
@TableName("warehouse_allocation_process")
|
||||
public class WarehouseAllocationProcess implements Serializable {
|
||||
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Integer softDelete;
|
||||
private Long warehouseAllocationId;
|
||||
private String allocationOrderNo;
|
||||
private Integer allocationCount;
|
||||
private Date applyDate;
|
||||
private String applyUserName;
|
||||
private Long applyUserId;
|
||||
/** 视图列名即 currend_audit_level(老系统拼写保留) */
|
||||
@TableField("currend_audit_level")
|
||||
private Integer currendAuditLevel;
|
||||
private Integer goodsCount;
|
||||
private LocalDate allocationDate;
|
||||
private String userName;
|
||||
private Long userId;
|
||||
/** 操作主题(0:审核通过;1:审核驳回;2:入库确认驳回;3:转出确认;4:出库确认驳回;5:驳回后修改提交;6:入库确认;7:出库确认;8:撤销) */
|
||||
private Integer operationTheme;
|
||||
/** 待办状态(0:无需待办;1:待办;2:已办) */
|
||||
private Integer scheduleState;
|
||||
/** 审核状态(0:审核驳回/驳回;1:审核通过/出库确认) */
|
||||
private Integer auditState;
|
||||
/** 流程行审核级别 */
|
||||
@TableField("process_audit_level")
|
||||
private Integer processAuditLevel;
|
||||
/** 操作类型(0:审批流操作;1:确认入库操作;2:确认出库操作;3:本人修改;4:撤销,纯日志) */
|
||||
private Integer operationType;
|
||||
private String warehouseOutName;
|
||||
private String warehouseInName;
|
||||
private Long warehouseInId;
|
||||
private Long warehouseOutId;
|
||||
/** 单据审核级别 */
|
||||
private Integer auditLevel;
|
||||
/** 调拨单状态(0:待审核;1:被驳回;2:待出库;3:待入库;4:已完成;5:已撤销;6:作废) */
|
||||
private Integer orderState;
|
||||
private Long agentId;
|
||||
private String agentName;
|
||||
private Date createTime;
|
||||
/** 办理类型(0:调拨;1:采购;2:划转) */
|
||||
private Integer opType;
|
||||
private String memo;
|
||||
private String inUserName;
|
||||
private Date inTime;
|
||||
private String nextUserName;
|
||||
private String outUserName;
|
||||
private Date outTime;
|
||||
private String pickupOrderNo;
|
||||
private String installOrderNo;
|
||||
private String deliveryOrderNo;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package me.iiv.agent.entity.m2;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
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;
|
||||
|
||||
/** 入库单流程视图 warehouse_in_process(m2,只读):一行 = 单据 × 处理人 × 待办环节。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@ToString
|
||||
@TableName("warehouse_in_process")
|
||||
public class WarehouseInProcess 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;
|
||||
/** 待办状态(0:无需待办;1:待办;2:已办) */
|
||||
private Integer scheduleState;
|
||||
/** 审核状态(0:审核驳回;1:审核通过),仅与流程审批有关 */
|
||||
private Integer auditState;
|
||||
/** 操作主题(0:审核通过;1:审核驳回;2:撤销入库;3:入库确认;4:驳回修改提交;5:撤销;6:入库驳回) */
|
||||
private Integer operationTheme;
|
||||
/** 操作类型(0:审批流操作;1:确认入库操作;2:本人修改;3:驳回或修改操作,纯日志) */
|
||||
private Integer operationType;
|
||||
private String inOrderNo;
|
||||
private String allocationOrderNo;
|
||||
private String outOrderNo;
|
||||
private String mallOrderNo;
|
||||
private Long agentId;
|
||||
private String agentName;
|
||||
private Long warehouseTypeId;
|
||||
private String warehouseTypeName;
|
||||
private LocalDate inDate;
|
||||
private Integer goodsCount;
|
||||
private Integer inCount;
|
||||
/** 入库单状态(0:待审核;1:被驳回;2:待入库;3:已完成;4:已撤销;5:作废) */
|
||||
private Integer orderState;
|
||||
private Long applyUserId;
|
||||
private String applyUserName;
|
||||
private Date applyDate;
|
||||
private Integer auditLevel;
|
||||
/** 视图列名即 currend_audit_level(老系统拼写保留) */
|
||||
@TableField("currend_audit_level")
|
||||
private Integer currendAuditLevel;
|
||||
private String memo;
|
||||
private Date createTime;
|
||||
private String pickupOrderNo;
|
||||
private String installOrderNo;
|
||||
private String deliveryOrderNo;
|
||||
private String withdrawOrderNo;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package me.iiv.agent.entity.m2;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
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;
|
||||
|
||||
/** 出库单流程视图 warehouse_out_process(m2,只读):一行 = 单据 × 处理人 × 待办环节。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@ToString
|
||||
@TableName("warehouse_out_process")
|
||||
public class WarehouseOutProcess implements Serializable {
|
||||
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Integer softDelete;
|
||||
private Long warehouseOutId;
|
||||
private Long warehouseId;
|
||||
private String warehouseName;
|
||||
private Long userId;
|
||||
private String userName;
|
||||
private Date operationTime;
|
||||
/** 待办状态(0:无需待办;1:待办;2:已办) */
|
||||
private Integer scheduleState;
|
||||
/** 审核状态(0:审核驳回;1:审核通过),仅与流程审批有关 */
|
||||
private Integer auditState;
|
||||
/** 操作主题(0:审核通过;1:审核驳回;2:撤销入库;3:出库确认;4:驳回修改提交;5:撤销) */
|
||||
private Integer operationTheme;
|
||||
/** 操作类型(0:审批流操作;1:确认出库操作;2:本人修改;3:驳回或修改操作,纯日志) */
|
||||
private Integer operationType;
|
||||
private String outOrderNo;
|
||||
private String allocationOrderNo;
|
||||
private String mallOrderNo;
|
||||
private Long agentId;
|
||||
private String agentName;
|
||||
private Long warehouseTypeId;
|
||||
private String warehouseTypeName;
|
||||
private LocalDate outDate;
|
||||
private Integer goodsCount;
|
||||
private Integer outCount;
|
||||
/** 出库单状态(0:待审核;1:被驳回;2:待出库;3:已完成;4:已撤销;5:作废) */
|
||||
private Integer orderState;
|
||||
private Long applyUserId;
|
||||
private String applyUserName;
|
||||
private Date applyDate;
|
||||
private Integer auditLevel;
|
||||
/** 视图列名即 currend_audit_level(老系统拼写保留) */
|
||||
@TableField("currend_audit_level")
|
||||
private Integer currendAuditLevel;
|
||||
private String memo;
|
||||
private Date createTime;
|
||||
private String pickupOrderNo;
|
||||
private String installOrderNo;
|
||||
private String deliveryOrderNo;
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package me.iiv.agent.mapper.m2;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import me.iiv.agent.entity.m2.WarehouseAllocationProcess;
|
||||
|
||||
public interface WarehouseAllocationProcessMapper extends BaseMapper<WarehouseAllocationProcess> {
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package me.iiv.agent.mapper.m2;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import me.iiv.agent.entity.m2.WarehouseInProcess;
|
||||
|
||||
public interface WarehouseInProcessMapper extends BaseMapper<WarehouseInProcess> {
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package me.iiv.agent.mapper.m2;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import me.iiv.agent.entity.m2.WarehouseOutProcess;
|
||||
|
||||
public interface WarehouseOutProcessMapper extends BaseMapper<WarehouseOutProcess> {
|
||||
}
|
||||
@@ -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,50 @@ 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(),
|
||||
row.getPropertyName(), row.getCustomerNo(), row.getCustomerName(),
|
||||
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()),
|
||||
row.getSaleName(),
|
||||
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 +129,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,77 @@
|
||||
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,
|
||||
@JsonProperty("property_name") String propertyName,
|
||||
@JsonProperty("customer_no") String customerNo,
|
||||
@JsonProperty("customer_name") String customerName,
|
||||
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("owner_name") String ownerName,
|
||||
@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,10 @@ 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。"
|
||||
+ "property_name/customer_no/customer_name/owner_user_id(负责人稳定 user.id)/owner_name/"
|
||||
+ "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 +88,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,381 @@
|
||||
package me.iiv.agent.mcp.tool;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import me.iiv.agent.entity.m0.User;
|
||||
import me.iiv.agent.entity.m2.WarehouseAllocationProcess;
|
||||
import me.iiv.agent.entity.m2.WarehouseInProcess;
|
||||
import me.iiv.agent.entity.m2.WarehouseOutProcess;
|
||||
import me.iiv.agent.mapper.m2.WarehouseAllocationProcessMapper;
|
||||
import me.iiv.agent.mapper.m2.WarehouseInProcessMapper;
|
||||
import me.iiv.agent.mapper.m2.WarehouseOutProcessMapper;
|
||||
import me.iiv.agent.mcp.ToolObject;
|
||||
import me.iiv.agent.mcp.XqTool;
|
||||
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.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 仓库待办查询工具,参考老系统 /myWarehouseOut/needToBeDealtWith、/myWarehouseIn/needToBeDealtWith、
|
||||
* /warehouseAllocation/warehouseAllocationProcessListByPage,统一查询三张流程视图。
|
||||
*/
|
||||
@Component
|
||||
public class WarehouseTodoTools implements ToolObject {
|
||||
|
||||
private static final int DEFAULT_ROWS = 20;
|
||||
private static final int MAX_ROWS = 100;
|
||||
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
|
||||
private static final DateTimeFormatter TIME_FORMATTER =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(BUSINESS_ZONE);
|
||||
|
||||
/** 待办环节只保留可办理的操作类型,3(驳回或修改记录)为纯日志,排除。 */
|
||||
private static final List<Integer> TODO_OPERATION_TYPES = List.of(0, 1, 2);
|
||||
|
||||
private static final Map<Integer, String> SCHEDULE_STATE_TEXT = Map.of(
|
||||
0, "无需待办", 1, "待办", 2, "已办");
|
||||
private static final Map<Integer, String> OUT_ORDER_STATE_TEXT = Map.of(
|
||||
0, "待审核", 1, "被驳回", 2, "待出库", 3, "已完成", 4, "已撤销", 5, "作废");
|
||||
private static final Map<Integer, String> IN_ORDER_STATE_TEXT = Map.of(
|
||||
0, "待审核", 1, "被驳回", 2, "待入库", 3, "已完成", 4, "已撤销", 5, "作废");
|
||||
private static final Map<Integer, String> ALLOCATION_ORDER_STATE_TEXT = Map.of(
|
||||
0, "待审核", 1, "被驳回", 2, "待出库", 3, "待入库", 4, "已完成", 5, "已撤销", 6, "作废");
|
||||
private static final Map<Integer, String> OUT_OPERATION_TYPE_TEXT = Map.of(
|
||||
0, "审批流操作", 1, "确认出库", 2, "本人修改");
|
||||
private static final Map<Integer, String> IN_OPERATION_TYPE_TEXT = Map.of(
|
||||
0, "审批流操作", 1, "确认入库", 2, "本人修改");
|
||||
private static final Map<Integer, String> ALLOCATION_OPERATION_TYPE_TEXT = Map.of(
|
||||
0, "审批流操作", 1, "确认入库", 2, "确认出库", 3, "本人修改", 4, "撤销");
|
||||
private static final Map<Integer, String> OP_TYPE_TEXT = Map.of(
|
||||
0, "调拨", 1, "采购", 2, "划转");
|
||||
|
||||
final WarehouseOutProcessMapper outProcessMapper;
|
||||
final WarehouseInProcessMapper inProcessMapper;
|
||||
final WarehouseAllocationProcessMapper allocationProcessMapper;
|
||||
|
||||
public WarehouseTodoTools(WarehouseOutProcessMapper outProcessMapper,
|
||||
WarehouseInProcessMapper inProcessMapper,
|
||||
WarehouseAllocationProcessMapper allocationProcessMapper) {
|
||||
this.outProcessMapper = outProcessMapper;
|
||||
this.inProcessMapper = inProcessMapper;
|
||||
this.allocationProcessMapper = allocationProcessMapper;
|
||||
}
|
||||
|
||||
@XqTool(category = "warehouse", kind = "read")
|
||||
@Tool(name = "warehouse.todo.list.query", description = "分页查询当前用户的仓库待办,统一老系统 /myWarehouseOut/needToBeDealtWith、"
|
||||
+ "/myWarehouseIn/needToBeDealtWith、/warehouseAllocation/warehouseAllocationProcessListByPage 三个待办接口。"
|
||||
+ "数据来自 m2 流程视图 warehouse_out_process / warehouse_in_process / warehouse_allocation_process:"
|
||||
+ "待办语义 = 视图里存在当前用户的任务行(user_id = 当前登录人 AND soft_delete = 0 AND operation_type IN (0,1,2),"
|
||||
+ "3=纯日志排除),不按代理商过滤。同一笔调拨最多以调拨审批/确认、出库执行、入库执行 3 个视角出现,不去重;"
|
||||
+ "派生自调拨的真实出入库单带 derivedFrom=\"allocation\" 标记。"
|
||||
+ "docType:out=出库单 in=入库单 allocation=调拨/采购/划转单(按 op_type 区分) all=全部,默认 all。"
|
||||
+ "todoOnly 默认 true(只返回 schedule_state=1 真待办),false 返回当前用户参与过的全部单据。"
|
||||
+ "orderState 按单据状态过滤:出库/入库单 0待审核 1被驳回 2待出库/待入库 3已完成 4已撤销 5作废;"
|
||||
+ "调拨类 0待审核 1被驳回 2待出库 3待入库 4已完成 5已撤销 6作废。"
|
||||
+ "分页 page 默认1,rows 默认20、上限100,超出截断为 100 并在 notice 提示。")
|
||||
public Map<String, Object> warehouseTodoListQuery(
|
||||
@ToolParam(required = false, description = "单据类型:out/in/allocation/all,默认 all。allocation 涵盖调拨/采购/划转") String docType,
|
||||
@ToolParam(required = false, description = "true=只返回 schedule_state=1 真待办(默认);false=当前用户参与过的全部单据") Boolean todoOnly,
|
||||
@ToolParam(required = false, description = "按单据状态过滤:出库/入库单 0待审核 1被驳回 2待出库或待入库 3已完成 4已撤销 5作废;调拨类 0待审核 1被驳回 2待出库 3待入库 4已完成 5已撤销 6作废") Integer orderState,
|
||||
@ToolParam(required = false, description = "单号模糊匹配(出库/入库/调拨单号)") String orderNo,
|
||||
@ToolParam(required = false, description = "页码,从1开始,默认1") Integer page,
|
||||
@ToolParam(required = false, description = "每页条数,默认20,上限100") Integer rows) {
|
||||
String type = StringUtils.hasText(docType) ? docType.trim().toLowerCase() : "all";
|
||||
if (!type.equals("out") && !type.equals("in") && !type.equals("allocation") && !type.equals("all")) {
|
||||
throw new IllegalArgumentException("docType 必须是 out / in / allocation / all");
|
||||
}
|
||||
boolean onlyTodo = todoOnly == null || todoOnly;
|
||||
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);
|
||||
|
||||
User user = AuthContextHolder.get().getUser();
|
||||
Long userId = user.getId();
|
||||
|
||||
List<TodoStub> stubs = new ArrayList<>();
|
||||
if (type.equals("out") || type.equals("all")) {
|
||||
stubs.addAll(outStubs(userId, onlyTodo, orderState, orderNo));
|
||||
}
|
||||
if (type.equals("in") || type.equals("all")) {
|
||||
stubs.addAll(inStubs(userId, onlyTodo, orderState, orderNo));
|
||||
}
|
||||
if (type.equals("allocation") || type.equals("all")) {
|
||||
stubs.addAll(allocationStubs(userId, onlyTodo, orderState, orderNo));
|
||||
}
|
||||
stubs.sort(Comparator.comparing(TodoStub::applyDate,
|
||||
Comparator.nullsLast(Comparator.reverseOrder()))
|
||||
.thenComparing(TodoStub::docId, Comparator.reverseOrder()));
|
||||
|
||||
int total = stubs.size();
|
||||
int from = Math.min((pageNo - 1) * pageSize, total);
|
||||
int to = Math.min(from + pageSize, total);
|
||||
List<TodoStub> pageStubs = stubs.subList(from, to);
|
||||
|
||||
List<Map<String, Object>> records = assemble(pageStubs, userId, onlyTodo);
|
||||
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;
|
||||
}
|
||||
|
||||
/** 第一阶段:全量查符合条件(含过滤)的流程行,内存按单据 id 去重。 */
|
||||
private List<TodoStub> outStubs(Long userId, boolean onlyTodo, Integer orderState, String orderNo) {
|
||||
LambdaQueryWrapper<WarehouseOutProcess> wrapper = new LambdaQueryWrapper<WarehouseOutProcess>()
|
||||
.select(WarehouseOutProcess::getWarehouseOutId, WarehouseOutProcess::getApplyDate)
|
||||
.eq(WarehouseOutProcess::getSoftDelete, 0)
|
||||
.eq(WarehouseOutProcess::getUserId, userId)
|
||||
.in(WarehouseOutProcess::getOperationType, TODO_OPERATION_TYPES)
|
||||
.eq(onlyTodo, WarehouseOutProcess::getScheduleState, 1)
|
||||
.eq(orderState != null, WarehouseOutProcess::getOrderState, orderState)
|
||||
.like(StringUtils.hasText(orderNo), WarehouseOutProcess::getOutOrderNo,
|
||||
StringUtils.hasText(orderNo) ? orderNo.trim() : orderNo);
|
||||
Map<Long, Date> byDocId = new LinkedHashMap<>();
|
||||
for (WarehouseOutProcess row : outProcessMapper.selectList(wrapper)) {
|
||||
byDocId.putIfAbsent(row.getWarehouseOutId(), row.getApplyDate());
|
||||
}
|
||||
return byDocId.entrySet().stream()
|
||||
.map(entry -> new TodoStub("out", entry.getKey(), entry.getValue()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private List<TodoStub> inStubs(Long userId, boolean onlyTodo, Integer orderState, String orderNo) {
|
||||
LambdaQueryWrapper<WarehouseInProcess> wrapper = new LambdaQueryWrapper<WarehouseInProcess>()
|
||||
.select(WarehouseInProcess::getWarehouseInId, WarehouseInProcess::getApplyDate)
|
||||
.eq(WarehouseInProcess::getSoftDelete, 0)
|
||||
.eq(WarehouseInProcess::getUserId, userId)
|
||||
.in(WarehouseInProcess::getOperationType, TODO_OPERATION_TYPES)
|
||||
.eq(onlyTodo, WarehouseInProcess::getScheduleState, 1)
|
||||
.eq(orderState != null, WarehouseInProcess::getOrderState, orderState)
|
||||
.like(StringUtils.hasText(orderNo), WarehouseInProcess::getInOrderNo,
|
||||
StringUtils.hasText(orderNo) ? orderNo.trim() : orderNo);
|
||||
Map<Long, Date> byDocId = new LinkedHashMap<>();
|
||||
for (WarehouseInProcess row : inProcessMapper.selectList(wrapper)) {
|
||||
byDocId.putIfAbsent(row.getWarehouseInId(), row.getApplyDate());
|
||||
}
|
||||
return byDocId.entrySet().stream()
|
||||
.map(entry -> new TodoStub("in", entry.getKey(), entry.getValue()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private List<TodoStub> allocationStubs(Long userId, boolean onlyTodo, Integer orderState, String orderNo) {
|
||||
LambdaQueryWrapper<WarehouseAllocationProcess> wrapper = new LambdaQueryWrapper<WarehouseAllocationProcess>()
|
||||
.select(WarehouseAllocationProcess::getWarehouseAllocationId, WarehouseAllocationProcess::getApplyDate)
|
||||
.eq(WarehouseAllocationProcess::getSoftDelete, 0)
|
||||
.eq(WarehouseAllocationProcess::getUserId, userId)
|
||||
.in(WarehouseAllocationProcess::getOperationType, TODO_OPERATION_TYPES)
|
||||
.eq(onlyTodo, WarehouseAllocationProcess::getScheduleState, 1)
|
||||
.eq(orderState != null, WarehouseAllocationProcess::getOrderState, orderState)
|
||||
.like(StringUtils.hasText(orderNo), WarehouseAllocationProcess::getAllocationOrderNo,
|
||||
StringUtils.hasText(orderNo) ? orderNo.trim() : orderNo);
|
||||
Map<Long, Date> byDocId = new LinkedHashMap<>();
|
||||
for (WarehouseAllocationProcess row : allocationProcessMapper.selectList(wrapper)) {
|
||||
byDocId.putIfAbsent(row.getWarehouseAllocationId(), row.getApplyDate());
|
||||
}
|
||||
return byDocId.entrySet().stream()
|
||||
.map(entry -> new TodoStub("allocation", entry.getKey(), entry.getValue()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/** 第二阶段:按页内单据 id 批量查视图行组装记录,避免 N+1;输出顺序与分页排序一致。 */
|
||||
private List<Map<String, Object>> assemble(List<TodoStub> stubs, Long userId, boolean onlyTodo) {
|
||||
Map<String, List<TodoStub>> byType = stubs.stream()
|
||||
.collect(Collectors.groupingBy(TodoStub::docType, LinkedHashMap::new, Collectors.toList()));
|
||||
|
||||
Map<Long, List<WarehouseOutProcess>> outRowsByDoc = Map.of();
|
||||
List<TodoStub> outStubs = byType.getOrDefault("out", List.of());
|
||||
if (!outStubs.isEmpty()) {
|
||||
List<Long> ids = outStubs.stream().map(TodoStub::docId).collect(Collectors.toList());
|
||||
outRowsByDoc = outProcessMapper.selectList(
|
||||
new LambdaQueryWrapper<WarehouseOutProcess>()
|
||||
.eq(WarehouseOutProcess::getSoftDelete, 0)
|
||||
.eq(WarehouseOutProcess::getUserId, userId)
|
||||
.in(WarehouseOutProcess::getOperationType, TODO_OPERATION_TYPES)
|
||||
.in(WarehouseOutProcess::getWarehouseOutId, ids)
|
||||
.orderByAsc(WarehouseOutProcess::getId)).stream()
|
||||
.collect(Collectors.groupingBy(WarehouseOutProcess::getWarehouseOutId,
|
||||
LinkedHashMap::new, Collectors.toList()));
|
||||
}
|
||||
|
||||
Map<Long, List<WarehouseInProcess>> inRowsByDoc = Map.of();
|
||||
List<TodoStub> inStubs = byType.getOrDefault("in", List.of());
|
||||
if (!inStubs.isEmpty()) {
|
||||
List<Long> ids = inStubs.stream().map(TodoStub::docId).collect(Collectors.toList());
|
||||
inRowsByDoc = inProcessMapper.selectList(
|
||||
new LambdaQueryWrapper<WarehouseInProcess>()
|
||||
.eq(WarehouseInProcess::getSoftDelete, 0)
|
||||
.eq(WarehouseInProcess::getUserId, userId)
|
||||
.in(WarehouseInProcess::getOperationType, TODO_OPERATION_TYPES)
|
||||
.in(WarehouseInProcess::getWarehouseInId, ids)
|
||||
.orderByAsc(WarehouseInProcess::getId)).stream()
|
||||
.collect(Collectors.groupingBy(WarehouseInProcess::getWarehouseInId,
|
||||
LinkedHashMap::new, Collectors.toList()));
|
||||
}
|
||||
|
||||
Map<Long, List<WarehouseAllocationProcess>> allocationRowsByDoc = Map.of();
|
||||
List<TodoStub> allocationStubs = byType.getOrDefault("allocation", List.of());
|
||||
if (!allocationStubs.isEmpty()) {
|
||||
List<Long> ids = allocationStubs.stream().map(TodoStub::docId).collect(Collectors.toList());
|
||||
allocationRowsByDoc = allocationProcessMapper.selectList(
|
||||
new LambdaQueryWrapper<WarehouseAllocationProcess>()
|
||||
.eq(WarehouseAllocationProcess::getSoftDelete, 0)
|
||||
.eq(WarehouseAllocationProcess::getUserId, userId)
|
||||
.in(WarehouseAllocationProcess::getOperationType, TODO_OPERATION_TYPES)
|
||||
.in(WarehouseAllocationProcess::getWarehouseAllocationId, ids)
|
||||
.orderByAsc(WarehouseAllocationProcess::getId)).stream()
|
||||
.collect(Collectors.groupingBy(WarehouseAllocationProcess::getWarehouseAllocationId,
|
||||
LinkedHashMap::new, Collectors.toList()));
|
||||
}
|
||||
|
||||
List<Map<String, Object>> records = new ArrayList<>();
|
||||
for (TodoStub stub : stubs) {
|
||||
switch (stub.docType()) {
|
||||
case "out" -> addIfPresent(records, outRowsByDoc.get(stub.docId()),
|
||||
rows -> outRecord(pickPending(rows, onlyTodo)));
|
||||
case "in" -> addIfPresent(records, inRowsByDoc.get(stub.docId()),
|
||||
rows -> inRecord(pickPending(rows, onlyTodo)));
|
||||
default -> addIfPresent(records, allocationRowsByDoc.get(stub.docId()),
|
||||
rows -> allocationRecord(pickPending(rows, onlyTodo)));
|
||||
}
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
private static <T> void addIfPresent(List<Map<String, Object>> records, List<T> rows,
|
||||
java.util.function.Function<List<T>, Map<String, Object>> mapper) {
|
||||
if (rows != null && !rows.isEmpty()) {
|
||||
records.add(mapper.apply(rows));
|
||||
}
|
||||
}
|
||||
|
||||
/** 取代表当前待办环节的行:优先 schedule_state=1 的真待办行,否则取首行(已办/无需待办)。 */
|
||||
private static <T> T pickPending(List<T> rows, boolean onlyTodo) {
|
||||
if (onlyTodo) {
|
||||
return rows.get(0);
|
||||
}
|
||||
return rows.stream()
|
||||
.filter(row -> currentScheduleState(row) == 1)
|
||||
.findFirst()
|
||||
.orElse(rows.get(0));
|
||||
}
|
||||
|
||||
private static int currentScheduleState(Object row) {
|
||||
if (row instanceof WarehouseOutProcess out) {
|
||||
return out.getScheduleState() == null ? -1 : out.getScheduleState();
|
||||
}
|
||||
if (row instanceof WarehouseInProcess in) {
|
||||
return in.getScheduleState() == null ? -1 : in.getScheduleState();
|
||||
}
|
||||
WarehouseAllocationProcess allocation = (WarehouseAllocationProcess) row;
|
||||
return allocation.getScheduleState() == null ? -1 : allocation.getScheduleState();
|
||||
}
|
||||
|
||||
private Map<String, Object> outRecord(WarehouseOutProcess row) {
|
||||
Map<String, Object> record = baseRecord("out", "出库单", row.getWarehouseOutId(),
|
||||
row.getOutOrderNo(), row.getOrderState(), text(OUT_ORDER_STATE_TEXT, row.getOrderState()),
|
||||
row.getScheduleState(), row.getOperationType(),
|
||||
text(OUT_OPERATION_TYPE_TEXT, row.getOperationType()),
|
||||
row.getWarehouseName(), row.getAgentId(), row.getAgentName(),
|
||||
row.getApplyUserId(), row.getApplyUserName(), row.getApplyDate());
|
||||
record.put("allocationOrderNo", row.getAllocationOrderNo());
|
||||
record.put("mallOrderNo", row.getMallOrderNo());
|
||||
if (StringUtils.hasText(row.getAllocationOrderNo())) {
|
||||
record.put("derivedFrom", "allocation");
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
private Map<String, Object> inRecord(WarehouseInProcess row) {
|
||||
Map<String, Object> record = baseRecord("in", "入库单", row.getWarehouseInId(),
|
||||
row.getInOrderNo(), row.getOrderState(), text(IN_ORDER_STATE_TEXT, row.getOrderState()),
|
||||
row.getScheduleState(), row.getOperationType(),
|
||||
text(IN_OPERATION_TYPE_TEXT, row.getOperationType()),
|
||||
row.getWarehouseName(), row.getAgentId(), row.getAgentName(),
|
||||
row.getApplyUserId(), row.getApplyUserName(), row.getApplyDate());
|
||||
record.put("allocationOrderNo", row.getAllocationOrderNo());
|
||||
record.put("outOrderNo", row.getOutOrderNo());
|
||||
record.put("mallOrderNo", row.getMallOrderNo());
|
||||
if (StringUtils.hasText(row.getAllocationOrderNo())) {
|
||||
record.put("derivedFrom", "allocation");
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
private Map<String, Object> allocationRecord(WarehouseAllocationProcess row) {
|
||||
Map<String, Object> record = baseRecord("allocation",
|
||||
text(OP_TYPE_TEXT, row.getOpType()) + "单", row.getWarehouseAllocationId(),
|
||||
row.getAllocationOrderNo(), row.getOrderState(),
|
||||
text(ALLOCATION_ORDER_STATE_TEXT, row.getOrderState()),
|
||||
row.getScheduleState(), row.getOperationType(),
|
||||
text(ALLOCATION_OPERATION_TYPE_TEXT, row.getOperationType()),
|
||||
null, row.getAgentId(), row.getAgentName(),
|
||||
row.getApplyUserId(), row.getApplyUserName(), row.getApplyDate());
|
||||
record.put("opType", row.getOpType());
|
||||
record.put("opTypeText", text(OP_TYPE_TEXT, row.getOpType()));
|
||||
record.put("warehouseOutName", row.getWarehouseOutName());
|
||||
record.put("warehouseInName", row.getWarehouseInName());
|
||||
record.put("allocationCount", row.getAllocationCount());
|
||||
record.put("allocationDate", row.getAllocationDate() == null ? null : row.getAllocationDate().toString());
|
||||
record.put("nextUserName", row.getNextUserName());
|
||||
record.put("inUserName", row.getInUserName());
|
||||
record.put("inTime", formatTime(row.getInTime()));
|
||||
record.put("outUserName", row.getOutUserName());
|
||||
record.put("outTime", formatTime(row.getOutTime()));
|
||||
return record;
|
||||
}
|
||||
|
||||
private Map<String, Object> baseRecord(String docType, String docTypeText, Long docId, String docNo,
|
||||
Integer orderState, String orderStateText,
|
||||
Integer scheduleState, Integer operationType, String operationTypeText,
|
||||
String warehouseName, Long agentId, String agentName,
|
||||
Long applyUserId, String applyUserName, Date applyDate) {
|
||||
Map<String, Object> record = new LinkedHashMap<>();
|
||||
record.put("docType", docType);
|
||||
record.put("docTypeText", docTypeText);
|
||||
record.put("docId", docId);
|
||||
record.put("docNo", docNo);
|
||||
record.put("orderState", orderState);
|
||||
record.put("orderStateText", orderStateText);
|
||||
record.put("scheduleState", scheduleState);
|
||||
record.put("scheduleStateText", text(SCHEDULE_STATE_TEXT, scheduleState));
|
||||
record.put("operationType", operationType);
|
||||
record.put("operationTypeText", operationTypeText);
|
||||
record.put("warehouseName", warehouseName);
|
||||
record.put("agentId", agentId);
|
||||
record.put("agentName", agentName);
|
||||
record.put("applyUserId", applyUserId);
|
||||
record.put("applyUserName", applyUserName);
|
||||
record.put("applyDate", formatTime(applyDate));
|
||||
return record;
|
||||
}
|
||||
|
||||
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 record TodoStub(String docType, Long docId, Date applyDate) {
|
||||
private TodoStub {
|
||||
Objects.requireNonNull(docType);
|
||||
Objects.requireNonNull(docId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
@@ -178,10 +182,15 @@ class SyncEntitiesTest {
|
||||
contract.setId(9L);
|
||||
contract.setContractNo("452542C001");
|
||||
contract.setContractName("望京SOHO厕纸服务合同");
|
||||
contract.setPropertyName("望京SOHO");
|
||||
contract.setCustomerNo("452542");
|
||||
contract.setCustomerName("金蝶客户名称");
|
||||
contract.setContractStatus(2);
|
||||
contract.setPropertyId(1L);
|
||||
contract.setSaleId(1001L);
|
||||
contract.setSaleName("张销售");
|
||||
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 +207,54 @@ 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("望京SOHO", rows.get(0).item().get("property_name"));
|
||||
assertEquals("452542", rows.get(0).item().get("customer_no"));
|
||||
assertEquals("金蝶客户名称", rows.get(0).item().get("customer_name"));
|
||||
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("张销售", rows.get(0).item().get("owner_name"));
|
||||
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,231 @@
|
||||
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.WarehouseAllocationProcess;
|
||||
import me.iiv.agent.entity.m2.WarehouseInProcess;
|
||||
import me.iiv.agent.entity.m2.WarehouseOutProcess;
|
||||
import me.iiv.agent.mapper.m2.WarehouseAllocationProcessMapper;
|
||||
import me.iiv.agent.mapper.m2.WarehouseInProcessMapper;
|
||||
import me.iiv.agent.mapper.m2.WarehouseOutProcessMapper;
|
||||
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.time.ZoneId;
|
||||
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.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.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class WarehouseTodoToolsTest {
|
||||
|
||||
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
|
||||
|
||||
@Mock WarehouseOutProcessMapper outProcessMapper;
|
||||
@Mock WarehouseInProcessMapper inProcessMapper;
|
||||
@Mock WarehouseAllocationProcessMapper allocationProcessMapper;
|
||||
|
||||
WarehouseTodoTools tools;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
|
||||
TableInfoHelper.initTableInfo(assistant, WarehouseOutProcess.class);
|
||||
TableInfoHelper.initTableInfo(assistant, WarehouseInProcess.class);
|
||||
TableInfoHelper.initTableInfo(assistant, WarehouseAllocationProcess.class);
|
||||
tools = new WarehouseTodoTools(outProcessMapper, inProcessMapper, allocationProcessMapper);
|
||||
User user = new User();
|
||||
user.setId(7L);
|
||||
AuthContextHolder.set(new AuthContext(null, user));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
AuthContextHolder.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void listMergesAllDocTypesSortsByApplyDateAndMarksDerivedDocs() {
|
||||
// 第一阶段(去重 id + applyDate)与第二阶段(整行组装)共用 mock,按调用顺序返回
|
||||
WarehouseOutProcess outStub1 = new WarehouseOutProcess();
|
||||
outStub1.setWarehouseOutId(20L);
|
||||
outStub1.setApplyDate(date(2026, 9, 10));
|
||||
WarehouseOutProcess outStub2 = new WarehouseOutProcess();
|
||||
outStub2.setWarehouseOutId(21L);
|
||||
outStub2.setApplyDate(date(2026, 9, 14));
|
||||
WarehouseInProcess inStub = new WarehouseInProcess();
|
||||
inStub.setWarehouseInId(30L);
|
||||
inStub.setApplyDate(date(2026, 9, 15));
|
||||
WarehouseAllocationProcess allocationStub = new WarehouseAllocationProcess();
|
||||
allocationStub.setWarehouseAllocationId(40L);
|
||||
allocationStub.setApplyDate(date(2026, 9, 12));
|
||||
when(outProcessMapper.selectList(any())).thenReturn(List.of(outStub1, outStub2), List.of(outRow(20L, null), outRow(21L, "DB-001")));
|
||||
when(inProcessMapper.selectList(any())).thenReturn(List.of(inStub), List.of(inRow(30L)));
|
||||
when(allocationProcessMapper.selectList(any())).thenReturn(List.of(allocationStub), List.of(allocationRow(40L)));
|
||||
|
||||
Map<String, Object> result = tools.warehouseTodoListQuery("all", null, null, null, 1, 500);
|
||||
|
||||
assertEquals(4, result.get("total"));
|
||||
assertEquals(1, result.get("page"));
|
||||
assertEquals(100, result.get("rows"));
|
||||
assertEquals("rows 超过上限 100,已截断为 100", result.get("notice"));
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> records = (List<Map<String, Object>>) result.get("records");
|
||||
assertEquals(4, records.size());
|
||||
// 申请时间倒序:in(9/15) → out(9/14) → allocation(9/12) → out(9/10)
|
||||
assertEquals("in", records.get(0).get("docType"));
|
||||
assertEquals(30L, records.get(0).get("docId"));
|
||||
assertEquals("待入库", records.get(0).get("orderStateText"));
|
||||
assertEquals("待办", records.get(0).get("scheduleStateText"));
|
||||
assertEquals("确认入库", records.get(0).get("operationTypeText"));
|
||||
assertNull(records.get(0).get("derivedFrom"));
|
||||
|
||||
assertEquals("out", records.get(1).get("docType"));
|
||||
assertEquals(21L, records.get(1).get("docId"));
|
||||
assertEquals("DB-001", records.get(1).get("allocationOrderNo"));
|
||||
assertEquals("allocation", records.get(1).get("derivedFrom"));
|
||||
|
||||
assertEquals("allocation", records.get(2).get("docType"));
|
||||
assertEquals("采购单", records.get(2).get("docTypeText"));
|
||||
assertEquals(1, records.get(2).get("opType"));
|
||||
assertEquals("待审核", records.get(2).get("orderStateText"));
|
||||
|
||||
assertEquals("out", records.get(3).get("docType"));
|
||||
assertEquals(20L, records.get(3).get("docId"));
|
||||
assertNull(records.get(3).get("derivedFrom"));
|
||||
verify(outProcessMapper, times(2)).selectList(any());
|
||||
verify(inProcessMapper, times(2)).selectList(any());
|
||||
verify(allocationProcessMapper, times(2)).selectList(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void todoOnlyFalsePrefersPendingRowAmongParticipatedDocs() {
|
||||
WarehouseOutProcess stub = new WarehouseOutProcess();
|
||||
stub.setWarehouseOutId(20L);
|
||||
stub.setApplyDate(date(2026, 9, 10));
|
||||
WarehouseOutProcess doneRow = outRow(20L, null);
|
||||
doneRow.setScheduleState(2);
|
||||
doneRow.setOperationType(0);
|
||||
WarehouseOutProcess pendingRow = outRow(20L, null);
|
||||
pendingRow.setScheduleState(1);
|
||||
pendingRow.setOperationType(1);
|
||||
when(outProcessMapper.selectList(any())).thenReturn(List.of(stub), List.of(doneRow, pendingRow));
|
||||
|
||||
Map<String, Object> result = tools.warehouseTodoListQuery("out", false, null, null, 1, 20);
|
||||
|
||||
assertEquals(1, result.get("total"));
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> records = (List<Map<String, Object>>) result.get("records");
|
||||
assertEquals(1, records.size());
|
||||
assertEquals(1, records.get(0).get("scheduleState"));
|
||||
assertEquals("待办", records.get(0).get("scheduleStateText"));
|
||||
assertEquals(1, records.get(0).get("operationType"));
|
||||
assertEquals("确认出库", records.get(0).get("operationTypeText"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listRejectsInvalidDocType() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> tools.warehouseTodoListQuery("transfer", null, null, null, 1, 20));
|
||||
verify(outProcessMapper, times(0)).selectList(any());
|
||||
verify(inProcessMapper, times(0)).selectList(any());
|
||||
verify(allocationProcessMapper, times(0)).selectList(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void springAiPublishesTodoListToolSchema() {
|
||||
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(1, schemas.size());
|
||||
assertTrue(schemas.get("warehouse.todo.list.query").contains("docType"));
|
||||
assertTrue(schemas.get("warehouse.todo.list.query").contains("todoOnly"));
|
||||
assertTrue(schemas.get("warehouse.todo.list.query").contains("orderNo"));
|
||||
}
|
||||
|
||||
private static WarehouseOutProcess outRow(Long docId, String allocationOrderNo) {
|
||||
WarehouseOutProcess row = new WarehouseOutProcess();
|
||||
row.setId(docId * 10 + 1);
|
||||
row.setSoftDelete(0);
|
||||
row.setWarehouseOutId(docId);
|
||||
row.setOutOrderNo("CK-" + docId);
|
||||
row.setAllocationOrderNo(allocationOrderNo);
|
||||
row.setUserId(7L);
|
||||
row.setScheduleState(1);
|
||||
row.setOperationType(1);
|
||||
row.setOrderState(2);
|
||||
row.setWarehouseName("一号仓");
|
||||
row.setAgentId(5L);
|
||||
row.setAgentName("代理商甲");
|
||||
row.setApplyUserId(7L);
|
||||
row.setApplyUserName("申请人");
|
||||
row.setApplyDate(date(2026, 9, 10));
|
||||
return row;
|
||||
}
|
||||
|
||||
private static WarehouseInProcess inRow(Long docId) {
|
||||
WarehouseInProcess row = new WarehouseInProcess();
|
||||
row.setId(docId * 10 + 1);
|
||||
row.setSoftDelete(0);
|
||||
row.setWarehouseInId(docId);
|
||||
row.setInOrderNo("RK-" + docId);
|
||||
row.setUserId(7L);
|
||||
row.setScheduleState(1);
|
||||
row.setOperationType(1);
|
||||
row.setOrderState(2);
|
||||
row.setWarehouseName("二号仓");
|
||||
row.setAgentId(5L);
|
||||
row.setAgentName("代理商甲");
|
||||
row.setApplyUserId(7L);
|
||||
row.setApplyUserName("申请人");
|
||||
row.setApplyDate(date(2026, 9, 15));
|
||||
return row;
|
||||
}
|
||||
|
||||
private static WarehouseAllocationProcess allocationRow(Long docId) {
|
||||
WarehouseAllocationProcess row = new WarehouseAllocationProcess();
|
||||
row.setId(docId * 10 + 1);
|
||||
row.setSoftDelete(0);
|
||||
row.setWarehouseAllocationId(docId);
|
||||
row.setAllocationOrderNo("DB-" + docId);
|
||||
row.setUserId(7L);
|
||||
row.setScheduleState(1);
|
||||
row.setOperationType(0);
|
||||
row.setOrderState(0);
|
||||
row.setOpType(1);
|
||||
row.setAgentId(5L);
|
||||
row.setAgentName("代理商甲");
|
||||
row.setApplyUserId(7L);
|
||||
row.setApplyUserName("申请人");
|
||||
row.setApplyDate(date(2026, 9, 12));
|
||||
row.setWarehouseOutName("一号仓");
|
||||
row.setWarehouseInName("二号仓");
|
||||
return row;
|
||||
}
|
||||
|
||||
private static Date date(int year, int month, int day) {
|
||||
return Date.from(LocalDate.of(year, month, day).atStartOfDay(BUSINESS_ZONE).toInstant());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user