From 50784fc40d98faa46af89a0b2c2e4f22c06d6dcb Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期六, 12 九月 2026 17:42:03 +0800
Subject: [PATCH] fix(merchant): 收紧自动提现汇总的结算配置与门店范围

---
 talent-card-server/src/main/resources/application.yml                                                          |    6 
 talent-card-server/src/main/resources/db/migration/V134__daily_withdrawal_aggregation_store_scope.sql          |   39 ++++
 .scratch/automatic-withdrawal/issues/04-daily-withdrawal-aggregation.md                                        |   53 +++--
 talent-card-server/src/main/java/com/talentcard/server/business/merchant/AutoWithdrawalAggregationService.java |  186 +++++++++++--------
 talent-card-server/src/main/java/com/talentcard/server/business/merchant/AutoWithdrawalProperties.java         |    2 
 talent-card-server/src/test/java/com/talentcard/server/business/merchant/AutoWithdrawalAggregationTests.java   |  162 ++++++++++++++---
 talent-card-server/docs/huifu-payment-integration.md                                                           |   39 ++-
 7 files changed, 334 insertions(+), 153 deletions(-)

diff --git a/.scratch/automatic-withdrawal/issues/04-daily-withdrawal-aggregation.md b/.scratch/automatic-withdrawal/issues/04-daily-withdrawal-aggregation.md
index 1a9d428..9355600 100644
--- a/.scratch/automatic-withdrawal/issues/04-daily-withdrawal-aggregation.md
+++ b/.scratch/automatic-withdrawal/issues/04-daily-withdrawal-aggregation.md
@@ -18,36 +18,47 @@
 已实现,主要落点:
 
 - 新增 `AutoWithdrawalAggregationService`:`run()` 汇总前一个自然日(北京时间),`run(businessDate)`
-  供测试和补跑指定业务日。业务日窗口是「核销时间不晚于该业务日 24:00」且没有下界——凌晨执行时当天
-  核销的订单都在窗口内,某天被跳过的订单(退款在途、商户当时没有可用结算银行卡)下一次仍在窗口内,
-  不会永久留在未提现状态。业务日与日期边界都用 `Asia/Shanghai`,不依赖服务器或 JVM 默认时区。
+  供测试和补跑指定业务日。业务日窗口是「核销时间 < 该业务日 24:00,且 >= 该业务日往前 30 天」——
+  凌晨执行时当天核销的订单都在窗口内,被跳过的订单(退款在途、门店当时没有可用结算银行卡、当天金额
+  不足)在下一次任务里仍在窗口内会继续累积(顺延),同时首次上线不会把多年前的历史一次性汇总进来。
+  业务日与日期边界都用 `Asia/Shanghai`,不依赖服务器或 JVM 默认时区。
 - 新增 `AutoWithdrawalScheduler`:cron 默认 `0 10 0 * * *` 且 `zone = "Asia/Shanghai"`,可用
   `app.merchant.auto-withdrawal.*` 配置。只生成本地提现记录,不调用斗拱;提交给斗拱、到账结果与
   失败顺延留给工单 05。
 - 新增 `AutoWithdrawalProperties`(`min-amount-cents` 默认 1000、`fee-buffer-cents` 默认 0、
   `enabled`、`cron`)。可提金额低于最低金额、或扣掉手续费缓冲后仍低于最低金额时,该商户当天不生成
-  记录,金额顺延到下一次任务。
+  记录,金额顺延到下一次任务。`enabled` 默认 false:手动提现入口还在,同时开启会让同一批订单既进
+  人工提现又进自动提现,工单 05 移除手动入口后再打开。
 - 提现记录复用 `merchant_withdrawal`,金额为整数分(来源订单 `pay_amount` 之和),状态保持本地
-  `pending`,业务日写新列 `withdrawal_date`。
+  `pending`,业务日写新列 `withdrawal_date`;汇总粒度是「商户 + 门店」,与提现记录本来按门店归属、
+  门店页面按门店统计的既有语义一致。
 - 来源订单关联写新表 `merchant_withdrawal_order(withdrawal_id, order_id, amount_cents)`,
   `order_id` 唯一。每个订单先 `OrderFundBoundaryService.lock(orderId)` 复检「已支付 + 已核销 +
-  未退款 + 未进入其它提现记录」,再 `lockIntoWithdrawal(orderId)` 锁定,最后登记关联;锁定失败即整笔
-  汇总回滚,因此同一订单不可能出现在两条提现记录里。
-- 每个商户一个事务(`TransactionTemplate`),一个商户失败不影响其它商户;失败时订单保持未锁定,
-  下一次任务重新汇总。
-- 幂等三层:先查当天是否已有记录(有则跳过)、业务请求号固定为 `auto-withdrawal:{merchantId}:{yyyyMMdd}`
-  (`uk_merchant_withdrawal_key`)、新增唯一索引 `uk_merchant_withdrawal_daily(merchant_id, withdrawal_date)`。
-  重复执行或两个实例并发都只会有一条记录、一份金额。
-- 迁移 `V133__daily_withdrawal_aggregation.sql`:新增 `withdrawal_date` 列、唯一索引和关联表。
+  未退款 + 未进入其它提现记录」,再写 `withdrawal_status = 1` 并登记关联;候选查询之后状态变化的
+  订单只跳过它自己,同一天其它订单照常汇总。
+- 汇总前要求的结算配置和提交路径一致:进件通过、`provider_huifu_id` 非空、结算银行卡审核通过且带
+  `token_no`;记录的 `bank_card_id` 和 token 一定来自同一张卡(首绑卡没有 token 就整张换成该门店
+  最新一张带 token 的审核通过卡)。配置不完整时这一天的金额顺延,而不是生成一条提交不出去、还会把
+  订单永久锁死的记录。
+- 每个(商户 + 门店)一个事务(`TransactionTemplate`),一个门店失败不影响其它门店;失败时订单保持
+  未锁定,下一次任务重新汇总。
+- 幂等三层:先查当天是否已有记录(有则跳过)、业务请求号固定为
+  `auto-withdrawal:{merchantId}:{storeId}:{yyyyMMdd}`(`uk_merchant_withdrawal_key`)、新增唯一索引
+  `uk_merchant_withdrawal_daily(merchant_id, store_key, withdrawal_date)`(`store_key` 是 `ifnull(store_id, 0)`
+  生成列,避免 NULL 让唯一约束失效)。重复执行或两个实例并发都只会有一条记录、一份金额。
+- 迁移:`V133__daily_withdrawal_aggregation.sql`(`withdrawal_date` 列、唯一索引、关联表)和
+  `V134__daily_withdrawal_aggregation_store_scope.sql`(`store_key` 生成列,把日汇总唯一约束从
+  「商户 + 业务日」收紧到「商户 + 门店 + 业务日」)。V133 已在共享测试库执行,按只追加规则没有修改它。
 - 文档:`talent-card-server/docs/huifu-payment-integration.md` 新增 "Daily withdrawal aggregation"
