Răsfoiți Sursa

feat(首页统计): 新增「支付宝收款」卡片 —— 汇安融(har)收款净额

用户 2026-10-05:har 还没上线、菜单只授给了管理员角色,但统计可以先做。

后端
- HarOrderMapper.statCollectedAmount:三窗口聚合(今日/近7日/全部),
  金额 = COALESCE(rcv_amount, price)(实收为空按订单金额兜底 —— SUM 会静默吞掉
  整行 NULL),窗口列 trade_time,状态集合走占位符。
- HarOrderStatus.PAY_RECORDED = {1 支付成功}:本通道退款恒为全额 ⇒ 净额用集合本身
  表达,SQL 里不减退款;「4 退款中」按用户裁定不算收款。
- AccountService.statHarCollectedAmount:**元,不再除 100**(与乐收赢那条腿唯一的
  差别:pay_har_order 的金额列本来就是 decimal(16,2));统一抬到两位小数,
  否则并排的乐收赢那格是 0.00 而这格是 0。
- 汇总统计加第四条腿:转账 + 消费 + 银联收款 + 支付宝收款。
- 端点 GET /payment/account/stat/har/amount,**不收 enterprise_id**(一租户多商户,
  按企业筛会让卡片恒 0.00 且不报错 —— 乐收赢那张卡 2026-10-05 的根因同型);
  权限串复用 module_payment:account:transfer,无菜单 SQL、无需改授权。

前端
- AccountAPI.statHarTradeAmount + 首页 tenant.vue 新增「支付宝收款」段落(三窗口),
  调用点与 fetchLsyAmount 一一对应(onMounted / 两处 watch / 收款方类型)。

测试
- HarOrderMapperSqlTest(6 条):用 MyBatis 真求值钉 SQL 形状 —— 兜底公式逐窗口、
  状态走占位符、窗口列 trade_time、企业条件传了也不出现、解析器解得动。
- AccountServiceStatTest +5 条:元不再除 100、键缺失按零、状态集合取自 PAY_RECORDED、
  租户透传、时间窗;汇总那条改成四段相加(数字挑过:两条腿的错误方向相反)。
- 变异两向 7 组:6 个真变异各红在指定用例(含「按企业筛」这个真 bug 的复现),
  1 个无害改动仍绿;7 次还原 sha256 逐字节相同。
- 全量 1049 绿;前端 ts:check 前后差分 329 行逐字节相同,两处变异各新增一条
  指向新代码的错误(模板 + 调用各一)。

部署:要重打 jar 与 dist;无 SQL。
alphaH 1 săptămână în urmă
părinte
comite
2cf395a4d6

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

@@ -61,6 +61,24 @@ export const AccountAPI = {
     });
   },
 
