1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
package com.talentcard.server.business.admin;
 
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
 
import com.alibaba.fastjson.JSON;
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.PageResult;
import com.talentcard.server.business.payment.huifu.HuifuSettlementRecordGateway;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import com.talentcard.server.common.SensitiveDataCrypto;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.talentcard.server.business.merchant.MerchantPaymentReadinessService;
import com.talentcard.server.business.merchant.MerchantSettlementService;
import com.talentcard.server.business.merchant.MerchantTypeCatalog;
import com.talentcard.server.business.merchant.RegionCityCatalog;
 
/**
 * 后台结算管理。
 *
 * <p>出款由斗拱按结算周期自动完成(核销确认后的钱进商户基本户,次日自动结算到结算卡),
 * 平台不再产生提现记录。后台保留进件、银行卡查询、收款能力快照刷新,以及向通道查询
 * 真实结算记录用于监控每天结了多少到银行卡。
 */
@Service
public class MerchantSettlementAdminService {
    private final JdbcTemplate jdbc;
    private final SensitiveDataCrypto sensitiveDataCrypto;
    private final MerchantPaymentReadinessService readinessService;
    private final HuifuSettlementRecordGateway settlementRecordGateway;
    @org.springframework.beans.factory.annotation.Autowired
    private com.talentcard.server.business.merchant.MerchantOnboardingProgressService progressService;
    @org.springframework.beans.factory.annotation.Autowired
    private com.talentcard.server.business.merchant.MerchantRealnameService realnameService;
    @org.springframework.beans.factory.annotation.Autowired
    private com.talentcard.server.business.merchant.MerchantSettlementService settlementService;
 
    public Map<String,Object> onboardingProgress(Long id) { return progressService.view(id); }
    /** 后台刷新与商户端走同一条路径:先推进已满足前置条件的开通步骤,再查询回报结果。 */
    public Map<String,Object> refreshOnboarding(Long id, boolean retry) { return settlementService.refreshProgress(id, retry); }
    /** 微信实名认证:平台运营可在后台代为发起;未决申请不会重复提交。 */
    public Map<String,Object> submitRealname(Long id, boolean retry) { return realnameService.submit(id, retry); }
    public MerchantSettlementAdminService(JdbcTemplate jdbc, SensitiveDataCrypto sensitiveDataCrypto, MerchantPaymentReadinessService readinessService, HuifuSettlementRecordGateway settlementRecordGateway) { this.jdbc = jdbc; this.sensitiveDataCrypto = sensitiveDataCrypto; this.readinessService = readinessService; this.settlementRecordGateway = settlementRecordGateway; }
 
    /**
     * 商家类型筛选项。
     *
     * <p>取值来自后端目录 {@link MerchantTypeCatalog}——前端原来自己抄了一份 8 项的清单,
     * 既有抄错的标签(住宿服务/购房服务/体检服务在目录里只是别名,库里存的是酒店服务/购房优惠/
     * 免费体检),也漏掉了后来新增的类型,导致下拉里有选项筛不出数据、数据里有的类型选不到。
     */
    public List<String> merchantTypes() {
        return MerchantTypeCatalog.all().stream().map(MerchantTypeCatalog.MerchantType::displayName).toList();
    }
 
