Sfoglia il codice sorgente

feat(首页统计): 「支付宝收款」卡改统计乐收赢净额,新增 GET /stat/lsy/amount

用户裁定(2026-10-05):「转账数据统计」改「支付数据统计」;「当面付收款统计」改
「支付宝收款」并**不再统计当面付**、改统计乐收赢;口径取**净额**(实收 − 已退)。

- 净额公式 `COALESCE(receipt_amount, total_amount) - COALESCE(refunded_amount, 0)`
  逐行 COALESCE:SUM 会**静默吞掉 NULL 行** —— 写成 SUM(receipt_amount - refunded_amount)
  时没回实收的那几单整行消失且不报错;回调腿 `payPatch(params, null)` 的兜底就是 null,
  这种行真实存在。窗口列 finish_time(支付成功时写,退款只写 pay_lsy_refund、不碰它)。
- 状态集合传 `LsyOrderStatus.PAY_RECORDED`(SUCCESS/PARTIAL_REFUNDED/REFUNDED),
  SQL 里不手抄字面量。
- 汇总统计第三段随之从当面付换成乐收赢;statF2fTradeAmount 保留不删(现无调用方,切回一行)。
- 权限串复用 module_payment:account:transfer ⇒ 不新增菜单/授权 SQL、不用重登。

前端:AccountAPI.statLsyTradeAmount 接上 tenant.vue 那张卡(含文案与变量改名)。

验证:
- 新增 AccountServiceStatTest(5):分→元两位小数、键缺失回 0.00、状态集合与窗口边界、
  汇总组合(数字选得让「漏换单位」会读出 2650.00 而不是 175.00)。
- 新增 LsyOrderMapperSqlTest(6):XMLLanguageDriver 真求值 + CCJSqlParserUtil 解析,
  钉净额公式三窗口各一次、status IN (?,?,?) 无状态字面量、finish_time 窗口 ×2、
  租户/企业条件成对出现。服务层测试把 mapper 全 mock ⇒ 注解 SQL 在那边完全不可见,
  必须有这一支。
- 全量 mvn test:1037 绿(基线 1026 + 新增 11)。
- 前端 ts:check 与改动前逐字节相同(289 条历史基线,该命令恒 exit 2);
  四个前端改动文件同一批各注入一条 `const __probe_num: number = "not-a-number"`,
  tsc 输出差分在四个注入行各读出一条新 error TS2322 ⇒ 量具确实在读这四个文件;
  还原后 sha256 逐一 OK。
alphaH 1 settimana fa
parent
commit
d0515f3613

+ 14 - 0
frontend/src/api/module_payment/account.ts

@@ -43,6 +43,20 @@ export const AccountAPI = {
     });
   },
 
