shinnosuke
2026-08-06 79ae06e6b4172d90df77b0e49ad3ab12ba63cd45
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
package com.talentcard.server.service;
 
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
import com.talentcard.server.business.BusinessIdGenerator;
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.PageResult;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import com.talentcard.server.entity.MerchantCouponClaim;
import com.talentcard.server.entity.MerchantCouponManagement;
import com.talentcard.server.mapper.MerchantCouponClaimMapper;
import com.talentcard.server.mapper.MerchantCouponManagementMapper;
import com.talentcard.server.request.CouponClaimIssueRequest;
import com.talentcard.server.request.CouponClaimStatusRequest;
import com.talentcard.server.request.CouponManagementSaveRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
 
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
 
@Service
public class MerchantCouponManagementService {
    private static final List<String> COUPON_TYPES = List.of("CASH", "FULL_REDUCTION", "DISCOUNT", "EXCHANGE");
 
    private final MerchantCouponManagementMapper couponMapper;
    private final MerchantCouponClaimMapper claimMapper;
 
    private final ObjectMapper objectMapper;
 
    public MerchantCouponManagementService(MerchantCouponManagementMapper couponMapper,
                                           MerchantCouponClaimMapper claimMapper,
                                           ObjectMapper objectMapper) {
        this.couponMapper = couponMapper;
        this.claimMapper = claimMapper;
        this.objectMapper = objectMapper;
    }
 
    @Transactional(readOnly = true)
    public PageResult<Map<String, Object>> coupons(Long merchantId, String couponType, String batchNo,
                                                    Integer status, String keyword, LocalDate validOn,
                                                    int page, int size) {
        String type = normalizeTypeFilter(couponType);
        validateCouponStatus(status);
        int safePage = Math.max(page, 1);
        int safeSize = Math.min(Math.max(size, 1), 100);
        List<Map<String, Object>> records = DatabaseRows.normalize(couponMapper.selectManagementPage(
                merchantId, type, clean(batchNo), status, clean(keyword), validOn,
                safeSize, (safePage - 1) * safeSize)).stream().map(this::couponView).toList();
        Long total = couponMapper.countManagementPage(merchantId, type, clean(batchNo), status, clean(keyword), validOn);
        return new PageResult<>(records, safePage, safeSize, total == null ? 0 : total);
    }
 
    @Transactional(readOnly = true)
    public Map<String, Object> coupon(Long id) {
        return couponView(toMap(requireCoupon(id)));
    }
 
    @Transactional
    public Map<String, Object> createCoupon(CouponManagementSaveRequest request) {
        validateSave(request, null);
        MerchantCouponManagement coupon = new MerchantCouponManagement();
        apply(coupon, request);
        coupon.setBatchNo(clean(request.batchNo()).isBlank() ? nextBatchNo() : clean(request.batchNo()));
        coupon.setClaimedQuantity(0);
        coupon.setUseCount(0);
        coupon.setProductScope("ALL");
        coupon.setStoreConsume(1);
        coupon.setDeleted(0);
        if (couponMapper.insert(coupon) != 1) {
            throw new BusinessException(ErrorCode.INTERNAL_ERROR, "优惠券配置保存失败");
        }
        return coupon(coupon.getId());
    }
 
    @Transactional
    public Map<String, Object> updateCoupon(Long id, CouponManagementSaveRequest request) {
        MerchantCouponManagement current = requireCoupon(id);
        validateSave(request, current);
        String requestedBatch = clean(request.batchNo());
        if (current.getClaimedQuantity() != null && current.getClaimedQuantity() > 0
                && !requestedBatch.isBlank() && !requestedBatch.equals(current.getBatchNo())) {
            throw new BusinessException(ErrorCode.CONFLICT, "已有领取记录时不能修改发放批次");
        }
        apply(current, request);
        if (!requestedBatch.isBlank()) current.setBatchNo(requestedBatch);
        if (couponMapper.updateById(current) != 1) {
            throw new BusinessException(ErrorCode.CONFLICT, "优惠券配置已变化,请刷新后重试");
        }
        return coupon(id);
    }
 
    @Transactional
    public Map<String, Object> updateCouponStatus(Long id, Integer status) {
        validateCouponStatus(status);
        MerchantCouponManagement coupon = requireCoupon(id);
        coupon.setStatus(status);
        if (couponMapper.updateById(coupon) != 1) {
            throw new BusinessException(ErrorCode.CONFLICT, "优惠券状态更新失败");
        }
        return coupon(id);
    }
 
