feat(mcp): 商城订单/设备生命周期/电池故障 changes 流(P2 增购复购与设备域准入)

- mall-orders.changes.list:m1.mall_goods_order,order_state 0-7 映射公开枚举 fail-closed,
  金额 BigDecimal 元转分,逾期/回款周期/开票状态等 SaaS 既有口径只读透传;
  行权限复用 applyOrderRowFilter(与 mall.order.list.query 同一 ACL)
- device-lifecycles.changes.list:m0.device_life_cycle,network_state 0/1→OFFLINE/ONLINE,
  其余流转状态整型透传;行权限按代理维度 agent_id(表内无 property 维度)
- battery-faults.changes.list:m0.battery_fault_log,故障类型/状态整型透传,
  行权限复用 applyBatteryFaultLogRowFilter
- 契约层统一 NON_NULL 省略空值键;测试:SyncEntitiesTest +5、ChangesToolsTest 注册数 6→9,
  全量 458 项测试通过
This commit is contained in:
zhiyuncai
2026-10-16 12:30:37 +08:00
parent 00b9c2d27f
commit f4cdc8e7bf
8 changed files with 623 additions and 5 deletions
@@ -0,0 +1,78 @@
package me.iiv.agent.mcp.sync;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import me.iiv.agent.entity.m0.BatteryFaultLog;
import me.iiv.agent.entity.m0.User;
import me.iiv.agent.mapper.m0.BatteryFaultLogMapper;
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;
/**
* battery-faults 同步实体(m0.battery_fault_log 电池/设备故障日志)。
* 用途:P2 设备经营域故障事项化底座(故障发生/恢复事件流)。
* fault_type/fault_state/device_type 取值域未逐一确认,整型透传不造枚举;
* fault_start_time/fault_recovery_time 为 SaaS 既有口径,只读透传。
* 行级权限:复用 PermissionService.applyBatteryFaultLogRowFilter
* (与 battery.fault.list.query 同一 ACL:代理/物业可见范围)。
*/
@Component
public class BatteryFaultsSyncEntity extends AbstractSyncEntity implements SyncEntity {
public static final String NAME = "battery-faults";
final BatteryFaultLogMapper batteryFaultLogMapper;
final PermissionService permissionService;
public BatteryFaultsSyncEntity(BatteryFaultLogMapper batteryFaultLogMapper,
PermissionService permissionService) {
this.batteryFaultLogMapper = batteryFaultLogMapper;
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<BatteryFaultLog> wrapper = new LambdaQueryWrapper<>();
if (user != null) {
permissionService.applyBatteryFaultLogRowFilter(wrapper, user);
}
applyWindow(wrapper, fromTs, cutoffTs, cursorMt, cursorId);
applyOrderAndLimit(wrapper, limit);
List<SyncRow> rows = new ArrayList<>();
for (BatteryFaultLog row : batteryFaultLogMapper.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(BatteryFaultLog 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.BatteryFaultChange(
stableId(row.getId()), row.getSnCode(), row.getDeviceType(),
row.getFaultType(), row.getFaultState(),
ChangesEngine.iso(row.getFaultStartTime()), ChangesEngine.iso(row.getFaultAddTime()),
ChangesEngine.iso(row.getFaultRecoveryTime()),
row.getInstallPosition(),
stableId(row.getDistrictId()), row.getDistrictName(),
stableId(row.getPropertyId()), row.getPropertyName(),
stableId(row.getAgentId()), row.getAgentName(),
row.getPeakValue(), row.getStandardValue(),
ChangesEngine.iso(effective), mt, deleted ? ChangesEngine.iso(effective) : null));
}
private static String stableId(Long id) {
return id == null ? null : id.toString();
}
}
@@ -0,0 +1,94 @@
package me.iiv.agent.mcp.sync;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import me.iiv.agent.entity.m0.DeviceLifeCycle;
import me.iiv.agent.entity.m0.User;
import me.iiv.agent.mapper.m0.DeviceLifeCycleMapper;
import me.iiv.agent.mcp.McpErrorCode;
import me.iiv.agent.mcp.ToolCallException;
import me.iiv.agent.permission.PermissionService;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Map;
/**
* device-lifecycles 同步实体(m0.device_life_cycle 设备生命周期)。
* 用途:P2 设备经营域事项化底座(离线/上线/退场/资产属性变化)。
* network_state 0/1 映射 OFFLINE/ONLINE,未知编码 fail-closed;
* exchange_state/road_state/inventory_state/asset_state/device_type 取值域较宽且
* SaaS 可能扩展,整型透传不造枚举(与 order_invoice_state 同一先例)。
* 行级权限:PermissionService.applyDeviceLifeCycleRowFilter(代理维度 agent_id,
* 表内无 property 维度)。
*/
@Component
public class DeviceLifecyclesSyncEntity extends AbstractSyncEntity implements SyncEntity {
public static final String NAME = "device-lifecycles";
final DeviceLifeCycleMapper deviceLifeCycleMapper;
final PermissionService permissionService;
public DeviceLifecyclesSyncEntity(DeviceLifeCycleMapper deviceLifeCycleMapper,
PermissionService permissionService) {
this.deviceLifeCycleMapper = deviceLifeCycleMapper;
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<DeviceLifeCycle> wrapper = new LambdaQueryWrapper<>();
if (user != null) {
permissionService.applyDeviceLifeCycleRowFilter(wrapper, user);
}
applyWindow(wrapper, fromTs, cutoffTs, cursorMt, cursorId);
applyOrderAndLimit(wrapper, limit);
List<SyncRow> rows = new ArrayList<>();
for (DeviceLifeCycle row : deviceLifeCycleMapper.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(DeviceLifeCycle 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.DeviceLifecycleChange(
stableId(row.getId()), row.getSnCode(), row.getDeviceType(),
row.getExchangeState(), row.getUpExchangeState(),
row.getRoadState(), row.getInventoryState(),
publicNetworkState(row.getNetworkState()), row.getAssetState(),
stableId(row.getAgentId()), row.getAgentName(),
stableId(row.getAssetOwnerId()), row.getAssetOwnerName(),
ChangesEngine.iso(row.getFirstUseDate()), ChangesEngine.iso(row.getLastOnlineDate()),
ChangesEngine.iso(row.getDiscardDate()), row.getLastOpUser(),
ChangesEngine.iso(row.getLastOpDate()),
ChangesEngine.iso(effective), mt, deleted ? ChangesEngine.iso(effective) : null));
}
/** Legacy network_state (0/1) to the stable public enum. */
static String publicNetworkState(Integer state) {
if (state == null) {
return null;
}
return switch (state) {
case 0 -> "OFFLINE";
case 1 -> "ONLINE";
default -> throw new ToolCallException(McpErrorCode.DATA_INTEGRITY_ERROR,
"设备联网状态无法映射到公开契约: " + state);
};
}
private static String stableId(Long id) {
return id == null ? null : id.toString();
}
}
@@ -0,0 +1,120 @@
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.MallGoodsOrder;
import me.iiv.agent.mapper.m1.MallGoodsOrderMapper;
import me.iiv.agent.mcp.McpErrorCode;
import me.iiv.agent.mcp.ToolCallException;
import me.iiv.agent.permission.PermissionService;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Map;
/**
* mall-orders 同步实体(m1.mall_goods_order 商城订单)。
* 用途:P2 增购/复购域订单事实 + 订单级回款/发票事实(overdue_flag/overdue_days/
* last_pay_time/order_invoice_state/invoice_money 均为 SaaS 既有口径,只读透传,本侧不重算)。
* 金额列为 BigDecimal(元),契约层统一转分(HALF_UP);version 由 modify_time 派生。
* order_state 0-7 映射公开枚举,未知编码 fail-closed 拒绝(与 contracts 同一铁律);
* 其余状态位(pay_state/order_invoice_state/accounts_type 等)取值域未逐一确认,整型透传不造枚举。
* 行级权限:复用 PermissionService.applyOrderRowFilter(与 mall.order.list.query 同一 ACL,
* 移植自老系统 WrapperUtils.getSaleCompany)。
*/
@Component
public class MallOrdersSyncEntity extends AbstractSyncEntity implements SyncEntity {
public static final String NAME = "mall-orders";
final MallGoodsOrderMapper mallGoodsOrderMapper;
final PermissionService permissionService;
public MallOrdersSyncEntity(MallGoodsOrderMapper mallGoodsOrderMapper,
PermissionService permissionService) {
this.mallGoodsOrderMapper = mallGoodsOrderMapper;
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<MallGoodsOrder> wrapper = new LambdaQueryWrapper<>();
if (user != null) {
permissionService.applyOrderRowFilter(wrapper, user);
}
applyWindow(wrapper, fromTs, cutoffTs, cursorMt, cursorId);
applyOrderAndLimit(wrapper, limit);
List<SyncRow> rows = new ArrayList<>();
for (MallGoodsOrder row : mallGoodsOrderMapper.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(MallGoodsOrder 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.MallOrderChange(
stableId(row.getId()), row.getOrderNo(), row.getBillNo(),
publicOrderState(row.getOrderState()),
row.getOrderType(), row.getAccountsType(), row.getPayState(),
row.getOrderPayState(), row.getOrderInvoiceState(),
stableId(row.getCreateId()), row.getCreateName(),
stableId(row.getAgentId()), row.getAgentName(),
stableId(row.getPropertyId()), row.getPropertyName(),
stableId(row.getDistrictId()), row.getDistrictName(),
stableId(row.getUserId()), row.getUserName(),
stableId(row.getSaleCompanyId()), row.getSaleCompany(),
toMinor(row.getTotalMoney()), toMinor(row.getGoodsMoney()),
toMinor(row.getPayMoney()), toMinor(row.getPaidMoney()), toMinor(row.getUnPayMoney()),
toMinor(row.getDiscountMoney()), toMinor(row.getInvoiceMoney()), toMinor(row.getUnInvoiceMoney()),
row.getOverdueFlag(), row.getOverdueDays(), row.getPaymentCollection(),
ChangesEngine.iso(row.getOrderTime()), ChangesEngine.iso(row.getPayTime()),
ChangesEngine.iso(row.getLastPayTime()), ChangesEngine.iso(row.getSignTime()),
ChangesEngine.iso(effective), mt, deleted ? ChangesEngine.iso(effective) : null));
}
/** BigDecimal(元)→ 分;null 原样返回 null。 */
static Long toMinor(BigDecimal amount) {
return amount == null ? null
: amount.movePointRight(2).setScale(0, RoundingMode.HALF_UP).longValue();
}
/** Legacy order_state (0-7) to the stable public enum. */
static String publicOrderState(Integer state) {
if (state == null) {
throw invalidOrderState(null);
}
return switch (state) {
case 0 -> "PENDING_PAYMENT";
case 1 -> "PENDING_OUTBOUND";
case 2 -> "PENDING_SHIPMENT";
case 3 -> "SHIPPED";
case 4 -> "SIGNED";
case 5 -> "RECEIVED";
case 6 -> "CANCELLED";
case 7 -> "DELETED";
default -> throw invalidOrderState(state);
};
}
private static ToolCallException invalidOrderState(Integer state) {
return new ToolCallException(McpErrorCode.DATA_INTEGRITY_ERROR,
"商城订单状态无法映射到公开契约: " + state);
}
private static String stableId(Long id) {
return id == null ? null : id.toString();
}
}
@@ -137,4 +137,96 @@ final class SyncChangeSchemas {
long version,
@JsonProperty("deleted_at") String deletedAt) {
}
/** 商城订单:金额均为分;逾期/回款周期/发票状态为 SaaS 既有口径,只读透传。 */
record MallOrderChange(
@JsonProperty("order_id") String orderId,
@JsonProperty("order_no") String orderNo,
@JsonProperty("bill_no") String billNo,
@JsonProperty("order_state") String orderState,
@JsonProperty("order_type") Integer orderType,
@JsonProperty("accounts_type") Integer accountsType,
@JsonProperty("pay_state") Integer payState,
@JsonProperty("order_pay_state") Integer orderPayState,
@JsonProperty("order_invoice_state") Integer orderInvoiceState,
@JsonProperty("owner_user_id") String ownerUserId,
@JsonProperty("owner_name") String ownerName,
@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("customer_user_id") String customerUserId,
@JsonProperty("customer_name") String customerName,
@JsonProperty("sale_company_id") String saleCompanyId,
@JsonProperty("sale_company_name") String saleCompanyName,
@JsonProperty("total_amount_minor") Long totalAmountMinor,
@JsonProperty("goods_amount_minor") Long goodsAmountMinor,
@JsonProperty("payable_amount_minor") Long payableAmountMinor,
@JsonProperty("paid_amount_minor") Long paidAmountMinor,
@JsonProperty("unpaid_amount_minor") Long unpaidAmountMinor,
@JsonProperty("discount_amount_minor") Long discountAmountMinor,
@JsonProperty("invoiced_amount_minor") Long invoicedAmountMinor,
@JsonProperty("uninvoiced_amount_minor") Long uninvoicedAmountMinor,
@JsonProperty("overdue_flag") Integer overdueFlag,
@JsonProperty("overdue_days") Integer overdueDays,
@JsonProperty("payment_collection_days") Integer paymentCollectionDays,
@JsonProperty("ordered_at") String orderedAt,
@JsonProperty("paid_at") String paidAt,
@JsonProperty("last_pay_due_at") String lastPayDueAt,
@JsonProperty("signed_at") String signedAt,
@JsonProperty("updated_at") String updatedAt,
long version,
@JsonProperty("deleted_at") String deletedAt) {
}
/** 设备生命周期:network_state 映射 OFFLINE/ONLINE,其余状态位整型透传。 */
record DeviceLifecycleChange(
@JsonProperty("lifecycle_id") String lifecycleId,
@JsonProperty("sn_code") String snCode,
@JsonProperty("device_type") Integer deviceType,
@JsonProperty("exchange_state") Integer exchangeState,
@JsonProperty("up_exchange_state") Integer upExchangeState,
@JsonProperty("road_state") Integer roadState,
@JsonProperty("inventory_state") Integer inventoryState,
@JsonProperty("network_state") String networkState,
@JsonProperty("asset_state") Integer assetState,
@JsonProperty("agent_id") String agentId,
@JsonProperty("agent_name") String agentName,
@JsonProperty("asset_owner_id") String assetOwnerId,
@JsonProperty("asset_owner_name") String assetOwnerName,
@JsonProperty("first_use_at") String firstUseAt,
@JsonProperty("last_online_at") String lastOnlineAt,
@JsonProperty("discarded_at") String discardedAt,
@JsonProperty("last_op_user") String lastOpUser,
@JsonProperty("last_op_at") String lastOpAt,
@JsonProperty("updated_at") String updatedAt,
long version,
@JsonProperty("deleted_at") String deletedAt) {
}
/** 电池/设备故障日志:故障类型与状态整型透传,发生/恢复时间只读透传。 */
record BatteryFaultChange(
@JsonProperty("fault_id") String faultId,
@JsonProperty("sn_code") String snCode,
@JsonProperty("device_type") Integer deviceType,
@JsonProperty("fault_type") Integer faultType,
@JsonProperty("fault_state") Integer faultState,
@JsonProperty("fault_started_at") String faultStartedAt,
@JsonProperty("fault_added_at") String faultAddedAt,
@JsonProperty("fault_recovered_at") String faultRecoveredAt,
@JsonProperty("install_position") String installPosition,
@JsonProperty("district_id") String districtId,
@JsonProperty("district_name") String districtName,
@JsonProperty("property_id") String propertyId,
@JsonProperty("property_name") String propertyName,
@JsonProperty("agent_id") String agentId,
@JsonProperty("agent_name") String agentName,
@JsonProperty("peak_value") Integer peakValue,
@JsonProperty("standard_value") Integer standardValue,
@JsonProperty("updated_at") String updatedAt,
long version,
@JsonProperty("deleted_at") String deletedAt) {
}
}
@@ -4,8 +4,11 @@ import me.iiv.agent.mcp.ToolObject;
import me.iiv.agent.mcp.McpErrorCode;
import me.iiv.agent.mcp.ToolCallException;
import me.iiv.agent.mcp.XqTool;
import me.iiv.agent.mcp.sync.BatteryFaultsSyncEntity;
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.MallOrdersSyncEntity;
import me.iiv.agent.mcp.sync.PropertiesSyncEntity;
import me.iiv.agent.mcp.sync.RepaymentDetailsSyncEntity;
import me.iiv.agent.mcp.sync.RepaymentTargetsSyncEntity;
@@ -25,7 +28,9 @@ import java.util.List;
import java.util.Map;
/**
* 增量同步工具(REQ-20260911-0013):users/properties/contracts 三个 changes 工具,
* 增量同步工具(REQ-20260911-0013;P2 域扩展于 REQ-20260902-0002):
* users/properties/contracts/repayment-targets/repayment-details/mall-orders/
* device-lifecycles/battery-faults 八个 changes 工具,
* 协议(snapshot/cursor/tombstone/sync_token)由 ChangesEngine 统一实现。
* 数据域硬约束:本类不声明 tenant_id/scope 入参,数据域只能从 Token 推导;
* GuardedToolCallback 对 *.changes.list 工具的原始入参做二次拦截,传 tenant_id/scope 一律拒绝。
@@ -146,6 +151,51 @@ public class ChangesTools implements ToolObject {
return changesEngine.execute(RepaymentDetailsSyncEntity.NAME, syncToken, cursor, limit);
}
@XqTool(category = "sync", kind = "read")
@Tool(name = "mall-orders.changes.list", description = "增量同步商城订单(m1.mall_goods_order)。" + PROTOCOL_DESC
+ "items 字段:order_id/order_no/bill_no/order_state(PENDING_PAYMENT/PENDING_OUTBOUND/"
+ "PENDING_SHIPMENT/SHIPPED/SIGNED/RECEIVED/CANCELLED/DELETED)/order_type/accounts_type/"
+ "pay_state/order_pay_state/order_invoice_state(状态位整型透传)/"
+ "owner_user_id(销售稳定 user.id)/owner_name/agent_id/property_id/district_id/customer_user_id 及名称/"
+ "sale_company_id/sale_company_name/金额八项(*_minor,分)/"
+ "overdue_flag/overdue_days/payment_collection_days(SaaS 既有逾期口径只读透传)/"
+ "ordered_at/paid_at/last_pay_due_at/signed_at/updated_at/version/deleted_at。"
+ "行级权限跟随 Token 绑定的人(与 mall.order.list.query 同一 ACL)。")
public Map<String, Object> mallOrdersChangesList(
@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(MallOrdersSyncEntity.NAME, syncToken, cursor, limit);
}
@XqTool(category = "sync", kind = "read")
@Tool(name = "device-lifecycles.changes.list", description = "增量同步设备生命周期(m0.device_life_cycle)。" + PROTOCOL_DESC
+ "items 字段:lifecycle_id/sn_code/device_type/exchange_state/up_exchange_state/"
+ "road_state/inventory_state(流转状态整型透传)/network_state(OFFLINE/ONLINE)/asset_state/"
+ "agent_id/agent_name/asset_owner_id/asset_owner_name/"
+ "first_use_at/last_online_at/discarded_at/last_op_user/last_op_at/updated_at/version/deleted_at。"
+ "行级权限跟随 Token 绑定的人(代理维度 agent_id)。")
public Map<String, Object> deviceLifecyclesChangesList(
@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(DeviceLifecyclesSyncEntity.NAME, syncToken, cursor, limit);
}
@XqTool(category = "sync", kind = "read")
@Tool(name = "battery-faults.changes.list", description = "增量同步设备故障日志(m0.battery_fault_log)。" + PROTOCOL_DESC
+ "items 字段:fault_id/sn_code/device_type/fault_type/fault_state(故障类型与状态整型透传)/"
+ "fault_started_at/fault_added_at/fault_recovered_at(SaaS 既有口径只读透传)/install_position/"
+ "district_id/district_name/property_id/property_name/agent_id/agent_name/"
+ "peak_value/standard_value/updated_at/version/deleted_at。"
+ "行级权限跟随 Token 绑定的人(与 battery.fault.list.query 同一 ACL)。")
public Map<String, Object> batteryFaultsChangesList(
@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(BatteryFaultsSyncEntity.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");
@@ -5,6 +5,7 @@ import me.iiv.agent.entity.m0.Agent;
import me.iiv.agent.entity.m0.AgentManageDistrictAuth;
import me.iiv.agent.entity.m0.Authority;
import me.iiv.agent.entity.m0.BatteryFaultLog;
import me.iiv.agent.entity.m0.DeviceLifeCycle;
import me.iiv.agent.entity.m0.DistrictDistrictStrategy;
import me.iiv.agent.entity.m0.HardwareView;
import me.iiv.agent.entity.m0.Properyt;
@@ -962,4 +963,16 @@ public class PermissionService {
wrapper.in(SalerRepaymentCustomer::getSalerAgentId, scope.ids);
}
}
/**
* 设备生命周期(m0 device_life_cycle)行权限:表内无 property 维度,
* 总部/服务号全量,其余按代理范围落到 agent_id(设备资产归属代理)。
*/
public void applyDeviceLifeCycleRowFilter(LambdaQueryWrapper<DeviceLifeCycle> wrapper, User user) {
RowScope scope = resolveRowScope(user);
if (scope.headquarters) {
return;
}
wrapper.in(DeviceLifeCycle::getAgentId, scope.ids);
}
}
@@ -3,13 +3,19 @@ package me.iiv.agent.mcp.sync;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import me.iiv.agent.entity.m0.BatteryFaultLog;
import me.iiv.agent.entity.m0.DeviceLifeCycle;
import me.iiv.agent.entity.m0.Properyt;
import me.iiv.agent.entity.m0.User;
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.mapper.m0.BatteryFaultLogMapper;
import me.iiv.agent.mapper.m0.DeviceLifeCycleMapper;
import me.iiv.agent.mapper.m0.ProperytMapper;
import me.iiv.agent.mapper.m0.UserMapper;
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;
@@ -42,7 +48,8 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 三个同步实体的窗口条件、tombstone 与字段映射测试(REQ-20260911-0013)。
* 八个同步实体的窗口条件、tombstone 与字段映射测试
* (REQ-20260911-0013;回款/订单/设备域扩展于 REQ-20260902-0002 P2)。
*/
@ExtendWith(MockitoExtension.class)
class SyncEntitiesTest {
@@ -58,6 +65,12 @@ class SyncEntitiesTest {
@Mock
SalerRepaymentCustomerMapper salerRepaymentCustomerMapper;
@Mock
MallGoodsOrderMapper mallGoodsOrderMapper;
@Mock
DeviceLifeCycleMapper deviceLifeCycleMapper;
@Mock
BatteryFaultLogMapper batteryFaultLogMapper;
@Mock
PermissionService permissionService;
@BeforeEach
@@ -67,6 +80,9 @@ class SyncEntitiesTest {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), ContractNew.class);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), SalerRepayment.class);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), SalerRepaymentCustomer.class);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), MallGoodsOrder.class);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), DeviceLifeCycle.class);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), BatteryFaultLog.class);
}
@Test
@@ -419,4 +435,149 @@ class SyncEntitiesTest {
assertEquals(McpErrorCode.DATA_INTEGRITY_ERROR, error.getCode());
}
@Test
void mallOrdersMapsAmountsStateAndOverduePassthrough() {
MallOrdersSyncEntity entity = new MallOrdersSyncEntity(mallGoodsOrderMapper, permissionService);
MallGoodsOrder order = new MallGoodsOrder();
order.setId(41L);
order.setOrderNo("SO20261001");
order.setBillNo("B202610-1");
order.setOrderState(1);
order.setOrderType(1);
order.setAccountsType(1);
order.setPayState(2);
order.setOrderInvoiceState(1);
order.setCreateId(1001L);
order.setCreateName("张销售");
order.setAgentId(21L);
order.setAgentName("趣致北京");
order.setPropertyId(9L);
order.setPropertyName("望京SOHO");
order.setDistrictId(3L);
order.setUserId(501L);
order.setUserName("物业采购");
order.setSaleCompanyId(21L);
order.setSaleCompany("趣致北京");
order.setTotalMoney(new BigDecimal("20000.00"));
order.setPaidMoney(new BigDecimal("12000.50"));
order.setUnPayMoney(new BigDecimal("7999.50"));
order.setInvoiceMoney(new BigDecimal("5000.00"));
order.setOverdueFlag(1);
order.setOverdueDays(12);
order.setPaymentCollection(30);
order.setOrderTime(Timestamp.valueOf("2026-09-01 10:00:00"));
order.setLastPayTime(Timestamp.valueOf("2026-10-01 00:00:00"));
order.setSoftDelete(0);
order.setModifyTime(Timestamp.valueOf("2026-10-11 08:30:00"));
when(mallGoodsOrderMapper.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).applyOrderRowFilter(any(), any());
Map<String, Object> item = rows.get(0).item();
assertEquals("41", item.get("order_id"));
assertEquals("SO20261001", item.get("order_no"));
assertEquals("PENDING_OUTBOUND", item.get("order_state"));
assertEquals("1001", item.get("owner_user_id"), "负责人=create_id 销售稳定 user.id");
assertEquals("望京SOHO", item.get("property_name"));
assertEquals(2000000L, item.get("total_amount_minor"), "BigDecimal 元按分输出");
assertEquals(1200050L, item.get("paid_amount_minor"));
assertEquals(799950L, item.get("unpaid_amount_minor"));
assertEquals(500000L, item.get("invoiced_amount_minor"));
assertEquals(1, item.get("overdue_flag"), "SaaS 逾期口径只读透传");
assertEquals(12, item.get("overdue_days"));
assertEquals(30, item.get("payment_collection_days"));
assertEquals(1, item.get("order_invoice_state"), "开票状态整型透传");
assertEquals("2026-10-01T00:00:00+08:00", item.get("last_pay_due_at"));
assertNull(item.get("deleted_at"));
}
@Test
void mallOrdersRejectsUnknownOrderState() {
ToolCallException error = assertThrows(ToolCallException.class,
() -> MallOrdersSyncEntity.publicOrderState(42));
assertEquals(McpErrorCode.DATA_INTEGRITY_ERROR, error.getCode());
}
@Test
void deviceLifecyclesMapsNetworkStateAndAppliesFilter() {
DeviceLifecyclesSyncEntity entity = new DeviceLifecyclesSyncEntity(deviceLifeCycleMapper, permissionService);
DeviceLifeCycle cycle = new DeviceLifeCycle();
cycle.setId(61L);
cycle.setSnCode("SN123456");
cycle.setDeviceType(0);
cycle.setExchangeState(9);
cycle.setNetworkState(0);
cycle.setAssetState(2);
cycle.setAgentId(21L);
cycle.setAgentName("趣致北京");
cycle.setLastOnlineDate(Timestamp.valueOf("2026-10-01 12:00:00"));
cycle.setSoftDelete(0);
cycle.setModifyTime(Timestamp.valueOf("2026-10-11 08:30:00"));
when(deviceLifeCycleMapper.selectList(any())).thenReturn(List.of(cycle));
User user = new User();
user.setId(7L);
List<SyncRow> rows = entity.fetchPage(null, 2000L, null, null, 3, user);
verify(permissionService).applyDeviceLifeCycleRowFilter(any(), any());
Map<String, Object> item = rows.get(0).item();
assertEquals("61", item.get("lifecycle_id"));
assertEquals("SN123456", item.get("sn_code"));
assertEquals("OFFLINE", item.get("network_state"));
assertEquals(9, item.get("exchange_state"), "流转状态整型透传");
assertEquals("21", item.get("agent_id"));
assertEquals("2026-10-01T12:00:00+08:00", item.get("last_online_at"));
}
@Test
void deviceLifecyclesRejectsUnknownNetworkState() {
ToolCallException error = assertThrows(ToolCallException.class,
() -> DeviceLifecyclesSyncEntity.publicNetworkState(7));
assertEquals(McpErrorCode.DATA_INTEGRITY_ERROR, error.getCode());
}
@Test
void batteryFaultsMapsFieldsAndAppliesFilterWithTombstone() {
BatteryFaultsSyncEntity entity = new BatteryFaultsSyncEntity(batteryFaultLogMapper, permissionService);
BatteryFaultLog fault = new BatteryFaultLog();
fault.setId(71L);
fault.setSnCode("SN123456");
fault.setDeviceType(2);
fault.setFaultType(3);
fault.setFaultState(1);
fault.setFaultStartTime(Timestamp.valueOf("2026-10-10 09:00:00"));
fault.setFaultRecoveryTime(Timestamp.valueOf("2026-10-11 09:00:00"));
fault.setInstallPosition("3F 男卫");
fault.setDistrictId(3L);
fault.setDistrictName("望京SOHO T3");
fault.setPropertyId(9L);
fault.setAgentId(21L);
fault.setPeakValue(88);
fault.setStandardValue(50);
fault.setSoftDelete(1);
fault.setModifyTime(Timestamp.valueOf("2026-10-11 10:00:00"));
when(batteryFaultLogMapper.selectList(any())).thenReturn(List.of(fault));
User user = new User();
user.setId(7L);
List<SyncRow> rows = entity.fetchPage(null, 2000L, null, null, 3, user);
verify(permissionService).applyBatteryFaultLogRowFilter(any(), any());
Map<String, Object> item = rows.get(0).item();
assertEquals("71", item.get("fault_id"));
assertEquals("SN123456", item.get("sn_code"));
assertEquals(3, item.get("fault_type"), "故障类型整型透传");
assertEquals("2026-10-10T09:00:00+08:00", item.get("fault_started_at"));
assertEquals("2026-10-11T09:00:00+08:00", item.get("fault_recovered_at"));
assertEquals("3F 男卫", item.get("install_position"));
assertEquals("望京SOHO T3", item.get("district_name"));
assertEquals(88, item.get("peak_value"));
assertEquals("2026-10-11T10:00:00+08:00", item.get("deleted_at"), "软删行返回 tombstone");
}
}
@@ -23,7 +23,7 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 五个 changes 工具的注册与路由(REQ-20260911-0013,回款域扩展于 REQ-20260902-0002 P2)。
* 八个 changes 工具的注册与路由(REQ-20260911-0013,P2 域扩展于 REQ-20260902-0002)。
*/
@ExtendWith(MockitoExtension.class)
class ChangesToolsTest {
@@ -41,9 +41,10 @@ class ChangesToolsTest {
callback -> callback.getToolDefinition().name(),
callback -> callback.getToolDefinition().inputSchema()));
assertEquals(6, schemas.size());
assertEquals(9, schemas.size());
for (String name : new String[]{"users.changes.list", "properties.changes.list", "contracts.changes.list",
"repayment-targets.changes.list", "repayment-details.changes.list"}) {
"repayment-targets.changes.list", "repayment-details.changes.list",
"mall-orders.changes.list", "device-lifecycles.changes.list", "battery-faults.changes.list"}) {
assertTrue(schemas.containsKey(name), "缺少工具 " + name);
String schema = schemas.get(name);
assertTrue(schema.contains("syncToken"));
@@ -75,6 +76,15 @@ class ChangesToolsTest {
tools.repaymentDetailsChangesList(null, null, 100);
verify(changesEngine).execute("repayment-details", null, null, 100);
tools.mallOrdersChangesList("t4", null, null);
verify(changesEngine).execute("mall-orders", "t4", null, null);
tools.deviceLifecyclesChangesList(null, "c5", 10);
verify(changesEngine).execute("device-lifecycles", null, "c5", 10);
tools.batteryFaultsChangesList(null, null, null);
verify(changesEngine).execute("battery-faults", null, null, null);
}
@Test