-  一节,写明规则表、配置项和窗口无下界的原因。
+  一节,写明规则表、配置项、窗口上下界的理由和门店粒度。
 
-测试:新增 `AutoWithdrawalAggregationTests` 10 项(MySQL),覆盖北京时间业务日边界(含 UTC 与北京
-日期不同的时刻)、业务日窗口右端、只汇总已核销未退款且未进入其它提现记录的订单、来源订单与金额关联、
-最低金额顺延(跨天累积)、手续费缓冲顺延、重复执行不重复出账(当天跑完后又核销一笔)、数据库层
-唯一约束、使用审核通过的结算银行卡、没有可用银行卡的商户不汇总、默认 `run()` 汇总北京时间昨天。
-全量 152 项测试仅剩 `docs/agents/testing.md` 记录的 3 项基线失败。
+测试:新增 `AutoWithdrawalAggregationTests` 13 项(MySQL),覆盖北京时间业务日边界(含 UTC 与北京
+日期不同的时刻)、业务日窗口右端、30 天回看下界、只汇总已支付已核销未退款且未进入其它提现记录的
+订单(五笔反例订单各自带有效核销记录,保证每个过滤条件本身被测到)、来源订单与金额关联、最低金额
+顺延(跨天累积)、手续费缓冲顺延、重复执行不重复出账(当天跑完后又核销一笔)、数据库层唯一约束、
+使用审核通过的结算银行卡、没有 token 的银行卡不汇总、没有子商户号的进件不汇总、门店之间互不影响、
+默认 `run()` 汇总北京时间昨天。全量测试仅剩 `docs/agents/testing.md` 记录的 3 项基线失败。
 
 遗留(不在本 ticket 范围):提交斗拱、到账结果处理、失败重试与失败顺延解锁订单,见工单 05;失败解锁
-需要 `OrderFundBoundaryService` 增加显式的解锁方法。首次上线会把历史上所有「已核销未提现」订单一次
-汇总进来,若单笔金额超过斗拱主动取现上限,需要在上限约束(工单 05/06)里处理。
+需要 `OrderFundBoundaryService` 增加显式的解锁方法;工单 05 落地时同时移除手动提现入口。超过斗拱
+主动取现单笔上限的处理放在工单 05/06。
diff --git a/talent-card-server/docs/huifu-payment-integration.md b/talent-card-server/docs/huifu-payment-integration.md
index 49491f6..39b5d08 100644
--- a/talent-card-server/docs/huifu-payment-integration.md
+++ b/talent-card-server/docs/huifu-payment-integration.md
@@ -165,36 +165,43 @@
 
 `AutoWithdrawalScheduler` runs `AutoWithdrawalAggregationService` every day at 00:10 Beijing time
 (`app.merchant.auto-withdrawal.cron`, zone `Asia/Shanghai`) for the previous calendar day. The service
-turns one merchant-day into one `merchant_withdrawal` row; it does not call the provider, so the row
-stays in the local `pending` state until a later ticket submits it.
+turns one merchant store-day into one `merchant_withdrawal` row; it does not call the provider, so the
+row stays in the local `pending` state until a later ticket submits it. `AUTO_WITHDRAWAL_ENABLED`
+defaults to `false`: the manual withdrawal endpoint still exists until it is removed, and running both
+would put the same orders into two records.
 
 | Rule | Implementation |
 | --- | --- |
-| Business day | Beijing time. The window is `verify_time < business day 24:00` with no lower bound |
-| Order eligibility | `order_status = 1`, `verify_status = 1`, `refund_status = 0`, `withdrawal_status = 0`, plus an active `verify_record` |
-| Merchant eligibility | Newest approved onboarding of each merchant store with an approved settlement card that has a provider token |
-| One record per merchant-day | `merchant_withdrawal.withdrawal_date` (Beijing `yyyyMMdd`) plus unique key `uk_merchant_withdrawal_daily` |
+| Business day | Beijing time. The window is `verify_time < business day 24:00` and `>= business day - 30 days` |
+| Order eligibility | `order_status = 1`, `verify_status = 1`, `refund_status = 0`, `withdrawal_status = 0`, not deleted, plus an active `verify_record` |
+| Merchant store eligibility | Newest approved onboarding of the store, with a non-blank `provider_huifu_id` and an approved settlement card that carries a provider token |
+| Settlement card | The approval-time card when it has a token, otherwise the store's newest approved token card; the recorded `bank_card_id` and the token always come from that one card |
+| One record per merchant store-day | `merchant_withdrawal.withdrawal_date` (Beijing `yyyyMMdd`) plus unique key `uk_merchant_withdrawal_daily (merchant_id, store_key, withdrawal_date)`, where `store_key = ifnull(store_id, 0)` |
 | Source order links | `merchant_withdrawal_order`, one row per order, unique key on `order_id` |
 | Amount | Integer cents, the sum of `benefit_order.pay_amount` |
-| Deferral | Skip the day when the total is below `min-amount-cents`, or when `total - fee-buffer-cents` is still below it |
-| Idempotency | The day's record is looked up first; the unique keys on `(merchant_id, withdrawal_date)` and `order_id` are the database-level guard |
+| Deferral | Skip the day when the total is below `min-amount-cents`, when `total - fee-buffer-cents` is still below it, or when the merchant store has no dispatchable settlement configuration |
+| Idempotency | The day's record is looked up first; the unique keys on `(merchant_id, store_key, withdrawal_date)` and on `order_id` are the database-level guard |
 | Locking | Every aggregated order goes through `OrderFundBoundaryService.lockIntoWithdrawal`, so it can no longer be revoked, refunded or aggregated again |
 
-The window has no lower bound on purpose: an order that was skipped because its refund was still in
-flight, or because the merchant had no usable settlement card that day, is picked up by the next run
-instead of being stranded. A merchant-day that never reaches the minimum simply never creates a row,
-and the amount carries forward into the next run.
+The window's lower bound exists so a first deployment does not sweep years of history into one
+withdrawal; anything older than 30 days is left alone for manual review. Inside the window, an order
+that was skipped because its refund was still in flight, because the merchant store had no usable
+settlement card, or because that day's total was below the minimum, is picked up by the next run
+instead of being stranded. Aggregation is per merchant store, matching the store-scoped withdrawal
+pages and the store that collected the money.
 
 ```text
-AUTO_WITHDRAWAL_ENABLED=true
+AUTO_WITHDRAWAL_ENABLED=false
 AUTO_WITHDRAWAL_MIN_AMOUNT_CENTS=1000
 AUTO_WITHDRAWAL_FEE_BUFFER_CENTS=0
 AUTO_WITHDRAWAL_CRON=0 10 0 * * *
 ```
 