    /**
     * 结算记录:钱按结算周期自动划到结算卡之后,「每天结了多少」只能问通道。
     *
     * <p>取各商家当前生效的斗拱子商户号,**按商户号去重后逐个查询**、合并成一条时间倒序的列表。
     * 通道查询失败只影响这一个商户号,不会让整页失败——监控页最重要的是「看得到」。
     *
     * <p><b>为什么按商户号而不是按商家去问</b>:结算流水属于商户号(一个收款主体),不属于商家。
     * 连锁各门店是各自独立的商家、共用同一个收款主体,通道里就是一条流水;按商家逐家去问会把同一条
     * 流水按共用它的商家数重复印出来(7 家共用就是 7 行一模一样的流水号/金额/到账卡),而每家的查询
     * 各自带 {@code limit},截断后又会把别的流水挤掉——重复与漏行是同一条错。共用时那一行的商家名
     * 把所有共用的商家都列出来(见 {@link #sharedMerchantLabel}),因为流水确实不属于其中任何一家。
     *
     * <p>只查「通道侧收款能力已核实且正常」的商户({@link MerchantPaymentReadinessService#channelCapabilityNormal}):
     * 没开通好、或在汇付被关停的商户,查过去也会被通道拒(实测被关闭的子商户回「客户与产品关系已关闭」),
     * 只会让页面每次打开都多一条失败提示。判定共用收款能力那套取值,不在这里另立一套。
     *
     * <p>筛选分两处,因为两类条件属于两边:商家、联系电话、商家类型是**本地商户字段**,先筛掉不匹配的
     * 商户再去问通道——既让查询生效,也顺带少打几次通道;状态是**通道流水的结算状态**({@code S}/{@code F}/{@code P}),
     * 只能等通道把流水给回来之后再过滤。{@code beginDate}/{@code endDate} 是结算日期的区间(不是商户申请时间)。
     *
     * <p>条数上限跟着通道走:这个接口的 {@code pageSize} 超过 50 会被判为基础参数校验失败,
     * 传 100 时整页一条流水都查不出来。上限在网关里还有一道兜底,这里只是不要再主动越界。
     */
    public Map<String, Object> settlements(String beginDate, String endDate, int size,
                                           String merchantName, String contactMobile, String merchantType, String status) {
        StringBuilder where = new StringBuilder("""
                where a.status = 'approved' and a.provider_huifu_id is not null and a.provider_huifu_id <> ''
                  and a.id = (select max(newest.id) from merchant_onboarding_application newest
                              where newest.merchant_id = a.merchant_id)
                """);
        List<Object> args = new ArrayList<>();
        addLike(where, args, "m.merchant_name", merchantName);
        addLike(where, args, "m.contact_mobile", contactMobile);
        addEqual(where, args, "m.merchant_type", merchantType);
        List<Map<String, Object>> candidates = rows("""
                select a.id as application_id, a.merchant_id, a.provider_huifu_id, m.merchant_name, s.store_name
                from merchant_onboarding_application a
                join merchant m on m.id = a.merchant_id
                join merchant_store s on s.id = a.store_id
                """ + where + " order by a.merchant_id asc", args.toArray());
        // 关键:按**商户号**聚合,而不是按商家。一个商户号就是一个收款主体,结算流水只挂在它上面。
        Map<String, List<Map<String, Object>>> merchantsByHuifuId = new LinkedHashMap<>();
        for (Map<String, Object> candidate : candidates) {
            if (!readinessService.channelCapabilityNormal(((Number) candidate.get("applicationId")).longValue())) continue;
            merchantsByHuifuId.computeIfAbsent(String.valueOf(candidate.get("providerHuifuId")), huifuId -> new ArrayList<>())
                    .add(candidate);
        }
        List<Map<String, Object>> records = new ArrayList<>();
        List<String> failures = new ArrayList<>();
        int limit = Math.min(Math.max(size, 1), HuifuSettlementRecordGateway.PROVIDER_MAX_PAGE_SIZE);
        for (Map.Entry<String, List<Map<String, Object>>> entry : merchantsByHuifuId.entrySet()) {
            String huifuId = entry.getKey();
            String label = sharedMerchantLabel(entry.getValue());
            try {
                HuifuSettlementRecordGateway.SettlementRecords response =
                        settlementRecordGateway.query(huifuId, beginDate, endDate, limit);
                if (!response.succeeded()) {
                    failures.add(label + ":" + response.message());
                    continue;
                }
                for (Map<String, Object> record : decode(response.rawRecords(), entry.getValue().get(0), label)) {
                    if (matchesStatus(record, status)) records.add(record);
                }
            } catch (RuntimeException exception) {
                failures.add(label + ":查询失败");
            }
        }
        records.sort((left, right) -> String.valueOf(right.get("settleDate"))
                .compareTo(String.valueOf(left.get("settleDate"))));
        Map<String, Object> view = new LinkedHashMap<>();
        view.put("beginDate", beginDate);
        view.put("endDate", endDate);
        view.put("records", records.size() > limit ? records.subList(0, limit) : records);
        view.put("total", records.size());
        view.put("failures", failures);
        return view;
    }
 
