feat(mcp): 退场/移机/安装工单 changes 流(P2 流失预警与交付跟踪)

- withdraw-orders.changes.list:客户撤设备=流失前置信号,销售挽单依据
- relocation-orders.changes.list:客户经营变动信号(搬迁=收缩/扩张背景)
- install-orders.changes.list:新签客户交付跟踪,mall_order_no 关联商城订单
- 状态位整型透传不造枚举;行权限复用各工单既有 list.query ACL;
  表内无销售列,下游经 property_id→properties 流归属负责人
- 全量 463 项测试通过
This commit is contained in:
zhiyuncai
2026-10-16 12:48:28 +08:00
parent da18e5c37c
commit 417c388eac
7 changed files with 544 additions and 4 deletions
@@ -0,0 +1,82 @@
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.m2.InstallOrder;
import me.iiv.agent.mapper.m2.InstallOrderMapper;
import me.iiv.agent.permission.PermissionService;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Map;
/**
* install-orders 同步实体(m2.install_order 安装工单)。
* 用途:P2 新签客户交付跟踪——已签约未安装超期影响客户满意度与首期回款,
* 销售需催交付;mall_order_no 关联商城订单事实。
* order_state/install_type 取值域未逐一确认,整型透传不造枚举;
* hope_install_date/true_install_date 为 SaaS 既有口径,只读透传。
* 行级权限:复用 PermissionService.applyInstallOrderRowFilter(与 install.order.list.query 同一 ACL)。
* 下游负责人解析:表内无销售列,CRM 侧经 property_id → properties 流归属销售。
*/
@Component
public class InstallOrdersSyncEntity extends AbstractSyncEntity implements SyncEntity {
public static final String NAME = "install-orders";
final InstallOrderMapper installOrderMapper;
final PermissionService permissionService;
public InstallOrdersSyncEntity(InstallOrderMapper installOrderMapper,
PermissionService permissionService) {
this.installOrderMapper = installOrderMapper;
this.permissionService = permissionService;
}
@Override
public String name() {
return NAME;
}
@Override
public List<SyncRow> fetchPage(Long fromTs, long cutoffTs, Long cursorMt, Long cursorId,
int limit, User user) {
LambdaQueryWrapper<InstallOrder> wrapper = new LambdaQueryWrapper<>();
if (user != null) {
permissionService.applyInstallOrderRowFilter(wrapper, user);
}
applyWindow(wrapper, fromTs, cutoffTs, cursorMt, cursorId);
applyOrderAndLimit(wrapper, limit);
List<SyncRow> rows = new ArrayList<>();
for (InstallOrder row : installOrderMapper.selectList(wrapper)) {
long mt = ChangesEngine.effectiveTs(row.getModifyTime(), row.getCreateTime());
rows.add(new SyncRow(toItem(row, mt), mt, row.getId()));
}
return rows;
}
private Map<String, Object> toItem(InstallOrder row, long mt) {
boolean deleted = row.getSoftDelete() != null && row.getSoftDelete() == 1;
Date effective = row.getModifyTime() != null ? row.getModifyTime() : row.getCreateTime();
return SyncChangeSchemas.toMap(new SyncChangeSchemas.InstallOrderChange(
stableId(row.getId()), row.getInstallOrderNo(), row.getInstallType(), row.getOrderState(),
stableId(row.getAgentId()), row.getAgentName(),
stableId(row.getPropertyId()), row.getPropertyName(),
stableId(row.getDistrictId()), row.getDistrictName(),
stableId(row.getApplyAgentId()), row.getApplyAgentName(),
stableId(row.getApplyUserId()), row.getApplyUserName(),
row.getHopeInstallDate(), row.getTrueInstallDate(),
row.getCustomerContact(), row.getContactMobile(),
row.getProjectAddress(), row.getProjectMemo(),
row.getMallOrderNo(), row.getDeliveryOrderNo(), row.getPickupOrderNo(),
ChangesEngine.iso(row.getApplyTime()), ChangesEngine.iso(row.getAuditTime()),
ChangesEngine.iso(row.getDispatchTime()), ChangesEngine.iso(row.getFinishDate()),
ChangesEngine.iso(effective), mt, deleted ? ChangesEngine.iso(effective) : null));
}
private static String stableId(Long id) {
return id == null ? null : id.toString();
}
}
@@ -0,0 +1,83 @@
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.m2.RelocationOrder;
import me.iiv.agent.mapper.m2.RelocationOrderMapper;
import me.iiv.agent.permission.PermissionService;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Map;
/**
* relocation-orders 同步实体(m2.relocation_order 移机工单)。
* 用途:P2 客户经营变动信号——站点搬迁可能意味着业务收缩或扩张,续约谈判前必须掌握。
* order_state 取值域未逐一确认,整型透传不造枚举;
* hope_relocation_date/true_relocation_date 为 SaaS 既有口径,只读透传。
* 行级权限:复用 PermissionService.applyRelocationOrderRowFilter(与 relocation.order.list.query 同一 ACL)。
* 下游负责人解析:表内无销售列,CRM 侧经 property_id → properties 流归属销售。
*/
@Component
public class RelocationOrdersSyncEntity extends AbstractSyncEntity implements SyncEntity {
public static final String NAME = "relocation-orders";
final RelocationOrderMapper relocationOrderMapper;
final PermissionService permissionService;
public RelocationOrdersSyncEntity(RelocationOrderMapper relocationOrderMapper,
PermissionService permissionService) {
this.relocationOrderMapper = relocationOrderMapper;
this.permissionService = permissionService;
}
@Override
public String name() {
return NAME;
}
@Override
public List<SyncRow> fetchPage(Long fromTs, long cutoffTs, Long cursorMt, Long cursorId,
int limit, User user) {
LambdaQueryWrapper<RelocationOrder> wrapper = new LambdaQueryWrapper<>();
if (user != null) {
permissionService.applyRelocationOrderRowFilter(wrapper, user);
}
applyWindow(wrapper, fromTs, cutoffTs, cursorMt, cursorId);
applyOrderAndLimit(wrapper, limit);
List<SyncRow> rows = new ArrayList<>();
for (RelocationOrder row : relocationOrderMapper.selectList(wrapper)) {
long mt = ChangesEngine.effectiveTs(row.getModifyTime(), row.getCreateTime());
rows.add(new SyncRow(toItem(row, mt), mt, row.getId()));
}
return rows;
}
private Map<String, Object> toItem(RelocationOrder row, long mt) {
boolean deleted = row.getSoftDelete() != null && row.getSoftDelete() == 1;
Date effective = row.getModifyTime() != null ? row.getModifyTime() : row.getCreateTime();
return SyncChangeSchemas.toMap(new SyncChangeSchemas.RelocationOrderChange(
stableId(row.getId()), row.getOrderNo(), row.getOrderState(),
stableId(row.getAgentId()), row.getAgentName(),
stableId(row.getPropertyId()), row.getPropertyName(),
stableId(row.getDistrictId()), row.getDistrictName(),
stableId(row.getApplyAgentId()), row.getApplyAgentName(),
stableId(row.getApplyUserId()), row.getApplyUserName(),
row.getRelocationReason(),
row.getHopeRelocationDate(), row.getTrueRelocationDate(),
row.getHopeRelocationCount(), row.getTrueRelocationCount(),
row.getCustomerContact(), row.getContactMobile(),
row.getProjectAddress(), row.getProjectMemo(),
ChangesEngine.iso(row.getApplyTime()), ChangesEngine.iso(row.getAuditTime()),
ChangesEngine.iso(row.getDispatchTime()), ChangesEngine.iso(row.getFinishTime()),
ChangesEngine.iso(row.getCancelTime()),
ChangesEngine.iso(effective), mt, deleted ? ChangesEngine.iso(effective) : null));
}
private static String stableId(Long id) {
return id == null ? null : id.toString();
}
}
@@ -265,4 +265,106 @@ final class SyncChangeSchemas {
long version,
@JsonProperty("deleted_at") String deletedAt) {
}
/** 退场工单:客户撤设备=流失前置信号;状态位整型透传,日期为 SaaS 既有口径。 */
record WithdrawOrderChange(
@JsonProperty("withdraw_order_id") String withdrawOrderId,
@JsonProperty("withdraw_order_no") String withdrawOrderNo,
@JsonProperty("pickup_order_no") String pickupOrderNo,
@JsonProperty("withdraw_type") Integer withdrawType,
@JsonProperty("order_state") Integer orderState,
@JsonProperty("agent_id") String agentId,
@JsonProperty("agent_name") String agentName,
@JsonProperty("property_id") String propertyId,
@JsonProperty("property_name") String propertyName,
@JsonProperty("district_id") String districtId,
@JsonProperty("district_name") String districtName,
@JsonProperty("apply_agent_id") String applyAgentId,
@JsonProperty("apply_agent_name") String applyAgentName,
@JsonProperty("apply_user_id") String applyUserId,
@JsonProperty("apply_user_name") String applyUserName,
@JsonProperty("planned_withdraw_date") String plannedWithdrawDate,
@JsonProperty("actual_withdraw_date") String actualWithdrawDate,
@JsonProperty("withdraw_goods_count") Integer withdrawGoodsCount,
@JsonProperty("customer_contact") String customerContact,
@JsonProperty("contact_mobile") String contactMobile,
@JsonProperty("project_address") String projectAddress,
@JsonProperty("project_memo") String projectMemo,
String memo,
@JsonProperty("applied_at") String appliedAt,
@JsonProperty("audited_at") String auditedAt,
@JsonProperty("dispatched_at") String dispatchedAt,
@JsonProperty("finished_at") String finishedAt,
@JsonProperty("updated_at") String updatedAt,
long version,
@JsonProperty("deleted_at") String deletedAt) {
}
/** 移机工单:客户经营变动信号;状态位整型透传,日期为 SaaS 既有口径。 */
record RelocationOrderChange(
@JsonProperty("relocation_order_id") String relocationOrderId,
@JsonProperty("order_no") String orderNo,
@JsonProperty("order_state") Integer orderState,
@JsonProperty("agent_id") String agentId,
@JsonProperty("agent_name") String agentName,
@JsonProperty("property_id") String propertyId,
@JsonProperty("property_name") String propertyName,
@JsonProperty("district_id") String districtId,
@JsonProperty("district_name") String districtName,
@JsonProperty("apply_agent_id") String applyAgentId,
@JsonProperty("apply_agent_name") String applyAgentName,
@JsonProperty("apply_user_id") String applyUserId,
@JsonProperty("apply_user_name") String applyUserName,
@JsonProperty("relocation_reason") String relocationReason,
@JsonProperty("planned_relocation_date") String plannedRelocationDate,
@JsonProperty("actual_relocation_date") String actualRelocationDate,
@JsonProperty("planned_relocation_count") Integer plannedRelocationCount,
@JsonProperty("actual_relocation_count") Integer actualRelocationCount,
@JsonProperty("customer_contact") String customerContact,
@JsonProperty("contact_mobile") String contactMobile,
@JsonProperty("project_address") String projectAddress,
@JsonProperty("project_memo") String projectMemo,
@JsonProperty("applied_at") String appliedAt,
@JsonProperty("audited_at") String auditedAt,
@JsonProperty("dispatched_at") String dispatchedAt,
@JsonProperty("finished_at") String finishedAt,
@JsonProperty("cancelled_at") String cancelledAt,
@JsonProperty("updated_at") String updatedAt,
long version,
@JsonProperty("deleted_at") String deletedAt) {
}
/** 安装工单:新签客户交付跟踪;状态位整型透传,日期为 SaaS 既有口径。 */
record InstallOrderChange(
@JsonProperty("install_order_id") String installOrderId,
@JsonProperty("install_order_no") String installOrderNo,
@JsonProperty("install_type") Integer installType,
@JsonProperty("order_state") Integer orderState,
@JsonProperty("agent_id") String agentId,
@JsonProperty("agent_name") String agentName,
@JsonProperty("property_id") String propertyId,
@JsonProperty("property_name") String propertyName,
@JsonProperty("district_id") String districtId,
@JsonProperty("district_name") String districtName,
@JsonProperty("apply_agent_id") String applyAgentId,
@JsonProperty("apply_agent_name") String applyAgentName,
@JsonProperty("apply_user_id") String applyUserId,
@JsonProperty("apply_user_name") String applyUserName,
@JsonProperty("planned_install_date") String plannedInstallDate,
@JsonProperty("actual_install_date") String actualInstallDate,
@JsonProperty("customer_contact") String customerContact,
@JsonProperty("contact_mobile") String contactMobile,
@JsonProperty("project_address") String projectAddress,
@JsonProperty("project_memo") String projectMemo,
@JsonProperty("mall_order_no") String mallOrderNo,
@JsonProperty("delivery_order_no") String deliveryOrderNo,
@JsonProperty("pickup_order_no") String pickupOrderNo,
@JsonProperty("applied_at") String appliedAt,
@JsonProperty("audited_at") String auditedAt,
@JsonProperty("dispatched_at") String dispatchedAt,
@JsonProperty("finished_at") String finishedAt,
@JsonProperty("updated_at") String updatedAt,
long version,
@JsonProperty("deleted_at") String deletedAt) {
}
}
@@ -0,0 +1,81 @@
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.m2.WithdrawOrder;
import me.iiv.agent.mapper.m2.WithdrawOrderMapper;
import me.iiv.agent.permission.PermissionService;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Map;
/**
* withdraw-orders 同步实体(m2.withdraw_order 退场工单)。
* 用途:P2 流失预警——客户撤设备是流失前置信号,销售需第一时间介入挽单。
* order_state/type 取值域未逐一确认,整型透传不造枚举;
* pre_withdraw_date/true_withdraw_date 为 SaaS 既有口径,只读透传。
* 行级权限:复用 PermissionService.applyWithdrawOrderRowFilter(与 withdraw.order.list.query 同一 ACL)。
* 下游负责人解析:表内无销售列,CRM 侧经 property_id → properties 流归属销售。
*/
@Component
public class WithdrawOrdersSyncEntity extends AbstractSyncEntity implements SyncEntity {
public static final String NAME = "withdraw-orders";
final WithdrawOrderMapper withdrawOrderMapper;
final PermissionService permissionService;
public WithdrawOrdersSyncEntity(WithdrawOrderMapper withdrawOrderMapper,
PermissionService permissionService) {
this.withdrawOrderMapper = withdrawOrderMapper;
this.permissionService = permissionService;
}
@Override
public String name() {
return NAME;
}
@Override
public List<SyncRow> fetchPage(Long fromTs, long cutoffTs, Long cursorMt, Long cursorId,
int limit, User user) {
LambdaQueryWrapper<WithdrawOrder> wrapper = new LambdaQueryWrapper<>();
if (user != null) {
permissionService.applyWithdrawOrderRowFilter(wrapper, user);
}
applyWindow(wrapper, fromTs, cutoffTs, cursorMt, cursorId);
applyOrderAndLimit(wrapper, limit);
List<SyncRow> rows = new ArrayList<>();
for (WithdrawOrder row : withdrawOrderMapper.selectList(wrapper)) {
long mt = ChangesEngine.effectiveTs(row.getModifyTime(), row.getCreateTime());
rows.add(new SyncRow(toItem(row, mt), mt, row.getId()));
}
return rows;
}
private Map<String, Object> toItem(WithdrawOrder row, long mt) {
boolean deleted = row.getSoftDelete() != null && row.getSoftDelete() == 1;
Date effective = row.getModifyTime() != null ? row.getModifyTime() : row.getCreateTime();
return SyncChangeSchemas.toMap(new SyncChangeSchemas.WithdrawOrderChange(
stableId(row.getId()), row.getWithdrawOrderNo(), row.getPickupOrderNo(), row.getType(),
row.getOrderState(),
stableId(row.getAgentId()), row.getAgentName(),
stableId(row.getPropertyId()), row.getPropertyName(),
stableId(row.getDistrictId()), row.getDistrictName(),
stableId(row.getApplyAgentId()), row.getApplyAgentName(),
stableId(row.getApplyUserId()), row.getApplyUserName(),
row.getPreWithdrawDate(), row.getTrueWithdrawDate(),
row.getWithdrawGoodsCount(), row.getCustomerContact(), row.getContactMobile(),
row.getProjectAddress(), row.getProjectMemo(), row.getMemo(),
ChangesEngine.iso(row.getApplyTime()), ChangesEngine.iso(row.getAuditTime()),
ChangesEngine.iso(row.getDispatchTime()), ChangesEngine.iso(row.getFinishTime()),
ChangesEngine.iso(effective), mt, deleted ? ChangesEngine.iso(effective) : null));
}
private static String stableId(Long id) {
return id == null ? null : id.toString();
}
}
@@ -9,11 +9,14 @@ import me.iiv.agent.mcp.sync.ChangesEngine;
import me.iiv.agent.mcp.sync.ContractsSyncEntity;
import me.iiv.agent.mcp.sync.DeviceLifecyclesSyncEntity;
import me.iiv.agent.mcp.sync.DevicesSyncEntity;
import me.iiv.agent.mcp.sync.InstallOrdersSyncEntity;
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.RepaymentTargetsSyncEntity;
import me.iiv.agent.mcp.sync.UsersSyncEntity;
import me.iiv.agent.mcp.sync.WithdrawOrdersSyncEntity;
import me.iiv.agent.security.AuthContext;
import me.iiv.agent.security.AuthContextHolder;
import org.springframework.ai.tool.annotation.Tool;
@@ -31,7 +34,8 @@ import java.util.Map;
/**
* 增量同步工具(REQ-20260911-0013;P2 域扩展于 REQ-20260902-0002):
* users/properties/contracts/repayment-targets/repayment-details/mall-orders/
* device-lifecycles/battery-faults/devices 九个 changes 工具,
* device-lifecycles/battery-faults/devices/withdraw-orders/relocation-orders/
* install-orders 十二个 changes 工具,
* 协议(snapshot/cursor/tombstone/sync_token)由 ChangesEngine 统一实现。
* 数据域硬约束:本类不声明 tenant_id/scope 入参,数据域只能从 Token 推导;
* GuardedToolCallback 对 *.changes.list 工具的原始入参做二次拦截,传 tenant_id/scope 一律拒绝。
@@ -213,6 +217,53 @@ public class ChangesTools implements ToolObject {
return changesEngine.execute(DevicesSyncEntity.NAME, syncToken, cursor, limit);
}
@XqTool(category = "sync", kind = "read")
@Tool(name = "withdraw-orders.changes.list", description = "增量同步退场工单(m2.withdraw_order)——客户撤设备=流失前置信号。" + PROTOCOL_DESC
+ "items 字段:withdraw_order_id/withdraw_order_no/pickup_order_no/withdraw_type/"
+ "order_state(整型透传)/agent/property/district 及名称/apply_agent_id/apply_user_id 及申请人名称/"
+ "planned_withdraw_date/actual_withdraw_date(SaaS 既有口径只读透传)/withdraw_goods_count/"
+ "customer_contact/contact_mobile/project_address/project_memo/memo/"
+ "applied_at/audited_at/dispatched_at/finished_at/updated_at/version/deleted_at。"
+ "行级权限跟随 Token 绑定的人(与 withdraw.order.list.query 同一 ACL)。")
public Map<String, Object> withdrawOrdersChangesList(
@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(WithdrawOrdersSyncEntity.NAME, syncToken, cursor, limit);
}
@XqTool(category = "sync", kind = "read")
@Tool(name = "relocation-orders.changes.list", description = "增量同步移机工单(m2.relocation_order)——客户经营变动信号。" + PROTOCOL_DESC
+ "items 字段:relocation_order_id/order_no/order_state(整型透传)/"
+ "agent/property/district 及名称/apply_agent_id/apply_user_id 及申请人名称/"
+ "relocation_reason/planned_relocation_date/actual_relocation_date(SaaS 既有口径只读透传)/"
+ "planned_relocation_count/actual_relocation_count/customer_contact/contact_mobile/"
+ "project_address/project_memo/applied_at/audited_at/dispatched_at/finished_at/cancelled_at/"
+ "updated_at/version/deleted_at。"
+ "行级权限跟随 Token 绑定的人(与 relocation.order.list.query 同一 ACL)。")
public Map<String, Object> relocationOrdersChangesList(
@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(RelocationOrdersSyncEntity.NAME, syncToken, cursor, limit);
}
@XqTool(category = "sync", kind = "read")
@Tool(name = "install-orders.changes.list", description = "增量同步安装工单(m2.install_order)——新签客户交付跟踪。" + PROTOCOL_DESC
+ "items 字段:install_order_id/install_order_no/install_type/order_state(整型透传)/"
+ "agent/property/district 及名称/apply_agent_id/apply_user_id 及申请人名称/"
+ "planned_install_date/actual_install_date(SaaS 既有口径只读透传)/"
+ "customer_contact/contact_mobile/project_address/project_memo/"
+ "mall_order_no/delivery_order_no/pickup_order_no(关联单号)/"
+ "applied_at/audited_at/dispatched_at/finished_at/updated_at/version/deleted_at。"
+ "行级权限跟随 Token 绑定的人(与 install.order.list.query 同一 ACL)。")
public Map<String, Object> installOrdersChangesList(
@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(InstallOrdersSyncEntity.NAME, syncToken, cursor, limit);
}
private static List<Long> parseContractIds(List<String> contractIds) {
if (contractIds == null || contractIds.isEmpty()) {
throw new ToolCallException(McpErrorCode.INVALID_ARGUMENT, "contractIds必须包含1至200个合同ID");
@@ -12,6 +12,9 @@ import me.iiv.agent.entity.m1.MallGoodsOrder;
import me.iiv.agent.entity.m1.SalerRepayment;
import me.iiv.agent.entity.m1.SalerRepaymentCustomer;
import me.iiv.agent.entity.m2.ContractNew;
import me.iiv.agent.entity.m2.InstallOrder;
import me.iiv.agent.entity.m2.RelocationOrder;
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;
@@ -21,6 +24,9 @@ import me.iiv.agent.mapper.m1.MallGoodsOrderMapper;
import me.iiv.agent.mapper.m1.SalerRepaymentCustomerMapper;
import me.iiv.agent.mapper.m1.SalerRepaymentMapper;
import me.iiv.agent.mapper.m2.ContractNewMapper;
import me.iiv.agent.mapper.m2.InstallOrderMapper;
import me.iiv.agent.mapper.m2.RelocationOrderMapper;
import me.iiv.agent.mapper.m2.WithdrawOrderMapper;
import me.iiv.agent.mcp.McpErrorCode;
import me.iiv.agent.mcp.ToolCallException;
import me.iiv.agent.permission.PermissionService;
@@ -75,6 +81,12 @@ class SyncEntitiesTest {
@Mock
HardwareViewMapper hardwareViewMapper;
@Mock
WithdrawOrderMapper withdrawOrderMapper;
@Mock
RelocationOrderMapper relocationOrderMapper;
@Mock
InstallOrderMapper installOrderMapper;
@Mock
PermissionService permissionService;
@BeforeEach
@@ -88,6 +100,9 @@ class SyncEntitiesTest {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), DeviceLifeCycle.class);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), BatteryFaultLog.class);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), HardwareView.class);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), WithdrawOrder.class);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), RelocationOrder.class);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), InstallOrder.class);
}
@Test
@@ -639,4 +654,120 @@ class SyncEntitiesTest {
assertEquals(McpErrorCode.DATA_INTEGRITY_ERROR, error.getCode());
}
@Test
void withdrawOrdersMapsFieldsAndAppliesFilter() {
WithdrawOrdersSyncEntity entity = new WithdrawOrdersSyncEntity(withdrawOrderMapper, permissionService);
WithdrawOrder order = new WithdrawOrder();
order.setId(91L);
order.setWithdrawOrderNo("WD20261001");
order.setType(1);
order.setOrderState(2);
order.setAgentId(21L);
order.setAgentName("趣致北京");
order.setPropertyId(9L);
order.setPropertyName("望京SOHO");
order.setDistrictId(3L);
order.setApplyUserId(1001L);
order.setApplyUserName("张运营");
order.setPreWithdrawDate("2026-10-20");
order.setTrueWithdrawDate("2026-10-22");
order.setWithdrawGoodsCount(4);
order.setCustomerContact("李经理");
order.setApplyTime(Timestamp.valueOf("2026-10-10 09:00:00"));
order.setFinishTime(Timestamp.valueOf("2026-10-22 18:00:00"));
order.setSoftDelete(0);
order.setModifyTime(Timestamp.valueOf("2026-10-22 18:30:00"));
when(withdrawOrderMapper.selectList(any())).thenReturn(List.of(order));
User user = new User();
user.setId(7L);
List<SyncRow> rows = entity.fetchPage(null, 2000L, null, null, 3, user);
verify(permissionService).applyWithdrawOrderRowFilter(any(), any());
Map<String, Object> item = rows.get(0).item();
assertEquals("91", item.get("withdraw_order_id"));
assertEquals("WD20261001", item.get("withdraw_order_no"));
assertEquals(2, item.get("order_state"), "工单状态整型透传");
assertEquals("望京SOHO", item.get("property_name"));
assertEquals("2026-10-20", item.get("planned_withdraw_date"));
assertEquals("2026-10-22", item.get("actual_withdraw_date"));
assertEquals(4, item.get("withdraw_goods_count"));
assertEquals("2026-10-10T09:00:00+08:00", item.get("applied_at"));
assertEquals("2026-10-22T18:00:00+08:00", item.get("finished_at"));
assertNull(item.get("deleted_at"));
}
@Test
void relocationOrdersMapsFieldsAndAppliesFilterWithTombstone() {
RelocationOrdersSyncEntity entity = new RelocationOrdersSyncEntity(relocationOrderMapper, permissionService);
RelocationOrder order = new RelocationOrder();
order.setId(92L);
order.setOrderNo("RL20261001");
order.setOrderState(1);
order.setAgentId(21L);
order.setPropertyId(9L);
order.setPropertyName("望京SOHO");
order.setRelocationReason("楼层改造");
order.setHopeRelocationDate("2026-10-25");
order.setHopeRelocationCount(2);
order.setApplyTime(Timestamp.valueOf("2026-10-10 09:00:00"));
order.setCancelTime(Timestamp.valueOf("2026-10-12 10:00:00"));
order.setSoftDelete(1);
order.setModifyTime(Timestamp.valueOf("2026-10-12 10:30:00"));
when(relocationOrderMapper.selectList(any())).thenReturn(List.of(order));
User user = new User();
user.setId(7L);
List<SyncRow> rows = entity.fetchPage(null, 2000L, null, null, 3, user);
verify(permissionService).applyRelocationOrderRowFilter(any(), any());
Map<String, Object> item = rows.get(0).item();
assertEquals("92", item.get("relocation_order_id"));
assertEquals("RL20261001", item.get("order_no"));
assertEquals("楼层改造", item.get("relocation_reason"));
assertEquals("2026-10-25", item.get("planned_relocation_date"));
assertEquals(2, item.get("planned_relocation_count"));
assertEquals("2026-10-12T10:00:00+08:00", item.get("cancelled_at"));
assertEquals("2026-10-12T10:30:00+08:00", item.get("deleted_at"), "软删行返回 tombstone");
}
@Test
void installOrdersMapsFieldsAndAppliesFilter() {
InstallOrdersSyncEntity entity = new InstallOrdersSyncEntity(installOrderMapper, permissionService);
InstallOrder order = new InstallOrder();
order.setId(93L);
order.setInstallOrderNo("IN20261001");
order.setInstallType(0);
order.setOrderState(3);
order.setAgentId(21L);
order.setPropertyId(9L);
order.setPropertyName("望京SOHO");
order.setDistrictId(3L);
order.setApplyUserId(1001L);
order.setApplyUserName("张运营");
order.setHopeInstallDate("2026-10-18");
order.setTrueInstallDate("2026-10-19");
order.setCustomerContact("李经理");
order.setMallOrderNo("SO20261001");
order.setApplyTime(Timestamp.valueOf("2026-10-10 09:00:00"));
order.setFinishDate(Timestamp.valueOf("2026-10-19 17:00:00"));
order.setSoftDelete(0);
order.setModifyTime(Timestamp.valueOf("2026-10-19 17:30:00"));
when(installOrderMapper.selectList(any())).thenReturn(List.of(order));
User user = new User();
user.setId(7L);
List<SyncRow> rows = entity.fetchPage(null, 2000L, null, null, 3, user);
verify(permissionService).applyInstallOrderRowFilter(any(), any());
Map<String, Object> item = rows.get(0).item();
assertEquals("93", item.get("install_order_id"));
assertEquals("IN20261001", item.get("install_order_no"));
assertEquals("2026-10-18", item.get("planned_install_date"));
assertEquals("2026-10-19", item.get("actual_install_date"));
assertEquals("SO20261001", item.get("mall_order_no"), "关联商城订单号必须透传");
assertEquals("2026-10-19T17:00:00+08:00", item.get("finished_at"));
assertNull(item.get("deleted_at"));
}
}
@@ -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,11 +41,12 @@ class ChangesToolsTest {
callback -> callback.getToolDefinition().name(),
callback -> callback.getToolDefinition().inputSchema()));
assertEquals(10, schemas.size());
assertEquals(13, 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"}) {
"devices.changes.list", "withdraw-orders.changes.list", "relocation-orders.changes.list",
"install-orders.changes.list"}) {
assertTrue(schemas.containsKey(name), "缺少工具 " + name);
String schema = schemas.get(name);
assertTrue(schema.contains("syncToken"));
@@ -89,6 +90,15 @@ class ChangesToolsTest {
tools.devicesChangesList("t6", null, 20);
verify(changesEngine).execute("devices", "t6", null, 20);
tools.withdrawOrdersChangesList(null, null, null);
verify(changesEngine).execute("withdraw-orders", null, null, null);
tools.relocationOrdersChangesList("t7", "c7", null);
verify(changesEngine).execute("relocation-orders", "t7", "c7", null);
tools.installOrdersChangesList(null, null, 80);
verify(changesEngine).execute("install-orders", null, null, 80);
}
@Test