-`AutoWithdrawalAggregationTests` covers the Beijing date boundary, the eligibility filters, the source
-order links, the minimum amount and fee-buffer deferral, repeated runs, the database uniqueness guard,
-the approved settlement card and the merchant without a usable card.
+`AutoWithdrawalAggregationTests` covers the Beijing date boundary, the eligibility filters (each
+negative order carries an active verification record, so the filters themselves are exercised), the
+source order links, the minimum amount and fee-buffer deferral, repeated runs, the database uniqueness
+guard, the 30-day lookback, the approved settlement card, a merchant store with no dispatchable
+configuration, and per-store isolation.
 
 ## Reconciliation and exception handling
 
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/AutoWithdrawalAggregationService.java b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/AutoWithdrawalAggregationService.java
index c15cc29..43ed10b 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/AutoWithdrawalAggregationService.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/AutoWithdrawalAggregationService.java
@@ -14,11 +14,10 @@
 import com.talentcard.server.business.DatabaseRows;
 import com.talentcard.server.business.order.OrderFundBoundaryService;
 import com.talentcard.server.common.BusinessException;
-import com.talentcard.server.common.ErrorCode;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
-import org.springframework.dao.DuplicateKeyException;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.dao.DuplicateKeyException;
 import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.support.TransactionTemplate;
@@ -26,26 +25,28 @@
 /**
  * 每日自动提现汇总。
  *
- * <p>按北京时间取前一个自然日,把每个商户「截至该日结束已核销、未退款、还没进入其它提现记录」的订单
- * 汇总成一条提现记录:金额写 {@code merchant_withdrawal},来源订单写内部关联表
+ * <p>按北京时间取前一个自然日,把每个商户结算账户下「已支付、已核销、未退款、还没进入其它提现记录」
+ * 的订单汇总成一条提现记录:金额写 {@code merchant_withdrawal},来源订单写内部关联表
  * {@code merchant_withdrawal_order},提现记录的业务日写 {@code withdrawal_date}。
  * 汇总后的订单通过 {@link OrderFundBoundaryService#lockIntoWithdrawal(long)} 标记为已进入提现记录,
- * 因此不能再撤销核销或退款,也不会被以后的任务重复计入。
+ * 因此不能再撤销核销或退款,也不会被以后的任务重复计入(关联表的 {@code order_id} 唯一索引兜底)。
  *
- * <p>业务日窗口是「核销时间不晚于该业务日 24:00」,没有下界:任务在凌晨跑,因此当天白天核销的订单
- * 都落在窗口内;某个业务日没被汇总的订单(核销时退款还在途、或商户当时没有可用的结算银行卡)
- * 不会被丢掉,下一次任务仍在窗口内,会随新订单一起汇总。这个选择保证订单不会因为一次跳过而永久
- * 留在未提现状态,代价是一次任务会扫描商户全部未进入提现记录的已核销订单({@code verify_record.verify_time}
- * 和 {@code benefit_order.merchant_id} 都有索引)。
+ * <p>业务日窗口是「核销时间不晚于该业务日 24:00,且不早于该业务日往前 {@value #MAX_LOOKBACK_DAYS} 天」。
+ * 任务在凌晨跑,因此当天白天核销的订单都落在窗口内;某天被跳过的订单(核销时退款还在途、商户当时
+ * 没有可用的结算银行卡、当天金额不足)在下一次任务里仍在窗口内,会继续累积(顺延),不会因为一次
+ * 跳过就永久留在未提现状态。下界的作用是给首次上线兜底:不会把多年前的历史订单一次性汇总进来。
  *
- * <p>可提金额低于最低提现金额、或扣掉手续费缓冲后不足最低金额时,这个商户当天不生成记录,
- * 金额留到下一次任务继续累积(顺延)。
+ * <p>只有「审批通过、登记了斗拱子商户号、且有审核通过的结算银行卡(带 token)」的商户才会生成记录,
+ * 否则这一天的金额顺延——生成一条提交不出去的记录会把订单永久锁死。
+ *
+ * <p>可提金额低于最低提现金额、或扣掉手续费缓冲后仍不足最低金额时,这一天的金额顺延到下一次任务。
  *
  * <p>幂等:同一商户同一业务日只可能有一条日汇总记录(先查后写,并有
- * {@code uk_merchant_withdrawal_daily} 兜底),任务重复执行不会重复生成提现金额。
+ * {@code uk_merchant_withdrawal_daily(merchant_id, store_key, withdrawal_date)} 兜底),
+ * 任务重复执行或两个实例并发都不会重复生成提现金额。
  *
- * <p>只在本服务内生成提现记录并把订单标记为处理中,不调用斗拱主动取现接口;提交给斗拱、处理
- * 到账结果和失败顺延由后续工单负责。
+ * <p>只在本服务内生成提现记录并把订单标记为已进入提现记录,不调用斗拱主动取现接口;提交给斗拱、
+ * 处理到账结果和失败顺延由工单 05 负责。
  */
 @Service
 public class AutoWithdrawalAggregationService {
@@ -55,46 +56,56 @@
     /** 日汇总和日期边界一律按北京时间计算,与商户看到的「日」一致。 */
     public static final ZoneId BEIJING = ZoneId.of("Asia/Shanghai");
 
+    /** 业务日窗口的下界长度:订单最多在窗口里等这么久,给首次上线和长期跳过的订单兜底。 */
+    static final int MAX_LOOKBACK_DAYS = 30;
+
     private static final DateTimeFormatter BUSINESS_DATE = DateTimeFormatter.ofPattern("yyyyMMdd");
 
+    /**
+     * 每个「商户 + 门店」在业务日窗口内有可汇总订单、且结算配置完整时的候选。
+     *
+     * <p>结算银行卡必须和 token 来自同一张卡:审核通过的首绑卡带 token 就用它,否则整张换成该门店
+     * 最新一张带 token 的审核通过卡,再取这张卡的 id。否则会出现「记录指向一张没有 token 的卡」
+     * 这种提交不出去的组合。
+     */
     private static final String ELIGIBLE_MERCHANTS = """
-            select o.merchant_id, o.store_id, coalesce(i.id, r.id) as bank_card_id,
-                   coalesce(i.provider_token_no, r2.provider_token_no) as token_no
+            select o.merchant_id, o.store_id, card.id as bank_card_id
             from merchant_onboarding_application o
-            join (select merchant_id, store_id, max(id) as onboarding_id
-                  from merchant_onboarding_application
-                  where status = 'approved'
-                  group by merchant_id, store_id) newest
-              on newest.onboarding_id = o.id
             left join merchant_bank_card i on i.id = o.initial_bank_card_id and i.status = 'approved'
             left join (select merchant_id, store_id, max(id) as id
                        from merchant_bank_card
                        where status = 'approved' and provider_token_no is not null and provider_token_no <> ''
-                       group by merchant_id, store_id) r
-              on r.merchant_id = o.merchant_id and r.store_id = o.store_id
-            left join merchant_bank_card r2 on r2.id = r.id
-            where coalesce(i.provider_token_no, r2.provider_token_no) is not null
-              and coalesce(i.provider_token_no, r2.provider_token_no) <> ''
+                       group by merchant_id, store_id) fallback
+              on fallback.merchant_id = o.merchant_id and fallback.store_id = o.store_id
+            join merchant_bank_card card
+              on card.id = if(i.provider_token_no is not null and i.provider_token_no <> '', i.id, fallback.id)
+            where card.status = 'approved'
+              and card.provider_token_no is not null and card.provider_token_no <> ''
+              and o.provider_huifu_id is not null and o.provider_huifu_id <> ''
+              and o.status = 'approved'
+              and o.id = (select max(newest.id) from merchant_onboarding_application newest
+                          where newest.merchant_id = o.merchant_id and newest.store_id = o.store_id
+                            and newest.status = 'approved')
               and exists (select 1
                           from benefit_order bo
                           join verify_record vr on vr.order_id = bo.id and vr.status = 1
-                          where bo.merchant_id = o.merchant_id
+                          where bo.merchant_id = o.merchant_id and bo.store_id = o.store_id
                             and bo.deleted = 0 and bo.order_status = 1 and bo.verify_status = 1
                             and bo.refund_status = 0 and bo.withdrawal_status = 0
-                            and vr.verify_time < ?)
-            order by o.id desc
+                            and vr.verify_time < ? and vr.verify_time >= ?)
             """;
 
+    /** 权威的可汇总订单查询:行锁复核之前先按同样的条件取一次。 */
     private static final String ELIGIBLE_ORDERS = """
             select bo.id, bo.pay_amount
             from benefit_order bo
-            where bo.merchant_id = ?
+            where bo.merchant_id = ? and bo.store_id = ?
               and bo.deleted = 0 and bo.order_status = 1 and bo.verify_status = 1
               and bo.refund_status = 0 and bo.withdrawal_status = 0
               and exists (select 1
                           from verify_record vr
                           where vr.order_id = bo.id and vr.status = 1
-                            and vr.verify_time < ?)
+                            and vr.verify_time < ? and vr.verify_time >= ?)
             order by bo.id asc
             """;
 
@@ -131,32 +142,30 @@
 
     /** 某个业务日的汇总。业务日始终按北京时间解释,因此测试和补跑都可以指定具体日期。 */
     public Result run(LocalDate businessDate) {
-        // 窗口右端是该业务日 24:00(北京时间)。任务在凌晨执行,当天核销的订单都在窗口内;
-        // 没被汇总的订单下一次仍在窗口内,会随新订单一起顺延。
+        // 窗口右端是该业务日 24:00:任务在凌晨执行,当天核销的订单都在窗口内。
         LocalDateTime windowEnd = businessDate.plusDays(1).atStartOfDay();
+        // 窗口左端给首次上线兜底,避免把历史订单一次性全部汇总。
+        LocalDateTime windowStart = businessDate.minusDays(MAX_LOOKBACK_DAYS).atStartOfDay();
         String dateText = businessDate.format(BUSINESS_DATE);
 
         Result result = new Result(dateText);
         for (Map<String, Object> candidate : DatabaseRows.normalize(jdbc.queryForList(
-                ELIGIBLE_MERCHANTS, windowEnd))) {
+                ELIGIBLE_MERCHANTS, windowEnd, windowStart))) {
             Long merchantId = number(candidate.get("merchantId"));
             Long storeId = number(candidate.get("storeId"));
             Long bankCardId = number(candidate.get("bankCardId"));
+            if (merchantId == null || storeId == null || bankCardId == null) continue;
             try {
-                aggregateMerchant(merchantId, storeId, bankCardId, dateText, windowEnd, result);
+                aggregate(merchantId, storeId, bankCardId, dateText, windowEnd, windowStart, result);
+            } catch (DuplicateKeyException exception) {
+                // 与另一个实例的汇总撞在一起:这一天的记录由对方生成,本实例没有出账。
+                result.merchantSkipped();
+                log.info("Auto withdrawal for merchant {} store {} on {} already exists, created by another run",
+                        merchantId, storeId, dateText);
             } catch (RuntimeException exception) {
-                // 一个商户汇总失败不影响其它商户;失败的订单保持未锁定,下一次任务会重新汇总。
-                // 与另一个实例撞在一起时唯一索引会抛 DuplicateKeyException:那一天的记录由对方生成,
-                // 本实例没有出账,属于「已存在」而不是失败。
-                if (exception instanceof DuplicateKeyException) {
-                    result.merchantSkipped();
-                    log.info("Auto withdrawal for merchant {} on {} already exists, created by another run",
-                            merchantId, dateText);
-                } else {
-                    result.merchantFailed();
-                    log.warn("Auto withdrawal aggregation failed for merchant {} on {}: {}",
-                            merchantId, dateText, exception.getClass().getSimpleName());
-                }
+                result.merchantFailed();
+                log.warn("Auto withdrawal aggregation failed for merchant {} store {} on {}: {}: {}",
+                        merchantId, storeId, dateText, exception.getClass().getSimpleName(), exception.getMessage());
             }
         }
         return result;
