From f4cdc8e7bf76c263179113d5e1a969d33a0221de Mon Sep 17 00:00:00 2001 From: zhiyuncai Date: Fri, 16 Oct 2026 12:30:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20=E5=95=86=E5=9F=8E=E8=AE=A2?= =?UTF-8?q?=E5=8D=95/=E8=AE=BE=E5=A4=87=E7=94=9F=E5=91=BD=E5=91=A8?= =?UTF-8?q?=E6=9C=9F/=E7=94=B5=E6=B1=A0=E6=95=85=E9=9A=9C=20changes=20?= =?UTF-8?q?=E6=B5=81=EF=BC=88P2=20=E5=A2=9E=E8=B4=AD=E5=A4=8D=E8=B4=AD?= =?UTF-8?q?=E4=B8=8E=E8=AE=BE=E5=A4=87=E5=9F=9F=E5=87=86=E5=85=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 项测试通过 --- .../mcp/sync/BatteryFaultsSyncEntity.java | 78 +++++++++ .../mcp/sync/DeviceLifecyclesSyncEntity.java | 94 ++++++++++ .../agent/mcp/sync/MallOrdersSyncEntity.java | 120 +++++++++++++ .../iiv/agent/mcp/sync/SyncChangeSchemas.java | 92 ++++++++++ .../me/iiv/agent/mcp/tool/ChangesTools.java | 52 +++++- .../agent/permission/PermissionService.java | 13 ++ .../iiv/agent/mcp/sync/SyncEntitiesTest.java | 163 +++++++++++++++++- .../iiv/agent/mcp/tool/ChangesToolsTest.java | 16 +- 8 files changed, 623 insertions(+), 5 deletions(-) create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/BatteryFaultsSyncEntity.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/DeviceLifecyclesSyncEntity.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/MallOrdersSyncEntity.java diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/BatteryFaultsSyncEntity.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/BatteryFaultsSyncEntity.java new file mode 100644 index 0000000..6e43150 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/BatteryFaultsSyncEntity.java @@ -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 fetchPage(Long fromTs, long cutoffTs, Long cursorMt, Long cursorId, + int limit, User user) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + if (user != null) { + permissionService.applyBatteryFaultLogRowFilter(wrapper, user); + } + applyWindow(wrapper, fromTs, cutoffTs, cursorMt, cursorId); + applyOrderAndLimit(wrapper, limit); + List 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 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(); + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/DeviceLifecyclesSyncEntity.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/DeviceLifecyclesSyncEntity.java new file mode 100644 index 0000000..39665b0 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/DeviceLifecyclesSyncEntity.java @@ -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 fetchPage(Long fromTs, long cutoffTs, Long cursorMt, Long cursorId, + int limit, User user) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + if (user != null) { + permissionService.applyDeviceLifeCycleRowFilter(wrapper, user); + } + applyWindow(wrapper, fromTs, cutoffTs, cursorMt, cursorId); + applyOrderAndLimit(wrapper, limit); + List 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 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(); + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/MallOrdersSyncEntity.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/MallOrdersSyncEntity.java new file mode 100644 index 0000000..dbcc2ca --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/MallOrdersSyncEntity.java @@ -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 fetchPage(Long fromTs, long cutoffTs, Long cursorMt, Long cursorId, + int limit, User user) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + if (user != null) { + permissionService.applyOrderRowFilter(wrapper, user); + } + applyWindow(wrapper, fromTs, cutoffTs, cursorMt, cursorId); + applyOrderAndLimit(wrapper, limit); + List 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 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(); + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/SyncChangeSchemas.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/SyncChangeSchemas.java index b54b622..18ec94b 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/SyncChangeSchemas.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/SyncChangeSchemas.java @@ -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) { + } } diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/ChangesTools.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/ChangesTools.java index a1512ff..e2c5c1e 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/ChangesTools.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/ChangesTools.java @@ -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 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 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 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 parseContractIds(List contractIds) { if (contractIds == null || contractIds.isEmpty()) { throw new ToolCallException(McpErrorCode.INVALID_ARGUMENT, "contractIds必须包含1至200个合同ID"); diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/permission/PermissionService.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/permission/PermissionService.java index 2dcd437..6b1c22a 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/permission/PermissionService.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/permission/PermissionService.java @@ -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 wrapper, User user) { + RowScope scope = resolveRowScope(user); + if (scope.headquarters) { + return; + } + wrapper.in(DeviceLifeCycle::getAgentId, scope.ids); + } } diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/sync/SyncEntitiesTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/sync/SyncEntitiesTest.java index b844ed1..c65f2a1 100644 --- a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/sync/SyncEntitiesTest.java +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/sync/SyncEntitiesTest.java @@ -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 rows = entity.fetchPage(null, 2000L, null, null, 3, user); + + verify(permissionService).applyOrderRowFilter(any(), any()); + Map 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 rows = entity.fetchPage(null, 2000L, null, null, 3, user); + + verify(permissionService).applyDeviceLifeCycleRowFilter(any(), any()); + Map 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 rows = entity.fetchPage(null, 2000L, null, null, 3, user); + + verify(permissionService).applyBatteryFaultLogRowFilter(any(), any()); + Map 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"); + } } diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/ChangesToolsTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/ChangesToolsTest.java index 6756beb..0db6da0 100644 --- a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/ChangesToolsTest.java +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/ChangesToolsTest.java @@ -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