feat(mcp): 回款目标/回款明细 changes 流工具(P2 应收数据准入)

- 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 项全过
This commit is contained in:
zhiyuncai
2026-10-16 12:15:52 +08:00
parent 32b6719376
commit 00b9c2d27f
11 changed files with 767 additions and 3 deletions
@@ -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;
}
@@ -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;
}
@@ -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<SalerRepaymentCustomer> {
}
@@ -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<SalerRepayment> {
}
@@ -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<SyncRow> fetchPage(Long fromTs, long cutoffTs, Long cursorMt, Long cursorId,
int limit, User user) {
LambdaQueryWrapper<SalerRepaymentCustomer> wrapper = new LambdaQueryWrapper<>();
if (user != null) {
permissionService.applyRepaymentCustomerRowFilter(wrapper, user);
}
applyWindow(wrapper, fromTs, cutoffTs, cursorMt, cursorId);
applyOrderAndLimit(wrapper, limit);
List<SyncRow> 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<String, Object> 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();
}
}
@@ -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<SyncRow> fetchPage(Long fromTs, long cutoffTs, Long cursorMt, Long cursorId,
int limit, User user) {
LambdaQueryWrapper<SalerRepayment> wrapper = new LambdaQueryWrapper<>();
if (user != null) {
permissionService.applyRepaymentTargetRowFilter(wrapper, user);
}
applyWindow(wrapper, fromTs, cutoffTs, cursorMt, cursorId);
applyOrderAndLimit(wrapper, limit);
List<SyncRow> 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<String, Object> 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();
}
}
@@ -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) {
}
}
@@ -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<String, Object> 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<String, Object> 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<Long> parseContractIds(List<String> contractIds) {
if (contractIds == null || contractIds.isEmpty()) {
throw new ToolCallException(McpErrorCode.INVALID_ARGUMENT, "contractIds必须包含1至200个合同ID");
@@ -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<SalerRepayment> 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<SalerRepaymentCustomer> 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);
}
}
}
@@ -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<SyncRow> rows = entity.fetchPage(null, 2000L, null, null, 3, user);
verify(permissionService).applyRepaymentTargetRowFilter(any(), any());
Map<String, Object> 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<SyncRow> rows = entity.fetchPage(null, 2000L, null, null, 3, null);
verify(permissionService, never()).applyRepaymentCustomerRowFilter(any(), any());
Map<String, Object> 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());
}
}
@@ -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