@@ -167,39 +176,45 @@
         return LocalDate.ofInstant(instant, BEIJING).minusDays(1);
     }
 
-    private void aggregateMerchant(Long merchantId, Long storeId, Long bankCardId, String dateText,
-                                   LocalDateTime windowEnd, Result result) {
-        if (merchantId == null || storeId == null || bankCardId == null) return;
-        transactions.executeWithoutResult(status -> {
-            if (hasDailyWithdrawal(merchantId, dateText)) {
-                result.merchantSkipped();
-                return;
-            }
+    /** 一个「商户 + 门店」一个业务日的汇总,整体在一个事务里完成。 */
+    private void aggregate(Long merchantId, Long storeId, Long bankCardId, String dateText,
+                           LocalDateTime windowEnd, LocalDateTime windowStart, Result result) {
+        Contribution contribution = transactions.execute(status -> {
+            if (hasDailyWithdrawal(merchantId, storeId, dateText)) return Contribution.SKIPPED;
             List<OrderAmount> orders = new ArrayList<>();
             long total = 0;
             for (Map<String, Object> row : DatabaseRows.normalize(jdbc.queryForList(
-                    ELIGIBLE_ORDERS, merchantId, windowEnd))) {
+                    ELIGIBLE_ORDERS, merchantId, storeId, windowEnd, windowStart))) {
                 Long orderId = number(row.get("id"));
                 Long payAmount = number(row.get("payAmount"));
-                long amount = payAmount == null ? 0 : payAmount;
                 OrderFundBoundaryService.LockedOrder locked = orderFundBoundary.lock(orderId);
-                if (locked.withdrawalLocked()) throw new BusinessException(ErrorCode.CONFLICT, "订单已进入其它提现记录");
-                if (!isEligible(locked)) throw new BusinessException(ErrorCode.CONFLICT, "订单已不满足进入提现记录的条件");
+                if (!isEligible(locked)) {
+                    // 候选查询之后订单被退款或撤销核销:只跳过这一单,同一天其它订单照常汇总。
+                    log.info("Skip order {} of merchant {} store {} on {}: {}", orderId, merchantId, storeId,
+                            dateText, locked.withdrawalLocked() ? "already in a withdrawal record" : "no longer eligible");
+                    continue;
+                }
+                long amount = payAmount == null ? 0 : payAmount;
                 orders.add(new OrderAmount(orderId, amount));
                 total += amount;
             }
-            if (orders.isEmpty()) return;
-            if (deferred(total)) {
-                result.merchantDeferred();
-                return;
-            }
+            if (orders.isEmpty()) return Contribution.NONE;
+            if (deferred(total)) return Contribution.DEFERRED;
 
             createWithdrawal(merchantId, storeId, bankCardId, dateText, total, orders);
-            result.merchantAggregated(total, orders.size());
+            return new Contribution(Contribution.Outcome.AGGREGATED, total, orders.size());
         });
+        // 事务提交成功之后才计数,提交失败不会被算成已汇总。
+        if (contribution == null) return;
+        switch (contribution.outcome()) {
+            case AGGREGATED -> result.merchantAggregated(contribution.amountCents(), contribution.orders());
+            case DEFERRED -> result.merchantDeferred();
+            case SKIPPED -> result.merchantSkipped();
+            case NONE -> { }
+        }
     }
 
-    /** 行锁拿到的状态才是判断依据:候选查询之后订单可能已经被退款或核销撤销。 */
+    /** 行锁拿到的状态才是判断依据:候选查询之后订单可能已经被退款、撤销核销或进入别的提现记录。 */
     private boolean isEligible(OrderFundBoundaryService.LockedOrder locked) {
         return locked.orderStatus() == 1 && locked.verifyStatus() == 1
                 && locked.refundStatus() == OrderFundBoundaryService.REFUND_NONE
@@ -212,17 +227,17 @@
         return total < minimum || total - buffer < minimum;
     }
 
-    private boolean hasDailyWithdrawal(Long merchantId, String dateText) {
+    private boolean hasDailyWithdrawal(Long merchantId, Long storeId, String dateText) {
         Integer count = jdbc.queryForObject(
-                "select count(*) from merchant_withdrawal where merchant_id = ? and withdrawal_date = ?",
-                Integer.class, merchantId, dateText);
+                "select count(*) from merchant_withdrawal where merchant_id = ? and store_id = ? and withdrawal_date = ?",
+                Integer.class, merchantId, storeId, dateText);
         return count != null && count > 0;
     }
 
     private void createWithdrawal(Long merchantId, Long storeId, Long bankCardId, String dateText,
                                   long total, List<OrderAmount> orders) {
-        // 业务请求号固定为商户 + 业务日:重复插入会被 uk_merchant_withdrawal_key 拦住,不会重复出账。
-        String idempotencyKey = "auto-withdrawal:" + merchantId + ":" + dateText;
+        // 业务请求号固定为商户 + 门店 + 业务日:重复插入会被 uk_merchant_withdrawal_key 拦住,不会重复出账。
+        String idempotencyKey = "auto-withdrawal:" + merchantId + ":" + storeId + ":" + dateText;
         jdbc.update("""
                 insert into merchant_withdrawal(merchant_id, store_id, withdrawal_no, bank_card_id,
                                                 amount_cents, idempotency_key, withdrawal_date)
@@ -231,13 +246,14 @@
         Long withdrawalId = jdbc.queryForObject(
                 "select id from merchant_withdrawal where merchant_id = ? and idempotency_key = ?",
                 Long.class, merchantId, idempotencyKey);
-        for (OrderAmount order : orders) {
-            orderFundBoundary.lockIntoWithdrawal(order.orderId());
-            jdbc.update("""
-                    insert into merchant_withdrawal_order(withdrawal_id, order_id, amount_cents)
-                    values (?, ?, ?)
-                    """, withdrawalId, order.orderId(), order.amount());
-        }
+        // 行已经被 lock(orderId) 锁住,这里只需要写锁定状态并登记关联。
+        jdbc.batchUpdate("update benefit_order set withdrawal_status = 1 where id = ? and withdrawal_status = 0",
+                orders.stream().map(order -> new Object[] { order.orderId() }).toList());
+        jdbc.batchUpdate("""
+                insert into merchant_withdrawal_order(withdrawal_id, order_id, amount_cents) values (?, ?, ?)
+                """, orders.stream()
+                .map(order -> new Object[] { withdrawalId, order.orderId(), order.amount() })
+                .toList());
     }
 
     private Long number(Object value) {
@@ -246,7 +262,7 @@
 
     private record OrderAmount(Long orderId, long amount) { }
 
-    /** 一次汇总的结果,用于日志和测试断言。 */
+    /** 一次(商户 + 门店 + 业务日)汇总的结果,用于日志和测试断言。 */
     public static final class Result {
 
         private final String businessDate;
@@ -294,4 +310,14 @@
                     + " failed=" + merchantsFailed + " orders=" + ordersAggregated + " amountCents=" + amountCents;
         }
     }
+
+    /** 一个(商户 + 门店 + 业务日)汇总的事务结果。 */
+    private record Contribution(Outcome outcome, long amountCents, int orders) {
+
+        static final Contribution NONE = new Contribution(Outcome.NONE, 0, 0);
+        static final Contribution DEFERRED = new Contribution(Outcome.DEFERRED, 0, 0);
+        static final Contribution SKIPPED = new Contribution(Outcome.SKIPPED, 0, 0);
+
+        enum Outcome { AGGREGATED, DEFERRED, SKIPPED, NONE }
+    }
 }
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/AutoWithdrawalProperties.java b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/AutoWithdrawalProperties.java
index 9935691..3575875 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/AutoWithdrawalProperties.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/AutoWithdrawalProperties.java
@@ -20,7 +20,7 @@
     /** 任务是否启用。凌晨的定时任务按北京时间触发,本地开发可以关掉。 */
     private boolean enabled = true;
 
-    /** 每天触发日汇总的时间(北京时间,cron 的 秒 分 时 日 月 周)。 */
+    /** 每天触发日汇总的时间(北京时间,cron 的 秒 分 时 日 月 周)。由 AutoWithdrawalScheduler 的 @Scheduled 从配置读取。 */
     private String cron = "0 10 0 * * *";
 
     public long getMinAmountCents() { return minAmountCents; }
diff --git a/talent-card-server/src/main/resources/application.yml b/talent-card-server/src/main/resources/application.yml
index 764fc8a..61c3494 100644
--- a/talent-card-server/src/main/resources/application.yml
+++ b/talent-card-server/src/main/resources/application.yml
@@ -85,8 +85,10 @@
         fixed-delay-ms: ${HUIFU_CAPABILITY_REFRESH_FIXED_DELAY_MS:1800000}
   merchant:
     auto-withdrawal:
-      # 每日凌晨按北京时间汇总前一个自然日已核销订单,生成一条商户日汇总提现记录。
-      enabled: ${AUTO_WITHDRAWAL_ENABLED:true}
+      # 每日凌晨按北京时间汇总前一个自然日已核销订单,生成一条商户(门店)日汇总提现记录。
+      # 手动提现入口在工单 05 之前仍然存在,且不会写 benefit_order.withdrawal_status,同时开启会出现
+      # 同一批订单既进人工提现又进自动提现;因此默认关闭,等工单 05 提交斗拱并移除手动入口后再打开。
+      enabled: ${AUTO_WITHDRAWAL_ENABLED:false}
       min-amount-cents: ${AUTO_WITHDRAWAL_MIN_AMOUNT_CENTS:1000}
       fee-buffer-cents: ${AUTO_WITHDRAWAL_FEE_BUFFER_CENTS:0}
       cron: ${AUTO_WITHDRAWAL_CRON:0 10 0 * * *}
diff --git a/talent-card-server/src/main/resources/db/migration/V134__daily_withdrawal_aggregation_store_scope.sql b/talent-card-server/src/main/resources/db/migration/V134__daily_withdrawal_aggregation_store_scope.sql
new file mode 100644
index 0000000..bd98720
--- /dev/null
+++ b/talent-card-server/src/main/resources/db/migration/V134__daily_withdrawal_aggregation_store_scope.sql
@@ -0,0 +1,39 @@
+-- 每日自动提现汇总的第二个迁移:V133 只加了列和唯一索引,这里把「同一商户同一自然日一条日汇总记录」
+-- 收紧到「同一商户 + 同一门店 + 同一自然日」,与提现记录本来按门店归属的既有语义一致;
+-- 并给 store_id 为空的历史行一个非空键(MySQL 唯一索引允许多个 NULL,NULL 会让约束形同虚设)。
+--
+-- 之所以不改 V133:它已经在共享测试库上执行过,迁移只追加不修改(docs/agents/testing.md)。
+-- 索引名沿用 V133 里的 uk_merchant_withdrawal_daily,所以先按条件把它换成新定义。
+
+-- 生成列 store_key:store_id 为空时取 0。generated 列不能直接改唯一索引里的 NULL 语义,
+-- 但可以让 NULL 变成一个确定值,从而保证同一天不会因为 store_id 为空而多出一行。
+alter table merchant_withdrawal
+    add column store_key bigint generated always as (ifnull(store_id, 0)) stored
+        comment '门店键:store_id 为空时取 0,仅用于日汇总唯一约束';
+
+-- 清理 V133 之后可能存在的重复日汇总(同一商户 + 门店 + 业务日),保留最早一条。
+delete later from merchant_withdrawal later
+    join merchant_withdrawal earliest
+      on earliest.merchant_id = later.merchant_id
+     and earliest.store_key = later.store_key
+     and earliest.withdrawal_date = later.withdrawal_date
+     and earliest.id < later.id
+where later.withdrawal_date is not null;
+
+-- 旧索引是 (merchant_id, withdrawal_date),新索引是 (merchant_id, store_key, withdrawal_date)。
+-- MySQL 的 DDL 不支持 IF EXISTS,用 information_schema 判断,保证在全新库和已执行过 V133 的库上都能跑。
+set @drop_daily_index = (
+    select if(count(*) > 0,
+              'alter table merchant_withdrawal drop index uk_merchant_withdrawal_daily',
+              'select 1')
+    from information_schema.statistics
+    where table_schema = database()
+      and table_name = 'merchant_withdrawal'
+      and index_name = 'uk_merchant_withdrawal_daily'
+);
+prepare drop_daily_index from @drop_daily_index;
+execute drop_daily_index;
+deallocate prepare drop_daily_index;
+
+alter table merchant_withdrawal
+    add unique key uk_merchant_withdrawal_daily (merchant_id, store_key, withdrawal_date);
diff --git a/talent-card-server/src/test/java/com/talentcard/server/business/merchant/AutoWithdrawalAggregationTests.java b/talent-card-server/src/test/java/com/talentcard/server/business/merchant/AutoWithdrawalAggregationTests.java
index 22c1b9d..6c6323d 100644
--- a/talent-card-server/src/test/java/com/talentcard/server/business/merchant/AutoWithdrawalAggregationTests.java
+++ b/talent-card-server/src/test/java/com/talentcard/server/business/merchant/AutoWithdrawalAggregationTests.java
@@ -18,7 +18,7 @@
 import org.junit.jupiter.api.Test;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.test.context.SpringBootTest;
-import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.dao.DuplicateKeyException;
 import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.jdbc.datasource.DataSourceTransactionManager;
 import org.springframework.test.context.ActiveProfiles;
@@ -58,22 +58,33 @@
         suffix = String.valueOf(System.nanoTime());
         jdbc.update("insert into merchant(merchant_name, status, deleted) values (?, 1, 0)", "Auto withdrawal merchant " + suffix);
         merchantId = jdbc.queryForObject("select max(id) from merchant", Long.class);
-        jdbc.update("insert into merchant_store(merchant_id, store_name, status, deleted) values (?, ?, 1, 0)", merchantId, "Auto withdrawal store " + suffix);
-        storeId = jdbc.queryForObject("select max(id) from merchant_store", Long.class);
         jdbc.update("insert into talent_user(openid, name, mobile, status, deleted) values (?, ?, ?, 1, 0)", "auto-withdrawal-" + suffix, "自动提现用户", "13900000003");
         talentId = jdbc.queryForObject("select max(id) from talent_user", Long.class);
-        jdbc.update("""
-                insert into merchant_onboarding_application(merchant_id, store_id, application_no, subject_type, subject_name,
-                                                            contact_name, contact_mobile, idempotency_key, status, provider_huifu_id)
-                values (?, ?, ?, 'individual', 'Auto withdrawal owner', 'Auto withdrawal owner', '13800000001', ?, 'approved', ?)
-                """, merchantId, storeId, "MO-AWA-" + suffix, "auto-withdrawal-onboarding-" + suffix, shortReference("H", suffix));
+        storeId = insertOnboardedStore("main");
+        bankCardId = jdbc.queryForObject(
+                "select id from merchant_bank_card where merchant_id = ? and store_id = ?", Long.class, merchantId, storeId);
+    }
+
+    /** 给商户加一个「进件通过 + 结算卡审核通过(带 token)」的门店,返回门店 ID。 */
+    private Long insertOnboardedStore(String name) {
+        String reference = name + "-" + suffix;
+        jdbc.update("insert into merchant_store(merchant_id, store_name, status, deleted) values (?, ?, 1, 0)",
+                merchantId, "Auto withdrawal " + reference);
+        Long newStoreId = jdbc.queryForObject("select max(id) from merchant_store", Long.class);
         jdbc.update("""
                 insert into merchant_bank_card(merchant_id, store_id, bank_name, branch_name, city_code, card_tail, card_masked,
                                                card_hash, status, idempotency_key, provider_token_no)
                 values (?, ?, '中国建设银行', '攀枝花东区支行', '510400', '8888', '**** **** **** 8888', ?, 'approved', ?, ?)
-                """, merchantId, storeId, "hash-auto-withdrawal-" + suffix, "auto-withdrawal-card-" + suffix, shortReference("T", suffix));
-        bankCardId = jdbc.queryForObject("select max(id) from merchant_bank_card", Long.class);
-        jdbc.update("update merchant_onboarding_application set initial_bank_card_id=? where application_no=?", bankCardId, "MO-AWA-" + suffix);
+                """, merchantId, newStoreId, "hash-" + reference, "card-" + reference, shortReference("T", reference));
+        Long newBankCardId = jdbc.queryForObject("select max(id) from merchant_bank_card", Long.class);
+        jdbc.update("""
+                insert into merchant_onboarding_application(merchant_id, store_id, application_no, subject_type, subject_name,
+                                                            contact_name, contact_mobile, idempotency_key, status,
+                                                            provider_huifu_id, initial_bank_card_id)
+                values (?, ?, ?, 'individual', 'Auto withdrawal owner', 'Auto withdrawal owner', '13800000001', ?, 'approved', ?, ?)
+                """, merchantId, newStoreId, "MO-" + reference, "onboarding-" + reference,
+                shortReference("H", reference), newBankCardId);
+        return newStoreId;
     }
 
     @Test
@@ -81,11 +92,15 @@
         LocalDate businessDate = someBusinessDate();
         Long first = verifiedOrder(businessDate, 9000);
         Long second = verifiedOrder(businessDate, 3000);
-        Long unpaid = order(1, 0, 0, 0, 7000, businessDate);            // 未支付
-        Long unverified = order(1, 1, 0, 0, 5000, businessDate);        // 支付但未核销
-        Long refunded = order(1, 1, 1, 2, 4000, businessDate);          // 核销后退款
-        Long alreadyInWithdrawal = order(1, 1, 1, 0, 6000, businessDate); // 已进入其它提现记录
-        jdbc.update("update benefit_order set withdrawal_status = 1 where id = ?", alreadyInWithdrawal);
+        // 下面每一笔都有有效核销记录,因此只能被对应的状态条件排除,保证过滤条件本身被测到。
+        Long unpaid = order(0, 0, 0, 0, 7000, businessDate);
+        Long unverified = order(1, 0, 0, 0, 5000, businessDate);
+        Long deleted = order(1, 1, 1, 0, 6000, businessDate);
+        Long refunded = order(1, 2, 0, 0, 4000, businessDate);
+        Long alreadyInWithdrawal = order(1, 0, 0, 1, 2000, businessDate);
+        for (Long orderId : List.of(unpaid, unverified, deleted, refunded, alreadyInWithdrawal)) {
+            verifiedRecord(orderId, storeId, businessDate.atTime(10, 0));
+        }
 
         service.run(businessDate);
 
@@ -93,15 +108,15 @@
         assertThat(withdrawal).containsEntry("amount_cents", 12000L).containsEntry("status", "pending");
         assertThat(sourceOrders(withdrawal)).containsExactlyInAnyOrder(
                 new SourceOrder(first, 9000L), new SourceOrder(second, 3000L));
-        // 未支付、未核销、已退款的订单没有进入提现记录;已在其它提现记录里的保持原状。
-        assertThat(lockedOrderIds()).containsExactlyInAnyOrder(first, second, alreadyInWithdrawal)
-                .doesNotContain(unpaid, unverified, refunded);
+        // 已进入其它提现记录的订单保持原状,其余四笔都被各自的过滤条件挡在外面。
+        assertThat(lockedOrderIds()).containsExactlyInAnyOrder(first, second)
+                .doesNotContain(unpaid, unverified, deleted, refunded, alreadyInWithdrawal);
     }
 
     @Test
     void ordersVerifiedAfterTheBusinessDayWaitForTheNextRun() {
         LocalDate businessDate = someBusinessDate();
-        Long early = verifiedOrderAt(businessDate.minusDays(1).atTime(23, 59, 59), 1500);
+        Long early = verifiedOrderAt(businessDate.minusDays(10).atTime(23, 59, 59), 1500);
         Long lastMoment = verifiedOrderAt(businessDate.atTime(23, 59, 59), 2000);
         Long afterWindow = verifiedOrderAt(businessDate.plusDays(1).atStartOfDay(), 3000);
 
@@ -117,6 +132,19 @@
         assertThat(sourceOrderIds(businessDate.plusDays(1))).containsExactly(afterWindow);
         assertThat(lockedOrderIds()).containsExactlyInAnyOrder(early, lastMoment, afterWindow);
         assertThat(allSourceOrderIds()).containsExactlyInAnyOrder(early, lastMoment, afterWindow);
+    }
+
+    @Test
+    void ordersOlderThanTheLookbackWindowAreLeftForManualReview() {
+        LocalDate businessDate = someBusinessDate();
+        Long tooOld = verifiedOrderAt(
+                businessDate.minusDays(AutoWithdrawalAggregationService.MAX_LOOKBACK_DAYS + 1).atTime(10, 0), 9000);
+
+        service.run(businessDate);
+
+        // 首次上线时不会把很久以前的历史订单一次性汇总进来。
+        assertThat(dailyWithdrawalOrNull(businessDate)).isNull();
+        assertThat(lockedOrderIds()).doesNotContain(tooOld);
     }
 
     @Test