    /** 结算状态筛选:值是通道自己的流水状态({@code S} 已结算 / {@code F} 失败 / {@code P} 处理中),空则全要。 */
    private boolean matchesStatus(Map<String, Object> record, String status) {
        if (status == null || status.isBlank()) return true;
        return status.trim().equalsIgnoreCase(String.valueOf(record.get("status")));
    }
 
    /**
     * 这一行显示成哪家的。
     *
     * <p>一个商户号可以被多家商家共用(连锁各门店各是一个独立商家,共用一个收款主体),而流水属于
     * 商户号、不属于其中任何一家。只说第一家会误导,只留一个名字又像把别家漏了,所以共用时把商家名
     * 都列出来并注明共用;只有一家时就照常显示那一家。
     */
    private static String sharedMerchantLabel(List<Map<String, Object>> merchants) {
        List<String> names = merchants.stream().map(merchant -> String.valueOf(merchant.get("merchantName")))
                .distinct().toList();
        return names.size() == 1 ? names.get(0) : String.join("、", names) + "(共用收款主体)";
    }
 
    /** 把通道返回的结算记录 JSON 转成后台要展示的字段;只读展示,不做金额换算以外的加工。 */
    private List<Map<String, Object>> decode(String rawRecords, Map<String, Object> merchant, String merchantLabel) {
        List<Map<String, Object>> decoded = new ArrayList<>();
        if (rawRecords == null || rawRecords.isBlank()) return decoded;
        List<Map<String, Object>> parsed;
        try {
            parsed = JSON.parseObject(rawRecords, new com.alibaba.fastjson.TypeReference<>() { });
        } catch (RuntimeException exception) {
            return decoded;
        }
        if (parsed == null) return decoded;
        for (Map<String, Object> record : parsed) {
            Map<String, Object> row = new LinkedHashMap<>();
            row.put("merchantId", merchant.get("merchantId"));
            // 商家名取聚合出来的那个标签:几家共用同一个商户号时,这一条流水是所有共用商家一起的。
            row.put("merchantName", merchantLabel);
            row.put("storeName", merchant.get("storeName"));
            row.put("providerTransactionNo", record.get("trans_id"));
            row.put("settleDate", record.get("trans_date"));
            row.put("settleAmountCents", cents(record.get("trans_amt")));
            row.put("feeAmountCents", cents(record.get("fee_amt")));
            row.put("settleCycle", record.get("settle_cycle"));
            row.put("status", record.get("trans_stat"));
            row.put("cardMasked", record.get("card_no"));
            row.put("cardHolder", record.get("card_name"));
            decoded.add(row);
        }
        return decoded;
    }
 
    private Long cents(Object yuan) {
        if (yuan == null) return null;
        return com.talentcard.server.business.payment.huifu.HuifuPayload.amountInCent(String.valueOf(yuan));
    }
 
