mirror of
https://gitee.com/dromara/dax-pay
synced 2026-08-10 06:46:04 +08:00
refactor(dashboard): 按运营/商户拆分交易报表 Mapper
运营端 Admin*、商户端 Mch* 各自持有 Mapper/Service,core 仅保留 Result 与 TradeReportSupport;同步商户工作台菜单与资金交易只读接口。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -186,3 +186,8 @@ INSERT INTO "public"."iam_perm_menu" VALUES (606, 607, 'trade:callback-record',
|
||||
-- Primary Key structure for table iam_perm_menu
|
||||
-- ----------------------------
|
||||
ALTER TABLE "public"."iam_perm_menu" ADD CONSTRAINT "iam_perm_menu_pkey" PRIMARY KEY ("id");
|
||||
|
||||
-- 商户端仪表板(client_code=merchant)
|
||||
INSERT INTO "public"."iam_perm_menu" VALUES (91001, NULL, NULL, 'merchant', 'Dashboard', 'menu.dashboard', 'lucide:layout-dashboard', 'f', 'f', NULL, '/dashboard', '/workspace', -1, 'f', 'f', 'f', 0, NULL, 0, 'f', 'catalog', NULL, NULL, NULL, NULL, NULL, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
|
||||
INSERT INTO "public"."iam_perm_menu" VALUES (91002, 91001, 'dashboard:workspace', 'merchant', 'Workspace', 'menu.dashboard.workspace', 'lucide:panels-top-left', 'f', 'f', '/dashboard/workspace/index', '/workspace', NULL, 1, 'f', 'f', 't', 0, 1, 1, 'f', 'menu', NULL, NULL, NULL, NULL, '', '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
|
||||
INSERT INTO "public"."iam_perm_menu" VALUES (91003, 91001, 'dashboard:analytics', 'merchant', 'Analytics', 'menu.dashboard.analytics', 'lucide:area-chart', 'f', 'f', '/dashboard/analytics/index', '/analytics', NULL, 2, 'f', 'f', 'f', 0, 1, 1, 'f', 'menu', NULL, NULL, NULL, NULL, '', '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
|
||||
|
||||
@@ -126,3 +126,24 @@ UPDATE "public"."iam_perm_menu"
|
||||
SET "component" = '/system/config/mobile-app/detail/MobileAppDetail',
|
||||
"last_modified_time" = CURRENT_TIMESTAMP
|
||||
WHERE "id" = 40505;
|
||||
|
||||
-- ========== 商户端仪表板菜单(client_code=merchant) ==========
|
||||
INSERT INTO "public"."iam_perm_menu" VALUES (91001, NULL, NULL, 'merchant', 'Dashboard', 'menu.dashboard', 'lucide:layout-dashboard', 'f', 'f', NULL, '/dashboard', '/workspace', -1, 'f', 'f', 'f', 0, NULL, 0, 'f', 'catalog', NULL, NULL, NULL, NULL, NULL, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT ("id") DO NOTHING;
|
||||
INSERT INTO "public"."iam_perm_menu" VALUES (91002, 91001, 'dashboard:workspace', 'merchant', 'Workspace', 'menu.dashboard.workspace', 'lucide:panels-top-left', 'f', 'f', '/dashboard/workspace/index', '/workspace', NULL, 1, 'f', 'f', 't', 0, 1, 1, 'f', 'menu', NULL, NULL, NULL, NULL, '', '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT ("id") DO NOTHING;
|
||||
INSERT INTO "public"."iam_perm_menu" VALUES (91003, 91001, 'dashboard:analytics', 'merchant', 'Analytics', 'menu.dashboard.analytics', 'lucide:area-chart', 'f', 'f', '/dashboard/analytics/index', '/analytics', NULL, 2, 'f', 'f', 'f', 0, 1, 1, 'f', 'menu', NULL, NULL, NULL, NULL, '', '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT ("id") DO NOTHING;
|
||||
|
||||
-- 挂到所有 client_code=merchant 的角色(若尚无商户角色则无行;新建商户管理员角色时需勾选仪表板)
|
||||
INSERT INTO "public"."iam_role_menu" ("id", "role_id", "menu_id")
|
||||
SELECT ((EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint * 1000 + (row_number() OVER ())::bigint),
|
||||
r."id",
|
||||
m.menu_id
|
||||
FROM "public"."iam_role" r
|
||||
CROSS JOIN (VALUES (91001::bigint), (91002::bigint), (91003::bigint)) AS m(menu_id)
|
||||
WHERE r."client_code" = 'merchant'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "public"."iam_role_menu" rm
|
||||
WHERE rm."role_id" = r."id" AND rm."menu_id" = m.menu_id
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ package cn.daxpay.open.payment.admin.controller.dashboard;
|
||||
|
||||
import cn.daxpay.open.platform.core.rest.Res;
|
||||
import cn.daxpay.open.platform.core.rest.result.Result;
|
||||
import cn.daxpay.open.payment.admin.service.dashboard.DashboardTradeService;
|
||||
import cn.daxpay.open.payment.admin.service.dashboard.AdminDashboardTradeService;
|
||||
import cn.daxpay.open.payment.trade.report.result.AmountRangeItemResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.HourlyDistItemResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.MerchantRankItemResult;
|
||||
@@ -22,7 +22,7 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/// # 工作台/分析页交易统计(管理端)
|
||||
/// # 工作台/分析页交易统计(运营端)
|
||||
///
|
||||
/// 面向运营端工作台仪表盘与分析页的聚合统计:
|
||||
/// 概览(含环比)、交易趋势、退款趋势、支付渠道分布、渠道成功率、时段分布、金额区间、商户排名。
|
||||
@@ -39,9 +39,9 @@ import java.util.List;
|
||||
@RestController
|
||||
@RequestMapping("/admin/dashboard/trade")
|
||||
@RequiredArgsConstructor
|
||||
public class DashboardTradeController {
|
||||
public class AdminDashboardTradeController {
|
||||
|
||||
private final DashboardTradeService dashboardTradeService;
|
||||
private final AdminDashboardTradeService adminDashboardTradeService;
|
||||
|
||||
// ===== 概览 =====
|
||||
|
||||
@@ -54,9 +54,9 @@ public class DashboardTradeController {
|
||||
@RequestParam(required = false) String start,
|
||||
@RequestParam(required = false) String end) {
|
||||
if (start != null && end != null) {
|
||||
return Res.ok(dashboardTradeService.overview(start, end));
|
||||
return Res.ok(adminDashboardTradeService.overview(start, end));
|
||||
}
|
||||
return Res.ok(dashboardTradeService.overview(date != null ? date : "today"));
|
||||
return Res.ok(adminDashboardTradeService.overview(date != null ? date : "today"));
|
||||
}
|
||||
|
||||
// ===== 趋势 =====
|
||||
@@ -68,9 +68,9 @@ public class DashboardTradeController {
|
||||
@RequestParam(required = false) String start,
|
||||
@RequestParam(required = false) String end) {
|
||||
if (start != null && end != null) {
|
||||
return Res.ok(dashboardTradeService.trend(start, end));
|
||||
return Res.ok(adminDashboardTradeService.trend(start, end));
|
||||
}
|
||||
return Res.ok(dashboardTradeService.trend(days));
|
||||
return Res.ok(adminDashboardTradeService.trend(days));
|
||||
}
|
||||
|
||||
@Operation(summary = "退款趋势(指定天数或自定义区间)")
|
||||
@@ -80,9 +80,9 @@ public class DashboardTradeController {
|
||||
@RequestParam(required = false) String start,
|
||||
@RequestParam(required = false) String end) {
|
||||
if (start != null && end != null) {
|
||||
return Res.ok(dashboardTradeService.refundTrend(start, end));
|
||||
return Res.ok(adminDashboardTradeService.refundTrend(start, end));
|
||||
}
|
||||
return Res.ok(dashboardTradeService.refundTrend(days));
|
||||
return Res.ok(adminDashboardTradeService.refundTrend(days));
|
||||
}
|
||||
|
||||
// ===== 渠道 =====
|
||||
@@ -94,9 +94,9 @@ public class DashboardTradeController {
|
||||
@RequestParam(required = false) String start,
|
||||
@RequestParam(required = false) String end) {
|
||||
if (start != null && end != null) {
|
||||
return Res.ok(dashboardTradeService.providerDist(start, end));
|
||||
return Res.ok(adminDashboardTradeService.providerDist(start, end));
|
||||
}
|
||||
return Res.ok(dashboardTradeService.providerDist(days));
|
||||
return Res.ok(adminDashboardTradeService.providerDist(days));
|
||||
}
|
||||
|
||||
@Operation(summary = "支付渠道成功率(指定天数或自定义区间)")
|
||||
@@ -106,9 +106,9 @@ public class DashboardTradeController {
|
||||
@RequestParam(required = false) String start,
|
||||
@RequestParam(required = false) String end) {
|
||||
if (start != null && end != null) {
|
||||
return Res.ok(dashboardTradeService.providerSuccess(start, end));
|
||||
return Res.ok(adminDashboardTradeService.providerSuccess(start, end));
|
||||
}
|
||||
return Res.ok(dashboardTradeService.providerSuccess(days));
|
||||
return Res.ok(adminDashboardTradeService.providerSuccess(days));
|
||||
}
|
||||
|
||||
// ===== 时段 =====
|
||||
@@ -120,9 +120,9 @@ public class DashboardTradeController {
|
||||
@RequestParam(required = false) String start,
|
||||
@RequestParam(required = false) String end) {
|
||||
if (start != null && end != null) {
|
||||
return Res.ok(dashboardTradeService.hourlyDist(start, end));
|
||||
return Res.ok(adminDashboardTradeService.hourlyDist(start, end));
|
||||
}
|
||||
return Res.ok(dashboardTradeService.hourlyDist(days));
|
||||
return Res.ok(adminDashboardTradeService.hourlyDist(days));
|
||||
}
|
||||
|
||||
// ===== 金额区间 =====
|
||||
@@ -134,9 +134,9 @@ public class DashboardTradeController {
|
||||
@RequestParam(required = false) String start,
|
||||
@RequestParam(required = false) String end) {
|
||||
if (start != null && end != null) {
|
||||
return Res.ok(dashboardTradeService.amountRange(start, end));
|
||||
return Res.ok(adminDashboardTradeService.amountRange(start, end));
|
||||
}
|
||||
return Res.ok(dashboardTradeService.amountRange(days));
|
||||
return Res.ok(adminDashboardTradeService.amountRange(days));
|
||||
}
|
||||
|
||||
// ===== 商户排名 =====
|
||||
@@ -149,8 +149,8 @@ public class DashboardTradeController {
|
||||
@RequestParam(required = false) String start,
|
||||
@RequestParam(required = false) String end) {
|
||||
if (start != null && end != null) {
|
||||
return Res.ok(dashboardTradeService.merchantRank(start, end, limit));
|
||||
return Res.ok(adminDashboardTradeService.merchantRank(start, end, limit));
|
||||
}
|
||||
return Res.ok(dashboardTradeService.merchantRank(days, limit));
|
||||
return Res.ok(adminDashboardTradeService.merchantRank(days, limit));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package cn.daxpay.open.payment.trade.report.dao;
|
||||
package cn.daxpay.open.payment.admin.dao.dashboard;
|
||||
|
||||
import cn.daxpay.open.payment.trade.order.entity.PayTrade;
|
||||
import cn.daxpay.open.payment.trade.report.result.AmountRangeItemResult;
|
||||
@@ -17,33 +17,17 @@ import org.apache.ibatis.annotations.Select;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/// # 交易聚合统计 Mapper
|
||||
/// # 运营端交易聚合统计 Mapper
|
||||
///
|
||||
/// 工作台仪表盘 + 分析页专用聚合查询, 直查 [PayTrade] 所在的 `pay_trade` 与 `pay_refund_order` 表,
|
||||
/// 商户排名 JOIN `mch_info` 取商户名。
|
||||
/// 全平台口径, **无** `mch_no` 过滤。含商户排名; 不含商户端维度排名。
|
||||
///
|
||||
/// ## 成交口径
|
||||
/// 以 `pay_trade` 资金凭证为准, 资金态 SUCCESS 且 `posted_amount > 0` 才计入成交;
|
||||
/// `posted_amount` 对结算类动作(normal/gateway/capture 等)= 金额, 对预授权冻结恒为 0,
|
||||
/// 以此自动排除冻结类资金动作(见 [PayTrade] 字段说明)。
|
||||
///
|
||||
/// ## 时间字段口径差异(重要)
|
||||
/// - **成交相关**(`success_amount/success_count`, 退款金额趋势等): 按 `pay_time` 过滤
|
||||
/// (支付成功时间), 失败/关闭单 pay_time 为 NULL, 自然落在本区间外。
|
||||
/// - **总下单笔数**(`total_orders`, 用于成功率分母): 按 `create_time` 过滤(创建时间),
|
||||
/// 因为失败/关闭单也需计入分母。两者口径不同, 同一笔单的 create_time 与 pay_time
|
||||
/// 可能跨区间, 因此 `success_rate = success_count / total_orders` 仅作运营参考。
|
||||
///
|
||||
/// ## 时区处理
|
||||
/// 数据库时间列为 `timestamptz`, MetaObjectHandler 按 UTC 写入。
|
||||
/// 概览/渠道分布用 `pay_time BETWEEN` 做瞬时范围比较(OffsetDateTime 即时比较, 无歧义);
|
||||
/// 趋势/时段/退款趋势按业务时区(Asia/Shanghai)日或小时分组, 用
|
||||
/// `pay_time AT TIME ZONE 'Asia/Shanghai'` 转换后截取, 确保接近 CST 日界线的交易归入正确业务日。
|
||||
/// 以 `pay_trade` 资金凭证为准, 资金态 SUCCESS 且 `posted_amount > 0` 才计入成交。
|
||||
///
|
||||
/// ## 金额单位
|
||||
/// 所有金额字段为分(最小货币单位), SUM 结果 `::bigint` 强转确保映射为 Java Long。
|
||||
@Mapper
|
||||
public interface TradeReportMapper extends MPJBaseMapper<PayTrade> {
|
||||
public interface AdminTradeReportMapper extends MPJBaseMapper<PayTrade> {
|
||||
|
||||
// ===== 概览 =====
|
||||
|
||||
@@ -57,8 +41,7 @@ public interface TradeReportMapper extends MPJBaseMapper<PayTrade> {
|
||||
@Param("start") OffsetDateTime start,
|
||||
@Param("end") OffsetDateTime end);
|
||||
|
||||
/// 总下单笔数聚合: 按 create_time 过滤, 填充 total_orders(用于成功率分母)
|
||||
/// 注意口径与 success_count 不同(见类注释"时间字段口径差异")
|
||||
/// 总下单笔数聚合: 按 create_time 过滤, 填充 total_orders
|
||||
@Select("SELECT COUNT(*)::bigint AS total_orders "
|
||||
+ "FROM pay_trade "
|
||||
+ "WHERE create_time >= #{start} AND create_time < #{end}")
|
||||
@@ -66,7 +49,7 @@ public interface TradeReportMapper extends MPJBaseMapper<PayTrade> {
|
||||
@Param("start") OffsetDateTime start,
|
||||
@Param("end") OffsetDateTime end);
|
||||
|
||||
/// 退款聚合: pay_refund_order 成功, 按 finish_time 范围, 填充 refund_amount / refund_count
|
||||
/// 退款聚合
|
||||
@Select("SELECT COALESCE(SUM(amount), 0)::bigint AS refund_amount, "
|
||||
+ "COUNT(*)::bigint AS refund_count "
|
||||
+ "FROM pay_refund_order "
|
||||
@@ -78,7 +61,6 @@ public interface TradeReportMapper extends MPJBaseMapper<PayTrade> {
|
||||
|
||||
// ===== 趋势 =====
|
||||
|
||||
/// 成交趋势: 按 CST 业务日分组, 返回每日成交金额(分)与笔数, 仅含有成交的日期(无成交日由 Service 补零)
|
||||
@Select("SELECT TO_CHAR(pay_time AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') AS date, "
|
||||
+ "COALESCE(SUM(posted_amount), 0)::bigint AS amount, "
|
||||
+ "COUNT(*)::bigint AS count "
|
||||
@@ -90,7 +72,6 @@ public interface TradeReportMapper extends MPJBaseMapper<PayTrade> {
|
||||
@Param("start") OffsetDateTime start,
|
||||
@Param("end") OffsetDateTime end);
|
||||
|
||||
/// 退款趋势: 按 CST 业务日分组, 返回每日退款金额(分)与笔数, 仅含有退款的日期(Service 补零)
|
||||
@Select("SELECT TO_CHAR(finish_time AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') AS date, "
|
||||
+ "COALESCE(SUM(amount), 0)::bigint AS amount, "
|
||||
+ "COUNT(*)::bigint AS count "
|
||||
@@ -104,8 +85,6 @@ public interface TradeReportMapper extends MPJBaseMapper<PayTrade> {
|
||||
|
||||
// ===== 渠道分布 =====
|
||||
|
||||
/// 支付渠道分布: 按支付渠道(provider code)分组, 返回各渠道成交金额(分)与笔数, 按金额倒序
|
||||
/// provider 为支付渠道编码(如 wechat/alipay/union_pay), 取自资金凭证冗余字段(权威在容器 provider)
|
||||
@Select("SELECT provider, "
|
||||
+ "COALESCE(SUM(posted_amount), 0)::bigint AS amount, "
|
||||
+ "COUNT(*)::bigint AS count "
|
||||
@@ -118,9 +97,6 @@ public interface TradeReportMapper extends MPJBaseMapper<PayTrade> {
|
||||
@Param("start") OffsetDateTime start,
|
||||
@Param("end") OffsetDateTime end);
|
||||
|
||||
/// 支付渠道成功率: 按 provider 分组, 成功率 = success_count / total_count * 100
|
||||
/// total_count 含所有非初始化态(success/fail/close/cancel)的单, 按 create_time 过滤(与 totalOrders 同口径)
|
||||
/// NULLIF 防止除零, 结果四舍五入保留 1 位小数
|
||||
@Select("SELECT provider, "
|
||||
+ "ROUND(COUNT(*) FILTER (WHERE status = 'success' AND posted_amount > 0)::numeric "
|
||||
+ " / NULLIF(COUNT(*), 0) * 100, 1) AS rate "
|
||||
@@ -136,8 +112,6 @@ public interface TradeReportMapper extends MPJBaseMapper<PayTrade> {
|
||||
|
||||
// ===== 时段分布 =====
|
||||
|
||||
/// 24 小时时段分布: 按 CST 小时(0-23)分组, 返回各时段成交金额(分)与笔数(区间合计)
|
||||
/// 仅含有成交的小时(Service 补齐 0-23 缺失时段后, 再除以区间天数得到日均)
|
||||
@Select("SELECT EXTRACT(HOUR FROM pay_time AT TIME ZONE 'Asia/Shanghai')::int AS hour, "
|
||||
+ "COALESCE(SUM(posted_amount), 0)::bigint AS amount, "
|
||||
+ "COUNT(*)::bigint AS count "
|
||||
@@ -151,10 +125,6 @@ public interface TradeReportMapper extends MPJBaseMapper<PayTrade> {
|
||||
|
||||
// ===== 金额区间分桶 =====
|
||||
|
||||
/// 金额区间分桶: 按 amount(订单金额, 分) CASE 分桶, 桶口径与前端 analytics 一致
|
||||
/// 桶定义: 0-50 / 50-200 / 200-1000 / 1000-5000 / 5000+ (单位: 元, 即 0-5000 / 5000-20000 / ... 分)
|
||||
/// 仅含成功单(status='success' AND posted_amount>0), 按 pay_time 过滤
|
||||
/// bucket 字段用固定 key 返回(如 '0-50'), Service 负责补齐 5 个缺失桶
|
||||
@Select("SELECT CASE "
|
||||
+ "WHEN amount < 5000 THEN '0-50' "
|
||||
+ "WHEN amount < 20000 THEN '50-200' "
|
||||
@@ -172,10 +142,6 @@ public interface TradeReportMapper extends MPJBaseMapper<PayTrade> {
|
||||
|
||||
// ===== 商户排名 =====
|
||||
|
||||
/// 商户交易额排名: 按 mch_no 分组, JOIN mch_info 取商户名, 按成交金额倒序
|
||||
/// 仅含成功单(status='success' AND posted_amount>0), 按 pay_time 过滤
|
||||
/// proportion(占比)由 Service 计算(基于本期总成交额)
|
||||
/// @param limit 返回前 N 名(通常 10)
|
||||
@Select("SELECT t.mch_no AS mchNo, m.mch_name AS merchantName, "
|
||||
+ "COALESCE(SUM(t.posted_amount), 0)::bigint AS amount, "
|
||||
+ "COUNT(*)::bigint AS orders "
|
||||
@@ -0,0 +1,182 @@
|
||||
package cn.daxpay.open.payment.admin.service.dashboard;
|
||||
|
||||
import cn.daxpay.open.payment.admin.dao.dashboard.AdminTradeReportMapper;
|
||||
import cn.daxpay.open.payment.trade.report.result.AmountRangeItemResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.HourlyDistItemResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.MerchantRankItemResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.ProviderDistItemResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.ProviderSuccessItemResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.RefundTrendItemResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.TradeOverviewResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.TradeTrendItemResult;
|
||||
import cn.daxpay.open.payment.trade.report.support.TradeReportSupport;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/// # 工作台/分析页交易统计(运营端)
|
||||
///
|
||||
/// 编排 [AdminTradeReportMapper](全平台口径)与 [TradeReportSupport]。
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AdminDashboardTradeService {
|
||||
|
||||
private final AdminTradeReportMapper adminTradeReportMapper;
|
||||
private final TradeReportSupport tradeReportSupport;
|
||||
|
||||
// ===== 概览 =====
|
||||
|
||||
public TradeOverviewResult overview(String date) {
|
||||
LocalDate today = LocalDate.now(TradeReportSupport.ZONE_CST);
|
||||
LocalDate target = "yesterday".equalsIgnoreCase(date) ? today.minusDays(1) : today;
|
||||
OffsetDateTime start = target.atStartOfDay(TradeReportSupport.ZONE_CST).toOffsetDateTime();
|
||||
OffsetDateTime end = target.plusDays(1).atStartOfDay(TradeReportSupport.ZONE_CST).toOffsetDateTime();
|
||||
OffsetDateTime prevStart = start.minusDays(1);
|
||||
OffsetDateTime prevEnd = start;
|
||||
return aggregateOverview(start, end, prevStart, prevEnd);
|
||||
}
|
||||
|
||||
public TradeOverviewResult overview(String start, String end) {
|
||||
OffsetDateTime startUtc = tradeReportSupport.parseDateStart(start);
|
||||
OffsetDateTime endUtc = tradeReportSupport.parseDateEndExclusive(end);
|
||||
long daysSpan = java.time.Duration.between(startUtc, endUtc).toDays();
|
||||
OffsetDateTime prevStart = startUtc.minusDays(daysSpan);
|
||||
OffsetDateTime prevEnd = startUtc;
|
||||
return aggregateOverview(startUtc, endUtc, prevStart, prevEnd);
|
||||
}
|
||||
|
||||
private TradeOverviewResult aggregateOverview(
|
||||
OffsetDateTime start, OffsetDateTime end,
|
||||
OffsetDateTime prevStart, OffsetDateTime prevEnd) {
|
||||
TradeOverviewResult currSuccess = adminTradeReportMapper.successAggregate(start, end);
|
||||
TradeOverviewResult currTotal = adminTradeReportMapper.totalOrdersAggregate(start, end);
|
||||
TradeOverviewResult currRefund = adminTradeReportMapper.refundAggregate(start, end);
|
||||
TradeOverviewResult prevSuccess = adminTradeReportMapper.successAggregate(prevStart, prevEnd);
|
||||
TradeOverviewResult prevTotal = adminTradeReportMapper.totalOrdersAggregate(prevStart, prevEnd);
|
||||
TradeOverviewResult prevRefund = adminTradeReportMapper.refundAggregate(prevStart, prevEnd);
|
||||
|
||||
return new TradeOverviewResult()
|
||||
.setSuccessAmount(currSuccess.getSuccessAmount())
|
||||
.setSuccessCount(currSuccess.getSuccessCount())
|
||||
.setRefundAmount(currRefund.getRefundAmount())
|
||||
.setRefundCount(currRefund.getRefundCount())
|
||||
.setTotalOrders(currTotal.getTotalOrders())
|
||||
.setPrevSuccessAmount(prevSuccess.getSuccessAmount())
|
||||
.setPrevSuccessCount(prevSuccess.getSuccessCount())
|
||||
.setPrevRefundAmount(prevRefund.getRefundAmount())
|
||||
.setPrevRefundCount(prevRefund.getRefundCount())
|
||||
.setPrevTotalOrders(prevTotal.getTotalOrders());
|
||||
}
|
||||
|
||||
// ===== 趋势 =====
|
||||
|
||||
public List<TradeTrendItemResult> trend(int days) {
|
||||
int safeDays = tradeReportSupport.clampDays(days);
|
||||
LocalDate today = LocalDate.now(TradeReportSupport.ZONE_CST);
|
||||
LocalDate startDate = today.minusDays(safeDays - 1L);
|
||||
OffsetDateTime start = startDate.atStartOfDay(TradeReportSupport.ZONE_CST).toOffsetDateTime();
|
||||
OffsetDateTime end = today.plusDays(1).atStartOfDay(TradeReportSupport.ZONE_CST).toOffsetDateTime();
|
||||
return tradeReportSupport.fillTrendDays(startDate, safeDays, adminTradeReportMapper.trend(start, end));
|
||||
}
|
||||
|
||||
public List<TradeTrendItemResult> trend(String startStr, String endStr) {
|
||||
OffsetDateTime start = tradeReportSupport.parseDateStart(startStr);
|
||||
OffsetDateTime end = tradeReportSupport.parseDateEndExclusive(endStr);
|
||||
long days = java.time.Duration.between(start, end).toDays();
|
||||
int safeDays = tradeReportSupport.clampDays((int) days);
|
||||
LocalDate startDate = start.atZoneSameInstant(TradeReportSupport.ZONE_CST).toLocalDate();
|
||||
return tradeReportSupport.fillTrendDays(startDate, safeDays, adminTradeReportMapper.trend(start, end));
|
||||
}
|
||||
|
||||
public List<RefundTrendItemResult> refundTrend(int days) {
|
||||
int safeDays = tradeReportSupport.clampDays(days);
|
||||
LocalDate today = LocalDate.now(TradeReportSupport.ZONE_CST);
|
||||
LocalDate startDate = today.minusDays(safeDays - 1L);
|
||||
OffsetDateTime start = startDate.atStartOfDay(TradeReportSupport.ZONE_CST).toOffsetDateTime();
|
||||
OffsetDateTime end = today.plusDays(1).atStartOfDay(TradeReportSupport.ZONE_CST).toOffsetDateTime();
|
||||
return tradeReportSupport.fillRefundTrendDays(
|
||||
startDate, safeDays, adminTradeReportMapper.refundTrend(start, end));
|
||||
}
|
||||
|
||||
public List<RefundTrendItemResult> refundTrend(String startStr, String endStr) {
|
||||
OffsetDateTime start = tradeReportSupport.parseDateStart(startStr);
|
||||
OffsetDateTime end = tradeReportSupport.parseDateEndExclusive(endStr);
|
||||
long days = java.time.Duration.between(start, end).toDays();
|
||||
int safeDays = tradeReportSupport.clampDays((int) days);
|
||||
LocalDate startDate = start.atZoneSameInstant(TradeReportSupport.ZONE_CST).toLocalDate();
|
||||
return tradeReportSupport.fillRefundTrendDays(
|
||||
startDate, safeDays, adminTradeReportMapper.refundTrend(start, end));
|
||||
}
|
||||
|
||||
// ===== 渠道 =====
|
||||
|
||||
public List<ProviderDistItemResult> providerDist(int days) {
|
||||
OffsetDateTime[] range = tradeReportSupport.daysRange(days);
|
||||
return adminTradeReportMapper.providerDist(range[0], range[1]);
|
||||
}
|
||||
|
||||
public List<ProviderDistItemResult> providerDist(String startStr, String endStr) {
|
||||
return adminTradeReportMapper.providerDist(
|
||||
tradeReportSupport.parseDateStart(startStr),
|
||||
tradeReportSupport.parseDateEndExclusive(endStr));
|
||||
}
|
||||
|
||||
public List<ProviderSuccessItemResult> providerSuccess(int days) {
|
||||
OffsetDateTime[] range = tradeReportSupport.daysRange(days);
|
||||
return adminTradeReportMapper.providerSuccess(range[0], range[1]);
|
||||
}
|
||||
|
||||
public List<ProviderSuccessItemResult> providerSuccess(String startStr, String endStr) {
|
||||
return adminTradeReportMapper.providerSuccess(
|
||||
tradeReportSupport.parseDateStart(startStr),
|
||||
tradeReportSupport.parseDateEndExclusive(endStr));
|
||||
}
|
||||
|
||||
// ===== 时段 / 金额 =====
|
||||
|
||||
public List<HourlyDistItemResult> hourlyDist(int days) {
|
||||
OffsetDateTime[] range = tradeReportSupport.daysRange(days);
|
||||
int daysSpan = tradeReportSupport.clampDays(days);
|
||||
return tradeReportSupport.toDailyAverage(
|
||||
tradeReportSupport.fillHourlyDist(adminTradeReportMapper.hourlyDist(range[0], range[1])),
|
||||
daysSpan);
|
||||
}
|
||||
|
||||
public List<HourlyDistItemResult> hourlyDist(String startStr, String endStr) {
|
||||
OffsetDateTime start = tradeReportSupport.parseDateStart(startStr);
|
||||
OffsetDateTime end = tradeReportSupport.parseDateEndExclusive(endStr);
|
||||
long daysSpan = Math.max(1L, java.time.Duration.between(start, end).toDays());
|
||||
return tradeReportSupport.toDailyAverage(
|
||||
tradeReportSupport.fillHourlyDist(adminTradeReportMapper.hourlyDist(start, end)),
|
||||
daysSpan);
|
||||
}
|
||||
|
||||
public List<AmountRangeItemResult> amountRange(int days) {
|
||||
OffsetDateTime[] range = tradeReportSupport.daysRange(days);
|
||||
return tradeReportSupport.fillAmountRange(adminTradeReportMapper.amountRange(range[0], range[1]));
|
||||
}
|
||||
|
||||
public List<AmountRangeItemResult> amountRange(String startStr, String endStr) {
|
||||
return tradeReportSupport.fillAmountRange(adminTradeReportMapper.amountRange(
|
||||
tradeReportSupport.parseDateStart(startStr),
|
||||
tradeReportSupport.parseDateEndExclusive(endStr)));
|
||||
}
|
||||
|
||||
// ===== 商户排名 =====
|
||||
|
||||
public List<MerchantRankItemResult> merchantRank(int days, int limit) {
|
||||
OffsetDateTime[] range = tradeReportSupport.daysRange(days);
|
||||
return tradeReportSupport.computeMerchantProportion(
|
||||
adminTradeReportMapper.merchantRank(range[0], range[1], tradeReportSupport.clampLimit(limit)));
|
||||
}
|
||||
|
||||
public List<MerchantRankItemResult> merchantRank(String startStr, String endStr, int limit) {
|
||||
return tradeReportSupport.computeMerchantProportion(adminTradeReportMapper.merchantRank(
|
||||
tradeReportSupport.parseDateStart(startStr),
|
||||
tradeReportSupport.parseDateEndExclusive(endStr),
|
||||
tradeReportSupport.clampLimit(limit)));
|
||||
}
|
||||
}
|
||||
@@ -1,374 +0,0 @@
|
||||
package cn.daxpay.open.payment.admin.service.dashboard;
|
||||
|
||||
import cn.daxpay.open.payment.trade.report.dao.TradeReportMapper;
|
||||
import cn.daxpay.open.payment.trade.report.result.AmountRangeItemResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.HourlyDistItemResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.MerchantRankItemResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.ProviderDistItemResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.ProviderSuccessItemResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.RefundTrendItemResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.TradeOverviewResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.TradeTrendItemResult;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/// # 工作台/分析页交易统计服务
|
||||
///
|
||||
/// 编排 [TradeReportMapper] 的聚合查询, 处理业务时区(Asia/Shanghai)下的日界线计算、
|
||||
/// 趋势/时段/分桶补零、商户排名占比计算、概览环比上一周期对比。
|
||||
///
|
||||
/// ## 时区
|
||||
/// 数据库 `timestamptz` 按 UTC 写入, 这里以 Asia/Shanghai 为业务日界线计算时间区间,
|
||||
/// 传给 Mapper 做即时比较, 确保接近 CST 日界线的交易归入正确的业务日。
|
||||
///
|
||||
/// ## 时间区间参数
|
||||
/// 所有方法支持 (start, end) 半开区间, 与分析页的 dateRange 直接对应。
|
||||
/// overview 额外计算"上一周期"(与本区间等长)用于环比, 前端可直接用 prev* / curr* 计算百分比。
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class DashboardTradeService {
|
||||
|
||||
/// 业务时区(支付平台默认 Asia/Shanghai)
|
||||
private static final ZoneId ZONE_CST = ZoneId.of("Asia/Shanghai");
|
||||
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
|
||||
/// 趋势/分析页天数上下限, 防止前端传参滥用导致大范围全表聚合
|
||||
private static final int TREND_DAYS_MIN = 1;
|
||||
private static final int TREND_DAYS_MAX = 365;
|
||||
|
||||
/// 商户排名最大返回数
|
||||
private static final int MERCHANT_RANK_LIMIT_MAX = 50;
|
||||
private static final int MERCHANT_RANK_LIMIT_DEFAULT = 10;
|
||||
|
||||
/// 金额区间分桶的固定顺序(Service 按此顺序补齐缺失桶)
|
||||
private static final List<String> AMOUNT_BUCKETS = List.of("0-50", "50-200", "200-1000", "1000-5000", "5000+");
|
||||
|
||||
private final TradeReportMapper tradeReportMapper;
|
||||
|
||||
// ===== 概览 =====
|
||||
|
||||
/// 交易概览(今日/昨日)
|
||||
///
|
||||
/// @param date "today" 或 "yesterday", 其它值按 today 处理
|
||||
public TradeOverviewResult overview(String date) {
|
||||
LocalDate today = LocalDate.now(ZONE_CST);
|
||||
LocalDate target = "yesterday".equalsIgnoreCase(date) ? today.minusDays(1) : today;
|
||||
// [start, end) 半开区间, end 指向次日 00:00 避免边界重复
|
||||
OffsetDateTime start = target.atStartOfDay(ZONE_CST).toOffsetDateTime();
|
||||
OffsetDateTime end = target.plusDays(1).atStartOfDay(ZONE_CST).toOffsetDateTime();
|
||||
// 上一周期: 同长度的前一天(单日概览时即昨日, 与 date=yesterday 不同语义, 这里 prev 用于环比)
|
||||
OffsetDateTime prevStart = start.minusDays(1);
|
||||
OffsetDateTime prevEnd = start;
|
||||
return aggregateOverview(start, end, prevStart, prevEnd);
|
||||
}
|
||||
|
||||
/// 交易概览(自定义区间, 含上期对比)
|
||||
///
|
||||
/// @param start 起始日期(yyyy-MM-dd, 包含), 业务时区 Asia/Shanghai
|
||||
/// @param end 结束日期(yyyy-MM-dd, **包含**), Service 内部转半开区间 [start, start+1day)
|
||||
public TradeOverviewResult overview(String start, String end) {
|
||||
OffsetDateTime startUtc = parseDateStart(start);
|
||||
OffsetDateTime endUtc = parseDateEndExclusive(end);
|
||||
// 上一周期: 与本区间等长, 紧接本区间之前
|
||||
long daysSpan = java.time.Duration.between(startUtc, endUtc).toDays();
|
||||
OffsetDateTime prevStart = startUtc.minusDays(daysSpan);
|
||||
OffsetDateTime prevEnd = startUtc;
|
||||
return aggregateOverview(startUtc, endUtc, prevStart, prevEnd);
|
||||
}
|
||||
|
||||
/// 聚合概览(本/上期 6 次查询组装到一个 Result)
|
||||
private TradeOverviewResult aggregateOverview(
|
||||
OffsetDateTime start, OffsetDateTime end,
|
||||
OffsetDateTime prevStart, OffsetDateTime prevEnd) {
|
||||
// 本期
|
||||
TradeOverviewResult currSuccess = tradeReportMapper.successAggregate(start, end);
|
||||
TradeOverviewResult currTotal = tradeReportMapper.totalOrdersAggregate(start, end);
|
||||
TradeOverviewResult currRefund = tradeReportMapper.refundAggregate(start, end);
|
||||
// 上期(用于环比)
|
||||
TradeOverviewResult prevSuccess = tradeReportMapper.successAggregate(prevStart, prevEnd);
|
||||
TradeOverviewResult prevTotal = tradeReportMapper.totalOrdersAggregate(prevStart, prevEnd);
|
||||
TradeOverviewResult prevRefund = tradeReportMapper.refundAggregate(prevStart, prevEnd);
|
||||
|
||||
return new TradeOverviewResult()
|
||||
.setSuccessAmount(currSuccess.getSuccessAmount())
|
||||
.setSuccessCount(currSuccess.getSuccessCount())
|
||||
.setRefundAmount(currRefund.getRefundAmount())
|
||||
.setRefundCount(currRefund.getRefundCount())
|
||||
.setTotalOrders(currTotal.getTotalOrders())
|
||||
.setPrevSuccessAmount(prevSuccess.getSuccessAmount())
|
||||
.setPrevSuccessCount(prevSuccess.getSuccessCount())
|
||||
.setPrevRefundAmount(prevRefund.getRefundAmount())
|
||||
.setPrevRefundCount(prevRefund.getRefundCount())
|
||||
.setPrevTotalOrders(prevTotal.getTotalOrders());
|
||||
}
|
||||
|
||||
// ===== 趋势 =====
|
||||
|
||||
/// 交易趋势(指定天数, 近 N 天每日成交金额与笔数)
|
||||
///
|
||||
/// @param days 近 N 天(含今天), 钳制到 [1, 365]
|
||||
public List<TradeTrendItemResult> trend(int days) {
|
||||
int safeDays = clampDays(days);
|
||||
LocalDate today = LocalDate.now(ZONE_CST);
|
||||
LocalDate startDate = today.minusDays(safeDays - 1L);
|
||||
OffsetDateTime start = startDate.atStartOfDay(ZONE_CST).toOffsetDateTime();
|
||||
OffsetDateTime end = today.plusDays(1).atStartOfDay(ZONE_CST).toOffsetDateTime();
|
||||
return fillTrendDays(startDate, safeDays, tradeReportMapper.trend(start, end));
|
||||
}
|
||||
|
||||
/// 交易趋势(自定义区间)
|
||||
public List<TradeTrendItemResult> trend(String startStr, String endStr) {
|
||||
OffsetDateTime start = parseDateStart(startStr);
|
||||
OffsetDateTime end = parseDateEndExclusive(endStr);
|
||||
long days = java.time.Duration.between(start, end).toDays();
|
||||
int safeDays = clampDays((int) days);
|
||||
LocalDate startDate = start.atZoneSameInstant(ZONE_CST).toLocalDate();
|
||||
List<TradeTrendItemResult> rows = tradeReportMapper.trend(start, end);
|
||||
return fillTrendDays(startDate, safeDays, rows);
|
||||
}
|
||||
|
||||
/// 退款趋势(指定天数)
|
||||
public List<RefundTrendItemResult> refundTrend(int days) {
|
||||
int safeDays = clampDays(days);
|
||||
LocalDate today = LocalDate.now(ZONE_CST);
|
||||
LocalDate startDate = today.minusDays(safeDays - 1L);
|
||||
OffsetDateTime start = startDate.atStartOfDay(ZONE_CST).toOffsetDateTime();
|
||||
OffsetDateTime end = today.plusDays(1).atStartOfDay(ZONE_CST).toOffsetDateTime();
|
||||
return fillRefundTrendDays(startDate, safeDays, tradeReportMapper.refundTrend(start, end));
|
||||
}
|
||||
|
||||
/// 退款趋势(自定义区间)
|
||||
public List<RefundTrendItemResult> refundTrend(String startStr, String endStr) {
|
||||
OffsetDateTime start = parseDateStart(startStr);
|
||||
OffsetDateTime end = parseDateEndExclusive(endStr);
|
||||
long days = java.time.Duration.between(start, end).toDays();
|
||||
int safeDays = clampDays((int) days);
|
||||
LocalDate startDate = start.atZoneSameInstant(ZONE_CST).toLocalDate();
|
||||
return fillRefundTrendDays(startDate, safeDays, tradeReportMapper.refundTrend(start, end));
|
||||
}
|
||||
|
||||
// ===== 渠道分布 =====
|
||||
|
||||
/// 支付渠道分布(指定天数, 各支付渠道成交金额与笔数)
|
||||
public List<ProviderDistItemResult> providerDist(int days) {
|
||||
OffsetDateTime[] range = daysRange(days);
|
||||
return tradeReportMapper.providerDist(range[0], range[1]);
|
||||
}
|
||||
|
||||
/// 支付渠道分布(自定义区间)
|
||||
public List<ProviderDistItemResult> providerDist(String startStr, String endStr) {
|
||||
return tradeReportMapper.providerDist(parseDateStart(startStr), parseDateEndExclusive(endStr));
|
||||
}
|
||||
|
||||
/// 支付渠道成功率(指定天数)
|
||||
public List<ProviderSuccessItemResult> providerSuccess(int days) {
|
||||
OffsetDateTime[] range = daysRange(days);
|
||||
return tradeReportMapper.providerSuccess(range[0], range[1]);
|
||||
}
|
||||
|
||||
/// 支付渠道成功率(自定义区间)
|
||||
public List<ProviderSuccessItemResult> providerSuccess(String startStr, String endStr) {
|
||||
return tradeReportMapper.providerSuccess(parseDateStart(startStr), parseDateEndExclusive(endStr));
|
||||
}
|
||||
|
||||
// ===== 时段分布 =====
|
||||
|
||||
/// 时段分布(日均, 指定天数): 区间内按小时汇总后 ÷ 天数, 补齐 0-23
|
||||
public List<HourlyDistItemResult> hourlyDist(int days) {
|
||||
OffsetDateTime[] range = daysRange(days);
|
||||
int daysSpan = clampDays(days);
|
||||
return toDailyAverage(fillHourlyDist(tradeReportMapper.hourlyDist(range[0], range[1])), daysSpan);
|
||||
}
|
||||
|
||||
/// 时段分布(日均, 自定义区间): 区间内按小时汇总后 ÷ 天数, 补齐 0-23
|
||||
public List<HourlyDistItemResult> hourlyDist(String startStr, String endStr) {
|
||||
OffsetDateTime start = parseDateStart(startStr);
|
||||
OffsetDateTime end = parseDateEndExclusive(endStr);
|
||||
long daysSpan = Math.max(1L, java.time.Duration.between(start, end).toDays());
|
||||
return toDailyAverage(fillHourlyDist(tradeReportMapper.hourlyDist(start, end)), daysSpan);
|
||||
}
|
||||
|
||||
// ===== 金额区间分桶 =====
|
||||
|
||||
/// 金额区间分桶(指定天数), 补齐 5 个缺失桶
|
||||
public List<AmountRangeItemResult> amountRange(int days) {
|
||||
OffsetDateTime[] range = daysRange(days);
|
||||
return fillAmountRange(tradeReportMapper.amountRange(range[0], range[1]));
|
||||
}
|
||||
|
||||
/// 金额区间分桶(自定义区间)
|
||||
public List<AmountRangeItemResult> amountRange(String startStr, String endStr) {
|
||||
return fillAmountRange(tradeReportMapper.amountRange(parseDateStart(startStr), parseDateEndExclusive(endStr)));
|
||||
}
|
||||
|
||||
// ===== 商户排名 =====
|
||||
|
||||
/// 商户交易额排名(指定天数, 含占比计算)
|
||||
///
|
||||
/// @param days 近 N 天(含今天)
|
||||
/// @param limit 返回前 N 名(默认 10, 上限 50)
|
||||
public List<MerchantRankItemResult> merchantRank(int days, int limit) {
|
||||
OffsetDateTime[] range = daysRange(days);
|
||||
return computeProportion(tradeReportMapper.merchantRank(range[0], range[1], clampLimit(limit)));
|
||||
}
|
||||
|
||||
/// 商户交易额排名(自定义区间)
|
||||
public List<MerchantRankItemResult> merchantRank(String startStr, String endStr, int limit) {
|
||||
return computeProportion(tradeReportMapper.merchantRank(
|
||||
parseDateStart(startStr), parseDateEndExclusive(endStr), clampLimit(limit)));
|
||||
}
|
||||
|
||||
// ===== 内部工具 =====
|
||||
|
||||
/// 钳制天数到 [1, 365]
|
||||
private int clampDays(int days) {
|
||||
return Math.max(TREND_DAYS_MIN, Math.min(days, TREND_DAYS_MAX));
|
||||
}
|
||||
|
||||
/// 钳制商户排名 limit 到 [1, 50], 默认 10
|
||||
private int clampLimit(int limit) {
|
||||
if (limit <= 0) {
|
||||
return MERCHANT_RANK_LIMIT_DEFAULT;
|
||||
}
|
||||
return Math.min(limit, MERCHANT_RANK_LIMIT_MAX);
|
||||
}
|
||||
|
||||
/// 近 N 天(含今天)的 [start, end) 区间
|
||||
private OffsetDateTime[] daysRange(int days) {
|
||||
int safeDays = clampDays(days);
|
||||
LocalDate today = LocalDate.now(ZONE_CST);
|
||||
OffsetDateTime start = today.minusDays(safeDays - 1L).atStartOfDay(ZONE_CST).toOffsetDateTime();
|
||||
OffsetDateTime end = today.plusDays(1).atStartOfDay(ZONE_CST).toOffsetDateTime();
|
||||
return new OffsetDateTime[]{start, end};
|
||||
}
|
||||
|
||||
/// 解析 yyyy-MM-dd 为该日 00:00:00 Asia/Shanghai 的 OffsetDateTime
|
||||
private OffsetDateTime parseDateStart(String date) {
|
||||
return LocalDate.parse(date, DATE_FMT).atStartOfDay(ZONE_CST).toOffsetDateTime();
|
||||
}
|
||||
|
||||
/// 解析 yyyy-MM-dd 为次日 00:00:00 Asia/Shanghai 的 OffsetDateTime (半开区间 end)
|
||||
private OffsetDateTime parseDateEndExclusive(String date) {
|
||||
return LocalDate.parse(date, DATE_FMT).plusDays(1).atStartOfDay(ZONE_CST).toOffsetDateTime();
|
||||
}
|
||||
|
||||
/// 趋势补零: 对完整日期区间补齐缺失日(amount=0, count=0)
|
||||
private List<TradeTrendItemResult> fillTrendDays(LocalDate startDate, int days, List<TradeTrendItemResult> rows) {
|
||||
Map<String, TradeTrendItemResult> indexed = new HashMap<>(rows.size());
|
||||
for (TradeTrendItemResult row : rows) {
|
||||
indexed.put(row.getDate(), row);
|
||||
}
|
||||
List<TradeTrendItemResult> result = new ArrayList<>(days);
|
||||
for (int i = 0; i < days; i++) {
|
||||
String key = startDate.plusDays(i).format(DATE_FMT);
|
||||
TradeTrendItemResult row = indexed.get(key);
|
||||
if (row != null) {
|
||||
if (row.getCount() == null) {
|
||||
row.setCount(0L);
|
||||
}
|
||||
result.add(row);
|
||||
} else {
|
||||
result.add(new TradeTrendItemResult().setDate(key).setAmount(0L).setCount(0L));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 退款趋势补零
|
||||
private List<RefundTrendItemResult> fillRefundTrendDays(LocalDate startDate, int days, List<RefundTrendItemResult> rows) {
|
||||
Map<String, RefundTrendItemResult> indexed = new HashMap<>(rows.size());
|
||||
for (RefundTrendItemResult row : rows) {
|
||||
indexed.put(row.getDate(), row);
|
||||
}
|
||||
List<RefundTrendItemResult> result = new ArrayList<>(days);
|
||||
for (int i = 0; i < days; i++) {
|
||||
String key = startDate.plusDays(i).format(DATE_FMT);
|
||||
RefundTrendItemResult row = indexed.get(key);
|
||||
if (row != null) {
|
||||
if (row.getCount() == null) {
|
||||
row.setCount(0L);
|
||||
}
|
||||
result.add(row);
|
||||
} else {
|
||||
result.add(new RefundTrendItemResult().setDate(key).setAmount(0L).setCount(0L));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 时段分布补齐 0-23 缺失小时(此时仍为区间合计, 日均见 [toDailyAverage])
|
||||
private List<HourlyDistItemResult> fillHourlyDist(List<HourlyDistItemResult> rows) {
|
||||
Map<Integer, HourlyDistItemResult> indexed = new HashMap<>(rows.size());
|
||||
for (HourlyDistItemResult row : rows) {
|
||||
indexed.put(row.getHour(), row);
|
||||
}
|
||||
List<HourlyDistItemResult> result = new ArrayList<>(24);
|
||||
for (int h = 0; h < 24; h++) {
|
||||
HourlyDistItemResult row = indexed.get(h);
|
||||
if (row != null) {
|
||||
if (row.getCount() == null) {
|
||||
row.setCount(0D);
|
||||
}
|
||||
if (row.getAmount() == null) {
|
||||
row.setAmount(0L);
|
||||
}
|
||||
result.add(row);
|
||||
} else {
|
||||
result.add(new HourlyDistItemResult().setHour(h).setAmount(0L).setCount(0D));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 将区间内按小时汇总的金额/笔数转为日均(天数至少为 1)
|
||||
/// 笔数保留 1 位小数; 金额(分)四舍五入为 Long
|
||||
private List<HourlyDistItemResult> toDailyAverage(List<HourlyDistItemResult> rows, long daysSpan) {
|
||||
long days = Math.max(1L, daysSpan);
|
||||
for (HourlyDistItemResult row : rows) {
|
||||
long amount = row.getAmount() == null ? 0L : row.getAmount();
|
||||
double count = row.getCount() == null ? 0D : row.getCount();
|
||||
row.setAmount(Math.round((double) amount / days));
|
||||
row.setCount(Math.round(count * 10.0 / days) / 10.0);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// 金额区间补齐 5 个缺失桶(保持固定顺序)
|
||||
private List<AmountRangeItemResult> fillAmountRange(List<AmountRangeItemResult> rows) {
|
||||
Map<String, Long> indexed = new HashMap<>(rows.size());
|
||||
for (AmountRangeItemResult row : rows) {
|
||||
indexed.put(row.getBucket(), row.getCount());
|
||||
}
|
||||
List<AmountRangeItemResult> result = new ArrayList<>(AMOUNT_BUCKETS.size());
|
||||
for (String bucket : AMOUNT_BUCKETS) {
|
||||
result.add(new AmountRangeItemResult()
|
||||
.setBucket(bucket)
|
||||
.setCount(indexed.getOrDefault(bucket, 0L)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 商户排名占比计算(基于本期返回的 amount 总和, 非全平台总额; 取 top N 内部相对占比)
|
||||
/// 注意: 这只是 top N 内部的相对占比, 分母为 top N 总额, 不是全平台总额
|
||||
private List<MerchantRankItemResult> computeProportion(List<MerchantRankItemResult> rows) {
|
||||
long total = rows.stream().mapToLong(MerchantRankItemResult::getAmount).sum();
|
||||
for (MerchantRankItemResult row : rows) {
|
||||
double proportion = total > 0
|
||||
? Math.round(row.getAmount() * 1000.0 / total) / 10.0
|
||||
: 0.0;
|
||||
row.setProportion(proportion);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package cn.daxpay.open.payment.trade.report.result;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
/// # 商户端维度交易额排名单项
|
||||
///
|
||||
/// 在本商户下按通道商户 / 应用 / 门店聚合; 金额单位分。
|
||||
/// proportion 由 Service 按返回列表总额计算。
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
@Schema(title = "维度交易额排名单项")
|
||||
public class DimRankItemResult {
|
||||
|
||||
/// 维度键(channelMchNo / appId / storeNo; 未指定门店为空串)
|
||||
@Schema(description = "维度键")
|
||||
private String dimKey;
|
||||
|
||||
/// 展示名(JOIN 得到; 门店未指定时为 null, 由前端 i18n)
|
||||
@Schema(description = "展示名")
|
||||
private String dimName;
|
||||
|
||||
/// 成交金额(分)
|
||||
@Schema(description = "成交金额(分)")
|
||||
private Long amount;
|
||||
|
||||
/// 成交笔数
|
||||
@Schema(description = "成交笔数")
|
||||
private Long orders;
|
||||
|
||||
/// 占比百分比(0-100, 保留 1 位小数)
|
||||
@Schema(description = "占比百分比")
|
||||
private Double proportion;
|
||||
}
|
||||
@@ -14,8 +14,8 @@ import lombok.experimental.Accessors;
|
||||
/// - **上期字段**(`prev*`): 由与本区间等长的前一区间聚合, 前端用
|
||||
/// `(curr - prev) / prev * 100` 计算环比百分比; 上期无数据(prevCount=0)时前端不应展示环比
|
||||
///
|
||||
/// 由 [cn.daxpay.open.payment.trade.report.dao.TradeReportMapper] 的成交/退款两个查询分别填充
|
||||
/// 各自一半字段, Service 合并.
|
||||
/// 由运营/商户端各自 TradeReportMapper 的成交/退款/总笔数查询分别填充
|
||||
/// 各自一半字段, 再由对应 DashboardTradeService 合并.
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
@Schema(title = "交易概览统计")
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
package cn.daxpay.open.payment.trade.report.support;
|
||||
|
||||
import cn.daxpay.open.payment.trade.report.result.AmountRangeItemResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.DimRankItemResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.HourlyDistItemResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.MerchantRankItemResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.RefundTrendItemResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.TradeTrendItemResult;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/// # 交易报表共享工具
|
||||
///
|
||||
/// 日期区间(CST 日界线)、趋势/时段/分桶补零、排名占比计算。无 Mapper 依赖, 供运营/商户端 Service 共用。
|
||||
@Component
|
||||
public class TradeReportSupport {
|
||||
|
||||
public static final ZoneId ZONE_CST = ZoneId.of("Asia/Shanghai");
|
||||
public static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
|
||||
private static final int TREND_DAYS_MIN = 1;
|
||||
private static final int TREND_DAYS_MAX = 365;
|
||||
|
||||
private static final int RANK_LIMIT_MAX = 50;
|
||||
private static final int RANK_LIMIT_DEFAULT = 10;
|
||||
|
||||
private static final List<String> AMOUNT_BUCKETS = List.of("0-50", "50-200", "200-1000", "1000-5000", "5000+");
|
||||
|
||||
/// 钳制天数到 [1, 365]
|
||||
public int clampDays(int days) {
|
||||
return Math.max(TREND_DAYS_MIN, Math.min(days, TREND_DAYS_MAX));
|
||||
}
|
||||
|
||||
/// 钳制排名条数; `<=0` 时用默认 10, 上限 50
|
||||
public int clampLimit(int limit) {
|
||||
if (limit <= 0) {
|
||||
return RANK_LIMIT_DEFAULT;
|
||||
}
|
||||
return Math.min(limit, RANK_LIMIT_MAX);
|
||||
}
|
||||
|
||||
/// 近 N 天(含今天)半开区间 `[start, end)`
|
||||
public OffsetDateTime[] daysRange(int days) {
|
||||
int safeDays = clampDays(days);
|
||||
LocalDate today = LocalDate.now(ZONE_CST);
|
||||
OffsetDateTime start = today.minusDays(safeDays - 1L).atStartOfDay(ZONE_CST).toOffsetDateTime();
|
||||
OffsetDateTime end = today.plusDays(1).atStartOfDay(ZONE_CST).toOffsetDateTime();
|
||||
return new OffsetDateTime[]{start, end};
|
||||
}
|
||||
|
||||
/// 解析 yyyy-MM-dd 为当日 00:00 CST
|
||||
public OffsetDateTime parseDateStart(String date) {
|
||||
return LocalDate.parse(date, DATE_FMT).atStartOfDay(ZONE_CST).toOffsetDateTime();
|
||||
}
|
||||
|
||||
/// 解析 yyyy-MM-dd 为次日 00:00 CST(半开区间上界)
|
||||
public OffsetDateTime parseDateEndExclusive(String date) {
|
||||
return LocalDate.parse(date, DATE_FMT).plusDays(1).atStartOfDay(ZONE_CST).toOffsetDateTime();
|
||||
}
|
||||
|
||||
/// 交易趋势按日补零
|
||||
public List<TradeTrendItemResult> fillTrendDays(LocalDate startDate, int days, List<TradeTrendItemResult> rows) {
|
||||
Map<String, TradeTrendItemResult> indexed = new HashMap<>(rows.size());
|
||||
for (TradeTrendItemResult row : rows) {
|
||||
indexed.put(row.getDate(), row);
|
||||
}
|
||||
List<TradeTrendItemResult> result = new ArrayList<>(days);
|
||||
for (int i = 0; i < days; i++) {
|
||||
String key = startDate.plusDays(i).format(DATE_FMT);
|
||||
TradeTrendItemResult row = indexed.get(key);
|
||||
if (row != null) {
|
||||
if (row.getCount() == null) {
|
||||
row.setCount(0L);
|
||||
}
|
||||
result.add(row);
|
||||
} else {
|
||||
result.add(new TradeTrendItemResult().setDate(key).setAmount(0L).setCount(0L));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 退款趋势按日补零
|
||||
public List<RefundTrendItemResult> fillRefundTrendDays(LocalDate startDate, int days, List<RefundTrendItemResult> rows) {
|
||||
Map<String, RefundTrendItemResult> indexed = new HashMap<>(rows.size());
|
||||
for (RefundTrendItemResult row : rows) {
|
||||
indexed.put(row.getDate(), row);
|
||||
}
|
||||
List<RefundTrendItemResult> result = new ArrayList<>(days);
|
||||
for (int i = 0; i < days; i++) {
|
||||
String key = startDate.plusDays(i).format(DATE_FMT);
|
||||
RefundTrendItemResult row = indexed.get(key);
|
||||
if (row != null) {
|
||||
if (row.getCount() == null) {
|
||||
row.setCount(0L);
|
||||
}
|
||||
result.add(row);
|
||||
} else {
|
||||
result.add(new RefundTrendItemResult().setDate(key).setAmount(0L).setCount(0L));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 时段分布补齐 0-23
|
||||
public List<HourlyDistItemResult> fillHourlyDist(List<HourlyDistItemResult> rows) {
|
||||
Map<Integer, HourlyDistItemResult> indexed = new HashMap<>(rows.size());
|
||||
for (HourlyDistItemResult row : rows) {
|
||||
indexed.put(row.getHour(), row);
|
||||
}
|
||||
List<HourlyDistItemResult> result = new ArrayList<>(24);
|
||||
for (int h = 0; h < 24; h++) {
|
||||
HourlyDistItemResult row = indexed.get(h);
|
||||
if (row != null) {
|
||||
if (row.getCount() == null) {
|
||||
row.setCount(0D);
|
||||
}
|
||||
if (row.getAmount() == null) {
|
||||
row.setAmount(0L);
|
||||
}
|
||||
result.add(row);
|
||||
} else {
|
||||
result.add(new HourlyDistItemResult().setHour(h).setAmount(0L).setCount(0D));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 时段汇总转为日均(金额四舍五入到分, 笔数保留一位小数)
|
||||
public List<HourlyDistItemResult> toDailyAverage(List<HourlyDistItemResult> rows, long daysSpan) {
|
||||
long days = Math.max(1L, daysSpan);
|
||||
for (HourlyDistItemResult row : rows) {
|
||||
long amount = row.getAmount() == null ? 0L : row.getAmount();
|
||||
double count = row.getCount() == null ? 0D : row.getCount();
|
||||
row.setAmount(Math.round((double) amount / days));
|
||||
row.setCount(Math.round(count * 10.0 / days) / 10.0);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// 金额区间按固定桶序补零
|
||||
public List<AmountRangeItemResult> fillAmountRange(List<AmountRangeItemResult> rows) {
|
||||
Map<String, Long> indexed = new HashMap<>(rows.size());
|
||||
for (AmountRangeItemResult row : rows) {
|
||||
indexed.put(row.getBucket(), row.getCount());
|
||||
}
|
||||
List<AmountRangeItemResult> result = new ArrayList<>(AMOUNT_BUCKETS.size());
|
||||
for (String bucket : AMOUNT_BUCKETS) {
|
||||
result.add(new AmountRangeItemResult()
|
||||
.setBucket(bucket)
|
||||
.setCount(indexed.getOrDefault(bucket, 0L)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 运营端商户排名占比(一位小数百分比)
|
||||
public List<MerchantRankItemResult> computeMerchantProportion(List<MerchantRankItemResult> rows) {
|
||||
long total = rows.stream().mapToLong(MerchantRankItemResult::getAmount).sum();
|
||||
for (MerchantRankItemResult row : rows) {
|
||||
double proportion = total > 0
|
||||
? Math.round(row.getAmount() * 1000.0 / total) / 10.0
|
||||
: 0.0;
|
||||
row.setProportion(proportion);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// 商户端维度排名占比
|
||||
public List<DimRankItemResult> computeDimProportion(List<DimRankItemResult> rows) {
|
||||
long total = rows.stream()
|
||||
.mapToLong(r -> r.getAmount() == null ? 0L : r.getAmount())
|
||||
.sum();
|
||||
for (DimRankItemResult row : rows) {
|
||||
long amount = row.getAmount() == null ? 0L : row.getAmount();
|
||||
double proportion = total > 0
|
||||
? Math.round(amount * 1000.0 / total) / 10.0
|
||||
: 0.0;
|
||||
row.setProportion(proportion);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package cn.daxpay.open.payment.merchant.controller.dashboard;
|
||||
|
||||
import cn.daxpay.open.platform.core.rest.Res;
|
||||
import cn.daxpay.open.platform.core.rest.result.Result;
|
||||
import cn.daxpay.open.payment.merchant.result.dashboard.MchDashboardHeaderCountResult;
|
||||
import cn.daxpay.open.payment.merchant.service.dashboard.MchDashboardTradeService;
|
||||
import cn.daxpay.open.payment.trade.report.result.AmountRangeItemResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.DimRankItemResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.HourlyDistItemResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.ProviderDistItemResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.ProviderSuccessItemResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.RefundTrendItemResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.TradeOverviewResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.TradeTrendItemResult;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/// # 工作台/分析页交易统计(商户端)
|
||||
///
|
||||
/// 参数约定与运营端 `/admin/dashboard/trade` 对齐; 数据强制按当前登录商户隔离。
|
||||
@Tag(name = "工作台/分析页交易统计(商户端)")
|
||||
@Validated
|
||||
@RestController
|
||||
@RequestMapping("/mch/dashboard/trade")
|
||||
@RequiredArgsConstructor
|
||||
public class MchDashboardTradeController {
|
||||
|
||||
private final MchDashboardTradeService mchDashboardTradeService;
|
||||
|
||||
@Operation(summary = "工作台头部计数(应用/门店/通道商户)")
|
||||
@GetMapping("/header-counts")
|
||||
public Result<MchDashboardHeaderCountResult> headerCounts() {
|
||||
return Res.ok(mchDashboardTradeService.headerCounts());
|
||||
}
|
||||
|
||||
@Operation(summary = "交易概览(今日/昨日或自定义区间)")
|
||||
@GetMapping("/overview")
|
||||
public Result<TradeOverviewResult> overview(
|
||||
@RequestParam(required = false) String date,
|
||||
@RequestParam(required = false) String start,
|
||||
@RequestParam(required = false) String end) {
|
||||
if (start != null && end != null) {
|
||||
return Res.ok(mchDashboardTradeService.overview(start, end));
|
||||
}
|
||||
return Res.ok(mchDashboardTradeService.overview(date != null ? date : "today"));
|
||||
}
|
||||
|
||||
@Operation(summary = "交易趋势")
|
||||
@GetMapping("/trend")
|
||||
public Result<List<TradeTrendItemResult>> trend(
|
||||
@RequestParam(defaultValue = "7") int days,
|
||||
@RequestParam(required = false) String start,
|
||||
@RequestParam(required = false) String end) {
|
||||
if (start != null && end != null) {
|
||||
return Res.ok(mchDashboardTradeService.trend(start, end));
|
||||
}
|
||||
return Res.ok(mchDashboardTradeService.trend(days));
|
||||
}
|
||||
|
||||
@Operation(summary = "退款趋势")
|
||||
@GetMapping("/refund-trend")
|
||||
public Result<List<RefundTrendItemResult>> refundTrend(
|
||||
@RequestParam(defaultValue = "7") int days,
|
||||
@RequestParam(required = false) String start,
|
||||
@RequestParam(required = false) String end) {
|
||||
if (start != null && end != null) {
|
||||
return Res.ok(mchDashboardTradeService.refundTrend(start, end));
|
||||
}
|
||||
return Res.ok(mchDashboardTradeService.refundTrend(days));
|
||||
}
|
||||
|
||||
@Operation(summary = "支付渠道分布")
|
||||
@GetMapping("/provider-dist")
|
||||
public Result<List<ProviderDistItemResult>> providerDist(
|
||||
@RequestParam(defaultValue = "30") int days,
|
||||
@RequestParam(required = false) String start,
|
||||
@RequestParam(required = false) String end) {
|
||||
if (start != null && end != null) {
|
||||
return Res.ok(mchDashboardTradeService.providerDist(start, end));
|
||||
}
|
||||
return Res.ok(mchDashboardTradeService.providerDist(days));
|
||||
}
|
||||
|
||||
@Operation(summary = "支付渠道成功率")
|
||||
@GetMapping("/provider-success")
|
||||
public Result<List<ProviderSuccessItemResult>> providerSuccess(
|
||||
@RequestParam(defaultValue = "30") int days,
|
||||
@RequestParam(required = false) String start,
|
||||
@RequestParam(required = false) String end) {
|
||||
if (start != null && end != null) {
|
||||
return Res.ok(mchDashboardTradeService.providerSuccess(start, end));
|
||||
}
|
||||
return Res.ok(mchDashboardTradeService.providerSuccess(days));
|
||||
}
|
||||
|
||||
@Operation(summary = "时段分布(日均)")
|
||||
@GetMapping("/hourly-dist")
|
||||
public Result<List<HourlyDistItemResult>> hourlyDist(
|
||||
@RequestParam(defaultValue = "7") int days,
|
||||
@RequestParam(required = false) String start,
|
||||
@RequestParam(required = false) String end) {
|
||||
if (start != null && end != null) {
|
||||
return Res.ok(mchDashboardTradeService.hourlyDist(start, end));
|
||||
}
|
||||
return Res.ok(mchDashboardTradeService.hourlyDist(days));
|
||||
}
|
||||
|
||||
@Operation(summary = "金额区间分桶")
|
||||
@GetMapping("/amount-range")
|
||||
public Result<List<AmountRangeItemResult>> amountRange(
|
||||
@RequestParam(defaultValue = "7") int days,
|
||||
@RequestParam(required = false) String start,
|
||||
@RequestParam(required = false) String end) {
|
||||
if (start != null && end != null) {
|
||||
return Res.ok(mchDashboardTradeService.amountRange(start, end));
|
||||
}
|
||||
return Res.ok(mchDashboardTradeService.amountRange(days));
|
||||
}
|
||||
|
||||
/// 维度排行: dim=channelMch|app|store
|
||||
@Operation(summary = "维度交易额排名(通道商户/应用/门店)")
|
||||
@GetMapping("/dim-rank")
|
||||
public Result<List<DimRankItemResult>> dimRank(
|
||||
@RequestParam(defaultValue = "app") String dim,
|
||||
@RequestParam(defaultValue = "7") int days,
|
||||
@RequestParam(defaultValue = "10") int limit,
|
||||
@RequestParam(required = false) String start,
|
||||
@RequestParam(required = false) String end) {
|
||||
if (start != null && end != null) {
|
||||
return Res.ok(mchDashboardTradeService.dimRank(dim, start, end, limit));
|
||||
}
|
||||
return Res.ok(mchDashboardTradeService.dimRank(dim, days, limit));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package cn.daxpay.open.payment.merchant.controller.trade;
|
||||
|
||||
import cn.daxpay.open.platform.core.rest.Res;
|
||||
import cn.daxpay.open.platform.core.rest.param.PageParam;
|
||||
import cn.daxpay.open.platform.core.rest.result.PageResult;
|
||||
import cn.daxpay.open.platform.core.rest.result.Result;
|
||||
import cn.daxpay.open.payment.merchant.service.trade.MchPayTradeService;
|
||||
import cn.daxpay.open.payment.trade.order.param.PayTradeQuery;
|
||||
import cn.daxpay.open.payment.trade.order.result.PayTradeResult;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/// # 资金交易凭证(商户端)
|
||||
///
|
||||
/// 工作台「最近资金交易」等只读列表;完整订单管理页后续再挂菜单。
|
||||
@Validated
|
||||
@Tag(name = "资金交易凭证(商户端)")
|
||||
@RestController
|
||||
@RequestMapping("/mch/order/pay-trade")
|
||||
@RequiredArgsConstructor
|
||||
public class MchPayTradeController {
|
||||
|
||||
private final MchPayTradeService mchPayTradeService;
|
||||
|
||||
@Operation(summary = "资金交易凭证分页")
|
||||
@GetMapping("/page")
|
||||
public Result<PageResult<PayTradeResult>> page(PageParam pageParam, PayTradeQuery query) {
|
||||
return Res.ok(mchPayTradeService.page(pageParam, query));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package cn.daxpay.open.payment.merchant.convert.trade;
|
||||
|
||||
import cn.daxpay.open.payment.trade.order.entity.PayTrade;
|
||||
import cn.daxpay.open.payment.trade.order.result.PayTradeResult;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.ReportingPolicy;
|
||||
import org.mapstruct.factory.Mappers;
|
||||
|
||||
/// # 资金交易凭证转换器(商户端)
|
||||
///
|
||||
/// 列表页同名映射;详情联表由后续订单页再补
|
||||
@Mapper(unmappedTargetPolicy = ReportingPolicy.IGNORE)
|
||||
public interface MchPayTradeConvert {
|
||||
|
||||
MchPayTradeConvert CONVERT = Mappers.getMapper(MchPayTradeConvert.class);
|
||||
|
||||
/// PayTrade → Result(工作台最近交易列表)
|
||||
PayTradeResult toResult(PayTrade entity);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package cn.daxpay.open.payment.merchant.dao.dashboard;
|
||||
|
||||
import cn.daxpay.open.payment.trade.order.entity.PayTrade;
|
||||
import cn.daxpay.open.payment.trade.report.result.AmountRangeItemResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.DimRankItemResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.HourlyDistItemResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.ProviderDistItemResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.ProviderSuccessItemResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.RefundTrendItemResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.TradeOverviewResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.TradeTrendItemResult;
|
||||
import com.github.yulichang.base.MPJBaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/// # 商户端交易聚合统计 Mapper
|
||||
///
|
||||
/// 强制 `AND mch_no = #{mchNo}`, 禁止可空全平台语义。含维度排名; 不含运营端商户排名。
|
||||
///
|
||||
/// ## 成交口径
|
||||
/// 以 `pay_trade` 资金凭证为准, 资金态 SUCCESS 且 `posted_amount > 0` 才计入成交。
|
||||
///
|
||||
/// ## 金额单位
|
||||
/// 所有金额字段为分(最小货币单位), SUM 结果 `::bigint` 强转确保映射为 Java Long。
|
||||
@Mapper
|
||||
public interface MchTradeReportMapper extends MPJBaseMapper<PayTrade> {
|
||||
|
||||
// ===== 概览 =====
|
||||
|
||||
/// 成交聚合: 按 pay_time 过滤, 填充 success_amount / success_count
|
||||
@Select("SELECT COALESCE(SUM(posted_amount), 0)::bigint AS success_amount, "
|
||||
+ "COUNT(*)::bigint AS success_count "
|
||||
+ "FROM pay_trade "
|
||||
+ "WHERE status = 'success' AND posted_amount > 0 "
|
||||
+ "AND pay_time >= #{start} AND pay_time < #{end} "
|
||||
+ "AND mch_no = #{mchNo}")
|
||||
TradeOverviewResult successAggregate(
|
||||
@Param("start") OffsetDateTime start,
|
||||
@Param("end") OffsetDateTime end,
|
||||
@Param("mchNo") String mchNo);
|
||||
|
||||
/// 总下单笔数聚合: 按 create_time 过滤, 填充 total_orders
|
||||
@Select("SELECT COUNT(*)::bigint AS total_orders "
|
||||
+ "FROM pay_trade "
|
||||
+ "WHERE create_time >= #{start} AND create_time < #{end} "
|
||||
+ "AND mch_no = #{mchNo}")
|
||||
TradeOverviewResult totalOrdersAggregate(
|
||||
@Param("start") OffsetDateTime start,
|
||||
@Param("end") OffsetDateTime end,
|
||||
@Param("mchNo") String mchNo);
|
||||
|
||||
/// 退款聚合
|
||||
@Select("SELECT COALESCE(SUM(amount), 0)::bigint AS refund_amount, "
|
||||
+ "COUNT(*)::bigint AS refund_count "
|
||||
+ "FROM pay_refund_order "
|
||||
+ "WHERE status = 'success' "
|
||||
+ "AND finish_time >= #{start} AND finish_time < #{end} "
|
||||
+ "AND mch_no = #{mchNo}")
|
||||
TradeOverviewResult refundAggregate(
|
||||
@Param("start") OffsetDateTime start,
|
||||
@Param("end") OffsetDateTime end,
|
||||
@Param("mchNo") String mchNo);
|
||||
|
||||
// ===== 趋势 =====
|
||||
|
||||
@Select("SELECT TO_CHAR(pay_time AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') AS date, "
|
||||
+ "COALESCE(SUM(posted_amount), 0)::bigint AS amount, "
|
||||
+ "COUNT(*)::bigint AS count "
|
||||
+ "FROM pay_trade "
|
||||
+ "WHERE status = 'success' AND posted_amount > 0 "
|
||||
+ "AND pay_time >= #{start} AND pay_time < #{end} "
|
||||
+ "AND mch_no = #{mchNo} "
|
||||
+ "GROUP BY 1 ORDER BY 1")
|
||||
List<TradeTrendItemResult> trend(
|
||||
@Param("start") OffsetDateTime start,
|
||||
@Param("end") OffsetDateTime end,
|
||||
@Param("mchNo") String mchNo);
|
||||
|
||||
@Select("SELECT TO_CHAR(finish_time AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') AS date, "
|
||||
+ "COALESCE(SUM(amount), 0)::bigint AS amount, "
|
||||
+ "COUNT(*)::bigint AS count "
|
||||
+ "FROM pay_refund_order "
|
||||
+ "WHERE status = 'success' "
|
||||
+ "AND finish_time >= #{start} AND finish_time < #{end} "
|
||||
+ "AND mch_no = #{mchNo} "
|
||||
+ "GROUP BY 1 ORDER BY 1")
|
||||
List<RefundTrendItemResult> refundTrend(
|
||||
@Param("start") OffsetDateTime start,
|
||||
@Param("end") OffsetDateTime end,
|
||||
@Param("mchNo") String mchNo);
|
||||
|
||||
// ===== 渠道分布 =====
|
||||
|
||||
@Select("SELECT provider, "
|
||||
+ "COALESCE(SUM(posted_amount), 0)::bigint AS amount, "
|
||||
+ "COUNT(*)::bigint AS count "
|
||||
+ "FROM pay_trade "
|
||||
+ "WHERE status = 'success' AND posted_amount > 0 "
|
||||
+ "AND provider IS NOT NULL "
|
||||
+ "AND pay_time >= #{start} AND pay_time < #{end} "
|
||||
+ "AND mch_no = #{mchNo} "
|
||||
+ "GROUP BY provider ORDER BY amount DESC")
|
||||
List<ProviderDistItemResult> providerDist(
|
||||
@Param("start") OffsetDateTime start,
|
||||
@Param("end") OffsetDateTime end,
|
||||
@Param("mchNo") String mchNo);
|
||||
|
||||
@Select("SELECT provider, "
|
||||
+ "ROUND(COUNT(*) FILTER (WHERE status = 'success' AND posted_amount > 0)::numeric "
|
||||
+ " / NULLIF(COUNT(*), 0) * 100, 1) AS rate "
|
||||
+ "FROM pay_trade "
|
||||
+ "WHERE provider IS NOT NULL "
|
||||
+ "AND create_time >= #{start} AND create_time < #{end} "
|
||||
+ "AND mch_no = #{mchNo} "
|
||||
+ "GROUP BY provider "
|
||||
+ "HAVING COUNT(*) > 0 "
|
||||
+ "ORDER BY rate DESC")
|
||||
List<ProviderSuccessItemResult> providerSuccess(
|
||||
@Param("start") OffsetDateTime start,
|
||||
@Param("end") OffsetDateTime end,
|
||||
@Param("mchNo") String mchNo);
|
||||
|
||||
// ===== 时段分布 =====
|
||||
|
||||
@Select("SELECT EXTRACT(HOUR FROM pay_time AT TIME ZONE 'Asia/Shanghai')::int AS hour, "
|
||||
+ "COALESCE(SUM(posted_amount), 0)::bigint AS amount, "
|
||||
+ "COUNT(*)::bigint AS count "
|
||||
+ "FROM pay_trade "
|
||||
+ "WHERE status = 'success' AND posted_amount > 0 "
|
||||
+ "AND pay_time >= #{start} AND pay_time < #{end} "
|
||||
+ "AND mch_no = #{mchNo} "
|
||||
+ "GROUP BY 1 ORDER BY 1")
|
||||
List<HourlyDistItemResult> hourlyDist(
|
||||
@Param("start") OffsetDateTime start,
|
||||
@Param("end") OffsetDateTime end,
|
||||
@Param("mchNo") String mchNo);
|
||||
|
||||
// ===== 金额区间分桶 =====
|
||||
|
||||
@Select("SELECT CASE "
|
||||
+ "WHEN amount < 5000 THEN '0-50' "
|
||||
+ "WHEN amount < 20000 THEN '50-200' "
|
||||
+ "WHEN amount < 100000 THEN '200-1000' "
|
||||
+ "WHEN amount < 500000 THEN '1000-5000' "
|
||||
+ "ELSE '5000+' END AS bucket, "
|
||||
+ "COUNT(*)::bigint AS count "
|
||||
+ "FROM pay_trade "
|
||||
+ "WHERE status = 'success' AND posted_amount > 0 "
|
||||
+ "AND pay_time >= #{start} AND pay_time < #{end} "
|
||||
+ "AND mch_no = #{mchNo} "
|
||||
+ "GROUP BY 1")
|
||||
List<AmountRangeItemResult> amountRange(
|
||||
@Param("start") OffsetDateTime start,
|
||||
@Param("end") OffsetDateTime end,
|
||||
@Param("mchNo") String mchNo);
|
||||
|
||||
// ===== 维度排名 =====
|
||||
|
||||
/// 按通道商户号分组排名
|
||||
@Select("SELECT t.channel_mch_no AS dimKey, "
|
||||
+ "COALESCE(cm.channel_merchant_name, t.channel_mch_no) AS dimName, "
|
||||
+ "COALESCE(SUM(t.posted_amount), 0)::bigint AS amount, "
|
||||
+ "COUNT(*)::bigint AS orders "
|
||||
+ "FROM pay_trade t "
|
||||
+ "LEFT JOIN mch_channel_merchant cm "
|
||||
+ " ON cm.channel_mch_no = t.channel_mch_no AND cm.mch_no = t.mch_no AND cm.deleted = false "
|
||||
+ "WHERE t.status = 'success' AND t.posted_amount > 0 "
|
||||
+ "AND t.channel_mch_no IS NOT NULL AND t.channel_mch_no <> '' "
|
||||
+ "AND t.pay_time >= #{start} AND t.pay_time < #{end} "
|
||||
+ "AND t.mch_no = #{mchNo} "
|
||||
+ "GROUP BY t.channel_mch_no, cm.channel_merchant_name "
|
||||
+ "ORDER BY amount DESC "
|
||||
+ "LIMIT #{limit}")
|
||||
List<DimRankItemResult> dimRankByChannelMch(
|
||||
@Param("start") OffsetDateTime start,
|
||||
@Param("end") OffsetDateTime end,
|
||||
@Param("mchNo") String mchNo,
|
||||
@Param("limit") int limit);
|
||||
|
||||
/// 按应用分组排名
|
||||
@Select("SELECT t.app_id AS dimKey, "
|
||||
+ "COALESCE(a.app_name, t.app_id) AS dimName, "
|
||||
+ "COALESCE(SUM(t.posted_amount), 0)::bigint AS amount, "
|
||||
+ "COUNT(*)::bigint AS orders "
|
||||
+ "FROM pay_trade t "
|
||||
+ "LEFT JOIN mch_app_info a "
|
||||
+ " ON a.app_id = t.app_id AND a.mch_no = t.mch_no AND a.deleted = false "
|
||||
+ "WHERE t.status = 'success' AND t.posted_amount > 0 "
|
||||
+ "AND t.app_id IS NOT NULL AND t.app_id <> '' "
|
||||
+ "AND t.pay_time >= #{start} AND t.pay_time < #{end} "
|
||||
+ "AND t.mch_no = #{mchNo} "
|
||||
+ "GROUP BY t.app_id, a.app_name "
|
||||
+ "ORDER BY amount DESC "
|
||||
+ "LIMIT #{limit}")
|
||||
List<DimRankItemResult> dimRankByApp(
|
||||
@Param("start") OffsetDateTime start,
|
||||
@Param("end") OffsetDateTime end,
|
||||
@Param("mchNo") String mchNo,
|
||||
@Param("limit") int limit);
|
||||
|
||||
/// 按门店分组排名; 空 store_no 归到 dimKey='' (前端展示「未指定门店」)
|
||||
@Select("SELECT COALESCE(NULLIF(t.store_no, ''), '') AS dimKey, "
|
||||
+ "s.store_name AS dimName, "
|
||||
+ "COALESCE(SUM(t.posted_amount), 0)::bigint AS amount, "
|
||||
+ "COUNT(*)::bigint AS orders "
|
||||
+ "FROM pay_trade t "
|
||||
+ "LEFT JOIN mch_store_info s "
|
||||
+ " ON s.store_no = t.store_no AND s.mch_no = t.mch_no AND s.deleted = false "
|
||||
+ "WHERE t.status = 'success' AND t.posted_amount > 0 "
|
||||
+ "AND t.pay_time >= #{start} AND t.pay_time < #{end} "
|
||||
+ "AND t.mch_no = #{mchNo} "
|
||||
+ "GROUP BY COALESCE(NULLIF(t.store_no, ''), ''), s.store_name "
|
||||
+ "ORDER BY amount DESC "
|
||||
+ "LIMIT #{limit}")
|
||||
List<DimRankItemResult> dimRankByStore(
|
||||
@Param("start") OffsetDateTime start,
|
||||
@Param("end") OffsetDateTime end,
|
||||
@Param("mchNo") String mchNo,
|
||||
@Param("limit") int limit);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package cn.daxpay.open.payment.merchant.result.dashboard;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
/// # 商户工作台头部计数
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
@Schema(title = "商户工作台头部计数")
|
||||
public class MchDashboardHeaderCountResult {
|
||||
|
||||
/// 应用数
|
||||
@Schema(description = "应用数")
|
||||
private Long appCount;
|
||||
|
||||
/// 门店数
|
||||
@Schema(description = "门店数")
|
||||
private Long storeCount;
|
||||
|
||||
/// 通道商户数
|
||||
@Schema(description = "通道商户数")
|
||||
private Long channelMerchantCount;
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package cn.daxpay.open.payment.merchant.service.dashboard;
|
||||
|
||||
import cn.daxpay.open.platform.core.code.CommonCode;
|
||||
import cn.daxpay.open.platform.core.exception.BizInfoException;
|
||||
import cn.daxpay.open.payment.common.context.PaymentContext;
|
||||
import cn.daxpay.open.payment.merchant.dao.appinfo.MchAppInfoManager;
|
||||
import cn.daxpay.open.payment.merchant.dao.channel.ChannelMerchantManager;
|
||||
import cn.daxpay.open.payment.merchant.dao.dashboard.MchTradeReportMapper;
|
||||
import cn.daxpay.open.payment.merchant.dao.store.MchStoreInfoManager;
|
||||
import cn.daxpay.open.payment.merchant.entity.appinfo.MchAppInfo;
|
||||
import cn.daxpay.open.payment.merchant.entity.channel.ChannelMerchant;
|
||||
import cn.daxpay.open.payment.merchant.entity.store.MchStoreInfo;
|
||||
import cn.daxpay.open.payment.merchant.result.dashboard.MchDashboardHeaderCountResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.AmountRangeItemResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.DimRankItemResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.HourlyDistItemResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.ProviderDistItemResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.ProviderSuccessItemResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.RefundTrendItemResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.TradeOverviewResult;
|
||||
import cn.daxpay.open.payment.trade.report.result.TradeTrendItemResult;
|
||||
import cn.daxpay.open.payment.trade.report.support.TradeReportSupport;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/// # 商户端工作台/分析页交易统计
|
||||
///
|
||||
/// 编排 [MchTradeReportMapper](强制 mch_no)与 [TradeReportSupport]。
|
||||
/// 强制使用 [PaymentContext#getMchNo], 禁止请求参数指定任意商户号。
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class MchDashboardTradeService {
|
||||
|
||||
/// 维度: 通道商户
|
||||
public static final String DIM_CHANNEL_MCH = "channelMch";
|
||||
/// 维度: 应用
|
||||
public static final String DIM_APP = "app";
|
||||
/// 维度: 门店
|
||||
public static final String DIM_STORE = "store";
|
||||
|
||||
private final PaymentContext paymentContext;
|
||||
private final MchTradeReportMapper mchTradeReportMapper;
|
||||
private final TradeReportSupport tradeReportSupport;
|
||||
private final MchAppInfoManager mchAppInfoManager;
|
||||
private final MchStoreInfoManager mchStoreInfoManager;
|
||||
private final ChannelMerchantManager channelMerchantManager;
|
||||
|
||||
/// 解析并校验当前商户号
|
||||
private String requireMchNo() {
|
||||
String mchNo = paymentContext.getMchNo();
|
||||
if (mchNo == null || mchNo.isBlank()) {
|
||||
throw new BizInfoException(CommonCode.FAIL_CODE, "error.payment.merchant.dataErrorNoMchNo");
|
||||
}
|
||||
return mchNo;
|
||||
}
|
||||
|
||||
/// 工作台头部: 应用 / 门店 / 通道商户 计数
|
||||
public MchDashboardHeaderCountResult headerCounts() {
|
||||
String mchNo = requireMchNo();
|
||||
long appCount = mchAppInfoManager.lambdaQuery()
|
||||
.eq(MchAppInfo::getMchNo, mchNo)
|
||||
.count();
|
||||
long storeCount = mchStoreInfoManager.lambdaQuery()
|
||||
.eq(MchStoreInfo::getMchNo, mchNo)
|
||||
.count();
|
||||
long channelMerchantCount = channelMerchantManager.lambdaQuery()
|
||||
.eq(ChannelMerchant::getMchNo, mchNo)
|
||||
.count();
|
||||
return new MchDashboardHeaderCountResult()
|
||||
.setAppCount(appCount)
|
||||
.setStoreCount(storeCount)
|
||||
.setChannelMerchantCount(channelMerchantCount);
|
||||
}
|
||||
|
||||
// ===== 概览 =====
|
||||
|
||||
public TradeOverviewResult overview(String date) {
|
||||
String mchNo = requireMchNo();
|
||||
LocalDate today = LocalDate.now(TradeReportSupport.ZONE_CST);
|
||||
LocalDate target = "yesterday".equalsIgnoreCase(date) ? today.minusDays(1) : today;
|
||||
OffsetDateTime start = target.atStartOfDay(TradeReportSupport.ZONE_CST).toOffsetDateTime();
|
||||
OffsetDateTime end = target.plusDays(1).atStartOfDay(TradeReportSupport.ZONE_CST).toOffsetDateTime();
|
||||
OffsetDateTime prevStart = start.minusDays(1);
|
||||
OffsetDateTime prevEnd = start;
|
||||
return aggregateOverview(mchNo, start, end, prevStart, prevEnd);
|
||||
}
|
||||
|
||||
public TradeOverviewResult overview(String start, String end) {
|
||||
String mchNo = requireMchNo();
|
||||
OffsetDateTime startUtc = tradeReportSupport.parseDateStart(start);
|
||||
OffsetDateTime endUtc = tradeReportSupport.parseDateEndExclusive(end);
|
||||
long daysSpan = java.time.Duration.between(startUtc, endUtc).toDays();
|
||||
OffsetDateTime prevStart = startUtc.minusDays(daysSpan);
|
||||
OffsetDateTime prevEnd = startUtc;
|
||||
return aggregateOverview(mchNo, startUtc, endUtc, prevStart, prevEnd);
|
||||
}
|
||||
|
||||
private TradeOverviewResult aggregateOverview(
|
||||
String mchNo,
|
||||
OffsetDateTime start, OffsetDateTime end,
|
||||
OffsetDateTime prevStart, OffsetDateTime prevEnd) {
|
||||
TradeOverviewResult currSuccess = mchTradeReportMapper.successAggregate(start, end, mchNo);
|
||||
TradeOverviewResult currTotal = mchTradeReportMapper.totalOrdersAggregate(start, end, mchNo);
|
||||
TradeOverviewResult currRefund = mchTradeReportMapper.refundAggregate(start, end, mchNo);
|
||||
TradeOverviewResult prevSuccess = mchTradeReportMapper.successAggregate(prevStart, prevEnd, mchNo);
|
||||
TradeOverviewResult prevTotal = mchTradeReportMapper.totalOrdersAggregate(prevStart, prevEnd, mchNo);
|
||||
TradeOverviewResult prevRefund = mchTradeReportMapper.refundAggregate(prevStart, prevEnd, mchNo);
|
||||
|
||||
return new TradeOverviewResult()
|
||||
.setSuccessAmount(currSuccess.getSuccessAmount())
|
||||
.setSuccessCount(currSuccess.getSuccessCount())
|
||||
.setRefundAmount(currRefund.getRefundAmount())
|
||||
.setRefundCount(currRefund.getRefundCount())
|
||||
.setTotalOrders(currTotal.getTotalOrders())
|
||||
.setPrevSuccessAmount(prevSuccess.getSuccessAmount())
|
||||
.setPrevSuccessCount(prevSuccess.getSuccessCount())
|
||||
.setPrevRefundAmount(prevRefund.getRefundAmount())
|
||||
.setPrevRefundCount(prevRefund.getRefundCount())
|
||||
.setPrevTotalOrders(prevTotal.getTotalOrders());
|
||||
}
|
||||
|
||||
// ===== 趋势 =====
|
||||
|
||||
public List<TradeTrendItemResult> trend(int days) {
|
||||
String mchNo = requireMchNo();
|
||||
int safeDays = tradeReportSupport.clampDays(days);
|
||||
LocalDate today = LocalDate.now(TradeReportSupport.ZONE_CST);
|
||||
LocalDate startDate = today.minusDays(safeDays - 1L);
|
||||
OffsetDateTime start = startDate.atStartOfDay(TradeReportSupport.ZONE_CST).toOffsetDateTime();
|
||||
OffsetDateTime end = today.plusDays(1).atStartOfDay(TradeReportSupport.ZONE_CST).toOffsetDateTime();
|
||||
return tradeReportSupport.fillTrendDays(
|
||||
startDate, safeDays, mchTradeReportMapper.trend(start, end, mchNo));
|
||||
}
|
||||
|
||||
public List<TradeTrendItemResult> trend(String startStr, String endStr) {
|
||||
String mchNo = requireMchNo();
|
||||
OffsetDateTime start = tradeReportSupport.parseDateStart(startStr);
|
||||
OffsetDateTime end = tradeReportSupport.parseDateEndExclusive(endStr);
|
||||
long days = java.time.Duration.between(start, end).toDays();
|
||||
int safeDays = tradeReportSupport.clampDays((int) days);
|
||||
LocalDate startDate = start.atZoneSameInstant(TradeReportSupport.ZONE_CST).toLocalDate();
|
||||
return tradeReportSupport.fillTrendDays(
|
||||
startDate, safeDays, mchTradeReportMapper.trend(start, end, mchNo));
|
||||
}
|
||||
|
||||
public List<RefundTrendItemResult> refundTrend(int days) {
|
||||
String mchNo = requireMchNo();
|
||||
int safeDays = tradeReportSupport.clampDays(days);
|
||||
LocalDate today = LocalDate.now(TradeReportSupport.ZONE_CST);
|
||||
LocalDate startDate = today.minusDays(safeDays - 1L);
|
||||
OffsetDateTime start = startDate.atStartOfDay(TradeReportSupport.ZONE_CST).toOffsetDateTime();
|
||||
OffsetDateTime end = today.plusDays(1).atStartOfDay(TradeReportSupport.ZONE_CST).toOffsetDateTime();
|
||||
return tradeReportSupport.fillRefundTrendDays(
|
||||
startDate, safeDays, mchTradeReportMapper.refundTrend(start, end, mchNo));
|
||||
}
|
||||
|
||||
public List<RefundTrendItemResult> refundTrend(String startStr, String endStr) {
|
||||
String mchNo = requireMchNo();
|
||||
OffsetDateTime start = tradeReportSupport.parseDateStart(startStr);
|
||||
OffsetDateTime end = tradeReportSupport.parseDateEndExclusive(endStr);
|
||||
long days = java.time.Duration.between(start, end).toDays();
|
||||
int safeDays = tradeReportSupport.clampDays((int) days);
|
||||
LocalDate startDate = start.atZoneSameInstant(TradeReportSupport.ZONE_CST).toLocalDate();
|
||||
return tradeReportSupport.fillRefundTrendDays(
|
||||
startDate, safeDays, mchTradeReportMapper.refundTrend(start, end, mchNo));
|
||||
}
|
||||
|
||||
// ===== 渠道 =====
|
||||
|
||||
public List<ProviderDistItemResult> providerDist(int days) {
|
||||
String mchNo = requireMchNo();
|
||||
OffsetDateTime[] range = tradeReportSupport.daysRange(days);
|
||||
return mchTradeReportMapper.providerDist(range[0], range[1], mchNo);
|
||||
}
|
||||
|
||||
public List<ProviderDistItemResult> providerDist(String startStr, String endStr) {
|
||||
String mchNo = requireMchNo();
|
||||
return mchTradeReportMapper.providerDist(
|
||||
tradeReportSupport.parseDateStart(startStr),
|
||||
tradeReportSupport.parseDateEndExclusive(endStr),
|
||||
mchNo);
|
||||
}
|
||||
|
||||
public List<ProviderSuccessItemResult> providerSuccess(int days) {
|
||||
String mchNo = requireMchNo();
|
||||
OffsetDateTime[] range = tradeReportSupport.daysRange(days);
|
||||
return mchTradeReportMapper.providerSuccess(range[0], range[1], mchNo);
|
||||
}
|
||||
|
||||
public List<ProviderSuccessItemResult> providerSuccess(String startStr, String endStr) {
|
||||
String mchNo = requireMchNo();
|
||||
return mchTradeReportMapper.providerSuccess(
|
||||
tradeReportSupport.parseDateStart(startStr),
|
||||
tradeReportSupport.parseDateEndExclusive(endStr),
|
||||
mchNo);
|
||||
}
|
||||
|
||||
// ===== 时段 / 金额 =====
|
||||
|
||||
public List<HourlyDistItemResult> hourlyDist(int days) {
|
||||
String mchNo = requireMchNo();
|
||||
OffsetDateTime[] range = tradeReportSupport.daysRange(days);
|
||||
int daysSpan = tradeReportSupport.clampDays(days);
|
||||
return tradeReportSupport.toDailyAverage(
|
||||
tradeReportSupport.fillHourlyDist(mchTradeReportMapper.hourlyDist(range[0], range[1], mchNo)),
|
||||
daysSpan);
|
||||
}
|
||||
|
||||
public List<HourlyDistItemResult> hourlyDist(String startStr, String endStr) {
|
||||
String mchNo = requireMchNo();
|
||||
OffsetDateTime start = tradeReportSupport.parseDateStart(startStr);
|
||||
OffsetDateTime end = tradeReportSupport.parseDateEndExclusive(endStr);
|
||||
long daysSpan = Math.max(1L, java.time.Duration.between(start, end).toDays());
|
||||
return tradeReportSupport.toDailyAverage(
|
||||
tradeReportSupport.fillHourlyDist(mchTradeReportMapper.hourlyDist(start, end, mchNo)),
|
||||
daysSpan);
|
||||
}
|
||||
|
||||
public List<AmountRangeItemResult> amountRange(int days) {
|
||||
String mchNo = requireMchNo();
|
||||
OffsetDateTime[] range = tradeReportSupport.daysRange(days);
|
||||
return tradeReportSupport.fillAmountRange(mchTradeReportMapper.amountRange(range[0], range[1], mchNo));
|
||||
}
|
||||
|
||||
public List<AmountRangeItemResult> amountRange(String startStr, String endStr) {
|
||||
String mchNo = requireMchNo();
|
||||
return tradeReportSupport.fillAmountRange(mchTradeReportMapper.amountRange(
|
||||
tradeReportSupport.parseDateStart(startStr),
|
||||
tradeReportSupport.parseDateEndExclusive(endStr),
|
||||
mchNo));
|
||||
}
|
||||
|
||||
// ===== 维度排名 =====
|
||||
|
||||
/// @param dim channelMch | app | store
|
||||
public List<DimRankItemResult> dimRank(String dim, int days, int limit) {
|
||||
OffsetDateTime[] range = tradeReportSupport.daysRange(days);
|
||||
return dimRank(requireMchNo(), dim, range[0], range[1], limit);
|
||||
}
|
||||
|
||||
public List<DimRankItemResult> dimRank(String dim, String startStr, String endStr, int limit) {
|
||||
return dimRank(
|
||||
requireMchNo(),
|
||||
dim,
|
||||
tradeReportSupport.parseDateStart(startStr),
|
||||
tradeReportSupport.parseDateEndExclusive(endStr),
|
||||
limit);
|
||||
}
|
||||
|
||||
private List<DimRankItemResult> dimRank(
|
||||
String mchNo, String dim, OffsetDateTime start, OffsetDateTime end, int limit) {
|
||||
int safeLimit = tradeReportSupport.clampLimit(limit);
|
||||
String normalized = dim == null ? DIM_APP : dim.trim();
|
||||
List<DimRankItemResult> rows = switch (normalized) {
|
||||
case DIM_CHANNEL_MCH -> mchTradeReportMapper.dimRankByChannelMch(start, end, mchNo, safeLimit);
|
||||
case DIM_STORE -> mchTradeReportMapper.dimRankByStore(start, end, mchNo, safeLimit);
|
||||
default -> mchTradeReportMapper.dimRankByApp(start, end, mchNo, safeLimit);
|
||||
};
|
||||
return tradeReportSupport.computeDimProportion(rows);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package cn.daxpay.open.payment.merchant.service.trade;
|
||||
|
||||
import cn.daxpay.open.platform.core.rest.param.PageParam;
|
||||
import cn.daxpay.open.platform.core.rest.result.PageResult;
|
||||
import cn.daxpay.open.payment.merchant.convert.trade.MchPayTradeConvert;
|
||||
import cn.daxpay.open.payment.trade.order.dao.PayTradeManager;
|
||||
import cn.daxpay.open.payment.trade.order.entity.PayTrade;
|
||||
import cn.daxpay.open.payment.trade.order.param.PayTradeQuery;
|
||||
import cn.daxpay.open.payment.trade.order.result.PayTradeResult;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/// # 资金交易凭证查询(商户端)
|
||||
///
|
||||
/// 仅分页列表;行级隔离依赖 [MchBaseEntity] + 商户端 PaymentContext / TenantLine。
|
||||
/// 禁止信任请求体中的 mchNo(查询条件亦不作为跨商户入口)。
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class MchPayTradeService {
|
||||
|
||||
private final PayTradeManager payTradeManager;
|
||||
|
||||
/// 分页查询本商户资金交易
|
||||
public PageResult<PayTradeResult> page(PageParam pageParam, PayTradeQuery query) {
|
||||
// 忽略入参 mchNo,避免越权指定他商户
|
||||
if (query != null) {
|
||||
query.setMchNo(null);
|
||||
}
|
||||
Page<PayTrade> page = payTradeManager.page(pageParam, query);
|
||||
var records = page.getRecords().stream()
|
||||
.map(MchPayTradeConvert.CONVERT::toResult)
|
||||
.toList();
|
||||
return new PageResult<PayTradeResult>()
|
||||
.setRecords(records)
|
||||
.setTotal(page.getTotal())
|
||||
.setSize(page.getSize())
|
||||
.setCurrent(page.getCurrent());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user