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
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;
 
/**
 * 后台结算管理。
 *
 * <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>按门店取该商户当前生效的斗拱子商户号,逐商户查询结算记录后合并成一条时间倒序的列表。
     * 通道查询失败只影响这一条商户,不会让整页失败——监控页最重要的是「看得到」。
     *
     * <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());
        List<Map<String, Object>> merchants = new ArrayList<>();
        for (Map<String, Object> candidate : candidates) {
            if (readinessService.channelCapabilityNormal(((Number) candidate.get("applicationId")).longValue())) {
                merchants.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<String, Object> merchant : merchants) {
            String huifuId = String.valueOf(merchant.get("providerHuifuId"));
            try {
                HuifuSettlementRecordGateway.SettlementRecords response =
                        settlementRecordGateway.query(huifuId, beginDate, endDate, limit);
                if (!response.succeeded()) {
                    failures.add(String.valueOf(merchant.get("merchantName")) + ":" + response.message());
                    continue;
                }
                for (Map<String, Object> record : decode(response.rawRecords(), merchant)) {
                    if (matchesStatus(record, status)) records.add(record);
                }
            } catch (RuntimeException exception) {
                failures.add(String.valueOf(merchant.get("merchantName")) + ":查询失败");
            }
        }
        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")));
    }
 
    /** 把通道返回的结算记录 JSON 转成后台要展示的字段;只读展示,不做金额换算以外的加工。 */
    private List<Map<String, Object>> decode(String rawRecords, Map<String, Object> merchant) {
        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", merchant.get("merchantName"));
            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) {
        return page("select a.*, m.merchant_name, m.merchant_type, m.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);
    }
    @Transactional(readOnly = true)
    public Map<String, Object> onboarding(Long id) {
        List<Map<String, Object>> rows = rows("select a.*, a.merchant_name as onboarding_merchant_name, m.merchant_name, m.merchant_type, m.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 where a.id = ?", id);
        if (rows.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "商户进件不存在");
        Map<String, Object> result = rows.get(0);
        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 -> {
            String encrypted = String.valueOf(row.remove("cardNoEncrypted"));
            if (!encrypted.isBlank() && !"null".equals(encrypted)) row.put("cardNo", sensitiveDataCrypto.decrypt(encrypted));
            else row.put("cardNo", row.get("cardMasked"));
            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); } }
    private String bankCardCityName(Object cityCode) {
        return switch (cityCode == null ? "" : String.valueOf(cityCode).trim()) {
            case "510100" -> "四川省|成都市";
            case "510300" -> "四川省|自贡市";
            case "510400" -> "四川省|攀枝花市";
            case "510500" -> "四川省|泸州市";
            case "510600" -> "四川省|德阳市";
            case "510700" -> "四川省|绵阳市";
            case "510800" -> "四川省|广元市";
            case "510900" -> "四川省|遂宁市";
            case "511000" -> "四川省|内江市";
            case "511100" -> "四川省|乐山市";
            case "511300" -> "四川省|南充市";
            case "511400" -> "四川省|眉山市";
            case "511500" -> "四川省|宜宾市";
            case "511600" -> "四川省|广安市";
            case "511700" -> "四川省|达州市";
            case "511800" -> "四川省|雅安市";
            case "511900" -> "四川省|巴中市";
            case "512000" -> "四川省|资阳市";
            case "513200" -> "四川省|阿坝藏族羌族自治州";
            case "513300" -> "四川省|甘孜藏族自治州";
            case "513400" -> "四川省|凉山彝族自治州";
            default -> cityCode == null ? "" : String.valueOf(cityCode).trim();
        };
    }
    private List<Map<String, Object>> rows(String sql, Object... args) { return DatabaseRows.normalize(jdbc.queryForList(sql, args)); }
}