@@ -137,7 +165,6 @@
         assertThat(sourceOrderIds(businessDate.plusDays(1))).contains(small, later);
         assertThat(lockedOrderIds()).containsExactlyInAnyOrder(small, later);
     }
-
     @Test
     void anAmountThatWouldNotCoverTheFeeBufferIsDeferred() {
         AutoWithdrawalProperties properties = new AutoWithdrawalProperties();
@@ -184,7 +211,7 @@
     }
 
     @Test
-    void theDatabaseRejectsASecondDailyWithdrawalForOneMerchantAndDay() {
+    void theDatabaseRejectsASecondDailyWithdrawalForOneMerchantStoreAndDay() {
         LocalDate businessDate = someBusinessDate();
         verifiedOrder(businessDate, 2000);
         service.run(businessDate);
@@ -195,7 +222,17 @@
                                                 idempotency_key, withdrawal_date)
                 values (?, ?, ?, ?, 100, ?, ?)
                 """, merchantId, storeId, "WA-DUP-" + suffix, bankCardId, "auto-withdrawal-duplicate:" + suffix, dateText))
-                .isInstanceOf(DataIntegrityViolationException.class);
+                .isInstanceOf(DuplicateKeyException.class);
+
+        // 同一天另一个门店是另一条日汇总记录,不受这条约束影响。
+        Long otherStoreId = insertOnboardedStore("daily-key");
+        jdbc.update("""
+                insert into merchant_withdrawal(merchant_id, store_id, withdrawal_no, bank_card_id, amount_cents,
+                                                idempotency_key, withdrawal_date)
+                values (?, ?, ?, ?, 100, ?, ?)
+                """, merchantId, otherStoreId, "WA-OTHER-" + suffix, bankCardId, "auto-withdrawal-other:" + suffix, dateText);
+        assertThat(dailyWithdrawalCountForStore(businessDate, storeId)).isEqualTo(1);
+        assertThat(dailyWithdrawalCountForStore(businessDate, otherStoreId)).isEqualTo(1);
     }
 
     @Test
@@ -216,8 +253,36 @@
 
         service.run(businessDate);
 
+        // 卡没有 token 时不能生成记录,否则订单被锁死而记录提交不出去;这一天的金额顺延。
         assertThat(dailyWithdrawalOrNull(businessDate)).isNull();
         assertThat(lockedOrderIds()).doesNotContain(orderId);
+    }
+
+    @Test
+    void anOnboardingWithoutASubMerchantNumberIsNotAggregated() {
+        jdbc.update("update merchant_onboarding_application set provider_huifu_id = null where merchant_id = ?", merchantId);
+        LocalDate businessDate = someBusinessDate();
+        Long orderId = verifiedOrder(businessDate, 9000);
+
+        service.run(businessDate);
+
+        assertThat(dailyWithdrawalOrNull(businessDate)).isNull();
+        assertThat(lockedOrderIds()).doesNotContain(orderId);
+    }
+
+    @Test
+    void aDedicatedStoreIsAggregatedSeparatelyAndKeepsTheOrdersOfOtherStores() {
+        Long otherStoreId = insertOnboardedStore("other");
+        LocalDate businessDate = someBusinessDate();
+        Long mainOrder = verifiedOrder(businessDate, 2000);
+        Long otherOrder = verifiedOrderForStore(otherStoreId, businessDate, 500);
+
+        service.run(businessDate);
+
+        // 另一个门店只有 500 分,低于最低金额,它的订单不会被主门店的汇总带走。
+        assertThat(dailyWithdrawal(businessDate)).containsEntry("amount_cents", 2000L).containsEntry("store_id", storeId);
+        assertThat(sourceOrderIds(businessDate)).containsExactly(mainOrder);
+        assertThat(lockedOrderIds()).containsExactly(mainOrder).doesNotContain(otherOrder);
     }
 
     @Test
@@ -249,27 +314,52 @@
 
     /** 已支付、已核销的订单,核销时间落在业务日 10:00,返回订单 ID。 */
     private Long verifiedOrder(LocalDate businessDate, long payAmount) {
-        return verifiedOrderAt(businessDate.atTime(10, 0), payAmount);
+        return verifiedOrderForStore(storeId, businessDate, payAmount);
+    }
+
+    private Long verifiedOrderForStore(Long orderStoreId, LocalDate businessDate, long payAmount) {
+        return verifiedOrderAt(orderStoreId, businessDate.atTime(10, 0), payAmount);
     }
 
     private Long verifiedOrderAt(LocalDateTime verifyTime, long payAmount) {
-        Long orderId = order(1, 1, 0, 0, payAmount, verifyTime.toLocalDate());
-        jdbc.update("""
-                insert into verify_record(verify_no, order_id, talent_id, merchant_id, store_id, verify_type,
-                                          verify_amount, verify_time, status)
-                values (?, ?, ?, ?, ?, 'ORDER_CODE', ?, ?, 1)
-                """, "V-AWA-" + suffix + "-" + orderId, orderId, talentId, merchantId, storeId, payAmount, verifyTime);
+        return verifiedOrderAt(storeId, verifyTime, payAmount);
+    }
+
+    private Long verifiedOrderAt(Long orderStoreId, LocalDateTime verifyTime, long payAmount) {
+        Long orderId = order(orderStoreId, 1, 1, 0, 0, payAmount, verifyTime.toLocalDate());
+        verifiedRecord(orderId, orderStoreId, verifyTime);
         return orderId;
     }
 
+    private void verifiedRecord(Long orderId, LocalDateTime verifyTime) {
+        verifiedRecord(orderId, storeId, verifyTime);
+    }
+
+    private void verifiedRecord(Long orderId, Long orderStoreId, LocalDateTime verifyTime) {
+        jdbc.update("""
+                insert into verify_record(verify_no, order_id, talent_id, merchant_id, store_id, verify_type,
+                                          verify_amount, verify_time, status)
+                values (?, ?, ?, ?, ?, 'ORDER_CODE', 0, ?, 1)
+                """, "V-AWA-" + suffix + "-" + orderId, orderId, talentId, merchantId, orderStoreId, verifyTime);
+    }
+
+    /**
+     * 插入一张订单。参数顺序是 {@code order_status, verify_status, deleted, refund_status},
+     * {@code deleted} 是 {@code verify_status = 0}(没有有效核销记录)之外的第二个排除原因。
+     */
     private Long order(int orderStatus, int verifyStatus, int deleted, int refundStatus, long payAmount, LocalDate createdAt) {
+        return order(storeId, orderStatus, verifyStatus, deleted, refundStatus, payAmount, createdAt);
+    }
+
+    private Long order(Long orderStoreId, int orderStatus, int verifyStatus, int deleted, int refundStatus,
+                       long payAmount, LocalDate createdAt) {
         String orderNo = "AWA-" + suffix + "-" + System.nanoTime();
         jdbc.update("""
                 insert into benefit_order(order_no, talent_id, merchant_id, store_id, order_type, order_amount,
                                           subsidy_amount, pay_amount, order_status, verify_status, refund_status,
                                           withdrawal_status, deleted, created_at)
                 values (?, ?, ?, ?, 'GROUP_BUY', ?, 0, ?, ?, ?, ?, 0, ?, ?)
-                """, orderNo, talentId, merchantId, storeId, payAmount, payAmount, orderStatus, verifyStatus,
+                """, orderNo, talentId, merchantId, orderStoreId, payAmount, payAmount, orderStatus, verifyStatus,
                 refundStatus, deleted, createdAt.atTime(9, 0));
         return jdbc.queryForObject("select id from benefit_order where order_no = ?", Long.class, orderNo);
     }
@@ -288,8 +378,14 @@
     }
 
     private Integer dailyWithdrawalCount(LocalDate businessDate) {
-        return jdbc.queryForObject("select count(*) from merchant_withdrawal where merchant_id = ? and withdrawal_date = ?",
-                Integer.class, merchantId, businessDate.format(BUSINESS_DATE));
+        return dailyWithdrawalCountForStore(businessDate, storeId);
+    }
+
+    private Integer dailyWithdrawalCountForStore(LocalDate businessDate, Long withdrawalStoreId) {
+        return jdbc.queryForObject("""
+                select count(*) from merchant_withdrawal
+                where merchant_id = ? and store_id = ? and withdrawal_date = ?
+                """, Integer.class, merchantId, withdrawalStoreId, businessDate.format(BUSINESS_DATE));
     }
 
     private List<Long> sourceOrderIds(LocalDate businessDate) {

--
Gitblit v1.7.1