From 00b9c2d27f96cd5d9eb6fd709ddb5af49b2299bf Mon Sep 17 00:00:00 2001 From: zhiyuncai Date: Fri, 16 Oct 2026 12:15:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20=E5=9B=9E=E6=AC=BE=E7=9B=AE?= =?UTF-8?q?=E6=A0=87/=E5=9B=9E=E6=AC=BE=E6=98=8E=E7=BB=86=20changes=20?= =?UTF-8?q?=E6=B5=81=E5=B7=A5=E5=85=B7=EF=BC=88P2=20=E5=BA=94=E6=94=B6?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=87=86=E5=85=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - repayment-targets.changes.list:m1.saler_repayment 月度回款目标,status 0/1→INCOMPLETE/COMPLETE 未知值 fail-closed - repayment-details.changes.list:m1.saler_repayment_customer 客户×订单×周期明细,overdue_status/overdue_time 等 SaaS 既有口径只读透传 - 金额 Double 元统一转 *_minor(分,Long),四舍五入 - 行级权限:目标(总部全量/销售本人/代理范围),明细与合同同 ACL(销售房产范围/主管代理范围) - 测试:SyncEntitiesTest +5、ChangesToolsTest 注册数 4→6 并补路由验证,共 18 项全过 --- .../iiv/agent/entity/m1/SalerRepayment.java | 105 ++++++++++++ .../entity/m1/SalerRepaymentCustomer.java | 153 ++++++++++++++++++ .../m1/SalerRepaymentCustomerMapper.java | 11 ++ .../agent/mapper/m1/SalerRepaymentMapper.java | 11 ++ .../mcp/sync/RepaymentDetailsSyncEntity.java | 110 +++++++++++++ .../mcp/sync/RepaymentTargetsSyncEntity.java | 106 ++++++++++++ .../iiv/agent/mcp/sync/SyncChangeSchemas.java | 62 +++++++ .../me/iiv/agent/mcp/tool/ChangesTools.java | 32 ++++ .../agent/permission/PermissionService.java | 39 +++++ .../iiv/agent/mcp/sync/SyncEntitiesTest.java | 128 +++++++++++++++ .../iiv/agent/mcp/tool/ChangesToolsTest.java | 13 +- 11 files changed, 767 insertions(+), 3 deletions(-) create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m1/SalerRepayment.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m1/SalerRepaymentCustomer.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m1/SalerRepaymentCustomerMapper.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m1/SalerRepaymentMapper.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/RepaymentDetailsSyncEntity.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/RepaymentTargetsSyncEntity.java diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m1/SalerRepayment.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m1/SalerRepayment.java new file mode 100644 index 0000000..72ee432 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m1/SalerRepayment.java @@ -0,0 +1,105 @@ +package me.iiv.agent.entity.m1; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import java.io.Serializable; +import java.util.Date; + +/** + * 销售回款目标表(按月汇总,m1.saler_repayment)。 + * 金额列为 legacy Double(元),公开契约层统一换算为分(*_minor)。 + */ +@Getter +@Setter +@ToString +@TableName("saler_repayment") +public class SalerRepayment implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** 创建id */ + private Long createId; + + /** 创建时间 */ + private Date createTime; + + /** 修改ID */ + private Long modifyId; + + /** 修改时间 */ + private Date modifyTime; + + /** 数据状态 */ + private Integer softDelete; + + /** 下单公司(代理ID) */ + private Long agentId; + + /** 销售id */ + private Long userId; + + /** 下单公司名称 */ + private String agentName; + + /** 销售名称 */ + private String userName; + + /** 周期(yyyy-mm) */ + private String period; + + /** 待收款金额(按期) */ + private Double pendingReceivableAmount; + + /** 超期待收款金额 */ + private Double overdueReceivableAmount; + + /** 待收款总金额 */ + private Double totalReceivableAmount; + + /** 按期还款金额 */ + private Double pendingRepaymentAmount; + + /** 超期还款金额 */ + private Double overdueRepaymentAmount; + + /** 还款总金额 */ + private Double totalRepaymentAmount; + + /** 按期目标金额 */ + private Double pendingTargetAmount; + + /** 超期目标金额 */ + private Double overdueTargetAmount; + + /** 目标金额 */ + private Double totalTargetAmount; + + /** 订单数 */ + private Integer totalOrderCount; + + /** 超期订单数 */ + private Integer overdueOrderCount; + + /** 订单总金额 */ + private Double totalOrderAmount; + + /** 客户数 */ + private Integer totalCustomerCount; + + /** 超期客户数 */ + private Integer overdueCustomerCount; + + /** 当期开票金额 */ + private Double invoiceAmount; + + /** 完成状态(0:未完成;1:已完成) */ + private Integer status; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m1/SalerRepaymentCustomer.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m1/SalerRepaymentCustomer.java new file mode 100644 index 0000000..bbd7623 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m1/SalerRepaymentCustomer.java @@ -0,0 +1,153 @@ +package me.iiv.agent.entity.m1; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import java.io.Serializable; +import java.util.Date; + +/** + * 销售回款客户明细表(客户 × 订单 × 周期,m1.saler_repayment_customer)。 + * 金额列为 legacy Double(元),公开契约层统一换算为分(*_minor)。 + */ +@Getter +@Setter +@ToString +@TableName("saler_repayment_customer") +public class SalerRepaymentCustomer implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** 创建id */ + private Long createId; + + /** 创建时间 */ + private Date createTime; + + /** 修改ID */ + private Long modifyId; + + /** 修改时间 */ + private Date modifyTime; + + /** 数据状态 */ + private Integer softDelete; + + /** 公司id */ + private Long agentId; + + /** 下单id */ + private Long userId; + + /** 销售id */ + private Long salerUserId; + + /** 预下单人员id */ + private Long placingUserId; + + /** 公司名称 */ + private String agentName; + + /** 下单人员名称 */ + private String userName; + + /** 销售名称 */ + private String salerUserName; + + /** 预下单人员名称 */ + private String placingUserName; + + /** 周期(yyyy-mm) */ + private String period; + + /** 待收款金额 */ + private Double pendingReceivableAmount; + + /** 超期待收款金额 */ + private Double overdueReceivableAmount; + + /** 待收款总金额 */ + private Double totalReceivableAmount; + + /** 按期还款金额 */ + private Double pendingRepaymentAmount; + + /** 超期还款金额 */ + private Double overdueRepaymentAmount; + + /** 还款总金额 */ + private Double totalRepaymentAmount; + + /** 订单数 */ + private Integer totalOrderCount; + + /** 超期订单数 */ + private Integer overdueOrderCount; + + /** 订单总金额 */ + private Double totalOrderAmount; + + /** 当期开票金额 */ + private Double invoiceAmount; + + /** 物业ID */ + private Long propertyId; + + /** 站点ID */ + private Long districtId; + + /** 物业名称 */ + private String propertyName; + + /** 站点名称 */ + private String districtName; + + /** 销售公司id */ + private Long salerAgentId; + + /** 销售公司名称 */ + private String salerAgentName; + + /** 上级id */ + private Long parentId; + + /** 订单编号 */ + private String orderNo; + + /** 账单编号 */ + private String billNo; + + /** 当期的超期状态(0:未超期;1:已超期) */ + private Integer overdueStatus; + + /** 超期日期 */ + private Date overdueTime; + + /** 账期类型 */ + private String billType; + + /** 发票名称 */ + private String invoiceName; + + /** 客户名称 */ + private String customerName; + + /** 下单时间 */ + private Date orderTime; + + /** 支付时间 */ + private Date payTime; + + /** 订单开票状态(legacy 整型编码,取值域待源侧确认,契约层原样透传) */ + private Integer orderInvoiceState; + + /** 账单周期 */ + private String billPeriod; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m1/SalerRepaymentCustomerMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m1/SalerRepaymentCustomerMapper.java new file mode 100644 index 0000000..15dc528 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m1/SalerRepaymentCustomerMapper.java @@ -0,0 +1,11 @@ +package me.iiv.agent.mapper.m1; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import me.iiv.agent.entity.m1.SalerRepaymentCustomer; + +/** + * 销售回款客户明细 Mapper 接口 + */ +public interface SalerRepaymentCustomerMapper extends BaseMapper { + +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m1/SalerRepaymentMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m1/SalerRepaymentMapper.java new file mode 100644 index 0000000..5828d66 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m1/SalerRepaymentMapper.java @@ -0,0 +1,11 @@ +package me.iiv.agent.mapper.m1; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import me.iiv.agent.entity.m1.SalerRepayment; + +/** + * 销售回款目标 Mapper 接口 + */ +public interface SalerRepaymentMapper extends BaseMapper { + +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/RepaymentDetailsSyncEntity.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/RepaymentDetailsSyncEntity.java new file mode 100644 index 0000000..71368fa --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/RepaymentDetailsSyncEntity.java @@ -0,0 +1,110 @@ +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.SalerRepaymentCustomer; +import me.iiv.agent.mapper.m1.SalerRepaymentCustomerMapper; +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; + +/** + * repayment-details 同步实体(m1.saler_repayment_customer 客户 × 订单 × 周期回款明细)。 + * 字段口径(2026-10-16 字段核对):owner_user_id → saler_user_id(销售,稳定 user.id); + * overdue_status/overdue_time/bill_type 为 SaaS 既有账期口径,只读透传,本侧不重算; + * 金额列为 legacy Double(元),契约层统一转分(HALF_UP);version 由 modify_time 派生; + * order_invoice_state 取值域未确认,按整型原样透传(不造枚举)。 + * 行级权限:Token 绑定人时走 PermissionService.applyRepaymentCustomerRowFilter + * (与合同同一 ACL 语义:property 维度 + 代管)。 + */ +@Component +public class RepaymentDetailsSyncEntity extends AbstractSyncEntity implements SyncEntity { + + public static final String NAME = "repayment-details"; + + final SalerRepaymentCustomerMapper salerRepaymentCustomerMapper; + final PermissionService permissionService; + + public RepaymentDetailsSyncEntity(SalerRepaymentCustomerMapper salerRepaymentCustomerMapper, + PermissionService permissionService) { + this.salerRepaymentCustomerMapper = salerRepaymentCustomerMapper; + 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.applyRepaymentCustomerRowFilter(wrapper, user); + } + applyWindow(wrapper, fromTs, cutoffTs, cursorMt, cursorId); + applyOrderAndLimit(wrapper, limit); + List rows = new ArrayList<>(); + for (SalerRepaymentCustomer row : salerRepaymentCustomerMapper.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(SalerRepaymentCustomer 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.RepaymentDetailChange( + stableId(row.getId()), stableId(row.getSalerUserId()), row.getSalerUserName(), + stableId(row.getSalerAgentId()), row.getSalerAgentName(), + stableId(row.getPropertyId()), row.getPropertyName(), + stableId(row.getDistrictId()), row.getDistrictName(), row.getCustomerName(), + row.getPeriod(), row.getBillPeriod(), row.getBillType(), + row.getOrderNo(), row.getBillNo(), + publicOverdueStatus(row.getOverdueStatus()), ChangesEngine.iso(row.getOverdueTime()), + toMinor(row.getPendingReceivableAmount()), toMinor(row.getOverdueReceivableAmount()), + toMinor(row.getTotalReceivableAmount()), + toMinor(row.getPendingRepaymentAmount()), toMinor(row.getOverdueRepaymentAmount()), + toMinor(row.getTotalRepaymentAmount()), toMinor(row.getInvoiceAmount()), + row.getOrderInvoiceState(), + ChangesEngine.iso(row.getOrderTime()), ChangesEngine.iso(row.getPayTime()), + ChangesEngine.iso(effective), mt, deleted ? ChangesEngine.iso(effective) : null)); + } + + /** legacy Double(元)→ 分;null 原样返回 null。 */ + static Long toMinor(Double amount) { + return amount == null ? null + : BigDecimal.valueOf(amount).movePointRight(2).setScale(0, RoundingMode.HALF_UP).longValue(); + } + + /** Legacy overdue flag to the stable public enum. */ + static String publicOverdueStatus(Integer status) { + if (status == null) { + throw invalidOverdueStatus(null); + } + return switch (status) { + case 0 -> "NOT_OVERDUE"; + case 1 -> "OVERDUE"; + default -> throw invalidOverdueStatus(status); + }; + } + + private static ToolCallException invalidOverdueStatus(Integer status) { + return new ToolCallException(McpErrorCode.DATA_INTEGRITY_ERROR, + "回款明细超期状态无法映射到公开契约: " + status); + } + + 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/RepaymentTargetsSyncEntity.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/RepaymentTargetsSyncEntity.java new file mode 100644 index 0000000..34424bb --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/RepaymentTargetsSyncEntity.java @@ -0,0 +1,106 @@ +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.SalerRepayment; +import me.iiv.agent.mapper.m1.SalerRepaymentMapper; +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; + +/** + * repayment-targets 同步实体(m1.saler_repayment 销售月度回款目标)。 + * 字段口径(2026-10-16 字段核对):owner_user_id → user_id(销售,稳定 user.id); + * 金额列为 legacy Double(元),契约层统一转分(HALF_UP);version 由 modify_time 派生; + * status 0/1 映射 INCOMPLETE/COMPLETE,未知编码 fail-closed 拒绝(与 contracts 同一铁律)。 + * 行级权限:Token 绑定人时走 PermissionService.applyRepaymentTargetRowFilter。 + */ +@Component +public class RepaymentTargetsSyncEntity extends AbstractSyncEntity implements SyncEntity { + + public static final String NAME = "repayment-targets"; + + final SalerRepaymentMapper salerRepaymentMapper; + final PermissionService permissionService; + + public RepaymentTargetsSyncEntity(SalerRepaymentMapper salerRepaymentMapper, + PermissionService permissionService) { + this.salerRepaymentMapper = salerRepaymentMapper; + 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.applyRepaymentTargetRowFilter(wrapper, user); + } + applyWindow(wrapper, fromTs, cutoffTs, cursorMt, cursorId); + applyOrderAndLimit(wrapper, limit); + List rows = new ArrayList<>(); + for (SalerRepayment row : salerRepaymentMapper.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(SalerRepayment 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.RepaymentTargetChange( + stableId(row.getId()), stableId(row.getUserId()), row.getUserName(), + stableId(row.getAgentId()), row.getAgentName(), row.getPeriod(), + toMinor(row.getTotalTargetAmount()), toMinor(row.getPendingTargetAmount()), + toMinor(row.getOverdueTargetAmount()), + toMinor(row.getTotalRepaymentAmount()), toMinor(row.getPendingRepaymentAmount()), + toMinor(row.getOverdueRepaymentAmount()), + toMinor(row.getTotalReceivableAmount()), toMinor(row.getPendingReceivableAmount()), + toMinor(row.getOverdueReceivableAmount()), + row.getTotalOrderCount(), row.getOverdueOrderCount(), + row.getTotalCustomerCount(), row.getOverdueCustomerCount(), + toMinor(row.getInvoiceAmount()), publicStatus(row.getStatus()), + ChangesEngine.iso(effective), mt, deleted ? ChangesEngine.iso(effective) : null)); + } + + /** legacy Double(元)→ 分;null 原样返回 null。 */ + static Long toMinor(Double amount) { + return amount == null ? null + : BigDecimal.valueOf(amount).movePointRight(2).setScale(0, RoundingMode.HALF_UP).longValue(); + } + + /** Legacy database status code to the stable public enum. */ + static String publicStatus(Integer status) { + if (status == null) { + throw invalidStatus(null); + } + return switch (status) { + case 0 -> "INCOMPLETE"; + case 1 -> "COMPLETE"; + default -> throw invalidStatus(status); + }; + } + + private static ToolCallException invalidStatus(Integer status) { + return new ToolCallException(McpErrorCode.DATA_INTEGRITY_ERROR, + "回款目标完成状态无法映射到公开契约: " + status); + } + + 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 a664cd6..b54b622 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 @@ -75,4 +75,66 @@ final class SyncChangeSchemas { long version, @JsonProperty("deleted_at") String deletedAt) { } + + /** 回款目标(销售 × 月):目标/还款/应收三组金额均为分。 */ + record RepaymentTargetChange( + @JsonProperty("repayment_id") String repaymentId, + @JsonProperty("owner_user_id") String ownerUserId, + @JsonProperty("owner_name") String ownerName, + @JsonProperty("agent_id") String agentId, + @JsonProperty("agent_name") String agentName, + String period, + @JsonProperty("total_target_amount_minor") Long totalTargetAmountMinor, + @JsonProperty("pending_target_amount_minor") Long pendingTargetAmountMinor, + @JsonProperty("overdue_target_amount_minor") Long overdueTargetAmountMinor, + @JsonProperty("total_repayment_amount_minor") Long totalRepaymentAmountMinor, + @JsonProperty("pending_repayment_amount_minor") Long pendingRepaymentAmountMinor, + @JsonProperty("overdue_repayment_amount_minor") Long overdueRepaymentAmountMinor, + @JsonProperty("total_receivable_amount_minor") Long totalReceivableAmountMinor, + @JsonProperty("pending_receivable_amount_minor") Long pendingReceivableAmountMinor, + @JsonProperty("overdue_receivable_amount_minor") Long overdueReceivableAmountMinor, + @JsonProperty("total_order_count") Integer totalOrderCount, + @JsonProperty("overdue_order_count") Integer overdueOrderCount, + @JsonProperty("total_customer_count") Integer totalCustomerCount, + @JsonProperty("overdue_customer_count") Integer overdueCustomerCount, + @JsonProperty("invoice_amount_minor") Long invoiceAmountMinor, + String status, + @JsonProperty("updated_at") String updatedAt, + long version, + @JsonProperty("deleted_at") String deletedAt) { + } + + /** 回款明细(客户 × 订单 × 周期):超期状态与超期日期为 SaaS 既有口径,只读透传。 */ + record RepaymentDetailChange( + @JsonProperty("repayment_detail_id") String repaymentDetailId, + @JsonProperty("owner_user_id") String ownerUserId, + @JsonProperty("owner_name") String ownerName, + @JsonProperty("saler_agent_id") String salerAgentId, + @JsonProperty("saler_agent_name") String salerAgentName, + @JsonProperty("property_id") String propertyId, + @JsonProperty("property_name") String propertyName, + @JsonProperty("district_id") String districtId, + @JsonProperty("district_name") String districtName, + @JsonProperty("customer_name") String customerName, + String period, + @JsonProperty("bill_period") String billPeriod, + @JsonProperty("bill_type") String billType, + @JsonProperty("order_no") String orderNo, + @JsonProperty("bill_no") String billNo, + @JsonProperty("overdue_status") String overdueStatus, + @JsonProperty("overdue_at") String overdueAt, + @JsonProperty("pending_receivable_amount_minor") Long pendingReceivableAmountMinor, + @JsonProperty("overdue_receivable_amount_minor") Long overdueReceivableAmountMinor, + @JsonProperty("total_receivable_amount_minor") Long totalReceivableAmountMinor, + @JsonProperty("pending_repayment_amount_minor") Long pendingRepaymentAmountMinor, + @JsonProperty("overdue_repayment_amount_minor") Long overdueRepaymentAmountMinor, + @JsonProperty("total_repayment_amount_minor") Long totalRepaymentAmountMinor, + @JsonProperty("invoice_amount_minor") Long invoiceAmountMinor, + @JsonProperty("order_invoice_state") Integer orderInvoiceState, + @JsonProperty("order_at") String orderAt, + @JsonProperty("paid_at") String paidAt, + @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 d4657e1..a1512ff 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 @@ -7,6 +7,8 @@ import me.iiv.agent.mcp.XqTool; import me.iiv.agent.mcp.sync.ChangesEngine; import me.iiv.agent.mcp.sync.ContractsSyncEntity; import me.iiv.agent.mcp.sync.PropertiesSyncEntity; +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.security.AuthContext; import me.iiv.agent.security.AuthContextHolder; @@ -114,6 +116,36 @@ public class ChangesTools implements ToolObject { return result; } + @XqTool(category = "sync", kind = "read") + @Tool(name = "repayment-targets.changes.list", description = "增量同步销售月度回款目标(m1.saler_repayment)。" + PROTOCOL_DESC + + "items 字段:repayment_id/owner_user_id(销售稳定 user.id)/owner_name/agent_id/agent_name/" + + "period(yyyy-mm)/目标、还款、应收三组金额(*_minor,分,各含 total/pending/overdue)/" + + "total_order_count/overdue_order_count/total_customer_count/overdue_customer_count/" + + "invoice_amount_minor/status(INCOMPLETE/COMPLETE)/updated_at/version/deleted_at。" + + "行级权限跟随 Token 绑定的人(销售非主管仅本人,其余按代理范围)。") + public Map repaymentTargetsChangesList( + @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(RepaymentTargetsSyncEntity.NAME, syncToken, cursor, limit); + } + + @XqTool(category = "sync", kind = "read") + @Tool(name = "repayment-details.changes.list", description = "增量同步客户回款明细(m1.saler_repayment_customer,客户×订单×周期)。" + PROTOCOL_DESC + + "items 字段:repayment_detail_id/owner_user_id(销售稳定 user.id)/owner_name/" + + "saler_agent_id/saler_agent_name/property_id/property_name/district_id/district_name/customer_name/" + + "period/bill_period/bill_type(账期类型)/order_no/bill_no/" + + "overdue_status(NOT_OVERDUE/OVERDUE,SaaS 既有口径只读透传)/overdue_at/" + + "应收与还款金额(*_minor,分,各含 pending/overdue/total)/invoice_amount_minor/" + + "order_invoice_state(整型透传)/order_at/paid_at/updated_at/version/deleted_at。" + + "行级权限跟随 Token 绑定的人(与合同同一 ACL 语义)。") + public Map repaymentDetailsChangesList( + @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(RepaymentDetailsSyncEntity.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 491350c..2dcd437 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 @@ -11,6 +11,8 @@ import me.iiv.agent.entity.m0.Properyt; import me.iiv.agent.entity.m0.ToiletAirquality; 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.entity.m2.DeliveryOrder; import me.iiv.agent.entity.m2.InstallOrder; @@ -923,4 +925,41 @@ public class PermissionService { wrapper.in(ContractNew::getContractAgentId, scope.ids); } } + + /** + * 回款目标(m1 saler_repayment)行权限:总部/服务号全量;目标表无 property 维度, + * 销售非主管退化为本人行(user_id=自己);其余按代理范围落到 agent_id。 + */ + public void applyRepaymentTargetRowFilter(LambdaQueryWrapper wrapper, User user) { + RowScope scope = resolveRowScope(user); + if (scope.headquarters) { + return; + } + if (scope.salesPropertyIds != null) { + wrapper.eq(SalerRepayment::getUserId, user.getId()); + return; + } + wrapper.in(SalerRepayment::getAgentId, scope.ids); + } + + /** + * 回款明细(m1 saler_repayment_customer)行权限:与合同同一 ACL 语义—— + * 销售非主管按 property_id 集合;其余按销售公司(saler_agent_id)+ 代管物业组合。 + */ + public void applyRepaymentCustomerRowFilter(LambdaQueryWrapper wrapper, User user) { + RowScope scope = resolveRowScope(user); + if (scope.headquarters) { + return; + } + if (scope.salesPropertyIds != null) { + wrapper.in(SalerRepaymentCustomer::getPropertyId, scope.salesPropertyIds); + return; + } + if (!scope.managedPropertyIds.isEmpty()) { + wrapper.and(w -> w.in(SalerRepaymentCustomer::getSalerAgentId, scope.ids) + .or().in(SalerRepaymentCustomer::getPropertyId, scope.managedPropertyIds)); + } else { + wrapper.in(SalerRepaymentCustomer::getSalerAgentId, 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 0b3bc06..b844ed1 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 @@ -5,9 +5,13 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import me.iiv.agent.entity.m0.Properyt; import me.iiv.agent.entity.m0.User; +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.ProperytMapper; import me.iiv.agent.mapper.m0.UserMapper; +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.mcp.McpErrorCode; import me.iiv.agent.mcp.ToolCallException; @@ -50,6 +54,10 @@ class SyncEntitiesTest { @Mock ContractNewMapper contractNewMapper; @Mock + SalerRepaymentMapper salerRepaymentMapper; + @Mock + SalerRepaymentCustomerMapper salerRepaymentCustomerMapper; + @Mock PermissionService permissionService; @BeforeEach @@ -57,6 +65,8 @@ class SyncEntitiesTest { TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), User.class); TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), Properyt.class); TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), ContractNew.class); + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), SalerRepayment.class); + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), SalerRepaymentCustomer.class); } @Test @@ -291,4 +301,122 @@ class SyncEntitiesTest { assertEquals("2026-09-15T09:00:00+08:00", items.get(0).get("deleted_at")); verify(permissionService, never()).applyContractRowFilter(any(), any()); } + + @Test + void repaymentTargetsMapsAmountsToMinorAndStatus() { + RepaymentTargetsSyncEntity entity = new RepaymentTargetsSyncEntity(salerRepaymentMapper, permissionService); + SalerRepayment target = new SalerRepayment(); + target.setId(5L); + target.setUserId(1001L); + target.setUserName("张销售"); + target.setAgentId(21L); + target.setAgentName("趣致北京"); + target.setPeriod("2026-10"); + target.setTotalTargetAmount(200000.00); + target.setOverdueTargetAmount(50000.005); + target.setTotalRepaymentAmount(120000.10); + target.setOverdueReceivableAmount(30000.00); + target.setTotalOrderCount(8); + target.setOverdueOrderCount(2); + target.setTotalCustomerCount(5); + target.setOverdueCustomerCount(1); + target.setStatus(0); + target.setSoftDelete(0); + target.setModifyTime(Timestamp.valueOf("2026-10-10 09:00:00")); + when(salerRepaymentMapper.selectList(any())).thenReturn(List.of(target)); + User user = new User(); + user.setId(7L); + + List rows = entity.fetchPage(null, 2000L, null, null, 3, user); + + verify(permissionService).applyRepaymentTargetRowFilter(any(), any()); + Map item = rows.get(0).item(); + assertEquals("5", item.get("repayment_id")); + assertEquals("1001", item.get("owner_user_id")); + assertEquals("张销售", item.get("owner_name")); + assertEquals("2026-10", item.get("period")); + assertEquals(20000000L, item.get("total_target_amount_minor"), "金额按分输出"); + assertEquals(5000001L, item.get("overdue_target_amount_minor"), "legacy Double 转分四舍五入"); + assertEquals(12000010L, item.get("total_repayment_amount_minor")); + assertEquals(3000000L, item.get("overdue_receivable_amount_minor")); + assertEquals(8, item.get("total_order_count")); + assertEquals(2, item.get("overdue_order_count")); + assertEquals("INCOMPLETE", item.get("status")); + assertNull(item.get("deleted_at")); + } + + @Test + void repaymentTargetsRejectsUnknownStatus() { + ToolCallException error = assertThrows(ToolCallException.class, + () -> RepaymentTargetsSyncEntity.publicStatus(7)); + + assertEquals(McpErrorCode.DATA_INTEGRITY_ERROR, error.getCode()); + } + + @Test + void repaymentDetailsMapsOverdueFieldsAndTombstone() { + RepaymentDetailsSyncEntity entity = new RepaymentDetailsSyncEntity(salerRepaymentCustomerMapper, permissionService); + SalerRepaymentCustomer detail = new SalerRepaymentCustomer(); + detail.setId(31L); + detail.setSalerUserId(1001L); + detail.setSalerUserName("张销售"); + detail.setSalerAgentId(21L); + detail.setPropertyId(9L); + detail.setPropertyName("望京SOHO"); + detail.setDistrictId(3L); + detail.setCustomerName("金蝶客户名称"); + detail.setPeriod("2026-10"); + detail.setBillPeriod("2026-09"); + detail.setBillType("月结30天"); + detail.setOrderNo("SO20260901"); + detail.setBillNo("B20260901-1"); + detail.setOverdueStatus(1); + detail.setOverdueTime(Timestamp.valueOf("2026-10-01 00:00:00")); + detail.setOverdueReceivableAmount(8000.00); + detail.setTotalReceivableAmount(20000.00); + detail.setTotalRepaymentAmount(12000.00); + detail.setOrderInvoiceState(2); + detail.setOrderTime(Timestamp.valueOf("2026-09-01 10:00:00")); + detail.setSoftDelete(1); + detail.setModifyTime(Timestamp.valueOf("2026-10-11 08:30:00")); + when(salerRepaymentCustomerMapper.selectList(any())).thenReturn(List.of(detail)); + + List rows = entity.fetchPage(null, 2000L, null, null, 3, null); + + verify(permissionService, never()).applyRepaymentCustomerRowFilter(any(), any()); + Map item = rows.get(0).item(); + assertEquals("31", item.get("repayment_detail_id")); + assertEquals("1001", item.get("owner_user_id"), "销售=saler_user_id 稳定 user.id"); + assertEquals("9", item.get("property_id")); + assertEquals("望京SOHO", item.get("property_name")); + assertEquals("金蝶客户名称", item.get("customer_name")); + assertEquals("月结30天", item.get("bill_type"), "账期类型只读透传"); + assertEquals("OVERDUE", item.get("overdue_status")); + assertEquals("2026-10-01T00:00:00+08:00", item.get("overdue_at")); + assertEquals(800000L, item.get("overdue_receivable_amount_minor")); + assertEquals(2000000L, item.get("total_receivable_amount_minor")); + assertEquals(2, item.get("order_invoice_state"), "开票状态整型透传,不造枚举"); + assertEquals("2026-09-01T10:00:00+08:00", item.get("order_at")); + assertEquals("2026-10-11T08:30:00+08:00", item.get("deleted_at"), "软删行返回 tombstone"); + } + + @Test + void repaymentDetailsAppliesRowFilterForUserToken() { + RepaymentDetailsSyncEntity entity = new RepaymentDetailsSyncEntity(salerRepaymentCustomerMapper, permissionService); + when(salerRepaymentCustomerMapper.selectList(any())).thenReturn(List.of()); + User user = new User(); + user.setId(7L); + + entity.fetchPage(null, 2000L, null, null, 3, user); + + verify(permissionService).applyRepaymentCustomerRowFilter(any(), any()); + } + + @Test + void repaymentDetailsRejectsUnknownOverdueStatus() { + ToolCallException error = assertThrows(ToolCallException.class, + () -> RepaymentDetailsSyncEntity.publicOverdueStatus(9)); + + assertEquals(McpErrorCode.DATA_INTEGRITY_ERROR, error.getCode()); + } } 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 2843405..6756beb 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)。 + * 五个 changes 工具的注册与路由(REQ-20260911-0013,回款域扩展于 REQ-20260902-0002 P2)。 */ @ExtendWith(MockitoExtension.class) class ChangesToolsTest { @@ -41,8 +41,9 @@ class ChangesToolsTest { callback -> callback.getToolDefinition().name(), callback -> callback.getToolDefinition().inputSchema())); - assertEquals(4, schemas.size()); - for (String name : new String[]{"users.changes.list", "properties.changes.list", "contracts.changes.list"}) { + assertEquals(6, schemas.size()); + for (String name : new String[]{"users.changes.list", "properties.changes.list", "contracts.changes.list", + "repayment-targets.changes.list", "repayment-details.changes.list"}) { assertTrue(schemas.containsKey(name), "缺少工具 " + name); String schema = schemas.get(name); assertTrue(schema.contains("syncToken")); @@ -68,6 +69,12 @@ class ChangesToolsTest { tools.contractsChangesList("t2", null, 200); verify(changesEngine).execute("contracts", "t2", null, 200); + + tools.repaymentTargetsChangesList("t3", "c3", null); + verify(changesEngine).execute("repayment-targets", "t3", "c3", null); + + tools.repaymentDetailsChangesList(null, null, 100); + verify(changesEngine).execute("repayment-details", null, null, 100); } @Test