    @Transactional
    public Map<String, Object> issue(Long couponId, CouponClaimIssueRequest request) {
        MerchantCouponManagement coupon = requireCoupon(couponId);
        LocalDate today = LocalDate.now();
        if (!Integer.valueOf(1).equals(coupon.getStatus())) {
            throw new BusinessException(ErrorCode.CONFLICT, "优惠券已下架,不能发放");
        }
        if (coupon.getValidStart() != null && coupon.getValidStart().isAfter(today)) {
            throw new BusinessException(ErrorCode.CONFLICT, "优惠券尚未开始生效");
        }
        if (coupon.getValidEnd() != null && coupon.getValidEnd().isBefore(today)) {
            throw new BusinessException(ErrorCode.CONFLICT, "优惠券已过期");
        }
        if (couponMapper.countTalent(request.talentId()) == 0) {
            throw new BusinessException(ErrorCode.NOT_FOUND, "领取人才不存在");
        }
        if (request.cardId() != null && couponMapper.countTalentCard(request.cardId(), request.talentId()) == 0) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "人才卡不属于领取人");
        }
        Long duplicate = claimMapper.selectCount(Wrappers.<MerchantCouponClaim>lambdaQuery()
                .eq(MerchantCouponClaim::getCouponId, couponId)
                .eq(MerchantCouponClaim::getTalentId, request.talentId())
                .eq(MerchantCouponClaim::getBatchNo, coupon.getBatchNo()));
        if (duplicate != null && duplicate > 0) {
            throw new BusinessException(ErrorCode.CONFLICT, "该用户已领取当前批次优惠券");
        }
        if (couponMapper.reserveOne(couponId) != 1) {
            throw new BusinessException(ErrorCode.CONFLICT, "优惠券已下架或发放数量不足");
        }
        MerchantCouponClaim claim = new MerchantCouponClaim();
        claim.setClaimNo(BusinessIdGenerator.next("MCC"));
        claim.setCouponId(couponId);
        claim.setMerchantId(coupon.getMerchantId());
        claim.setBatchNo(coupon.getBatchNo());
        claim.setTalentId(request.talentId());
        claim.setCardId(request.cardId());
        claim.setClaimStatus(1);
        claim.setUseStatus(0);
        claim.setClaimedAt(LocalDateTime.now());
        claim.setExpireTime(request.expireTime() != null ? request.expireTime()
                : coupon.getValidEnd() == null ? null : LocalDateTime.of(coupon.getValidEnd(), LocalTime.MAX));
        claim.setRemark(trimToNull(request.remark()));
        claim.setDeleted(0);
        if (claimMapper.insert(claim) != 1) {
            throw new BusinessException(ErrorCode.INTERNAL_ERROR, "优惠券发放记录保存失败");
        }
        return claim(claim.getId());
    }
 
    @Transactional(readOnly = true)
    public PageResult<Map<String, Object>> claims(Long couponId, Long merchantId, String batchNo,
                                                   Integer claimStatus, Integer useStatus, String keyword,
                                                   LocalDate startDate, LocalDate endDate, int page, int size) {
        validateClaimStatus(claimStatus);
        validateUseStatus(useStatus);
        if (startDate != null && endDate != null && startDate.isAfter(endDate)) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "开始日期不能晚于结束日期");
        }
        int safePage = Math.max(page, 1);
        int safeSize = Math.min(Math.max(size, 1), 100);
        LocalDateTime start = startDate == null ? null : startDate.atStartOfDay();
        LocalDateTime end = endDate == null ? null : endDate.plusDays(1).atStartOfDay();
        List<Map<String, Object>> records = DatabaseRows.normalize(claimMapper.selectClaimPage(
                couponId, merchantId, clean(batchNo), claimStatus, useStatus, clean(keyword),
                start, end, safeSize, (safePage - 1) * safeSize)).stream().map(this::claimView).toList();
        Long total = claimMapper.countClaimPage(couponId, merchantId, clean(batchNo), claimStatus,
                useStatus, clean(keyword), start, end);
        return new PageResult<>(records, safePage, safeSize, total == null ? 0 : total);
    }
 
    @Transactional(readOnly = true)
    public Map<String, Object> claim(Long id) {
        MerchantCouponClaim claim = requireClaim(id);
        Map<String, Object> value = toMap(claim);
        MerchantCouponManagement coupon = couponMapper.selectById(claim.getCouponId());
        if (coupon != null) {
            value.put("couponName", coupon.getCouponName());
            value.put("couponType", coupon.getCouponType());
            value.put("couponAmount", coupon.getCouponAmount());
        }
        return claimView(value);
    }
 
    @Transactional
    public Map<String, Object> updateClaimStatus(Long id, CouponClaimStatusRequest request) {
        if (request.claimStatus() == null && request.useStatus() == null) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "领取状态和使用状态至少填写一项");
        }
        validateClaimStatus(request.claimStatus());
        validateUseStatus(request.useStatus());
        MerchantCouponClaim claim = requireClaim(id);
        Integer oldClaimStatus = claim.getClaimStatus();
        Integer targetClaimStatus = request.claimStatus() == null ? oldClaimStatus : request.claimStatus();
        Integer targetUseStatus = request.useStatus() == null ? claim.getUseStatus() : request.useStatus();
        if (targetClaimStatus == 2 && targetUseStatus == 1) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "已取消领取的优惠券不能标记为已使用");
        }
        if (oldClaimStatus != 2 && targetClaimStatus == 2) couponMapper.releaseOne(claim.getCouponId());
        if (oldClaimStatus == 2 && targetClaimStatus != 2 && couponMapper.reserveOne(claim.getCouponId()) != 1) {
            throw new BusinessException(ErrorCode.CONFLICT, "优惠券剩余发放数量不足,不能恢复领取");
        }
        claim.setClaimStatus(targetClaimStatus);
        claim.setUseStatus(targetUseStatus);
        claim.setOrderId(request.orderId());
        claim.setRemark(trimToNull(request.remark()));
        if (targetUseStatus == 1 && claim.getUsedAt() == null) claim.setUsedAt(LocalDateTime.now());
        if (targetUseStatus != 1) claim.setUsedAt(null);
        if (claimMapper.updateById(claim) != 1) {
            throw new BusinessException(ErrorCode.CONFLICT, "领取记录状态更新失败");
        }
        return claim(id);
    }
 
    private void apply(MerchantCouponManagement coupon, CouponManagementSaveRequest request) {
        coupon.setMerchantId(request.merchantId());
        coupon.setCouponName(request.couponName().trim());
        coupon.setCouponType(normalizeType(request.couponType()));
        coupon.setCouponAmount(request.couponAmount());
        coupon.setConditionType(clean(request.conditionType()).isBlank() ? "NONE" : clean(request.conditionType()).toUpperCase(Locale.ROOT));
        coupon.setConditionAmount(request.conditionAmount());
        coupon.setTalentLevels(writeLevels(request.talentLevels()));
        coupon.setValidStart(request.validStart());
        coupon.setValidEnd(request.validEnd());
        coupon.setTotalQuantity(request.totalQuantity());
        coupon.setGovernmentSubsidy(Boolean.TRUE.equals(request.governmentSubsidy()) ? 1 : 0);
        coupon.setStatus(request.status() == null ? 1 : request.status());
    }
 
    private void validateSave(CouponManagementSaveRequest request, MerchantCouponManagement current) {
        normalizeType(request.couponType());
        validateCouponStatus(request.status());
        if (couponMapper.countMerchant(request.merchantId()) == 0) {
            throw new BusinessException(ErrorCode.NOT_FOUND, "适用商户不存在");
        }
        if (request.validStart() != null && request.validEnd() != null && request.validStart().isAfter(request.validEnd())) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "有效开始日期不能晚于结束日期");
        }
        int claimed = current == null || current.getClaimedQuantity() == null ? 0 : current.getClaimedQuantity();
        if (request.totalQuantity() != -1 && request.totalQuantity() < claimed) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "发放总量不能小于已领取数量");
        }
    }
 
    private MerchantCouponManagement requireCoupon(Long id) {
        if (id == null || id <= 0) throw new BusinessException(ErrorCode.BAD_REQUEST, "优惠券ID不正确");
        MerchantCouponManagement coupon = couponMapper.selectById(id);
        if (coupon == null) throw new BusinessException(ErrorCode.NOT_FOUND, "优惠券不存在");
        return coupon;
    }
 
    private MerchantCouponClaim requireClaim(Long id) {
        if (id == null || id <= 0) throw new BusinessException(ErrorCode.BAD_REQUEST, "领取记录ID不正确");
        MerchantCouponClaim claim = claimMapper.selectById(id);
        if (claim == null) throw new BusinessException(ErrorCode.NOT_FOUND, "优惠券领取记录不存在");
        return claim;
    }
 
    private void validateCouponStatus(Integer status) {
        if (status != null && status != 0 && status != 1) throw new BusinessException(ErrorCode.BAD_REQUEST, "优惠券状态只能为0下架或1上架");
    }
 
    private void validateClaimStatus(Integer status) {
        if (status != null && (status < 0 || status > 2)) throw new BusinessException(ErrorCode.BAD_REQUEST, "领取状态不正确");
    }
 
    private void validateUseStatus(Integer status) {
        if (status != null && (status < 0 || status > 3)) throw new BusinessException(ErrorCode.BAD_REQUEST, "使用状态不正确");
    }
 
    private String normalizeTypeFilter(String value) {
        return clean(value).isBlank() ? "" : normalizeType(value);
    }
 
    private String normalizeType(String value) {
        String type = clean(value).toUpperCase(Locale.ROOT).replace('-', '_');
        if (!COUPON_TYPES.contains(type)) throw new BusinessException(ErrorCode.BAD_REQUEST, "优惠券类型不正确");
        return type;
    }
 
    private String writeLevels(List<String> levels) {
        if (levels == null || levels.isEmpty()) return null;
        try {
            return objectMapper.writeValueAsString(levels.stream().filter(v -> v != null && !v.isBlank()).map(String::trim).distinct().toList());
        } catch (Exception exception) {
            throw new BusinessException(ErrorCode.INTERNAL_ERROR, "适用人才卡等级保存失败");
        }
    }
 
    private Object readLevels(Object value) {
        if (value == null || String.valueOf(value).isBlank()) return List.of();
        try {
            return objectMapper.readValue(String.valueOf(value), new TypeReference<List<String>>() {});
        } catch (Exception exception) {
            return List.of(String.valueOf(value));
        }
    }
 
    private Map<String, Object> couponView(Map<String, Object> source) {
        Map<String, Object> result = new LinkedHashMap<>(source);
        result.put("talentLevels", readLevels(source.get("talentLevels")));
        Integer status = intValue(source.get("status"));
        result.put("statusText", status != null && status == 1 ? "上架" : "下架");
        String type = String.valueOf(source.getOrDefault("couponType", ""));
        result.put("couponTypeText", couponTypeText(type));
        int total = intValue(source.get("totalQuantity")) == null ? -1 : intValue(source.get("totalQuantity"));
        int claimed = intValue(source.get("claimedQuantity")) == null ? 0 : intValue(source.get("claimedQuantity"));
        result.put("remainingQuantity", total == -1 ? -1 : Math.max(total - claimed, 0));
        return result;
    }
 
    private Map<String, Object> claimView(Map<String, Object> source) {
        Map<String, Object> result = new LinkedHashMap<>(source);
        result.put("claimStatusText", switch (intValue(source.get("claimStatus")) == null ? -1 : intValue(source.get("claimStatus"))) {
            case 0 -> "待领取"; case 1 -> "已领取"; case 2 -> "已取消"; default -> "未知";
        });
        result.put("useStatusText", switch (intValue(source.get("useStatus")) == null ? -1 : intValue(source.get("useStatus"))) {
            case 0 -> "未使用"; case 1 -> "已使用"; case 2 -> "已过期"; case 3 -> "已作废"; default -> "未知";
        });
        return result;
    }
 
    private String couponTypeText(String type) {
        return switch (type) {
            case "CASH" -> "代金券";
            case "FULL_REDUCTION" -> "满减券";
            case "DISCOUNT" -> "折扣券";
            case "EXCHANGE" -> "兑换券";
            default -> "未知";
        };
    }
 
    private Map<String, Object> toMap(Object bean) {
        return objectMapper.convertValue(bean, new TypeReference<LinkedHashMap<String, Object>>() {});
    }
 
    private String nextBatchNo() {
        return "CPB" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
                + UUID.randomUUID().toString().replace("-", "").substring(0, 6).toUpperCase(Locale.ROOT);
    }
 
    private String clean(String value) { return value == null ? "" : value.trim(); }
    private String trimToNull(String value) { return clean(value).isBlank() ? null : clean(value); }
    private Integer intValue(Object value) { return value instanceof Number number ? number.intValue() : null; }
}