+  /**
+   * 乐收赢收款统计(**净额** = 实收 − 已退)—— 首页「支付宝收款」卡的数据源。
+   * 单位已是元(后端把库里的分换好才出网),键名与旁边三块统计一致。
+   */
+  statLsyTradeAmount(tenantId?: number, enterpriseId?: string) {
+    return request<
+      ApiResponse<{ amount_of_today: string; amount_of_7days: string; amount_of_all: string }>
+    >({
+      url: `${API_PATH}/stat/lsy/amount`,
+      method: "get",
+      params: { tenant_id: tenantId, enterprise_id: enterpriseId },
+    });
+  },
+
   authorizeApply(data: { enterprise_id: string }) {
     return request<ApiResponse<{ sign_url: string; external_agreement_no: string }>>({
       url: `${API_PATH}/authorize/apply`,

+ 18 - 17
frontend/src/views/dashboard/tenant.vue

@@ -26,7 +26,7 @@
     <!-- 核心数据 -->
     <div class="core-data-section">
       <div class="section-header">
-        <h2>转账数据统计</h2>
+        <h2>支付数据统计</h2>
         <div class="section-header-right">
           <el-select
             v-if="is_platform_user"
@@ -103,20 +103,20 @@
       </div>
 
       <div class="section-header" style="margin-top: 24px;">
-        <h2>当面付收款统计</h2>
+        <h2>支付宝收款</h2>
       </div>
       <div class="data-cards">
-        <div class="data-card f2f-card">
+        <div class="data-card lsy-card">
           <div class="card-title">今日收款金额(元)</div>
-          <div class="card-value">{{ f2fAmount.amount_of_today || "--" }}</div>
+          <div class="card-value">{{ lsyAmount.amount_of_today || "--" }}</div>
         </div>
-        <div class="data-card f2f-card">
+        <div class="data-card lsy-card">
           <div class="card-title">近7日收款金额(元)</div>
-          <div class="card-value">{{ f2fAmount.amount_of_7days || "--" }}</div>
+          <div class="card-value">{{ lsyAmount.amount_of_7days || "--" }}</div>
         </div>
-        <div class="data-card f2f-card">
+        <div class="data-card lsy-card">
           <div class="card-title">总收款金额(元)</div>
-          <div class="card-value">{{ f2fAmount.amount_of_all || "--" }}</div>
+          <div class="card-value">{{ lsyAmount.amount_of_all || "--" }}</div>
         </div>
       </div>
 
@@ -164,7 +164,7 @@ const selectedEnterpriseId = ref<string | undefined>(
 const selectedPayeeType = ref<string | undefined>(undefined);
 function handlePayeeTypeChange() {
   fetchStatAmount();
-  fetchF2fAmount();
+  fetchLsyAmount();
   fetchSummaryAmount();
 }
 
@@ -185,7 +185,8 @@ const consumeAmount = ref<{
   amount_of_all?: string;
 }>({});
 
-const f2fAmount = ref<{
+/** 「支付宝收款」卡的数据 —— 2026-10-05 起来自**乐收赢**(净额,后端已把分换成元)。 */
+const lsyAmount = ref<{
   amount_of_today?: string;
   amount_of_7days?: string;
   amount_of_all?: string;
@@ -221,12 +222,12 @@ async function fetchConsumeAmount() {
   }
 }
 
-async function fetchF2fAmount() {
+async function fetchLsyAmount() {
   try {
-    const res = await AccountAPI.statF2fTradeAmount(currentTenantId.value, selectedEnterpriseId.value);
-    f2fAmount.value = res.data.data || {};
+    const res = await AccountAPI.statLsyTradeAmount(currentTenantId.value, selectedEnterpriseId.value);
+    lsyAmount.value = res.data.data || {};
   } catch (error) {
-    console.error("获取当面付收款统计失败:", error);
+    console.error("获取乐收赢收款统计失败:", error);
   }
 }
 
@@ -263,7 +264,7 @@ onMounted(async () => {
   }
   fetchStatAmount();
   fetchConsumeAmount();
-  fetchF2fAmount();
+  fetchLsyAmount();
   fetchSummaryAmount();
 });
 
@@ -276,14 +277,14 @@ watch(currentTenantId, async (newTenantId) => {
   selectedEnterpriseId.value = undefined;
   fetchStatAmount();
   fetchConsumeAmount();
-  fetchF2fAmount();
+  fetchLsyAmount();
   fetchSummaryAmount();
 });
 
 watch(selectedEnterpriseId, () => {
   fetchStatAmount();
   fetchConsumeAmount();
-  fetchF2fAmount();
+  fetchLsyAmount();
   fetchSummaryAmount();
 });
 

+ 15 - 0
java/src/main/java/com/payment/platform/module/payment/account/controller/AccountController.java

@@ -61,6 +61,21 @@ public class AccountController {
         return Result.ok(accountService.statF2fTradeAmount(tenantId, enterpriseId));
     }
 
+    /**
+     * 乐收赢收款统计(净额,元)—— 首页「支付宝收款」卡片的数据源(2026-10-05 起)。
+     *
+     * <p>权限串**故意复用**上面三个统计的 {@code module_payment:account:transfer}:
+     * 这张卡与它们在同一个页面、同一批人看,新开一个串就得配菜单 + 角色授权 SQL,
+     * 还要使用者重登才生效 —— 对一次「换数据源」来说是白加一次部署风险。
+     */
+    @PreAuthorize("@perm.hasAny('module_payment:account:transfer')")
+    @GetMapping("/stat/lsy/amount")
+    public Result<Map<String, String>> statLsyCollectedAmount(
+            @RequestParam(name = "tenant_id", required = false) Long tenantId,
+            @RequestParam(name = "enterprise_id", required = false) String enterpriseId) {
+        return Result.ok(accountService.statLsyCollectedAmount(tenantId, enterpriseId));
+    }
+
     @PreAuthorize("@perm.hasAny('module_payment:account:transfer')")
     @GetMapping("/stat/summary/amount")
     public Result<Map<String, String>> statSummaryAmount(

+ 44 - 5
java/src/main/java/com/payment/platform/module/payment/account/service/AccountService.java

@@ -20,6 +20,8 @@ import com.payment.platform.module.payment.notification.entity.PayBillEntity;
 import com.payment.platform.module.payment.notification.mapper.PayBillMapper;
 import com.payment.platform.module.payment.facetoface.entity.F2fTradeRecordEntity;
 import com.payment.platform.module.payment.facetoface.mapper.F2fTradeRecordMapper;
+import com.payment.platform.module.payment.leshouying.enums.LsyOrderStatus;
+import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
 import com.payment.platform.common.exception.BusinessException;
 import com.payment.platform.core.alipay.AlipayClientFactory;
 import com.alipay.api.AlipayApiException;
@@ -67,6 +69,7 @@ public class AccountService {
     private final RedisTemplate<String, Object> redisTemplate;
     private final PayBillMapper payBillMapper;
     private final F2fTradeRecordMapper f2fTradeRecordMapper;
+    private final LsyOrderMapper lsyOrderMapper;
     private final AlipayTransferService alipayTransferService;
     private final ReceiptFileFetcher receiptFileFetcher;
 
@@ -150,20 +153,56 @@ public class AccountService {
     }
 
     /**
-     * 汇总统计金额 — 转账 + 消费 + 当面付收款
+     * 统计乐收赢收款金额 —— **净额**(实收 − 已退),只算钱已到账的状态。
+     *
+     * <p>2026-10-05 用户裁定:首页「支付宝收款」这张卡不再统计当面付、改统计乐收赢。
+     * 口径细节(逐行 COALESCE 兜底、finish_time 窗口、状态集合来源)全在
+     * {@link LsyOrderMapper#statCollectedAmount} 的注释里,这里只做两件事:
+     * 把 {@link LsyOrderStatus#PAY_RECORDED} 传下去,把**分换成元**。
+     *
+     * <p>时间窗口由应用层计算(北京时间),与旁边三块统计同一口径;
+     * 窗口键名也一模一样(amount_of_today / amount_of_7days / amount_of_all),
+     * 前端四块卡片才能共用一个渲染形状。
+     */
+    public Map<String, String> statLsyCollectedAmount(Long tenantId, String enterpriseId) {
+        OffsetDateTime today = LocalDate.now().atStartOfDay(ZoneOffset.ofHours(8)).toOffsetDateTime();
+        OffsetDateTime weekAgo = today.minusDays(7);
+        Map<String, BigDecimal> fen = lsyOrderMapper.statCollectedAmount(
+                tenantId, enterpriseId, LsyOrderStatus.PAY_RECORDED, today, weekAgo);
+        return Map.of(
+                "amount_of_today", fenToYuan(fen.get("amount_of_today")),
+                "amount_of_7days", fenToYuan(fen.get("amount_of_7days")),
+                "amount_of_all",  fenToYuan(fen.get("amount_of_all")));
+    }
+
+    /**
+     * 分 → 元(两位小数)。{@code pay_lsy_order} 的金额列是分(BIGINT),
+     * 而首页四块统计对外都是元字符串 —— 少这一步卡片上的数就大 100 倍。
+     * null(键缺失 / SQL 回 NULL)按 0:统计口径下「没有」就是零,不该让整页崩。
+     */
+    private static String fenToYuan(BigDecimal fen) {
+        return (fen == null ? BigDecimal.ZERO : fen).movePointLeft(2).toPlainString();
+    }
+
+    /**
+     * 汇总统计金额 — 转账 + 消费 + 乐收赢收款(净额)
+     *
+     * ★ 2026-10-05:第三段从「当面付」换成「乐收赢」—— 首页那张卡不再统计当面付了,
+     *   汇总若还带着当面付,卡片上的数与汇总对不上(用户裁定,见 tenant.vue)。
+     *   当面付的 statF2fTradeAmount 保留不删(现在没有调用方;切回去是一行)。
      */
     public Map<String, String> statSummaryAmount(Long tenantId, String enterpriseId, String payeeType) {
         Map<String, String> transfer = statTransferAmount(tenantId, enterpriseId, payeeType);
         Map<String, String> consume = statConsumeAmount(tenantId, enterpriseId, payeeType);
-        Map<String, String> f2f = statF2fTradeAmount(tenantId, enterpriseId);
+        Map<String, String> lsy = statLsyCollectedAmount(tenantId, enterpriseId);
 
         return Map.of(
                 "amount_of_today", add(add(transfer.get("amount_of_today"), consume.get("amount_of_today")),
-                        f2f.get("amount_of_today")),
+                        lsy.get("amount_of_today")),
                 "amount_of_7days", add(add(transfer.get("amount_of_7days"), consume.get("amount_of_7days")),
-                        f2f.get("amount_of_7days")),
+                        lsy.get("amount_of_7days")),
                 "amount_of_all", add(add(transfer.get("amount_of_all"), consume.get("amount_of_all")),
-                        f2f.get("amount_of_all")));
+                        lsy.get("amount_of_all")));
     }
 
     private String add(String a, String b) {

+ 59 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/mapper/LsyOrderMapper.java

@@ -3,7 +3,66 @@ package com.payment.platform.module.payment.leshouying.mapper;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
 import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.math.BigDecimal;
+import java.time.OffsetDateTime;
+import java.util.Collection;
+import java.util.Map;
 
 @Mapper
 public interface LsyOrderMapper extends BaseMapper<LsyOrderEntity> {
+
+    /**
+     * 首页「支付宝收款」卡片的三窗口聚合 —— **净额**(实收 − 已退),单位:**分**。
+     *
+     * <p>【净额 = 实收 − 已退】实收取 {@code receipt_amount}(上游回的实收),
+     * <b>为空的那一行按该行 {@code total_amount} 兜底</b>。逐行 COALESCE 不是笔误:
+     * SQL 的 SUM **静默忽略 NULL 行** —— 写成 {@code SUM(receipt_amount - refunded_amount)}
+     * 时,没回 receipt_amount 的那几单整行消失、且没有任何报错。为什么会有没回的行:
+     * 回调腿 {@code LsyNotifyService} 调 {@code payPatch(params, null)} 传的兜底就是 null
+     * (上游报文没这个字段就是「未知」,不许替它编),不是每条支付成功都带回实收。
+     *
+     * <p>【窗口列是 {@code finish_time}】到账时刻:支付成功时由 {@code applyPayResult} 写入,
+     * 退款只写 {@code pay_lsy_refund}、**不碰它** ⇒ 一笔「收过又退了」的单仍归在收款那一天。
+     * {@code finish_time} 为 NULL 的行不计入今日/近7日,但仍计入「全部」。
+     *
+     * <p>【状态集合走占位符】{@code statuses} 由调用方传 {@code LsyOrderStatus.PAY_RECORDED}
+     * (SUCCESS / PARTIAL_REFUNDED / REFUNDED),不在 SQL 里手抄字面量 —— 手抄就会漂。
+     * 只算钱**已经记在账上**的状态:付成功、以及付成功之后的部分/全额退款。
+     *
+     * <p>【租户条件】pay_lsy_order 在 {@code TenantInnerInterceptor.CONDITIONAL_TABLES} 里:
+     * 已登录租户用户由拦截器自动追加;平台用户(tenantId 为 null)拦截器跳过,
+     * 走这里的 {@code <if>} 显式条件(首页「选择商户」下拉就是这条路)。
+     *
+     * <p>SQL 形状由 {@code LsyOrderMapperSqlTest} 用 MyBatis 真求值钉住 ——
+     * 服务层测试把本接口 mock 掉,注解里的 SQL 在那里完全不可见。
+     */
+    @Select("""
+            <script>
+            SELECT
+              COALESCE(SUM(CASE WHEN finish_time >= #{today}
+                THEN COALESCE(receipt_amount, total_amount) - COALESCE(refunded_amount, 0)
+                ELSE 0 END), 0) AS amount_of_today,
+              COALESCE(SUM(CASE WHEN finish_time >= #{weekAgo}
+                THEN COALESCE(receipt_amount, total_amount) - COALESCE(refunded_amount, 0)
+                ELSE 0 END), 0) AS amount_of_7days,
+              COALESCE(SUM(COALESCE(receipt_amount, total_amount) - COALESCE(refunded_amount, 0)), 0) AS amount_of_all
+            FROM pay_lsy_order
+            WHERE status IN
+            <foreach collection="statuses" item="s" open="(" separator="," close=")">#{s}</foreach>
+            <if test="tenantId != null">
+            AND tenant_id = #{tenantId}
+            </if>
+            <if test="enterpriseId != null and enterpriseId != ''">
+            AND enterprise_id = #{enterpriseId}
+            </if>
+            </script>
+            """)
+    Map<String, BigDecimal> statCollectedAmount(@Param("tenantId") Long tenantId,
+                                                @Param("enterpriseId") String enterpriseId,
+                                                @Param("statuses") Collection<String> statuses,
+                                                @Param("today") OffsetDateTime today,
+                                                @Param("weekAgo") OffsetDateTime weekAgo);
 }

+ 3 - 1
java/src/test/java/com/payment/platform/module/payment/account/service/AccountServiceReceiptFileTest.java

@@ -15,6 +15,7 @@ import com.payment.platform.module.payment.account.mapper.DepositMapper;
 import com.payment.platform.module.payment.account.mapper.TransferMapper;
 import com.payment.platform.module.payment.account.mapper.WithdrawMapper;
 import com.payment.platform.module.payment.facetoface.mapper.F2fTradeRecordMapper;
+import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
 import com.payment.platform.module.payment.notification.mapper.PayBillMapper;
 import org.apache.ibatis.builder.MapperBuilderAssistant;
 import org.junit.jupiter.api.BeforeEach;
@@ -57,6 +58,7 @@ class AccountServiceReceiptFileTest {
     @Mock private ValueOperations<String, Object> valueOperations;
     @Mock private PayBillMapper payBillMapper;
     @Mock private F2fTradeRecordMapper f2fTradeRecordMapper;
+    @Mock private LsyOrderMapper lsyOrderMapper;
     @Mock private AlipayTransferService alipayTransferService;
     @Mock private ReceiptFileFetcher receiptFileFetcher;
     @Mock private AlipayClient alipayClient;
@@ -67,7 +69,7 @@ class AccountServiceReceiptFileTest {
     void setUp() {
         service = new AccountService(accountMapper, transferMapper, depositMapper, withdrawMapper,
                 alipayClientFactory, redisTemplate, payBillMapper, f2fTradeRecordMapper,
-                alipayTransferService, receiptFileFetcher);
+                lsyOrderMapper, alipayTransferService, receiptFileFetcher);
         // 初始化 MyBatis-Plus lambda 元数据缓存,使 LambdaQueryWrapper 可在无 Spring 上下文的单测里构造
         MybatisConfiguration configuration = new MybatisConfiguration();
         TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), TransferEntity.class);

+ 180 - 0
java/src/test/java/com/payment/platform/module/payment/account/service/AccountServiceStatTest.java

@@ -0,0 +1,180 @@
+package com.payment.platform.module.payment.account.service;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.payment.platform.core.alipay.AlipayClientFactory;
+import com.payment.platform.module.payment.account.entity.TransferEntity;
+import com.payment.platform.module.payment.account.mapper.AccountMapper;
+import com.payment.platform.module.payment.account.mapper.DepositMapper;
+import com.payment.platform.module.payment.account.mapper.TransferMapper;
+import com.payment.platform.module.payment.account.mapper.WithdrawMapper;
+import com.payment.platform.module.payment.facetoface.mapper.F2fTradeRecordMapper;
+import com.payment.platform.module.payment.leshouying.enums.LsyOrderStatus;
+import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
+import com.payment.platform.module.payment.notification.mapper.PayBillMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.data.redis.core.RedisTemplate;
+
+import java.math.BigDecimal;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.util.Collection;
+import java.util.Map;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+/**
+ * 首页「支付宝收款」这张卡的数从哪来 —— 乐收赢聚合腿(用户 2026-10-05 裁定:不再统计当面付)。
+ *
+ * <p>钉三件事:
+ * <ol>
+ *   <li><b>分转元</b>:{@code pay_lsy_order} 的金额列是分(BIGINT),而首页四块统计对外
+ *       都是元字符串。少这一步,卡片上的数会比真实金额大 100 倍;</li>
+ *   <li><b>状态集合与后端同一来源</b>:{@code LsyOrderStatus.PAY_RECORDED}
+ *       (SUCCESS / PARTIAL_REFUNDED / REFUNDED)从 Java 传进 SQL,不在注解里手抄 ——
+ *       本仓既有的「同一集合」裁定(退款在途标记那一条)。</li>
+ *   <li><b>汇总统计的第三段换成乐收赢</b>,且当面付那条腿一次都不再被碰
+ *       ({@code verifyNoInteractions}:不是「结果恰好对」,是「根本没走那条腿」)。</li>
+ * </ol>
+ *
+ * <p>SQL 本身的形状(净额公式逐窗口、finish_time 窗口列、占位符状态集合)由
+ * {@code LsyOrderMapperSqlTest} 用 MyBatis 真求值来钉 —— 这里 mapper 是 mock,
+ * 注解 SQL 在这个测试里**完全不可见**。
+ */
+@ExtendWith(MockitoExtension.class)
+class AccountServiceStatTest {
+
+    @Mock private AccountMapper accountMapper;
+    @Mock private TransferMapper transferMapper;
+    @Mock private DepositMapper depositMapper;
+    @Mock private WithdrawMapper withdrawMapper;
+    @Mock private AlipayClientFactory alipayClientFactory;
+    @Mock private RedisTemplate<String, Object> redisTemplate;
+    @Mock private PayBillMapper payBillMapper;
+    @Mock private F2fTradeRecordMapper f2fTradeRecordMapper;
+    @Mock private LsyOrderMapper lsyOrderMapper;
+    @Mock private AlipayTransferService alipayTransferService;
+    @Mock private ReceiptFileFetcher receiptFileFetcher;
+
+    private AccountService service;
+
+    @BeforeEach
+    void setUp() {
+        service = new AccountService(accountMapper, transferMapper, depositMapper, withdrawMapper,
+                alipayClientFactory, redisTemplate, payBillMapper, f2fTradeRecordMapper,
+                lsyOrderMapper, alipayTransferService, receiptFileFetcher);
+        // 初始化 MyBatis-Plus lambda 元数据缓存,使 LambdaQueryWrapper 可在无 Spring 上下文的单测里构造
+        MybatisConfiguration configuration = new MybatisConfiguration();
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), TransferEntity.class);
+    }
+
+    /** 库里是分,卡片上是元:12345 分 = 123.45 元;整百分位保留两位(100000 分 = 1000.00 元)。 */
+    @Test
+    void 乐收赢统计_分转元并保留两位() {
+        when(lsyOrderMapper.statCollectedAmount(any(), any(), any(), any(), any()))
+                .thenReturn(Map.of(
+                        "amount_of_today", new BigDecimal("12345"),
+                        "amount_of_7days", new BigDecimal("100000"),
+                        "amount_of_all", new BigDecimal("0")));
+
+        Map<String, String> r = service.statLsyCollectedAmount(null, null);
+
+        assertEquals("123.45", r.get("amount_of_today"));
+        assertEquals("1000.00", r.get("amount_of_7days"));
+        assertEquals("0.00", r.get("amount_of_all"));
+    }
+
+    /** 空表 / 键缺失时是 0.00 而不是 null 或异常 —— 首页任何一块统计都不该让整页报错。 */
+    @Test
+    void 乐收赢统计_键缺失时按零() {
+        when(lsyOrderMapper.statCollectedAmount(any(), any(), any(), any(), any()))
+                .thenReturn(Map.of("amount_of_all", new BigDecimal("250")));
+
+        Map<String, String> r = service.statLsyCollectedAmount(null, null);
+
+        assertEquals("0.00", r.get("amount_of_today"));
+        assertEquals("0.00", r.get("amount_of_7days"));
+        assertEquals("2.50", r.get("amount_of_all"));
+    }
+
+    /**
+     * 传进 SQL 的状态集合必须**就是** {@code LsyOrderStatus.PAY_RECORDED}(同一个集合对象,
+     * 不是等价的字面量)—— 将来枚举加/删成员,这里跟着一起动,不会两边各写一份。
+     */
+    @Test
+    void 乐收赢统计_状态集合取自PAY_RECORDED() {
+        when(lsyOrderMapper.statCollectedAmount(any(), any(), any(), any(), any()))
+                .thenReturn(Map.of("amount_of_today", BigDecimal.ZERO,
+                        "amount_of_7days", BigDecimal.ZERO, "amount_of_all", BigDecimal.ZERO));
+
+        service.statLsyCollectedAmount(null, null);
+
+        @SuppressWarnings("unchecked")
+        ArgumentCaptor<Collection<String>> captor = ArgumentCaptor.forClass(Collection.class);
+        verify(lsyOrderMapper).statCollectedAmount(isNull(), isNull(), captor.capture(), any(), any());
+        assertEquals(Set.copyOf(LsyOrderStatus.PAY_RECORDED), Set.copyOf(captor.getValue()));
+    }
+
+    /** 时间窗:北京时间当日零点起的「今日」,近 7 日 = 今日减 7 天(与另外三块统计同一口径)。 */
+    @Test
+    void 乐收赢统计_时间窗是北京时间当日零点() {
+        when(lsyOrderMapper.statCollectedAmount(any(), any(), any(), any(), any()))
+                .thenReturn(Map.of("amount_of_today", BigDecimal.ZERO,
+                        "amount_of_7days", BigDecimal.ZERO, "amount_of_all", BigDecimal.ZERO));
+
+        service.statLsyCollectedAmount(null, null);
+
+        ArgumentCaptor<OffsetDateTime> today = ArgumentCaptor.forClass(OffsetDateTime.class);
+        ArgumentCaptor<OffsetDateTime> weekAgo = ArgumentCaptor.forClass(OffsetDateTime.class);
+        verify(lsyOrderMapper).statCollectedAmount(isNull(), isNull(), any(),
+                today.capture(), weekAgo.capture());
+        assertEquals(ZoneOffset.ofHours(8), today.getValue().getOffset());
+        assertEquals(0, today.getValue().getHour());
+        assertEquals(0, today.getValue().getMinute());
+        assertEquals(0, today.getValue().getSecond());
+        assertEquals(today.getValue().minusDays(7), weekAgo.getValue());
+    }
+
+    /**
+     * 汇总 = 转账 + 消费 + 乐收赢(净额,分已转元)。
+     *
+     * <p>数字是挑过的:乐收赢那 2500 分若不转元,今日会算出 2650.00 而不是 175.00 ——
+     * 这条断言顺带把「汇总里漏了换算」也钉住。7 日/全部同理(3000 分 / 4000 分)。
+     * <p>{@code verifyNoInteractions(f2fTradeRecordMapper)}:当面付那一段是**不再走**,
+     * 不是「走出来的结果恰好为 0」。
+     */
+    @Test
+    void 汇总统计_当面付那段换成乐收赢且分已转元() {
+        when(transferMapper.statAmounts(any(), any(), any())).thenReturn(Map.of(
+                "amount_of_today", new BigDecimal("100"),
+                "amount_of_7days", new BigDecimal("200"),
+                "amount_of_all", new BigDecimal("300")));
+        when(payBillMapper.statConsumeAmount(any(), any(), any(), any(), any())).thenReturn(Map.of(
+                "amount_of_today", new BigDecimal("50"),
+                "amount_of_7days", new BigDecimal("60"),
+                "amount_of_all", new BigDecimal("70")));
+        when(lsyOrderMapper.statCollectedAmount(any(), any(), any(), any(), any())).thenReturn(Map.of(
+                "amount_of_today", new BigDecimal("2500"),
+                "amount_of_7days", new BigDecimal("3000"),
+                "amount_of_all", new BigDecimal("4000")));
+
+        Map<String, String> r = service.statSummaryAmount(null, null, null);
+
+        assertEquals("175.00", r.get("amount_of_today"));   // 100 + 50 + 25.00
+        assertEquals("290.00", r.get("amount_of_7days"));   // 200 + 60 + 30.00
+        assertEquals("410.00", r.get("amount_of_all"));     // 300 + 70 + 40.00
+        verifyNoInteractions(f2fTradeRecordMapper);
+    }
+}

+ 141 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/mapper/LsyOrderMapperSqlTest.java

@@ -0,0 +1,141 @@
+package com.payment.platform.module.payment.leshouying.mapper;
+
+import net.sf.jsqlparser.parser.CCJSqlParserUtil;
+import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.mapping.SqlSource;
+import org.apache.ibatis.scripting.xmltags.XMLLanguageDriver;
+import org.apache.ibatis.session.Configuration;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Method;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 钉住首页「支付宝收款」那只聚合查询({@code LsyOrderMapper#statCollectedAmount})的形状。
+ *
+ * <p>【为什么必须单独钉】{@code AccountServiceStatTest} 把 mapper 整个 mock 掉 ⇒
+ * 注解里的 SQL 一次都没被 MyBatis 求值过,它长什么样在测试里完全不可见 ——
+ * 本仓已有一次实证({@code RoleMapperSqlTest} 类注释:漏一个 {@code separator}
+ * 从写下来躺了四个月,因为「只配一个菜单」恰好合法)。
+ *
+ * <p>【量具】照 {@code RoleMapperSqlTest} 的同两支:① MyBatis 自己的注解动态 SQL 解析
+ * ({@link XMLLanguageDriver}),求值出来的就是线上那条 BoundSql;② 再拿
+ * {@link CCJSqlParserUtil} 解一遍 —— MyBatis-Plus 的租户行拦截器走的就是这支解析器。
+ *
+ * <p>这里钉的是**口径**,不是排版:净额公式在三个窗口里各出现一次、状态集合走占位符
+ * (不手抄字面量)、窗口列是 {@code finish_time}、租户/企业条件按参数有无出现。
+ */
+class LsyOrderMapperSqlTest {
+
+    private static final OffsetDateTime TODAY =
+            OffsetDateTime.of(2026, 10, 5, 0, 0, 0, 0, ZoneOffset.ofHours(8));
+    private static final OffsetDateTime WEEK_AGO = TODAY.minusDays(7);
+
+    /**
+     * 取注解里的 script,交给 MyBatis 真求值,返回排版归一的最终 SQL。
+     *
+     * <p>归一化只做三件事:空白折叠、逗号两侧留一个空格、**括号内侧不保留空格** ——
+     * 后两者都是 MyBatis foreach 拼串的产物(实测渲染 `status IN ( ?, ?, ? )`),
+     * 不是被测对象;占位符**有几个、什么顺序**必须由断言管。
+     */
+    private static String boundSql(Long tenantId, String enterpriseId) {
+        Method m = Arrays.stream(LsyOrderMapper.class.getDeclaredMethods())
+                .filter(x -> x.getName().equals("statCollectedAmount"))
+                .findFirst()
+                .orElseThrow(() -> new AssertionError("LsyOrderMapper 里没有 statCollectedAmount"));
+        Select select = m.getAnnotation(Select.class);
+        if (select == null) throw new AssertionError("statCollectedAmount 上不是 @Select");
+        Map<String, Object> params = new LinkedHashMap<>();
+        params.put("tenantId", tenantId);
+        params.put("enterpriseId", enterpriseId);
+        // 状态集合与线上同一个来源:LsyOrderStatus.PAY_RECORDED(占位符个数=集合大小)
+        params.put("statuses", List.of("SUCCESS", "PARTIAL_REFUNDED", "REFUNDED"));
+        params.put("today", TODAY);
+        params.put("weekAgo", WEEK_AGO);
+        SqlSource sqlSource = new XMLLanguageDriver()
+                .createSqlSource(new Configuration(), select.value()[0], Object.class);
+        return sqlSource.getBoundSql(params).getSql()
+                .replaceAll("\\s+", " ")
+                .replaceAll("\\s*,\\s*", ", ")
+                .replaceAll("\\(\\s+", "(")
+                .replaceAll("\\s+\\)", ")")
+                .trim();
+    }
+
+    private static int count(String sql, String needle) {
+        return sql.split(Pattern.quote(needle), -1).length - 1;
+    }
+
+    /**
+     * 净额 = 实收 − 已退;实收为空的那一行按该行订单总额兜底。
+     *
+     * <p>为什么兜底必须逐行:SQL 的 SUM **静默忽略 NULL 行** ——
+     * 写成 {@code SUM(receipt_amount - refunded_amount)} 时,没回 receipt_amount 的
+     * 那几单整行消失、且没有任何报错。所以净额表达式必须在三个窗口里各自出现一次,
+     * 一次都不能少(少了哪一支,哪一支就退回「会吞行的写法」)。
+     */
+    @Test
+    void 净额公式在三个窗口里各出现一次() {
+        String sql = boundSql(null, null);
+        String net = "COALESCE(receipt_amount, total_amount) - COALESCE(refunded_amount, 0)";
+        assertEquals(3, count(sql, net), "三个窗口(今日/近7日/全部)都得用同一条净额公式: " + sql);
+        assertTrue(sql.contains("FROM pay_lsy_order"), sql);
+    }
+
+    /**
+     * 状态集合以占位符传入,SQL 里**不许**出现手抄的状态字面量 ——
+     * 手抄就会漂(本仓有「两个集合必须同一来源」的既有裁定)。
+     */
+    @Test
+    void 状态集合走占位符_不手抄字面量() {
+        String sql = boundSql(null, null);
+        assertTrue(sql.contains("status IN (?, ?, ?)"), sql);
+        assertFalse(sql.contains("SUCCESS") || sql.contains("REFUNDED"),
+                "状态字面量不该出现在 SQL 里(应从 LsyOrderStatus.PAY_RECORDED 传进来): " + sql);
+    }
+
+    /** 时间窗的列是到账时刻 finish_time:支付成功时写它,退款只写 pay_lsy_refund、不碰它。 */
+    @Test
+    void 三个窗口都按finish_time划() {
+        String sql = boundSql(null, null);
+        assertEquals(2, count(sql, "finish_time >= ?"),
+                "今日与近7日各一个窗口条件(全部那个不带): " + sql);
+        assertTrue(sql.contains("AS amount_of_today") && sql.contains("AS amount_of_7days")
+                && sql.contains("AS amount_of_all"), sql);
+    }
+
+    /** 不传租户/企业时两条条件都不出现(已登录租户用户由拦截器自动追加租户条件)。 */
+    @Test
+    void 缺省参数不生成条件() {
+        String sql = boundSql(null, null);
+        assertFalse(sql.contains("tenant_id"), sql);
+        assertFalse(sql.contains("enterprise_id"), sql);
+    }
+
+    /** 传了租户/企业时两条条件都在(平台用户在首页选商户/企业就是这条路)。 */
+    @Test
+    void 传了参数条件成对出现() {
+        String sql = boundSql(7L, "ENT-1");
+        assertTrue(sql.contains("tenant_id = ?"), sql);
+        assertTrue(sql.contains("enterprise_id = ?"), sql);
+    }
+
+    /** 解析器解得动这条 SQL —— 租户拦截器线上就是拿它解,解不动整个查询 500。 */
+    @Test
+    void 解析器能解这条SQL() {
+        String sql = boundSql(7L, "ENT-1");
+        assertDoesNotThrow(() -> CCJSqlParserUtil.parse(sql),
+                "MyBatis-Plus 租户拦截器解析不了这条 SQL: " + sql);
+    }
+}