package com.talentcard.server.service; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.DayOfWeek; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.YearMonth; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import com.talentcard.server.business.PageResult; import com.talentcard.server.business.merchant.MerchantTypeCatalog; import com.talentcard.server.common.BusinessException; import com.talentcard.server.common.ErrorCode; import com.talentcard.server.mapper.MerchantSettlementStatisticsMapper; import org.apache.poi.ss.usermodel.CellStyle; import org.apache.poi.ss.usermodel.FillPatternType; import org.apache.poi.ss.usermodel.IndexedColors; import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Sheet; import org.apache.poi.ss.usermodel.Workbook; import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class MerchantSettlementStatisticsService { private static final int EXPORT_LIMIT = 200000; private final MerchantSettlementStatisticsMapper mapper; public MerchantSettlementStatisticsService(MerchantSettlementStatisticsMapper mapper) { this.mapper = mapper; } @Transactional(readOnly = true) public PageResult> summaries( Long merchantId, String merchantType, String periodType, String startDate, String endDate, int page, int size ) { requireMerchantIdWhenSpecified(merchantId); String normalizedPeriod = normalizePeriod(periodType); DateRange range = range(normalizedPeriod, startDate, endDate); int safePage = Math.max(page, 1); int safeSize = Math.min(Math.max(size, 1), 100); List> records = mapper.selectPeriodSummaries( merchantId, merchantTypeCode(merchantType), normalizedPeriod, range.startTime(), range.endTime(), safeSize, (safePage - 1) * safeSize ).stream() .map(row -> view(row, normalizedPeriod)) .toList(); Long total = mapper.countPeriodSummaries( merchantId, merchantTypeCode(merchantType), normalizedPeriod, range.startTime(), range.endTime() ); return new PageResult<>(records, safePage, safeSize, total == null ? 0 : total); } @Transactional(readOnly = true) public Map overview(Long merchantId, String merchantType, String startDate, String endDate) { requireMerchantIdWhenSpecified(merchantId); DateRange range = range("DAY", startDate, endDate); List> rows = mapper.selectMerchantOverviews(merchantId, merchantTypeCode(merchantType), range.startTime(), range.endTime()); Map result = new LinkedHashMap<>(); result.put("startDate", range.startDate() == null ? "" : range.startDate().toString()); result.put("endDate", range.endDateInclusive() == null ? "" : range.endDateInclusive().toString()); result.put("records", rows.stream().map(row -> view(row, "TOTAL")).toList()); result.put("total", total(rows)); return result; } public Map currentMonthOverview(Long merchantId) { YearMonth currentMonth = YearMonth.now(); return overview(merchantId, null, currentMonth.atDay(1).toString(), currentMonth.atEndOfMonth().toString()); } private String merchantTypeCode(String value) { String cleaned = clean(value); return cleaned.isBlank() ? "" : MerchantTypeCatalog.canonicalCode(cleaned); } @Transactional(readOnly = true) public Map dashboardCards(Long merchantId) { requireMerchantId(merchantId); LocalDate today = LocalDate.now(); LocalDate weekStartDate = today.with(DayOfWeek.MONDAY); Map row = mapper.selectDashboardCards( merchantId, today.atStartOfDay(), today.plusDays(1).atStartOfDay(), weekStartDate.atStartOfDay(), today.plusDays(1).atStartOfDay() ); long transactionAmount = longValue(row, "transactionAmountCents"); long todayVerificationCount = longValue(row, "todayVerificationCount"); long weekVerificationCount = longValue(row, "weekVerificationCount"); long subsidyAmount = longValue(row, "subsidyAmountCents"); Map result = new LinkedHashMap<>(); result.put("merchantId", merchantId); result.put("statisticsDate", today.toString()); result.put("weekStartDate", weekStartDate.toString()); result.put("transactionTotal", amountCard("交易总额", transactionAmount)); result.put("todayVerification", countCard("今日核销", todayVerificationCount)); result.put("weekVerification", countCard("本周核销", weekVerificationCount)); result.put("subsidyTotal", amountCard("补贴总额", subsidyAmount)); result.put("transactionAmountCents", transactionAmount); result.put("transactionAmount", yuan(transactionAmount)); result.put("todayVerificationCount", todayVerificationCount); result.put("weekVerificationCount", weekVerificationCount); result.put("subsidyAmountCents", subsidyAmount); result.put("subsidyAmount", yuan(subsidyAmount)); result.put("hasData", transactionAmount > 0 || todayVerificationCount > 0 || weekVerificationCount > 0 || subsidyAmount > 0); result.put("statisticsBasis", "交易总额统计已支付且已核销订单的实付金额;补贴总额统计政府承担的订单补贴、政府补贴券及酒店免费入住线下补贴,商家自行让利不进入政府补贴结算"); return result; } @Transactional(readOnly = true) public byte[] exportSettlementDetailsExcel(Long merchantId, String status, String startDate, String endDate) { requireMerchantId(merchantId); DateRange range = range("DAY", startDate, endDate); StatusFilter statusFilter = normalizeExportStatus(status); List> records = mapper.selectSettlementExportDetails( merchantId, statusFilter.orderStatus(), statusFilter.verifyStatus(), range.startTime(), range.endTime(), EXPORT_LIMIT ); String[] headers = { "序号", "订单号", "订单类型", "商户名称", "门店名称", "持卡人", "手机号", "人才卡号", "人才卡等级", "商品/服务", "交易金额(元)", "政府补贴(元)", "应结算金额(元)", "优惠券金额(元)", "订单状态", "核销状态", "下单时间", "支付时间", "核销时间", "核销人员" }; try (Workbook workbook = new XSSFWorkbook(); ByteArrayOutputStream output = new ByteArrayOutputStream()) { Sheet sheet = workbook.createSheet("结算明细"); sheet.createFreezePane(0, 1); CellStyle headerStyle = headerStyle(workbook); Row headerRow = sheet.createRow(0); for (int index = 0; index < headers.length; index++) { headerRow.createCell(index).setCellValue(headers[index]); headerRow.getCell(index).setCellStyle(headerStyle); } int rowIndex = 1; for (Map record : records) { Row row = sheet.createRow(rowIndex); List values = exportValues(rowIndex, record); for (int index = 0; index < values.size(); index++) { row.createCell(index).setCellValue(values.get(index)); } rowIndex++; } int[] widths = {8, 26, 18, 28, 24, 16, 18, 24, 16, 28, 16, 16, 18, 16, 14, 14, 24, 24, 24, 16}; for (int index = 0; index < widths.length; index++) { sheet.setColumnWidth(index, widths[index] * 256); } workbook.write(output); return output.toByteArray(); } catch (IOException exception) { throw new BusinessException(ErrorCode.INTERNAL_ERROR, "结算明细导出失败"); } } private List exportValues(int index, Map row) { List values = new ArrayList<>(); values.add(String.valueOf(index)); values.add(text(row, "orderNo", "order_no")); values.add(orderTypeName(text(row, "orderType", "order_type"))); values.add(text(row, "merchantName", "merchant_name")); values.add(text(row, "storeName", "store_name")); values.add(text(row, "talentName", "talent_name")); values.add(text(row, "talentMobile", "talent_mobile")); values.add(text(row, "cardNo", "card_no")); values.add(text(row, "cardLevel", "card_level")); values.add(text(row, "productName", "product_name")); values.add(string(yuan(longValue(row, "transactionAmountCents")))); values.add(string(yuan(longValue(row, "subsidyAmountCents")))); values.add(string(yuan(longValue(row, "settlementAmountCents")))); values.add(string(yuan(longValue(row, "couponAmountCents")))); values.add(orderStatusName(intValue(row, "orderStatus"))); values.add(verifyStatusName(intValue(row, "verifyStatus"))); values.add(string(value(row, "createdAt", "created_at"))); values.add(string(value(row, "paidAt", "paid_at"))); values.add(string(value(row, "verifyTime", "verify_time"))); values.add(text(row, "verifyOperator", "verify_operator")); return values; } private Map amountCard(String title, long amountCents) { Map card = new LinkedHashMap<>(); card.put("title", title); card.put("amountCents", amountCents); card.put("amount", yuan(amountCents)); card.put("unit", "元"); return card; } private Map countCard(String title, long count) { Map card = new LinkedHashMap<>(); card.put("title", title); card.put("count", count); card.put("unit", "笔"); return card; } private Map total(List> rows) { long orderCount = 0; long transactionAmount = 0; long subsidyAmount = 0; long settlementAmount = 0; long originalAmount = 0; long couponAmount = 0; long subsidyOrderCount = 0; for (Map row : rows) { orderCount += longValue(row, "orderCount"); transactionAmount += longValue(row, "transactionAmountCents"); subsidyAmount += longValue(row, "subsidyAmountCents"); settlementAmount += longValue(row, "settlementAmountCents"); originalAmount += longValue(row, "originalAmountCents"); couponAmount += longValue(row, "couponAmountCents"); subsidyOrderCount += longValue(row, "governmentSubsidyOrderCount"); } Map total = new LinkedHashMap<>(); total.put("orderCount", orderCount); total.put("transactionAmountCents", transactionAmount); total.put("transactionAmount", yuan(transactionAmount)); total.put("subsidyAmountCents", subsidyAmount); total.put("subsidyAmount", yuan(subsidyAmount)); total.put("settlementAmountCents", settlementAmount); total.put("settlementAmount", yuan(settlementAmount)); total.put("originalAmountCents", originalAmount); total.put("originalAmount", yuan(originalAmount)); total.put("couponAmountCents", couponAmount); total.put("couponAmount", yuan(couponAmount)); total.put("governmentSubsidyOrderCount", subsidyOrderCount); return total; } private Map view(Map row, String periodType) { Map result = new LinkedHashMap<>(); if (row.containsKey("periodKey")) { result.put("periodKey", text(row, "periodKey")); result.put("periodType", periodType); } result.put("merchantId", value(row, "merchantId")); result.put("merchantName", text(row, "merchantName")); result.put("merchantType", text(row, "merchantType")); result.put("orderCount", longValue(row, "orderCount")); result.put("transactionAmountCents", longValue(row, "transactionAmountCents")); result.put("transactionAmount", yuan(longValue(row, "transactionAmountCents"))); result.put("subsidyAmountCents", longValue(row, "subsidyAmountCents")); result.put("subsidyAmount", yuan(longValue(row, "subsidyAmountCents"))); result.put("settlementAmountCents", longValue(row, "settlementAmountCents")); result.put("settlementAmount", yuan(longValue(row, "settlementAmountCents"))); result.put("originalAmountCents", longValue(row, "originalAmountCents")); result.put("originalAmount", yuan(longValue(row, "originalAmountCents"))); result.put("couponAmountCents", longValue(row, "couponAmountCents")); result.put("couponAmount", yuan(longValue(row, "couponAmountCents"))); result.put("governmentSubsidyOrderCount", longValue(row, "governmentSubsidyOrderCount")); result.put("periodStartTime", value(row, "periodStartTime", "startTime")); result.put("periodEndTime", value(row, "periodEndTime", "endTime")); result.put("statisticsBasis", "已支付且已核销订单及酒店免费入住核销记录;补贴额统计政府承担的订单补贴、政府补贴券及酒店免费入住线下补贴,商家自行让利不进入政府补贴结算"); return result; } private DateRange range(String periodType, String startDate, String endDate) { LocalDate start = parseDate(startDate, "开始日期"); LocalDate end = parseDate(endDate, "结束日期"); if (start != null && end != null && start.isAfter(end)) { throw new BusinessException(ErrorCode.BAD_REQUEST, "开始日期不能晚于结束日期"); } if ("MONTH".equals(periodType)) { LocalDate normalizedStart = start == null ? null : start.withDayOfMonth(1); LocalDate normalizedEnd = end == null ? null : YearMonth.from(end).atEndOfMonth(); return new DateRange( normalizedStart, normalizedEnd, normalizedStart == null ? null : normalizedStart.atStartOfDay(), normalizedEnd == null ? null : normalizedEnd.plusDays(1).atStartOfDay() ); } return new DateRange( start, end, start == null ? null : start.atStartOfDay(), end == null ? null : end.plusDays(1).atStartOfDay() ); } private LocalDate parseDate(String value, String field) { String text = clean(value); if (text.isBlank()) { return null; } try { if (text.matches("^\\d{4}-\\d{2}$")) { return YearMonth.parse(text).atDay(1); } return LocalDate.parse(text); } catch (Exception exception) { throw new BusinessException(ErrorCode.BAD_REQUEST, field + "格式不正确,应为yyyy-MM-dd或yyyy-MM"); } } private StatusFilter normalizeExportStatus(String raw) { String status = clean(raw).toUpperCase(Locale.ROOT).replace("-", "_"); if (status.isBlank() || "ALL".equals(status) || "全部".equals(status)) { return new StatusFilter(null, null); } return switch (status) { case "PAID", "已支付" -> new StatusFilter(1, null); case "VERIFIED", "COMPLETED", "已核销", "已完成" -> new StatusFilter(1, 1); case "UNVERIFIED", "PENDING_VERIFICATION", "待核销", "未核销" -> new StatusFilter(1, 0); case "PENDING_PAYMENT", "UNPAID", "待支付", "未支付", "0" -> new StatusFilter(0, null); case "CANCELLED", "REFUNDED", "已取消", "已退款", "2" -> new StatusFilter(2, null); case "1" -> new StatusFilter(1, null); default -> throw new BusinessException(ErrorCode.BAD_REQUEST, "状态参数不正确"); }; } private String normalizePeriod(String value) { String period = clean(value).toUpperCase(Locale.ROOT); if (period.isBlank() || "DAY".equals(period) || "DAILY".equals(period) || "日".equals(period)) { return "DAY"; } if ("MONTH".equals(period) || "MONTHLY".equals(period) || "月".equals(period)) { return "MONTH"; } throw new BusinessException(ErrorCode.BAD_REQUEST, "统计周期只能为DAY日汇总或MONTH月汇总"); } private void requireMerchantIdWhenSpecified(Long merchantId) { if (merchantId != null && merchantId <= 0) { throw new BusinessException(ErrorCode.BAD_REQUEST, "商户ID不正确"); } } private void requireMerchantId(Long merchantId) { if (merchantId == null || merchantId <= 0) { throw new BusinessException(ErrorCode.BAD_REQUEST, "商户ID不正确"); } } private CellStyle headerStyle(Workbook workbook) { CellStyle style = workbook.createCellStyle(); style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); style.setFillPattern(FillPatternType.SOLID_FOREGROUND); return style; } private Object value(Map row, String... keys) { if (row == null) { return null; } for (String key : keys) { if (row.containsKey(key)) { return row.get(key); } } return null; } private String text(Map row, String... keys) { Object value = value(row, keys); return value == null ? "" : String.valueOf(value).trim(); } private long longValue(Map row, String key) { Object value = value(row, key); return value instanceof Number number ? number.longValue() : 0L; } private Integer intValue(Map row, String key) { Object value = value(row, key); return value instanceof Number number ? number.intValue() : null; } private BigDecimal yuan(long cents) { return BigDecimal.valueOf(cents, 2).setScale(2, RoundingMode.HALF_UP); } private String orderStatusName(Integer status) { if (status == null) { return "未知"; } return switch (status) { case 0 -> "待支付"; case 1 -> "已支付"; case 2 -> "已取消/已退款"; default -> "未知"; }; } private String verifyStatusName(Integer status) { if (status == null) { return "未知"; } return status == 1 ? "已核销" : "未核销"; } private String orderTypeName(String orderType) { return switch (clean(orderType).toUpperCase(Locale.ROOT)) { case "STORE_CONSUME" -> "到店消费"; case "GROUP_BUY" -> "团购订单"; case "HOTEL_RESERVATION" -> "酒店预约"; case "HOTEL_FREE" -> "酒店免费入住"; default -> clean(orderType); }; } private String string(Object value) { return value == null ? "" : String.valueOf(value); } private String clean(String value) { return value == null ? "" : value.trim(); } private record DateRange(LocalDate startDate, LocalDate endDateInclusive, LocalDateTime startTime, LocalDateTime endTime) { } private record StatusFilter(Integer orderStatus, Integer verifyStatus) { } }