shinnosuke
2026-09-27 35c0810b268661c79f776fe922f7e3834cba4186
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
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
package com.talentcard.server.business.merchant;
 
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
 
import com.alibaba.fastjson.JSON;
 
import com.talentcard.server.business.BusinessIdGenerator;
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.merchant.auth.MerchantPrincipal;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
 
@Service
public class ManualConsumptionService {
    private static final String TALENT_CODE = "TALENT_CODE";
    private static final int ATTEMPT_PENDING = 0;
    private static final int ATTEMPT_ORDER_CREATED = 3;
 
    private final JdbcTemplate jdbc;
    private final MerchantPaymentReadinessService paymentReadinessService;
 
    public ManualConsumptionService(JdbcTemplate jdbc, MerchantPaymentReadinessService paymentReadinessService) {
        this.jdbc = jdbc;
        this.paymentReadinessService = paymentReadinessService;
    }
 
    @Transactional(readOnly = true)
    public Map<String, Object> detail(MerchantPrincipal principal, String verificationId) {
        Map<String, Object> source = source(principal, verificationId, false);
        ensurePending(source);
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("verificationId", verificationId);
        result.put("customerName", displayName(string(source.get("talentName")), number(source.get("gender"))));
        result.put("customerMobile", maskMobile(string(source.get("talentMobile"))));
        result.put("cardLevel", string(source.get("cardLevel")));
        List<Map<String, Object>> matchedRules = rules(principal.merchantId(), string(source.get("cardLevel")), string(source.get("sourceAreaType")));
        if (matchedRules.isEmpty()) throw new BusinessException(ErrorCode.CONFLICT, "当前商户未配置适用于该人才卡的到店消费权益");
        result.put("benefitText", benefitTextForLevel(principal.merchantId(), string(source.get("cardLevel")),
                string(source.get("sourceAreaType")), matchedRules));
        result.put("rules", matchedRules);
        result.put("coupons", coupons(principal.merchantId(), number(source.get("talentId")), string(source.get("cardLevel"))));
        result.put("expiresAt", source.get("expiresAt"));
        return result;
    }
 
    @Transactional(readOnly = true)
    public Map<String, Object> quote(MerchantPrincipal principal, ManualConsumptionCreateRequest request) {
        Map<String, Object> source = source(principal, request.verificationId(), false);
        ensurePending(source);
        Map<String, Object> rule = rule(principal.merchantId(), string(source.get("cardLevel")), string(source.get("sourceAreaType")), request.ruleId());
        Map<String, Object> coupon = usableCoupon(principal.merchantId(), number(source.get("talentId")), string(source.get("cardLevel")), request.couponId(), discountedAmount(rule, request.amount()));
        return quote(rule, coupon, request.amount());
    }
 