    @Transactional(readOnly = true)
    public PageResult<Map<String, Object>> onboardings(int page, int size, String merchantName, String contactMobile, String merchantType, String status, String startDate, String endDate) {
        PageResult<Map<String, Object>> result = page("select a.*, a.merchant_name as onboarding_merchant_name, m.merchant_name as merchant_table_name, m.merchant_type, m.contact_mobile as merchant_table_contact_mobile, s.store_name from merchant_onboarding_application a join merchant m on m.id = a.merchant_id join merchant_store s on s.id = a.store_id", "select count(*) from merchant_onboarding_application a join merchant m on m.id = a.merchant_id join merchant_store s on s.id = a.store_id", "a", page, size, merchantName, contactMobile, merchantType, status, startDate, endDate);
        result.records().forEach(this::useMerchantTableFields);
        return result;
    }
    @Transactional(readOnly = true)
    public Map<String, Object> onboarding(Long id) {
        // 详情是进件当时的存档,所以名称和联系方式都取进件表那一份(a.* 里就有):这里不选
        // m.merchant_name / m.contact_mobile,同名撞车靠列顺序决定是不可靠的,不选它就没有歧义。
        // 列表的「联系电话」是商户表的筛选条件,两者用途不同,别把列表那套映射套过来。
        // 结算卡按 initial_bank_card_id 左连进来:运营要在这页看到这条进件绑的是哪张卡,
        // 但只取脱敏字段——明文卡号和 card_no_encrypted 都不出接口。
        List<Map<String, Object>> rows = rows("select a.*, a.merchant_name as onboarding_merchant_name, m.merchant_type, s.store_name, c.bank_name as settlement_bank_name, c.branch_name as settlement_branch_name, c.city_code as settlement_city_code, c.card_masked as settlement_card_masked, c.card_tail as settlement_card_tail, c.status as settlement_card_status, c.reject_reason as settlement_card_reject_reason from merchant_onboarding_application a join merchant m on m.id = a.merchant_id join merchant_store s on s.id = a.store_id left join merchant_bank_card c on c.id = a.initial_bank_card_id where a.id = ?", id);
        if (rows.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "商户进件不存在");
        Map<String, Object> result = rows.get(0);
        // 结算凭证图一列两义(对私=银行卡卡号面、对公=开户许可证),名字取决于主体是哪一个。
        // 判定复用进件那条业务规则,后台不另抄一份。
        result.put("privateSettlement", MerchantSettlementService.isPrivateSettlement(result.get("subjectType"), result.get("companyType")));
        result.put("settlementCityName", bankCardCityName(result.remove("settlementCityCode")));
        if (progressService.supports(result)) result.put("progress", progressService.view(result));
        // 个人主体的历史记录可能使用 individual、personal 或中文类型值;
        // 只要已保存加密身份证号,后台详情均应解密后完整展示。
        String encrypted = String.valueOf(result.get("subjectIdNoEncrypted"));
        if (!encrypted.isBlank() && !"null".equals(encrypted)) result.put("subjectIdNo", sensitiveDataCrypto.decrypt(encrypted));
        return result;
    }
    @Transactional(readOnly = true)
    public PageResult<Map<String, Object>> bankCards(int page, int size, String merchantName, String contactMobile, String merchantType, String status, String startDate, String endDate) {
        PageResult<Map<String, Object>> result = page("select c.*, m.merchant_name, m.merchant_type, m.contact_mobile, s.store_name from merchant_bank_card c join merchant m on m.id = c.merchant_id join merchant_store s on s.id = c.store_id", "select count(*) from merchant_bank_card c join merchant m on m.id = c.merchant_id join merchant_store s on s.id = c.store_id", "c", page, size, merchantName, contactMobile, merchantType, status, startDate, endDate);
        result.records().forEach(row -> {
            // 卡号在库里是密文,解密出来就是完整 PAN。列表只需要展示,所以密文、卡哈希和
            // 明文卡号都不出接口,前端改用后端已生成的 cardMasked。
            row.remove("cardNoEncrypted");
            row.remove("cardHash");
            row.put("cityName", bankCardCityName(row.get("cityCode")));
        });
        return result;
    }
    /** Provider-confirmed settlement and encashment capability of one approved sub-merchant. */
    public Map<String, Object> paymentCapability(Long applicationId) { return readinessService.capability(applicationId); }
 
