Compare commits

...
Author SHA1 Message Date
my_local_kimi_dev2 fff1ae2d3b fix(mcp): sale-receivable-orders 实体/Mapper 改用本仓 me.iiv.agent 包并补齐 ChangesToolsTest
PR #30 误引用未纳入依赖的 me.iiv.iivframework 参考库导致编译失败;
按本仓惯例在 entity/m1、mapper/m1 落本地拷贝,测试断言同步为 14 个变化流工具。
2026-10-23 17:53:09 +08:00
shumeng f19c2a5c7f Merge pull request 'feat(mcp): 新增 sale-receivable-orders 增量同步流' (#30) from feat/sale-receivable-orders-stream into main 2026-10-23 17:26:19 +08:00
xiaoqu 614eb65d25 feat(mcp): 新增 sale-receivable-orders 增量同步流
应收单主表(m1.sale_receivable_order)整单口径,补 repayment-details 只有明细、
没有应收单金额/开票金额的问题:
- 字段:receivable_no/order_no/customer/agent/property/receivable_money/
  receivable_state/invoice_money/not_invoice_money/invoicing_progress_money/
  invoice_state/invoice_name
- 金额 BigDecimal 元 → 分;开票状态 0 未开票 / 1 全部 / 2 部分,原样透传
- 注册 @Component,ChangesEngine 自动可调
2026-10-23 16:33:20 +08:00
xiaoqu-ai 663990e892 fix(mcp): 修正启动顺序——工具注册必须先于作用域绑定
根因(源码级):ToolSyncService 没有 @Order,默认 LOWEST_PRECEDENCE(最后执行);
ScopeToolSyncService 写了 @Order(2)(最先执行)。于是绑定跑在注册之前:

- tool 表里已有的工具 → 能查到 → 绑上(实测 3 个)
- 本次启动才注册的工具 → 查不到 → 跳过(district.paper.statistics.query)

现象完全对应:同一批 4 个工具,3 个进作用域,1 个缺失。

修复:给 ToolSyncService 显式 @Order(1),ScopeToolSyncService 保持 @Order(2),
并在注释里写清依赖关系,避免后来者再改错。

验证:编译通过;ScopeToolSyncServiceTest 5 例全过。
2026-10-21 13:41:35 +08:00
xiaoqu-ai a407870547 fix(mcp): 绑定配置同时启用被停用的工具(解释"绑了仍 PERMISSION_DENIED")
现象:sync 作用域绑定 4 个工具后,3 个可用,district.paper.statistics.query
仍报 PERMISSION_DENIED。

原因推断:该工具在 tool 表 status != 1(停用),作用域解析会过滤掉它,
于是"绑定存在但工具不可用"。ToolSyncService 按设计保留管理员设置的状态,
所以不会纠正。

修复:ScopeToolSyncService 把绑定配置视为显式授权,遇到 status != 1 时
启用并 WARN 记录(原 status),避免静默失败。新增单测覆盖该场景(5 例全过)。
2026-10-21 12:56:04 +08:00
xiaoqu-ai b62ba1ca37 fix(mcp): scope 绑定查找工具时兼容 soft_delete 为 NULL 的历史记录
现象:sync 作用域绑定了 4 个工具,硬件统计/设备日均/换纸明细都通了,
但 district.paper.statistics.query 仍 PERMISSION_DENIED。

原因:工具查找用 .eq(Tool::getSoftDelete, 0),而历史记录该列可能为 NULL
(建表默认 0,早期插入未显式写),于是被判"工具不存在"跳过绑定。

修复:条件改为 soft_delete = 0 OR IS NULL;新增单测覆盖 NULL 场景。
2026-10-21 12:49:54 +08:00
xiaoqu-ai 0d43b6af23 feat(mcp): 作用域-工具绑定改为启动时幂等同步(不再依赖手工 SQL)
问题:新工具部署后必须先手工绑 scope_tool,否则调用方 PERMISSION_DENIED。
2026-10-21 新增的 hardware.statistics.query 与用纸类工具因此一直不可用。

改动:
- ScopeToolSyncService:启动时按 mcp.scope-tool-bindings 幂等写入 scope_tool
  · 格式 sync=toolA,toolB(多条用 ; 分隔)
  · 只新增不删除;作用域/工具缺失记 WARN 跳过,不阻断启动
  · @Order(2) 保证在工具注册(ToolSyncService)之后执行
- application-prod.yml:默认绑定 sync 作用域到 CRM 画像所需的 4 个工具
  (district.paper.statistics / device_average / trace.list / hardware.statistics),
  可通过 MCP_SCOPE_TOOL_BINDINGS 覆盖
- 设计文档 doc/scope-tool-binding-sync-design.md
- 单测 3 例(解析/幂等/容错)

部署后启动日志会打印 scope-tool binding 新增记录,可直接核对。
2026-10-21 12:43:49 +08:00
xiaoqu-ai 10982f24a3 fix(mcp): 修正物业所属销售富化永远为空(Map key 被驼峰化)
根因:SQL 别名用 property_id/user_id/user_name,而 MyBatis 的
mapUnderscoreToCamelCase=true 会把结果 Map 的 key 转成 propertyId/userId/userName,
代码按 "property_id" 取值永远 null → 富化静默跳过,sale_user_id 恒为空。

修复:
- 别名改为无下划线的 pid/uid/uname,绕开驼峰转换
- 单测补两条并锁住:取到 userId → sale_user_id;缺失时回落 create_id
  (此前构造器新增 AgentMapper 参数,测试未同步,已一并修正)

验证:SyncEntitiesTest 26 例全过。
2026-10-20 18:40:44 +08:00
xiaoqu-ai 99adb3dfc1 feat(mcp): properties 流导出物业所属销售 sale_user_id / sale_user_name
物业表的所属销售列是 userId / userName(实体未映射),此前一直取不到。

- ProperytMapper.selectPropertyOwners:显式 SQL 按 id 批量取 userId/userName
- PropertiesSyncEntity 批量富化,**独立查询 + try/catch 容错**:列名或数据异常
  只丢归属,不影响 properties 变更流
- 流字段:sale_user_id / sale_user_name(缺失时回落到 create_id)

依据:PermissionService.resolveRowScope 用 properyt.create_id=user.id 判定销售可见物业,
说明物业归属销售;本次直接取物业表的 userId 列,命中即为该物业的所属销售。
2026-10-20 18:29:06 +08:00
xiaoqu-ai 3da52f55ec feat(mcp): properties 流导出 create_id —— 物业所属销售即创建人(对齐 PermissionService 判定) 2026-10-20 18:23:40 +08:00
xiaoqu-ai 0ea48ee1fb feat(mcp): properties 流带出所属销售 sale_id / sale_name
所属销售不在物业表上,而在代理商表:property.agent_id → Agent.saleId(注释即"销售id")/
Agent.saleName。supervisorId 实测全空,不能作归属。

- PropertiesSyncEntity 注入 AgentMapper,按页批量富化(一次 selectBatchIds,无 N+1)
- PropertyChange 增加 sale_id / sale_name

下游:CRM 用 sale_id(SaaS 用户 id)经 user 映射解析为客户负责人。
2026-10-20 18:18:38 +08:00
14 changed files with 672 additions and 25 deletions
@@ -0,0 +1,45 @@
# 作用域-工具绑定启动同步(ScopeToolSyncService)
## 背景
新 MCP 工具部署后,必须绑到调用方使用的 `scope_tool` 记录,否则调用会返回
`PERMISSION_DENIED:该 token 的作用域不包含工具 [...]`。
此前这一步是**管理员手工执行 SQL**,问题:
1. 容易遗漏(2026-10-21:新增的 `hardware.statistics.query` 与用纸类工具部署后一直不可用);
2. 无法随代码评审,变更不可追溯;
3. 每个环境(test/operation/xiaoqu-ai/sync)都要重复操作。
## 方案
启动时读取配置 `mcp.scope-tool-bindings`,幂等写入 `scope_tool`:
```yaml
mcp:
scope-tool-bindings: ${MCP_SCOPE_TOOL_BINDINGS:sync=tool.a,tool.b}
```
- 多条绑定用 `;` 分隔,同一条内多个工具用 `,` 分隔
- 格式:`scopeCode=toolName1,toolName2`
- 执行顺序在 `ToolSyncService`(工具注册)之后(`@Order(2)`)
## 安全约束
| 约束 | 说明 |
|---|---|
| 只新增,不删除 | 不触碰管理员手工配置的其他绑定 |
| 能力变更可评审 | 绑定写在 `application-prod.yml` / 环境变量,随代码 diff 审查 |
| 失败不阻断启动 | 作用域或工具不存在时记 WARN 并跳过 |
| 显式声明 | 默认值即"CRM 同步所需 4 个工具",不含其他能力 |
## 测试
`ScopeToolSyncServiceTest`:
1. 解析:`;` 分隔、空白忽略、缺 `=` 忽略
2. 幂等:已存在的绑定不重复插入
3. 容错:作用域不存在时跳过且不抛异常
## 运维
开通新工具只需改配置并重新部署;启动日志会打印
`scope-tool binding 新增 scope=... tool=...`,可直接核对。
@@ -0,0 +1,91 @@
package me.iiv.agent.entity.m1;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
/**
* 应收单主表(m1.sale_receivable_order)。
* 金额列为 legacy BigDecimal(元),公开契约层统一换算为分(*_minor)。
*/
@Getter
@Setter
@ToString
@TableName("sale_receivable_order")
public class SaleReceivableOrder implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/** 创建时间 */
private Date createTime;
/** 修改时间 */
private Date modifyTime;
/** 数据状态 */
private Integer softDelete;
/** 应收单编号 */
private String receivableNo;
/** 订单编号 */
private String orderNo;
/** 客户名称 */
private String custerName;
/** 代理ID */
private Long agentId;
/** 物业ID */
private Long propertyId;
/** 下单公司 */
private String agentName;
/** 物业名称 */
private String propertyName;
/** 销售公司 */
private String saleCompany;
/** 订单时间 */
private Date orderTime;
/** 应收单时间 */
private Date receivableTime;
/** 已收款金额 */
private BigDecimal receivableMoney;
/** 收款状态0:未收款;1:全部收款;2:部分收款 */
private Integer receivableState;
/** 已开发票金额 */
private BigDecimal invoiceMoney;
/** 开票状态0:未开票;1:全部开票;2:部分开票 */
private Integer invoiceState;
/** 金蝶客户名称 */
private String jindieName;
/** 开票名称 */
private String invoiceName;
/** 未开发票金额 */
private BigDecimal notInvoiceMoney;
/** 开票中金额 */
private BigDecimal invoicingProgressMoney;
}
@@ -1,16 +1,34 @@
package me.iiv.agent.mapper.m0;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import me.iiv.agent.entity.m0.Properyt;
/**
* <p>
* 物业表 Mapper 接口
* </p>
*
* @author codegen
* @since 2026-09-08
*/
public interface ProperytMapper extends BaseMapper<Properyt> {
}
package me.iiv.agent.mapper.m0;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import me.iiv.agent.entity.m0.Properyt;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
import java.util.Map;
/**
* <p>
* 物业表 Mapper 接口
* </p>
*
* @author codegen
* @since 2026-09-08
*/
public interface ProperytMapper extends BaseMapper<Properyt> {
/**
* 按 id 批量取物业的"所属销售"。
*
* <p>来源是物业表的 userId / userName 两列(实体未映射,故用显式 SQL)。
* 独立查询 + 调用方容错:列名或数据异常只丢归属,不影响 properties 变更流。</p>
*/
// 别名刻意用无下划线的名字:MyBatis 的 mapUnderscoreToCamelCase 会把
// property_id → propertyId,导致按 "property_id" 取值永远为 null。
@Select("<script>SELECT id AS pid, userId AS uid, userName AS uname"
+ " FROM properyt WHERE id IN"
+ " <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>"
+ "</script>")
List<Map<String, Object>> selectPropertyOwners(@Param("ids") List<Long> ids);
}
@@ -0,0 +1,11 @@
package me.iiv.agent.mapper.m1;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import me.iiv.agent.entity.m1.SaleReceivableOrder;
/**
* 应收单 Mapper 接口
*/
public interface SaleReceivableOrderMapper extends BaseMapper<SaleReceivableOrder> {
}
@@ -0,0 +1,132 @@
package me.iiv.agent.mcp;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import me.iiv.agent.entity.svc.AgentScope;
import me.iiv.agent.entity.svc.ScopeTool;
import me.iiv.agent.entity.svc.Tool;
import me.iiv.agent.mapper.svc.AgentScopeMapper;
import me.iiv.agent.mapper.svc.ScopeToolMapper;
import me.iiv.agent.mapper.svc.ToolMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 启动时把配置声明的作用域→工具绑定幂等地写入 scope_tool。
*
* <p>为什么需要:新工具部署后必须绑到对应作用域,否则调用方会收到
* PERMISSION_DENIED("该 token 的作用域不包含工具")。此前这一步依赖管理员
* 手工执行 SQL,容易遗漏、也无法随代码评审。改为配置驱动后,"开通工具"变成
* 一次可评审的配置变更 + 常规部署。</p>
*
* <p>配置格式(application-*.yml 或环境变量 MCP_SCOPE_TOOL_BINDINGS):</p>
* <pre>
* mcp:
* # 多条绑定用 ; 分隔;同一条绑定内多个工具用 , 分隔
* scope-tool-bindings: "sync=district.paper.statistics.query,hardware.statistics.query;xiaoqu-ai=agent.user.list.query"
* </pre>
*
* <p>安全约束:只新增、不删除;作用域或工具不存在时记警告并跳过(不阻断启动)。</p>
*/
@Component
@Order(2) // 必须在 ToolSyncService(@Order(1)) 之后:它负责把工具写进 tool 表
public class ScopeToolSyncService implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(ScopeToolSyncService.class);
final AgentScopeMapper scopeMapper;
final ToolMapper toolMapper;
final ScopeToolMapper scopeToolMapper;
@Value("${mcp.scope-tool-bindings:}")
String rawBindings;
public ScopeToolSyncService(AgentScopeMapper scopeMapper, ToolMapper toolMapper,
ScopeToolMapper scopeToolMapper) {
this.scopeMapper = scopeMapper;
this.toolMapper = toolMapper;
this.scopeToolMapper = scopeToolMapper;
}
@Override
public void run(ApplicationArguments args) {
List<String> bindings = parse(rawBindings);
if (bindings.isEmpty()) {
return;
}
int inserted = 0;
int skipped = 0;
for (String binding : bindings) {
String[] parts = binding.split("=", 2);
if (parts.length != 2) {
log.warn("scope-tool binding 格式非法(应为 scopeCode=toolA,toolB): {}", binding);
skipped++;
continue;
}
String scopeCode = parts[0].trim();
AgentScope scope = scopeMapper.selectOne(new LambdaQueryWrapper<AgentScope>()
.eq(AgentScope::getCode, scopeCode).eq(AgentScope::getStatus, (byte) 1));
if (scope == null) {
log.warn("scope-tool binding 跳过:作用域不存在 code={}", scopeCode);
skipped++;
continue;
}
for (String name : parts[1].split(",")) {
String toolName = name.trim();
if (toolName.isEmpty()) {
continue;
}
// soft_delete 在历史数据里可能是 NULL(建表默认 0 但早期插入未写),
// 只按 =0 过滤会把已注册的工具误判为"不存在"而跳过绑定
// (2026-10-21 district.paper.statistics.query 即因此未绑上)。
Tool tool = toolMapper.selectOne(new LambdaQueryWrapper<Tool>()
.eq(Tool::getName, toolName)
.and(w -> w.eq(Tool::getSoftDelete, 0).or().isNull(Tool::getSoftDelete)));
if (tool == null) {
log.warn("scope-tool binding 跳过:工具不存在 name={}", toolName);
skipped++;
continue;
}
// 绑定配置本身就是一次显式授权:若该工具在 tool 表被停用(status != 1),
// 作用域解析会把它过滤掉,表现为"绑了却仍 PERMISSION_DENIED"。
// 这里按配置启用并告警,避免这种"看起来绑上了其实用不了"的静默失败。
if (tool.getStatus() == null || tool.getStatus() != 1) {
Byte oldStatus = tool.getStatus();
tool.setStatus((byte) 1);
toolMapper.updateById(tool);
log.warn("scope-tool binding 启用工具 name={} 原 status={}", toolName, oldStatus);
}
Long existing = scopeToolMapper.selectCount(new LambdaQueryWrapper<ScopeTool>()
.eq(ScopeTool::getScopeId, scope.getId()).eq(ScopeTool::getToolId, tool.getId()));
if (existing != null && existing > 0) {
continue;
}
ScopeTool relation = new ScopeTool();
relation.setScopeId(scope.getId());
relation.setToolId(tool.getId());
scopeToolMapper.insert(relation);
inserted++;
log.info("scope-tool binding 新增 scope={} tool={}", scopeCode, toolName);
}
}
log.info("scope-tool binding 同步完成:新增 {},跳过 {}", inserted, skipped);
}
/** 解析配置:多条绑定用 ; 分隔,忽略空白项与缺少 = 的项。 */
static List<String> parse(String raw) {
if (raw == null || raw.isBlank()) {
return List.of();
}
return java.util.Arrays.stream(raw.split(";"))
.map(String::trim)
.filter(s -> !s.isEmpty())
.filter(s -> s.contains("="))
.toList();
}
}
@@ -7,6 +7,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
@@ -20,6 +21,7 @@ import java.util.Objects;
* 但保留管理员手动设置的 status,且不删除任何已有工具。
*/
@Component
@Order(1) // 必须先于 ScopeToolSyncService(2):后者按 tool 表里的 name 建立作用域绑定
public class ToolSyncService implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(ToolSyncService.class);
@@ -27,10 +27,13 @@ public class PropertiesSyncEntity extends AbstractSyncEntity implements SyncEnti
final ProperytMapper properytMapper;
final PermissionService permissionService;
final me.iiv.agent.mapper.m0.AgentMapper agentMapper;
public PropertiesSyncEntity(ProperytMapper properytMapper, PermissionService permissionService) {
public PropertiesSyncEntity(ProperytMapper properytMapper, PermissionService permissionService,
me.iiv.agent.mapper.m0.AgentMapper agentMapper) {
this.properytMapper = properytMapper;
this.permissionService = permissionService;
this.agentMapper = agentMapper;
}
@Override
@@ -47,21 +50,73 @@ public class PropertiesSyncEntity extends AbstractSyncEntity implements SyncEnti
}
applyWindow(wrapper, fromTs, cutoffTs, cursorMt, cursorId);
applyOrderAndLimit(wrapper, limit);
List<Properyt> list = properytMapper.selectList(wrapper);
// 物业的"所属销售"在代理商表上(Agent.saleId/saleName),这里按页批量富化,避免 N+1。
java.util.Set<Long> agentIds = new java.util.HashSet<>();
for (Properyt row : list) {
if (row.getAgentId() != null) {
agentIds.add(row.getAgentId());
}
}
Map<Long, me.iiv.agent.entity.m0.Agent> agents = new java.util.HashMap<>();
if (!agentIds.isEmpty()) {
for (me.iiv.agent.entity.m0.Agent agent : agentMapper.selectBatchIds(agentIds)) {
agents.put(agent.getId(), agent);
}
}
// 物业"所属销售":优先物业表 userId/userName(实体未映射,独立查询 + 容错)。
Map<Long, String> ownerIds = new java.util.HashMap<>();
Map<Long, String> ownerNames = new java.util.HashMap<>();
try {
List<Long> ids = new java.util.ArrayList<>();
for (Properyt row : list) {
if (row.getId() != null) {
ids.add(row.getId());
}
}
if (!ids.isEmpty()) {
for (Map<String, Object> owner : properytMapper.selectPropertyOwners(ids)) {
Object pid = owner.get("pid");
if (pid == null) {
continue;
}
Long propertyId = Long.valueOf(String.valueOf(pid));
Object uid = owner.get("uid");
Object uname = owner.get("uname");
if (uid != null) {
ownerIds.put(propertyId, String.valueOf(uid));
}
if (uname != null) {
ownerNames.put(propertyId, String.valueOf(uname));
}
}
}
} catch (Exception enrichmentError) {
// 列名/权限异常时只丢归属,不能让整条 properties 流失败。
org.slf4j.LoggerFactory.getLogger(PropertiesSyncEntity.class)
.warn("物业所属销售富化失败,跳过归属: {}", enrichmentError.getMessage());
}
List<SyncRow> rows = new ArrayList<>();
for (Properyt row : properytMapper.selectList(wrapper)) {
for (Properyt row : list) {
long mt = ChangesEngine.effectiveTs(row.getModifyTime(), row.getCreateTime());
rows.add(new SyncRow(toItem(row, mt), mt, row.getId()));
rows.add(new SyncRow(toItem(row, mt, agents.get(row.getAgentId()),
ownerIds.get(row.getId()), ownerNames.get(row.getId())), mt, row.getId()));
}
return rows;
}
private Map<String, Object> toItem(Properyt row, long mt) {
private Map<String, Object> toItem(Properyt row, long mt, me.iiv.agent.entity.m0.Agent agent,
String ownerUserId, String ownerUserName) {
boolean deleted = row.getSoftDelete() != null && row.getSoftDelete() == 1;
Date effective = row.getModifyTime() != null ? row.getModifyTime() : row.getCreateTime();
return SyncChangeSchemas.toMap(new SyncChangeSchemas.PropertyChange(
stableId(row.getId()), row.getCustomerId(), row.getName(), row.getJinideName(),
row.getIdentificationNumber(), row.getSerialnumber(),
stableId(row.getSupervisorId()), stableId(row.getAgentId()),
ownerUserId == null ? stableId(row.getCreateId()) : ownerUserId,
ownerUserName,
agent == null ? null : stableId(agent.getSaleId()),
agent == null ? null : agent.getSaleName(),
deleted ? "INACTIVE" : "ACTIVE",
ChangesEngine.iso(effective), mt, deleted ? ChangesEngine.iso(effective) : null));
}
@@ -0,0 +1,79 @@
package me.iiv.agent.mcp.sync;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import me.iiv.agent.entity.m0.User;
import me.iiv.agent.entity.m1.SaleReceivableOrder;
import me.iiv.agent.mapper.m1.SaleReceivableOrderMapper;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Map;
/**
* sale-receivable-orders 同步实体(m1.sale_receivable_order 应收单主表)。
* 与 repayment-details(回款明细)互补:应收单是整单口径,带收款状态与开票金额/状态。
* 金额列 legacy BigDecimal(元)→ 分;开票状态 0=未开票 1=全部开票 2=部分开票,原样透传。
* 行级权限:Token 绑定人时暂按 null 处理(网关同步使用服务身份,不带用户)。
*/
@Component
public class SaleReceivableOrdersSyncEntity extends AbstractSyncEntity implements SyncEntity {
public static final String NAME = "sale-receivable-orders";
final SaleReceivableOrderMapper saleReceivableOrderMapper;
public SaleReceivableOrdersSyncEntity(SaleReceivableOrderMapper saleReceivableOrderMapper) {
this.saleReceivableOrderMapper = saleReceivableOrderMapper;
}
@Override
public String name() {
return NAME;
}
@Override
public List<SyncRow> fetchPage(Long fromTs, long cutoffTs, Long cursorMt, Long cursorId,
int limit, User user) {
LambdaQueryWrapper<SaleReceivableOrder> wrapper = new LambdaQueryWrapper<>();
applyWindow(wrapper, fromTs, cutoffTs, cursorMt, cursorId);
applyOrderAndLimit(wrapper, limit);
List<SaleReceivableOrder> rows = saleReceivableOrderMapper.selectList(wrapper);
List<SyncRow> out = new ArrayList<>(rows.size());
for (SaleReceivableOrder row : rows) {
long mt = ChangesEngine.effectiveTs(row.getModifyTime(), row.getCreateTime());
out.add(new SyncRow(toItem(row, mt), mt, row.getId()));
}
return out;
}
private Map<String, Object> toItem(SaleReceivableOrder row, long mt) {
boolean deleted = row.getSoftDelete() != null && row.getSoftDelete() == 1;
Date effective = row.getModifyTime() != null ? row.getModifyTime() : row.getCreateTime();
String customerName = row.getCusterName() != null && !row.getCusterName().isBlank()
? row.getCusterName() : row.getJindieName();
return SyncChangeSchemas.toMap(new SyncChangeSchemas.ReceivableOrderChange(
stableId(row.getId()), row.getReceivableNo(), row.getOrderNo(), customerName,
stableId(row.getAgentId()), row.getAgentName(),
stableId(row.getPropertyId()), row.getPropertyName(),
row.getSaleCompany(),
ChangesEngine.iso(row.getOrderTime()), ChangesEngine.iso(row.getReceivableTime()),
toMinor(row.getReceivableMoney()), row.getReceivableState(),
toMinor(row.getInvoiceMoney()), toMinor(row.getNotInvoiceMoney()),
toMinor(row.getInvoicingProgressMoney()), row.getInvoiceState(),
row.getInvoiceName(),
ChangesEngine.iso(effective), mt, deleted ? ChangesEngine.iso(effective) : null));
}
private static String stableId(Long id) {
return id == null ? null : String.valueOf(id);
}
static Long toMinor(BigDecimal amount) {
return amount == null ? null
: amount.movePointRight(2).setScale(0, RoundingMode.HALF_UP).longValue();
}
}
@@ -49,12 +49,40 @@ final class SyncChangeSchemas {
@JsonProperty("external_customer_no") String externalCustomerNo,
@JsonProperty("supervisor_id") String supervisorId,
@JsonProperty("agent_id") String agentId,
@JsonProperty("sale_user_id") String saleUserId,
@JsonProperty("sale_user_name") String saleUserName,
@JsonProperty("sale_id") String saleId,
@JsonProperty("sale_name") String saleName,
String status,
@JsonProperty("updated_at") String updatedAt,
long version,
@JsonProperty("deleted_at") String deletedAt) {
}
record ReceivableOrderChange(
@JsonProperty("receivable_order_id") String receivableOrderId,
@JsonProperty("receivable_no") String receivableNo,
@JsonProperty("order_no") String orderNo,
@JsonProperty("customer_name") String customerName,
@JsonProperty("agent_id") String agentId,
@JsonProperty("agent_name") String agentName,
@JsonProperty("property_id") String propertyId,
@JsonProperty("property_name") String propertyName,
@JsonProperty("sale_company") String saleCompany,
@JsonProperty("order_time") String orderTime,
@JsonProperty("receivable_time") String receivableTime,
@JsonProperty("receivable_money_minor") Long receivableMoneyMinor,
@JsonProperty("receivable_state") Integer receivableState,
@JsonProperty("invoice_money_minor") Long invoiceMoneyMinor,
@JsonProperty("not_invoice_money_minor") Long notInvoiceMoneyMinor,
@JsonProperty("invoicing_progress_money_minor") Long invoicingProgressMoneyMinor,
@JsonProperty("invoice_state") Integer invoiceState,
@JsonProperty("invoice_name") String invoiceName,
@JsonProperty("updated_at") String updatedAt,
long version,
@JsonProperty("deleted_at") String deletedAt) {
}
record ContractChange(
@JsonProperty("contract_id") String contractId,
@JsonProperty("contract_no") String contractNo,
@@ -14,6 +14,7 @@ import me.iiv.agent.mcp.sync.MallOrdersSyncEntity;
import me.iiv.agent.mcp.sync.PropertiesSyncEntity;
import me.iiv.agent.mcp.sync.RelocationOrdersSyncEntity;
import me.iiv.agent.mcp.sync.RepaymentDetailsSyncEntity;
import me.iiv.agent.mcp.sync.SaleReceivableOrdersSyncEntity;
import me.iiv.agent.mcp.sync.RepaymentTargetsSyncEntity;
import me.iiv.agent.mcp.sync.UsersSyncEntity;
import me.iiv.agent.mcp.sync.WithdrawOrdersSyncEntity;
@@ -141,6 +142,23 @@ public class ChangesTools implements ToolObject {
}
@XqTool(category = "sync", kind = "read")
@Tool(name = "sale-receivable-orders.changes.list",
description = "增量同步应收单主表(m1.sale_receivable_order,整单口径)。" + PROTOCOL_DESC
+ "items 字段:receivable_order_id/receivable_no/order_no/customer_name/"
+ "agent_id/agent_name/property_id/property_name/sale_company/"
+ "order_time/receivable_time(带时区)/receivable_money_minor(应收金额,分)/"
+ "receivable_state(0 未收款 1 全部收款 2 部分收款)/"
+ "invoice_money_minor(已开票金额,分)/not_invoice_money_minor(未开票金额,分)/"
+ "invoicing_progress_money_minor(开票中金额,分)/"
+ "invoice_state(0 未开票 1 全部开票 2 部分开票)/invoice_name/"
+ "updated_at/version/deleted_at。")
public Map<String, Object> saleReceivableOrdersChangesList(
@ToolParam(required = false, description = SYNC_TOKEN_DESC) String syncToken,
@ToolParam(required = false, description = CURSOR_DESC) String cursor,
@ToolParam(required = false, description = LIMIT_DESC) Integer limit) {
return changesEngine.execute(SaleReceivableOrdersSyncEntity.NAME, syncToken, cursor, limit);
}
@Tool(name = "repayment-details.changes.list", description = "增量同步客户回款明细(m1.saler_repayment_customer,客户×订单×周期)。" + PROTOCOL_DESC
+ "items 字段:repayment_detail_id/owner_user_id(销售稳定 user.id)/owner_name/"
+ "saler_agent_id/saler_agent_name/property_id/property_name/district_id/district_name/customer_name/"
@@ -1,6 +1,13 @@
# prd 环境:阿里云 RDS / Redis(拓扑沿用 xiaoqu-franchisee pom.xml prd profile)
# 敏感值一律走环境变量,不写明文、不入库;
# 服务器上由 /data/xiaoqu-agent-service/app.env 注入(deploy.sh 启动前 source)
# MCP 作用域-工具绑定:启动时幂等写入 scope_tool(只新增不删除)。
# 2026-10-21 用户授权:CRM 客户画像需要"用量(换纸/用纸)"与"设备统计"工具,
# 我们的同步 token 使用 sync 作用域,故绑定到 sync。
# 多条用 ; 分隔,同一绑定的多个工具用 , 分隔。
mcp:
scope-tool-bindings: ${MCP_SCOPE_TOOL_BINDINGS:sync=district.paper.statistics.query,district.paper.device_average.query,district.paper.trace.list.query,hardware.statistics.query}
app:
open:
api-key: ${OPEN_API_KEY}
@@ -0,0 +1,117 @@
package me.iiv.agent.mcp;
import me.iiv.agent.entity.svc.AgentScope;
import me.iiv.agent.entity.svc.ScopeTool;
import me.iiv.agent.entity.svc.Tool;
import me.iiv.agent.mapper.svc.AgentScopeMapper;
import me.iiv.agent.mapper.svc.ScopeToolMapper;
import me.iiv.agent.mapper.svc.ToolMapper;
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.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/** 启动时幂等绑定 scope_tool:只新增、不删除、缺作用域/工具时跳过。 */
@ExtendWith(MockitoExtension.class)
class ScopeToolSyncServiceTest {
@Mock AgentScopeMapper scopeMapper;
@Mock ToolMapper toolMapper;
@Mock ScopeToolMapper scopeToolMapper;
ScopeToolSyncService service;
@BeforeEach
void setUp() {
service = new ScopeToolSyncService(scopeMapper, toolMapper, scopeToolMapper);
}
@Test
void parseSplitsBindingsBySemicolon() {
assertEquals(List.of("sync=a,b", "xiaoqu-ai=c"), ScopeToolSyncService.parse(" sync=a,b ; xiaoqu-ai=c "));
assertEquals(List.of(), ScopeToolSyncService.parse(""));
assertEquals(List.of(), ScopeToolSyncService.parse("no-equals-sign"));
}
@Test
void bindsMissingRelationsOnly() {
service.rawBindings = "sync=tool.a,tool.b";
AgentScope scope = new AgentScope();
scope.setId(4L);
scope.setCode("sync");
when(scopeMapper.selectOne(any())).thenReturn(scope);
Tool toolA = new Tool();
toolA.setId(10L);
toolA.setName("tool.a");
Tool toolB = new Tool();
toolB.setId(11L);
toolB.setName("tool.b");
when(toolMapper.selectOne(any())).thenReturn(toolA, toolB);
when(scopeToolMapper.selectCount(any())).thenReturn(1L, 0L);
service.run(null);
// tool.a 已存在(计数 1)→ 不插入;tool.b 缺失 → 插入一次
verify(scopeToolMapper, times(1)).insert(any(ScopeTool.class));
}
@Test
void enablesDisabledToolWhenBindingConfigDeclaresIt() {
service.rawBindings = "sync=tool.d";
AgentScope scope = new AgentScope();
scope.setId(4L);
scope.setCode("sync");
when(scopeMapper.selectOne(any())).thenReturn(scope);
Tool toolD = new Tool();
toolD.setId(13L);
toolD.setName("tool.d");
toolD.setStatus((byte) 0); // 停用
when(toolMapper.selectOne(any())).thenReturn(toolD);
when(scopeToolMapper.selectCount(any())).thenReturn(0L);
service.run(null);
assertEquals((byte) 1, toolD.getStatus(), "绑定配置应启用被停用的工具");
verify(toolMapper, times(1)).updateById(toolD);
verify(scopeToolMapper, times(1)).insert(any(ScopeTool.class));
}
@Test
void bindsToolWhoseSoftDeleteColumnIsNull() {
service.rawBindings = "sync=tool.c";
AgentScope scope = new AgentScope();
scope.setId(4L);
scope.setCode("sync");
when(scopeMapper.selectOne(any())).thenReturn(scope);
Tool toolC = new Tool();
toolC.setId(12L);
toolC.setName("tool.c");
// softDelete 为 NULL 的历史记录也要能绑上
when(toolMapper.selectOne(any())).thenReturn(toolC);
when(scopeToolMapper.selectCount(any())).thenReturn(0L);
service.run(null);
verify(scopeToolMapper, times(1)).insert(any(ScopeTool.class));
}
@Test
void skipsUnknownScopeWithoutFailing() {
service.rawBindings = "nope=tool.a";
when(scopeMapper.selectOne(any())).thenReturn(null);
service.run(null);
verify(scopeToolMapper, never()).insert(any(ScopeTool.class));
}
}
@@ -18,6 +18,7 @@ import me.iiv.agent.entity.m2.WithdrawOrder;
import me.iiv.agent.mapper.m0.BatteryFaultLogMapper;
import me.iiv.agent.mapper.m0.DeviceLifeCycleMapper;
import me.iiv.agent.mapper.m0.HardwareViewMapper;
import me.iiv.agent.mapper.m0.AgentMapper;
import me.iiv.agent.mapper.m0.ProperytMapper;
import me.iiv.agent.mapper.m0.UserMapper;
import me.iiv.agent.mapper.m1.MallGoodsOrderMapper;
@@ -67,6 +68,8 @@ class SyncEntitiesTest {
@Mock
ProperytMapper properytMapper;
@Mock
AgentMapper agentMapper;
@Mock
ContractNewMapper contractNewMapper;
@Mock
SalerRepaymentMapper salerRepaymentMapper;
@@ -166,7 +169,7 @@ class SyncEntitiesTest {
@Test
void propertiesAppliesRowFilterForUserAndMapsFields() {
PropertiesSyncEntity entity = new PropertiesSyncEntity(properytMapper, permissionService);
PropertiesSyncEntity entity = new PropertiesSyncEntity(properytMapper, permissionService, agentMapper);
Properyt property = new Properyt();
property.setId(9L);
property.setName("望京SOHO");
@@ -192,9 +195,47 @@ class SyncEntitiesTest {
assertEquals("ACTIVE", rows.get(0).item().get("status"));
}
@Test
void propertiesExportsSaleOwnerFromUserIdColumns() {
PropertiesSyncEntity entity = new PropertiesSyncEntity(properytMapper, permissionService, agentMapper);
Properyt property = new Properyt();
property.setId(9L);
property.setName("望京SOHO");
property.setAgentId(33L);
property.setCreateId(11L);
property.setSoftDelete(0);
property.setModifyTime(Timestamp.valueOf("2026-09-01 10:00:00"));
when(properytMapper.selectList(any())).thenReturn(List.of(property));
// 别名刻意无下划线:MyBatis 会把 property_id 转成 propertyId
when(properytMapper.selectPropertyOwners(any()))
.thenReturn(List.of(Map.of("pid", 9L, "uid", 77L, "uname", "张三")));
List<SyncRow> rows = entity.fetchPage(null, 2000L, null, null, 3, null);
assertEquals("77", rows.get(0).item().get("sale_user_id"), "物业所属销售取 userId 列");
assertEquals("张三", rows.get(0).item().get("sale_user_name"));
}
@Test
void propertiesFallsBackToCreateIdWhenOwnerMissing() {
PropertiesSyncEntity entity = new PropertiesSyncEntity(properytMapper, permissionService, agentMapper);
Properyt property = new Properyt();
property.setId(9L);
property.setName("望京SOHO");
property.setCreateId(11L);
property.setSoftDelete(0);
property.setModifyTime(Timestamp.valueOf("2026-09-01 10:00:00"));
when(properytMapper.selectList(any())).thenReturn(List.of(property));
when(properytMapper.selectPropertyOwners(any())).thenReturn(List.of());
List<SyncRow> rows = entity.fetchPage(null, 2000L, null, null, 3, null);
assertEquals("11", rows.get(0).item().get("sale_user_id"), "缺 userId 时回落到创建人");
}
@Test
void propertiesSkipsRowFilterForServiceToken() {
PropertiesSyncEntity entity = new PropertiesSyncEntity(properytMapper, permissionService);
PropertiesSyncEntity entity = new PropertiesSyncEntity(properytMapper, permissionService, agentMapper);
when(properytMapper.selectList(any())).thenReturn(List.of());
entity.fetchPage(null, 2000L, null, null, 3, null);
@@ -23,7 +23,7 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 十二个 changes 工具的注册与路由(REQ-20260911-0013,P2 域扩展于 REQ-20260902-0002)。
* 十三个 changes 工具的注册与路由(REQ-20260911-0013,P2 域扩展于 REQ-20260902-0002)。
*/
@ExtendWith(MockitoExtension.class)
class ChangesToolsTest {
@@ -41,12 +41,12 @@ class ChangesToolsTest {
callback -> callback.getToolDefinition().name(),
callback -> callback.getToolDefinition().inputSchema()));
assertEquals(13, schemas.size());
assertEquals(14, schemas.size());
for (String name : new String[]{"users.changes.list", "properties.changes.list", "contracts.changes.list",
"repayment-targets.changes.list", "repayment-details.changes.list",
"mall-orders.changes.list", "device-lifecycles.changes.list", "battery-faults.changes.list",
"devices.changes.list", "withdraw-orders.changes.list", "relocation-orders.changes.list",
"install-orders.changes.list"}) {
"install-orders.changes.list", "sale-receivable-orders.changes.list"}) {
assertTrue(schemas.containsKey(name), "缺少工具 " + name);
String schema = schemas.get(name);
assertTrue(schema.contains("syncToken"));
@@ -99,6 +99,9 @@ class ChangesToolsTest {
tools.installOrdersChangesList(null, null, 80);
verify(changesEngine).execute("install-orders", null, null, 80);
tools.saleReceivableOrdersChangesList("t8", "c8", null);
verify(changesEngine).execute("sale-receivable-orders", "t8", "c8", null);
}
@Test