    @Transactional
    public Map<String, Object> create(MerchantPrincipal principal, ManualConsumptionCreateRequest request, String idempotencyKey) {
        if (idempotencyKey == null || idempotencyKey.isBlank()) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "缺少幂等键");
        }
        Map<String, Object> source = source(principal, request.verificationId(), true);
        Long existingOrderId = number(source.get("manualOrderId"));
        if (number(source.get("attemptStatus")) == ATTEMPT_ORDER_CREATED && existingOrderId != null) {
            return createdOrder(existingOrderId);
        }
        ensurePending(source);
        Map<String, Object> rule = rule(principal.merchantId(), string(source.get("cardLevel")), string(source.get("sourceAreaType")), request.ruleId());
        Map<String, Object> coupon = usableCoupon(principal.merchantId(), number(source.get("talentId")), string(source.get("cardLevel")), request.couponId(), discountedAmount(rule, request.amount()));
        Map<String, Object> amounts = quote(rule, coupon, request.amount());
        // 需要支付的订单在创建时固定收款子商户;能力未就绪时不消耗人才码,避免产生无法结算的订单。
        MerchantPaymentReadinessService.CollectingMerchant collectingMerchant = number(amounts.get("payAmount")) > 0
                ? paymentReadinessService.requireCollectingMerchant(principal.merchantId(), principal.storeId())
                : null;
        Long dynamicCodeId = number(source.get("dynamicCodeId"));
        int codeUpdated = consumeScannedTalentCode(principal, dynamicCodeId);
        if (codeUpdated == 0) {
            throw new BusinessException(ErrorCode.CONFLICT, "人才码已被使用或已失效");
        }
 
        Map<String, Object> merchant = merchant(principal);
        String orderNo = BusinessIdGenerator.next("MC");
        jdbc.update("""
                insert into benefit_order(order_no, talent_id, card_id, card_level_snapshot, merchant_id, store_id, order_type,
                                          order_amount, subsidy_amount, base_pay_amount, pay_amount, coupon_id, coupon_amount, expires_at, price_version,
                                          merchant_name_snapshot, merchant_address_snapshot, merchant_mobile_snapshot,
                                          merchant_longitude_snapshot, merchant_latitude_snapshot, product_name_snapshot,
                                          collect_onboarding_id, collect_store_id, collect_huifu_id,
                                          order_status, verify_status, merchant_verification_attempt_id, client_idempotency_key)
                values (?, ?, ?, ?, ?, ?, 'STORE_CONSUME', ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?)
                """, orderNo, number(source.get("talentId")), number(source.get("cardId")), string(source.get("cardLevel")), principal.merchantId(), principal.storeId(),
                amounts.get("originalAmount"), amounts.get("subsidyAmount"), amounts.get("basePayAmount"), amounts.get("payAmount"), request.couponId(), amounts.get("couponAmount"),
                LocalDateTime.now().plusMinutes(15), merchant.get("merchantName"), merchant.get("address"), merchant.get("contactMobile"),
                merchant.get("longitude"), merchant.get("latitude"), "到店消费 - " + rule.get("ruleName"),
                MerchantPaymentReadinessService.CollectingMerchant.applicationIdOf(collectingMerchant),
                MerchantPaymentReadinessService.CollectingMerchant.storeIdOf(collectingMerchant),
                MerchantPaymentReadinessService.CollectingMerchant.huifuIdOf(collectingMerchant),
                number(source.get("attemptId")), "manual-consumption-" + number(source.get("attemptId")));
        Long orderId = jdbc.queryForObject("select id from benefit_order where merchant_verification_attempt_id = ?", Long.class, number(source.get("attemptId")));
        jdbc.update("""
                update merchant_verification_attempt set status = ?, manual_order_id = ?
                where id = ? and status = ?
                """, ATTEMPT_ORDER_CREATED, orderId, number(source.get("attemptId")), ATTEMPT_PENDING);
        return createdOrder(orderId);
    }
 
    private Map<String, Object> source(MerchantPrincipal principal, String verificationId, boolean forUpdate) {
        String lock = forUpdate ? " for update" : "";
        List<Map<String, Object>> sources = rows("""
                select a.id attempt_id, a.dynamic_code_id, a.status attempt_status, a.expires_at, a.manual_order_id,
                       d.talent_id, d.card_id, c.card_level,
                       t.name talent_name, t.mobile talent_mobile, t.gender, t.source_area_type
                from merchant_verification_attempt a
                join talent_dynamic_code d on d.id = a.dynamic_code_id
                join talent_card c on c.id = d.card_id and c.deleted = 0
                join talent_user t on t.id = d.talent_id and t.deleted = 0
                where a.verification_id = ? and a.credential_type = ? and a.merchant_id = ? and a.store_id = ? and a.operator_id = ?
                limit 1
                """ + lock, verificationId.trim(), TALENT_CODE, principal.merchantId(), principal.storeId(), principal.operatorId());
        if (sources.isEmpty()) {
            throw new BusinessException(ErrorCode.NOT_FOUND, "人才码核销凭证不存在或无权操作");
        }
        return sources.get(0);
    }
 
    int consumeScannedTalentCode(MerchantPrincipal principal, Long dynamicCodeId) {
        return jdbc.update("""
                update talent_dynamic_code set status = 1, merchant_id = ?, used_at = now()
                where id = ? and status in (0, 2)
                """, principal.merchantId(), dynamicCodeId);
    }
 
    private void ensurePending(Map<String, Object> source) {
        if (number(source.get("attemptStatus")) != ATTEMPT_PENDING) {
            throw new BusinessException(ErrorCode.CONFLICT, "该人才码已处理,请重新扫码");
        }
    }
 
    private List<Map<String, Object>> rules(Long merchantId, String cardLevel, String sourceAreaType) {
        List<Map<String, Object>> configured = configuredRules(merchantId, cardLevel, sourceAreaType);
        if (!configured.isEmpty() || hasStructuredRightsConfig(merchantId)) return configured;
        return rows("""
                select id, card_level, rule_name, discount_rate, subsidy_enabled
                from merchant_store_consume_rule
                where merchant_id = ? and status = 1 and (valid_start is null or valid_start <= current_date)
                  and (valid_end is null or valid_end >= current_date)
                order by sort_no desc, id desc
                """, merchantId).stream().filter(rule -> matchesCardLevel(string(rule.get("cardLevel")), cardLevel)).map(rule -> {
            Map<String, Object> result = new LinkedHashMap<>();
            result.put("id", String.valueOf(rule.get("id")));
            result.put("name", rule.get("ruleName"));
            result.put("discountRate", decimal(rule.get("discountRate")));
            result.put("subsidyEnabled", number(rule.get("subsidyEnabled")) == 1);
            return result;
        }).toList();
    }
 
    /**
     * 免费入住券要显示「超过免费天数按门店配置的到店消费规则计算」:门店给该人才卡配了折扣就是
     * 「门市价X折」,配的是立减就是「立减X元」(走和到店消费页同一套解析,结构化配置和旧的门店
     * 规则表都能取到)。
     *
     * <p>以前只认折扣,酒店把到店消费配成「优惠(立减)」时整段是空的——而酒店最常见的配法恰恰
     * 是立减(见商家权益里的到店消费二选一),页面上就什么都不显示,看起来像功能没做。
     *
     * <p>都没有配时返回空串,调用方据此不显示这段说明。
     */
    String freeStayExceedDiscountText(Long merchantId, String cardLevel, String sourceAreaType) {
        for (Map<String, Object> rule : rules(merchantId, cardLevel, sourceAreaType)) {
            String text = exceedDiscountText(rule);
            if (!text.isBlank()) return text;
        }
        return "";
    }
 
    /** 单条规则的「超过免费天数」文案:折扣优先,没配折扣(门店选的是立减)时用立减。 */
    String exceedDiscountText(Map<String, Object> rule) {
        BigDecimal rate = rule.get("discountRate") == null ? BigDecimal.ZERO : decimal(rule.get("discountRate"));
        if (rate.signum() > 0) return "门市价" + rate.movePointRight(1).stripTrailingZeros().toPlainString() + "折";
        BigDecimal reduction = rule.get("fixedReduction") == null ? BigDecimal.ZERO : decimal(rule.get("fixedReduction"));
        if (reduction.signum() > 0) return "立减" + reduction.stripTrailingZeros().toPlainString() + "元";
        return "";
    }
 
    boolean hasApplicableRule(Long merchantId, String cardLevel, String sourceAreaType) {
        return !rules(merchantId, cardLevel, sourceAreaType).isEmpty();
    }
 
    private boolean hasStructuredRightsConfig(Long merchantId) {
        return !rightsConfigs(merchantId).isEmpty();
    }
 
    private List<Map<String, Object>> rightsConfigs(Long merchantId) {
        List<Map<String, Object>> merchants = rows("select merchant_type_code, merchant_rights_config from merchant where id = ? and deleted = 0 limit 1", merchantId);
        if (merchants.isEmpty()) return List.of();
        List<Map<String, Object>> configs = parseRightsConfigs(merchants.get(0).get("merchantRightsConfig"));
        if (!"parking".equalsIgnoreCase(string(merchants.get(0).get("merchantTypeCode")))) return configs;
 
        // Parking's dedicated card-level configuration takes precedence over legacy JSON.
        List<Map<String, Object>> parkingRights = rows("""
                select card_level as level, enabled, label as tag, benefit_detail as detail,
                       charge_type, store_consume_enabled, discount_text, government_subsidy,
                       consume_usage_text as consume_desc, status
                from transport_parking_card_level_right
                where merchant_id = ? and deleted = 0
                """, merchantId);
        for (Map<String, Object> parkingRight : parkingRights) {
            String level = string(parkingRight.get("level"));
            Map<String, Object> merged = configs.stream()
                    .filter(right -> matchesCardLevel(string(right.get("level")), level))
                    .findFirst().map(LinkedHashMap::new).orElseGet(LinkedHashMap::new);
            for (Map.Entry<String, Object> entry : parkingRight.entrySet()) {
                Object value = entry.getValue();
                if (value != null && (!(value instanceof String text) || !text.isBlank())) merged.put(entry.getKey(), value);
            }
            merged.put("enabled", booleanValue(parkingRight.get("enabled")) && booleanValue(parkingRight.get("status")));
            merged.put("parkingType", "PAID".equalsIgnoreCase(string(parkingRight.get("chargeType"))) ? "paid" : "free");
            merged.put("storeConsume", booleanValue(parkingRight.get("storeConsumeEnabled")));
            String discount = string(parkingRight.get("discountText")).replace("折", "").trim();
            if (!discount.isBlank()) merged.put("discount", discount);
            configs.removeIf(right -> matchesCardLevel(string(right.get("level")), level));
            configs.add(merged);
        }
        return configs;
    }
 
    private List<Map<String, Object>> parseRightsConfigs(Object raw) {
        if (raw == null || string(raw).isBlank()) return new ArrayList<>();
        String json = raw instanceof byte[] bytes ? new String(bytes, java.nio.charset.StandardCharsets.UTF_8) : string(raw);
        List<Map<String, Object>> configs = new ArrayList<>();
        try {
            if (raw instanceof List<?> list) {
                for (Object item : list) if (item instanceof Map<?, ?> map) {
                    Map<String, Object> value = new LinkedHashMap<>();
                    for (Map.Entry<?, ?> entry : map.entrySet()) value.put(String.valueOf(entry.getKey()), entry.getValue());
                    configs.add(value);
                }
            } else {
                for (Object item : JSON.parseArray(json, Map.class)) if (item instanceof Map<?, ?> map) {
                    Map<String, Object> value = new LinkedHashMap<>();
                    for (Map.Entry<?, ?> entry : map.entrySet()) value.put(String.valueOf(entry.getKey()), entry.getValue());
                    configs.add(value);
                }
            }
        } catch (Exception ignored) {
            return new ArrayList<>();
        }
        return configs;
    }
 
    private List<Map<String, Object>> configuredRules(Long merchantId, String cardLevel, String sourceAreaType) {
        return rightsConfigs(merchantId).stream()
                .filter(right -> booleanValue(right.get("enabled"))
                        && configuredStoreConsumptionEnabled(right)
                        && matchesConfiguredLevel(string(right.get("level")), cardLevel, sourceAreaType))
                .map(this::configuredRule)
                .toList();
    }
 
    boolean configuredStoreConsumptionEnabled(Map<String, Object> right) {
        if (!booleanValue(right.get("storeConsume"))) return false;
        if (!string(right.get("parkingType")).isBlank()) return "paid".equalsIgnoreCase(string(right.get("parkingType")));
        return booleanValue(right.get("consumeEnabled"));
    }
 
    Map<String, Object> configuredRule(Map<String, Object> right) {
        String level = normalizedCardLevel(string(right.get("level")));
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("id", configRuleId(level));
        result.put("cardLevel", level);
        result.put("ruleName", string(right.get("tag")).isBlank() ? "到店消费优惠" : right.get("tag"));
        result.put("name", result.get("ruleName"));
        result.put("discountRate", configuredDiscountRate(right));
        result.put("fixedReduction", configuredFixedReduction(right));
        result.put("subsidyEnabled", configuredSubsidyEnabled(right) ? 1 : 0);
        result.put("merchantDetail", right.get("detail"));
        result.put("consumeDesc", right.get("consumeDesc"));
        result.put("benefitText", configuredBenefitText(right));
        // 到店消费开着但没配折扣(子女托管、文化服务常见)时也要能发单:按原价收,标签上写明。
        // 以前这类规则被过滤掉,页面上价格全是 0、发送订单永远点不动。
        if (string(result.get("benefitText")).isBlank()) result.put("benefitText", "按原价支付");
        return result;
    }
 
    private Map<String, Object> rule(Long merchantId, String cardLevel, String sourceAreaType, Long ruleId) {
        List<Map<String, Object>> configured = configuredRules(merchantId, cardLevel, sourceAreaType);
        for (Map<String, Object> candidate : configured) if (String.valueOf(candidate.get("id")).equals(String.valueOf(ruleId))) return candidate;
        List<Map<String, Object>> found = rows("""
                select id, card_level, rule_name, discount_rate, subsidy_enabled
                from merchant_store_consume_rule
                where id = ? and merchant_id = ? and status = 1 and (valid_start is null or valid_start <= current_date)
                  and (valid_end is null or valid_end >= current_date)
                limit 1
                """, ruleId, merchantId);
        if (found.isEmpty() || !matchesCardLevel(string(found.get(0).get("cardLevel")), cardLevel)) throw new BusinessException(ErrorCode.CONFLICT, "当前人才卡不可使用所选优惠方式");
        return found.get(0);
    }
 
    private List<Map<String, Object>> coupons(Long merchantId, Long talentId, String cardLevel) {
        if (!storeConsumptionCouponsEnabled(merchantId)) return List.of();
        return rows("""
                select c.id, c.coupon_name as name, c.coupon_type, c.coupon_amount, c.talent_levels,
                       c.condition_type, c.condition_amount, c.valid_end
                from merchant_coupon c
                where c.merchant_id = ? and c.deleted = 0 and c.status = 1 and c.store_consume = 1
                  and (c.valid_start is null or c.valid_start <= current_date) and (c.valid_end is null or c.valid_end >= current_date)
                  and not exists (
                    select 1 from benefit_order o
                    where o.coupon_id = c.id and o.talent_id = ? and o.deleted = 0 and o.order_status = 0
                      and (o.expires_at is null or o.expires_at > current_timestamp)
                  )
                order by c.coupon_amount desc, c.id desc
                """, merchantId, talentId).stream().filter(c -> supportsCard(string(c.get("talentLevels")), cardLevel)).map(c -> {
                    Map<String,Object> result = new LinkedHashMap<>(c); result.put("couponAmount", decimal(c.get("couponAmount")).movePointRight(2).longValue()); return result;
                }).toList();
    }
 
    private Map<String, Object> usableCoupon(Long merchantId, Long talentId, String cardLevel, Long couponId, BigDecimal amount) {
        if (couponId == null) return null;
        if (!storeConsumptionCouponsEnabled(merchantId)) {
            throw new BusinessException(ErrorCode.CONFLICT, "文化服务到店消费仅支持人才卡折扣,不支持优惠券");
        }
        List<Map<String,Object>> found = rows("""
                select c.id, c.coupon_amount, c.condition_type, c.condition_amount,
                       c.talent_levels
                from merchant_coupon c
                where c.id = ? and c.merchant_id = ? and c.deleted = 0 and c.status = 1 and c.store_consume = 1
                  and (c.valid_start is null or c.valid_start <= current_date) and (c.valid_end is null or c.valid_end >= current_date)
                  and not exists (
                    select 1 from benefit_order o
                    where o.coupon_id = c.id and o.talent_id = ? and o.deleted = 0 and o.order_status = 0
                      and (o.expires_at is null or o.expires_at > current_timestamp)
                  )
                """, couponId, merchantId, talentId);
        if (found.isEmpty() || !supportsCard(string(found.get(0).get("talentLevels")), cardLevel)) throw new BusinessException(ErrorCode.CONFLICT, "优惠券当前不可用,请重新选择");
        Map<String,Object> coupon = found.get(0);
        String condition = string(coupon.get("conditionType"));
        if (!"NONE".equalsIgnoreCase(condition) && coupon.get("conditionAmount") != null && amount.compareTo(decimal(coupon.get("conditionAmount"))) < 0) throw new BusinessException(ErrorCode.CONFLICT, "消费金额未达到优惠券使用条件");
        return coupon;
    }
 
    private boolean storeConsumptionCouponsEnabled(Long merchantId) {
        List<Map<String, Object>> merchants = rows("select merchant_type_code from merchant where id = ? and deleted = 0 limit 1", merchantId);
        return !merchants.isEmpty() && !"culture".equalsIgnoreCase(string(merchants.get(0).get("merchantTypeCode")));
    }
 
    private boolean supportsCard(String levels, String cardLevel) {
        if (levels == null || levels.isBlank()) return true;
        return java.util.Arrays.stream(levels.split("[,、,]" )).anyMatch(v -> matchesCardLevel(v, cardLevel));
    }
 
    private long configRuleId(String level) {
        return switch (level) { case "EXCELLENT" -> -1L; case "OUTSTANDING" -> -2L; case "ELITE" -> -3L; default -> -9L; };
    }
 
    private BigDecimal configuredDiscountRate(Map<String, Object> right) {
        try {
            Object value = !string(right.get("parkingType")).isBlank() ? right.get("discount")
                    : "折扣".equals(string(right.get("consumeType"))) ? right.get("consumeDiscount") : right.get("discount");
            BigDecimal discount = decimal(string(value));
            return discount.signum() > 0 && discount.compareTo(BigDecimal.TEN) <= 0 ? discount.divide(BigDecimal.TEN, 4, RoundingMode.HALF_UP) : BigDecimal.ZERO;
        } catch (Exception ignored) {
            return BigDecimal.ZERO;
        }
    }
 
    private BigDecimal configuredFixedReduction(Map<String, Object> right) {
        if (!string(right.get("parkingType")).isBlank()) return BigDecimal.ZERO;
        if (!"优惠".equals(string(right.get("consumeType")))) return BigDecimal.ZERO;
        try {
            BigDecimal reduction = decimal(string(right.get("consumeCoupon")));
            return reduction.signum() > 0 ? reduction : BigDecimal.ZERO;
        } catch (Exception ignored) {
            return BigDecimal.ZERO;
        }
    }
 
    private boolean configuredSubsidyEnabled(Map<String, Object> right) {
        if (!string(right.get("parkingType")).isBlank()) return booleanValue(right.get("governmentSubsidy"));
        String consumeType = string(right.get("consumeType"));
        if ("折扣".equals(consumeType)) return booleanValue(right.get("consumeDiscountSubsidy"));
        if ("优惠".equals(consumeType)) return booleanValue(right.get("consumeCouponSubsidy"));
        return booleanValue(right.get("governmentSubsidy"));
    }
 
    private boolean booleanValue(Object value) {
        if (value instanceof Boolean bool) return bool;
        if (value instanceof Number number) return number.intValue() != 0;
        String text = string(value).toLowerCase();
        return "true".equals(text) || "1".equals(text) || "yes".equals(text);
    }
 
    Map<String, Object> quote(Map<String, Object> rule, Map<String,Object> coupon, BigDecimal inputAmount) {
        long originalAmount = inputAmount.movePointRight(2).setScale(0, RoundingMode.HALF_UP).longValueExact();
        long payAmount = discountedAmountCents(rule, originalAmount);
        long basePayAmount = payAmount;
        long couponAmount = coupon == null ? 0 : Math.min(decimal(coupon.get("couponAmount")).movePointRight(2).setScale(0, RoundingMode.HALF_UP).longValue(), basePayAmount);
        payAmount = basePayAmount - couponAmount;
        long discountAmount = originalAmount - payAmount;
        long subsidyAmount = booleanValue(rule.get("subsidyEnabled")) ? discountAmount : 0;
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("originalAmount", originalAmount);
        result.put("discountAmount", discountAmount);
        result.put("payAmount", payAmount);
        result.put("basePayAmount", basePayAmount);
        result.put("couponAmount", couponAmount);
        result.put("subsidyAmount", subsidyAmount);
        result.put("ruleName", rule.get("ruleName"));
        return result;
    }
 
    private BigDecimal discountedAmount(Map<String, Object> rule, BigDecimal inputAmount) {
        long originalAmount = inputAmount.movePointRight(2).setScale(0, RoundingMode.HALF_UP).longValueExact();
        return BigDecimal.valueOf(discountedAmountCents(rule, originalAmount), 2);
    }
 
    private long discountedAmountCents(Map<String, Object> rule, long originalAmount) {
        BigDecimal fixedReduction = rule.get("fixedReduction") == null ? BigDecimal.ZERO : decimal(rule.get("fixedReduction"));
        if (fixedReduction.signum() > 0) {
            long reductionCents = fixedReduction.movePointRight(2).setScale(0, RoundingMode.HALF_UP).longValueExact();
            return Math.max(0, originalAmount - reductionCents);
        }
        BigDecimal rate = decimal(rule.get("discountRate"));
        if (rate.signum() <= 0 || rate.compareTo(BigDecimal.ONE) > 0) throw new BusinessException(ErrorCode.CONFLICT, "商户优惠规则配置无效");
        return BigDecimal.valueOf(originalAmount).multiply(rate).setScale(0, RoundingMode.HALF_UP).longValueExact();
    }
    private Map<String, Object> merchant(MerchantPrincipal principal) {
        List<Map<String, Object>> merchants = rows("""
                select merchant_name, address, contact_mobile, longitude, latitude
                from merchant where id = ? and status = 1 and deleted = 0
                limit 1
                """, principal.merchantId());
        if (merchants.isEmpty()) throw new BusinessException(ErrorCode.CONFLICT, "当前商户不可创建到店消费订单");
        return merchants.get(0);
    }
 
    private Map<String, Object> createdOrder(Long orderId) {
        List<Map<String, Object>> orders = rows("""
                select order_no, order_amount, pay_amount, subsidy_amount, price_version, expires_at
                from benefit_order where id = ? and deleted = 0 limit 1
                """, orderId);
        if (orders.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "到店消费订单不存在");
        Map<String, Object> order = orders.get(0);
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("orderNo", order.get("orderNo"));
        result.put("originalAmount", order.get("orderAmount"));
        result.put("payAmount", order.get("payAmount"));
        result.put("subsidyAmount", order.get("subsidyAmount"));
        result.put("priceVersion", order.get("priceVersion"));
        result.put("expiresAt", order.get("expiresAt"));
        return result;
    }
 
    private String benefitText(Long merchantId, String cardLevel, String sourceAreaType) {
        List<Map<String, Object>> merchants = rows("select merchant_type_code from merchant where id = ? and deleted = 0 limit 1", merchantId);
        String merchantType = merchants.isEmpty() ? "" : string(merchants.get(0).get("merchantTypeCode"));
        if ("children".equalsIgnoreCase(merchantType) || "culture".equalsIgnoreCase(merchantType)) {
            List<Map<String, Object>> configs = rightsConfigs(merchantId).stream()
                    .filter(right -> booleanValue(right.get("enabled")))
                    .toList();
            String configuredText = firstRightsText(configs, cardLevel, sourceAreaType,
                    "culture".equalsIgnoreCase(merchantType) ? "consumeDesc" : "detail");
            // 没填权益描述时也要返回兜底文案:调用方会据此换成规则自己的权益(如「按原价支付」),
            // 返回空串会让卡片上的「当前权益」整行空着。
            return configuredText.isBlank() ? DEFAULT_BENEFIT_TEXT : configuredText;
        }
        String configuredDescription = consumeDescFromRights(merchantId, cardLevel, sourceAreaType);
        if (!configuredDescription.isBlank()) return configuredDescription;
        List<Map<String, Object>> benefits = rows("""
                select card_level, benefit_text from merchant_card_benefit
                where merchant_id = ? and status = 1 order by sort_no desc, id desc
                """, merchantId);
        return benefits.stream().filter(benefit -> matchesCardLevel(string(benefit.get("cardLevel")), cardLevel))
                .map(benefit -> string(benefit.get("benefitText"))).findFirst().orElse(DEFAULT_BENEFIT_TEXT);
    }
 
    /** 后台没给该人才等级填权益描述时的兜底:页面上至少要显示这条规则自己的权益文案。 */
    private static final String DEFAULT_BENEFIT_TEXT = "以当前商户到店消费规则为准";
 
    /**
     * 「当前权益」文案:优先用后台按人才等级配置的权益描述;没配置时用匹配到的规则文案
     * (如「到店消费8折」)顶上,别给商户看「以当前商户到店消费规则为准」这种没有信息量的兜底话术。
     */
    private String benefitTextForLevel(Long merchantId, String cardLevel, String sourceAreaType,
                                       List<Map<String, Object>> matchedRules) {
        String configured = benefitText(merchantId, cardLevel, sourceAreaType);
        if (!DEFAULT_BENEFIT_TEXT.equals(configured)) return configured;
        for (Map<String, Object> rule : matchedRules) {
            String text = ruleBenefitText(rule);
            if (!text.isBlank()) return text;
        }
        return configured;
    }
 
    /** 规则自身的权益文案:结构化配置里已经算好了;旧的门店规则表只有名字和折扣,按折扣拼一句。 */
    private String ruleBenefitText(Map<String, Object> rule) {
        String configured = string(rule.get("benefitText"));
        if (!configured.isBlank()) return configured;
        String name = string(rule.get("name"));
        if (!name.isBlank() && (name.contains("折") || name.contains("减"))) return name;
        BigDecimal rate = decimal(rule.get("discountRate"));
        if (rate.signum() > 0) return "到店消费" + rate.movePointRight(1).stripTrailingZeros().toPlainString() + "折";
        BigDecimal reduction = decimal(rule.get("fixedReduction"));
        if (reduction.signum() > 0) return "到店消费立减" + reduction.stripTrailingZeros().toPlainString() + "元";
        return name;
    }
 
    private String consumeDescFromRights(Long merchantId, String cardLevel, String sourceAreaType) {
        List<Map<String, Object>> configs = rightsConfigs(merchantId).stream()
                .filter(right -> booleanValue(right.get("enabled")))
                .toList();
        String description = firstRightsText(configs, cardLevel, sourceAreaType, "consumeDesc");
        if (!description.isBlank()) return description;
        return firstRightsText(configs, cardLevel, sourceAreaType, "detail");
    }
 
    private String firstRightsText(List<Map<String, Object>> configs, String cardLevel, String sourceAreaType, String field) {
        for (Map<String, Object> right : configs) {
            if (!matchesConfiguredLevel(string(right.get("level")), cardLevel, sourceAreaType)) continue;
            String text = string(right.get(field)).trim();
            if (!text.isBlank()) return text;
        }
        if (!sourceAreaKnown(sourceAreaType)) {
            for (Map<String, Object> right : configs) {
                if (!matchesCardLevel(string(right.get("level")), cardLevel)) continue;
                String text = string(right.get(field)).trim();
                if (!text.isBlank()) return text;
            }
        }
        return "";
    }
 
    private boolean sourceAreaKnown(String sourceAreaType) {
        return localArea(sourceAreaType) || outsideArea(sourceAreaType);
    }
 
    private boolean localArea(String sourceAreaType) {
        String area = string(sourceAreaType).trim().toUpperCase();
        return area.contains("LOCAL") || area.contains("本地");
    }
 
    private boolean outsideArea(String sourceAreaType) {
        String area = string(sourceAreaType).trim().toUpperCase();
        return area.contains("OUTSIDE") || area.contains("外地");
    }
 
    /** Build the displayed benefit from the same structured fields used for pricing. */
    private String configuredBenefitText(Map<String, Object> right) {
        if (!string(right.get("parkingType")).isBlank()) {
            BigDecimal rate = configuredDiscountRate(right);
            return rate.signum() > 0 ? "到店消费" + rate.movePointRight(1).stripTrailingZeros().toPlainString() + "折" : "";
        }
        List<String> parts = new ArrayList<>();
        String consumeType = string(right.get("consumeType"));
        if ("优惠".equals(consumeType)) {
            String amount = string(right.get("consumeCoupon"));
            if (!amount.isBlank()) parts.add("到店消费立减" + amount + "元");
        } else {
            String discount = "折扣".equals(consumeType) ? string(right.get("consumeDiscount")) : string(right.get("discount"));
            if (!discount.isBlank()) parts.add("到店消费" + discount + "折");
        }
        return String.join(",", parts);
    }
 
    String benefitTextForMerchant(Long merchantId, String cardLevel) {
        return benefitText(merchantId, cardLevel, "");
    }
 
    String benefitTextForMerchant(Long merchantId, String cardLevel, String sourceAreaType) {
        return benefitText(merchantId, cardLevel, sourceAreaType);
    }
 
    boolean matchesConfiguredLevel(String configured, String actual, String sourceAreaType) {
        if (!"EXCELLENT".equals(normalizedCardLevel(actual))) return matchesCardLevel(configured, actual);
        String configuredUpper = configured.trim().toUpperCase();
        String areaUpper = string(sourceAreaType).trim().toUpperCase();
        if (configuredUpper.contains("本地") || configuredUpper.contains("LOCAL")) {
            return areaUpper.contains("LOCAL") || areaUpper.contains("本地");
        }
        if (configuredUpper.contains("外地") || configuredUpper.contains("OUTSIDE")) {
            return areaUpper.contains("OUTSIDE") || areaUpper.contains("外地");
        }
        return matchesCardLevel(configured, actual);
    }
 
    private boolean matchesCardLevel(String configured, String actual) {
        return normalizedCardLevel(configured).equals(normalizedCardLevel(actual));
    }
 
    private String normalizedCardLevel(String value) {
        String upper = string(value).trim().toUpperCase();
        if (upper.contains("卓越") || upper.equals("EXCELLENT")) return "EXCELLENT";
        if (upper.contains("杰出") || upper.equals("OUTSTANDING")) return "OUTSTANDING";
        if (upper.contains("菁英") || upper.equals("ELITE")) return "ELITE";
        return upper;
    }
 
    private boolean expired(Object value) {
        return value instanceof LocalDateTime time && !time.isAfter(LocalDateTime.now());
    }
 
    private Long number(Object value) {
        return value instanceof Number number ? number.longValue() : null;
    }
 
    private BigDecimal decimal(Object value) {
        if (value instanceof BigDecimal decimal) return decimal;
        if (value instanceof Number number) return BigDecimal.valueOf(number.doubleValue());
        return new BigDecimal(string(value));
    }
 
    private String string(Object value) {
        return value == null ? "" : String.valueOf(value);
    }
 
    private String maskName(String name) {
        if (name.isBlank()) return "持卡人";
        return name.length() == 1 ? name + "*" : name.substring(0, 1) + "*".repeat(name.length() - 1);
    }
 
    private String displayName(String name, Long gender) {
        if (name.isBlank()) return "持卡人";
        if (name.endsWith("先生") || name.endsWith("女士")) return name;
        return name + (gender != null && gender == 2 ? "女士" : "先生");
    }
 
    private String maskMobile(String mobile) {
        if (mobile.length() < 7) return "";
        return mobile.substring(0, 3) + "****" + mobile.substring(mobile.length() - 4);
    }
 
    private List<Map<String, Object>> rows(String sql, Object... args) {
        return DatabaseRows.normalize(jdbc.queryForList(sql, args));
    }
}