    /** Re-reads the provider configuration and stores a fresh capability snapshot. */
    public Map<String, Object> refreshPaymentCapability(Long applicationId) { return readinessService.refresh(applicationId); }
 
    private PageResult<Map<String, Object>> page(String query, String count, String tableAlias, int page, int size, String merchantName, String contactMobile, String merchantType, String status, String startDate, String endDate) {
        return page(query, count, tableAlias, page, size, merchantName, contactMobile, merchantType, status, startDate, endDate, "");
    }
 
    private PageResult<Map<String, Object>> page(String query, String count, String tableAlias, int page, int size, String merchantName, String contactMobile, String merchantType, String status, String startDate, String endDate, String extraWhere) {
        int current = Math.max(1, page);
        int limit = Math.min(Math.max(1, size), 100);
        StringBuilder where = new StringBuilder(" where 1 = 1" + extraWhere);
        List<Object> args = new ArrayList<>();
        addLike(where, args, "m.merchant_name", merchantName);
        addLike(where, args, "m.contact_mobile", contactMobile);
        addEqual(where, args, "m.merchant_type", merchantType);
        addEqual(where, args, tableAlias + ".status", status);
        addDate(where, args, tableAlias + ".created_at", startDate, true);
        addDate(where, args, tableAlias + ".created_at", endDate, false);
        List<Object> rowArgs = new ArrayList<>(args);
        rowArgs.add(limit);
        rowArgs.add((current - 1) * limit);
        List<Map<String, Object>> values = rows(query + where + " order by " + tableAlias + ".created_at desc, " + tableAlias + ".id desc limit ? offset ?", rowArgs.toArray());
        Long total = jdbc.queryForObject(count + where, Long.class, args.toArray());
        return new PageResult<>(values, current, limit, total == null ? 0 : total);
    }
    private void addLike(StringBuilder where, List<Object> args, String column, String value) { if (value != null && !value.trim().isEmpty()) { where.append(" and ").append(column).append(" like ?"); args.add("%" + value.trim() + "%"); } }
    private void addEqual(StringBuilder where, List<Object> args, String column, String value) { if (value != null && !value.trim().isEmpty()) { where.append(" and ").append(column).append(" = ?"); args.add(value.trim()); } }
    private void addDate(StringBuilder where, List<Object> args, String column, String value, boolean start) { if (value != null && value.matches("\\d{4}-\\d{2}-\\d{2}")) { where.append(" and ").append(column).append(start ? " >= ?" : " < date_add(?, interval 1 day)"); args.add(value); } }
    /**
     * 把「商家名称 / 联系方式」换成商户表的值。
     *
     * <p>进件表和商户表都有 merchant_name 和 contact_mobile。查询里又带了 {@code a.*},所以即使给
     * join 出来的列另起别名,同名列依然存在两份,最终取到哪一个取决于驱动返回同名列的顺序——
     * 实测是「先出现的非空值赢」,于是进件表里申请时填的商户名称会盖掉商户表的。而这两个字段的筛选
     * 条件({@link #page})用的都是商户表,显示必须跟着筛选走;申请时填的商户名称另以
     * onboardingMerchantName 带出,不丢。
     */
    private void useMerchantTableFields(Map<String, Object> row) {
        row.put("merchantName", row.remove("merchantTableName"));
        row.put("contactMobile", row.remove("merchantTableContactMobile"));
    }
 
    /**
     * 开户城市代码转「省|市」。
     *
     * <p>未收录的代码原样返回:行政区划是外部数据,宁可把生僻值露出来让运营反馈,也不要猜一个。
     */
    private String bankCardCityName(Object cityCode) {
        String code = cityCode == null ? "" : String.valueOf(cityCode).trim();
        if (code.isEmpty()) return "";
        String name = RegionCityCatalog.provinceCity(code);
        return name.isEmpty() ? code : name;
    }
    private List<Map<String, Object>> rows(String sql, Object... args) { return DatabaseRows.normalize(jdbc.queryForList(sql, args)); }
}