From 31768fcc07f831fe263dda4de8d4b1c4c371e313 Mon Sep 17 00:00:00 2001 From: Tihon Date: Fri, 20 Mar 2026 13:39:38 +0200 Subject: [PATCH] admin statistics part3 --- .../controller/AdminAnalyticsController.java | 44 ++- .../honey/dto/AdminStatisticsTableRowDto.java | 36 +++ .../java/com/honey/honey/model/UserD.java | 5 + .../AdminStatisticsTableRepository.java | 302 ++++++++++++++++++ .../honey/repository/UserARepository.java | 3 - .../honey/repository/UserDRepository.java | 9 +- .../service/AdminStatisticsTableService.java | 196 ++++++++++++ .../com/honey/honey/service/UserService.java | 5 +- ...78__payments_index_status_completed_at.sql | 2 + .../V79__users_d_referer_master_index.sql | 2 + .../db/migration/V80__users_d_date_reg.sql | 9 + 11 files changed, 604 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/honey/honey/dto/AdminStatisticsTableRowDto.java create mode 100644 src/main/java/com/honey/honey/repository/AdminStatisticsTableRepository.java create mode 100644 src/main/java/com/honey/honey/service/AdminStatisticsTableService.java create mode 100644 src/main/resources/db/migration/V78__payments_index_status_completed_at.sql create mode 100644 src/main/resources/db/migration/V79__users_d_referer_master_index.sql create mode 100644 src/main/resources/db/migration/V80__users_d_date_reg.sql diff --git a/src/main/java/com/honey/honey/controller/AdminAnalyticsController.java b/src/main/java/com/honey/honey/controller/AdminAnalyticsController.java index d6b472a..a57f1cc 100644 --- a/src/main/java/com/honey/honey/controller/AdminAnalyticsController.java +++ b/src/main/java/com/honey/honey/controller/AdminAnalyticsController.java @@ -5,6 +5,8 @@ import com.honey.honey.model.Payout; import com.honey.honey.repository.PaymentRepository; import com.honey.honey.repository.PayoutRepository; import com.honey.honey.repository.UserARepository; +import com.honey.honey.repository.UserDRepository; +import com.honey.honey.service.AdminStatisticsTableService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; @@ -31,11 +33,47 @@ public class AdminAnalyticsController { private static final BigDecimal ZERO = BigDecimal.ZERO; private final UserARepository userARepository; + private final UserDRepository userDRepository; private final PaymentRepository paymentRepository; private final PayoutRepository payoutRepository; + private final AdminStatisticsTableService adminStatisticsTableService; /** - * Bounds for admin statistics-by-day UI: UTC day start of the earliest {@code date_reg} in the system + * Paginated daily / monthly statistics table (admin). + */ + @GetMapping("/statistics-table") + public ResponseEntity> getStatisticsTable( + @RequestParam String mode, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "50") int size) { + + AdminStatisticsTableService.TableMode tableMode; + try { + tableMode = AdminStatisticsTableService.TableMode.valueOf(mode.trim().toUpperCase()); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().build(); + } + + int cappedSize = Math.min(200, Math.max(1, size)); + int safePage = Math.max(0, page); + + var dtoPage = adminStatisticsTableService.getStatisticsTable(tableMode, safePage, cappedSize); + + Map response = new HashMap<>(); + response.put("content", dtoPage.getContent()); + response.put("totalElements", dtoPage.getTotalElements()); + response.put("totalPages", dtoPage.getTotalPages()); + response.put("currentPage", dtoPage.getNumber()); + response.put("size", dtoPage.getSize()); + response.put("hasNext", dtoPage.hasNext()); + response.put("hasPrevious", dtoPage.hasPrevious()); + response.put("mode", tableMode.name()); + + return ResponseEntity.ok(response); + } + + /** + * Bounds for admin statistics-by-day UI: UTC day start of the earliest {@code db_users_d.date_reg} * and exclusive end of the latest 30-day window (start of tomorrow UTC). */ @GetMapping("/meta") @@ -48,8 +86,8 @@ public class AdminAnalyticsController { .toInstant(); long firstProjectDayStartEpoch = latestWindowEndExclusive.getEpochSecond() - 30L * 24 * 3600; - var regOpt = userARepository.findMinDateReg(); - if (regOpt.isPresent() && regOpt.get() != null) { + var regOpt = userDRepository.findMinDateReg(); + if (regOpt.isPresent() && regOpt.get() != null && regOpt.get() > 0) { Instant reg = Instant.ofEpochSecond(regOpt.get()); firstProjectDayStartEpoch = reg.atZone(ZoneOffset.UTC) .toLocalDate() diff --git a/src/main/java/com/honey/honey/dto/AdminStatisticsTableRowDto.java b/src/main/java/com/honey/honey/dto/AdminStatisticsTableRowDto.java new file mode 100644 index 0000000..27faf46 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/AdminStatisticsTableRowDto.java @@ -0,0 +1,36 @@ +package com.honey.honey.dto; + +import lombok.Builder; +import lombok.Value; + +import java.math.BigDecimal; + +@Value +@Builder +public class AdminStatisticsTableRowDto { + /** ISO date yyyy-MM-dd (day mode) or yyyy-MM (month mode). */ + String periodKey; + /** Human-readable label for the period. */ + String periodLabel; + + long regs; + long regsOurs; + long regsNonOurs; + + /** Placeholder for future use; null = not implemented. */ + Long deadOurs; + Long deadNonOurs; + + long depositsCount; + BigDecimal depositsSumUsd; + long ftdCount; + /** Average USD per payment where usd_amount is present; null if none. */ + BigDecimal avgPaymentUsd; + + long withdrawalsCount; + BigDecimal withdrawalsSumUsd; + + /** (1 - withdrawals/deposits) * 100; null if deposits sum is zero. */ + BigDecimal profitPercent; + BigDecimal profitUsd; +} diff --git a/src/main/java/com/honey/honey/model/UserD.java b/src/main/java/com/honey/honey/model/UserD.java index fd2fa42..0dbeff2 100644 --- a/src/main/java/com/honey/honey/model/UserD.java +++ b/src/main/java/com/honey/honey/model/UserD.java @@ -20,6 +20,11 @@ public class UserD { @Builder.Default private String screenName = "-"; + /** Unix registration time (seconds); kept in sync with db_users_a.date_reg on signup. */ + @Column(name = "date_reg", nullable = false) + @Builder.Default + private Integer dateReg = 0; + @Column(name = "referer_id_1", nullable = false) @Builder.Default private Integer refererId1 = 0; diff --git a/src/main/java/com/honey/honey/repository/AdminStatisticsTableRepository.java b/src/main/java/com/honey/honey/repository/AdminStatisticsTableRepository.java new file mode 100644 index 0000000..a379884 --- /dev/null +++ b/src/main/java/com/honey/honey/repository/AdminStatisticsTableRepository.java @@ -0,0 +1,302 @@ +package com.honey.honey.repository; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.Query; +import org.springframework.stereotype.Repository; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.sql.Date; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +/** + * Native aggregate queries for the admin statistics-by-day/month table. + * Registration metrics use {@code db_users_d.date_reg} only (no join to {@code db_users_a}). + */ +@Repository +public class AdminStatisticsTableRepository { + + private static final BigDecimal ZERO = new BigDecimal("0.00"); + + @PersistenceContext + private EntityManager entityManager; + + /** + * Mutable per-period aggregates filled by this repository. + */ + public static final class StatisticsTableBucket { + public long regs; + public long regsOurs; + public long depositsCount; + public long ftdCount; + public BigDecimal depositsSumUsd = ZERO; + public BigDecimal avgPaymentUsd; + public long withdrawalsCount; + public BigDecimal withdrawalsSumUsd = ZERO; + } + + public void loadRegistrationBucketsByDay(long startSec, long endSec, Map buckets) { + String sql = """ + SELECT DATE(FROM_UNIXTIME(d.date_reg)) AS b, COUNT(*) AS c + FROM db_users_d d + WHERE d.date_reg >= :startSec AND d.date_reg < :endSec + GROUP BY b + """; + Query q = entityManager.createNativeQuery(sql); + q.setParameter("startSec", startSec); + q.setParameter("endSec", endSec); + List rows = q.getResultList(); + for (Object[] row : rows) { + LocalDate d = toLocalDate(row[0]); + if (d == null) { + continue; + } + buckets.computeIfAbsent(d, x -> new StatisticsTableBucket()).regs = toLong(row[1]); + } + } + + public void loadRegistrationOursBucketsByDay(long startSec, long endSec, Map buckets) { + String sql = """ + SELECT DATE(FROM_UNIXTIME(d.date_reg)) AS b, COUNT(*) AS c + FROM db_users_d d + WHERE d.date_reg >= :startSec AND d.date_reg < :endSec + AND d.referer_id_1 = d.master_id + AND d.id <> d.master_id + AND d.master_id > 0 + GROUP BY b + """; + Query q = entityManager.createNativeQuery(sql); + q.setParameter("startSec", startSec); + q.setParameter("endSec", endSec); + @SuppressWarnings("unchecked") + List rows = q.getResultList(); + for (Object[] row : rows) { + LocalDate d = toLocalDate(row[0]); + if (d == null) { + continue; + } + buckets.computeIfAbsent(d, x -> new StatisticsTableBucket()).regsOurs = toLong(row[1]); + } + } + + public void loadPaymentBucketsByDay(Instant start, Instant end, Map buckets) { + String sql = """ + SELECT DATE(p.completed_at) AS b, + COUNT(*) AS cnt, + COALESCE(SUM(p.usd_amount), 0) AS sum_usd, + AVG(p.usd_amount) AS avg_usd, + SUM(CASE WHEN p.ftd = 1 THEN 1 ELSE 0 END) AS ftd + FROM payments p + WHERE p.status = 'COMPLETED' + AND p.completed_at IS NOT NULL + AND p.completed_at >= :startTs AND p.completed_at < :endTs + GROUP BY b + """; + Query q = entityManager.createNativeQuery(sql); + q.setParameter("startTs", Timestamp.from(start)); + q.setParameter("endTs", Timestamp.from(end)); + @SuppressWarnings("unchecked") + List rows = q.getResultList(); + for (Object[] row : rows) { + LocalDate d = toLocalDate(row[0]); + if (d == null) { + continue; + } + StatisticsTableBucket b = buckets.computeIfAbsent(d, x -> new StatisticsTableBucket()); + b.depositsCount = toLong(row[1]); + b.depositsSumUsd = readBd(row[2]); + b.avgPaymentUsd = readBdNullable(row[3]); + b.ftdCount = toLong(row[4]); + } + } + + public void loadPayoutBucketsByDay(Instant start, Instant end, Map buckets) { + String sql = """ + SELECT DATE(COALESCE(p.resolved_at, p.updated_at, p.created_at)) AS b, + COUNT(*) AS cnt, + COALESCE(SUM(p.usd_amount), 0) AS sum_usd + FROM payouts p + WHERE p.status = 'COMPLETED' + AND COALESCE(p.resolved_at, p.updated_at, p.created_at) >= :startTs + AND COALESCE(p.resolved_at, p.updated_at, p.created_at) < :endTs + GROUP BY b + """; + Query q = entityManager.createNativeQuery(sql); + q.setParameter("startTs", Timestamp.from(start)); + q.setParameter("endTs", Timestamp.from(end)); + @SuppressWarnings("unchecked") + List rows = q.getResultList(); + for (Object[] row : rows) { + LocalDate d = toLocalDate(row[0]); + if (d == null) { + continue; + } + StatisticsTableBucket b = buckets.computeIfAbsent(d, x -> new StatisticsTableBucket()); + b.withdrawalsCount = toLong(row[1]); + b.withdrawalsSumUsd = readBd(row[2]); + } + } + + public void loadRegistrationBucketsByMonth(long startSec, long endSec, Map buckets) { + String sql = """ + SELECT DATE_FORMAT(FROM_UNIXTIME(d.date_reg), '%Y-%m') AS ym, COUNT(*) AS c + FROM db_users_d d + WHERE d.date_reg >= :startSec AND d.date_reg < :endSec + GROUP BY ym + """; + Query q = entityManager.createNativeQuery(sql); + q.setParameter("startSec", startSec); + q.setParameter("endSec", endSec); + @SuppressWarnings("unchecked") + List rows = q.getResultList(); + for (Object[] row : rows) { + String ym = row[0] != null ? row[0].toString() : null; + if (ym == null) { + continue; + } + buckets.computeIfAbsent(ym, x -> new StatisticsTableBucket()).regs = toLong(row[1]); + } + } + + public void loadRegistrationOursBucketsByMonth(long startSec, long endSec, Map buckets) { + String sql = """ + SELECT DATE_FORMAT(FROM_UNIXTIME(d.date_reg), '%Y-%m') AS ym, COUNT(*) AS c + FROM db_users_d d + WHERE d.date_reg >= :startSec AND d.date_reg < :endSec + AND d.referer_id_1 = d.master_id + AND d.id <> d.master_id + AND d.master_id > 0 + GROUP BY ym + """; + Query q = entityManager.createNativeQuery(sql); + q.setParameter("startSec", startSec); + q.setParameter("endSec", endSec); + @SuppressWarnings("unchecked") + List rows = q.getResultList(); + for (Object[] row : rows) { + String ym = row[0] != null ? row[0].toString() : null; + if (ym == null) { + continue; + } + buckets.computeIfAbsent(ym, x -> new StatisticsTableBucket()).regsOurs = toLong(row[1]); + } + } + + public void loadPaymentBucketsByMonth(Instant start, Instant end, Map buckets) { + String sql = """ + SELECT DATE_FORMAT(p.completed_at, '%Y-%m') AS ym, + COUNT(*) AS cnt, + COALESCE(SUM(p.usd_amount), 0) AS sum_usd, + AVG(p.usd_amount) AS avg_usd, + SUM(CASE WHEN p.ftd = 1 THEN 1 ELSE 0 END) AS ftd + FROM payments p + WHERE p.status = 'COMPLETED' + AND p.completed_at IS NOT NULL + AND p.completed_at >= :startTs AND p.completed_at < :endTs + GROUP BY ym + """; + Query q = entityManager.createNativeQuery(sql); + q.setParameter("startTs", Timestamp.from(start)); + q.setParameter("endTs", Timestamp.from(end)); + @SuppressWarnings("unchecked") + List rows = q.getResultList(); + for (Object[] row : rows) { + String ym = row[0] != null ? row[0].toString() : null; + if (ym == null) { + continue; + } + StatisticsTableBucket b = buckets.computeIfAbsent(ym, x -> new StatisticsTableBucket()); + b.depositsCount = toLong(row[1]); + b.depositsSumUsd = readBd(row[2]); + b.avgPaymentUsd = readBdNullable(row[3]); + b.ftdCount = toLong(row[4]); + } + } + + public void loadPayoutBucketsByMonth(Instant start, Instant end, Map buckets) { + String sql = """ + SELECT DATE_FORMAT(COALESCE(p.resolved_at, p.updated_at, p.created_at), '%Y-%m') AS ym, + COUNT(*) AS cnt, + COALESCE(SUM(p.usd_amount), 0) AS sum_usd + FROM payouts p + WHERE p.status = 'COMPLETED' + AND COALESCE(p.resolved_at, p.updated_at, p.created_at) >= :startTs + AND COALESCE(p.resolved_at, p.updated_at, p.created_at) < :endTs + GROUP BY ym + """; + Query q = entityManager.createNativeQuery(sql); + q.setParameter("startTs", Timestamp.from(start)); + q.setParameter("endTs", Timestamp.from(end)); + @SuppressWarnings("unchecked") + List rows = q.getResultList(); + for (Object[] row : rows) { + String ym = row[0] != null ? row[0].toString() : null; + if (ym == null) { + continue; + } + StatisticsTableBucket b = buckets.computeIfAbsent(ym, x -> new StatisticsTableBucket()); + b.withdrawalsCount = toLong(row[1]); + b.withdrawalsSumUsd = readBd(row[2]); + } + } + + private static LocalDate toLocalDate(Object o) { + if (o == null) { + return null; + } + if (o instanceof LocalDate ld) { + return ld; + } + if (o instanceof Date sd) { + return sd.toLocalDate(); + } + if (o instanceof Timestamp ts) { + return ts.toLocalDateTime().toLocalDate(); + } + if (o instanceof java.util.Date ud) { + return new Timestamp(ud.getTime()).toLocalDateTime().toLocalDate(); + } + return null; + } + + private static long toLong(Object o) { + if (o == null) { + return 0L; + } + if (o instanceof Number n) { + return n.longValue(); + } + return 0L; + } + + private static BigDecimal readBd(Object o) { + if (o == null) { + return ZERO; + } + if (o instanceof BigDecimal bd) { + return bd.setScale(2, RoundingMode.HALF_UP); + } + if (o instanceof Number n) { + return BigDecimal.valueOf(n.doubleValue()).setScale(2, RoundingMode.HALF_UP); + } + return ZERO; + } + + private static BigDecimal readBdNullable(Object o) { + if (o == null) { + return null; + } + if (o instanceof BigDecimal bd) { + return bd.setScale(2, RoundingMode.HALF_UP); + } + if (o instanceof Number n) { + return BigDecimal.valueOf(n.doubleValue()).setScale(2, RoundingMode.HALF_UP); + } + return null; + } +} diff --git a/src/main/java/com/honey/honey/repository/UserARepository.java b/src/main/java/com/honey/honey/repository/UserARepository.java index a725bbf..e8a8160 100644 --- a/src/main/java/com/honey/honey/repository/UserARepository.java +++ b/src/main/java/com/honey/honey/repository/UserARepository.java @@ -25,9 +25,6 @@ public interface UserARepository extends JpaRepository, JpaSpeci @Query("SELECT COALESCE(MAX(u.id), 0) FROM UserA u") int getMaxId(); - /** Earliest registration timestamp (seconds) among all users. */ - @Query("SELECT MIN(u.dateReg) FROM UserA u") - Optional findMinDateReg(); Optional findByTelegramId(Long telegramId); /** diff --git a/src/main/java/com/honey/honey/repository/UserDRepository.java b/src/main/java/com/honey/honey/repository/UserDRepository.java index ec45eae..1daac81 100644 --- a/src/main/java/com/honey/honey/repository/UserDRepository.java +++ b/src/main/java/com/honey/honey/repository/UserDRepository.java @@ -11,10 +11,17 @@ import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Optional; @Repository public interface UserDRepository extends JpaRepository { - + + /** + * Earliest {@code date_reg} on {@code db_users_d} (registration time), ignoring unset rows. + */ + @Query("SELECT MIN(d.dateReg) FROM UserD d WHERE d.dateReg > 0") + Optional findMinDateReg(); + /** * Increments referals_1 for a user. */ diff --git a/src/main/java/com/honey/honey/service/AdminStatisticsTableService.java b/src/main/java/com/honey/honey/service/AdminStatisticsTableService.java new file mode 100644 index 0000000..dffcb6f --- /dev/null +++ b/src/main/java/com/honey/honey/service/AdminStatisticsTableService.java @@ -0,0 +1,196 @@ +package com.honey.honey.service; + +import com.honey.honey.dto.AdminStatisticsTableRowDto; +import com.honey.honey.repository.AdminStatisticsTableRepository; +import com.honey.honey.repository.AdminStatisticsTableRepository.StatisticsTableBucket; +import com.honey.honey.repository.UserDRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Instant; +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class AdminStatisticsTableService { + + private static final BigDecimal ZERO = new BigDecimal("0.00"); + private static final DateTimeFormatter MONTH_LABEL = DateTimeFormatter.ofPattern("MMMM yyyy", Locale.ENGLISH); + + private final AdminStatisticsTableRepository adminStatisticsTableRepository; + private final UserDRepository userDRepository; + + public enum TableMode { + DAY, + MONTH + } + + public Page getStatisticsTable(TableMode mode, int page, int size) { + LocalDate todayUtc = LocalDate.now(ZoneOffset.UTC); + LocalDate firstDay = resolveFirstProjectDay(todayUtc); + + if (mode == TableMode.DAY) { + return buildDayPage(todayUtc, firstDay, page, size); + } + return buildMonthPage(todayUtc, firstDay, page, size); + } + + private LocalDate resolveFirstProjectDay(LocalDate todayUtc) { + Optional minReg = userDRepository.findMinDateReg(); + if (minReg.isEmpty() || minReg.get() == null || minReg.get() <= 0) { + return todayUtc; + } + LocalDate d = Instant.ofEpochSecond(minReg.get()).atZone(ZoneOffset.UTC).toLocalDate(); + if (d.isAfter(todayUtc)) { + return todayUtc; + } + return d; + } + + private Page buildDayPage(LocalDate todayUtc, LocalDate firstDay, int page, int size) { + long totalPeriods = ChronoUnit.DAYS.between(firstDay, todayUtc) + 1; + if (totalPeriods < 1) { + totalPeriods = 1; + } + + int offset = page * size; + if (offset >= totalPeriods) { + return new PageImpl<>(List.of(), PageRequest.of(page, size), totalPeriods); + } + + int pageLen = (int) Math.min(size, totalPeriods - offset); + LocalDate newestOnPage = todayUtc.minusDays(offset); + LocalDate oldestOnPage = todayUtc.minusDays(offset + pageLen - 1); + + long startSec = oldestOnPage.atStartOfDay(ZoneOffset.UTC).toEpochSecond(); + long endSec = newestOnPage.plusDays(1).atStartOfDay(ZoneOffset.UTC).toEpochSecond(); + + Instant instStart = Instant.ofEpochSecond(startSec); + Instant instEnd = Instant.ofEpochSecond(endSec); + + Map buckets = new HashMap<>(); + adminStatisticsTableRepository.loadRegistrationBucketsByDay(startSec, endSec, buckets); + adminStatisticsTableRepository.loadRegistrationOursBucketsByDay(startSec, endSec, buckets); + adminStatisticsTableRepository.loadPaymentBucketsByDay(instStart, instEnd, buckets); + adminStatisticsTableRepository.loadPayoutBucketsByDay(instStart, instEnd, buckets); + + List rows = new ArrayList<>(pageLen); + for (int i = 0; i < pageLen; i++) { + int k = offset + i; + LocalDate day = todayUtc.minusDays(k); + rows.add(toRowDay(day, buckets.getOrDefault(day, new StatisticsTableBucket()))); + } + + return new PageImpl<>(rows, PageRequest.of(page, size), totalPeriods); + } + + private Page buildMonthPage(LocalDate todayUtc, LocalDate firstDay, int page, int size) { + YearMonth nowYm = YearMonth.from(todayUtc); + YearMonth firstYm = YearMonth.from(firstDay); + + long totalMonths = (nowYm.getYear() - firstYm.getYear()) * 12L + + (nowYm.getMonthValue() - firstYm.getMonthValue()) + 1; + if (totalMonths < 1) { + totalMonths = 1; + } + + int offset = page * size; + if (offset >= totalMonths) { + return new PageImpl<>(List.of(), PageRequest.of(page, size), totalMonths); + } + + int pageLen = (int) Math.min(size, totalMonths - offset); + YearMonth newestOnPage = nowYm.minusMonths(offset); + YearMonth oldestOnPage = nowYm.minusMonths(offset + pageLen - 1); + + long startSec = oldestOnPage.atDay(1).atStartOfDay(ZoneOffset.UTC).toEpochSecond(); + long endSec = newestOnPage.plusMonths(1).atDay(1).atStartOfDay(ZoneOffset.UTC).toEpochSecond(); + + Instant instStart = Instant.ofEpochSecond(startSec); + Instant instEnd = Instant.ofEpochSecond(endSec); + + Map buckets = new HashMap<>(); + adminStatisticsTableRepository.loadRegistrationBucketsByMonth(startSec, endSec, buckets); + adminStatisticsTableRepository.loadRegistrationOursBucketsByMonth(startSec, endSec, buckets); + adminStatisticsTableRepository.loadPaymentBucketsByMonth(instStart, instEnd, buckets); + adminStatisticsTableRepository.loadPayoutBucketsByMonth(instStart, instEnd, buckets); + + List rows = new ArrayList<>(pageLen); + for (int i = 0; i < pageLen; i++) { + int k = offset + i; + YearMonth ym = nowYm.minusMonths(k); + rows.add(toRowMonth(ym, buckets.getOrDefault(ym.toString(), new StatisticsTableBucket()))); + } + + return new PageImpl<>(rows, PageRequest.of(page, size), totalMonths); + } + + private AdminStatisticsTableRowDto toRowDay(LocalDate day, StatisticsTableBucket b) { + long nonOurs = Math.max(0, b.regs - b.regsOurs); + BigDecimal profitUsd = b.depositsSumUsd.subtract(b.withdrawalsSumUsd).setScale(2, RoundingMode.HALF_UP); + BigDecimal profitPct = null; + if (b.depositsSumUsd.compareTo(ZERO) > 0) { + BigDecimal ratio = b.withdrawalsSumUsd.divide(b.depositsSumUsd, 8, RoundingMode.HALF_UP); + profitPct = BigDecimal.ONE.subtract(ratio).multiply(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP); + } + return AdminStatisticsTableRowDto.builder() + .periodKey(day.toString()) + .periodLabel(day.toString()) + .regs(b.regs) + .regsOurs(b.regsOurs) + .regsNonOurs(nonOurs) + .deadOurs(null) + .deadNonOurs(null) + .depositsCount(b.depositsCount) + .depositsSumUsd(b.depositsSumUsd) + .ftdCount(b.ftdCount) + .avgPaymentUsd(b.avgPaymentUsd) + .withdrawalsCount(b.withdrawalsCount) + .withdrawalsSumUsd(b.withdrawalsSumUsd) + .profitPercent(profitPct) + .profitUsd(profitUsd) + .build(); + } + + private AdminStatisticsTableRowDto toRowMonth(YearMonth ym, StatisticsTableBucket b) { + long nonOurs = Math.max(0, b.regs - b.regsOurs); + BigDecimal profitUsd = b.depositsSumUsd.subtract(b.withdrawalsSumUsd).setScale(2, RoundingMode.HALF_UP); + BigDecimal profitPct = null; + if (b.depositsSumUsd.compareTo(ZERO) > 0) { + BigDecimal ratio = b.withdrawalsSumUsd.divide(b.depositsSumUsd, 8, RoundingMode.HALF_UP); + profitPct = BigDecimal.ONE.subtract(ratio).multiply(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP); + } + return AdminStatisticsTableRowDto.builder() + .periodKey(ym.toString()) + .periodLabel(ym.format(MONTH_LABEL)) + .regs(b.regs) + .regsOurs(b.regsOurs) + .regsNonOurs(nonOurs) + .deadOurs(null) + .deadNonOurs(null) + .depositsCount(b.depositsCount) + .depositsSumUsd(b.depositsSumUsd) + .ftdCount(b.ftdCount) + .avgPaymentUsd(b.avgPaymentUsd) + .withdrawalsCount(b.withdrawalsCount) + .withdrawalsSumUsd(b.withdrawalsSumUsd) + .profitPercent(profitPct) + .profitUsd(profitUsd) + .build(); + } +} diff --git a/src/main/java/com/honey/honey/service/UserService.java b/src/main/java/com/honey/honey/service/UserService.java index 2a065e8..a26c1df 100644 --- a/src/main/java/com/honey/honey/service/UserService.java +++ b/src/main/java/com/honey/honey/service/UserService.java @@ -266,7 +266,7 @@ public class UserService { userBRepository.save(userB); // Create UserD with referral handling - UserD userD = createUserDWithReferral(userId, screenName, start); + UserD userD = createUserDWithReferral(userId, screenName, start, (int) nowSeconds); userDRepository.save(userD); return userA; @@ -281,7 +281,7 @@ public class UserService { * @param screenName User's screen name (from db_users_a) * @param start Referral parameter (from bot registration, not WebApp) */ - private UserD createUserDWithReferral(Integer userId, String screenName, String start) { + private UserD createUserDWithReferral(Integer userId, String screenName, String start, int dateReg) { log.debug("Creating UserD with referral: userId={}, start={}", userId, start); // Defensive check: Ensure UserD doesn't already exist (should never happen, but safety check) @@ -294,6 +294,7 @@ public class UserService { UserD.UserDBuilder builder = UserD.builder() .id(userId) .screenName(screenName != null ? screenName : "-") + .dateReg(dateReg) .refererId1(0) .refererId2(0) .refererId3(0) diff --git a/src/main/resources/db/migration/V78__payments_index_status_completed_at.sql b/src/main/resources/db/migration/V78__payments_index_status_completed_at.sql new file mode 100644 index 0000000..29be84d --- /dev/null +++ b/src/main/resources/db/migration/V78__payments_index_status_completed_at.sql @@ -0,0 +1,2 @@ +-- Admin statistics table & analytics: range scans on COMPLETED payments by completion time +CREATE INDEX idx_payments_status_completed_at ON payments (status, completed_at); diff --git a/src/main/resources/db/migration/V79__users_d_referer_master_index.sql b/src/main/resources/db/migration/V79__users_d_referer_master_index.sql new file mode 100644 index 0000000..261eb59 --- /dev/null +++ b/src/main/resources/db/migration/V79__users_d_referer_master_index.sql @@ -0,0 +1,2 @@ +-- Admin statistics: registrations "ours" (referer_id_1 = master_id, id <> master_id, master_id > 0) +CREATE INDEX idx_users_d_master_referer1 ON db_users_d (master_id, referer_id_1); diff --git a/src/main/resources/db/migration/V80__users_d_date_reg.sql b/src/main/resources/db/migration/V80__users_d_date_reg.sql new file mode 100644 index 0000000..e28f70b --- /dev/null +++ b/src/main/resources/db/migration/V80__users_d_date_reg.sql @@ -0,0 +1,9 @@ +-- Mirror registration time on db_users_d for analytics without joining db_users_a +ALTER TABLE db_users_d + ADD COLUMN date_reg INT NOT NULL DEFAULT 0 COMMENT 'Registration unix time (seconds); same as db_users_a.date_reg' AFTER screen_name; + +UPDATE db_users_d d +INNER JOIN db_users_a a ON a.id = d.id +SET d.date_reg = a.date_reg; + +CREATE INDEX idx_users_d_date_reg ON db_users_d (date_reg);