+  /**
+   * 汇安融收款统计(**净额** = 实收)—— 首页「支付宝收款」卡的数据源
+   * (2026-10-05 用户裁定:har = 支付宝收款,与「银联收款」的乐收赢腿并列)。
+   * 单位已是元,后端**不做**分转元(汇安融的金额列本来就是 decimal(16,2))。
+   *
+   * 【不带企业参数】同乐收赢:一租户多商户,按企业筛只会把别的商户的单整批排除、
+   * 卡片恒 0.00 且不报错。后端端点也不再收这个参数。
+   */
+  statHarTradeAmount(tenantId?: number) {
+    return request<
+      ApiResponse<{ amount_of_today: string; amount_of_7days: string; amount_of_all: string }>
+    >({
+      url: `${API_PATH}/stat/har/amount`,
+      method: "get",
+      params: { tenant_id: tenantId },
+    });
+  },
+
   authorizeApply(data: { enterprise_id: string }) {
     return request<ApiResponse<{ sign_url: string; external_agreement_no: string }>>({
       url: `${API_PATH}/authorize/apply`,

+ 47 - 0
frontend/src/views/dashboard/tenant.vue

@@ -120,6 +120,24 @@
         </div>
       </div>
 
+      <div class="section-header" style="margin-top: 24px;">
+        <h2>支付宝收款</h2>
+      </div>
+      <div class="data-cards">
+        <div class="data-card">
+          <div class="card-title">今日收款金额(元)</div>
+          <div class="card-value">{{ harAmount.amount_of_today || "--" }}</div>
+        </div>
+        <div class="data-card">
+          <div class="card-title">近7日收款金额(元)</div>
+          <div class="card-value">{{ harAmount.amount_of_7days || "--" }}</div>
+        </div>
+        <div class="data-card">
+          <div class="card-title">总收款金额(元)</div>
+          <div class="card-value">{{ harAmount.amount_of_all || "--" }}</div>
+        </div>
+      </div>
+
       <div class="section-header" style="margin-top: 24px;">
         <h2>汇总统计</h2>
       </div>
@@ -165,6 +183,7 @@ const selectedPayeeType = ref<string | undefined>(undefined);
 function handlePayeeTypeChange() {
   fetchStatAmount();
   fetchLsyAmount();
+  fetchHarAmount();
   fetchSummaryAmount();
 }
 
@@ -202,6 +221,18 @@ const summaryAmount = ref<{
   amount_of_all?: string;
 }>({});
 
+/**
+ * 「支付宝收款」卡的数据 —— 2026-10-05 起来自**汇安融**(净额 = 实收,单位元)。
+ * 与上一张「银联收款统计」(乐收赢)成对:用户裁定 lsy = 银联收款、har = 支付宝收款。
+ * 后端汇安融菜单目前只授给了管理员角色 —— 租户角色看不到这一块,是用户的预期
+ * (「har 还没上线,只有超管能看到」),不是缺陷。
+ */
+const harAmount = ref<{
+  amount_of_today?: string;
+  amount_of_7days?: string;
+  amount_of_all?: string;
+}>({});
+
 const allTenantData = ref<TenantTable[]>([]);
 
 function handleEnterpriseChange() {
@@ -237,6 +268,19 @@ async function fetchLsyAmount() {
   }
 }
 
+// 汇安融(支付宝收款)统计与乐收赢同形:只按租户取,**不随「企业」下拉变**
+// (一租户多商户,带企业筛会把别的商户的单全排除、卡片恒 0.00)。
+// 下面几处调用点与 fetchLsyAmount 一一对应 —— 对租户级统计来说其中两处是多余的,
+// 但两条腿保持同形,将来谁改一处不会漏另一处。
+async function fetchHarAmount() {
+  try {
+    const res = await AccountAPI.statHarTradeAmount(currentTenantId.value);
+    harAmount.value = res.data.data || {};
+  } catch (error) {
+    console.error("获取支付宝收款统计失败:", error);
+  }
+}
+
 async function fetchSummaryAmount() {
   try {
     const res = await AccountAPI.statSummaryAmount(currentTenantId.value, selectedEnterpriseId.value, selectedPayeeType.value);
@@ -271,6 +315,7 @@ onMounted(async () => {
   fetchStatAmount();
   fetchConsumeAmount();
   fetchLsyAmount();
+  fetchHarAmount();
   fetchSummaryAmount();
 });
 
@@ -284,6 +329,7 @@ watch(currentTenantId, async (newTenantId) => {
   fetchStatAmount();
   fetchConsumeAmount();
   fetchLsyAmount();
+  fetchHarAmount();
   fetchSummaryAmount();
 });
 
@@ -291,6 +337,7 @@ watch(selectedEnterpriseId, () => {
   fetchStatAmount();
   fetchConsumeAmount();
   fetchLsyAmount();
+  fetchHarAmount();
   fetchSummaryAmount();
 });
 

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

@@ -80,6 +80,26 @@ public class AccountController {
         return Result.ok(accountService.statLsyCollectedAmount(tenantId));
     }
 
+    /**
+     * 汇安融收款统计(净额,元)—— 首页「支付宝收款」卡片的数据源(2026-10-05 起)。
+     * 与上一张卡的命名成对:lsy = 银联收款、har = 支付宝收款(用户裁定)。
+     *
+     * <p>【没有 enterprise_id 参数,故意的】同乐收赢:本通道一租户多商户,
+     * 按企业筛只会把其余商户的单整批排除、卡片恒 0.00,而且不报错。
+     * 相邻三个统计仍收这个参数;本端点不声明它 ⇒ 调用方多传也不会被使用。
+     *
+     * <p>权限串**故意复用**统计那一组的 {@code module_payment:account:transfer}:
+     * 同页面同批人看,新开串就得配菜单 + 角色授权 SQL + 重登 —— 白加一次部署风险。
+     * 注意这只管**接口**权限:卡片本身的可见性由菜单表决定(汇安融菜单目前只授给了
+     * 管理员角色 ⇒ 租户角色看不到这块,用户 2026-10-05 已确认这是预期)。
+     */
+    @PreAuthorize("@perm.hasAny('module_payment:account:transfer')")
+    @GetMapping("/stat/har/amount")
+    public Result<Map<String, String>> statHarCollectedAmount(
+            @RequestParam(name = "tenant_id", required = false) Long tenantId) {
+        return Result.ok(accountService.statHarCollectedAmount(tenantId));
+    }
+
     @PreAuthorize("@perm.hasAny('module_payment:account:transfer')")
     @GetMapping("/stat/summary/amount")
     public Result<Map<String, String>> statSummaryAmount(

+ 60 - 12
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.har.enums.HarOrderStatus;
+import com.payment.platform.module.payment.har.mapper.HarOrderMapper;
 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;
@@ -70,6 +72,7 @@ public class AccountService {
     private final PayBillMapper payBillMapper;
     private final F2fTradeRecordMapper f2fTradeRecordMapper;
     private final LsyOrderMapper lsyOrderMapper;
+    private final HarOrderMapper harOrderMapper;
     private final AlipayTransferService alipayTransferService;
     private final ReceiptFileFetcher receiptFileFetcher;
 
@@ -190,26 +193,71 @@ public class AccountService {
     }
 
     /**
-     * 汇总统计金额 — 转账 + 消费 + 乐收赢收款(净额)
+     * 首页「支付宝收款」卡片(2026-10-05 用户裁定:har = 支付宝收款,与「银联收款」
+     * 的乐收赢腿并列)—— 单位**元**,{@code pay_har_order} 的金额列本来就是元
+     * ({@code decimal(16,2)})⇒ 这里**不换算**,与 {@link #statLsyCollectedAmount}
+     * 那条腿的唯一区别就在这里(那条要 movePointLeft(2))。
      *
-     * ★ 2026-10-05:第三段从「当面付」换成「乐收赢」—— 首页那张卡不再统计当面付了,
-     *   汇总若还带着当面付,卡片上的数与汇总对不上(用户裁定,见 tenant.vue)。
-     *   当面付的 statF2fTradeAmount 保留不删(现在没有调用方;切回去是一行)。
-     *   注意三段的口径差:前两段按企业筛,第三段(乐收赢)是租户级 —— 汇总=上面三张
-     *   卡片直接相加,卡片上是什么数、汇总里就是什么数。
+     * <p>口径细节(逐行 COALESCE 兜底、trade_time 窗口、状态集合来源、为什么本通道
+     * 不减退款)全在 {@link HarOrderMapper#statCollectedAmount} 的注释里。
+     *
+     * <p>【只按租户,没有企业参数】与乐收赢腿同一个理由(见 {@link #statLsyCollectedAmount}):
+     * 一租户多商户,按企业筛会把别的商户的单整批排除、卡片恒 0.00 且不报错。
+     * 首页的企业下拉对这一块不生效。
+     *
+     * <p>时间窗口与旁边三块统计同一口径(北京时间当日零点);窗口键名也一模一样,
+     * 前端四块卡片共用一个渲染形状。
+     */
+    public Map<String, String> statHarCollectedAmount(Long tenantId) {
+        OffsetDateTime today = LocalDate.now().atStartOfDay(ZoneOffset.ofHours(8)).toOffsetDateTime();
+        OffsetDateTime weekAgo = today.minusDays(7);
+        Map<String, BigDecimal> amounts = harOrderMapper.statCollectedAmount(
+                tenantId, HarOrderStatus.PAY_RECORDED, today, weekAgo);
+        return Map.of(
+                "amount_of_today", yuan(amounts.get("amount_of_today")),
+                "amount_of_7days", yuan(amounts.get("amount_of_7days")),
+                "amount_of_all",  yuan(amounts.get("amount_of_all")));
+    }
+
+    /**
+     * 已经是元的金额出字符串,**统一抬到两位小数**;null(键缺失 / SQL 回 NULL)按 0,不让整页崩。
+     *
+     * <p>为什么必须抬:输入是 scale 0 的 BigDecimal(整数元、以及聚合回字面量 0 的情形)
+     * 时 {@code toPlainString()} 给的是 {@code "100"} / {@code "0"},而并排的乐收赢那格
+     * 被 {@code movePointLeft(2)} 定死成 scale 2 ⇒ 同一排卡片会一边 {@code 0} 一边 {@code 0.00}
+     * (本类单测实测:不抬时 100 元那格读数是 "100")。四张卡并排,格式得一样。
+     *
+     * <p>{@code HALF_UP} 只是兜底:金额列是 {@code decimal(16,2)},求和仍是两位,
+     * 本该没有要舍入的位;真出现了也不该让首页整页报错。
+     */
+    private static String yuan(BigDecimal amount) {
+        return (amount == null ? BigDecimal.ZERO : amount)
+                .setScale(2, java.math.RoundingMode.HALF_UP).toPlainString();
+    }
+
+    /**
+     * 汇总统计金额 — 转账 + 消费 + 银联收款(乐收赢)+ 支付宝收款(汇安融)
+     *
+     * ★ 2026-10-05:第三段从「当面付」换成「乐收赢」,随后(同日)按用户裁定
+     *   把「支付宝收款」也加进来 —— 汇总**就是上面那四张卡片直接相加**,
+     *   卡片上是什么数、汇总里就是什么数。当面付的 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> lsy = statLsyCollectedAmount(tenantId);
+        Map<String, String> har = statHarCollectedAmount(tenantId);
 
         return Map.of(
-                "amount_of_today", add(add(transfer.get("amount_of_today"), consume.get("amount_of_today")),
-                        lsy.get("amount_of_today")),
-                "amount_of_7days", add(add(transfer.get("amount_of_7days"), consume.get("amount_of_7days")),
-                        lsy.get("amount_of_7days")),
-                "amount_of_all", add(add(transfer.get("amount_of_all"), consume.get("amount_of_all")),
-                        lsy.get("amount_of_all")));
+                "amount_of_today", add(add(add(transfer.get("amount_of_today"), consume.get("amount_of_today")),
+                        lsy.get("amount_of_today")), har.get("amount_of_today")),
+                "amount_of_7days", add(add(add(transfer.get("amount_of_7days"), consume.get("amount_of_7days")),
+                        lsy.get("amount_of_7days")), har.get("amount_of_7days")),
+                "amount_of_all", add(add(add(transfer.get("amount_of_all"), consume.get("amount_of_all")),
+                        lsy.get("amount_of_all")), har.get("amount_of_all")));
     }
 
     private String add(String a, String b) {

+ 15 - 0
java/src/main/java/com/payment/platform/module/payment/har/enums/HarOrderStatus.java

@@ -24,6 +24,21 @@ public final class HarOrderStatus {
     /** 终态 —— 不再轮询 */
     public static final Set<String> FINAL = Set.of(PAID, REFUNDED, CLOSED);
 
+    /**
+     * 计入收款净额的状态 —— 只有「1 支付成功」。
+     *
+     * <p>【本通道的净额是「集合本身」,不是「公式里减一笔」】两个退款入口都只收原因、
+     * 不收金额({@code HarRefundRequest})⇒ 退款恒为**全额**:单子退成「2 退款成功」
+     * 之后,它贡献的净额就是 0,留在集合里只是多一个恒零分支 ⇒ 不收。
+     * 「4 退款中」按用户 2026-10-05 裁定**不算**收款(钱还在途,退没退成未知)。
+     *
+     * <p>由此得到的不变量:**集合里每一行都全额计入**,首页那张卡才不需要在 SQL 里
+     * 再减一次退款 —— 谁把 {@link #REFUNDING} 或 {@link #REFUNDED} 加回来,
+     * 都得同时给 SQL 补上扣减,否则数字偏大。(与乐收赢 {@code LsyOrderStatus.PAY_RECORDED}
+     * 不同:那边是部分退款,所以集合含已退状态、金额逐行减 {@code refunded_amount}。)
+     */
+    public static final Set<String> PAY_RECORDED = Set.of(PAID);
+
     public static boolean isPending(String status) {
         return status != null && PENDING.contains(status);
     }

+ 66 - 0
java/src/main/java/com/payment/platform/module/payment/har/mapper/HarOrderMapper.java

@@ -3,7 +3,73 @@ package com.payment.platform.module.payment.har.mapper;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.payment.platform.module.payment.har.entity.HarOrderEntity;
 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 HarOrderMapper extends BaseMapper<HarOrderEntity> {
+
+    /**
+     * 首页「支付宝收款」卡片的三窗口聚合 —— **净额**(实收),单位:**元**。
+     *
+     * <p>★ 单位与乐收赢相反:{@code pay_har_order} 的金额列本来就是元
+     * ({@code decimal(16,2)},见 {@code V1.8__create_pay_har_tables.sql}),
+     * 而 {@code pay_lsy_order} 是分 ⇒ 这条腿**不做任何换算**,
+     * 服务层也不许除 100({@code AccountServiceStatTest} 钉着)。
+     *
+     * <p>【金额 = 实收,为空按订单金额兜底】{@code rcv_amount} 是上游通知里的
+     * {@code actual_money},**只有通知腿(或事后点「同步」)才写得上**,
+     * 为空是常态不是异常。逐行 COALESCE 不是笔误:SQL 的 SUM **静默忽略 NULL 行** ——
+     * 写成 {@code SUM(rcv_amount)} 时那几单整行消失、且没有任何报错。
+     *
+     * <p>【本通道不需要减退款】退款**恒为全额**(两个入口都只收原因不收金额 ⇒
+     * 退成「2 退款成功」的单净额就是 0)⇒ 用状态集合本身表达净额:
+     * {@code statuses} 由调用方传 {@link com.payment.platform.module.payment.har.enums.HarOrderStatus#PAY_RECORDED}
+     * (只含「1 支付成功」),不在 SQL 里手抄字面量。集合语义与「为什么不含 4 退款中」
+     * 写在那个常量的注释里 —— 谁把已退状态加回集合,必须同时在这里补扣减。
+     *
+     * <p>【窗口列是 {@code trade_time}】支付时刻(上游 {@code trade_date} 映射而来),
+     * 不是 {@code created_time}:后者是「单子建出来」的时刻,跨日/跨周会给出不同的数。
+     *
+     * <p>【租户条件】{@code pay_har_order} 在 {@code TenantInnerInterceptor.CONDITIONAL_TABLES} 里:
+     * 已登录租户用户由拦截器自动追加;平台用户(tenantId 为 null)拦截器跳过,
+     * 走这里的 {@code <if>} 显式条件(首页「选择商户」下拉就是这条路)。
+     *
+     * <p>【没有企业条件,是故意的】本通道与乐收赢同一条去企业化的路:
+     * 一租户下可以有多个商户,首页那只企业下拉默认就带着一个企业
+     * (只有一个企业时 {@code EnterpriseSwitcher} 自动选中)⇒ 加上
+     * {@code AND enterprise_id = ?} 会把别的商户的单整批排除、三个窗口一起归零,
+     * 而且**不报任何错** —— 乐收赢那张卡 2026-10-05 恒 0.00 就是这么来的
+     * (用户当天裁定:这类卡片是租户级,不对企业下拉生效)。
+     *
+     * <p>SQL 形状由 {@code HarOrderMapperSqlTest} 用 MyBatis 真求值钉住 ——
+     * 服务层测试把本接口 mock 掉,注解里的 SQL 在那里完全不可见。
+     */
+    @Select("""
+            <script>
+            SELECT
+              COALESCE(SUM(CASE WHEN trade_time >= #{today}
+                THEN COALESCE(rcv_amount, price)
+                ELSE 0 END), 0) AS amount_of_today,
+              COALESCE(SUM(CASE WHEN trade_time >= #{weekAgo}
+                THEN COALESCE(rcv_amount, price)
+                ELSE 0 END), 0) AS amount_of_7days,
+              COALESCE(SUM(COALESCE(rcv_amount, price)), 0) AS amount_of_all
+            FROM pay_har_order
+            WHERE trade_status IN
+            <foreach collection="statuses" item="s" open="(" separator="," close=")">#{s}</foreach>
+            <if test="tenantId != null">
+            AND tenant_id = #{tenantId}
+            </if>
+            </script>
+            """)
+    Map<String, BigDecimal> statCollectedAmount(@Param("tenantId") Long tenantId,
+                                                @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.har.mapper.HarOrderMapper;
 import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
 import com.payment.platform.module.payment.notification.mapper.PayBillMapper;
 import org.apache.ibatis.builder.MapperBuilderAssistant;
@@ -59,6 +60,7 @@ class AccountServiceReceiptFileTest {
     @Mock private PayBillMapper payBillMapper;
     @Mock private F2fTradeRecordMapper f2fTradeRecordMapper;
     @Mock private LsyOrderMapper lsyOrderMapper;
+    @Mock private HarOrderMapper harOrderMapper;
     @Mock private AlipayTransferService alipayTransferService;
     @Mock private ReceiptFileFetcher receiptFileFetcher;
     @Mock private AlipayClient alipayClient;
@@ -69,7 +71,7 @@ class AccountServiceReceiptFileTest {
     void setUp() {
         service = new AccountService(accountMapper, transferMapper, depositMapper, withdrawMapper,
                 alipayClientFactory, redisTemplate, payBillMapper, f2fTradeRecordMapper,
-                lsyOrderMapper, alipayTransferService, receiptFileFetcher);
+                lsyOrderMapper, harOrderMapper, alipayTransferService, receiptFileFetcher);
         // 初始化 MyBatis-Plus lambda 元数据缓存,使 LambdaQueryWrapper 可在无 Spring 上下文的单测里构造
         MybatisConfiguration configuration = new MybatisConfiguration();
         TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), TransferEntity.class);

+ 118 - 10
java/src/test/java/com/payment/platform/module/payment/account/service/AccountServiceStatTest.java

@@ -9,6 +9,8 @@ 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.har.enums.HarOrderStatus;
+import com.payment.platform.module.payment.har.mapper.HarOrderMapper;
 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;
@@ -36,8 +38,9 @@ import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.when;
 
 /**
- * 首页「银联收款统计」这张卡的数从哪来 —— 乐收赢聚合腿(用户 2026-10-05 裁定:不再统计当面付;
- * 同日定名 lsy = 银联收款、har = 支付宝收款)。
+ * 首页统计四块卡片的数从哪来 —— 乐收赢聚合腿(「银联收款统计」)与汇安融聚合腿
+ * (「支付宝收款」,用户 2026-10-05 裁定:不再统计当面付;同日定名 lsy = 银联收款、
+ * har = 支付宝收款)。
  *
  * <p>钉三件事:
  * <ol>
@@ -71,6 +74,7 @@ class AccountServiceStatTest {
     @Mock private PayBillMapper payBillMapper;
     @Mock private F2fTradeRecordMapper f2fTradeRecordMapper;
     @Mock private LsyOrderMapper lsyOrderMapper;
+    @Mock private HarOrderMapper harOrderMapper;
     @Mock private AlipayTransferService alipayTransferService;
     @Mock private ReceiptFileFetcher receiptFileFetcher;
 
@@ -80,7 +84,7 @@ class AccountServiceStatTest {
     void setUp() {
         service = new AccountService(accountMapper, transferMapper, depositMapper, withdrawMapper,
                 alipayClientFactory, redisTemplate, payBillMapper, f2fTradeRecordMapper,
-                lsyOrderMapper, alipayTransferService, receiptFileFetcher);
+                lsyOrderMapper, harOrderMapper, alipayTransferService, receiptFileFetcher);
         // 初始化 MyBatis-Plus lambda 元数据缓存,使 LambdaQueryWrapper 可在无 Spring 上下文的单测里构造
         MybatisConfiguration configuration = new MybatisConfiguration();
         TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), TransferEntity.class);
@@ -171,16 +175,116 @@ class AccountServiceStatTest {
         assertEquals(today.getValue().minusDays(7), weekAgo.getValue());
     }
 
+    // ==================== 支付宝收款(汇安融)腿 ====================
+
+    /**
+     * ★ 汇安融的钱**本来就是元**({@code decimal(16,2)}),这条腿**不许**再除 100 ——
+     * 与乐收赢那条腿唯一的差别就在这里,而两条腿长得几乎一样(复制粘贴的天然候选),
+     * 所以必须有一条用例按「除 100 会怎样」挑数字:
+     * 100 元若被除 100 会变成 1.00,而这里断言的就是 100.00。
+     * 反过来乐收赢那条腿断言的是「不换算会大 100 倍」—— 一正一反,谁也替不了谁。
+     */
+    @Test
+    void 支付宝收款统计_元不再除100() {
+        when(harOrderMapper.statCollectedAmount(any(), any(), any(), any()))
+                .thenReturn(Map.of(
+                        "amount_of_today", new BigDecimal("123.45"),
+                        "amount_of_7days", new BigDecimal("100"),
+                        "amount_of_all", new BigDecimal("0")));
+
+        Map<String, String> r = service.statHarCollectedAmount(null);
+
+        assertEquals("123.45", r.get("amount_of_today"));
+        assertEquals("100.00", r.get("amount_of_7days"), "被当成分除了 100 就会是 1.00");
+        assertEquals("0.00", r.get("amount_of_all"));
+    }
+
+    /** 空表 / 键缺失时是 0.00 而不是 null 或异常 —— 与乐收赢腿同一个不变式。 */
+    @Test
+    void 支付宝收款统计_键缺失时按零() {
+        when(harOrderMapper.statCollectedAmount(any(), any(), any(), any()))
+                .thenReturn(Map.of("amount_of_all", new BigDecimal("250.5")));
+
+        Map<String, String> r = service.statHarCollectedAmount(null);
+
+        assertEquals("0.00", r.get("amount_of_today"));
+        assertEquals("0.00", r.get("amount_of_7days"));
+        assertEquals("250.50", r.get("amount_of_all"));
+    }
+
+    /**
+     * 传进 SQL 的状态集合必须**就是** {@code HarOrderStatus.PAY_RECORDED},
+     * 且那个集合的**内容**是 {@code {PAID}} —— 两件不同的事,都要钉:
+     * 前者防「两边各写一份」,后者防「往集合里加成员却不改 SQL 的扣减」
+     * (本通道退款恒为全额 ⇒ 集合里每一行都全额计入;加回「2 退款成功」
+     * 就会把已经退掉的单当成收款,数字偏大)。
+     */
+    @Test
+    void 支付宝收款统计_状态集合取自PAY_RECORDED() {
+        when(harOrderMapper.statCollectedAmount(any(), any(), any(), any()))
+                .thenReturn(Map.of("amount_of_today", BigDecimal.ZERO,
+                        "amount_of_7days", BigDecimal.ZERO, "amount_of_all", BigDecimal.ZERO));
+
+        service.statHarCollectedAmount(null);
+
+        @SuppressWarnings("unchecked")
+        ArgumentCaptor<Collection<String>> captor = ArgumentCaptor.forClass(Collection.class);
+        verify(harOrderMapper).statCollectedAmount(isNull(), captor.capture(), any(), any());
+        assertEquals(Set.copyOf(HarOrderStatus.PAY_RECORDED), Set.copyOf(captor.getValue()));
+        assertEquals(Set.of(HarOrderStatus.PAID), Set.copyOf(captor.getValue()),
+                "净额口径 = 只算支付成功;加成员必须同时给 SQL 补扣减");
+    }
+
+    /** 租户参数**照旧透传**(正面控制):去掉企业筛选时不能顺手把租户筛也去掉。 */
+    @Test
+    void 支付宝收款统计_租户参数仍然透传() {
+        when(harOrderMapper.statCollectedAmount(any(), any(), any(), any()))
+                .thenReturn(Map.of("amount_of_today", BigDecimal.ZERO,
+                        "amount_of_7days", BigDecimal.ZERO, "amount_of_all", BigDecimal.ZERO));
+
+        service.statHarCollectedAmount(7L);
+
+        ArgumentCaptor<Long> tenant = ArgumentCaptor.forClass(Long.class);
+        verify(harOrderMapper).statCollectedAmount(tenant.capture(), any(), any(), any());
+        assertEquals(7L, tenant.getValue());
+    }
+
+    /**
+     * 时间窗与另外三块同一口径(北京时间当日零点,近 7 日 = 今日减 7 天)。
+     * 这几行是**另一份拷贝**(每条腿各算各的)⇒ 单独钉一次,别靠乐收赢那条腿替它作证。
+     */
+    @Test
+    void 支付宝收款统计_时间窗是北京时间当日零点() {
+        when(harOrderMapper.statCollectedAmount(any(), any(), any(), any()))
+                .thenReturn(Map.of("amount_of_today", BigDecimal.ZERO,
+                        "amount_of_7days", BigDecimal.ZERO, "amount_of_all", BigDecimal.ZERO));
+
+        service.statHarCollectedAmount(null);
+
+        ArgumentCaptor<OffsetDateTime> today = ArgumentCaptor.forClass(OffsetDateTime.class);
+        ArgumentCaptor<OffsetDateTime> weekAgo = ArgumentCaptor.forClass(OffsetDateTime.class);
+        verify(harOrderMapper).statCollectedAmount(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());
+    }
+
     /**
-     * 汇总 = 转账 + 消费 + 乐收赢(净额,分已转元)。
+     * 汇总 = 转账 + 消费 + 乐收赢 + 汇安融(用户 2026-10-05 双裁定:第三段换乐收赢、
+     * 第四段加汇安融)。
      *
-     * <p>数字是挑过的:乐收赢那 2500 分若不转元,今日会算出 2650.00 而不是 175.00 ——
-     * 这条断言顺带把「汇总里漏了换算」也钉住。7 日/全部同理(3000 分 / 4000 分)。
+     * <p>数字是挑过的,每条腿都能自己把自己钉出来:乐收赢那 2500 **分**若不转元,
+     * 今日会算出 2650.00 而不是 175.00;汇安融那 12.34 **元**若被当成分除 100,
+     * 今日会算出 162.78 而不是 175.00。两个错误方向相反 ⇒ 一条断言同时钉住两侧。
+     * 7 日/全部同理(乐收赢 3000/4000 分;汇安融 20/30 元)。
      * <p>{@code verifyNoInteractions(f2fTradeRecordMapper)}:当面付那一段是**不再走**,
      * 不是「走出来的结果恰好为 0」。
      */
     @Test
-    void 汇总统计_当面付那段换成乐收赢且分已转元() {
+    void 汇总统计_转账加消费加乐收赢加汇安融() {
         when(transferMapper.statAmounts(any(), any(), any())).thenReturn(Map.of(
                 "amount_of_today", new BigDecimal("100"),
                 "amount_of_7days", new BigDecimal("200"),
@@ -193,12 +297,16 @@ class AccountServiceStatTest {
                 "amount_of_today", new BigDecimal("2500"),
                 "amount_of_7days", new BigDecimal("3000"),
                 "amount_of_all", new BigDecimal("4000")));
+        when(harOrderMapper.statCollectedAmount(any(), any(), any(), any())).thenReturn(Map.of(
+                "amount_of_today", new BigDecimal("12.34"),
+                "amount_of_7days", new BigDecimal("20"),
+                "amount_of_all", new BigDecimal("30")));
 
         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
+        assertEquals("187.34", r.get("amount_of_today"));   // 100 + 50 + 25.00 + 12.34
+        assertEquals("310.00", r.get("amount_of_7days"));   // 200 + 60 + 30.00 + 20
+        assertEquals("440.00", r.get("amount_of_all"));     // 300 + 70 + 40.00 + 30
         verifyNoInteractions(f2fTradeRecordMapper);
     }
 }

+ 169 - 0
java/src/test/java/com/payment/platform/module/payment/har/mapper/HarOrderMapperSqlTest.java

@@ -0,0 +1,169 @@
+package com.payment.platform.module.payment.har.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 HarOrderMapper#statCollectedAmount})的形状。
+ *
+ * <p>【为什么必须单独钉】{@code AccountServiceStatTest} 把 mapper 整个 mock 掉 ⇒
+ * 注解里的 SQL 一次都没被 MyBatis 求值过,它长什么样在测试里完全不可见 ——
+ * 本仓已有一次实证({@code RoleMapperSqlTest} 类注释:漏一个 {@code separator}
+ * 从写下来躺了四个月,因为「只配一个菜单」恰好合法)。
+ *
+ * <p>【量具】与 {@code LsyOrderMapperSqlTest} 同两支:① MyBatis 自己的注解动态 SQL 解析
+ * ({@link XMLLanguageDriver}),求值出来的就是线上那条 BoundSql;② 再拿
+ * {@link CCJSqlParserUtil} 解一遍 —— MyBatis-Plus 的租户行拦截器走的就是这支解析器。
+ *
+ * <p>这里钉的是**口径**,不是排版:金额表达式在三个窗口里各出现一次、状态集合走占位符
+ * (不手抄字面量)、窗口列是 {@code trade_time}、租户条件按参数有无出现、
+ * 而**企业条件一次都不许出现**(乐收赢那张卡 2026-10-05 恒 0.00 的根因,
+ * HAR 走同一条去企业化的路,见 {@link #企业条件传了也不出现()})。
+ *
+ * <p>★ 单位:HAR 的金额列本来就是**元**({@code decimal(16,2)},见
+ * {@code V1.8__create_pay_har_tables.sql}),与乐收赢的**分**不同 ⇒
+ * 这条 SQL 里**不许**出现任何换算,换算与否的判据在服务层那条用例
+ * ({@code AccountServiceStatTest#支付宝收款统计_元不再除100})。
+ */
+class HarOrderMapperSqlTest {
+
+    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 拼串的产物(实测渲染 `trade_status IN ( ? )`),
+     * 不是被测对象;占位符**有几个、什么顺序**必须由断言管。
+     *
+     * <p>{@code enterpriseId} **不是**这个接口的参数 —— 它仍然进 param map,
+     * 是为了让「按企业筛选」这个变异体看得见:谁把那条 {@code <if>} 加回来,
+     * 传非空值的用例立刻红(param map 里多余的键 MyBatis 不报错)。
+     */
+    private static String boundSql(Long tenantId, String enterpriseId) {
+        Method m = Arrays.stream(HarOrderMapper.class.getDeclaredMethods())
+                .filter(x -> x.getName().equals("statCollectedAmount"))
+                .findFirst()
+                .orElseThrow(() -> new AssertionError("HarOrderMapper 里没有 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);
+        // 与线上同一个来源:HarOrderStatus.PAY_RECORDED(净额口径 = 只算「1 支付成功」)
+        params.put("statuses", List.of("1"));
+        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(rcv_amount)} 时,上游没带 {@code actual_money} 的那几单
+     * 整行消失、且没有任何报错。{@code rcv_amount} 本来就只在通知里带
+     * {@code actual_money}(或事后点「同步」)时才有值,为空是常态不是异常。
+     */
+    @Test
+    void 金额公式在三个窗口里各出现一次() {
+        String sql = boundSql(null, null);
+        assertEquals(3, count(sql, "COALESCE(rcv_amount, price)"),
+                "三个窗口(今日/近7日/全部)都得用同一条兜底公式: " + sql);
+        assertTrue(sql.contains("FROM pay_har_order"), sql);
+    }
+
+    /**
+     * 状态集合以占位符传入,SQL 里**不许**出现手抄的状态字面量 ——
+     * 手抄就会漂(本仓有「两个集合必须同一来源」的既有裁定)。
+     *
+     * <p>净额为什么只放一个状态:本通道退款**恒为全额**(两个退款入口都不收金额),
+     * 退成「2 退款成功」后净额就是 0 ⇒ 把它留在集合里只是多一个恒零的分支;
+     * 「4 退款中」按用户 2026-10-05 裁定不算收款。
+     */
+    @Test
+    void 状态集合走占位符_不手抄字面量() {
+        String sql = boundSql(null, null);
+        assertTrue(sql.contains("trade_status IN (?)"), sql);
+        assertFalse(sql.contains("'1'") || sql.contains("'2'") || sql.contains("'4'"),
+                "状态字面量不该出现在 SQL 里(应从 HarOrderStatus.PAY_RECORDED 传进来): " + sql);
+    }
+
+    /**
+     * 时间窗的列是**支付时刻** {@code trade_time}(上游 {@code trade_date} 映射而来)——
+     * 不是 created_time:下单时间是「单子建出来」的时刻,不是「钱到账」的时刻,
+     * 两者在跨日/跨周时给出不同的数。
+     */
+    @Test
+    void 三个窗口都按trade_time划() {
+        String sql = boundSql(null, null);
+        assertEquals(2, count(sql, "trade_time >= ?"),
+                "今日与近7日各一个窗口条件(全部那个不带): " + sql);
+        assertTrue(sql.contains("AS amount_of_today") && sql.contains("AS amount_of_7days")
+                && sql.contains("AS amount_of_all"), sql);
+        assertFalse(sql.contains("created_time"), "窗口列不许是下单时间: " + sql);
+    }
+
+    /** 不传租户时条件不出现(已登录租户用户由拦截器自动追加租户条件)。 */
+    @Test
+    void 缺省参数不生成条件() {
+        String sql = boundSql(null, null);
+        assertFalse(sql.contains("tenant_id"), sql);
+        assertFalse(sql.contains("enterprise_id"), sql);
+    }
+
+    /**
+     * 租户条件照旧按参数出现,**企业条件传了也不出现**。
+     *
+     * <p>汇安融与乐收赢同一条路:2026-09-24 去企业化({@code har-tenant-level.sql})
+     * 之后商户映射与订单都按**租户账号**寻址,首页那只企业下拉默认就带着一个企业
+     * (只有一个企业时 {@code EnterpriseSwitcher} 自动选中)⇒ {@code AND enterprise_id = ?}
+     * 会把不带企业的单整批排除,三个窗口一起归零,且**不报任何错**。
+     * 乐收赢那张卡 2026-10-05 恒 0.00 就是这么来的。
+     */
+    @Test
+    void 企业条件传了也不出现() {
+        String sql = boundSql(7L, "ENT-1");
+        assertTrue(sql.contains("tenant_id = ?"), sql);
+        assertFalse(sql.contains("enterprise_id"), sql);
+    }
+
+    /** 解析器解得动这条 SQL —— 租户拦截器线上就是拿它解,解不动整个查询 500。 */
+    @Test
+    void 解析器能解这条SQL() {
+        String sql = boundSql(7L, "ENT-1");
+        assertDoesNotThrow(() -> CCJSqlParserUtil.parse(sql),
+                "MyBatis-Plus 租户拦截器解析不了这条 SQL: " + sql);
+    }
+}