Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fff1ae2d3b | ||
|
|
f19c2a5c7f | ||
|
|
614eb65d25 | ||
|
|
663990e892 | ||
|
|
a407870547 | ||
|
|
b62ba1ca37 | ||
|
|
0d43b6af23 | ||
|
|
10982f24a3 | ||
|
|
99adb3dfc1 | ||
|
|
3da52f55ec | ||
|
|
0ea48ee1fb |
@@ -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);
|
||||
}
|
||||
|
||||
+11
@@ -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));
|
||||
}
|
||||
|
||||
+79
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user