yeg
2026-09-15 9d994ea0eee51dbc87fe83ead985580545926b8a
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
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;
 
    public Map<String,Object> onboardingProgress(Long id) { return progressService.view(id); }
    public Map<String,Object> refreshOnboarding(Long id, boolean retry) { return progressService.refresh(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>条数上限跟着通道走:这个接口的 {@code pageSize} 超过 50 会被判为基础参数校验失败,
     * 传 100 时整页一条流水都查不出来。上限在网关里还有一道兜底,这里只是不要再主动越界。
     */
    public Map<String, Object> settlements(String beginDate, String endDate, int size) {
        List<Map<String, Object>> merchants = rows("""
                select 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 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)
                order by a.merchant_id asc
                """);
        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;
                }
                records.addAll(decode(response.rawRecords(), merchant));
            } 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;
    }
 
    /** 把通道返回的结算记录 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)); }
}