feat:流水错误修改
This commit is contained in:
1 parent
3ae6fdbfb4
commit
c0285edd4c
29 files changed
+2631
-1111
No files matched your search
@@ -1,5 +1,7 @@
|
||||
# 部门交易统计优化验证记录(2026-10-22)
|
||||
|
||||
> **历史记录:下文验证的是旧订单统计口径的性能优化,已由本次钱包账本统计替代。** 下文的 106 项回归证明与旧口径保持一致,不能作为商户、部门报表已经统一到 `wallet_transaction` 的证据。当前支付与退款均按流水发生时间、历史部门、原始金额和流水条数汇总;生产差额须使用 [只读对账脚本](20261022_wallet_ledger_reconciliation.sql) 按实际租户和权限另行核实。本次未执行生产对账。
|
||||
|
||||
## 范围与数据库
|
||||
|
||||
目标接口:`POST /hard/hard-total/page-list-dept`,同时优化共用统计逻辑的同步/异步部门导出。用户反馈线上超过 30 秒未返回。线上为 PolarDB MySQL 8 兼容版;本次实际连接的是用户提供的本机 MySQL 8.0.46 测试库,未连接或修改生产数据库。
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
-- 钱包账本核对草稿(2026-10-22);只含 SELECT 和会话变量 SET,不建表、不改数据。
|
||||
-- 本文件未连接任何数据库、未执行生产核对,不能将下列查询当作已有线上结果。
|
||||
-- 适用 MySQL 8 / PolarDB MySQL 8。三个库须在同一实例且核对账号有 SELECT 权限。
|
||||
-- 使用前只填写本租户编号。NULL 默认不会匹配任何业务数据,避免不知租户时查全库。
|
||||
-- 核对的是用户给出的“全权限、未额外筛选商户/商户类型/钱包类型”场景;
|
||||
-- 若实际账号有行级权限或商户类型限制,应先将相同限制添加到新旧两侧,不能直接拿全租户结果与页面比较。
|
||||
-- 日期使用中国业务时间;请确保会话中 DATETIME/TIMESTAMP 的解释与应用一致,本脚本不更改时区。
|
||||
SET @recon_tenant_id = NULL;
|
||||
SET @recon_from = CAST('2026-09-01 00:00:00' AS DATETIME);
|
||||
SET @recon_to = CAST('2026-10-01 00:00:00' AS DATETIME);
|
||||
SET @recon_excluded_dept_1 = 1875013738914156546;
|
||||
SET @recon_excluded_dept_2 = 1910218102049226753;
|
||||
SET @recon_excluded_dept_3 = 1910218128624336898;
|
||||
SET @recon_excluded_dept_4 = 1942046316044144642;
|
||||
|
||||
-- 用户报告的原页面结果,单位分;用于比较是否复现,绝不代表本次已验证。
|
||||
SET @recon_reported_merchant_cents = 429542126;
|
||||
SET @recon_reported_department_cents = 429394976;
|
||||
SET @recon_reported_merchant_count = 64361;
|
||||
SET @recon_reported_department_count = 64133;
|
||||
|
||||
SELECT @recon_tenant_id AS selected_tenant,
|
||||
@recon_from AS inclusive_start, @recon_to AS exclusive_end,
|
||||
CASE WHEN @recon_tenant_id IS NULL THEN '请先填写租户编号,后续空结果不是零流水结论' ELSE '已指定租户' END AS setup_status,
|
||||
@recon_reported_merchant_cents - @recon_reported_department_cents AS reported_delta_cents,
|
||||
@recon_reported_merchant_count - @recon_reported_department_count AS reported_delta_count;
|
||||
|
||||
-- 1. 新口径原始账本基准:PAYMENT=4 价格取反;REFUND=5 保留原始符号;零金额也计次。
|
||||
-- 部门排除保留 dept_id=NULL,这些交易必须进入“未归属部门/非订单未归属”桶。
|
||||
WITH ledger_base AS (
|
||||
SELECT ledger.*,
|
||||
CASE WHEN ledger.biz_id REGEXP '^[0-9]{1,19}$'
|
||||
THEN CAST(ledger.biz_id AS UNSIGNED) ELSE NULL END AS numeric_biz_id
|
||||
FROM pjcloudx_wallet.wallet_transaction ledger
|
||||
WHERE @recon_tenant_id IS NOT NULL
|
||||
AND ledger.tenant_id = @recon_tenant_id
|
||||
AND ledger.del_flag = '0'
|
||||
AND ledger.biz_type IN ('4', '5')
|
||||
AND ledger.create_time >= @recon_from
|
||||
AND ledger.create_time < @recon_to
|
||||
AND (ledger.dept_id IS NULL OR ledger.dept_id NOT IN (@recon_excluded_dept_1, @recon_excluded_dept_2, @recon_excluded_dept_3, @recon_excluded_dept_4))
|
||||
)
|
||||
SELECT COALESCE(-SUM(CASE WHEN biz_type = '4' THEN price ELSE 0 END), 0) AS payment_cents,
|
||||
COUNT(CASE WHEN biz_type = '4' THEN 1 END) AS payment_count,
|
||||
COALESCE(SUM(CASE WHEN biz_type = '5' THEN price ELSE 0 END), 0) AS refund_cents,
|
||||
COUNT(CASE WHEN biz_type = '5' THEN 1 END) AS refund_count,
|
||||
COALESCE(-SUM(price), 0) AS net_cents,
|
||||
COUNT(CASE WHEN biz_type = '4' THEN 1 END)
|
||||
- COUNT(CASE WHEN biz_type = '5' THEN 1 END) AS net_count
|
||||
FROM ledger_base;
|
||||
|
||||
-- 2. 同一批账本按真实商户归属汇总,金额和次数仍完全来自账本。
|
||||
-- 订单/售后只补商户关系,不限制其软删除、状态、支付金额及支付/创建时间。
|
||||
-- 精确业务编号 + 同租户 + 同钱包,未匹配项显示“非订单/未归属”;不能漏掉这个桶。
|
||||
WITH ledger_base AS (
|
||||
SELECT ledger.*,
|
||||
CASE WHEN ledger.biz_id REGEXP '^[0-9]{1,19}$'
|
||||
THEN CAST(ledger.biz_id AS UNSIGNED) ELSE NULL END AS numeric_biz_id
|
||||
FROM pjcloudx_wallet.wallet_transaction ledger
|
||||
WHERE @recon_tenant_id IS NOT NULL
|
||||
AND ledger.tenant_id = @recon_tenant_id
|
||||
AND ledger.del_flag = '0'
|
||||
AND ledger.biz_type IN ('4', '5')
|
||||
AND ledger.create_time >= @recon_from
|
||||
AND ledger.create_time < @recon_to
|
||||
AND (ledger.dept_id IS NULL OR ledger.dept_id NOT IN (@recon_excluded_dept_1, @recon_excluded_dept_2, @recon_excluded_dept_3, @recon_excluded_dept_4))
|
||||
),
|
||||
merchant_ledger AS (
|
||||
SELECT ledger.*, ledger_order.merchant_id
|
||||
FROM ledger_base ledger
|
||||
LEFT JOIN pjcloudx_hardware.hard_after_sale ledger_refund
|
||||
ON ledger.biz_type = '5'
|
||||
AND ledger_refund.tenant_id = ledger.tenant_id
|
||||
AND ledger_refund.id = ledger.numeric_biz_id
|
||||
AND CAST(ledger.biz_id AS BINARY) = CAST(CONCAT('', ledger_refund.id) AS BINARY)
|
||||
LEFT JOIN pjcloudx_hardware.hard_order ledger_order
|
||||
ON ledger_order.tenant_id = ledger.tenant_id
|
||||
AND ledger_order.wallet_id = ledger.wallet_id
|
||||
AND ledger_order.id = CASE WHEN ledger.biz_type = '4' THEN ledger.numeric_biz_id
|
||||
WHEN ledger.biz_type = '5' THEN ledger_refund.order_id ELSE NULL END
|
||||
AND (ledger.biz_type = '5'
|
||||
OR CAST(ledger.biz_id AS BINARY) = CAST(CONCAT('', ledger_order.id) AS BINARY))
|
||||
)
|
||||
SELECT merchant_id,
|
||||
CASE WHEN merchant_id IS NULL THEN '非订单/未归属' ELSE CONCAT('商户(', merchant_id, ')') END AS merchant_bucket,
|
||||
COALESCE(-SUM(CASE WHEN biz_type = '4' THEN price ELSE 0 END), 0) AS payment_cents,
|
||||
COUNT(CASE WHEN biz_type = '4' THEN 1 END) AS payment_count,
|
||||
COALESCE(SUM(CASE WHEN biz_type = '5' THEN price ELSE 0 END), 0) AS refund_cents,
|
||||
COUNT(CASE WHEN biz_type = '5' THEN 1 END) AS refund_count,
|
||||
COALESCE(-SUM(price), 0) AS net_cents,
|
||||
COUNT(CASE WHEN biz_type = '4' THEN 1 END)
|
||||
- COUNT(CASE WHEN biz_type = '5' THEN 1 END) AS net_count
|
||||
FROM merchant_ledger
|
||||
GROUP BY merchant_id
|
||||
ORDER BY merchant_id;
|
||||
|
||||
-- 3. 同一批账本按流水历史部门汇总;各组相加应等于第1组、第2组结果。
|
||||
-- 不与当前部门列表做 INNER JOIN,物理缺失部门及 NULL 部门仍保留。
|
||||
WITH ledger_base AS (
|
||||
SELECT ledger.*,
|
||||
CASE WHEN ledger.biz_id REGEXP '^[0-9]{1,19}$'
|
||||
THEN CAST(ledger.biz_id AS UNSIGNED) ELSE NULL END AS numeric_biz_id
|
||||
FROM pjcloudx_wallet.wallet_transaction ledger
|
||||
WHERE @recon_tenant_id IS NOT NULL
|
||||
AND ledger.tenant_id = @recon_tenant_id
|
||||
AND ledger.del_flag = '0'
|
||||
AND ledger.biz_type IN ('4', '5')
|
||||
AND ledger.create_time >= @recon_from
|
||||
AND ledger.create_time < @recon_to
|
||||
AND (ledger.dept_id IS NULL OR ledger.dept_id NOT IN (@recon_excluded_dept_1, @recon_excluded_dept_2, @recon_excluded_dept_3, @recon_excluded_dept_4))
|
||||
)
|
||||
SELECT dept_id,
|
||||
CASE WHEN dept_id IS NULL THEN '未归属部门' ELSE CONCAT('历史部门(', dept_id, ')') END AS department_bucket,
|
||||
COALESCE(-SUM(CASE WHEN biz_type = '4' THEN price ELSE 0 END), 0) AS payment_cents,
|
||||
COUNT(CASE WHEN biz_type = '4' THEN 1 END) AS payment_count,
|
||||
COALESCE(SUM(CASE WHEN biz_type = '5' THEN price ELSE 0 END), 0) AS refund_cents,
|
||||
COUNT(CASE WHEN biz_type = '5' THEN 1 END) AS refund_count,
|
||||
COALESCE(-SUM(price), 0) AS net_cents,
|
||||
COUNT(CASE WHEN biz_type = '4' THEN 1 END)
|
||||
- COUNT(CASE WHEN biz_type = '5' THEN 1 END) AS net_count
|
||||
FROM ledger_base
|
||||
GROUP BY dept_id
|
||||
ORDER BY dept_id;
|
||||
|
||||
-- 4. 旧两个支付链路的完整集合差异(仅解释旧“流水金额/次数”,不把退款混入支付差额)。
|
||||
-- 旧商户:订单 pay_time BETWEEN(含右端)、订单 user_dept_id、merchant_id非空、pay_price/每单一次。
|
||||
-- 旧部门:订单 create_time BETWEEN(含右端)、最新有效 PAYMENT 历史部门(不限流水时间/金额符号),
|
||||
-- 无快照则回退订单部门;最后仅输出 UPMS 存在的部门。UPMS 软删除部门仍存在,不能过滤del_flag。
|
||||
-- 该查询故意保留旧 BETWEEN;第1至3组的新账本使用半开区间,两者并非相同口径。
|
||||
-- 本段模拟现有源码的全权限行为;若部署代码版本不同,先比对源码,不可硬解释为生产差额。
|
||||
WITH order_basis AS (
|
||||
SELECT a.id, a.tenant_id, a.wallet_id, a.merchant_id, a.user_dept_id,
|
||||
a.pay_price, a.pay_time, a.create_time,
|
||||
COALESCE((
|
||||
SELECT history.dept_id
|
||||
FROM pjcloudx_wallet.wallet_transaction history
|
||||
WHERE history.tenant_id = a.tenant_id
|
||||
AND history.biz_id = CONCAT('', a.id)
|
||||
AND CAST(history.biz_id AS BINARY) = CAST(CONCAT('', a.id) AS BINARY)
|
||||
AND history.wallet_id = a.wallet_id
|
||||
AND history.biz_type = '4'
|
||||
AND CAST(history.biz_type AS BINARY) = CAST('4' AS BINARY)
|
||||
AND history.del_flag = '0'
|
||||
AND history.dept_id IS NOT NULL
|
||||
ORDER BY history.id DESC LIMIT 1
|
||||
), a.user_dept_id) AS historical_dept_id
|
||||
FROM pjcloudx_hardware.hard_order a
|
||||
WHERE @recon_tenant_id IS NOT NULL
|
||||
AND a.tenant_id = @recon_tenant_id AND a.del_flag = '0'
|
||||
AND a.order_status IN ('5', '20', '10')
|
||||
AND ((a.pay_time BETWEEN @recon_from AND @recon_to)
|
||||
OR (a.create_time BETWEEN @recon_from AND @recon_to))
|
||||
),
|
||||
old_flags AS (
|
||||
SELECT a.*,
|
||||
CASE WHEN a.pay_time BETWEEN @recon_from AND @recon_to THEN 1 ELSE 0 END AS pay_time_inside,
|
||||
CASE WHEN a.create_time BETWEEN @recon_from AND @recon_to THEN 1 ELSE 0 END AS create_time_inside,
|
||||
CASE WHEN a.user_dept_id NOT IN (@recon_excluded_dept_1, @recon_excluded_dept_2, @recon_excluded_dept_3, @recon_excluded_dept_4) THEN 1 ELSE 0 END AS order_department_inside,
|
||||
CASE WHEN a.historical_dept_id NOT IN (@recon_excluded_dept_1, @recon_excluded_dept_2, @recon_excluded_dept_3, @recon_excluded_dept_4) THEN 1 ELSE 0 END AS historical_department_inside,
|
||||
CASE WHEN EXISTS (
|
||||
SELECT 1 FROM pjcloudx.sys_dept department
|
||||
WHERE department.tenant_id = a.tenant_id AND department.dept_id = a.historical_dept_id
|
||||
) THEN 1 ELSE 0 END AS historical_department_exists,
|
||||
CASE WHEN a.merchant_id IS NOT NULL
|
||||
AND a.pay_time BETWEEN @recon_from AND @recon_to
|
||||
AND a.user_dept_id NOT IN (@recon_excluded_dept_1, @recon_excluded_dept_2, @recon_excluded_dept_3, @recon_excluded_dept_4)
|
||||
THEN 1 ELSE 0 END AS old_merchant_match,
|
||||
CASE WHEN a.create_time BETWEEN @recon_from AND @recon_to
|
||||
AND a.historical_dept_id NOT IN (@recon_excluded_dept_1, @recon_excluded_dept_2, @recon_excluded_dept_3, @recon_excluded_dept_4)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM pjcloudx.sys_dept department
|
||||
WHERE department.tenant_id = a.tenant_id AND department.dept_id = a.historical_dept_id
|
||||
)
|
||||
THEN 1 ELSE 0 END AS old_department_match
|
||||
FROM order_basis a
|
||||
)
|
||||
SELECT COALESCE(SUM(CASE WHEN old_merchant_match = 1 THEN pay_price ELSE 0 END), 0) AS old_merchant_cents,
|
||||
SUM(old_merchant_match) AS old_merchant_count,
|
||||
COALESCE(SUM(CASE WHEN old_department_match = 1 THEN pay_price ELSE 0 END), 0) AS old_department_cents,
|
||||
SUM(old_department_match) AS old_department_count,
|
||||
COALESCE(SUM(COALESCE(pay_price, 0) * (old_merchant_match - old_department_match)), 0) AS reconstructed_delta_cents,
|
||||
COALESCE(SUM(old_merchant_match - old_department_match), 0) AS reconstructed_delta_count,
|
||||
@recon_reported_merchant_cents - @recon_reported_department_cents AS reported_delta_cents,
|
||||
@recon_reported_merchant_count - @recon_reported_department_count AS reported_delta_count,
|
||||
CASE WHEN @recon_tenant_id IS NOT NULL
|
||||
AND COALESCE(SUM(COALESCE(pay_price, 0) * (old_merchant_match - old_department_match)), 0)
|
||||
= @recon_reported_merchant_cents - @recon_reported_department_cents
|
||||
AND COALESCE(SUM(old_merchant_match - old_department_match), 0)
|
||||
= @recon_reported_merchant_count - @recon_reported_department_count
|
||||
THEN '金额及次数差额同时复现,仍需下组逐笔核对' ELSE '未同时复现,检查租户/权限/部署版本/元数据变化' END AS comparison_status
|
||||
FROM old_flags;
|
||||
|
||||
-- 5. 逐笔列出旧链路集合差:正向仅进商户,负向仅进部门。
|
||||
-- signed_difference_cents/count 相加应等于第4组重建差额;同时查看各时间、部门、缺失元数据列。
|
||||
WITH order_basis AS (
|
||||
SELECT a.id, a.tenant_id, a.wallet_id, a.merchant_id, a.user_dept_id,
|
||||
a.pay_price, a.pay_time, a.create_time,
|
||||
COALESCE((
|
||||
SELECT history.dept_id
|
||||
FROM pjcloudx_wallet.wallet_transaction history
|
||||
WHERE history.tenant_id = a.tenant_id
|
||||
AND history.biz_id = CONCAT('', a.id)
|
||||
AND CAST(history.biz_id AS BINARY) = CAST(CONCAT('', a.id) AS BINARY)
|
||||
AND history.wallet_id = a.wallet_id
|
||||
AND history.biz_type = '4'
|
||||
AND CAST(history.biz_type AS BINARY) = CAST('4' AS BINARY)
|
||||
AND history.del_flag = '0'
|
||||
AND history.dept_id IS NOT NULL
|
||||
ORDER BY history.id DESC LIMIT 1
|
||||
), a.user_dept_id) AS historical_dept_id
|
||||
FROM pjcloudx_hardware.hard_order a
|
||||
WHERE @recon_tenant_id IS NOT NULL
|
||||
AND a.tenant_id = @recon_tenant_id AND a.del_flag = '0'
|
||||
AND a.order_status IN ('5', '20', '10')
|
||||
AND ((a.pay_time BETWEEN @recon_from AND @recon_to)
|
||||
OR (a.create_time BETWEEN @recon_from AND @recon_to))
|
||||
),
|
||||
old_flags AS (
|
||||
SELECT a.*,
|
||||
CASE WHEN a.pay_time BETWEEN @recon_from AND @recon_to THEN 1 ELSE 0 END AS pay_time_inside,
|
||||
CASE WHEN a.create_time BETWEEN @recon_from AND @recon_to THEN 1 ELSE 0 END AS create_time_inside,
|
||||
CASE WHEN a.user_dept_id NOT IN (@recon_excluded_dept_1, @recon_excluded_dept_2, @recon_excluded_dept_3, @recon_excluded_dept_4) THEN 1 ELSE 0 END AS order_department_inside,
|
||||
CASE WHEN a.historical_dept_id NOT IN (@recon_excluded_dept_1, @recon_excluded_dept_2, @recon_excluded_dept_3, @recon_excluded_dept_4) THEN 1 ELSE 0 END AS historical_department_inside,
|
||||
CASE WHEN EXISTS (
|
||||
SELECT 1 FROM pjcloudx.sys_dept department
|
||||
WHERE department.tenant_id = a.tenant_id AND department.dept_id = a.historical_dept_id
|
||||
) THEN 1 ELSE 0 END AS historical_department_exists,
|
||||
CASE WHEN a.merchant_id IS NOT NULL
|
||||
AND a.pay_time BETWEEN @recon_from AND @recon_to
|
||||
AND a.user_dept_id NOT IN (@recon_excluded_dept_1, @recon_excluded_dept_2, @recon_excluded_dept_3, @recon_excluded_dept_4)
|
||||
THEN 1 ELSE 0 END AS old_merchant_match,
|
||||
CASE WHEN a.create_time BETWEEN @recon_from AND @recon_to
|
||||
AND a.historical_dept_id NOT IN (@recon_excluded_dept_1, @recon_excluded_dept_2, @recon_excluded_dept_3, @recon_excluded_dept_4)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM pjcloudx.sys_dept department
|
||||
WHERE department.tenant_id = a.tenant_id AND department.dept_id = a.historical_dept_id
|
||||
)
|
||||
THEN 1 ELSE 0 END AS old_department_match
|
||||
FROM order_basis a
|
||||
)
|
||||
SELECT id AS order_id, merchant_id, wallet_id,
|
||||
pay_time AS old_merchant_time, create_time AS old_department_time,
|
||||
user_dept_id AS old_merchant_department, historical_dept_id AS old_department_department,
|
||||
pay_price AS order_payment_cents,
|
||||
old_merchant_match, old_department_match,
|
||||
CASE WHEN old_merchant_match = 1 THEN '仅旧商户链路' ELSE '仅旧部门链路' END AS difference_direction,
|
||||
COALESCE(pay_price, 0) * (old_merchant_match - old_department_match) AS signed_difference_cents,
|
||||
old_merchant_match - old_department_match AS signed_difference_count,
|
||||
pay_time_inside, create_time_inside, order_department_inside, historical_department_inside,
|
||||
historical_department_exists
|
||||
FROM old_flags
|
||||
WHERE old_merchant_match <> old_department_match
|
||||
ORDER BY difference_direction, id;
|
||||
|
||||
-- 6. 单独隔离“订单支付时间 vs 订单创建时间”差集:其他条件固定为旧商户的订单部门、真实商户及有效订单。
|
||||
-- 这只是时间因素,不与第7组机械相加,时间和部门因素可能交叠。
|
||||
WITH order_basis AS (
|
||||
SELECT a.id, a.tenant_id, a.wallet_id, a.merchant_id, a.user_dept_id,
|
||||
a.pay_price, a.pay_time, a.create_time,
|
||||
COALESCE((
|
||||
SELECT history.dept_id
|
||||
FROM pjcloudx_wallet.wallet_transaction history
|
||||
WHERE history.tenant_id = a.tenant_id
|
||||
AND history.biz_id = CONCAT('', a.id)
|
||||
AND CAST(history.biz_id AS BINARY) = CAST(CONCAT('', a.id) AS BINARY)
|
||||
AND history.wallet_id = a.wallet_id
|
||||
AND history.biz_type = '4'
|
||||
AND CAST(history.biz_type AS BINARY) = CAST('4' AS BINARY)
|
||||
AND history.del_flag = '0'
|
||||
AND history.dept_id IS NOT NULL
|
||||
ORDER BY history.id DESC LIMIT 1
|
||||
), a.user_dept_id) AS historical_dept_id
|
||||
FROM pjcloudx_hardware.hard_order a
|
||||
WHERE @recon_tenant_id IS NOT NULL
|
||||
AND a.tenant_id = @recon_tenant_id AND a.del_flag = '0'
|
||||
AND a.order_status IN ('5', '20', '10')
|
||||
AND ((a.pay_time BETWEEN @recon_from AND @recon_to)
|
||||
OR (a.create_time BETWEEN @recon_from AND @recon_to))
|
||||
),
|
||||
old_flags AS (
|
||||
SELECT a.*,
|
||||
CASE WHEN a.pay_time BETWEEN @recon_from AND @recon_to THEN 1 ELSE 0 END AS pay_time_inside,
|
||||
CASE WHEN a.create_time BETWEEN @recon_from AND @recon_to THEN 1 ELSE 0 END AS create_time_inside,
|
||||
CASE WHEN a.user_dept_id NOT IN (@recon_excluded_dept_1, @recon_excluded_dept_2, @recon_excluded_dept_3, @recon_excluded_dept_4) THEN 1 ELSE 0 END AS order_department_inside,
|
||||
CASE WHEN a.historical_dept_id NOT IN (@recon_excluded_dept_1, @recon_excluded_dept_2, @recon_excluded_dept_3, @recon_excluded_dept_4) THEN 1 ELSE 0 END AS historical_department_inside,
|
||||
CASE WHEN EXISTS (
|
||||
SELECT 1 FROM pjcloudx.sys_dept department
|
||||
WHERE department.tenant_id = a.tenant_id AND department.dept_id = a.historical_dept_id
|
||||
) THEN 1 ELSE 0 END AS historical_department_exists,
|
||||
CASE WHEN a.merchant_id IS NOT NULL
|
||||
AND a.pay_time BETWEEN @recon_from AND @recon_to
|
||||
AND a.user_dept_id NOT IN (@recon_excluded_dept_1, @recon_excluded_dept_2, @recon_excluded_dept_3, @recon_excluded_dept_4)
|
||||
THEN 1 ELSE 0 END AS old_merchant_match,
|
||||
CASE WHEN a.create_time BETWEEN @recon_from AND @recon_to
|
||||
AND a.historical_dept_id NOT IN (@recon_excluded_dept_1, @recon_excluded_dept_2, @recon_excluded_dept_3, @recon_excluded_dept_4)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM pjcloudx.sys_dept department
|
||||
WHERE department.tenant_id = a.tenant_id AND department.dept_id = a.historical_dept_id
|
||||
)
|
||||
THEN 1 ELSE 0 END AS old_department_match
|
||||
FROM order_basis a
|
||||
)
|
||||
SELECT CASE WHEN pay_time_inside = 1 THEN '仅支付时间命中' ELSE '仅创建时间命中' END AS time_difference,
|
||||
COUNT(*) AS order_count,
|
||||
COALESCE(SUM(pay_price), 0) AS order_payment_cents,
|
||||
COALESCE(SUM(COALESCE(pay_price, 0) * (pay_time_inside - create_time_inside)), 0) AS signed_time_delta_cents,
|
||||
SUM(pay_time_inside - create_time_inside) AS signed_time_delta_count
|
||||
FROM old_flags
|
||||
WHERE merchant_id IS NOT NULL AND order_department_inside = 1
|
||||
AND pay_time_inside <> create_time_inside
|
||||
GROUP BY pay_time_inside, create_time_inside
|
||||
ORDER BY pay_time_inside DESC;
|
||||
|
||||
-- 7. 单独隔离“订单部门快照 vs 最新钱包支付历史部门”差集:统一使用订单支付时间、有效订单及真实商户。
|
||||
-- 对照的是历史快照归属,不以当前用户部门代替;部门物理缺失的影响另看第5组 exists 字段。
|
||||
-- 本组两方向均可能存在,不能因为总差额为正就只查“测试部门迁出”。
|
||||
WITH order_basis AS (
|
||||
SELECT a.id, a.tenant_id, a.wallet_id, a.merchant_id, a.user_dept_id,
|
||||
a.pay_price, a.pay_time, a.create_time,
|
||||
COALESCE((
|
||||
SELECT history.dept_id
|
||||
FROM pjcloudx_wallet.wallet_transaction history
|
||||
WHERE history.tenant_id = a.tenant_id
|
||||
AND history.biz_id = CONCAT('', a.id)
|
||||
AND CAST(history.biz_id AS BINARY) = CAST(CONCAT('', a.id) AS BINARY)
|
||||
AND history.wallet_id = a.wallet_id
|
||||
AND history.biz_type = '4'
|
||||
AND CAST(history.biz_type AS BINARY) = CAST('4' AS BINARY)
|
||||
AND history.del_flag = '0'
|
||||
AND history.dept_id IS NOT NULL
|
||||
ORDER BY history.id DESC LIMIT 1
|
||||
), a.user_dept_id) AS historical_dept_id
|
||||
FROM pjcloudx_hardware.hard_order a
|
||||
WHERE @recon_tenant_id IS NOT NULL
|
||||
AND a.tenant_id = @recon_tenant_id AND a.del_flag = '0'
|
||||
AND a.order_status IN ('5', '20', '10')
|
||||
AND ((a.pay_time BETWEEN @recon_from AND @recon_to)
|
||||
OR (a.create_time BETWEEN @recon_from AND @recon_to))
|
||||
),
|
||||
old_flags AS (
|
||||
SELECT a.*,
|
||||
CASE WHEN a.pay_time BETWEEN @recon_from AND @recon_to THEN 1 ELSE 0 END AS pay_time_inside,
|
||||
CASE WHEN a.create_time BETWEEN @recon_from AND @recon_to THEN 1 ELSE 0 END AS create_time_inside,
|
||||
CASE WHEN a.user_dept_id NOT IN (@recon_excluded_dept_1, @recon_excluded_dept_2, @recon_excluded_dept_3, @recon_excluded_dept_4) THEN 1 ELSE 0 END AS order_department_inside,
|
||||
CASE WHEN a.historical_dept_id NOT IN (@recon_excluded_dept_1, @recon_excluded_dept_2, @recon_excluded_dept_3, @recon_excluded_dept_4) THEN 1 ELSE 0 END AS historical_department_inside,
|
||||
CASE WHEN EXISTS (
|
||||
SELECT 1 FROM pjcloudx.sys_dept department
|
||||
WHERE department.tenant_id = a.tenant_id AND department.dept_id = a.historical_dept_id
|
||||
) THEN 1 ELSE 0 END AS historical_department_exists,
|
||||
CASE WHEN a.merchant_id IS NOT NULL
|
||||
AND a.pay_time BETWEEN @recon_from AND @recon_to
|
||||
AND a.user_dept_id NOT IN (@recon_excluded_dept_1, @recon_excluded_dept_2, @recon_excluded_dept_3, @recon_excluded_dept_4)
|
||||
THEN 1 ELSE 0 END AS old_merchant_match,
|
||||
CASE WHEN a.create_time BETWEEN @recon_from AND @recon_to
|
||||
AND a.historical_dept_id NOT IN (@recon_excluded_dept_1, @recon_excluded_dept_2, @recon_excluded_dept_3, @recon_excluded_dept_4)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM pjcloudx.sys_dept department
|
||||
WHERE department.tenant_id = a.tenant_id AND department.dept_id = a.historical_dept_id
|
||||
)
|
||||
THEN 1 ELSE 0 END AS old_department_match
|
||||
FROM order_basis a
|
||||
)
|
||||
SELECT id AS order_id, pay_time, wallet_id, merchant_id,
|
||||
user_dept_id AS order_department, historical_dept_id AS wallet_snapshot_department,
|
||||
order_department_inside, historical_department_inside,
|
||||
COALESCE(pay_price, 0) * (order_department_inside - historical_department_inside) AS signed_department_delta_cents,
|
||||
order_department_inside - historical_department_inside AS signed_department_delta_count
|
||||
FROM old_flags
|
||||
WHERE merchant_id IS NOT NULL AND pay_time_inside = 1
|
||||
AND order_department_inside <> historical_department_inside
|
||||
ORDER BY signed_department_delta_count DESC, id;
|
||||
|
||||
-- 8. 结束时刻探针:旧 BETWEEN 会包含 10月1日00:00:00,新账本半开区间排除它。
|
||||
-- 这里只展示边界交易,不把结果预设为任何金额或次数。
|
||||
SELECT ledger.id AS transaction_id, ledger.biz_type, ledger.price AS signed_cents,
|
||||
ledger.create_time, ledger.dept_id, ledger.wallet_id, ledger.biz_id
|
||||
FROM pjcloudx_wallet.wallet_transaction ledger
|
||||
WHERE @recon_tenant_id IS NOT NULL
|
||||
AND ledger.tenant_id = @recon_tenant_id AND ledger.del_flag = '0'
|
||||
AND ledger.biz_type IN ('4', '5') AND ledger.create_time = @recon_to
|
||||
AND (ledger.dept_id IS NULL OR ledger.dept_id NOT IN (@recon_excluded_dept_1, @recon_excluded_dept_2, @recon_excluded_dept_3, @recon_excluded_dept_4))
|
||||
ORDER BY ledger.id;
|
||||
@@ -0,0 +1,43 @@
|
||||
# 钱包账本交易统计验证记录(2026-10-22)
|
||||
|
||||
## 结果与口径
|
||||
|
||||
商户、部门和商户月份报表的支付、退款均改为 `wallet_transaction` 聚合:PAYMENT=4 的金额取 `-SUM(price)`,REFUND=5 保留原符号,每条流水分别计次,净额和净次数分别相减。所有报表使用流水 `create_time >= start AND create_time < end`,部门包含/排除使用流水 `dept_id`。
|
||||
|
||||
订单、售后只用于商户归属及原有数据权限,不再根据订单金额、状态、创建或支付时间决定账本收支。零元但无流水的订单不计入,实际零金额流水与冲正记录保留。全部 REFUND 都保留,非订单退款及缺失关联归入空商户 ID 的“非订单/未归属”行;该行仅全局权限、未限定商户范围时可见。普通历史商户资料缺失、部门物理缺失或空部门不会导致账本漏数。
|
||||
|
||||
部门的批量充值/扣减、单次充值/扣减和退还食堂仍保留原计算规则,改在同一账本查询中聚合。其他钱包接口、数据库结构与生产数据均未修改。
|
||||
|
||||
## 接口和导出
|
||||
|
||||
- 日商户和部门接口仍返回原列表结构;月份接口仍分页,支付与退款先合并聚合后分页,包含只有退款的月份。
|
||||
- 现有字段名、金额分单位、租户和商户权限保留。权限在分组前执行;客户端指定商户类型必须与服务端类型授权取交集。
|
||||
- 页面、同步/异步汇总导出共享查询。自然日分片使用相邻半开边界,支付和退款四个原始汇总字段一起累计;抽成在完整合并后计算。
|
||||
- 月导出从账本月份分组产生商户集合,包含未归属和退款单边分组;一次固定查询/权限上下文,分页读取,金额全程以 BigDecimal 分值累计后转元。
|
||||
- 单商户明细导出改为逐条钱包流水,包含原始金额、支付/退款金额与关联业务编号。保留五万笔上限,拒绝空或非正商户 ID,每批 200 笔按流水主键游标读取,完整临时文件生成后响应。
|
||||
- 三个 Panel report 共用商户统计服务,原 createTimeList 映射为账本时间,保留其商户类型和商户筛选。
|
||||
- 前端日期标签、半开边界、逐流水计次说明同步更新;未归属行禁止结账和单商户导出;月表明确“本页合计”。
|
||||
|
||||
## 实际验证
|
||||
|
||||
`mvn -o -pl pjcloud-hardware/pjcloud-hardware-biz -am -DskipTests -Dfile.encoding=UTF-8 compile` 实际通过。
|
||||
|
||||
联合 Maven test 在 testCompile 阶段被原有 `src/test/java/hard/com/bsd/pjcloud/hard/WechatPayTest.java:39` 的未定义 `log` 阻塞,未修改该无关测试。使用该模块 Maven 已解析的依赖类路径、当前 Maven 编译的主代码,在临时目录以 javac 编译本次定向测试,并通过 JUnit Platform 1.10.5 执行:
|
||||
|
||||
- 12 个测试类,共发现 88 项,实际执行 **87 项全部通过,0 失败、0 中止**。
|
||||
- 1 项原订单统计百万行压测因 `hard.order.statistics.performance` 未开启而跳过,不计为通过。
|
||||
- 其中新账本 SQL 13 项执行真实 XML、租户和分页插件,验证商户合计、部门合计与独立原始账本基准一致;覆盖半开边界、多流水、正负冲正、零金额、非订单退款、精确大整数关联、软删/缺失资料、钱包类型、权限和跨租户。
|
||||
- Service 与导出回归覆盖元数据分批查询及失败、半开自然日分片、空商户分组跨片合并、月导出跨 200 分组边界、真实 XLSX 全部金额/次数列和合计、重复导出不污染原统计,以及明细导出跨批及失败不响应残缺文件。
|
||||
- 现有订单统计 SQL、权限、Service 和 Controller 定向回归同时通过;旧订单查询的通过不作为新账本口径正确性依据。
|
||||
|
||||
后端运行日志:`/var/folders/ng/w0f04t956sb_29sq0_pbw63c0000gn/T/pj-wallet-ledger-regression-gyqchw17/test.log`。该路径为临时验证文件,不是发布依赖。
|
||||
|
||||
管理端相关 Node 测试 **15/15 通过**,目标 ESLint 检查与四个 Vue SFC 模板编译通过,两仓库变更的 whitespace 检查通过。
|
||||
|
||||
## 生产核对边界
|
||||
|
||||
SQL 执行正确性使用 H2 的 MySQL 兼容模式;另验证未经替换的 MySQL 源 SQL 能被生产使用的 SQL 解析器解析。未连接生产 MySQL/PolarDB,未执行生产查询计划或新查询的大范围压测,不承诺生产时延。
|
||||
|
||||
线上用户报告的旧金额差 147150 分、次数差 228 尚未逐笔归因。使用 [只读对账脚本](20261022_wallet_ledger_reconciliation.sql),指定租户后核对原始账本、新商户/部门分组、旧订单时间/部门快照双向差集。脚本默认租户 NULL,空结果不能解释为零流水。
|
||||
|
||||
上线前应以实际 Hardware 数据库账号确认跨库 SELECT 权限,并在真实 PolarDB 上检查时间区间扫描、订单/售后主键关联与耗时;本次不自动执行索引或数据变更。新报表不再调用 Wallet 部门聚合 RPC,无需先发布 Wallet 服务。前端和 Hardware 应配套发布以保持时间标签和实际语义一致。本次尚未发布上线。
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package com.bsd.pjcloud.hard.api.vo.order.excel;
|
||||
|
||||
import com.alibaba.excel.annotation.ExcelProperty;
|
||||
import com.alibaba.excel.annotation.write.style.ColumnWidth;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/** 单商户钱包支付及退款逐笔对账导出;金额均为保留原始正负号的元值,主键以文本保存。 */
|
||||
@Data
|
||||
@ColumnWidth(24)
|
||||
public class HardWalletLedgerDetailExcelVo {
|
||||
/** 本次附件内从一开始的流水序号;合计行为空。 */
|
||||
@ExcelProperty("序号")
|
||||
private Long lineNumber;
|
||||
/** 钱包流水主键文本,避免 Excel 对大整数取舍导致无法对账。 */
|
||||
@ExcelProperty("钱包流水编号")
|
||||
private String transactionId;
|
||||
/** 流水业务类型中文名称,支付和退款分别独立导出。 */
|
||||
@ExcelProperty("流水类型")
|
||||
private String bizType;
|
||||
/** 钱包流水实际发生时间,使用 yyyy-MM-dd HH:mm:ss 文本。 */
|
||||
@ExcelProperty("流水发生时间")
|
||||
private String createTime;
|
||||
/** PAYMENT 流水价格取反后的支付金额,单位元;支付冲正可以为负。 */
|
||||
@ExcelProperty("支付金额(元)")
|
||||
private BigDecimal paymentPrice;
|
||||
/** REFUND 流水原始价格,单位元;退款扣回或冲正可以为负。 */
|
||||
@ExcelProperty("退款金额(元)")
|
||||
private BigDecimal refundPrice;
|
||||
/** 钱包流水原始有符号价格,单位元,用于与钱包账本逐笔核对。 */
|
||||
@ExcelProperty("钱包变动金额(元)")
|
||||
private BigDecimal ledgerPrice;
|
||||
/** 流水归属的真实商户主键文本。 */
|
||||
@ExcelProperty("商户编号")
|
||||
private String merchantId;
|
||||
/** 对应订单主键文本;退款通过售后记录找到原订单。 */
|
||||
@ExcelProperty("订单编号")
|
||||
private String orderId;
|
||||
/** 对应订单业务单号,历史数据允许为空。 */
|
||||
@ExcelProperty("订单号")
|
||||
private String orderNo;
|
||||
/** REFUND 关联的售后主键文本,PAYMENT 行为空。 */
|
||||
@ExcelProperty("售后编号")
|
||||
private String afterSaleId;
|
||||
/** wallet_transaction.biz_id 原始业务编号文本。 */
|
||||
@ExcelProperty("流水关联业务编号")
|
||||
private String bizId;
|
||||
/** 流水发生时固化的部门主键文本,不替换为用户当前部门。 */
|
||||
@ExcelProperty("流水历史部门编号")
|
||||
private String deptId;
|
||||
/** 本笔交易的钱包主键文本。 */
|
||||
@ExcelProperty("钱包编号")
|
||||
private String walletId;
|
||||
/** 本笔交易的用户主键文本。 */
|
||||
@ExcelProperty("用户编号")
|
||||
private String userId;
|
||||
}
|
||||
+13
-89
@@ -12,7 +12,6 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.bsd.pjcloud.admin.api.dto.UserInfo;
|
||||
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
|
||||
import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum;
|
||||
import com.bsd.pjcloud.common.core.util.BeanUtils;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.common.data.datascope.DataScope;
|
||||
import com.bsd.pjcloud.common.log.annotation.SysLog;
|
||||
@@ -25,7 +24,6 @@ import com.bsd.pjcloud.hard.api.dto.order.HardDeptTotalDto;
|
||||
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
|
||||
import com.bsd.pjcloud.hard.api.vo.HardDoorFlowVo;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.*;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.excel.HardMchOrderExcelVo;
|
||||
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
|
||||
import com.bsd.pjcloud.hard.service.HardManagerService;
|
||||
import com.bsd.pjcloud.hard.service.HardOrderService;
|
||||
@@ -62,6 +60,9 @@ public class HardOrderTotalController {
|
||||
/** 复用原模板的后台分批导出。 */
|
||||
private final com.bsd.pjcloud.hard.service.impl.order.HardBatchExportService batchExportService;
|
||||
|
||||
/** 单商户明细按钱包账本导出,与商户汇总复用筛选和权限。 */
|
||||
private final com.bsd.pjcloud.hard.service.impl.order.HardWalletLedgerDetailExportService walletLedgerDetailExportService;
|
||||
|
||||
private final HardOrderService hardOrderService;
|
||||
private final HardOrderTotalService hardOrderTotalService;
|
||||
private final HardManagerService hardManagerService;
|
||||
@@ -261,95 +262,18 @@ public class HardOrderTotalController {
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "单个商户交易流水导出", description = "单个商户交易流水导出")
|
||||
/**
|
||||
* 导出单个真实商户的逐笔钱包支付、退款流水,时间、部门、钱包和权限口径与商户汇总一致。
|
||||
*
|
||||
* @param response 完整 XLSX 附件响应
|
||||
* @param dto 必须指定 merchantId 的页面筛选条件,时间采用流水发生时间半开区间
|
||||
* @throws Exception 流水查询、完整附件生成或响应失败时抛出
|
||||
*/
|
||||
@Operation(summary = "单个商户交易流水导出", description = "逐笔钱包支付和退款流水,保留原始正负号")
|
||||
@PostMapping("/export-mch-cs")
|
||||
@SysLog("单个商户交易流水导出")
|
||||
public void exportMchCs(HttpServletResponse response, @RequestBody HardOrderDto dto) throws IOException {
|
||||
dto.setOrderStatusCodes(HardOrderStatusEnum.getPayPrice());
|
||||
// 查询商户和下面的子商户
|
||||
List<Long> mchDtoIds = new ArrayList<>();
|
||||
if (ObjectUtil.isNotEmpty(dto.getMerchantId())) {
|
||||
List<MchManageVo> mchList = mchService.getByParentId(dto.getMerchantId()).getData();
|
||||
if (ObjectUtil.isNotEmpty(mchList)) {
|
||||
List<Long> mchIds = mchList.stream().map(MchManageVo::getId).toList();
|
||||
mchDtoIds.add(dto.getMerchantId());
|
||||
mchDtoIds.addAll(mchIds);
|
||||
dto.setMerchantId(null);
|
||||
dto.setMchIds(mchDtoIds);
|
||||
}
|
||||
}
|
||||
// 查询商户
|
||||
MchManageVo mch = mchService.getById(dto.getMerchantId()).getData();
|
||||
if (ObjectUtil.isEmpty(mch)) throw new RuntimeException("商户不存在");
|
||||
|
||||
Long count = hardOrderMapper.count(dto, DataScope.of());
|
||||
if (count > 5 * 10000) throw new RuntimeException("导出数据超出100000条,请缩小查询范围");
|
||||
|
||||
try (ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream()).withTemplate(ResourceUtil.getStream("file/商户交易流水导出.xlsx")).build()) {
|
||||
WriteSheet writeSheet = EasyExcel.writerSheet().build();
|
||||
FillConfig fillConfig = FillConfig.builder().forceNewRow(Boolean.TRUE).build();
|
||||
Map<String, Object> map = new HashMap<String, Object>();
|
||||
if (ObjectUtil.isNotEmpty(dto.getPayTimeList())) {
|
||||
map.put("timeSpan", " 到 ");
|
||||
map.put("startTime", LocalDateTimeUtil.format(dto.getPayTimeList()[0], DatePattern.NORM_DATETIME_PATTERN));
|
||||
map.put("endTime", LocalDateTimeUtil.format(dto.getPayTimeList()[1], DatePattern.NORM_DATETIME_PATTERN));
|
||||
}
|
||||
map.put("merchantName", mch.getName());
|
||||
// 统计
|
||||
BigDecimal totalPriceSum = BigDecimal.ZERO;
|
||||
BigDecimal extraPriceSum = BigDecimal.ZERO;
|
||||
BigDecimal payPriceSum = BigDecimal.ZERO;
|
||||
BigDecimal refundPriceSum = BigDecimal.ZERO;
|
||||
// 列号
|
||||
Long lineNumber = 1L;
|
||||
// 分页相关
|
||||
long currentPage = 1;
|
||||
long pageSize = 1000;
|
||||
|
||||
while (true) {
|
||||
Page<HardOrderVo> page = hardOrderService.scopePageList(new Page(currentPage, pageSize), dto, DataScope.of());
|
||||
List<HardOrderVo> records = page.getRecords();
|
||||
if (ObjectUtil.isEmpty(records)) break;
|
||||
List<HardMchOrderExcelVo> excelList = BeanUtils.toBean(records, HardMchOrderExcelVo.class);
|
||||
// 处理excel start
|
||||
for (HardMchOrderExcelVo v : excelList) {
|
||||
v.setLineNumber(lineNumber);
|
||||
lineNumber++;
|
||||
if (ObjectUtil.isNotEmpty(v.getTotalPrice())) {
|
||||
totalPriceSum = totalPriceSum.add(BigDecimal.valueOf(v.getTotalPrice()));
|
||||
v.setTotalPrice(BigDecimal.valueOf(v.getTotalPrice()).divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).doubleValue());
|
||||
}
|
||||
if (ObjectUtil.isNotEmpty(v.getExtraPrice())) {
|
||||
extraPriceSum = extraPriceSum.add(BigDecimal.valueOf(v.getExtraPrice()));
|
||||
v.setExtraPrice(BigDecimal.valueOf(v.getExtraPrice()).divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).doubleValue());
|
||||
}
|
||||
if (ObjectUtil.isNotEmpty(v.getPayPrice())) {
|
||||
payPriceSum = payPriceSum.add(BigDecimal.valueOf(v.getPayPrice()));
|
||||
v.setPayPrice(BigDecimal.valueOf(v.getPayPrice()).divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).doubleValue());
|
||||
}
|
||||
if (ObjectUtil.isNotEmpty(v.getRefundPrice())) {
|
||||
refundPriceSum = refundPriceSum.add(BigDecimal.valueOf(v.getRefundPrice()));
|
||||
v.setRefundPrice(BigDecimal.valueOf(v.getRefundPrice()).divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).doubleValue());
|
||||
}
|
||||
if (ObjectUtil.isNotEmpty(v.getPayType())) {
|
||||
HardPayTypeEnum hardPayTypeEnum = HardPayTypeEnum.valueOfByType(v.getPayType());
|
||||
if (ObjectUtil.isNotEmpty(hardPayTypeEnum)) {
|
||||
v.setPayType(hardPayTypeEnum.getDescription());
|
||||
}
|
||||
}
|
||||
}
|
||||
excelWriter.fill(excelList, fillConfig, writeSheet);
|
||||
// 处理excel end
|
||||
if (page.getTotal() < pageSize) break;
|
||||
currentPage++;
|
||||
}
|
||||
map.put("totalPriceSum", totalPriceSum.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
|
||||
map.put("extraPriceSum", extraPriceSum.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
|
||||
map.put("payPriceSum", payPriceSum.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
|
||||
map.put("refundPriceSum", refundPriceSum.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
|
||||
|
||||
excelWriter.fill(map, writeSheet);
|
||||
}
|
||||
public void exportMchCs(HttpServletResponse response, @RequestBody HardOrderDto dto) throws Exception {
|
||||
walletLedgerDetailExportService.export(response, dto);
|
||||
}
|
||||
|
||||
@Operation(summary = "商户交易记录导出", description = "商户交易记录导出")
|
||||
|
||||
+4
-1
@@ -7,7 +7,10 @@ import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 部门报表在数据库还原支付历史部门,只将部门级聚合结果返回应用。 */
|
||||
/**
|
||||
* 旧订单统计口径,仅保留历史性能对照;当前部门页面和导出使用 HardWalletLedgerStatisticsMapper。
|
||||
* 此查询按订单创建时间、订单金额和每单一次统计,不代表钱包账本口径。
|
||||
*/
|
||||
@Mapper
|
||||
public interface HardDepartmentStatisticsMapper {
|
||||
List<HardOrderSumAndCountVo> paymentStatistics(@Param("dto") HardOrderSumAndCountDto dto,
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.bsd.pjcloud.hard.mapper.order;
|
||||
|
||||
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
|
||||
import com.bsd.pjcloud.hard.mapper.order.model.HardOrderStatisticsScope;
|
||||
import com.bsd.pjcloud.hard.mapper.order.model.HardWalletLedgerDetail;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 单商户流水导出与商户统计复用同一账本、关联及权限 SQL,避免导出重新回到订单口径。 */
|
||||
@Mapper
|
||||
public interface HardWalletLedgerDetailMapper {
|
||||
/** 按流水时间、历史部门、钱包及服务端权限计算导出总条数,不限制金额符号。 */
|
||||
long count(@Param("dto") HardOrderDto dto, @Param("scope") HardOrderStatisticsScope scope);
|
||||
|
||||
/** 按流水主键倒序查询最多 size 条;beforeId 为空表示第一批,否则只读取更小主键。 */
|
||||
List<HardWalletLedgerDetail> listBatch(@Param("dto") HardOrderDto dto, @Param("scope") HardOrderStatisticsScope scope,
|
||||
@Param("beforeId") Long beforeId, @Param("size") int size);
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.bsd.pjcloud.hard.mapper.order;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.HardDeptTotalVo;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.HardOrderCsVo;
|
||||
import com.bsd.pjcloud.hard.mapper.order.model.HardOrderStatisticsScope;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 钱包流水是金额、次数、发生时间与用户部门的唯一事实来源,订单只提供商户归属及权限。 */
|
||||
@Mapper
|
||||
public interface HardWalletLedgerStatisticsMapper {
|
||||
List<HardOrderCsVo> merchantStatistics(@Param("dto") HardOrderDto dto,
|
||||
@Param("scope") HardOrderStatisticsScope scope);
|
||||
|
||||
Page<HardOrderCsVo> merchantStatisticsByMonth(Page<HardOrderCsVo> page,
|
||||
@Param("dto") HardOrderDto dto,
|
||||
@Param("scope") HardOrderStatisticsScope scope);
|
||||
|
||||
List<HardDeptTotalVo> departmentStatistics(@Param("dto") HardOrderDto dto,
|
||||
@Param("scope") HardOrderStatisticsScope scope);
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.bsd.pjcloud.hard.mapper.order.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 钱包统计同一事实集合中的逐笔流水,价格保持数据库分值,不受订单状态及订单金额影响。 */
|
||||
@Data
|
||||
public class HardWalletLedgerDetail {
|
||||
/** 钱包流水唯一主键,同时用作降序游标。 */
|
||||
private Long transactionId;
|
||||
/** 钱包业务类型持久化编码,4 为支付、5 为退款。 */
|
||||
private String bizType;
|
||||
/** 钱包流水实际发生时间。 */
|
||||
private LocalDateTime createTime;
|
||||
/** 钱包有符号变动金额,单位分;异常空金额按统计口径视为零。 */
|
||||
private Long price;
|
||||
/** 同租户、同钱包且精确业务编号关联到的订单所属真实商户。 */
|
||||
private Long merchantId;
|
||||
/** 关联订单主键,退款由售后记录关联到原订单。 */
|
||||
private Long orderId;
|
||||
/** 关联订单业务单号,历史数据允许为空。 */
|
||||
private String orderNo;
|
||||
/** 退款流水关联的售后主键,支付流水为空。 */
|
||||
private String afterSaleId;
|
||||
/** 钱包流水原始关联业务编号,不做数值化截断。 */
|
||||
private String bizId;
|
||||
/** 钱包流水保存的历史部门主键,允许为空。 */
|
||||
private Long deptId;
|
||||
/** 钱包流水保存的钱包主键,允许历史关联元数据缺失。 */
|
||||
private Long walletId;
|
||||
/** 钱包流水保存的用户主键。 */
|
||||
private Long userId;
|
||||
}
|
||||
+77
-65
@@ -110,7 +110,7 @@ public class HardBatchExportService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 提交部门交易导出,当前历史部门快照归属规则不变。 */
|
||||
/** 部门导出与页面共用钱包账本口径。 */
|
||||
public AsyncTaskProgressVO submitDepartment(HardDeptTotalDto dto) {
|
||||
HardDeptTotalDto query = BeanUtil.toBean(dto, HardDeptTotalDto.class);
|
||||
return tasks.submitWithResult("hard:export:department", "部门交易记录导出", 1,
|
||||
@@ -134,84 +134,95 @@ public class HardBatchExportService {
|
||||
}, workbook -> workbook.getSheetAt(0).getRow(1).getCell(16).setCellValue("消费净金额"));
|
||||
}
|
||||
|
||||
/** 按原有年度、商户筛选取商户编号,再以每五家为一组读取月份数据。 */
|
||||
/** 按相同账本范围读取月份分组,不再从订单枚举商户,保留退款单边及未归属。 */
|
||||
private <TResult> TResult monthly(HardOrderDto query, AsyncTaskProgressReporter reporter,
|
||||
BatchExcelExportTemplate.FileConsumer<TResult> consumer) throws Exception {
|
||||
BatchExcelExportTemplate.FileConsumer<TResult> consumer) throws Exception {
|
||||
return writeMerchantMonths(totals, query, reporter, consumer);
|
||||
}
|
||||
|
||||
/** 只保留每个商户的十二个月聚合值,明细始终在数据库聚合,金额累计全程使用分。 */
|
||||
static <TResult> TResult writeMerchantMonths(HardOrderTotalServiceImpl totals, HardOrderDto query,
|
||||
AsyncTaskProgressReporter reporter,
|
||||
BatchExcelExportTemplate.FileConsumer<TResult> consumer) throws Exception {
|
||||
int year;
|
||||
try { year = Integer.parseInt(query.getPayTimeYearStr()); }
|
||||
catch (Exception exception) { throw new AsyncTaskException("请选择导出年份"); }
|
||||
HardSelectOrderMerchIdsDto selection = new HardSelectOrderMerchIdsDto();
|
||||
if (query.getMerchantId() != null) selection.setMerchIds(List.of(query.getMerchantId()));
|
||||
selection.setMerchTypes(query.getMchTypeCodes());
|
||||
selection.setCreateTimeList(query.getCreateTimeList());
|
||||
selection.setPayTimeYearStr(query.getPayTimeYearStr());
|
||||
List<Long> ids = mapper.selectOrderMerchIds(selection);
|
||||
if (ids.isEmpty()) throw new AsyncTaskException("当前筛选条件没有可导出的商户交易");
|
||||
if (reporter != null) reporter.updateTotal(BatchExcelExportTemplate.calculateProgressTotal(ids.size(), 5, 2), "正在按商户分批汇总月份");
|
||||
String merchantName = "所有";
|
||||
if (query.getMerchantId() != null) {
|
||||
R<MchManageVo> result = mchService.getById(query.getMerchantId());
|
||||
if (result == null || !result.isOk()) throw new AsyncTaskException("查询商户名称失败,请重试");
|
||||
if (result.getData() != null) merchantName = result.getData().getName();
|
||||
try { year = Year.parse(query.getPayTimeYearStr()).getValue(); }
|
||||
catch (RuntimeException exception) { throw new AsyncTaskException("请选择导出年份"); }
|
||||
Map<Long, BigDecimal[]> byMerchant = new LinkedHashMap<>();
|
||||
Map<Long, String> names = new HashMap<>();
|
||||
BigDecimal[] monthSums = sums(24);
|
||||
Function<Long, Page<HardOrderCsVo>> monthPages = totals.monthlyExportPages(query);
|
||||
for (long current = 1; ; current++) {
|
||||
Page<HardOrderCsVo> page = monthPages.apply(current);
|
||||
if (current == 1 && reporter != null)
|
||||
reporter.updateTotal(BatchExcelExportTemplate.calculateProgressTotal(page.getTotal(), BatchExportSupport.BATCH_SIZE, 2),
|
||||
"正在按钱包流水汇总月份");
|
||||
for (HardOrderCsVo record : page.getRecords()) {
|
||||
YearMonth month = YearMonth.parse(record.getDay());
|
||||
if (month.getYear() != year) throw new IllegalStateException("月份统计超出所选年份");
|
||||
int index = (month.getMonthValue() - 1) * 2;
|
||||
BigDecimal[] columns = byMerchant.computeIfAbsent(record.getMerchantId(), ignored -> sums(24));
|
||||
BigDecimal price = record.getTotalProfitPrice();
|
||||
BigDecimal count = record.getTotalProfitCount();
|
||||
columns[index] = columns[index].add(price);
|
||||
columns[index + 1] = columns[index + 1].add(count);
|
||||
monthSums[index] = monthSums[index].add(price);
|
||||
monthSums[index + 1] = monthSums[index + 1].add(count);
|
||||
names.put(record.getMerchantId(), record.getMerchantName());
|
||||
}
|
||||
if (reporter != null) reporter.advance("已读取第 " + current + " 批月份分组");
|
||||
if (!page.hasNext()) break;
|
||||
if (page.getRecords().isEmpty()) throw new IllegalStateException("月份统计分页结果不完整");
|
||||
}
|
||||
if (byMerchant.isEmpty()) throw new AsyncTaskException("当前筛选条件没有可导出的商户交易");
|
||||
Map<String, Object> values = new HashMap<>();
|
||||
values.put("title", year + "年" + merchantName + "合作商家流水情况统计表");
|
||||
List<HardOrderCsVo.Month> monthTotals = new ArrayList<>();
|
||||
for (int month = 1; month <= 12; month++) {
|
||||
HardOrderCsVo.Month item = new HardOrderCsVo.Month();
|
||||
item.setDay(String.format("%04d-%02d", year, month));
|
||||
item.setTotalProfitPrice(0); item.setTotalProfitCount(0);
|
||||
monthTotals.add(item);
|
||||
String merchantName = query.getMerchantId() == null ? "所有"
|
||||
: names.getOrDefault(query.getMerchantId(), "所选");
|
||||
values.put("title", year + "年" + merchantName + "合作商家钱包流水情况统计表");
|
||||
BigDecimal yearPrice = BigDecimal.ZERO;
|
||||
BigDecimal yearCount = BigDecimal.ZERO;
|
||||
for (int index = 0; index < 24; index += 2) {
|
||||
values.put("total" + index, yuan(monthSums[index]));
|
||||
values.put("total" + (index + 1), monthSums[index + 1].longValueExact());
|
||||
yearPrice = yearPrice.add(monthSums[index]);
|
||||
yearCount = yearCount.add(monthSums[index + 1]);
|
||||
}
|
||||
Function<Long, Page<Map<String, Object>>> fetcher = current -> {
|
||||
int start = Math.toIntExact((current - 1) * BatchExportSupport.BATCH_SIZE);
|
||||
int end = Math.min(start + BatchExportSupport.BATCH_SIZE, ids.size());
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for (int from = start; from < end; from += 5) {
|
||||
List<Long> batch = ids.subList(from, Math.min(from + 5, end));
|
||||
HardOrderDto slice = BeanUtil.toBean(query, HardOrderDto.class);
|
||||
slice.setMchIds(new ArrayList<>(batch));
|
||||
Page<HardOrderCsVo> page = totals.pageListCsByMonth(new Page<>(1, 200), slice);
|
||||
R<List<MchManageVo>> result = mchService.getByIds(batch);
|
||||
if (result == null || !result.isOk()) throw new AsyncTaskException("查询商户名称失败,请重试");
|
||||
Map<Long, String> names = new HashMap<>();
|
||||
if (result.getData() != null) result.getData().forEach(item -> names.put(item.getId(), item.getName()));
|
||||
for (Long id : batch) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("col0", names.getOrDefault(id, "未知商户"));
|
||||
List<Map<String, Object>> columns = totals.disposeCsByMonth(id, page.getRecords(), monthTotals, year);
|
||||
for (int column = 0; column < columns.size(); column++) row.put("col" + (column + 1), columns.get(column).get("col"));
|
||||
rows.add(row);
|
||||
}
|
||||
if (reporter != null) reporter.advance("已汇总 " + Math.min(from + 5, end) + " / " + ids.size() + " 家商户");
|
||||
values.put("total24", yuan(yearPrice));
|
||||
values.put("total25", yearCount.longValueExact());
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
byMerchant.forEach((id, columns) -> {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("col0", names.getOrDefault(id, id == null ? "非订单/未归属" : "历史商户(" + id + ")"));
|
||||
BigDecimal price = BigDecimal.ZERO;
|
||||
BigDecimal count = BigDecimal.ZERO;
|
||||
for (int index = 0; index < 24; index += 2) {
|
||||
row.put("col" + (index + 1), yuan(columns[index]));
|
||||
row.put("col" + (index + 2), columns[index + 1].longValueExact());
|
||||
price = price.add(columns[index]);
|
||||
count = count.add(columns[index + 1]);
|
||||
}
|
||||
double price = 0; long count = 0;
|
||||
for (int i = 0; i < monthTotals.size(); i++) {
|
||||
HardOrderCsVo.Month month = monthTotals.get(i);
|
||||
values.put("total" + (i * 2), month.getTotalProfitPrice());
|
||||
values.put("total" + (i * 2 + 1), month.getTotalProfitCount());
|
||||
price += month.getTotalProfitPrice(); count += month.getTotalProfitCount();
|
||||
}
|
||||
values.put("total24", price); values.put("total25", count);
|
||||
Page<Map<String, Object>> page = new Page<>(current, BatchExportSupport.BATCH_SIZE);
|
||||
page.setTotal(ids.size()); page.setRecords(rows);
|
||||
return page;
|
||||
};
|
||||
return BatchExportSupport.template("file/商户交易金额汇总(按月).xlsx", values, fetcher, row -> row, null, file -> {
|
||||
if (reporter != null) reporter.advance("按月汇总文件已生成,正在保存导出结果");
|
||||
row.put("col25", yuan(price));
|
||||
row.put("col26", count.longValueExact());
|
||||
rows.add(row);
|
||||
});
|
||||
return BatchExportSupport.template("file/商户交易金额汇总(按月).xlsx", values, pages(rows), row -> row, null, file -> {
|
||||
if (reporter != null) reporter.advance("按月钱包流水文件已生成");
|
||||
return consumer.consume(file);
|
||||
}, workbook -> {
|
||||
// 原模板的合计横向填充改为等价占位符,直接流式写入,避免生成中间内存工作簿。
|
||||
Sheet sheet = workbook.getSheetAt(0);
|
||||
Row footer = sheet.getRow(5);
|
||||
CellStyle style = footer.getCell(1).getCellStyle();
|
||||
for (int i = 0; i < 26; i++) {
|
||||
Cell cell = footer.getCell(i + 1, Row.MissingCellPolicy.CREATE_NULL_AS_BLANK);
|
||||
cell.setCellStyle(style); cell.setCellValue("{total" + i + "}");
|
||||
for (int index = 0; index < 26; index++) {
|
||||
Cell cell = footer.getCell(index + 1, Row.MissingCellPolicy.CREATE_NULL_AS_BLANK);
|
||||
cell.setCellStyle(style);
|
||||
cell.setCellValue("{total" + index + "}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static BigDecimal yuan(BigDecimal cents) {
|
||||
return cents.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
/** 聚合结果仅含商户或部门行;业务明细已在查询阶段按批释放。 */
|
||||
private static <T> Function<Long, Page<T>> pages(List<T> rows) {
|
||||
return current -> {
|
||||
@@ -269,7 +280,8 @@ public class HardBatchExportService {
|
||||
}
|
||||
|
||||
/** 沿用旧 exportTk 的字段、数值类型及合计规则。 */
|
||||
private Map<String, Object> merchantRow(HardOrderCsVo v, Map<String, Object> values, BigDecimal[] sums) {
|
||||
private Map<String, Object> merchantRow(HardOrderCsVo source, Map<String, Object> values, BigDecimal[] sums) {
|
||||
HardOrderCsVo v = BeanUtil.toBean(source, HardOrderCsVo.class);
|
||||
if (ObjectUtil.isNotEmpty(v.getHardType())) {
|
||||
v.setHardType(HardTypeEnum.valueOfByType(v.getHardType()).getDescription());
|
||||
}
|
||||
|
||||
+139
-539
@@ -26,7 +26,6 @@ import com.bsd.pjcloud.common.core.util.CollectionUtils;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.common.data.datascope.DataScope;
|
||||
import com.bsd.pjcloud.common.data.datascope.DataScopeFuncEnum;
|
||||
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
|
||||
import com.bsd.pjcloud.common.security.service.PjcloudUser;
|
||||
import com.bsd.pjcloud.common.security.util.SecurityUtils;
|
||||
import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
|
||||
@@ -41,17 +40,15 @@ import com.bsd.pjcloud.hard.api.utils.HardUtil;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.*;
|
||||
import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper;
|
||||
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
|
||||
import com.bsd.pjcloud.hard.mapper.order.HardDepartmentStatisticsMapper;
|
||||
import com.bsd.pjcloud.hard.mapper.order.HardWalletLedgerStatisticsMapper;
|
||||
import com.bsd.pjcloud.hard.mapper.order.model.HardOrderStatisticsScope;
|
||||
import com.bsd.pjcloud.hard.service.order.HardWalletLedgerQuerySupport;
|
||||
import com.bsd.pjcloud.hard.service.HardManagerService;
|
||||
import com.bsd.pjcloud.hard.service.HardOrderTotalService;
|
||||
import com.bsd.pjcloud.hard.utils.RemoteBatchQueryUtil;
|
||||
import com.bsd.pjcloud.wallet.api.dto.WalletTransactionSumAndCountDto;
|
||||
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
|
||||
import com.bsd.pjcloud.wallet.api.feign.RemoteWalletTransactionService;
|
||||
import com.bsd.pjcloud.wallet.api.utils.WalletDataUtil;
|
||||
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
|
||||
import com.bsd.pjcloud.wallet.api.vo.WalletDepartmentStatisticsVo;
|
||||
import com.bsd.pjcloud.wallet.api.vo.WalletTransactionSumAndCountVo;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -84,9 +81,9 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService {
|
||||
private final HardManagerService hardManagerService;
|
||||
|
||||
private final RemoteMchService remoteMchService;
|
||||
private final RemoteWalletTransactionService remoteWalletTransactionService;
|
||||
private final RemoteDeptService remoteDeptService;
|
||||
private final HardDepartmentStatisticsMapper departmentStatisticsMapper;
|
||||
private final HardWalletLedgerStatisticsMapper ledgerStatisticsMapper;
|
||||
private final HardWalletLedgerQuerySupport ledgerQuerySupport;
|
||||
|
||||
// private final DelegatingSecurityContextExecutor securityContextExecutor;
|
||||
|
||||
@@ -233,205 +230,75 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService {
|
||||
|
||||
private List<HardOrderCsVo> listCsInternal(HardOrderDto dto, boolean exporting, AsyncTaskProgressReporter reporter) {
|
||||
StopWatch timing = new StopWatch("hard-total/page-list-cs");
|
||||
timing.start("merchantTypePermission");
|
||||
PjcloudUser user = SecurityUtils.getUser();
|
||||
if (ObjectUtil.isEmpty(dto.getMchTypeCodes())) {
|
||||
CurrentUserHardTypeInfoDto hardTypeInfo = hardManagerService.getCurrentUserHardTypeInfo(user);
|
||||
dto.setMchTypeCodes(hardTypeInfo.getHardTypeList());
|
||||
}
|
||||
timing.start("ledgerQuery");
|
||||
HardWalletLedgerQuerySupport.QueryContext context = ledgerQuerySupport.merchant(dto);
|
||||
List<HardOrderCsVo> rows = exporting
|
||||
? queryCsExportSlices(context.dto(), context.scope(), reporter)
|
||||
: ledgerStatisticsMapper.merchantStatistics(context.dto(), context.scope());
|
||||
timing.stop();
|
||||
// 流水与退款的业务发生时间不同,必须分别查询后再按商户合并,避免直接关联造成一笔订单被多笔售后重复放大。
|
||||
HardOrderDto totalDto = BeanUtil.toBean(dto, HardOrderDto.class);
|
||||
totalDto.setOrderStatusCodes(HardOrderStatusEnum.getPayPrice());
|
||||
DataScope totalDataScope = DataScope.of();
|
||||
totalDataScope.setScopeDeptName("merchant_dept_id");
|
||||
timing.start("paymentQueryWithDataScope");
|
||||
List<HardOrderCsVo> totalList = exporting ? queryCsExportSlices(totalDto, false, totalDataScope, reporter) : hardOrderMapper.listCs(totalDto, totalDataScope);
|
||||
timing.start("metadata");
|
||||
enrichLedgerMerchants(rows);
|
||||
timing.stop();
|
||||
// 扣款总流水
|
||||
HardOrderDto refundDto = BeanUtil.toBean(dto, HardOrderDto.class);
|
||||
refundDto.setPayTimeList(null);
|
||||
refundDto.setRefundTimeList(dto.getPayTimeList());
|
||||
// refundStatusCodes 属于订单退款状态,售后完成状态必须使用 saleStatusCodes,二者混用会把有效退款全部过滤掉。
|
||||
// 部分退款后订单仍是已支付状态,退款统计不能只查询整单退款状态的订单。
|
||||
refundDto.setOrderStatusCodes(HardOrderStatusEnum.getPayPrice());
|
||||
refundDto.setRefundStatusCodes(List.of(HardSaleStatusEnum.COMPLETE.getStatus()));
|
||||
DataScope refundDataScope = DataScope.of();
|
||||
refundDataScope.setScopeDeptName("merchant_dept_id");
|
||||
timing.start("refundQueryWithDataScope");
|
||||
List<HardOrderCsVo> refundList = exporting ? queryCsExportSlices(refundDto, true, refundDataScope, reporter) : hardOrderMapper.listRefundCs(refundDto, refundDataScope);
|
||||
timing.stop();
|
||||
timing.start("merge");
|
||||
// 保留支付查询的商户顺序,退款用 Map 线性合并;整个区间只有退款时也必须显示。
|
||||
Map<Long, HardOrderCsVo> totalsByMerchant = new LinkedHashMap<>();
|
||||
for (HardOrderCsVo total : totalList) {
|
||||
totalsByMerchant.put(total.getMerchantId(), total);
|
||||
}
|
||||
for (HardOrderCsVo refund : refundList) {
|
||||
HardOrderCsVo total = totalsByMerchant.computeIfAbsent(refund.getMerchantId(), id -> {
|
||||
HardOrderCsVo row = BeanUtils.toBean(refund, HardOrderCsVo.class);
|
||||
row.setTotalPayPrice(BigDecimal.ZERO);
|
||||
row.setTotalPayCount(BigDecimal.ZERO);
|
||||
return row;
|
||||
});
|
||||
total.setTotalRefundPrice(refund.getTotalPayPrice());
|
||||
total.setTotalRefundCount(refund.getTotalPayCount());
|
||||
}
|
||||
totalList = new ArrayList<>(totalsByMerchant.values());
|
||||
for (HardOrderCsVo v : totalList) {
|
||||
// 消费净金额
|
||||
if (ObjectUtil.isNotEmpty(v.getTotalPayPrice())) {
|
||||
BigDecimal refundPrice = BigDecimal.ZERO;
|
||||
if (ObjectUtil.isNotEmpty(v.getTotalRefundPrice())) {
|
||||
refundPrice = v.getTotalRefundPrice();
|
||||
}
|
||||
v.setTotalProfitPrice(v.getTotalPayPrice().subtract(refundPrice));
|
||||
}
|
||||
// 消费净次数
|
||||
if (ObjectUtil.isNotEmpty(v.getTotalPayCount())) {
|
||||
BigDecimal refundCount = BigDecimal.ZERO;
|
||||
if (ObjectUtil.isNotEmpty(v.getTotalRefundCount())) {
|
||||
refundCount = v.getTotalRefundCount();
|
||||
}
|
||||
v.setTotalProfitCount(v.getTotalPayCount().subtract(refundCount));
|
||||
}
|
||||
}
|
||||
timing.stop();
|
||||
// 聚合一下数据
|
||||
if (ObjectUtil.isNotEmpty(totalList)) {
|
||||
timing.start("merchantMetadataRpc");
|
||||
// 商户
|
||||
List<Long> mchIds = totalList.stream().map(HardOrderCsVo::getMerchantId).distinct().toList();
|
||||
if (ObjectUtil.isNotEmpty(mchIds)) {
|
||||
List<MchManageVo> mchList = getMchListByIds(mchIds);
|
||||
if (ObjectUtil.isNotEmpty(mchList)) {
|
||||
WalletDataUtil.convertMchList(totalList, CollectionUtils.convertMap(mchList, MchManageVo::getId));
|
||||
computeMchPlatformRanke(totalList, mchList);
|
||||
}
|
||||
}
|
||||
timing.stop();
|
||||
timing.start("departmentMetadataRpc");
|
||||
// 商户部门
|
||||
List<Long> mchDeptIds = totalList.stream().map(HardOrderCsVo::getMerchantDeptId).distinct().toList();
|
||||
if (ObjectUtil.isNotEmpty(mchDeptIds)) {
|
||||
Map<Long, SysDept> deptMap = getDeptMapByIds(mchDeptIds);
|
||||
if (ObjectUtil.isNotEmpty(deptMap)) {
|
||||
for (HardOrderCsVo v : totalList) {
|
||||
if (ObjectUtil.isEmpty(v.getMerchantDeptId())) continue;
|
||||
SysDept targetDept = deptMap.get(v.getMerchantDeptId());
|
||||
if (ObjectUtil.isNotEmpty(targetDept)) {
|
||||
v.setMerchantDeptName(targetDept.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
timing.stop();
|
||||
}
|
||||
if (!exporting && timing.getTotalTimeMillis() >= 1000) {
|
||||
log.info("商户汇总慢查询,商户数={},总耗时={}ms,分段耗时:{}", totalList.size(), timing.getTotalTimeMillis(), timing.prettyPrint());
|
||||
log.info("商户流水统计慢查询,商户数={},总耗时={}ms,分段耗时:{}",
|
||||
rows.size(), timing.getTotalTimeMillis(), timing.prettyPrint());
|
||||
}
|
||||
return totalList;
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 商户交易记录(按商户、月)查询
|
||||
*/
|
||||
/** 支付和退款先按同一账本分组,再分页,退款单边月份也参与 total 和 records。 */
|
||||
@Override
|
||||
public Page<HardOrderCsVo> pageListCsByMonth(Page page, HardOrderDto dto) {
|
||||
PjcloudUser user = SecurityUtils.getUser();
|
||||
if (ObjectUtil.isEmpty(dto.getMchTypeCodes())) {
|
||||
CurrentUserHardTypeInfoDto hardTypeInfo = hardManagerService.getCurrentUserHardTypeInfo(user);
|
||||
dto.setMchTypeCodes(hardTypeInfo.getHardTypeList());
|
||||
} else {
|
||||
dto.setMchTypeCodes(dto.getMchTypeCodes());
|
||||
return queryLedgerMonths(page, ledgerQuerySupport.merchant(dto));
|
||||
}
|
||||
|
||||
/** 长导出只解析一次权限和筛选快照,后续分页沿用同一个账本范围。 */
|
||||
public Function<Long, Page<HardOrderCsVo>> monthlyExportPages(HardOrderDto dto) {
|
||||
HardWalletLedgerQuerySupport.QueryContext context = ledgerQuerySupport.merchant(dto);
|
||||
return current -> queryLedgerMonths(new Page<>(current, 200), context);
|
||||
}
|
||||
|
||||
private Page<HardOrderCsVo> queryLedgerMonths(Page page, HardWalletLedgerQuerySupport.QueryContext context) {
|
||||
Page<HardOrderCsVo> result = ledgerStatisticsMapper.merchantStatisticsByMonth(page, context.dto(), context.scope());
|
||||
enrichLedgerMerchants(result.getRecords());
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 元数据缺失不删除账本行;远程失败则终止,避免将服务故障伪装成历史商户。 */
|
||||
private void enrichLedgerMerchants(List<HardOrderCsVo> rows) {
|
||||
for (HardOrderCsVo row : rows) {
|
||||
row.setTotalPayPrice(ObjectUtil.defaultIfNull(row.getTotalPayPrice(), BigDecimal.ZERO));
|
||||
row.setTotalPayCount(ObjectUtil.defaultIfNull(row.getTotalPayCount(), BigDecimal.ZERO));
|
||||
row.setTotalRefundPrice(ObjectUtil.defaultIfNull(row.getTotalRefundPrice(), BigDecimal.ZERO));
|
||||
row.setTotalRefundCount(ObjectUtil.defaultIfNull(row.getTotalRefundCount(), BigDecimal.ZERO));
|
||||
row.setTotalProfitPrice(row.getTotalPayPrice().subtract(row.getTotalRefundPrice()));
|
||||
row.setTotalProfitCount(row.getTotalPayCount().subtract(row.getTotalRefundCount()));
|
||||
row.setMchPlatformRanke(0D);
|
||||
row.setMchPlatformRankePrice(BigDecimal.ZERO);
|
||||
row.setMerchantName(row.getMerchantId() == null ? "非订单/未归属" : "历史商户(" + row.getMerchantId() + ")");
|
||||
}
|
||||
// 查询总流水
|
||||
HardOrderDto totalDto = BeanUtil.toBean(dto, HardOrderDto.class);
|
||||
totalDto.setOrderStatusCodes(HardOrderStatusEnum.getPayPrice());
|
||||
totalDto.setPayTimeYearStr(dto.getPayTimeYearStr());
|
||||
DataScope totalDataScope = DataScope.of();
|
||||
totalDataScope.setScopeDeptName("merchant_dept_id");
|
||||
Page<HardOrderCsVo> totalPage = hardOrderMapper.pageCsByMonth(page, totalDto, totalDataScope);
|
||||
List<HardOrderCsVo> totalList = totalPage.getRecords();
|
||||
// 扣款总流水
|
||||
HardOrderDto refundDto = BeanUtil.toBean(dto, HardOrderDto.class);
|
||||
refundDto.setPayTimeList(null);
|
||||
refundDto.setRefundTimeList(dto.getPayTimeList());
|
||||
refundDto.setAuditTimeYearStr(dto.getPayTimeYearStr());
|
||||
refundDto.setPayTimeYearStr(null);
|
||||
// 部分退款后订单仍是已支付状态,退款统计不能只查询整单退款状态的订单;审核中的售后单未完成退款,不计入统计。
|
||||
refundDto.setOrderStatusCodes(HardOrderStatusEnum.getPayPrice());
|
||||
refundDto.setRefundStatusCodes(List.of(HardSaleStatusEnum.COMPLETE.getStatus()));
|
||||
if (ObjectUtil.isNotEmpty(dto.getMchIds())) {
|
||||
refundDto.setMchIds(dto.getMchIds());
|
||||
List<Long> ids = rows.stream().map(HardOrderCsVo::getMerchantId).filter(Objects::nonNull).distinct().toList();
|
||||
if (!ids.isEmpty()) {
|
||||
List<MchManageVo> merchants = RemoteBatchQueryUtil.queryByIds(ids, batch -> {
|
||||
R<List<MchManageVo>> response = remoteMchService.getByIds(batch);
|
||||
if (response == null || !response.isOk()) throw new IllegalStateException("查询商户交易统计的商户信息失败");
|
||||
return response.getData();
|
||||
});
|
||||
WalletDataUtil.convertMchList(rows, CollectionUtils.convertMap(merchants, MchManageVo::getId));
|
||||
computeMchPlatformRanke(rows, merchants);
|
||||
}
|
||||
DataScope refundDataScope = DataScope.of();
|
||||
refundDataScope.setScopeDeptName("merchant_dept_id");
|
||||
List<HardOrderCsVo> refundList = hardOrderMapper.listRefundCsByMonth(refundDto, refundDataScope);
|
||||
// 有退款没有流水的情况
|
||||
if (ObjectUtil.isAllNotEmpty(totalList, refundList)) {
|
||||
for (HardOrderCsVo refund : refundList) {
|
||||
HardOrderCsVo target = totalList.stream().filter(v -> ObjectUtil.equals(refund.getMerchantId(), v.getMerchantId()) && ObjectUtil.equals(refund.getDay(), v.getDay())).findFirst().orElse(null);
|
||||
if (ObjectUtil.isEmpty(target)) {
|
||||
HardOrderCsVo entity = BeanUtils.toBean(refund, HardOrderCsVo.class);
|
||||
entity.setTotalPayPrice(BigDecimal.ZERO);
|
||||
entity.setTotalPayCount(BigDecimal.ZERO);
|
||||
totalList.add(entity);
|
||||
}
|
||||
List<Long> departmentIds = rows.stream().map(HardOrderCsVo::getMerchantDeptId).filter(Objects::nonNull).distinct().toList();
|
||||
if (!departmentIds.isEmpty()) {
|
||||
Map<Long, SysDept> departments = RemoteBatchQueryUtil.queryMapByIds(departmentIds, batch -> {
|
||||
R<Map<Long, SysDept>> response = remoteDeptService.getDeptByDeptIds(batch);
|
||||
if (response == null || !response.isOk()) throw new IllegalStateException("查询商户交易统计的部门信息失败");
|
||||
return response.getData();
|
||||
});
|
||||
for (HardOrderCsVo row : rows) {
|
||||
SysDept department = departments.get(row.getMerchantDeptId());
|
||||
if (department != null) row.setMerchantDeptName(department.getName());
|
||||
}
|
||||
}
|
||||
for (HardOrderCsVo v : totalList) {
|
||||
// 退款
|
||||
if (ObjectUtil.isNotEmpty(refundList)) {
|
||||
refundList.stream().filter(i -> ObjectUtil.equals(i.getMerchantId(), v.getMerchantId()) && ObjectUtil.equals(i.getDay(), v.getDay())).findFirst().ifPresent(i -> {
|
||||
v.setTotalRefundPrice(i.getTotalPayPrice());
|
||||
v.setTotalRefundCount(i.getTotalPayCount());
|
||||
});
|
||||
}
|
||||
// 消费净金额
|
||||
if (ObjectUtil.isNotEmpty(v.getTotalPayPrice())) {
|
||||
BigDecimal refundPrice = BigDecimal.ZERO;
|
||||
if (ObjectUtil.isNotEmpty(v.getTotalRefundPrice())) {
|
||||
refundPrice = v.getTotalRefundPrice();
|
||||
}
|
||||
v.setTotalProfitPrice(v.getTotalPayPrice().subtract(refundPrice));
|
||||
}
|
||||
// 消费净次数
|
||||
if (ObjectUtil.isNotEmpty(v.getTotalPayCount())) {
|
||||
BigDecimal refundCount = BigDecimal.ZERO;
|
||||
if (ObjectUtil.isNotEmpty(v.getTotalRefundCount())) {
|
||||
refundCount = v.getTotalRefundCount();
|
||||
}
|
||||
v.setTotalProfitCount(v.getTotalPayCount().subtract(refundCount));
|
||||
}
|
||||
}
|
||||
// 聚合一下数据
|
||||
if (ObjectUtil.isNotEmpty(totalList)) {
|
||||
// 商户
|
||||
List<Long> mchIds = totalList.stream().map(HardOrderCsVo::getMerchantId).distinct().toList();
|
||||
if (ObjectUtil.isNotEmpty(mchIds)) {
|
||||
List<MchManageVo> mchList = getMchListByIds(mchIds);
|
||||
if (ObjectUtil.isNotEmpty(mchList)) {
|
||||
WalletDataUtil.convertMchList(totalList, CollectionUtils.convertMap(mchList, MchManageVo::getId));
|
||||
computeMchPlatformRanke(totalList, mchList);
|
||||
}
|
||||
}
|
||||
// 商户部门
|
||||
List<Long> mchDeptIds = totalList.stream().map(HardOrderCsVo::getMerchantDeptId).distinct().toList();
|
||||
if (ObjectUtil.isNotEmpty(mchDeptIds)) {
|
||||
Map<Long, SysDept> deptMap = getDeptMapByIds(mchDeptIds);
|
||||
if (ObjectUtil.isNotEmpty(deptMap)) {
|
||||
for (HardOrderCsVo v : totalList) {
|
||||
if (ObjectUtil.isEmpty(v.getMerchantDeptId())) continue;
|
||||
SysDept targetDept = deptMap.get(v.getMerchantDeptId());
|
||||
if (ObjectUtil.isNotEmpty(targetDept)) {
|
||||
v.setMerchantDeptName(targetDept.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return totalPage;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -545,193 +412,69 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService {
|
||||
return totalList;
|
||||
}
|
||||
|
||||
/**
|
||||
* 部门交易记录
|
||||
*
|
||||
* <p>钱包操作与退款金额由一次 Wallet 聚合返回;订单支付在数据库按支付流水创建时固化的部门归集,
|
||||
* 现金、第三方支付回退订单部门快照。应用内只保留部门级结果。
|
||||
* 退款金额保留 REFUND 流水原始正负号,审核中的退款尚未生成流水,不会计入任何金额。</p>
|
||||
*
|
||||
* @param dto 当前租户的部门、时间和钱包类型筛选条件
|
||||
* @return 部门交易统计;筛选范围内没有部门时返回空列表
|
||||
* @throws IllegalStateException 部门或钱包远程统计调用失败时抛出,避免返回不完整报表
|
||||
*/
|
||||
/** 部门页面与导出均直接聚合钱包账本,不再混用订单支付和钱包退款。 */
|
||||
@Override
|
||||
public List<HardDeptTotalVo> pageListDept(HardDeptTotalDto dto) {
|
||||
return departmentRows(dto, null);
|
||||
}
|
||||
|
||||
/** 导出和页面共用部门级聚合,原始订单与历史快照不跨服务传输。 */
|
||||
public List<HardDeptTotalVo> listDeptForExport(HardDeptTotalDto dto, AsyncTaskProgressReporter reporter) {
|
||||
return departmentRows(dto, reporter);
|
||||
}
|
||||
|
||||
private List<HardDeptTotalVo> departmentRows(HardDeptTotalDto dto, AsyncTaskProgressReporter reporter) {
|
||||
Long tenantId = TenantContextHolder.getTenantId();
|
||||
if (tenantId == null) throw new IllegalStateException("查询部门交易统计缺少租户上下文");
|
||||
HardWalletLedgerQuerySupport.QueryContext context = ledgerQuerySupport.department(dto);
|
||||
StopWatch timing = new StopWatch("hard-total/page-list-dept");
|
||||
if (reporter != null) reporter.updateTotal(6, "正在汇总部门交易");
|
||||
if (reporter != null) reporter.updateTotal(5, "正在汇总部门交易");
|
||||
timing.start("departmentRpc");
|
||||
List<SysDept> deptList = queryDepartmentList(dto);
|
||||
List<SysDept> departments = queryDepartmentList(dto);
|
||||
timing.stop();
|
||||
if (reporter != null) reporter.advance("部门范围已确认");
|
||||
if (ObjectUtil.isEmpty(deptList)) return List.of();
|
||||
|
||||
timing.start("walletAggregationRpc");
|
||||
List<WalletDepartmentStatisticsVo> walletRows = queryWalletDepartmentStatistics(
|
||||
buildWalletStatisticsDto(dto));
|
||||
timing.start("ledgerAggregation");
|
||||
List<HardDeptTotalVo> aggregates = ledgerStatisticsMapper.departmentStatistics(context.dto(), context.scope());
|
||||
timing.stop();
|
||||
if (reporter != null) reporter.advance("钱包六类流水汇总完成");
|
||||
timing.start("paymentAggregation");
|
||||
List<HardOrderSumAndCountVo> payRows = departmentStatisticsMapper.paymentStatistics(buildPayStatisticsDto(dto), tenantId);
|
||||
timing.stop();
|
||||
if (reporter != null) reporter.advance("订单历史部门归集完成");
|
||||
|
||||
timing.start("merge");
|
||||
Map<Long, WalletDepartmentStatisticsVo> walletMap = CollectionUtils.convertMap(walletRows, WalletDepartmentStatisticsVo::getDeptId);
|
||||
Map<Long, HardOrderSumAndCountVo> payMap = indexOrderStatistics(payRows);
|
||||
List<HardDeptTotalVo> result = new ArrayList<>(deptList.size());
|
||||
for (SysDept dept : deptList) {
|
||||
if (reporter != null) reporter.advance("钱包账本聚合完成");
|
||||
Map<Long, HardDeptTotalVo> rows = new LinkedHashMap<>();
|
||||
// 保留 UPMS 顺序、软删除名称及零交易部门,历史缺失部门则在后面补行。
|
||||
for (SysDept department : departments) {
|
||||
HardDeptTotalVo row = new HardDeptTotalVo();
|
||||
row.setDeptId(dept.getDeptId());
|
||||
row.setDeptName(dept.getName());
|
||||
WalletDepartmentStatisticsVo wallet = walletMap.get(dept.getDeptId());
|
||||
WalletTransactionSumAndCountVo refund = null;
|
||||
if (wallet != null) {
|
||||
row.setWalletPrice(BigDecimal.valueOf(ObjectUtil.defaultIfNull(wallet.getBatchIncomeSum(), 0L)));
|
||||
row.setWalletCount(ObjectUtil.defaultIfNull(wallet.getBatchIncomeCount(), 0L));
|
||||
row.setWalletReducePrice(Math.abs(ObjectUtil.defaultIfNull(wallet.getBatchExpenseSum(), 0L)));
|
||||
row.setWalletReduceCount(ObjectUtil.defaultIfNull(wallet.getBatchExpenseCount(), 0L));
|
||||
row.setWalletOncePrice(BigDecimal.valueOf(ObjectUtil.defaultIfNull(wallet.getOnceIncomeSum(), 0L)));
|
||||
row.setWalletOnceCount(ObjectUtil.defaultIfNull(wallet.getOnceIncomeCount(), 0L));
|
||||
row.setWalletOnceReducePrice(BigDecimal.valueOf(Math.abs(ObjectUtil.defaultIfNull(wallet.getOnceExpenseSum(), 0L))));
|
||||
row.setWalletOnceReduceCount(ObjectUtil.defaultIfNull(wallet.getOnceExpenseCount(), 0L));
|
||||
row.setWalletReturnCanteenPrice(BigDecimal.valueOf(Math.abs(ObjectUtil.defaultIfNull(wallet.getReturnCanteenSum(), 0L))));
|
||||
row.setWalletReturnCanteenCount(ObjectUtil.defaultIfNull(wallet.getReturnCanteenCount(), 0L));
|
||||
refund = new WalletTransactionSumAndCountVo();
|
||||
refund.setSum(wallet.getRefundSum());
|
||||
refund.setCount(wallet.getRefundCount());
|
||||
}
|
||||
applyOrderStatistics(row, payMap.get(dept.getDeptId()), refund);
|
||||
result.add(row);
|
||||
row.setDeptId(department.getDeptId());
|
||||
row.setDeptName(department.getName());
|
||||
rows.put(row.getDeptId(), row);
|
||||
}
|
||||
for (HardDeptTotalVo aggregate : aggregates) {
|
||||
if (dto.getDeptId() != null && ObjectUtil.isEmpty(dto.getDeptIds())
|
||||
&& !Objects.equals(dto.getDeptId(), aggregate.getDeptId())) continue;
|
||||
HardDeptTotalVo existing = rows.get(aggregate.getDeptId());
|
||||
aggregate.setDeptName(existing == null
|
||||
? (aggregate.getDeptId() == null ? "未归属部门" : "历史部门(" + aggregate.getDeptId() + ")")
|
||||
: existing.getDeptName());
|
||||
aggregate.setOrderProfitPrice(aggregate.getOrderPayPrice().subtract(aggregate.getOrderRefundPrice()));
|
||||
aggregate.setOrderProfitCont(aggregate.getOrderPayCount() - aggregate.getOrderRefundCont());
|
||||
rows.put(aggregate.getDeptId(), aggregate);
|
||||
}
|
||||
timing.stop();
|
||||
if (reporter != null) reporter.advance("部门统计合并完成");
|
||||
if (timing.getTotalTimeMillis() >= 1000) {
|
||||
log.info("部门统计慢查询,部门数={},总耗时={}ms,分段耗时:{}", result.size(), timing.getTotalTimeMillis(), timing.prettyPrint());
|
||||
log.info("部门流水统计慢查询,部门数={},总耗时={}ms,分段耗时:{}",
|
||||
rows.size(), timing.getTotalTimeMillis(), timing.prettyPrint());
|
||||
}
|
||||
return result;
|
||||
return new ArrayList<>(rows.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询统计结果需要展示的部门,并校验 UPMS 远程调用结果。
|
||||
*
|
||||
* @param dto 部门包含或排除筛选条件
|
||||
* @return 匹配的部门列表,远程响应数据为空时返回空列表
|
||||
* @throws IllegalStateException UPMS 部门远程调用失败时抛出
|
||||
*/
|
||||
/** 名称查询失败不能返回缺少部门的部分报表;空名称列表仍继续读取账本。 */
|
||||
private List<SysDept> queryDepartmentList(HardDeptTotalDto dto) {
|
||||
DeptAllListDto deptDto = new DeptAllListDto();
|
||||
deptDto.setIsInclude(dto.getIsInclude());
|
||||
if (ObjectUtil.isNotEmpty(dto.getDeptId())) {
|
||||
deptDto.setDeptId(dto.getDeptId());
|
||||
}
|
||||
if (dto.getDeptId() != null) deptDto.setDeptId(dto.getDeptId());
|
||||
if (ObjectUtil.isNotEmpty(dto.getDeptIds())) {
|
||||
deptDto.setDeptId(null);
|
||||
deptDto.setDeptIds(dto.getDeptIds());
|
||||
}
|
||||
R<List<SysDept>> response = remoteDeptService.getInnerAllDept(deptDto);
|
||||
if (response == null || !response.isOk()) {
|
||||
throw new IllegalStateException("查询部门交易统计的部门信息失败");
|
||||
}
|
||||
if (response == null || !response.isOk()) throw new IllegalStateException("查询部门交易统计的部门信息失败");
|
||||
return response.getData() == null ? List.of() : response.getData();
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建钱包部门统计条件,保证六类钱包统计使用完全一致的部门、时间和钱包类型范围。
|
||||
*
|
||||
* @param dto 部门统计页面筛选条件
|
||||
* @return 钱包流水汇总条件
|
||||
*/
|
||||
private WalletTransactionSumAndCountDto buildWalletStatisticsDto(HardDeptTotalDto dto) {
|
||||
WalletTransactionSumAndCountDto walletDto = new WalletTransactionSumAndCountDto();
|
||||
walletDto.setDeptIds(dto.getDeptIds());
|
||||
walletDto.setIsInclude(dto.getIsInclude());
|
||||
walletDto.setCreateTimeList(dto.getCreateTimeList());
|
||||
walletDto.setWalletTypeId(dto.getWalletTypeId());
|
||||
walletDto.setWalletTypeIdList(dto.getWalletTypeIdList());
|
||||
return walletDto;
|
||||
}
|
||||
|
||||
/** 钱包六类统计一次返回;远程失败必须终止报表,不能当作零金额。 */
|
||||
private List<WalletDepartmentStatisticsVo> queryWalletDepartmentStatistics(WalletTransactionSumAndCountDto dto) {
|
||||
R<List<WalletDepartmentStatisticsVo>> response = remoteWalletTransactionService.departmentStatistics(dto);
|
||||
if (response == null || !response.isOk()) throw new IllegalStateException("查询钱包部门交易统计失败");
|
||||
return response.getData() == null ? List.of() : response.getData();
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建硬件订单支付部门统计条件。数据库先还原支付流水的历史部门,缺失时回退订单部门,
|
||||
* 再应用部门包含/排除条件并分组汇总。
|
||||
*
|
||||
* @param dto 部门统计页面筛选条件
|
||||
* @return 支付部门聚合查询条件
|
||||
*/
|
||||
private HardOrderSumAndCountDto buildPayStatisticsDto(HardDeptTotalDto dto) {
|
||||
HardOrderSumAndCountDto orderDto = new HardOrderSumAndCountDto();
|
||||
orderDto.setUserDeptIds(dto.getDeptIds());
|
||||
orderDto.setIsInclude(dto.getIsInclude());
|
||||
orderDto.setOrderStatusCodes(HardOrderStatusEnum.getPayPrice());
|
||||
orderDto.setWalletTypeId(dto.getWalletTypeId());
|
||||
orderDto.setWalletTypeIdList(dto.getWalletTypeIdList());
|
||||
orderDto.setCreateTimeList(dto.getCreateTimeList());
|
||||
return orderDto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将订单支付统计按部门建立索引,供支付结果与 Wallet 退款结果按相同键合并。
|
||||
*
|
||||
* @param statisticsList 订单部门统计列表
|
||||
* @return 以部门 ID 为键的统计索引
|
||||
*/
|
||||
private Map<Long, HardOrderSumAndCountVo> indexOrderStatistics(List<HardOrderSumAndCountVo> statisticsList) {
|
||||
Map<Long, HardOrderSumAndCountVo> statisticsMap = new LinkedHashMap<>();
|
||||
if (ObjectUtil.isNotEmpty(statisticsList)) {
|
||||
for (HardOrderSumAndCountVo statistics : statisticsList) {
|
||||
if (statistics != null && statistics.getDeptId() != null) {
|
||||
statisticsMap.put(statistics.getDeptId(), statistics);
|
||||
}
|
||||
}
|
||||
}
|
||||
return statisticsMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并部门的订单支付与 Wallet 退款流水,并计算净金额和净次数。
|
||||
*
|
||||
* <p>退款金额保留 REFUND 流水原始正负号,退款次数按流水条数统计,因此退款到账、充值退款扣回
|
||||
* 与退款记录页面保持完全相同的流水口径;退款审核中的售后单尚未生成流水,不会计入。</p>
|
||||
*
|
||||
* @param target 部门统计结果
|
||||
* @param pay 支付统计,允许为空
|
||||
* @param refund Wallet REFUND 流水统计,允许为空
|
||||
*/
|
||||
private void applyOrderStatistics(HardDeptTotalVo target, HardOrderSumAndCountVo pay,
|
||||
WalletTransactionSumAndCountVo refund) {
|
||||
long payPrice = pay == null ? 0L : ObjectUtil.defaultIfNull(pay.getSum(), 0L);
|
||||
long payCount = pay == null ? 0L : ObjectUtil.defaultIfNull(pay.getCount(), 0L);
|
||||
long refundPrice = refund == null ? 0L : ObjectUtil.defaultIfNull(refund.getSum(), 0L);
|
||||
long refundCount = refund == null ? 0L : ObjectUtil.defaultIfNull(refund.getCount(), 0L);
|
||||
BigDecimal orderPayPrice = BigDecimal.valueOf(payPrice);
|
||||
BigDecimal orderRefundPrice = BigDecimal.valueOf(refundPrice);
|
||||
target.setOrderPayPrice(orderPayPrice);
|
||||
target.setOrderPayCount(payCount);
|
||||
target.setOrderRefundPrice(orderRefundPrice);
|
||||
target.setOrderRefundCont(refundCount);
|
||||
target.setOrderProfitPrice(orderPayPrice.subtract(orderRefundPrice));
|
||||
target.setOrderProfitCont(payCount - refundCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付方式交易记录
|
||||
*/
|
||||
@@ -869,180 +612,29 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 商户交易记录导出(按月)
|
||||
*/
|
||||
/** 兼容旧服务入口,与当前同步/异步导出共用同一钱包月份聚合。 */
|
||||
@Override
|
||||
public void exportCsByMonth(HttpServletResponse response, HardOrderDto dto) {
|
||||
// 查询所有有订单或者退款的店铺记录
|
||||
List<Long> mchIds = new ArrayList<>();
|
||||
if (ObjectUtil.isNotEmpty(mchIds)) {
|
||||
mchIds = new ArrayList<>(dto.getMchIds());
|
||||
}
|
||||
// 查询总流水
|
||||
if (ObjectUtil.isEmpty(mchIds)) {
|
||||
HardSelectOrderMerchIdsDto merchQuery = new HardSelectOrderMerchIdsDto();
|
||||
if (ObjectUtil.isNotEmpty(dto.getMerchantId())) {
|
||||
merchQuery.setMerchIds(List.of(dto.getMerchantId()));
|
||||
}
|
||||
if (ObjectUtil.isNotEmpty(dto.getMchTypeCodes())) {
|
||||
merchQuery.setMerchTypes(dto.getMchTypeCodes());
|
||||
}
|
||||
if (ObjectUtil.isNotEmpty(dto.getCreateTimeList())) {
|
||||
merchQuery.setCreateTimeList(dto.getCreateTimeList());
|
||||
}
|
||||
if (ObjectUtil.isNotEmpty(dto.getPayTimeYearStr())) {
|
||||
merchQuery.setPayTimeYearStr(dto.getPayTimeYearStr());
|
||||
}
|
||||
mchIds = hardOrderMapper.selectOrderMerchIds(merchQuery);
|
||||
}
|
||||
List<List<Long>> chuckMchIds = ListUtil.split(mchIds, 5);
|
||||
|
||||
List<MchManageVo> mchList = getMchListByIds(mchIds);
|
||||
|
||||
Map<String, Object> map = new HashMap<String, Object>();
|
||||
|
||||
// 查询食堂
|
||||
String mchName = "所有";
|
||||
if (ObjectUtil.isNotEmpty(dto.getMerchantId())) {
|
||||
MchManageVo mch = remoteMchService.getById(dto.getMerchantId()).getData();
|
||||
if (ObjectUtil.isNotEmpty(mch)) {
|
||||
mchName = mch.getName();
|
||||
HardOrderDto query = BeanUtil.toBean(dto, HardOrderDto.class);
|
||||
if (query.getMerchantId() != null) {
|
||||
R<List<MchManageVo>> children = remoteMchService.getByParentId(query.getMerchantId());
|
||||
if (children == null || !children.isOk()) throw new IllegalStateException("查询导出商户范围失败");
|
||||
if (ObjectUtil.isNotEmpty(children.getData())) {
|
||||
List<Long> ids = new ArrayList<>();
|
||||
ids.add(query.getMerchantId());
|
||||
children.getData().forEach(merchant -> ids.add(merchant.getId()));
|
||||
query.setMerchantId(null);
|
||||
query.setMchIds(ids);
|
||||
}
|
||||
}
|
||||
// 标题
|
||||
// 格式-2025年浦江大饭店合作商家流水情况统计表
|
||||
String title = dto.getPayTimeYearStr() + "年" + mchName + "合作商家流水情况统计表";
|
||||
map.put("title", title);
|
||||
|
||||
// 年份
|
||||
int payTimeYearint = Integer.parseInt(dto.getPayTimeYearStr());
|
||||
|
||||
ByteArrayOutputStream tempExcel = new ByteArrayOutputStream();
|
||||
try {
|
||||
ExcelWriter excelWriter = EasyExcel.write(tempExcel).withTemplate(ResourceUtil.getStream("file/商户交易金额汇总(按月).xlsx")).build();
|
||||
WriteSheet writeSheet = EasyExcel.writerSheet().build();
|
||||
FillConfig fillConfig = FillConfig.builder().forceNewRow(Boolean.TRUE).build();
|
||||
List<Map<String, Object>> excelTempMapList = new ArrayList<>();
|
||||
for (int i = 0; i < mchIds.size(); i++) {
|
||||
Long currentMchId = mchIds.get(i);
|
||||
Map<String, Object> excelMap = new LinkedHashMap<>();
|
||||
for (int j = 0; j <= 26; j++) {
|
||||
String key = String.format("col%d", j);
|
||||
String value = "";
|
||||
if (j == 0) {
|
||||
value = String.format("{col_%d.col}", currentMchId);
|
||||
}
|
||||
excelMap.put(key, value);
|
||||
}
|
||||
excelTempMapList.add(excelMap);
|
||||
}
|
||||
excelWriter.fill(Map.of("total", "{total.total}", "title", "{title}"), writeSheet);
|
||||
excelWriter.fill(excelTempMapList, fillConfig, writeSheet);
|
||||
excelWriter.finish();
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
HardBatchExportService.writeMerchantMonths(this, query, null, file -> {
|
||||
com.bsd.pjcloud.common.asynctask.service.BatchExportSupport.respond(file, "商户交易记录(按月).xlsx", response);
|
||||
return null;
|
||||
});
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("商户月份流水导出失败", exception);
|
||||
}
|
||||
|
||||
// 导出tempExcel用于调试
|
||||
// FileUtil.writeBytes(tempExcel.toByteArray(), "/Users/dengzhangchen/Downloads/debug_hard_order_" + System.currentTimeMillis() + ".xlsx");
|
||||
|
||||
try (ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream()).withTemplate(new ByteArrayInputStream(tempExcel.toByteArray())).build()) {
|
||||
WriteSheet writeSheet = EasyExcel.writerSheet().build();
|
||||
FillConfig fillConfig = FillConfig.builder().direction(WriteDirectionEnum.HORIZONTAL).forceNewRow(Boolean.TRUE).build();
|
||||
// 按月份总计
|
||||
List<HardOrderCsVo.Month> monthTotalList = new LinkedList<>();
|
||||
for (Month month : Month.values()) {
|
||||
HardOrderCsVo.Month defaulMonthTotal = new HardOrderCsVo.Month();
|
||||
LocalDate monthDate = LocalDate.of(payTimeYearint, month.getValue(), 1);
|
||||
String monthStr = LocalDateTimeUtil.format(monthDate, DatePattern.NORM_MONTH_PATTERN);
|
||||
defaulMonthTotal.setDay(monthStr);
|
||||
defaulMonthTotal.setTotalProfitPrice(0);
|
||||
defaulMonthTotal.setTotalProfitCount(0);
|
||||
monthTotalList.add(defaulMonthTotal);
|
||||
}
|
||||
for (List<Long> ids : chuckMchIds) {
|
||||
dto.setMchIds(ids);
|
||||
Page<HardOrderCsVo> page = pageListCsByMonth(new Page(1L, 999L), dto);
|
||||
List<HardOrderCsVo> records = page.getRecords();
|
||||
if (ObjectUtil.isEmpty(records)) break;
|
||||
for (Long id : ids) {
|
||||
List<Map<String, Object>> excelMapList = new LinkedList<Map<String, Object>>();
|
||||
// 先查询商户名
|
||||
String currentMchName = "未知商户";
|
||||
MchManageVo mchDetail = mchList.stream().filter(m -> ObjectUtil.equals(m.getId(), id)).findFirst().orElse(null);
|
||||
if (ObjectUtil.isNotEmpty(mchDetail) && ObjectUtil.isNotEmpty(mchDetail.getName())) {
|
||||
currentMchName = mchDetail.getName();
|
||||
}
|
||||
excelMapList.add(Map.of("col", currentMchName));
|
||||
excelMapList.addAll(disposeCsByMonth(id, records, monthTotalList, payTimeYearint));
|
||||
excelWriter.fill(new FillWrapper("col_" + id, excelMapList), fillConfig, writeSheet);
|
||||
}
|
||||
}
|
||||
// 统计
|
||||
double sumTotalPrice = 0;
|
||||
long sumTimeCount = 0;
|
||||
List<Map<String, Object>> excelTotalMapList = new LinkedList<Map<String, Object>>();
|
||||
for (HardOrderCsVo.Month total : monthTotalList) {
|
||||
excelTotalMapList.add(Map.of("total", total.getTotalProfitPrice()));
|
||||
excelTotalMapList.add(Map.of("total", total.getTotalProfitCount()));
|
||||
sumTotalPrice += total.getTotalProfitPrice();
|
||||
sumTimeCount += total.getTotalProfitCount();
|
||||
}
|
||||
excelTotalMapList.add(Map.of("total", sumTotalPrice));
|
||||
excelTotalMapList.add(Map.of("total", sumTimeCount));
|
||||
excelWriter.fill(new FillWrapper("total", excelTotalMapList), fillConfig, writeSheet);
|
||||
excelWriter.fill(map, writeSheet);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
log.error("|商户交易记录导出(按月)|以外的错误,error:{}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> disposeCsByMonth(Long mchId, List<HardOrderCsVo> list, List<HardOrderCsVo.Month> monthTotalList, Integer yearStr) {
|
||||
List<LocalDate> monthlyRanges = new LinkedList<>();
|
||||
for (Month month : Month.values()) {
|
||||
LocalDate localDate = LocalDate.of(yearStr, month.getValue(), 1);
|
||||
monthlyRanges.add(localDate);
|
||||
}
|
||||
List<Map<String, Object>> listMap = new LinkedList<>();
|
||||
List<HardOrderCsVo> sqlList = new LinkedList<>();
|
||||
for (LocalDate i : monthlyRanges) {
|
||||
String monthStr = LocalDateTimeUtil.format(i, DatePattern.NORM_MONTH_PATTERN);
|
||||
HardOrderCsVo target = list.stream().filter(v -> ObjectUtil.equals(v.getDay(), monthStr) && ObjectUtil.equals(v.getMerchantId(), mchId)).findFirst().orElse(null);
|
||||
if (ObjectUtil.isEmpty(target)) {
|
||||
HardOrderCsVo defaultVo = new HardOrderCsVo();
|
||||
defaultVo.setTotalProfitPrice(BigDecimal.ZERO);
|
||||
defaultVo.setTotalProfitCount(BigDecimal.ZERO);
|
||||
sqlList.add(defaultVo);
|
||||
} else {
|
||||
sqlList.add(target);
|
||||
}
|
||||
}
|
||||
double sumTotalPrice = 0;
|
||||
long sumTimeCount = 0;
|
||||
for (HardOrderCsVo v : sqlList) {
|
||||
BigDecimal totalProfitPrice = Optional.of(v.getTotalProfitPrice()).orElse(BigDecimal.ZERO);
|
||||
BigDecimal totalProfitCount = Optional.of(v.getTotalProfitCount()).orElse(BigDecimal.ZERO);
|
||||
|
||||
totalProfitPrice = v.getTotalProfitPrice().divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP);
|
||||
|
||||
listMap.add(Map.of("col", totalProfitPrice.toString()));
|
||||
listMap.add(Map.of("col", totalProfitCount.intValue()));
|
||||
|
||||
sumTotalPrice += totalProfitPrice.doubleValue();
|
||||
sumTimeCount += totalProfitCount.longValue();
|
||||
|
||||
HardOrderCsVo.Month month = monthTotalList.stream().filter(item -> ObjectUtil.equals(item.getDay(), v.getDay())).findFirst().orElse(null);
|
||||
if (ObjectUtil.isNotEmpty(month)) {
|
||||
month.setTotalProfitPrice(month.getTotalProfitPrice() + totalProfitPrice.doubleValue());
|
||||
month.setTotalProfitCount(month.getTotalProfitCount() + totalProfitCount.longValue());
|
||||
}
|
||||
}
|
||||
listMap.add(Map.of("col", sumTotalPrice));
|
||||
listMap.add(Map.of("col", sumTimeCount));
|
||||
|
||||
return listMap;
|
||||
}
|
||||
|
||||
private void computeMchPlatformRanke(List<HardOrderCsVo> totalList, List<MchManageVo> mchList) {
|
||||
@@ -1071,39 +663,47 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService {
|
||||
return RemoteBatchQueryUtil.queryMapByIds(deptIds, ids -> remoteDeptService.getDeptByDeptIds(ids).getData());
|
||||
}
|
||||
|
||||
/** 日分片只累计原始金额与次数;平台抽成在完整合并后按原函数计算。 */
|
||||
private List<HardOrderCsVo> queryCsExportSlices(HardOrderDto dto, boolean refund, DataScope scope, AsyncTaskProgressReporter reporter) {
|
||||
LocalDateTime[] range = refund ? dto.getRefundTimeList() : dto.getPayTimeList();
|
||||
if (range == null || range.length != 2 || range[0] == null || range[1] == null) {
|
||||
/** 相邻自然日共用边界,不减纳秒;每片一次读取支付退款,合并后再计算抽成。 */
|
||||
private List<HardOrderCsVo> queryCsExportSlices(HardOrderDto dto, HardOrderStatisticsScope scope,
|
||||
AsyncTaskProgressReporter reporter) {
|
||||
LocalDateTime[] range = dto.getPayTimeList();
|
||||
if (range == null) {
|
||||
if (reporter != null) reporter.updateTotal(3, "正在汇总商户交易");
|
||||
return refund ? hardOrderMapper.listRefundCs(dto, scope) : hardOrderMapper.listCs(dto, scope);
|
||||
return ledgerStatisticsMapper.merchantStatistics(dto, scope);
|
||||
}
|
||||
long days = java.time.temporal.ChronoUnit.DAYS.between(range[0].toLocalDate(), range[1].toLocalDate()) + 1;
|
||||
if (days <= 0 || days > Integer.MAX_VALUE - 3) throw new IllegalArgumentException("导出时间范围不正确");
|
||||
if (reporter != null && !refund) reporter.updateTotal(Math.toIntExact(days * 2 + 3), "正在按自然日汇总商户交易");
|
||||
Map<Long, HardOrderCsVo> totals = new java.util.TreeMap<>();
|
||||
if (!range[0].isBefore(range[1])) return new ArrayList<>();
|
||||
long days = java.time.temporal.ChronoUnit.DAYS.between(range[0].toLocalDate(),
|
||||
range[1].minusNanos(1).toLocalDate()) + 1;
|
||||
if (days > Integer.MAX_VALUE - 3) throw new IllegalArgumentException("导出时间范围不正确");
|
||||
if (reporter != null) reporter.updateTotal(Math.toIntExact(days + 3), "正在按自然日汇总商户交易");
|
||||
Map<Long, HardOrderCsVo> totals = new LinkedHashMap<>();
|
||||
LocalDateTime from = range[0];
|
||||
int day = 0;
|
||||
while (!from.isAfter(range[1])) {
|
||||
while (from.isBefore(range[1])) {
|
||||
LocalDateTime next = from.toLocalDate().plusDays(1).atStartOfDay();
|
||||
LocalDateTime to = next.isAfter(range[1]) ? range[1] : next.minusNanos(1);
|
||||
LocalDateTime to = next.isAfter(range[1]) ? range[1] : next;
|
||||
HardOrderDto slice = BeanUtil.toBean(dto, HardOrderDto.class);
|
||||
if (refund) slice.setRefundTimeList(new LocalDateTime[]{from, to});
|
||||
else slice.setPayTimeList(new LocalDateTime[]{from, to});
|
||||
List<HardOrderCsVo> rows = refund ? hardOrderMapper.listRefundCs(slice, scope) : hardOrderMapper.listCs(slice, scope);
|
||||
for (HardOrderCsVo row : rows) {
|
||||
HardOrderCsVo old = totals.get(row.getMerchantId());
|
||||
if (old == null) totals.put(row.getMerchantId(), row);
|
||||
slice.setPayTimeList(new LocalDateTime[]{from, to});
|
||||
for (HardOrderCsVo row : ledgerStatisticsMapper.merchantStatistics(slice, scope)) {
|
||||
HardOrderCsVo existing = totals.get(row.getMerchantId());
|
||||
if (existing == null) totals.put(row.getMerchantId(), row);
|
||||
else {
|
||||
old.setTotalPayPrice(ObjectUtil.defaultIfNull(old.getTotalPayPrice(), BigDecimal.ZERO).add(ObjectUtil.defaultIfNull(row.getTotalPayPrice(), BigDecimal.ZERO)));
|
||||
old.setTotalPayCount(ObjectUtil.defaultIfNull(old.getTotalPayCount(), BigDecimal.ZERO).add(ObjectUtil.defaultIfNull(row.getTotalPayCount(), BigDecimal.ZERO)));
|
||||
if (row.getMerchantDeptId() != null && (old.getMerchantDeptId() == null || row.getMerchantDeptId() > old.getMerchantDeptId())) old.setMerchantDeptId(row.getMerchantDeptId());
|
||||
existing.setTotalPayPrice(zero(existing.getTotalPayPrice()).add(zero(row.getTotalPayPrice())));
|
||||
existing.setTotalPayCount(zero(existing.getTotalPayCount()).add(zero(row.getTotalPayCount())));
|
||||
existing.setTotalRefundPrice(zero(existing.getTotalRefundPrice()).add(zero(row.getTotalRefundPrice())));
|
||||
existing.setTotalRefundCount(zero(existing.getTotalRefundCount()).add(zero(row.getTotalRefundCount())));
|
||||
if (row.getMerchantDeptId() != null && (existing.getMerchantDeptId() == null
|
||||
|| row.getMerchantDeptId() > existing.getMerchantDeptId())) existing.setMerchantDeptId(row.getMerchantDeptId());
|
||||
}
|
||||
}
|
||||
if (reporter != null) reporter.advance("已汇总 " + (++day) + " / " + days + (refund ? " 天退款记录" : " 天支付记录"));
|
||||
from = next;
|
||||
if (reporter != null) reporter.advance("已汇总 " + (++day) + " / " + days + " 天钱包流水");
|
||||
from = to;
|
||||
}
|
||||
return new ArrayList<>(totals.values());
|
||||
}
|
||||
|
||||
private static BigDecimal zero(BigDecimal value) {
|
||||
return value == null ? BigDecimal.ZERO : value;
|
||||
}
|
||||
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package com.bsd.pjcloud.hard.service.impl.order;
|
||||
|
||||
import com.alibaba.excel.EasyExcel;
|
||||
import com.alibaba.excel.ExcelWriter;
|
||||
import com.alibaba.excel.write.metadata.WriteSheet;
|
||||
import com.bsd.pjcloud.common.asynctask.service.BatchExportSupport;
|
||||
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.excel.HardWalletLedgerDetailExcelVo;
|
||||
import com.bsd.pjcloud.hard.mapper.order.HardWalletLedgerDetailMapper;
|
||||
import com.bsd.pjcloud.hard.mapper.order.model.HardWalletLedgerDetail;
|
||||
import com.bsd.pjcloud.hard.service.order.HardWalletLedgerQuerySupport;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** 单商户按钱包流水发生时间导出支付和退款;先生成完整临时附件再响应,异常不返回残缺文件。 */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class HardWalletLedgerDetailExportService {
|
||||
/** 延续旧同步明细导出的五万条保护上限,数量按实际钱包流水计算。 */
|
||||
private static final long MAX_DETAIL_COUNT = 50_000L;
|
||||
/** 每次只保留两百笔流水,避免 MyBatis 会话和 Excel 映射随总行数增长。 */
|
||||
private static final int BATCH_SIZE = 200;
|
||||
/** 附件中的流水时间格式,与页面时间参数格式一致。 */
|
||||
private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
/** 复用汇总的时间、商户层级及权限准备,保证明细与合计使用相同条件。 */
|
||||
private final HardWalletLedgerQuerySupport querySupport;
|
||||
/** 使用汇总公共 SQL 的逐笔流水查询。 */
|
||||
private final HardWalletLedgerDetailMapper detailMapper;
|
||||
|
||||
/**
|
||||
* 导出指定真实商户及其查询范围内的支付、退款流水,保留零金额、负向冲正和大整数主键。
|
||||
*
|
||||
* @param response 仅在完整文件生成后写出的附件响应
|
||||
* @param source 页面原始条件,merchantId 必须是正整数;不得改成未知商户或无界导出
|
||||
* @throws Exception 查询、完整性校验、临时文件生成或附件写出失败时抛出
|
||||
*/
|
||||
public void export(HttpServletResponse response, HardOrderDto source) throws Exception {
|
||||
if (source.getMerchantId() == null || source.getMerchantId() <= 0) {
|
||||
throw new IllegalArgumentException("请选择一个真实商户后导出流水明细");
|
||||
}
|
||||
var context = querySupport.merchant(source);
|
||||
long expected = detailMapper.count(context.dto(), context.scope());
|
||||
if (expected == 0) throw new IllegalArgumentException("当前筛选条件没有可导出的钱包流水");
|
||||
if (expected > MAX_DETAIL_COUNT) throw new IllegalArgumentException("导出流水超出50000条,请缩小查询范围");
|
||||
Path file = Files.createTempFile("hard-wallet-ledger-detail-", ".xlsx");
|
||||
try {
|
||||
try (ExcelWriter writer = EasyExcel.write(file.toFile(), HardWalletLedgerDetailExcelVo.class).build()) {
|
||||
WriteSheet sheet = EasyExcel.writerSheet("商户钱包交易流水").build();
|
||||
long written = 0;
|
||||
Long beforeId = null;
|
||||
BigDecimal paymentTotal = BigDecimal.ZERO;
|
||||
BigDecimal refundTotal = BigDecimal.ZERO;
|
||||
BigDecimal ledgerTotal = BigDecimal.ZERO;
|
||||
while (written < expected) {
|
||||
List<HardWalletLedgerDetail> details = detailMapper.listBatch(context.dto(), context.scope(), beforeId, BATCH_SIZE);
|
||||
if (details == null || details.isEmpty() || details.size() > expected - written) {
|
||||
throw new IllegalStateException("导出期间钱包流水发生变化,请重试");
|
||||
}
|
||||
List<HardWalletLedgerDetailExcelVo> rows = new ArrayList<>(details.size());
|
||||
for (HardWalletLedgerDetail detail : details) {
|
||||
if (detail.getTransactionId() == null || beforeId != null && detail.getTransactionId() >= beforeId) {
|
||||
throw new IllegalStateException("钱包流水游标顺序异常,无法生成完整对账文件");
|
||||
}
|
||||
HardWalletLedgerDetailExcelVo row = excelRow(detail, ++written);
|
||||
rows.add(row);
|
||||
paymentTotal = paymentTotal.add(row.getPaymentPrice());
|
||||
refundTotal = refundTotal.add(row.getRefundPrice());
|
||||
ledgerTotal = ledgerTotal.add(row.getLedgerPrice());
|
||||
beforeId = detail.getTransactionId();
|
||||
}
|
||||
writer.write(rows, sheet);
|
||||
}
|
||||
HardWalletLedgerDetailExcelVo total = new HardWalletLedgerDetailExcelVo();
|
||||
total.setBizType("合计");
|
||||
total.setPaymentPrice(paymentTotal);
|
||||
total.setRefundPrice(refundTotal);
|
||||
total.setLedgerPrice(ledgerTotal);
|
||||
writer.write(List.of(total), sheet);
|
||||
}
|
||||
BatchExportSupport.respond(file, "商户钱包交易流水.xlsx", response);
|
||||
} finally {
|
||||
Files.deleteIfExists(file);
|
||||
}
|
||||
}
|
||||
|
||||
/** 将数据库分值转换为元;PAYMENT 取反,REFUND 保持原符号,其他字段仅补充对账信息。 */
|
||||
private HardWalletLedgerDetailExcelVo excelRow(HardWalletLedgerDetail detail, long lineNumber) {
|
||||
HardWalletLedgerDetailExcelVo row = new HardWalletLedgerDetailExcelVo();
|
||||
row.setLineNumber(lineNumber);
|
||||
row.setTransactionId(text(detail.getTransactionId()));
|
||||
row.setBizType("4".equals(detail.getBizType()) ? "钱包支付" : "钱包退款");
|
||||
row.setCreateTime(detail.getCreateTime() == null ? null : TIME_FORMAT.format(detail.getCreateTime()));
|
||||
BigDecimal price = BigDecimal.valueOf(detail.getPrice() == null ? 0L : detail.getPrice()).movePointLeft(2).setScale(2);
|
||||
row.setLedgerPrice(price);
|
||||
row.setPaymentPrice("4".equals(detail.getBizType()) ? price.negate() : BigDecimal.ZERO.setScale(2));
|
||||
row.setRefundPrice("5".equals(detail.getBizType()) ? price : BigDecimal.ZERO.setScale(2));
|
||||
row.setMerchantId(text(detail.getMerchantId()));
|
||||
row.setOrderId(text(detail.getOrderId()));
|
||||
row.setOrderNo(detail.getOrderNo());
|
||||
row.setAfterSaleId(detail.getAfterSaleId());
|
||||
row.setBizId(detail.getBizId());
|
||||
row.setDeptId(text(detail.getDeptId()));
|
||||
row.setWalletId(text(detail.getWalletId()));
|
||||
row.setUserId(text(detail.getUserId()));
|
||||
return row;
|
||||
}
|
||||
|
||||
/** 主键以十进制字符串写入 Excel;历史空值保持为空。 */
|
||||
private String text(Long id) {
|
||||
return id == null ? null : id.toString();
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package com.bsd.pjcloud.hard.service.order;
|
||||
|
||||
import cn.hutool.core.bean.BeanUtil;
|
||||
import com.bsd.pjcloud.common.security.util.SecurityUtils;
|
||||
import com.bsd.pjcloud.hard.api.dto.hard.CurrentUserHardTypeInfoDto;
|
||||
import com.bsd.pjcloud.hard.api.dto.order.HardDeptTotalDto;
|
||||
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
|
||||
import com.bsd.pjcloud.hard.mapper.order.model.HardOrderStatisticsScope;
|
||||
import com.bsd.pjcloud.hard.service.HardManagerService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Year;
|
||||
import java.util.List;
|
||||
|
||||
/** 页面、月份及明细导出共用服务端权限和流水时间,避免重新混入订单时间。 */
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class HardWalletLedgerQuerySupport {
|
||||
private final HardOrderStatisticsScopeResolver scopeResolver;
|
||||
private final HardManagerService hardManagerService;
|
||||
|
||||
public record QueryContext(HardOrderDto dto, HardOrderStatisticsScope scope) { }
|
||||
|
||||
public QueryContext merchant(HardOrderDto source) {
|
||||
HardOrderDto query = BeanUtil.toBean(source, HardOrderDto.class);
|
||||
normalizeTime(query);
|
||||
HardOrderStatisticsScope scope = scopeResolver.resolve();
|
||||
CurrentUserHardTypeInfoDto permission = hardManagerService.getCurrentUserHardTypeInfo(SecurityUtils.getUser());
|
||||
if (permission == null) throw new IllegalStateException("查询商户交易类型权限失败");
|
||||
if (!permission.isAll()) {
|
||||
List<String> allowed = permission.getHardTypeList();
|
||||
List<String> requested = query.getMchTypeCodes();
|
||||
List<String> effective = requested == null || requested.isEmpty() ? allowed
|
||||
: requested.stream().filter(allowed::contains).distinct().toList();
|
||||
query.setMchTypeCodes(effective);
|
||||
// 空类型列表在旧查询中表示不限类型;授权交集为空时必须显式拒绝消费流水。
|
||||
if (effective.isEmpty()) scope = new HardOrderStatisticsScope(scope.tenantId(), false, List.of(), null);
|
||||
}
|
||||
return new QueryContext(query, scope);
|
||||
}
|
||||
|
||||
public QueryContext department(HardDeptTotalDto source) {
|
||||
HardOrderDto query = new HardOrderDto();
|
||||
query.setPayTimeList(source.getCreateTimeList());
|
||||
query.setUserDeptIds(source.getDeptIds());
|
||||
query.setIsInclude(source.getIsInclude());
|
||||
query.setWalletTypeId(source.getWalletTypeId());
|
||||
query.setWalletTypeIdList(source.getWalletTypeIdList());
|
||||
return merchant(query);
|
||||
}
|
||||
|
||||
/** 仅规范化副本;旧请求字段保留,时间按流水发生时刻采用左闭右开区间。 */
|
||||
private static void normalizeTime(HardOrderDto query) {
|
||||
LocalDateTime[] range = query.getPayTimeList();
|
||||
// 三个大屏 report 仍传 createTimeList,进入账本层后也解释为流水时间。
|
||||
if (range == null || range.length == 0) range = query.getCreateTimeList();
|
||||
if (range != null && range.length == 0) range = null;
|
||||
if (range != null) {
|
||||
if (range.length != 2 || range[0] == null || range[1] == null || range[1].isBefore(range[0]))
|
||||
throw new IllegalArgumentException("交易时间范围不正确");
|
||||
range = range.clone();
|
||||
}
|
||||
String yearText = query.getPayTimeYearStr();
|
||||
if (yearText != null && !yearText.isBlank()) {
|
||||
LocalDateTime from;
|
||||
LocalDateTime to;
|
||||
try {
|
||||
Year year = Year.parse(yearText);
|
||||
from = year.atDay(1).atStartOfDay();
|
||||
to = year.plusYears(1).atDay(1).atStartOfDay();
|
||||
} catch (RuntimeException exception) {
|
||||
throw new IllegalArgumentException("交易年份不正确", exception);
|
||||
}
|
||||
if (range == null) range = new LocalDateTime[]{from, to};
|
||||
else {
|
||||
LocalDateTime start = range[0].isAfter(from) ? range[0] : from;
|
||||
LocalDateTime end = range[1].isBefore(to) ? range[1] : to;
|
||||
range = new LocalDateTime[]{start, end.isBefore(start) ? start : end};
|
||||
}
|
||||
}
|
||||
query.setPayTimeList(range);
|
||||
query.setCreateTimeList(null);
|
||||
query.setPayTimeYearStr(null);
|
||||
}
|
||||
}
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.bsd.pjcloud.hard.mapper.order.HardDepartmentStatisticsMapper">
|
||||
<!-- 历史订单口径对照,当前报表不再调用;新账本统计位于 HardWalletLedgerStatisticsMapper。 -->
|
||||
<select id="paymentStatistics" resultType="com.bsd.pjcloud.hard.api.vo.order.HardOrderSumAndCountVo">
|
||||
<!-- 一次还原每单的历史部门,避免派生表合并后在筛选/分组中重复执行快照查询。 -->
|
||||
select /*+ NO_MERGE(department_orders) */ department_orders.dept_id as deptId,
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.bsd.pjcloud.hard.mapper.order.HardWalletLedgerDetailMapper">
|
||||
<!-- 明细与汇总复用事实集,禁止按订单支付时间、当前状态或当前用户部门重新过滤。 -->
|
||||
<sql id="detailWhere">
|
||||
ledger.biz_type in ('4', '5')
|
||||
and <include refid="com.bsd.pjcloud.hard.mapper.order.HardWalletLedgerStatisticsMapper.ledgerFilter"/>
|
||||
<include refid="com.bsd.pjcloud.hard.mapper.order.HardWalletLedgerStatisticsMapper.merchantFilter"/>
|
||||
<include refid="com.bsd.pjcloud.hard.mapper.order.HardWalletLedgerStatisticsMapper.merchantScope"/>
|
||||
</sql>
|
||||
<select id="count" resultType="long" useCache="false">
|
||||
select count(0)
|
||||
<include refid="com.bsd.pjcloud.hard.mapper.order.HardWalletLedgerStatisticsMapper.ledgerJoins"/>
|
||||
where <include refid="detailWhere"/>
|
||||
</select>
|
||||
<select id="listBatch" resultType="com.bsd.pjcloud.hard.mapper.order.model.HardWalletLedgerDetail" useCache="false" flushCache="true">
|
||||
select ledger.id as transactionId,
|
||||
ledger.biz_type as bizType,
|
||||
ledger.create_time as createTime,
|
||||
ledger.price as price,
|
||||
ledger_order.merchant_id as merchantId,
|
||||
ledger_order.id as orderId,
|
||||
ledger_order.order_no as orderNo,
|
||||
case when ledger.biz_type = '5' then ledger.biz_id end as afterSaleId,
|
||||
ledger.biz_id as bizId,
|
||||
ledger.dept_id as deptId,
|
||||
ledger.wallet_id as walletId,
|
||||
ledger.user_id as userId
|
||||
<include refid="com.bsd.pjcloud.hard.mapper.order.HardWalletLedgerStatisticsMapper.ledgerJoins"/>
|
||||
where <include refid="detailWhere"/>
|
||||
<if test="beforeId != null">and ledger.id < #{beforeId}</if>
|
||||
order by ledger.id desc
|
||||
limit #{size}
|
||||
</select>
|
||||
</mapper>
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.bsd.pjcloud.hard.mapper.order.HardWalletLedgerStatisticsMapper">
|
||||
<!-- 数字主键连接保持可用索引;随后字节比较拒绝前导零、尾空格等非精确业务号。
|
||||
先检查长度和数字格式,避免非订单退款编号产生转换警告或溢出。 -->
|
||||
<sql id="numericBusinessId">
|
||||
case when ledger.biz_id regexp '^[0-9]{1,19}$' then cast(ledger.biz_id as unsigned) else null end
|
||||
</sql>
|
||||
|
||||
<!-- 供汇总与逐笔导出共用。关联元数据即使软删仍可归属;不得用订单状态/金额过滤实际账本。 -->
|
||||
<sql id="ledgerJoins">
|
||||
from pjcloudx_wallet.wallet_transaction ledger
|
||||
left join hard_after_sale ledger_refund
|
||||
on ledger.biz_type = '5'
|
||||
and ledger_refund.tenant_id = ledger.tenant_id
|
||||
and ledger_refund.id = <include refid="com.bsd.pjcloud.hard.mapper.order.HardWalletLedgerStatisticsMapper.numericBusinessId"/>
|
||||
and cast(ledger.biz_id as binary) = cast(concat('', ledger_refund.id) as binary)
|
||||
left join hard_order ledger_order
|
||||
on ledger_order.tenant_id = ledger.tenant_id
|
||||
and ledger_order.wallet_id = ledger.wallet_id
|
||||
and ledger_order.id = case
|
||||
when ledger.biz_type = '4' then <include refid="com.bsd.pjcloud.hard.mapper.order.HardWalletLedgerStatisticsMapper.numericBusinessId"/>
|
||||
when ledger.biz_type = '5' then ledger_refund.order_id
|
||||
else null end
|
||||
and (ledger.biz_type = '5'
|
||||
or cast(ledger.biz_id as binary) = cast(concat('', ledger_order.id) as binary))
|
||||
<if test="dto.walletTypeId != null or (dto.walletTypeIdList != null and dto.walletTypeIdList.size() != 0)">
|
||||
inner join pjcloudx_wallet.wallet_manage ledger_wallet
|
||||
on ledger_wallet.id = ledger.wallet_id and ledger_wallet.tenant_id = ledger.tenant_id
|
||||
</if>
|
||||
</sql>
|
||||
|
||||
<!-- 只包含流水自身的筛选;所有时间使用左闭右开区间。钱包类型沿用当前钱包类型。 -->
|
||||
<sql id="ledgerFilter">
|
||||
ledger.tenant_id = #{scope.tenantId} and ledger.del_flag = '0'
|
||||
<if test="dto.payTimeList != null and dto.payTimeList.length == 2">
|
||||
and ledger.create_time >= #{dto.payTimeList[0]}
|
||||
and ledger.create_time < #{dto.payTimeList[1]}
|
||||
</if>
|
||||
<if test="dto.walletId != null">and ledger.wallet_id = #{dto.walletId}</if>
|
||||
<if test="dto.walletTypeId != null">and ledger_wallet.wallet_type_id = #{dto.walletTypeId}</if>
|
||||
<if test="dto.walletTypeIdList != null and dto.walletTypeIdList.size() != 0">
|
||||
and ledger_wallet.wallet_type_id in
|
||||
<foreach collection="dto.walletTypeIdList" item="id" open="(" close=")" separator=",">#{id}</foreach>
|
||||
</if>
|
||||
<if test="dto.userId != null">and ledger.user_id = #{dto.userId}</if>
|
||||
<if test="dto.userIds != null and dto.userIds.size() != 0">
|
||||
and ledger.user_id in
|
||||
<foreach collection="dto.userIds" item="id" open="(" close=")" separator=",">#{id}</foreach>
|
||||
</if>
|
||||
<if test="dto.userDeptId != null">and ledger.dept_id = #{dto.userDeptId}</if>
|
||||
<if test="dto.userDeptIds != null and dto.userDeptIds.size() != 0">
|
||||
<choose>
|
||||
<when test="dto.isInclude != null and dto.isInclude == false">
|
||||
and (ledger.dept_id is null or ledger.dept_id not in
|
||||
<foreach collection="dto.userDeptIds" item="id" open="(" close=")" separator=",">#{id}</foreach>)
|
||||
</when>
|
||||
<otherwise>
|
||||
and ledger.dept_id in
|
||||
<foreach collection="dto.userDeptIds" item="id" open="(" close=")" separator=",">#{id}</foreach>
|
||||
</otherwise>
|
||||
</choose>
|
||||
</if>
|
||||
</sql>
|
||||
|
||||
<!-- 商户维度筛选仅作用于支付/退款;显式筛选无法匹配非订单/未归属行。 -->
|
||||
<sql id="merchantFilter">
|
||||
<if test="dto.id != null or (dto.orderIds != null and dto.orderIds.size() != 0)
|
||||
or (dto.orderNo != null and dto.orderNo != '') or dto.merchantId != null
|
||||
or (dto.merchantType != null and dto.merchantType != '')
|
||||
or (dto.mchIds != null and dto.mchIds.size() != 0)
|
||||
or (dto.mchTypeCodes != null and dto.mchTypeCodes.size() != 0)
|
||||
or dto.merchantDeptId != null or (dto.merchantDeptIds != null and dto.merchantDeptIds.size() != 0)
|
||||
or dto.hardId != null or (dto.hardIds != null and dto.hardIds.size() != 0)
|
||||
or dto.deptId != null or (dto.deptIds != null and dto.deptIds.size() != 0)
|
||||
or (dto.hardTypeCodes != null and dto.hardTypeCodes.size() != 0)
|
||||
or (dto.orderType != null and dto.orderType != '') or (dto.hourType != null and dto.hourType != '')
|
||||
or dto.isOfflineOrder != null or (dto.orderPayTypeCodes != null and dto.orderPayTypeCodes.size() != 0)">
|
||||
and ledger_order.merchant_id is not null
|
||||
</if>
|
||||
<if test="dto.id != null">and ledger_order.id = #{dto.id}</if>
|
||||
<if test="dto.orderIds != null and dto.orderIds.size() != 0">
|
||||
and ledger_order.id in
|
||||
<foreach collection="dto.orderIds" item="id" open="(" close=")" separator=",">#{id}</foreach>
|
||||
</if>
|
||||
<if test="dto.orderNo != null and dto.orderNo != ''">and ledger_order.order_no = #{dto.orderNo}</if>
|
||||
<if test="dto.merchantId != null">and ledger_order.merchant_id = #{dto.merchantId}</if>
|
||||
<if test="dto.merchantType != null and dto.merchantType != ''">and ledger_order.merchant_type = #{dto.merchantType}</if>
|
||||
<if test="dto.mchIds != null and dto.mchIds.size() != 0">
|
||||
and ledger_order.merchant_id in
|
||||
<foreach collection="dto.mchIds" item="id" open="(" close=")" separator=",">#{id}</foreach>
|
||||
</if>
|
||||
<if test="dto.mchTypeCodes != null and dto.mchTypeCodes.size() != 0">
|
||||
and ledger_order.merchant_type in
|
||||
<foreach collection="dto.mchTypeCodes" item="code" open="(" close=")" separator=",">#{code}</foreach>
|
||||
</if>
|
||||
<if test="dto.merchantDeptId != null">and ledger_order.merchant_dept_id = #{dto.merchantDeptId}</if>
|
||||
<if test="dto.merchantDeptIds != null and dto.merchantDeptIds.size() != 0">
|
||||
<choose>
|
||||
<when test="dto.isIncludeMerchantDept != null and dto.isIncludeMerchantDept == false">and ledger_order.merchant_dept_id not in</when>
|
||||
<otherwise>and ledger_order.merchant_dept_id in</otherwise>
|
||||
</choose>
|
||||
<foreach collection="dto.merchantDeptIds" item="id" open="(" close=")" separator=",">#{id}</foreach>
|
||||
</if>
|
||||
<if test="dto.hardId != null">and ledger_order.hard_id = #{dto.hardId}</if>
|
||||
<if test="dto.hardIds != null and dto.hardIds.size() != 0">
|
||||
and ledger_order.hard_id in
|
||||
<foreach collection="dto.hardIds" item="id" open="(" close=")" separator=",">#{id}</foreach>
|
||||
</if>
|
||||
<if test="dto.deptId != null">and ledger_order.dept_id = #{dto.deptId}</if>
|
||||
<if test="dto.deptIds != null and dto.deptIds.size() != 0">
|
||||
<choose>
|
||||
<when test="dto.isInclude != null and dto.isInclude == false">and ledger_order.dept_id not in</when>
|
||||
<otherwise>and ledger_order.dept_id in</otherwise>
|
||||
</choose>
|
||||
<foreach collection="dto.deptIds" item="id" open="(" close=")" separator=",">#{id}</foreach>
|
||||
</if>
|
||||
<if test="dto.hardTypeCodes != null and dto.hardTypeCodes.size() != 0">
|
||||
and exists (select 1 from hard_manager ledger_hard
|
||||
where ledger_hard.id = ledger_order.hard_id and ledger_hard.tenant_id = ledger.tenant_id
|
||||
and ledger_hard.type in
|
||||
<foreach collection="dto.hardTypeCodes" item="code" open="(" close=")" separator=",">#{code}</foreach>)
|
||||
</if>
|
||||
<if test="dto.orderType != null and dto.orderType != ''">and ledger_order.order_type = #{dto.orderType}</if>
|
||||
<if test="dto.hourType != null and dto.hourType != ''">and ledger_order.hour_type = #{dto.hourType}</if>
|
||||
<if test="dto.isOfflineOrder != null">and ledger_order.is_offline_order = #{dto.isOfflineOrder}</if>
|
||||
<if test="dto.orderPayTypeCodes != null and dto.orderPayTypeCodes.size() != 0">
|
||||
and ledger_order.pay_type in
|
||||
<foreach collection="dto.orderPayTypeCodes" item="code" open="(" close=")" separator=",">#{code}</foreach>
|
||||
</if>
|
||||
</sql>
|
||||
|
||||
<!-- 权限必须在聚合前应用;全权限才可显示非订单/未归属,个人权限复用原订单创建人。 -->
|
||||
<sql id="merchantScope">
|
||||
<if test="!scope.all">
|
||||
and ledger_order.id is not null and ledger_order.merchant_id is not null
|
||||
<choose>
|
||||
<when test="scope.username != null or scope.deptIds.size() > 0">
|
||||
and (
|
||||
<if test="scope.username != null">ledger_order.create_by = #{scope.username}</if>
|
||||
<if test="scope.username != null and scope.deptIds.size() > 0">or</if>
|
||||
<if test="scope.deptIds.size() > 0">
|
||||
ledger_order.merchant_dept_id in
|
||||
<foreach collection="scope.deptIds" item="id" open="(" close=")" separator=",">#{id}</foreach>
|
||||
</if>
|
||||
)
|
||||
</when>
|
||||
<otherwise>and 1 = 0</otherwise>
|
||||
</choose>
|
||||
</if>
|
||||
</sql>
|
||||
|
||||
<sql id="merchantAmounts">
|
||||
coalesce(-sum(case when ledger.biz_type = '4' then ledger.price else 0 end), 0) as totalPayPrice,
|
||||
sum(case when ledger.biz_type = '4' then 1 else 0 end) as totalPayCount,
|
||||
coalesce(sum(case when ledger.biz_type = '5' then ledger.price else 0 end), 0) as totalRefundPrice,
|
||||
sum(case when ledger.biz_type = '5' then 1 else 0 end) as totalRefundCount,
|
||||
coalesce(-sum(ledger.price), 0) as totalProfitPrice,
|
||||
sum(case when ledger.biz_type = '4' then 1 else -1 end) as totalProfitCount
|
||||
</sql>
|
||||
|
||||
<select id="merchantStatistics" resultType="com.bsd.pjcloud.hard.api.vo.order.HardOrderCsVo" useCache="false">
|
||||
select ledger_order.merchant_id as merchantId,
|
||||
max(ledger_order.merchant_dept_id) as merchantDeptId,
|
||||
max(ledger_order.merchant_type) as merchantType,
|
||||
<include refid="merchantAmounts"/>
|
||||
<include refid="ledgerJoins"/>
|
||||
where ledger.biz_type in ('4', '5') and <include refid="ledgerFilter"/>
|
||||
<include refid="merchantFilter"/><include refid="merchantScope"/>
|
||||
group by ledger_order.merchant_id
|
||||
order by case max(ledger_order.merchant_type) when '1' then 1 when '3' then 2 when '0' then 3 when '2' then 4 else 5 end,
|
||||
ledger_order.merchant_id
|
||||
</select>
|
||||
|
||||
<select id="merchantStatisticsByMonth" resultType="com.bsd.pjcloud.hard.api.vo.order.HardOrderCsVo" useCache="false">
|
||||
select ledger_order.merchant_id as merchantId,
|
||||
max(ledger_order.merchant_dept_id) as merchantDeptId,
|
||||
max(ledger_order.merchant_type) as merchantType,
|
||||
date_format(ledger.create_time, '%Y-%m') as `day`,
|
||||
<include refid="merchantAmounts"/>
|
||||
<include refid="ledgerJoins"/>
|
||||
where ledger.biz_type in ('4', '5') and <include refid="ledgerFilter"/>
|
||||
<include refid="merchantFilter"/><include refid="merchantScope"/>
|
||||
group by ledger_order.merchant_id, date_format(ledger.create_time, '%Y-%m')
|
||||
order by ledger_order.merchant_id desc, `day` desc
|
||||
</select>
|
||||
|
||||
<select id="departmentStatistics" resultType="com.bsd.pjcloud.hard.api.vo.order.HardDeptTotalVo" useCache="false">
|
||||
select ledger.dept_id as deptId,
|
||||
coalesce(sum(case when ledger.biz_type = '2' and ledger.price > 0 then ledger.price else 0 end), 0) as walletPrice,
|
||||
sum(case when ledger.biz_type = '2' and ledger.price > 0 then 1 else 0 end) as walletCount,
|
||||
coalesce(-sum(case when ledger.biz_type = '2' and ledger.price < 0 then ledger.price else 0 end), 0) as walletReducePrice,
|
||||
sum(case when ledger.biz_type = '2' and ledger.price < 0 then 1 else 0 end) as walletReduceCount,
|
||||
coalesce(sum(case when ledger.biz_type in ('1', '3') and ledger.price > 0 then ledger.price else 0 end), 0) as walletOncePrice,
|
||||
sum(case when ledger.biz_type in ('1', '3') and ledger.price > 0 then 1 else 0 end) as walletOnceCount,
|
||||
coalesce(-sum(case when ledger.biz_type in ('1', '3') and ledger.price < 0 then ledger.price else 0 end), 0) as walletOnceReducePrice,
|
||||
sum(case when ledger.biz_type in ('1', '3') and ledger.price < 0 then 1 else 0 end) as walletOnceReduceCount,
|
||||
coalesce(abs(sum(case when ledger.biz_type = '8' then ledger.price else 0 end)), 0) as walletReturnCanteenPrice,
|
||||
sum(case when ledger.biz_type = '8' then 1 else 0 end) as walletReturnCanteenCount,
|
||||
coalesce(-sum(case when ledger.biz_type = '4' then ledger.price else 0 end), 0) as orderPayPrice,
|
||||
sum(case when ledger.biz_type = '4' then 1 else 0 end) as orderPayCount,
|
||||
coalesce(sum(case when ledger.biz_type = '5' then ledger.price else 0 end), 0) as orderRefundPrice,
|
||||
sum(case when ledger.biz_type = '5' then 1 else 0 end) as orderRefundCont,
|
||||
coalesce(-sum(case when ledger.biz_type in ('4', '5') then ledger.price else 0 end), 0) as orderProfitPrice,
|
||||
sum(case when ledger.biz_type = '4' then 1 when ledger.biz_type = '5' then -1 else 0 end) as orderProfitCont
|
||||
<include refid="ledgerJoins"/>
|
||||
where ledger.biz_type in ('1', '2', '3', '4', '5', '8') and <include refid="ledgerFilter"/>
|
||||
and (ledger.biz_type not in ('4', '5') or (1 = 1 <include refid="merchantFilter"/><include refid="merchantScope"/>))
|
||||
and (ledger.price <> 0 or ledger.biz_type in ('4', '5', '8'))
|
||||
group by ledger.dept_id
|
||||
order by ledger.dept_id
|
||||
</select>
|
||||
</mapper>
|
||||
+54
@@ -59,6 +59,60 @@ class HardBatchExportFileTest {
|
||||
assertEquals("122.22", sheet.getRow(3).getCell(7).getStringCellValue());
|
||||
assertEquals("12.22", sheet.getRow(3).getCell(10).getStringCellValue());
|
||||
}
|
||||
assertEquals(new BigDecimal("12345"), row.getTotalPayPrice(), "导出不能污染页面的分值");
|
||||
}
|
||||
|
||||
@Test void monthlyLedgerGroupsSpanPagesAndKeepUnassignedRefundsAndExactCents() throws Exception {
|
||||
List<HardOrderCsVo> groups = new ArrayList<>();
|
||||
for (long merchant = 1; merchant <= 17; merchant++) {
|
||||
for (int month = 1; month <= (merchant == 17 ? 9 : 12); month++) {
|
||||
HardOrderCsVo group = new HardOrderCsVo();
|
||||
group.setMerchantId(merchant);
|
||||
group.setMerchantName("商户" + merchant);
|
||||
group.setDay(String.format("2026-%02d", month));
|
||||
group.setTotalProfitPrice(new BigDecimal("101"));
|
||||
group.setTotalProfitCount(BigDecimal.ONE);
|
||||
groups.add(group);
|
||||
}
|
||||
}
|
||||
HardOrderCsVo unassigned = new HardOrderCsVo();
|
||||
unassigned.setMerchantName("非订单/未归属"); unassigned.setDay("2026-09");
|
||||
unassigned.setTotalProfitPrice(new BigDecimal("-27")); unassigned.setTotalProfitCount(BigDecimal.ONE.negate());
|
||||
groups.add(unassigned);
|
||||
when(totals.monthlyExportPages(any())).thenReturn(current -> {
|
||||
Page<HardOrderCsVo> request = new Page<>(current, 200);
|
||||
int start = Math.toIntExact((request.getCurrent() - 1) * request.getSize());
|
||||
return new Page<HardOrderCsVo>(request.getCurrent(), request.getSize()).setTotal(groups.size())
|
||||
.setRecords(groups.subList(start, Math.min(start + (int) request.getSize(), groups.size())));
|
||||
});
|
||||
HardOrderDto query = new HardOrderDto(); query.setPayTimeYearStr("2026");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
service.merchant(response, query, true);
|
||||
try (Workbook workbook = open(response)) {
|
||||
Sheet sheet = workbook.getSheetAt(0);
|
||||
assertEquals("商户17", sheet.getRow(20).getCell(0).getStringCellValue());
|
||||
assertEquals(9.09, sheet.getRow(20).getCell(25).getNumericCellValue(), 0.000001);
|
||||
assertEquals("非订单/未归属", sheet.getRow(21).getCell(0).getStringCellValue());
|
||||
assertEquals(-0.27, sheet.getRow(21).getCell(17).getNumericCellValue(), 0.000001);
|
||||
assertEquals(-1, sheet.getRow(21).getCell(18).getNumericCellValue());
|
||||
assertEquals("小计", sheet.getRow(22).getCell(0).getStringCellValue());
|
||||
assertEquals(16.90, sheet.getRow(22).getCell(17).getNumericCellValue(), 0.000001);
|
||||
assertEquals(16, sheet.getRow(22).getCell(18).getNumericCellValue());
|
||||
assertEquals(202.74, sheet.getRow(22).getCell(25).getNumericCellValue(), 0.000001);
|
||||
assertEquals(200, sheet.getRow(22).getCell(26).getNumericCellValue());
|
||||
}
|
||||
verify(totals).monthlyExportPages(same(query));
|
||||
assertEquals("2026", query.getPayTimeYearStr());
|
||||
assertEquals(new BigDecimal("101"), groups.get(0).getTotalProfitPrice());
|
||||
}
|
||||
|
||||
@Test void monthlyAggregationFailureDoesNotReturnAPartialFile() {
|
||||
when(totals.monthlyExportPages(any())).thenThrow(new IllegalStateException("账本不可用"));
|
||||
HardOrderDto query = new HardOrderDto(); query.setPayTimeYearStr("2026");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
assertThrows(IllegalStateException.class, () -> service.merchant(response, query, true));
|
||||
assertEquals(0, response.getContentAsByteArray().length);
|
||||
assertNull(response.getHeader("Content-Disposition"));
|
||||
}
|
||||
|
||||
@Test void departmentFooterUsesExistingSixteenSummaryPlaceholders() throws Exception {
|
||||
|
||||
+148
-73
@@ -1,101 +1,176 @@
|
||||
package com.bsd.pjcloud.hard.service.export;
|
||||
|
||||
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressReporter;
|
||||
import com.bsd.pjcloud.common.data.datascope.DataScope;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
|
||||
import com.bsd.pjcloud.admin.api.entity.SysDept;
|
||||
import com.bsd.pjcloud.admin.api.feign.RemoteDeptService;
|
||||
import com.bsd.pjcloud.hard.api.dto.order.*;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.*;
|
||||
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressReporter;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.hard.api.dto.order.HardDeptTotalDto;
|
||||
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.HardDeptTotalVo;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.HardOrderCsVo;
|
||||
import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper;
|
||||
import com.bsd.pjcloud.hard.mapper.order.HardDepartmentStatisticsMapper;
|
||||
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
|
||||
import com.bsd.pjcloud.hard.mapper.order.HardWalletLedgerStatisticsMapper;
|
||||
import com.bsd.pjcloud.hard.mapper.order.model.HardOrderStatisticsScope;
|
||||
import com.bsd.pjcloud.hard.service.HardManagerService;
|
||||
import com.bsd.pjcloud.hard.service.impl.order.HardOrderTotalServiceImpl;
|
||||
import com.bsd.pjcloud.hard.service.order.HardWalletLedgerQuerySupport;
|
||||
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
|
||||
import com.bsd.pjcloud.wallet.api.feign.RemoteWalletTransactionService;
|
||||
import com.bsd.pjcloud.wallet.api.vo.WalletDepartmentStatisticsVo;
|
||||
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 验证商户分片边界及部门导出的数据库聚合路径,不依赖真实数据库。 */
|
||||
/** 验证真实导出服务以钱包账本按半开自然日分片,且与列表复用服务端权限。 */
|
||||
class HardBatchExportQueryTest {
|
||||
@Test void naturalDaySlicesKeepRawCentsAndCommissionIsRoundedAfterMerging() {
|
||||
HardOrderTotalServiceImpl service = mock(HardOrderTotalServiceImpl.class, CALLS_REAL_METHODS);
|
||||
HardOrderMapper mapper = mock(HardOrderMapper.class);
|
||||
ReflectionTestUtils.setField(service, "hardOrderMapper", mapper);
|
||||
DataScope scope = mock(DataScope.class);
|
||||
private final HardOrderMapper orders = mock(HardOrderMapper.class);
|
||||
private final HardWalletLedgerStatisticsMapper ledger = mock(HardWalletLedgerStatisticsMapper.class);
|
||||
private final HardWalletLedgerQuerySupport support = mock(HardWalletLedgerQuerySupport.class);
|
||||
private final RemoteMchService merchants = mock(RemoteMchService.class);
|
||||
private final RemoteDeptService departments = mock(RemoteDeptService.class);
|
||||
private final HardOrderStatisticsScope scope = new HardOrderStatisticsScope(7L, false, List.of(10L), "alice");
|
||||
private final HardOrderTotalServiceImpl service = new HardOrderTotalServiceImpl(orders, mock(HardManagerMapper.class),
|
||||
mock(HardManagerService.class), merchants, departments, ledger, support);
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
when(support.merchant(any())).thenAnswer(call -> new HardWalletLedgerQuerySupport.QueryContext(call.getArgument(0), scope));
|
||||
when(ledger.merchantStatistics(any(), any())).thenReturn(List.of());
|
||||
when(merchants.getByIds(anyList())).thenReturn(R.ok(List.of()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void adjacentHalfOpenSlicesAccumulateAllPaymentAndRefundFieldsBeforeCommissionRounding() {
|
||||
List<LocalDateTime[]> ranges = new ArrayList<>();
|
||||
when(mapper.listCs(any(), eq(scope))).thenAnswer(call -> {
|
||||
HardOrderDto query = call.getArgument(0);
|
||||
ranges.add(query.getPayTimeList());
|
||||
HardOrderCsVo row = new HardOrderCsVo(); row.setMerchantId(1L);
|
||||
row.setTotalPayPrice(new BigDecimal(ranges.size() == 1 ? "101" : "201"));
|
||||
row.setTotalPayCount(BigDecimal.ONE);
|
||||
return List.of(row);
|
||||
});
|
||||
HardOrderDto query = new HardOrderDto();
|
||||
LocalDateTime from = LocalDateTime.of(2026, 10, 20, 10, 0), to = LocalDateTime.of(2026, 10, 21, 10, 0);
|
||||
query.setPayTimeList(new LocalDateTime[]{from, to});
|
||||
AsyncTaskProgressReporter reporter = mock(AsyncTaskProgressReporter.class);
|
||||
List<HardOrderCsVo> rows = ReflectionTestUtils.invokeMethod(service, "queryCsExportSlices", query, false, scope, reporter);
|
||||
query.setUserDeptIds(List.of(20L)); query.setIsInclude(false); query.setWalletTypeIdList(List.of(111L));
|
||||
when(ledger.merchantStatistics(any(), same(scope))).thenAnswer(call -> {
|
||||
HardOrderDto slice = call.getArgument(0);
|
||||
ranges.add(slice.getPayTimeList().clone());
|
||||
assertEquals(List.of(20L), slice.getUserDeptIds());
|
||||
assertEquals(false, slice.getIsInclude());
|
||||
assertEquals(List.of(111L), slice.getWalletTypeIdList());
|
||||
int index = ranges.size();
|
||||
var merchant = row(1L, index == 1 ? 101 : 201, index, index == 1 ? 97 : 197, 1);
|
||||
var unassigned = row(null, 0, 0, index == 1 ? 5 : 7, 1);
|
||||
return List.of(merchant, unassigned);
|
||||
});
|
||||
var merchant = new MchManageVo(); merchant.setId(1L); merchant.setPlatformRanke(12.345D);
|
||||
when(merchants.getByIds(List.of(1L))).thenReturn(R.ok(List.of(merchant)));
|
||||
var reporter = mock(AsyncTaskProgressReporter.class);
|
||||
|
||||
var rows = service.listCsForExport(query, reporter);
|
||||
|
||||
assertEquals(2, ranges.size());
|
||||
assertEquals(from, ranges.get(0)[0]); assertEquals(to, ranges.get(1)[1]);
|
||||
assertEquals(ranges.get(1)[0].minusNanos(1), ranges.get(0)[1]);
|
||||
assertEquals(new BigDecimal("302"), rows.get(0).getTotalPayPrice());
|
||||
assertEquals(new BigDecimal("2"), rows.get(0).getTotalPayCount());
|
||||
rows.get(0).setTotalProfitPrice(rows.get(0).getTotalPayPrice());
|
||||
MchManageVo merchant = new MchManageVo(); merchant.setId(1L); merchant.setPlatformRanke(10D);
|
||||
ReflectionTestUtils.invokeMethod(service, "computeMchPlatformRanke", rows, List.of(merchant));
|
||||
assertEquals(new BigDecimal("30.20"), rows.get(0).getMchPlatformRankePrice());
|
||||
assertArrayEquals(new LocalDateTime[]{from, LocalDateTime.of(2026, 10, 21, 0, 0)}, ranges.get(0));
|
||||
assertArrayEquals(new LocalDateTime[]{ranges.get(0)[1], to}, ranges.get(1), "相邻分片共享同一个边界,不减纳秒");
|
||||
assertArrayEquals(new LocalDateTime[]{from, to}, query.getPayTimeList());
|
||||
assertNull(query.getRefundTimeList());
|
||||
assertEquals(2, rows.size());
|
||||
var paid = rows.get(0);
|
||||
assertEquals(new BigDecimal("302"), paid.getTotalPayPrice());
|
||||
assertEquals(new BigDecimal("3"), paid.getTotalPayCount());
|
||||
assertEquals(new BigDecimal("294"), paid.getTotalRefundPrice());
|
||||
assertEquals(new BigDecimal("2"), paid.getTotalRefundCount());
|
||||
assertEquals(new BigDecimal("8"), paid.getTotalProfitPrice());
|
||||
assertEquals(BigDecimal.ONE, paid.getTotalProfitCount());
|
||||
assertEquals(new BigDecimal("0.99"), paid.getMchPlatformRankePrice(), "按合并后的 8 分算抽成,不能把两片各自舍入为 0.49 后相加");
|
||||
var unassigned = rows.get(1);
|
||||
assertNull(unassigned.getMerchantId());
|
||||
assertEquals(new BigDecimal("12"), unassigned.getTotalRefundPrice());
|
||||
assertEquals(new BigDecimal("-12"), unassigned.getTotalProfitPrice());
|
||||
assertEquals(new BigDecimal("-2"), unassigned.getTotalProfitCount());
|
||||
verify(merchants).getByIds(List.of(1L));
|
||||
verifyZeroInteractions(departments, orders);
|
||||
verify(reporter).updateTotal(5, "正在按自然日汇总商户交易");
|
||||
verify(reporter, times(2)).advance(anyString());
|
||||
}
|
||||
|
||||
@Test void departmentExportUsesDepartmentLevelDatabaseAggregation() {
|
||||
HardOrderMapper orders = mock(HardOrderMapper.class);
|
||||
HardDepartmentStatisticsMapper aggregate = mock(HardDepartmentStatisticsMapper.class);
|
||||
RemoteWalletTransactionService wallets = mock(RemoteWalletTransactionService.class);
|
||||
RemoteDeptService departments = mock(RemoteDeptService.class);
|
||||
HardOrderTotalServiceImpl service = new HardOrderTotalServiceImpl(orders, mock(HardManagerMapper.class),
|
||||
mock(HardManagerService.class), mock(RemoteMchService.class), wallets, departments, aggregate);
|
||||
SysDept department = new SysDept(); department.setDeptId(42L); department.setName("历史部门");
|
||||
@Test
|
||||
void midnightEndDoesNotProduceAnEmptyOrOverlappingExtraSlice() {
|
||||
var query = new HardOrderDto();
|
||||
var from = LocalDateTime.of(2026, 10, 20, 10, 0);
|
||||
var midnight = LocalDateTime.of(2026, 10, 21, 0, 0);
|
||||
query.setPayTimeList(new LocalDateTime[]{from, midnight});
|
||||
var reporter = mock(AsyncTaskProgressReporter.class);
|
||||
assertTrue(service.listCsForExport(query, reporter).isEmpty());
|
||||
verify(ledger).merchantStatistics(argThat(slice -> from.equals(slice.getPayTimeList()[0])
|
||||
&& midnight.equals(slice.getPayTimeList()[1])), same(scope));
|
||||
verifyNoMoreInteractions(ledger);
|
||||
verify(reporter).updateTotal(4, "正在按自然日汇总商户交易");
|
||||
verify(reporter, times(1)).advance(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void absentRangeUsesOnePreparedLedgerQueryAndEmptyRangeUsesNone() {
|
||||
var query = new HardOrderDto();
|
||||
var reporter = mock(AsyncTaskProgressReporter.class);
|
||||
assertTrue(service.listCsForExport(query, reporter).isEmpty());
|
||||
verify(ledger).merchantStatistics(same(query), same(scope));
|
||||
verify(reporter).updateTotal(3, "正在汇总商户交易");
|
||||
clearInvocations(ledger);
|
||||
var instant = LocalDateTime.of(2026, 10, 20, 0, 0);
|
||||
query.setPayTimeList(new LocalDateTime[]{instant, instant});
|
||||
assertTrue(service.listCsForExport(query, reporter).isEmpty());
|
||||
verifyZeroInteractions(ledger, orders, merchants, departments);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedLaterSliceStopsWithoutMetadataOrASuccessfulPartialReport() {
|
||||
var query = new HardOrderDto();
|
||||
LocalDateTime from = LocalDateTime.of(2026, 10, 20, 10, 0), to = LocalDateTime.of(2026, 10, 21, 10, 0);
|
||||
query.setPayTimeList(new LocalDateTime[]{from, to});
|
||||
var failure = new IllegalStateException("第二片查询失败");
|
||||
when(ledger.merchantStatistics(any(), any())).thenReturn(List.of(row(1L, 100, 1, 20, 1))).thenThrow(failure);
|
||||
var reporter = mock(AsyncTaskProgressReporter.class);
|
||||
assertSame(failure, assertThrows(IllegalStateException.class, () -> service.listCsForExport(query, reporter)));
|
||||
verify(reporter, times(1)).advance(anyString());
|
||||
verifyZeroInteractions(merchants, departments, orders);
|
||||
assertArrayEquals(new LocalDateTime[]{from, to}, query.getPayTimeList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void departmentExportUsesThePreparedLedgerQueryOnceAndKeepsOriginalVoFields() {
|
||||
var query = new HardDeptTotalDto(); query.setDeptIds(List.of(42L)); query.setIsInclude(true);
|
||||
var prepared = new HardOrderDto(); prepared.setUserDeptIds(List.of(42L));
|
||||
when(support.department(same(query))).thenReturn(new HardWalletLedgerQuerySupport.QueryContext(prepared, scope));
|
||||
var department = new SysDept(); department.setDeptId(42L); department.setName("历史部门");
|
||||
when(departments.getInnerAllDept(any())).thenReturn(R.ok(List.of(department)));
|
||||
WalletDepartmentStatisticsVo wallet = new WalletDepartmentStatisticsVo();
|
||||
wallet.setDeptId(42L); wallet.setRefundSum(23L); wallet.setRefundCount(1L);
|
||||
when(wallets.departmentStatistics(any())).thenReturn(R.ok(List.of(wallet)));
|
||||
HardOrderSumAndCountVo payment = new HardOrderSumAndCountVo();
|
||||
payment.setDeptId(42L); payment.setSum(123L); payment.setCount(2L);
|
||||
when(aggregate.paymentStatistics(any(), eq(7L))).thenReturn(List.of(payment));
|
||||
HardDeptTotalDto query = new HardDeptTotalDto(); query.setDeptIds(List.of(42L)); query.setIsInclude(true);
|
||||
AsyncTaskProgressReporter reporter = mock(AsyncTaskProgressReporter.class);
|
||||
Long previousTenant = TenantContextHolder.getTenantId();
|
||||
try {
|
||||
TenantContextHolder.setTenantId(7L);
|
||||
List<HardDeptTotalVo> rows = service.listDeptForExport(query, reporter);
|
||||
assertEquals(1, rows.size()); assertEquals(42L, rows.get(0).getDeptId());
|
||||
assertEquals("历史部门", rows.get(0).getDeptName());
|
||||
assertEquals(new BigDecimal("123"), rows.get(0).getOrderPayPrice());
|
||||
assertEquals(new BigDecimal("23"), rows.get(0).getOrderRefundPrice());
|
||||
assertEquals(new BigDecimal("100"), rows.get(0).getOrderProfitPrice());
|
||||
assertEquals(1L, rows.get(0).getOrderProfitCont());
|
||||
verify(wallets).departmentStatistics(any());
|
||||
verify(aggregate).paymentStatistics(argThat(dto -> dto.getUserDeptIds().equals(List.of(42L))
|
||||
&& Boolean.TRUE.equals(dto.getIsInclude())), eq(7L));
|
||||
verify(reporter).updateTotal(6, "正在汇总部门交易");
|
||||
verify(reporter, times(4)).advance(anyString());
|
||||
verifyNoMoreInteractions(wallets, aggregate);
|
||||
verifyZeroInteractions(orders);
|
||||
} finally {
|
||||
if (previousTenant == null) TenantContextHolder.clear();
|
||||
else TenantContextHolder.setTenantId(previousTenant);
|
||||
}
|
||||
var aggregate = new HardDeptTotalVo(); aggregate.setDeptId(42L);
|
||||
aggregate.setOrderPayPrice(new BigDecimal("123")); aggregate.setOrderPayCount(2L);
|
||||
aggregate.setOrderRefundPrice(new BigDecimal("23")); aggregate.setOrderRefundCont(1L);
|
||||
when(ledger.departmentStatistics(same(prepared), same(scope))).thenReturn(List.of(aggregate));
|
||||
var reporter = mock(AsyncTaskProgressReporter.class);
|
||||
|
||||
var rows = service.listDeptForExport(query, reporter);
|
||||
|
||||
assertEquals(1, rows.size()); assertEquals(42L, rows.get(0).getDeptId());
|
||||
assertEquals("历史部门", rows.get(0).getDeptName());
|
||||
assertEquals(new BigDecimal("123"), rows.get(0).getOrderPayPrice());
|
||||
assertEquals(new BigDecimal("23"), rows.get(0).getOrderRefundPrice());
|
||||
assertEquals(new BigDecimal("100"), rows.get(0).getOrderProfitPrice());
|
||||
assertEquals(1L, rows.get(0).getOrderProfitCont());
|
||||
verify(ledger).departmentStatistics(same(prepared), same(scope));
|
||||
verifyNoMoreInteractions(ledger);
|
||||
verify(reporter).updateTotal(5, "正在汇总部门交易");
|
||||
verify(reporter, times(3)).advance(anyString());
|
||||
verifyZeroInteractions(orders, merchants);
|
||||
}
|
||||
|
||||
private static HardOrderCsVo row(Long merchant, long paid, long payments, long refunded, long refunds) {
|
||||
var row = new HardOrderCsVo(); row.setMerchantId(merchant);
|
||||
row.setTotalPayPrice(BigDecimal.valueOf(paid)); row.setTotalPayCount(BigDecimal.valueOf(payments));
|
||||
row.setTotalRefundPrice(BigDecimal.valueOf(refunded)); row.setTotalRefundCount(BigDecimal.valueOf(refunds));
|
||||
return row;
|
||||
}
|
||||
}
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
package com.bsd.pjcloud.hard.service.export;
|
||||
|
||||
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
|
||||
import com.bsd.pjcloud.hard.mapper.order.HardWalletLedgerDetailMapper;
|
||||
import com.bsd.pjcloud.hard.mapper.order.model.HardOrderStatisticsScope;
|
||||
import com.bsd.pjcloud.hard.mapper.order.model.HardWalletLedgerDetail;
|
||||
import com.bsd.pjcloud.hard.service.impl.order.HardWalletLedgerDetailExportService;
|
||||
import com.bsd.pjcloud.hard.service.order.HardWalletLedgerQuerySupport;
|
||||
import org.apache.poi.ss.usermodel.CellType;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 验证真实 XLSX 中每笔钱包流水、符号、小数、大整数及跨批合计,而非只核对字段映射。 */
|
||||
class HardWalletLedgerDetailExportTest {
|
||||
/** 导出查询必须复用的服务端筛选准备器。 */
|
||||
private final HardWalletLedgerQuerySupport support = mock(HardWalletLedgerQuerySupport.class);
|
||||
/** 对账明细数据库依赖。 */
|
||||
private final HardWalletLedgerDetailMapper mapper = mock(HardWalletLedgerDetailMapper.class);
|
||||
/** 被测完整文件导出服务。 */
|
||||
private final HardWalletLedgerDetailExportService service = new HardWalletLedgerDetailExportService(support, mapper);
|
||||
|
||||
/** 空商户、未知商户等非法输入在查询或准备权限前拒绝,不能退化成全量导出。 */
|
||||
@Test
|
||||
void missingOrUnknownMerchantNeverBecomesAnUnboundedExport() {
|
||||
for (Long merchantId : new Long[]{null, 0L, -1L}) {
|
||||
HardOrderDto dto = new HardOrderDto();
|
||||
dto.setMerchantId(merchantId);
|
||||
assertThrows(IllegalArgumentException.class, () -> service.export(new MockHttpServletResponse(), dto));
|
||||
}
|
||||
verifyZeroInteractions(support, mapper);
|
||||
}
|
||||
|
||||
/** 支付与退款均逐笔导出,超过两百行仍保持游标、元金额和尾部合计一致。 */
|
||||
@Test
|
||||
void realWorkbookKeepsEverySignedTransactionAcrossBatchesAndExactTextIds() throws Exception {
|
||||
HardOrderDto source = query();
|
||||
HardOrderDto normalized = query();
|
||||
normalized.setPayTimeList(new LocalDateTime[]{LocalDateTime.of(2026, 9, 1, 0, 0), LocalDateTime.of(2026, 10, 1, 0, 0)});
|
||||
HardOrderStatisticsScope scope = new HardOrderStatisticsScope(7L, false, List.of(80L), "alice");
|
||||
when(support.merchant(source)).thenReturn(new HardWalletLedgerQuerySupport.QueryContext(normalized, scope));
|
||||
when(mapper.count(normalized, scope)).thenReturn(201L);
|
||||
List<HardWalletLedgerDetail> all = new ArrayList<>();
|
||||
for (int index = 0; index < 201; index++) {
|
||||
String type = index % 2 == 0 ? "4" : "5";
|
||||
long price = index % 4 == 0 ? 123L : index % 4 == 1 ? -45L : index % 4 == 2 ? -200L : 0L;
|
||||
all.add(detail(9_000_000_000_001_000L - index, type, price));
|
||||
}
|
||||
when(mapper.listBatch(eq(normalized), eq(scope), nullable(Long.class), eq(200))).thenAnswer(call -> {
|
||||
Long before = call.getArgument(2);
|
||||
return all.stream().filter(row -> before == null || row.getTransactionId() < before).limit(200).toList();
|
||||
});
|
||||
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
service.export(response, source);
|
||||
|
||||
assertTrue(response.getHeader("Content-Disposition").startsWith("attachment;"));
|
||||
try (var workbook = new XSSFWorkbook(new ByteArrayInputStream(response.getContentAsByteArray()))) {
|
||||
var sheet = workbook.getSheetAt(0);
|
||||
assertEquals(202, sheet.getLastRowNum());
|
||||
assertEquals("钱包流水编号", sheet.getRow(0).getCell(1).getStringCellValue());
|
||||
BigDecimal payment = BigDecimal.ZERO;
|
||||
BigDecimal refund = BigDecimal.ZERO;
|
||||
BigDecimal ledger = BigDecimal.ZERO;
|
||||
for (int index = 0; index < all.size(); index++) {
|
||||
var row = sheet.getRow(index + 1);
|
||||
HardWalletLedgerDetail original = all.get(index);
|
||||
BigDecimal price = BigDecimal.valueOf(original.getPrice()).movePointLeft(2);
|
||||
assertEquals(index + 1, row.getCell(0).getNumericCellValue());
|
||||
assertEquals(CellType.STRING, row.getCell(1).getCellType());
|
||||
assertEquals(original.getTransactionId().toString(), row.getCell(1).getStringCellValue());
|
||||
assertEquals("4".equals(original.getBizType()) ? "钱包支付" : "钱包退款", row.getCell(2).getStringCellValue());
|
||||
assertEquals("2026-09-15 12:34:56", row.getCell(3).getStringCellValue());
|
||||
assertEquals(price.doubleValue(), row.getCell(6).getNumericCellValue(), 0.000001);
|
||||
if ("4".equals(original.getBizType())) payment = payment.subtract(price);
|
||||
else refund = refund.add(price);
|
||||
ledger = ledger.add(price);
|
||||
}
|
||||
var footer = sheet.getRow(202);
|
||||
assertEquals("合计", footer.getCell(2).getStringCellValue());
|
||||
assertEquals(payment.doubleValue(), footer.getCell(4).getNumericCellValue(), 0.000001);
|
||||
assertEquals(refund.doubleValue(), footer.getCell(5).getNumericCellValue(), 0.000001);
|
||||
assertEquals(ledger.doubleValue(), footer.getCell(6).getNumericCellValue(), 0.000001);
|
||||
assertEquals(-1.23, sheet.getRow(1).getCell(4).getNumericCellValue(), 0.000001);
|
||||
assertEquals(-0.45, sheet.getRow(2).getCell(5).getNumericCellValue(), 0.000001);
|
||||
assertEquals(2.0, sheet.getRow(3).getCell(4).getNumericCellValue(), 0.000001);
|
||||
assertEquals(0.0, sheet.getRow(4).getCell(5).getNumericCellValue(), 0.000001);
|
||||
}
|
||||
verify(mapper).listBatch(normalized, scope, null, 200);
|
||||
verify(mapper).listBatch(normalized, scope, all.get(199).getTransactionId(), 200);
|
||||
assertNull(source.getPayTimeList());
|
||||
assertEquals(100L, source.getMerchantId());
|
||||
assertEquals(123L, all.get(0).getPrice());
|
||||
}
|
||||
|
||||
/** 数据量保护和空数据均应在开始生成或写出附件前完成。 */
|
||||
@Test
|
||||
void noDataAndOverLimitDoNotWriteAFile() {
|
||||
HardOrderDto dto = prepared();
|
||||
for (long count : new long[]{0L, 50_001L}) {
|
||||
when(mapper.count(eq(dto), any())).thenReturn(count);
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
assertThrows(IllegalArgumentException.class, () -> service.export(response, dto));
|
||||
assertEquals(0, response.getContentAsByteArray().length);
|
||||
assertNull(response.getHeader("Content-Disposition"));
|
||||
}
|
||||
verify(mapper, never()).listBatch(any(), any(), any(), anyInt());
|
||||
}
|
||||
|
||||
/** 后一批查询失败时临时附件不可作为成功文件返回给浏览器。 */
|
||||
@Test
|
||||
void failedBatchLeavesTheResponseEmpty() {
|
||||
HardOrderDto dto = prepared();
|
||||
when(mapper.count(eq(dto), any())).thenReturn(2L);
|
||||
when(mapper.listBatch(eq(dto), any(), isNull(), eq(200))).thenReturn(List.of(detail(10L, "4", -123L)));
|
||||
when(mapper.listBatch(eq(dto), any(), eq(10L), eq(200))).thenThrow(new IllegalStateException("数据库查询失败"));
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
assertThrows(IllegalStateException.class, () -> service.export(response, dto));
|
||||
assertEquals(0, response.getContentAsByteArray().length);
|
||||
assertNull(response.getHeader("Content-Disposition"));
|
||||
}
|
||||
|
||||
/** 数量缩减或乱序重复不能导出漏行、重复行的成功附件。 */
|
||||
@Test
|
||||
void changedOrNonDescendingRowsRejectTheIncompleteWorkbook() {
|
||||
HardOrderDto dto = prepared();
|
||||
when(mapper.count(eq(dto), any())).thenReturn(2L);
|
||||
for (List<HardWalletLedgerDetail> rows : List.of(List.<HardWalletLedgerDetail>of(),
|
||||
List.of(detail(10L, "4", -123L), detail(10L, "5", 50L)))) {
|
||||
when(mapper.listBatch(eq(dto), any(), isNull(), eq(200))).thenReturn(rows);
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
assertThrows(IllegalStateException.class, () -> service.export(response, dto));
|
||||
assertEquals(0, response.getContentAsByteArray().length);
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建真实商户页面条件,业务查询准备器仍负责生成最终副本。 */
|
||||
private HardOrderDto query() {
|
||||
HardOrderDto dto = new HardOrderDto();
|
||||
dto.setMerchantId(100L);
|
||||
return dto;
|
||||
}
|
||||
|
||||
/** 为异常路径固定服务端已规范化条件,排除与本测试无关的权限远程调用。 */
|
||||
private HardOrderDto prepared() {
|
||||
HardOrderDto dto = query();
|
||||
when(support.merchant(dto)).thenReturn(new HardWalletLedgerQuerySupport.QueryContext(dto,
|
||||
new HardOrderStatisticsScope(7L, true, List.of(), null)));
|
||||
return dto;
|
||||
}
|
||||
|
||||
/** 构造原始分值流水,不依赖订单金额或订单状态。 */
|
||||
private HardWalletLedgerDetail detail(long id, String type, long cents) {
|
||||
HardWalletLedgerDetail detail = new HardWalletLedgerDetail();
|
||||
detail.setTransactionId(id);
|
||||
detail.setBizType(type);
|
||||
detail.setPrice(cents);
|
||||
detail.setCreateTime(LocalDateTime.of(2026, 9, 15, 12, 34, 56));
|
||||
detail.setMerchantId(100L);
|
||||
detail.setOrderId(1234567890123456789L);
|
||||
detail.setOrderNo("order-no-001");
|
||||
detail.setAfterSaleId("5".equals(type) ? "200" : null);
|
||||
detail.setBizId("5".equals(type) ? "200" : "1234567890123456789");
|
||||
detail.setDeptId(20L);
|
||||
detail.setWalletId(30L);
|
||||
detail.setUserId(40L);
|
||||
return detail;
|
||||
}
|
||||
}
|
||||
+1
@@ -57,6 +57,7 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
*/
|
||||
@EnabledIfEnvironmentVariable(named = "HARD_DEPT_MYSQL_TEST", matches = "true")
|
||||
@EnabledIfEnvironmentVariable(named = "HARD_DEPT_MYSQL_BENCHMARK", matches = "true")
|
||||
// 仅验证历史订单查询;当前钱包账本验收见 HardWalletLedgerStatisticsSqlTest。
|
||||
class HardDepartmentStatisticsMySqlBenchmarkTest {
|
||||
private static final String HARDWARE = "pjcloudx_hardware";
|
||||
private static final String WALLET = "pjcloudx_wallet";
|
||||
|
||||
+1
@@ -45,6 +45,7 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
* 使用 HARD_DEPT_MYSQL_TEST=true 及 MYSQL_HOST/MYSQL_PORT/MYSQL_USER/MYSQL_PWD 显式启用。
|
||||
*/
|
||||
@EnabledIfEnvironmentVariable(named = "HARD_DEPT_MYSQL_TEST", matches = "true")
|
||||
// 仅验证历史订单查询;当前钱包账本验收见 HardWalletLedgerStatisticsSqlTest。
|
||||
class HardDepartmentStatisticsMySqlTest {
|
||||
static final String MAPPER = HardDepartmentStatisticsMapper.class.getName() + ".paymentStatistics";
|
||||
static final String MIGRATION = "db/20261022_hard_dept_statistics_indexes.sql";
|
||||
|
||||
+185
-240
@@ -7,26 +7,21 @@ import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
|
||||
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressReporter;
|
||||
import com.bsd.pjcloud.common.core.jackson.PjcloudJavaTimeModule;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
|
||||
import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
|
||||
import com.bsd.pjcloud.hard.api.dto.order.HardDeptTotalDto;
|
||||
import com.bsd.pjcloud.hard.api.dto.order.HardOrderSumAndCountDto;
|
||||
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.HardDeptTotalVo;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.HardOrderSumAndCountVo;
|
||||
import com.bsd.pjcloud.hard.controller.order.HardOrderTotalController;
|
||||
import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper;
|
||||
import com.bsd.pjcloud.hard.mapper.order.HardDepartmentStatisticsMapper;
|
||||
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
|
||||
import com.bsd.pjcloud.hard.mapper.order.HardWalletLedgerStatisticsMapper;
|
||||
import com.bsd.pjcloud.hard.mapper.order.model.HardOrderStatisticsScope;
|
||||
import com.bsd.pjcloud.hard.service.HardManagerService;
|
||||
import com.bsd.pjcloud.hard.service.HardOrderService;
|
||||
import com.bsd.pjcloud.hard.service.impl.order.HardBatchExportService;
|
||||
import com.bsd.pjcloud.hard.service.impl.order.HardOrderTotalServiceImpl;
|
||||
import com.bsd.pjcloud.wallet.api.dto.WalletTransactionSumAndCountDto;
|
||||
import com.bsd.pjcloud.hard.service.impl.order.HardWalletLedgerDetailExportService;
|
||||
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
|
||||
import com.bsd.pjcloud.wallet.api.feign.RemoteWalletTransactionService;
|
||||
import com.bsd.pjcloud.wallet.api.vo.WalletDepartmentStatisticsVo;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
@@ -37,6 +32,7 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
@@ -45,294 +41,243 @@ import static org.mockito.Mockito.*;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/** 验证真实报表服务只使用部门级聚合,同时保持页面、导出的字段和远程失败处理。 */
|
||||
/** 部门报表一次读取钱包账本,名称元数据不可删除缺失部门或无归属流水。 */
|
||||
class HardDepartmentStatisticsServiceTest {
|
||||
private final HardOrderMapper orders = mock(HardOrderMapper.class);
|
||||
private final HardManagerMapper hardManagers = mock(HardManagerMapper.class);
|
||||
private final HardManagerService managers = mock(HardManagerService.class);
|
||||
private final RemoteMchService merchants = mock(RemoteMchService.class);
|
||||
private final RemoteWalletTransactionService wallets = mock(RemoteWalletTransactionService.class);
|
||||
private final RemoteDeptService departments = mock(RemoteDeptService.class);
|
||||
private final HardDepartmentStatisticsMapper aggregates = mock(HardDepartmentStatisticsMapper.class);
|
||||
private final HardWalletLedgerStatisticsMapper ledger = mock(HardWalletLedgerStatisticsMapper.class);
|
||||
private final HardWalletLedgerQuerySupport support = mock(HardWalletLedgerQuerySupport.class);
|
||||
private final HardOrderStatisticsScope scope = new HardOrderStatisticsScope(7L, false, List.of(80L), "alice");
|
||||
private final HardOrderTotalServiceImpl service = new HardOrderTotalServiceImpl(
|
||||
orders, hardManagers, managers, merchants, wallets, departments, aggregates);
|
||||
orders, hardManagers, managers, merchants, departments, ledger, support);
|
||||
private final ObjectMapper json = new ObjectMapper().registerModule(new PjcloudJavaTimeModule());
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
TenantContextHolder.setTenantId(7L);
|
||||
when(support.department(any())).thenAnswer(call -> {
|
||||
HardDeptTotalDto source = call.getArgument(0);
|
||||
HardOrderDto normalized = new HardOrderDto();
|
||||
normalized.setPayTimeList(source.getCreateTimeList());
|
||||
normalized.setUserDeptIds(source.getDeptIds());
|
||||
normalized.setIsInclude(source.getIsInclude());
|
||||
normalized.setWalletTypeId(source.getWalletTypeId());
|
||||
normalized.setWalletTypeIdList(source.getWalletTypeIdList());
|
||||
return new HardWalletLedgerQuerySupport.QueryContext(normalized, scope);
|
||||
});
|
||||
when(departments.getInnerAllDept(any())).thenReturn(R.ok(List.of(dept(7L, "部门七"))));
|
||||
when(wallets.departmentStatistics(any())).thenReturn(R.ok(List.of()));
|
||||
when(aggregates.paymentStatistics(any(), eq(7L))).thenReturn(List.of());
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void clear() {
|
||||
TenantContextHolder.clear();
|
||||
when(ledger.departmentStatistics(any(), any())).thenReturn(List.of());
|
||||
}
|
||||
|
||||
@Test
|
||||
void allSixteenFieldsKeepSignedRefundAbsoluteExpenseAndExactNetAmounts() {
|
||||
when(wallets.departmentStatistics(any())).thenReturn(R.ok(List.of(fullWallet())));
|
||||
when(aggregates.paymentStatistics(any(), eq(7L))).thenReturn(List.of(payment(7L, 5005L, 8L)));
|
||||
void allSixteenFieldsKeepLedgerSignsAndExactNetAmounts() {
|
||||
when(ledger.departmentStatistics(any(), same(scope))).thenReturn(List.of(fullLedger()));
|
||||
HardDeptTotalVo row = service.pageListDept(query()).get(0);
|
||||
assertEquals(7L, row.getDeptId());
|
||||
assertEquals("部门七", row.getDeptName());
|
||||
assertEquals(new BigDecimal("1001"), row.getWalletPrice());
|
||||
assertEquals(2L, row.getWalletCount());
|
||||
assertEquals(101L, row.getWalletReducePrice());
|
||||
assertEquals(3L, row.getWalletReduceCount());
|
||||
assertEquals(new BigDecimal("2002"), row.getWalletOncePrice());
|
||||
assertEquals(4L, row.getWalletOnceCount());
|
||||
assertEquals(new BigDecimal("202"), row.getWalletOnceReducePrice());
|
||||
assertEquals(5L, row.getWalletOnceReduceCount());
|
||||
assertEquals(new BigDecimal("303"), row.getWalletReturnCanteenPrice());
|
||||
assertEquals(6L, row.getWalletReturnCanteenCount());
|
||||
assertEquals(new BigDecimal("5005"), row.getOrderPayPrice());
|
||||
assertEquals(8L, row.getOrderPayCount());
|
||||
assertEquals(new BigDecimal("-404"), row.getOrderRefundPrice());
|
||||
assertEquals(7L, row.getOrderRefundCont());
|
||||
assertEquals(new BigDecimal("5409"), row.getOrderProfitPrice());
|
||||
assertEquals(1L, row.getOrderProfitCont());
|
||||
verify(wallets, times(1)).departmentStatistics(any());
|
||||
verify(aggregates, times(1)).paymentStatistics(any(), eq(7L));
|
||||
verifyNoMoreInteractions(wallets, aggregates);
|
||||
assertEquals(7L, row.getDeptId()); assertEquals("部门七", row.getDeptName());
|
||||
assertEquals(new BigDecimal("1001"), row.getWalletPrice()); assertEquals(2L, row.getWalletCount());
|
||||
assertEquals(101L, row.getWalletReducePrice()); assertEquals(3L, row.getWalletReduceCount());
|
||||
assertEquals(new BigDecimal("2002"), row.getWalletOncePrice()); assertEquals(4L, row.getWalletOnceCount());
|
||||
assertEquals(new BigDecimal("202"), row.getWalletOnceReducePrice()); assertEquals(5L, row.getWalletOnceReduceCount());
|
||||
assertEquals(new BigDecimal("303"), row.getWalletReturnCanteenPrice()); assertEquals(6L, row.getWalletReturnCanteenCount());
|
||||
assertEquals(new BigDecimal("5005"), row.getOrderPayPrice()); assertEquals(8L, row.getOrderPayCount());
|
||||
assertEquals(new BigDecimal("-404"), row.getOrderRefundPrice()); assertEquals(7L, row.getOrderRefundCont());
|
||||
assertEquals(new BigDecimal("5409"), row.getOrderProfitPrice()); assertEquals(1L, row.getOrderProfitCont());
|
||||
verify(ledger).departmentStatistics(any(), same(scope));
|
||||
verifyNoMoreInteractions(ledger);
|
||||
verifyZeroInteractions(orders, hardManagers, managers, merchants);
|
||||
}
|
||||
|
||||
@Test
|
||||
void departmentOrderDeletedAndZeroRowsStayVisibleAndRefundOnlyRowsHaveNegativeNet() {
|
||||
void upmsOrderDeletedAndZeroDepartmentsRemainAndMissingLedgerDepartmentsAreAppended() {
|
||||
var deleted = dept(42L, "历史部门(已被删除)"); deleted.setDelFlag("1");
|
||||
when(departments.getInnerAllDept(any())).thenReturn(R.ok(List.of(
|
||||
dept(42L, "历史部门(已被删除)"), dept(7L, "部门七"), dept(99L, "仅退款部门"), dept(88L, "零交易部门"))));
|
||||
WalletDepartmentStatisticsVo refundOnly = new WalletDepartmentStatisticsVo();
|
||||
refundOnly.setDeptId(99L); refundOnly.setRefundSum(77L); refundOnly.setRefundCount(2L);
|
||||
when(wallets.departmentStatistics(any())).thenReturn(R.ok(List.of(refundOnly, fullWallet())));
|
||||
when(aggregates.paymentStatistics(any(), eq(7L))).thenReturn(List.of(
|
||||
payment(999L, 123456L, 999L), payment(7L, 5005L, 8L)));
|
||||
deleted, dept(7L, "部门七"), dept(88L, "零交易部门"))));
|
||||
when(ledger.departmentStatistics(any(), any())).thenReturn(List.of(
|
||||
payment(999L, 123L, 1, 0L, 0), fullLedger(), payment(42L, 10L, 1, 0L, 0),
|
||||
payment(null, 0L, 0, 77L, 2)));
|
||||
|
||||
List<HardDeptTotalVo> rows = service.pageListDept(query());
|
||||
assertEquals(List.of(42L, 7L, 99L, 88L), rows.stream().map(HardDeptTotalVo::getDeptId).toList());
|
||||
|
||||
assertEquals(Arrays.asList(42L, 7L, 88L, 999L, null), rows.stream().map(HardDeptTotalVo::getDeptId).toList());
|
||||
assertEquals("历史部门(已被删除)", rows.get(0).getDeptName());
|
||||
assertZeroAmounts(rows.get(0));
|
||||
assertZeroAmounts(rows.get(3));
|
||||
HardDeptTotalVo refund = rows.get(2);
|
||||
assertEquals(BigDecimal.ZERO, refund.getOrderPayPrice());
|
||||
assertEquals(0L, refund.getOrderPayCount());
|
||||
assertEquals(new BigDecimal("77"), refund.getOrderRefundPrice());
|
||||
assertEquals(2L, refund.getOrderRefundCont());
|
||||
assertEquals(new BigDecimal("-77"), refund.getOrderProfitPrice());
|
||||
assertEquals(-2L, refund.getOrderProfitCont());
|
||||
assertEquals(new BigDecimal("10"), rows.get(0).getOrderPayPrice());
|
||||
assertZeroAmounts(rows.get(2));
|
||||
assertEquals("历史部门(999)", rows.get(3).getDeptName());
|
||||
assertEquals(new BigDecimal("123"), rows.get(3).getOrderPayPrice());
|
||||
HardDeptTotalVo unassigned = rows.get(4);
|
||||
assertEquals("未归属部门", unassigned.getDeptName());
|
||||
assertEquals(new BigDecimal("77"), unassigned.getOrderRefundPrice());
|
||||
assertEquals(new BigDecimal("-77"), unassigned.getOrderProfitPrice());
|
||||
assertEquals(-2L, unassigned.getOrderProfitCont());
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyDepartmentsAvoidBothWalletAndOrderQueries() {
|
||||
when(departments.getInnerAllDept(any())).thenReturn(R.ok(List.of()));
|
||||
assertTrue(service.pageListDept(query()).isEmpty());
|
||||
verifyZeroInteractions(wallets, aggregates, orders);
|
||||
}
|
||||
|
||||
@Test
|
||||
void successfulNullDepartmentDataIsAnEmptyScope() {
|
||||
when(departments.getInnerAllDept(any())).thenReturn(R.ok(null));
|
||||
assertTrue(service.pageListDept(query()).isEmpty());
|
||||
verifyZeroInteractions(wallets, aggregates, orders);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullOrFailedDepartmentRpcTerminatesBeforeStatistics() {
|
||||
when(departments.getInnerAllDept(any())).thenReturn(null, R.failed("部门服务失败"));
|
||||
void emptyOrSuccessfulNullDepartmentNamesStillReadTheLedger() {
|
||||
when(departments.getInnerAllDept(any())).thenReturn(R.ok(List.of()), R.ok(null));
|
||||
when(ledger.departmentStatistics(any(), any())).thenAnswer(call -> List.of(payment(99L, 123L, 2, 23L, 1)));
|
||||
for (int attempt = 0; attempt < 2; attempt++) {
|
||||
IllegalStateException failure = assertThrows(IllegalStateException.class, () -> service.pageListDept(query()));
|
||||
assertTrue(failure.getMessage().contains("部门信息失败"));
|
||||
var rows = service.pageListDept(query());
|
||||
assertEquals(1, rows.size());
|
||||
assertEquals("历史部门(99)", rows.get(0).getDeptName());
|
||||
assertEquals(new BigDecimal("100"), rows.get(0).getOrderProfitPrice());
|
||||
}
|
||||
verifyZeroInteractions(wallets, aggregates, orders);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullOrFailedWalletRpcTerminatesBeforePaymentAggregation() {
|
||||
when(wallets.departmentStatistics(any())).thenReturn(null, R.failed("钱包服务失败"));
|
||||
for (int attempt = 0; attempt < 2; attempt++) {
|
||||
IllegalStateException failure = assertThrows(IllegalStateException.class, () -> service.pageListDept(query()));
|
||||
assertTrue(failure.getMessage().contains("钱包部门交易统计失败"));
|
||||
}
|
||||
verify(wallets, times(2)).departmentStatistics(any());
|
||||
verifyNoMoreInteractions(wallets);
|
||||
verifyZeroInteractions(aggregates, orders);
|
||||
}
|
||||
|
||||
@Test
|
||||
void successfulNullAggregatesAndNullFieldsUseZeroAmounts() {
|
||||
when(wallets.departmentStatistics(any())).thenReturn(R.ok(null));
|
||||
when(aggregates.paymentStatistics(any(), eq(7L))).thenReturn(null);
|
||||
assertZeroAmounts(service.pageListDept(query()).get(0));
|
||||
WalletDepartmentStatisticsVo wallet = new WalletDepartmentStatisticsVo();
|
||||
wallet.setDeptId(7L);
|
||||
wallet.setBatchIncomeSum(null); wallet.setBatchIncomeCount(null);
|
||||
wallet.setBatchExpenseSum(null); wallet.setBatchExpenseCount(null);
|
||||
wallet.setOnceIncomeSum(null); wallet.setOnceIncomeCount(null);
|
||||
wallet.setOnceExpenseSum(null); wallet.setOnceExpenseCount(null);
|
||||
wallet.setReturnCanteenSum(null); wallet.setReturnCanteenCount(null);
|
||||
wallet.setRefundSum(null); wallet.setRefundCount(null);
|
||||
when(wallets.departmentStatistics(any())).thenReturn(R.ok(List.of(wallet)));
|
||||
when(aggregates.paymentStatistics(any(), eq(7L))).thenReturn(List.of(payment(7L, null, null)));
|
||||
assertZeroAmounts(service.pageListDept(query()).get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void databaseFailurePropagatesWithoutReportingCompleteMerge() {
|
||||
var reporter = mock(AsyncTaskProgressReporter.class);
|
||||
IllegalStateException databaseFailure = new IllegalStateException("database unavailable");
|
||||
when(aggregates.paymentStatistics(any(), eq(7L))).thenThrow(databaseFailure);
|
||||
assertSame(databaseFailure, assertThrows(IllegalStateException.class, () -> service.listDeptForExport(query(), reporter)));
|
||||
verify(reporter).advance("部门范围已确认");
|
||||
verify(reporter).advance("钱包六类流水汇总完成");
|
||||
verify(reporter, never()).advance("订单历史部门归集完成");
|
||||
verify(reporter, never()).advance("部门统计合并完成");
|
||||
}
|
||||
|
||||
@Test
|
||||
void filtersAreForwardedWithoutMutatingTheRequestOrInventingAdditionalScope() {
|
||||
HardDeptTotalDto query = query();
|
||||
query.setDeptId(123L);
|
||||
query.setDeptIds(new ArrayList<>(List.of(7L, 99L)));
|
||||
query.setIsInclude(null);
|
||||
query.setIsDeepDept(true);
|
||||
query.setWalletTypeId(11L);
|
||||
query.setWalletTypeIdList(new ArrayList<>(List.of(11L, 22L)));
|
||||
var before = json.valueToTree(query);
|
||||
service.pageListDept(query);
|
||||
var deptFilter = ArgumentCaptor.forClass(DeptAllListDto.class);
|
||||
var walletFilter = ArgumentCaptor.forClass(WalletTransactionSumAndCountDto.class);
|
||||
var paymentFilter = ArgumentCaptor.forClass(HardOrderSumAndCountDto.class);
|
||||
verify(departments).getInnerAllDept(deptFilter.capture());
|
||||
verify(wallets).departmentStatistics(walletFilter.capture());
|
||||
verify(aggregates).paymentStatistics(paymentFilter.capture(), eq(7L));
|
||||
assertNull(deptFilter.getValue().getDeptId(), "多部门条件优先于单部门条件");
|
||||
assertEquals(query.getDeptIds(), deptFilter.getValue().getDeptIds());
|
||||
assertNull(deptFilter.getValue().getIsInclude());
|
||||
assertEquals(query.getDeptIds(), walletFilter.getValue().getDeptIds());
|
||||
assertNull(walletFilter.getValue().getIsInclude());
|
||||
assertEquals(query.getWalletTypeId(), walletFilter.getValue().getWalletTypeId());
|
||||
assertEquals(query.getWalletTypeIdList(), walletFilter.getValue().getWalletTypeIdList());
|
||||
assertArrayEquals(query.getCreateTimeList(), walletFilter.getValue().getCreateTimeList());
|
||||
assertNull(walletFilter.getValue().getBizTypes(), "六类统计不能被单一业务类型截断");
|
||||
assertNull(walletFilter.getValue().getType());
|
||||
assertEquals(query.getDeptIds(), paymentFilter.getValue().getUserDeptIds());
|
||||
assertNull(paymentFilter.getValue().getIsInclude());
|
||||
assertEquals(query.getWalletTypeId(), paymentFilter.getValue().getWalletTypeId());
|
||||
assertEquals(query.getWalletTypeIdList(), paymentFilter.getValue().getWalletTypeIdList());
|
||||
assertArrayEquals(query.getCreateTimeList(), paymentFilter.getValue().getCreateTimeList());
|
||||
assertEquals(HardOrderStatusEnum.getPayPrice(), paymentFilter.getValue().getOrderStatusCodes());
|
||||
assertEquals(before, json.valueToTree(query));
|
||||
}
|
||||
|
||||
@Test
|
||||
void singleDepartmentAndFalseIncludeAreForwardedWithoutClearingInput() {
|
||||
HardDeptTotalDto query = query();
|
||||
query.setDeptId(42L);
|
||||
query.setIsInclude(false);
|
||||
service.pageListDept(query);
|
||||
var capture = ArgumentCaptor.forClass(DeptAllListDto.class);
|
||||
verify(departments).getInnerAllDept(capture.capture());
|
||||
assertEquals(42L, capture.getValue().getDeptId());
|
||||
assertEquals(false, capture.getValue().getIsInclude());
|
||||
assertEquals(42L, query.getDeptId());
|
||||
assertEquals(false, query.getIsInclude());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingTenantContextFailsBeforeAnyQuery() {
|
||||
TenantContextHolder.clear();
|
||||
IllegalStateException failure = assertThrows(IllegalStateException.class, () -> service.pageListDept(query()));
|
||||
assertTrue(failure.getMessage().contains("租户上下文"));
|
||||
verifyZeroInteractions(departments, wallets, aggregates, orders);
|
||||
}
|
||||
|
||||
@Test
|
||||
void pageAndExportUseTheSameAggregatesAndReportFourOfSixExportStages() {
|
||||
when(wallets.departmentStatistics(any())).thenReturn(R.ok(List.of(fullWallet())));
|
||||
when(aggregates.paymentStatistics(any(), eq(7L))).thenReturn(List.of(payment(7L, 5005L, 8L)));
|
||||
HardDeptTotalDto dto = query();
|
||||
List<HardDeptTotalVo> page = service.pageListDept(dto);
|
||||
AsyncTaskProgressReporter reporter = mock(AsyncTaskProgressReporter.class);
|
||||
List<HardDeptTotalVo> exported = service.listDeptForExport(dto, reporter);
|
||||
assertEquals(page, exported);
|
||||
var progress = inOrder(reporter);
|
||||
progress.verify(reporter).updateTotal(6, "正在汇总部门交易");
|
||||
progress.verify(reporter).advance("部门范围已确认");
|
||||
progress.verify(reporter).advance("钱包六类流水汇总完成");
|
||||
progress.verify(reporter).advance("订单历史部门归集完成");
|
||||
progress.verify(reporter).advance("部门统计合并完成");
|
||||
verifyNoMoreInteractions(reporter);
|
||||
verify(wallets, times(2)).departmentStatistics(any());
|
||||
verify(aggregates, times(2)).paymentStatistics(any(), eq(7L));
|
||||
verifyNoMoreInteractions(wallets, aggregates);
|
||||
verify(ledger, times(2)).departmentStatistics(any(), same(scope));
|
||||
verifyZeroInteractions(orders);
|
||||
}
|
||||
|
||||
@Test
|
||||
void controllerJsonRequestUsesRealServiceAndOneWalletAggregation() throws Exception {
|
||||
when(wallets.departmentStatistics(any())).thenReturn(R.ok(List.of(fullWallet())));
|
||||
when(aggregates.paymentStatistics(any(), eq(7L))).thenReturn(List.of(payment(7L, 5005L, 8L)));
|
||||
var controller = new HardOrderTotalController(mock(HardBatchExportService.class), mock(HardOrderService.class),
|
||||
void emptyLedgerPreservesZeroDepartments() {
|
||||
assertZeroAmounts(service.pageListDept(query()).get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedNameRpcStopsBeforeLedgerStatistics() {
|
||||
when(departments.getInnerAllDept(any())).thenReturn(null, R.failed("部门服务失败"));
|
||||
for (int attempt = 0; attempt < 2; attempt++) {
|
||||
var failure = assertThrows(IllegalStateException.class, () -> service.pageListDept(query()));
|
||||
assertTrue(failure.getMessage().contains("部门信息失败"));
|
||||
}
|
||||
verifyZeroInteractions(ledger, orders);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sqlFailurePropagatesWithoutReportingSuccessfulAggregationOrMerge() {
|
||||
var reporter = mock(AsyncTaskProgressReporter.class);
|
||||
var failure = new IllegalStateException("ledger database unavailable");
|
||||
when(ledger.departmentStatistics(any(), any())).thenThrow(failure);
|
||||
assertSame(failure, assertThrows(IllegalStateException.class, () -> service.listDeptForExport(query(), reporter)));
|
||||
verify(reporter).advance("部门范围已确认");
|
||||
verify(reporter, never()).advance("钱包账本聚合完成");
|
||||
verify(reporter, never()).advance("部门统计合并完成");
|
||||
verifyZeroInteractions(orders);
|
||||
}
|
||||
|
||||
@Test
|
||||
void preparedScopeAndQueryAreForwardedWithoutChangingTheSourceFilters() {
|
||||
HardDeptTotalDto source = query();
|
||||
source.setDeptId(123L); source.setDeptIds(new ArrayList<>(List.of(7L, 99L)));
|
||||
source.setIsInclude(null); source.setIsDeepDept(true); source.setWalletTypeId(11L);
|
||||
source.setWalletTypeIdList(new ArrayList<>(List.of(11L, 22L)));
|
||||
HardOrderDto normalized = new HardOrderDto();
|
||||
normalized.setPayTimeList(source.getCreateTimeList().clone());
|
||||
normalized.setUserDeptIds(List.of(7L, 99L)); normalized.setWalletTypeIdList(List.of(11L, 22L));
|
||||
doReturn(new HardWalletLedgerQuerySupport.QueryContext(normalized, scope)).when(support).department(same(source));
|
||||
var before = json.valueToTree(source);
|
||||
|
||||
service.pageListDept(source);
|
||||
|
||||
var names = ArgumentCaptor.forClass(DeptAllListDto.class);
|
||||
verify(departments).getInnerAllDept(names.capture());
|
||||
assertNull(names.getValue().getDeptId(), "多部门条件优先于单部门条件");
|
||||
assertEquals(source.getDeptIds(), names.getValue().getDeptIds());
|
||||
assertNull(names.getValue().getIsInclude());
|
||||
verify(support).department(same(source));
|
||||
verify(ledger).departmentStatistics(same(normalized), same(scope));
|
||||
assertEquals(before, json.valueToTree(source));
|
||||
verifyZeroInteractions(orders);
|
||||
}
|
||||
|
||||
@Test
|
||||
void singleDepartmentNamesAndLedgerRowsRespectTheRequestedDepartment() {
|
||||
HardDeptTotalDto source = query(); source.setDeptId(42L); source.setIsInclude(false);
|
||||
when(departments.getInnerAllDept(any())).thenReturn(R.ok(List.of(dept(42L, "部门四二"))));
|
||||
when(ledger.departmentStatistics(any(), any())).thenReturn(List.of(
|
||||
payment(42L, 10L, 1, 0L, 0), payment(99L, 5000L, 5, 0L, 0), payment(null, 0L, 0, 20L, 1)));
|
||||
var rows = service.pageListDept(source);
|
||||
assertEquals(List.of(42L), rows.stream().map(HardDeptTotalVo::getDeptId).toList());
|
||||
var names = ArgumentCaptor.forClass(DeptAllListDto.class);
|
||||
verify(departments).getInnerAllDept(names.capture());
|
||||
assertEquals(42L, names.getValue().getDeptId());
|
||||
assertEquals(false, names.getValue().getIsInclude());
|
||||
assertEquals(42L, source.getDeptId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void preparationFailureStopsBeforeMetadataAndDatabaseQueries() {
|
||||
var failure = new IllegalStateException("缺少租户上下文");
|
||||
doThrow(failure).when(support).department(any());
|
||||
assertSame(failure, assertThrows(IllegalStateException.class, () -> service.pageListDept(query())));
|
||||
verifyZeroInteractions(departments, ledger, orders);
|
||||
}
|
||||
|
||||
@Test
|
||||
void pageAndExportShareOneLedgerAggregateAndProgressNeverClaimsOrderAggregation() {
|
||||
when(ledger.departmentStatistics(any(), any())).thenAnswer(call -> List.of(fullLedger()));
|
||||
var dto = query();
|
||||
List<HardDeptTotalVo> page = service.pageListDept(dto);
|
||||
var reporter = mock(AsyncTaskProgressReporter.class);
|
||||
assertEquals(page, service.listDeptForExport(dto, reporter));
|
||||
var progress = inOrder(reporter);
|
||||
progress.verify(reporter).updateTotal(5, "正在汇总部门交易");
|
||||
progress.verify(reporter).advance("部门范围已确认");
|
||||
progress.verify(reporter).advance("钱包账本聚合完成");
|
||||
progress.verify(reporter).advance("部门统计合并完成");
|
||||
verifyNoMoreInteractions(reporter);
|
||||
verify(ledger, times(2)).departmentStatistics(any(), same(scope));
|
||||
verifyNoMoreInteractions(ledger);
|
||||
verifyZeroInteractions(orders, merchants, managers);
|
||||
}
|
||||
|
||||
@Test
|
||||
void controllerPreservesJsonFieldsAndUsesThePreparedLedgerQuery() throws Exception {
|
||||
when(ledger.departmentStatistics(any(), any())).thenReturn(List.of(fullLedger()));
|
||||
var controller = new HardOrderTotalController(mock(HardBatchExportService.class),
|
||||
mock(HardWalletLedgerDetailExportService.class), mock(HardOrderService.class),
|
||||
service, managers, orders, merchants, mock(RemoteUserService.class));
|
||||
var mvc = MockMvcBuilders.standaloneSetup(controller)
|
||||
.setMessageConverters(new MappingJackson2HttpMessageConverter(json)).build();
|
||||
var response = mvc.perform(post("/hard-total/page-list-dept").contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"deptIds":[7],"isInclude":true,"walletTypeId":11,
|
||||
{"deptIds":[7],"isInclude":false,"walletTypeId":11,
|
||||
"walletTypeIdList":[11,22],
|
||||
"createTimeList":["2026-10-20 00:00:00","2026-10-21 23:59:59"]}
|
||||
"createTimeList":["2026-09-01 00:00:00","2026-10-01 00:00:00"]}
|
||||
"""))
|
||||
.andExpect(status().isOk()).andReturn().getResponse();
|
||||
var result = json.readTree(response.getContentAsByteArray());
|
||||
assertEquals(0, result.get("code").asInt());
|
||||
var rows = result.get("data");
|
||||
assertEquals(1, rows.size());
|
||||
var rows = result.get("data"); assertEquals(1, rows.size());
|
||||
var row = rows.get(0);
|
||||
assertEquals(7L, row.get("deptId").asLong());
|
||||
assertEquals("部门七", row.get("deptName").asText());
|
||||
assertEquals(7L, row.get("deptId").asLong()); assertEquals("部门七", row.get("deptName").asText());
|
||||
assertEquals(101L, row.get("walletReducePrice").asLong());
|
||||
assertEquals(303L, row.get("walletReturnCanteenPrice").asLong());
|
||||
assertEquals(-404L, row.get("orderRefundPrice").asLong());
|
||||
assertEquals(5409L, row.get("orderProfitPrice").asLong());
|
||||
var walletFilter = ArgumentCaptor.forClass(WalletTransactionSumAndCountDto.class);
|
||||
verify(wallets).departmentStatistics(walletFilter.capture());
|
||||
assertEquals(List.of(7L), walletFilter.getValue().getDeptIds());
|
||||
assertEquals(true, walletFilter.getValue().getIsInclude());
|
||||
assertEquals(11L, walletFilter.getValue().getWalletTypeId());
|
||||
assertEquals(List.of(11L, 22L), walletFilter.getValue().getWalletTypeIdList());
|
||||
assertArrayEquals(query().getCreateTimeList(), walletFilter.getValue().getCreateTimeList());
|
||||
verifyNoMoreInteractions(wallets);
|
||||
var prepared = ArgumentCaptor.forClass(HardOrderDto.class);
|
||||
verify(ledger).departmentStatistics(prepared.capture(), same(scope));
|
||||
assertEquals(List.of(7L), prepared.getValue().getUserDeptIds());
|
||||
assertEquals(false, prepared.getValue().getIsInclude());
|
||||
assertEquals(11L, prepared.getValue().getWalletTypeId());
|
||||
assertEquals(List.of(11L, 22L), prepared.getValue().getWalletTypeIdList());
|
||||
assertArrayEquals(query().getCreateTimeList(), prepared.getValue().getPayTimeList());
|
||||
verifyZeroInteractions(orders);
|
||||
}
|
||||
|
||||
private HardDeptTotalDto query() {
|
||||
HardDeptTotalDto dto = new HardDeptTotalDto();
|
||||
dto.setCreateTimeList(new LocalDateTime[]{LocalDateTime.of(2026, 10, 20, 0, 0),
|
||||
LocalDateTime.of(2026, 10, 21, 23, 59, 59)});
|
||||
private static HardDeptTotalDto query() {
|
||||
var dto = new HardDeptTotalDto();
|
||||
dto.setCreateTimeList(new LocalDateTime[]{LocalDateTime.of(2026, 9, 1, 0, 0), LocalDateTime.of(2026, 10, 1, 0, 0)});
|
||||
return dto;
|
||||
}
|
||||
|
||||
private static SysDept dept(long id, String name) {
|
||||
SysDept row = new SysDept(); row.setDeptId(id); row.setName(name);
|
||||
var row = new SysDept(); row.setDeptId(id); row.setName(name); return row;
|
||||
}
|
||||
|
||||
private static HardDeptTotalVo payment(Long id, long paid, long payments, long refunded, long refunds) {
|
||||
var row = new HardDeptTotalVo(); row.setDeptId(id);
|
||||
row.setOrderPayPrice(BigDecimal.valueOf(paid)); row.setOrderPayCount(payments);
|
||||
row.setOrderRefundPrice(BigDecimal.valueOf(refunded)); row.setOrderRefundCont(refunds);
|
||||
return row;
|
||||
}
|
||||
|
||||
private static HardOrderSumAndCountVo payment(Long id, Long sum, Long count) {
|
||||
HardOrderSumAndCountVo row = new HardOrderSumAndCountVo();
|
||||
row.setDeptId(id); row.setSum(sum); row.setCount(count);
|
||||
return row;
|
||||
}
|
||||
|
||||
private static WalletDepartmentStatisticsVo fullWallet() {
|
||||
WalletDepartmentStatisticsVo row = new WalletDepartmentStatisticsVo();
|
||||
row.setDeptId(7L);
|
||||
row.setBatchIncomeSum(1001L); row.setBatchIncomeCount(2L);
|
||||
row.setBatchExpenseSum(-101L); row.setBatchExpenseCount(3L);
|
||||
row.setOnceIncomeSum(2002L); row.setOnceIncomeCount(4L);
|
||||
row.setOnceExpenseSum(-202L); row.setOnceExpenseCount(5L);
|
||||
row.setReturnCanteenSum(-303L); row.setReturnCanteenCount(6L);
|
||||
row.setRefundSum(-404L); row.setRefundCount(7L);
|
||||
private static HardDeptTotalVo fullLedger() {
|
||||
var row = payment(7L, 5005, 8, -404, 7);
|
||||
row.setWalletPrice(new BigDecimal("1001")); row.setWalletCount(2L);
|
||||
row.setWalletReducePrice(101L); row.setWalletReduceCount(3L);
|
||||
row.setWalletOncePrice(new BigDecimal("2002")); row.setWalletOnceCount(4L);
|
||||
row.setWalletOnceReducePrice(new BigDecimal("202")); row.setWalletOnceReduceCount(5L);
|
||||
row.setWalletReturnCanteenPrice(new BigDecimal("303")); row.setWalletReturnCanteenCount(6L);
|
||||
return row;
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -42,6 +42,7 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
* 执行真实部门聚合 XML 与 TenantLine 插件,以原逐笔订单查询、历史快照选择和 Java 归集为独立基线。
|
||||
* H2 只验证统计等价性与 SQL/插件兼容性,不将内存数据库耗时作为 MySQL/PolarDB 性能依据。
|
||||
*/
|
||||
// 仅验证历史订单查询;当前钱包账本验收见 HardWalletLedgerStatisticsSqlTest。
|
||||
class HardDepartmentStatisticsSqlTest {
|
||||
private static final LocalDateTime FROM = LocalDateTime.of(2026, 10, 20, 0, 0);
|
||||
private static final LocalDateTime TO = LocalDateTime.of(2026, 10, 21, 23, 59, 59);
|
||||
|
||||
+178
-78
@@ -1,76 +1,67 @@
|
||||
package com.bsd.pjcloud.hard.service.order;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.bsd.pjcloud.admin.api.entity.SysDept;
|
||||
import com.bsd.pjcloud.admin.api.feign.RemoteDeptService;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.common.security.service.PjcloudUser;
|
||||
import com.bsd.pjcloud.hard.api.dto.hard.CurrentUserHardTypeInfoDto;
|
||||
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.HardOrderCsVo;
|
||||
import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper;
|
||||
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
|
||||
import com.bsd.pjcloud.hard.mapper.order.HardWalletLedgerStatisticsMapper;
|
||||
import com.bsd.pjcloud.hard.mapper.order.model.HardOrderStatisticsScope;
|
||||
import com.bsd.pjcloud.hard.service.HardManagerService;
|
||||
import com.bsd.pjcloud.hard.service.impl.order.HardOrderTotalServiceImpl;
|
||||
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
|
||||
import com.bsd.pjcloud.wallet.api.feign.RemoteWalletTransactionService;
|
||||
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.LongStream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 服务只消费已授权的钱包聚合;订单 SQL 不再是新商户统计的验收来源。 */
|
||||
class HardOrderCsServiceTest {
|
||||
private final HardOrderMapper mapper = mock(HardOrderMapper.class);
|
||||
private final HardOrderMapper orders = mock(HardOrderMapper.class);
|
||||
private final HardManagerService managers = mock(HardManagerService.class);
|
||||
private final RemoteMchService merchants = mock(RemoteMchService.class);
|
||||
private final RemoteDeptService departments = mock(RemoteDeptService.class);
|
||||
private final HardOrderTotalServiceImpl service = new HardOrderTotalServiceImpl(mapper, mock(HardManagerMapper.class),
|
||||
managers, merchants, mock(RemoteWalletTransactionService.class), departments,
|
||||
mock(com.bsd.pjcloud.hard.mapper.order.HardDepartmentStatisticsMapper.class));
|
||||
private final HardWalletLedgerStatisticsMapper ledger = mock(HardWalletLedgerStatisticsMapper.class);
|
||||
private final HardWalletLedgerQuerySupport support = mock(HardWalletLedgerQuerySupport.class);
|
||||
private final HardOrderStatisticsScope scope = new HardOrderStatisticsScope(7L, false, List.of(10L), "alice");
|
||||
private final HardOrderTotalServiceImpl service = new HardOrderTotalServiceImpl(orders,
|
||||
mock(HardManagerMapper.class), managers, merchants, departments, ledger, support);
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
var user = new PjcloudUser(1L, "alice", 10L, null, null, null, null, null, 7L, "test",
|
||||
true, true, "0", true, true, List.of());
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user, "", user.getAuthorities()));
|
||||
var permission = new CurrentUserHardTypeInfoDto();
|
||||
permission.setCanteenView(true);
|
||||
when(managers.getCurrentUserHardTypeInfo(any())).thenReturn(permission);
|
||||
when(mapper.listCs(any(), any())).thenReturn(List.of());
|
||||
when(mapper.listRefundCs(any(), any())).thenReturn(List.of());
|
||||
when(support.merchant(any())).thenAnswer(call -> new HardWalletLedgerQuerySupport.QueryContext(call.getArgument(0), scope));
|
||||
when(ledger.merchantStatistics(any(), any())).thenReturn(List.of());
|
||||
when(merchants.getByIds(anyList())).thenReturn(R.ok(List.of()));
|
||||
when(departments.getDeptByDeptIds(anyList())).thenReturn(R.ok(Map.of()));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void clear() { SecurityContextHolder.clearContext(); }
|
||||
|
||||
@Test
|
||||
void mixedRowsPreserveOrderExactNetAmountsAndCommission() {
|
||||
when(mapper.listCs(any(), any())).thenReturn(List.of(row(2, 1001, 2), row(1, 201, 1)));
|
||||
when(mapper.listRefundCs(any(), any())).thenReturn(List.of(row(1, 101, 2), row(3, 99, 1)));
|
||||
void ledgerGroupsKeepOrderExactNetAmountsAndCommission() {
|
||||
when(ledger.merchantStatistics(any(), same(scope))).thenReturn(List.of(
|
||||
row(2L, 1001, 2, 0, 0), row(1L, 201, 1, 101, 2), row(3L, 0, 0, 99, 1)));
|
||||
var merchant = new MchManageVo();
|
||||
merchant.setId(1L);
|
||||
merchant.setName("商户一");
|
||||
merchant.setPlatformRanke(12.345);
|
||||
merchant.setId(1L); merchant.setName("商户一"); merchant.setPlatformRanke(12.345);
|
||||
when(merchants.getByIds(anyList())).thenReturn(R.ok(List.of(merchant)));
|
||||
var department = new SysDept();
|
||||
department.setDeptId(10L);
|
||||
department.setName("交易时商户部门");
|
||||
var department = new SysDept(); department.setDeptId(10L); department.setName("交易时商户部门");
|
||||
when(departments.getDeptByDeptIds(List.of(10L))).thenReturn(R.ok(Map.of(10L, department)));
|
||||
var result = service.listCs(HardOrderCsSqlTest.query());
|
||||
|
||||
var result = service.listCs(query());
|
||||
|
||||
assertEquals(List.of(2L, 1L, 3L), result.stream().map(HardOrderCsVo::getMerchantId).toList());
|
||||
var target = result.get(1);
|
||||
assertEquals(new BigDecimal("100"), target.getTotalProfitPrice());
|
||||
@@ -78,76 +69,185 @@ class HardOrderCsServiceTest {
|
||||
assertEquals(new BigDecimal("12.35"), target.getMchPlatformRankePrice());
|
||||
assertEquals("商户一", target.getMerchantName());
|
||||
assertEquals("交易时商户部门", target.getMerchantDeptName());
|
||||
assertNull(result.get(0).getTotalRefundPrice());
|
||||
assertEquals(BigDecimal.ZERO, result.get(0).getTotalRefundPrice());
|
||||
assertEquals(new BigDecimal("1001"), result.get(0).getTotalProfitPrice());
|
||||
assertEquals(new BigDecimal("-99"), result.get(2).getTotalProfitPrice());
|
||||
verifyZeroInteractions(orders, managers);
|
||||
}
|
||||
|
||||
@Test
|
||||
void intervalContainingOnlyRefundsStillReturnsNegativeNetTotals() {
|
||||
when(mapper.listRefundCs(any(), any())).thenReturn(List.of(row(1, 250, 2)));
|
||||
var result = service.listCs(HardOrderCsSqlTest.query());
|
||||
assertEquals(1, result.size());
|
||||
assertEquals(BigDecimal.ZERO, result.get(0).getTotalPayPrice());
|
||||
assertEquals(BigDecimal.ZERO, result.get(0).getTotalPayCount());
|
||||
assertEquals(new BigDecimal("250"), result.get(0).getTotalRefundPrice());
|
||||
assertEquals(new BigDecimal("-250"), result.get(0).getTotalProfitPrice());
|
||||
void unassignedAndMissingMerchantsStayVisibleAndSignedRefundIsNotChanged() {
|
||||
var unassigned = row(null, 0, 0, -25, 2); unassigned.setMerchantDeptId(null);
|
||||
when(ledger.merchantStatistics(any(), any())).thenReturn(List.of(unassigned, row(99L, 0, 0, 250, 2)));
|
||||
|
||||
var result = service.listCs(query());
|
||||
|
||||
assertEquals(Arrays.asList(null, 99L), result.stream().map(HardOrderCsVo::getMerchantId).toList());
|
||||
assertEquals("非订单/未归属", result.get(0).getMerchantName());
|
||||
assertEquals(new BigDecimal("-25"), result.get(0).getTotalRefundPrice());
|
||||
assertEquals(new BigDecimal("25"), result.get(0).getTotalProfitPrice());
|
||||
assertEquals(new BigDecimal("-2"), result.get(0).getTotalProfitCount());
|
||||
assertEquals("历史商户(99)", result.get(1).getMerchantName());
|
||||
assertEquals(new BigDecimal("-250"), result.get(1).getTotalProfitPrice());
|
||||
assertEquals(BigDecimal.ZERO, result.get(1).getMchPlatformRankePrice());
|
||||
verify(merchants).getByIds(List.of(99L));
|
||||
verify(departments).getDeptByDeptIds(List.of(10L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullableLedgerFieldsDefaultToZeroAndUnassignedOnlyAvoidsMetadataRpc() {
|
||||
var row = new HardOrderCsVo();
|
||||
when(ledger.merchantStatistics(any(), any())).thenReturn(List.of(row));
|
||||
var result = service.listCs(query()).get(0);
|
||||
assertEquals(BigDecimal.ZERO, result.getTotalPayPrice());
|
||||
assertEquals(BigDecimal.ZERO, result.getTotalPayCount());
|
||||
assertEquals(BigDecimal.ZERO, result.getTotalRefundPrice());
|
||||
assertEquals(BigDecimal.ZERO, result.getTotalRefundCount());
|
||||
assertEquals(BigDecimal.ZERO, result.getTotalProfitPrice());
|
||||
assertEquals(BigDecimal.ZERO, result.getTotalProfitCount());
|
||||
verifyZeroInteractions(merchants, departments);
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyResultsAvoidMetadataRemoteCalls() {
|
||||
assertTrue(service.listCs(HardOrderCsSqlTest.query()).isEmpty());
|
||||
verifyNoMoreInteractions(merchants, departments);
|
||||
assertTrue(service.listCs(query()).isEmpty());
|
||||
verifyZeroInteractions(merchants, departments);
|
||||
}
|
||||
|
||||
@Test
|
||||
void paymentAndRefundKeepSeparateDatesStatusesAndMerchantPermission() {
|
||||
var query = HardOrderCsSqlTest.query();
|
||||
var dates = query.getPayTimeList().clone();
|
||||
service.listCs(query);
|
||||
var payments = ArgumentCaptor.forClass(HardOrderDto.class);
|
||||
var refunds = ArgumentCaptor.forClass(HardOrderDto.class);
|
||||
verify(mapper).listCs(payments.capture(), any());
|
||||
verify(mapper).listRefundCs(refunds.capture(), any());
|
||||
assertArrayEquals(dates, payments.getValue().getPayTimeList());
|
||||
assertNull(refunds.getValue().getPayTimeList());
|
||||
assertArrayEquals(dates, refunds.getValue().getRefundTimeList());
|
||||
assertEquals(List.of("50"), refunds.getValue().getRefundStatusCodes());
|
||||
assertEquals(List.of("5", "20", "10"), payments.getValue().getOrderStatusCodes());
|
||||
assertEquals(payments.getValue().getOrderStatusCodes(), refunds.getValue().getOrderStatusCodes());
|
||||
assertEquals(List.of("1"), refunds.getValue().getMchTypeCodes());
|
||||
assertArrayEquals(dates, query.getPayTimeList());
|
||||
assertNull(query.getRefundTimeList());
|
||||
void preparedQueryAndScopeArePassedUnchangedToTheLedger() {
|
||||
var source = query();
|
||||
var normalized = query(); normalized.setMchTypeCodes(List.of("1"));
|
||||
normalized.setUserDeptIds(List.of(30L)); normalized.setIsInclude(false);
|
||||
when(support.merchant(same(source))).thenReturn(new HardWalletLedgerQuerySupport.QueryContext(normalized, scope));
|
||||
var originalRange = source.getPayTimeList().clone();
|
||||
|
||||
service.listCs(source);
|
||||
|
||||
verify(support).merchant(same(source));
|
||||
verify(ledger).merchantStatistics(same(normalized), same(scope));
|
||||
assertArrayEquals(originalRange, source.getPayTimeList());
|
||||
assertNull(source.getMchTypeCodes());
|
||||
assertNull(source.getRefundTimeList());
|
||||
verifyZeroInteractions(orders, managers);
|
||||
}
|
||||
|
||||
@Test
|
||||
void manyMerchantsUseBoundedBatchesAndOneDepartmentLookup() {
|
||||
List<HardOrderCsVo> rows = LongStream.rangeClosed(1, 401).mapToObj(id -> row(id, 100, 1)).toList();
|
||||
when(mapper.listCs(any(), any())).thenReturn(rows);
|
||||
when(mapper.listRefundCs(any(), any())).thenReturn(LongStream.rangeClosed(1, 401).mapToObj(id -> row(id, 30, 1)).toList());
|
||||
List<List<Long>> batches = new ArrayList<>();
|
||||
when(merchants.getByIds(anyList())).thenAnswer(call -> {
|
||||
List<Long> ids = call.getArgument(0);
|
||||
batches.add(ids);
|
||||
return R.ok(ids.stream().map(id -> { var merchant = new MchManageVo(); merchant.setId(id); merchant.setPlatformRanke(10D); return merchant; }).toList());
|
||||
void monthlyPaginationKeepsRefundOnlyAndUnassignedGroupsInTheMapperPage() {
|
||||
var query = query();
|
||||
Page<HardOrderCsVo> requested = new Page<>(2, 2);
|
||||
Page<HardOrderCsVo> page = new Page<>(2, 2, 5);
|
||||
var unassigned = row(null, 0, 0, 99, 2); unassigned.setMerchantDeptId(null); unassigned.setDay("2026-09");
|
||||
var paid = row(1L, 10, 2, 3, 1); paid.setDay("2026-10");
|
||||
page.setRecords(List.of(unassigned, paid));
|
||||
when(ledger.merchantStatisticsByMonth(same(requested), same(query), same(scope))).thenReturn(page);
|
||||
|
||||
var result = service.pageListCsByMonth(requested, query);
|
||||
|
||||
assertSame(page, result);
|
||||
assertEquals(5L, result.getTotal());
|
||||
assertEquals(2, result.getRecords().size());
|
||||
assertEquals("2026-09", result.getRecords().get(0).getDay());
|
||||
assertEquals(new BigDecimal("-99"), result.getRecords().get(0).getTotalProfitPrice());
|
||||
assertEquals(new BigDecimal("7"), result.getRecords().get(1).getTotalProfitPrice());
|
||||
verify(ledger).merchantStatisticsByMonth(same(requested), same(query), same(scope));
|
||||
verifyNoMoreInteractions(ledger);
|
||||
verifyZeroInteractions(orders);
|
||||
}
|
||||
|
||||
@Test
|
||||
void monthlyExportResolvesOneQueryAndScopeSnapshotForAllPages() {
|
||||
var source = query();
|
||||
var normalized = query(); normalized.setMchTypeCodes(List.of("1"));
|
||||
when(support.merchant(same(source))).thenReturn(new HardWalletLedgerQuerySupport.QueryContext(normalized, scope));
|
||||
List<Long> requestedPages = new ArrayList<>();
|
||||
when(ledger.merchantStatisticsByMonth(any(), same(normalized), same(scope))).thenAnswer(call -> {
|
||||
Page<HardOrderCsVo> page = call.getArgument(0);
|
||||
requestedPages.add(page.getCurrent());
|
||||
assertEquals(200L, page.getSize());
|
||||
return page.setRecords(List.of());
|
||||
});
|
||||
var result = service.listCs(HardOrderCsSqlTest.query());
|
||||
|
||||
var pages = service.monthlyExportPages(source);
|
||||
verifyZeroInteractions(ledger);
|
||||
assertEquals(1L, pages.apply(1L).getCurrent());
|
||||
assertEquals(2L, pages.apply(2L).getCurrent());
|
||||
|
||||
assertEquals(List.of(1L, 2L), requestedPages);
|
||||
verify(support, times(1)).merchant(same(source));
|
||||
verifyNoMoreInteractions(support);
|
||||
verify(ledger, times(2)).merchantStatisticsByMonth(any(), same(normalized), same(scope));
|
||||
verifyNoMoreInteractions(ledger);
|
||||
verifyZeroInteractions(orders, managers, merchants, departments);
|
||||
}
|
||||
|
||||
@Test
|
||||
void manyMetadataIdsUseBoundedBatchesWithoutPerRowRemoteCalls() {
|
||||
List<HardOrderCsVo> rows = LongStream.rangeClosed(1, 401).mapToObj(id -> {
|
||||
var row = row(id, 100, 1, 30, 1); row.setMerchantDeptId(1000 + id); return row;
|
||||
}).toList();
|
||||
when(ledger.merchantStatistics(any(), any())).thenReturn(rows);
|
||||
List<List<Long>> merchantBatches = new ArrayList<>(), departmentBatches = new ArrayList<>();
|
||||
when(merchants.getByIds(anyList())).thenAnswer(call -> {
|
||||
List<Long> ids = call.getArgument(0); merchantBatches.add(new ArrayList<>(ids));
|
||||
return R.ok(ids.stream().map(id -> {
|
||||
var merchant = new MchManageVo(); merchant.setId(id); merchant.setPlatformRanke(10D); return merchant;
|
||||
}).toList());
|
||||
});
|
||||
when(departments.getDeptByDeptIds(anyList())).thenAnswer(call -> {
|
||||
List<Long> ids = call.getArgument(0); departmentBatches.add(new ArrayList<>(ids));
|
||||
return R.ok(ids.stream().collect(Collectors.toMap(id -> id, id -> {
|
||||
var department = new SysDept(); department.setDeptId(id); department.setName("部门" + id); return department;
|
||||
})));
|
||||
});
|
||||
|
||||
var result = service.listCs(query());
|
||||
|
||||
assertEquals(401, result.size());
|
||||
assertEquals(List.of(200, 200, 1), batches.stream().map(List::size).toList());
|
||||
verify(departments, times(1)).getDeptByDeptIds(List.of(10L));
|
||||
assertEquals(List.of(200, 200, 1), merchantBatches.stream().map(List::size).toList());
|
||||
assertEquals(List.of(200, 200, 1), departmentBatches.stream().map(List::size).toList());
|
||||
for (var row : result) {
|
||||
assertEquals(new BigDecimal("70"), row.getTotalProfitPrice());
|
||||
assertEquals(new BigDecimal("7.00"), row.getMchPlatformRankePrice());
|
||||
assertEquals("部门" + row.getMerchantDeptId(), row.getMerchantDeptName());
|
||||
}
|
||||
}
|
||||
|
||||
private static HardOrderCsVo row(long id, int amount, int count) {
|
||||
var row = new HardOrderCsVo();
|
||||
row.setMerchantId(id);
|
||||
row.setMerchantDeptId(10L);
|
||||
row.setTotalPayPrice(BigDecimal.valueOf(amount));
|
||||
row.setTotalPayCount(BigDecimal.valueOf(count));
|
||||
@Test
|
||||
void databaseFailureStopsBeforeMetadataLookups() {
|
||||
var failure = new IllegalStateException("ledger database unavailable");
|
||||
when(ledger.merchantStatistics(any(), any())).thenThrow(failure);
|
||||
assertSame(failure, assertThrows(IllegalStateException.class, () -> service.listCs(query())));
|
||||
verifyZeroInteractions(merchants, departments, orders);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedMerchantAndDepartmentRpcDoNotReturnPartialReports() {
|
||||
when(ledger.merchantStatistics(any(), any())).thenAnswer(call -> List.of(row(1L, 100, 1, 0, 0)));
|
||||
when(merchants.getByIds(anyList())).thenReturn(null, R.failed("商户服务失败"));
|
||||
for (int attempt = 0; attempt < 2; attempt++) {
|
||||
var failure = assertThrows(IllegalStateException.class, () -> service.listCs(query()));
|
||||
assertTrue(failure.getMessage().contains("商户信息失败"));
|
||||
}
|
||||
verifyZeroInteractions(departments);
|
||||
when(merchants.getByIds(anyList())).thenReturn(R.ok(List.of()));
|
||||
when(departments.getDeptByDeptIds(anyList())).thenReturn(null, R.failed("部门服务失败"));
|
||||
for (int attempt = 0; attempt < 2; attempt++) {
|
||||
var failure = assertThrows(IllegalStateException.class, () -> service.listCs(query()));
|
||||
assertTrue(failure.getMessage().contains("部门信息失败"));
|
||||
}
|
||||
}
|
||||
|
||||
private static HardOrderDto query() {
|
||||
var query = new HardOrderDto();
|
||||
query.setPayTimeList(new LocalDateTime[]{LocalDateTime.of(2026, 9, 1, 0, 0), LocalDateTime.of(2026, 10, 1, 0, 0)});
|
||||
return query;
|
||||
}
|
||||
|
||||
private static HardOrderCsVo row(Long id, int payment, int paymentCount, int refund, int refundCount) {
|
||||
var row = new HardOrderCsVo(); row.setMerchantId(id); row.setMerchantDeptId(10L);
|
||||
row.setTotalPayPrice(BigDecimal.valueOf(payment)); row.setTotalPayCount(BigDecimal.valueOf(paymentCount));
|
||||
row.setTotalRefundPrice(BigDecimal.valueOf(refund)); row.setTotalRefundCount(BigDecimal.valueOf(refundCount));
|
||||
return row;
|
||||
}
|
||||
}
|
||||
+31
-14
@@ -13,18 +13,19 @@ import com.bsd.pjcloud.common.data.datascope.DataScope;
|
||||
import com.bsd.pjcloud.common.data.datascope.DataScopeInnerInterceptor;
|
||||
import com.bsd.pjcloud.common.security.service.PjcloudUser;
|
||||
import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
|
||||
import com.bsd.pjcloud.hard.api.dto.hard.CurrentUserHardTypeInfoDto;
|
||||
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.HardOrderCsVo;
|
||||
import com.bsd.pjcloud.hard.controller.order.HardOrderTotalController;
|
||||
import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper;
|
||||
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
|
||||
import com.bsd.pjcloud.hard.mapper.order.HardWalletLedgerStatisticsMapper;
|
||||
import com.bsd.pjcloud.hard.mapper.order.model.HardOrderStatisticsScope;
|
||||
import com.bsd.pjcloud.hard.service.HardManagerService;
|
||||
import com.bsd.pjcloud.hard.service.HardOrderService;
|
||||
import com.bsd.pjcloud.hard.service.impl.order.HardBatchExportService;
|
||||
import com.bsd.pjcloud.hard.service.impl.order.HardOrderTotalServiceImpl;
|
||||
import com.bsd.pjcloud.hard.service.impl.order.HardWalletLedgerDetailExportService;
|
||||
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
|
||||
import com.bsd.pjcloud.wallet.api.feign.RemoteWalletTransactionService;
|
||||
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.io.InputStream;
|
||||
@@ -60,12 +61,15 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.same;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyZeroInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/** 执行真实 XML 和租户、部门插件,与保存的优化前 SQL 对比金额、次数及筛选。 */
|
||||
/** 旧订单 SQL 保留基线回归;HTTP 验收单独核对新账本入口,不能以旧 SQL 结果验收新统计。 */
|
||||
class HardOrderCsSqlTest {
|
||||
static final String MAPPER = HardOrderMapper.class.getName() + ".";
|
||||
SqlSession session;
|
||||
@@ -263,14 +267,14 @@ class HardOrderCsSqlTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void httpRequestRunsControllerServiceAndRealSqlIncludingChildMerchantsAndRefundOnlyRows() throws Exception {
|
||||
void httpRequestRunsControllerAndLedgerServiceIncludingChildMerchantsAndRefundOnlyRows() throws Exception {
|
||||
var managers = mock(HardManagerService.class);
|
||||
var merchants = mock(RemoteMchService.class);
|
||||
var departments = mock(RemoteDeptService.class);
|
||||
var allTypes = new CurrentUserHardTypeInfoDto();
|
||||
allTypes.setCanteenView(true);
|
||||
allTypes.setSupermarket(true);
|
||||
when(managers.getCurrentUserHardTypeInfo(any())).thenReturn(allTypes);
|
||||
var ledger = mock(HardWalletLedgerStatisticsMapper.class);
|
||||
var support = mock(HardWalletLedgerQuerySupport.class);
|
||||
var ledgerScope = new HardOrderStatisticsScope(7L, false, List.of(10L, 11L), "alice");
|
||||
when(support.merchant(any())).thenAnswer(call -> new HardWalletLedgerQuerySupport.QueryContext(call.getArgument(0), ledgerScope));
|
||||
var parent = new MchManageVo();
|
||||
parent.setId(100L);
|
||||
parent.setName("父商户");
|
||||
@@ -282,13 +286,19 @@ class HardOrderCsSqlTest {
|
||||
when(merchants.getByParentId(100L)).thenReturn(R.ok(List.of(child)));
|
||||
when(merchants.getByIds(anyList())).thenReturn(R.ok(List.of(parent, child)));
|
||||
when(departments.getDeptByDeptIds(anyList())).thenReturn(R.ok(Map.of()));
|
||||
var mapper = session.getMapper(HardOrderMapper.class);
|
||||
var mapper = mock(HardOrderMapper.class);
|
||||
var payment = new HardOrderCsVo();
|
||||
payment.setMerchantId(100L); payment.setTotalPayPrice(new BigDecimal("1530"));
|
||||
payment.setTotalPayCount(new BigDecimal("5")); payment.setTotalRefundPrice(new BigDecimal("250"));
|
||||
payment.setTotalRefundCount(new BigDecimal("2"));
|
||||
var refundOnly = new HardOrderCsVo();
|
||||
refundOnly.setMerchantId(200L); refundOnly.setTotalRefundPrice(new BigDecimal("90"));
|
||||
refundOnly.setTotalRefundCount(BigDecimal.ONE);
|
||||
when(ledger.merchantStatistics(any(), same(ledgerScope))).thenReturn(List.of(payment, refundOnly));
|
||||
var totals = new HardOrderTotalServiceImpl(mapper,
|
||||
mock(HardManagerMapper.class), managers, merchants,
|
||||
mock(RemoteWalletTransactionService.class), departments,
|
||||
mock(com.bsd.pjcloud.hard.mapper.order.HardDepartmentStatisticsMapper.class));
|
||||
mock(HardManagerMapper.class), managers, merchants, departments, ledger, support);
|
||||
var controller = new HardOrderTotalController(
|
||||
mock(HardBatchExportService.class),
|
||||
mock(HardBatchExportService.class), mock(HardWalletLedgerDetailExportService.class),
|
||||
mock(HardOrderService.class), totals, managers, mapper, merchants,
|
||||
mock(RemoteUserService.class));
|
||||
var json = new ObjectMapper().registerModule(new PjcloudJavaTimeModule());
|
||||
@@ -301,7 +311,7 @@ class HardOrderCsSqlTest {
|
||||
try {
|
||||
var response = mvc.perform(post("/hard-total/page-list-cs")
|
||||
.contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"merchantId":100,"payTimeList":["2026-10-20 00:00:00","2026-10-21 23:59:59"]}
|
||||
{"merchantId":100,"payTimeList":["2026-09-01 00:00:00","2026-10-01 00:00:00"]}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse();
|
||||
@@ -315,6 +325,13 @@ class HardOrderCsSqlTest {
|
||||
assertEquals(200L, data.get(1).get("merchantId").asLong());
|
||||
assertEquals(-90, data.get(1).get("totalProfitPrice").asInt());
|
||||
assertEquals(-1, data.get(1).get("totalProfitCount").asInt());
|
||||
var prepared = org.mockito.ArgumentCaptor.forClass(HardOrderDto.class);
|
||||
verify(ledger).merchantStatistics(prepared.capture(), same(ledgerScope));
|
||||
assertNull(prepared.getValue().getMerchantId());
|
||||
assertEquals(List.of(100L, 200L), prepared.getValue().getMchIds());
|
||||
assertArrayEquals(new LocalDateTime[]{LocalDateTime.of(2026, 9, 1, 0, 0),
|
||||
LocalDateTime.of(2026, 10, 1, 0, 0)}, prepared.getValue().getPayTimeList());
|
||||
verifyZeroInteractions(mapper);
|
||||
} finally { SecurityContextHolder.clearContext(); }
|
||||
}
|
||||
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package com.bsd.pjcloud.hard.service.order;
|
||||
|
||||
import com.bsd.pjcloud.common.security.service.PjcloudUser;
|
||||
import com.bsd.pjcloud.hard.api.dto.hard.CurrentUserHardTypeInfoDto;
|
||||
import com.bsd.pjcloud.hard.api.dto.order.HardDeptTotalDto;
|
||||
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
|
||||
import com.bsd.pjcloud.hard.mapper.order.model.HardOrderStatisticsScope;
|
||||
import com.bsd.pjcloud.hard.service.HardManagerService;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class HardWalletLedgerQuerySupportTest {
|
||||
private final HardOrderStatisticsScopeResolver scopes = mock(HardOrderStatisticsScopeResolver.class);
|
||||
private final HardManagerService managers = mock(HardManagerService.class);
|
||||
private final HardWalletLedgerQuerySupport support = new HardWalletLedgerQuerySupport(scopes, managers);
|
||||
private final CurrentUserHardTypeInfoDto permission = new CurrentUserHardTypeInfoDto();
|
||||
|
||||
@BeforeEach void setup() {
|
||||
PjcloudUser user = new PjcloudUser(1L, "alice", 10L, null, null, null, null, null, 7L, "test",
|
||||
true, true, "0", true, true, List.of());
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user, "", user.getAuthorities()));
|
||||
when(scopes.resolve()).thenReturn(new HardOrderStatisticsScope(7L, true, List.of(), null));
|
||||
permission.setCanteenView(true);
|
||||
permission.setSupermarket(true);
|
||||
when(managers.getCurrentUserHardTypeInfo(any())).thenReturn(permission);
|
||||
}
|
||||
|
||||
@AfterEach void clear() { SecurityContextHolder.clearContext(); }
|
||||
|
||||
@Test void yearAndRangeIntersectWithoutMutatingRequestOrAddingAnInclusiveEnd() {
|
||||
HardOrderDto source = new HardOrderDto();
|
||||
LocalDateTime[] range = {LocalDateTime.parse("2025-12-01T00:00:00"), LocalDateTime.parse("2026-10-01T00:00:00")};
|
||||
source.setPayTimeList(range);
|
||||
source.setPayTimeYearStr("2026");
|
||||
var query = support.merchant(source).dto();
|
||||
assertArrayEquals(new LocalDateTime[]{LocalDateTime.parse("2026-01-01T00:00:00"), range[1]}, query.getPayTimeList());
|
||||
assertNotSame(range, query.getPayTimeList());
|
||||
assertNull(query.getPayTimeYearStr());
|
||||
assertEquals("2026", source.getPayTimeYearStr());
|
||||
assertSame(range, source.getPayTimeList());
|
||||
}
|
||||
|
||||
@Test void panelCreateTimeAndDepartmentCreateTimeBothMapToLedgerTime() {
|
||||
LocalDateTime[] range = {LocalDateTime.parse("2026-09-01T00:00:00"), LocalDateTime.parse("2026-10-01T00:00:00")};
|
||||
HardOrderDto panel = new HardOrderDto();
|
||||
panel.setCreateTimeList(range);
|
||||
var panelQuery = support.merchant(panel).dto();
|
||||
assertArrayEquals(range, panelQuery.getPayTimeList());
|
||||
assertNull(panelQuery.getCreateTimeList());
|
||||
HardDeptTotalDto department = new HardDeptTotalDto();
|
||||
department.setCreateTimeList(range);
|
||||
department.setDeptIds(List.of(42L));
|
||||
department.setIsInclude(false);
|
||||
department.setWalletTypeId(9L);
|
||||
department.setWalletTypeIdList(List.of(9L, 10L));
|
||||
var departmentQuery = support.department(department).dto();
|
||||
assertArrayEquals(range, departmentQuery.getPayTimeList());
|
||||
assertEquals(List.of(42L), departmentQuery.getUserDeptIds());
|
||||
assertFalse(departmentQuery.getIsInclude());
|
||||
assertEquals(9L, departmentQuery.getWalletTypeId());
|
||||
assertEquals(List.of(9L, 10L), departmentQuery.getWalletTypeIdList());
|
||||
assertNull(departmentQuery.getDeptIds());
|
||||
}
|
||||
|
||||
@Test void yearOnlyBecomesAdjacentJanuaryBoundariesAndDisjointRangeIsEmpty() {
|
||||
HardOrderDto query = new HardOrderDto(); query.setPayTimeYearStr("2026");
|
||||
assertArrayEquals(new LocalDateTime[]{LocalDateTime.parse("2026-01-01T00:00:00"), LocalDateTime.parse("2027-01-01T00:00:00")},
|
||||
support.merchant(query).dto().getPayTimeList());
|
||||
query.setPayTimeList(new LocalDateTime[]{LocalDateTime.parse("2027-09-01T00:00:00"), LocalDateTime.parse("2027-10-01T00:00:00")});
|
||||
var range = support.merchant(query).dto().getPayTimeList();
|
||||
assertEquals(range[0], range[1]);
|
||||
}
|
||||
|
||||
@Test void requestedTypesCannotBypassServerPermissionAndEmptyIntersectionDeniesConsumption() {
|
||||
permission.setSupermarket(false);
|
||||
HardOrderDto query = new HardOrderDto();
|
||||
query.setMchTypeCodes(List.of("1", "3"));
|
||||
assertEquals(List.of("1"), support.merchant(query).dto().getMchTypeCodes());
|
||||
assertEquals(List.of("1", "3"), query.getMchTypeCodes());
|
||||
query.setMchTypeCodes(List.of("3"));
|
||||
var denied = support.merchant(query);
|
||||
assertFalse(denied.scope().all());
|
||||
assertTrue(denied.scope().deptIds().isEmpty());
|
||||
assertNull(denied.scope().username());
|
||||
permission.setCanteenView(false);
|
||||
assertFalse(support.department(new HardDeptTotalDto()).scope().all());
|
||||
}
|
||||
|
||||
@Test void malformedTimeAndFailedPermissionDoNotBecomeUnrestrictedQueries() {
|
||||
HardOrderDto query = new HardOrderDto();
|
||||
query.setPayTimeList(new LocalDateTime[]{LocalDateTime.now()});
|
||||
assertThrows(IllegalArgumentException.class, () -> support.merchant(query));
|
||||
query.setPayTimeList(null); query.setPayTimeYearStr("bad");
|
||||
assertThrows(IllegalArgumentException.class, () -> support.merchant(query));
|
||||
query.setPayTimeYearStr(null);
|
||||
when(managers.getCurrentUserHardTypeInfo(any())).thenReturn(null);
|
||||
assertThrows(IllegalStateException.class, () -> support.merchant(query));
|
||||
}
|
||||
}
|
||||
+417
@@ -0,0 +1,417 @@
|
||||
package com.bsd.pjcloud.hard.service.order;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.DbType;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.bsd.pjcloud.common.data.datascope.DataScopeInnerInterceptor;
|
||||
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.HardDeptTotalVo;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.HardOrderCsVo;
|
||||
import com.bsd.pjcloud.hard.mapper.order.HardWalletLedgerStatisticsMapper;
|
||||
import com.bsd.pjcloud.hard.mapper.order.HardWalletLedgerDetailMapper;
|
||||
import com.bsd.pjcloud.hard.mapper.order.model.HardOrderStatisticsScope;
|
||||
import net.sf.jsqlparser.expression.Expression;
|
||||
import net.sf.jsqlparser.expression.LongValue;
|
||||
import org.apache.ibatis.builder.xml.XMLMapperBuilder;
|
||||
import org.apache.ibatis.datasource.unpooled.UnpooledDataSource;
|
||||
import org.apache.ibatis.mapping.Environment;
|
||||
import org.apache.ibatis.session.LocalCacheScope;
|
||||
import org.apache.ibatis.session.SqlSession;
|
||||
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
|
||||
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.Connection;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** 真实报表 XML、租户和分页插件;基准直接汇总原始钱包流水,而不是旧订单统计。 */
|
||||
public class HardWalletLedgerStatisticsSqlTest {
|
||||
private static final LocalDateTime FROM = LocalDateTime.of(2026, 9, 1, 0, 0);
|
||||
private static final LocalDateTime TO = LocalDateTime.of(2026, 10, 1, 0, 0);
|
||||
private static final String RESOURCE = "mapper/order/HardWalletLedgerStatisticsMapper.xml";
|
||||
private final HardOrderStatisticsScope all = new HardOrderStatisticsScope(7L, true, List.of(), null);
|
||||
private SqlSession session;
|
||||
private Connection connection;
|
||||
private HardWalletLedgerStatisticsMapper mapper;
|
||||
|
||||
private record Totals(long pay, long payCount, long refund, long refundCount) {
|
||||
Totals plus(Totals other) { return new Totals(pay + other.pay, payCount + other.payCount, refund + other.refund, refundCount + other.refundCount); }
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setup() throws Exception {
|
||||
var source = new UnpooledDataSource("org.h2.Driver",
|
||||
"jdbc:h2:mem:wallet_ledger_" + UUID.randomUUID() + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE", "sa", "");
|
||||
var configuration = new MybatisConfiguration();
|
||||
configuration.setEnvironment(new Environment("ledger", new JdbcTransactionFactory(), source));
|
||||
configuration.setMapUnderscoreToCamelCase(true);
|
||||
configuration.setLocalCacheScope(LocalCacheScope.STATEMENT);
|
||||
var interceptors = new MybatisPlusInterceptor();
|
||||
interceptors.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() {
|
||||
@Override public Expression getTenantId() { return new LongValue(7L); }
|
||||
// 与生产相同,带库名前缀表依靠 SQL 中显式 tenant_id;元数据订单仍走插件。
|
||||
@Override public boolean ignoreTable(String table) { return table.contains("."); }
|
||||
}));
|
||||
var dataScope = new DataScopeInnerInterceptor();
|
||||
dataScope.setDataScopeHandle(scope -> { throw new AssertionError("内部权限快照不能触发聚合后 DataScope 包装"); });
|
||||
interceptors.addInnerInterceptor(dataScope);
|
||||
interceptors.addInnerInterceptor(new PaginationInnerInterceptor(DbType.H2));
|
||||
configuration.addInterceptor(interceptors);
|
||||
for (String resource : List.of(RESOURCE,"mapper/order/HardWalletLedgerDetailMapper.xml")) {
|
||||
try (InputStream input = getClass().getClassLoader().getResourceAsStream(resource)) {
|
||||
assertNotNull(input);
|
||||
// H2 没有 MySQL 的 unsigned/binary 默认长度语义,只适配方言;实际测试仍执行同一个 XML。
|
||||
String xml = new String(input.readAllBytes(), StandardCharsets.UTF_8)
|
||||
.replaceAll("(?i)as\\s+unsigned\\s*\\)", "AS DECIMAL(20,0))")
|
||||
.replaceAll("(?i)as\\s+binary\\s*\\)", "AS VARBINARY)");
|
||||
new XMLMapperBuilder(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)),
|
||||
configuration, resource, configuration.getSqlFragments()).parse();
|
||||
}
|
||||
}
|
||||
session = new SqlSessionFactoryBuilder().build(configuration).openSession(true);
|
||||
connection = session.getConnection();
|
||||
mapper = session.getMapper(HardWalletLedgerStatisticsMapper.class);
|
||||
execute("create alias date_format for 'com.bsd.pjcloud.hard.service.order.HardWalletLedgerStatisticsSqlTest.dateFormat'");
|
||||
execute("""
|
||||
create schema pjcloudx_wallet;
|
||||
create table hard_order (
|
||||
id bigint primary key, tenant_id bigint, wallet_id bigint, merchant_id bigint,
|
||||
merchant_type varchar(10), merchant_dept_id bigint, create_by varchar(64), del_flag varchar(1),
|
||||
order_status varchar(10), pay_price bigint, user_dept_id bigint, create_time timestamp,
|
||||
pay_time timestamp, order_no varchar(64), hard_id bigint, dept_id bigint, order_type varchar(10),
|
||||
hour_type varchar(10), is_offline_order varchar(1), pay_type varchar(10));
|
||||
create table hard_after_sale (
|
||||
id bigint primary key, tenant_id bigint, order_id bigint, del_flag varchar(1),
|
||||
status varchar(10), audit_time timestamp, refund_price bigint);
|
||||
create table hard_manager (id bigint primary key, tenant_id bigint, type varchar(10));
|
||||
create table pjcloudx_wallet.wallet_manage (
|
||||
id bigint primary key, tenant_id bigint, wallet_type_id bigint, dept_id bigint, del_flag varchar(1));
|
||||
create table pjcloudx_wallet.wallet_transaction (
|
||||
id bigint primary key, tenant_id bigint, wallet_id bigint, user_id bigint, dept_id bigint,
|
||||
biz_type varchar(12), biz_id varchar(64), price bigint, create_time timestamp, del_flag varchar(1));
|
||||
insert into pjcloudx_wallet.wallet_manage values
|
||||
(101,7,11,999,'0'),(102,7,22,999,'0'),(103,7,11,999,'1'),(104,8,11,999,'0');
|
||||
insert into hard_order (id,tenant_id,wallet_id,merchant_id,merchant_type,merchant_dept_id,create_by,
|
||||
del_flag,order_status,pay_price,user_dept_id,create_time,pay_time,order_no,hard_id,dept_id) values
|
||||
(11,7,101,100,'1',10,'alice','0','5',999999,999,'2026-01-01','2026-11-01','O11',501,70),
|
||||
(12,7,101,200,'2',20,'bob','1','1',888888,999,'2026-01-01','2026-11-01','O12',502,70),
|
||||
(13,7,102,100,'1',10,'alice','0','5',777777,999,'2026-01-01','2026-11-01','O13',501,70),
|
||||
(14,8,104,800,'1',80,'eve','0','5',666666,999,'2026-01-01','2026-11-01','O14',501,70),
|
||||
(15,7,101,null,'1',10,'alice','0','5',555555,999,'2026-01-01','2026-11-01','O15',501,70),
|
||||
(16,7,999,400,'1',40,'dave','0','5',444444,999,'2026-01-01','2026-11-01','O16',501,70),
|
||||
(17,7,103,500,'1',50,'frank','0','5',333333,999,'2026-01-01','2026-11-01','O17',501,70),
|
||||
(9007199254740993,7,101,300,'3',30,'carol','0','5',222222,999,'2026-01-01','2026-11-01','OL',501,70),
|
||||
(9007199254740992,7,101,301,'3',30,'carol','0','5',111111,999,'2026-01-01','2026-11-01','ON',501,70);
|
||||
insert into hard_after_sale values
|
||||
(201,7,11,'1','10','2026-12-01',123456),(202,7,12,'0','50','2026-12-01',123456),
|
||||
(203,8,11,'0','50','2026-12-01',123456),(204,7,13,'0','50','2026-12-01',123456),
|
||||
(205,7,9007199254740993,'0','50','2026-12-01',123456);
|
||||
insert into hard_manager values (501,7,'1'),(502,7,'2');
|
||||
""");
|
||||
transaction(1,101,10,"4","11",-100L,FROM);
|
||||
transaction(2,101,20,"4","11",-50L,FROM.plusDays(2));
|
||||
transaction(3,101,20,"4","11",20L,FROM.plusDays(2));
|
||||
transaction(4,101,20,"4","11",0L,FROM.plusDays(2));
|
||||
transaction(5,101,10,"5","201",30L,FROM.plusDays(2));
|
||||
transaction(6,101,20,"5","201",10L,FROM.plusDays(2));
|
||||
transaction(7,101,20,"5","recharge-refund-1",-15L,FROM.plusDays(2));
|
||||
for (int i = 0; i < 4; i++) transaction(8+i,101,30,"4",List.of("orphan","011","11 ","11.0").get(i),-7L-i,FROM.plusDays(2));
|
||||
transaction(12,101,30,"4","9007199254740993",-21L,FROM.plusDays(2));
|
||||
transaction(13,101,30,"5","205",2L,FROM.plusDays(2));
|
||||
transaction(14,101,20,"4","12",-40L,FROM.plusDays(2));
|
||||
transaction(15,101,20,"5","202",5L,FROM.plusDays(2));
|
||||
transaction(16,101,null,"4","11",-6L,FROM.plusDays(2));
|
||||
transaction(17,999,40,"4","16",-11L,FROM.plusDays(2));
|
||||
transaction(18,103,50,"4","17",-12L,FROM.plusDays(2));
|
||||
transaction(19,101,30,"4","15",-13L,FROM.plusDays(2));
|
||||
transaction(20,101,30,"4","13",-14L,FROM.plusDays(2));
|
||||
transaction(21,101,30,"5","204",3L,FROM.plusDays(2));
|
||||
transaction(22,101,30,"5","203",4L,FROM.plusDays(2));
|
||||
transaction(23,104,80,"4","14",-888L,FROM.plusDays(2));
|
||||
execute("update pjcloudx_wallet.wallet_transaction set tenant_id=8 where id=23");
|
||||
transaction(24,101,10,"4","11",-999L,FROM.plusDays(2));
|
||||
execute("update pjcloudx_wallet.wallet_transaction set del_flag='1' where id=24");
|
||||
transaction(25,101,10,"4","11",-1000L,FROM.minusSeconds(1));
|
||||
transaction(26,101,10,"5","201",1000L,TO);
|
||||
transaction(27,101,10,"4","11",-2000L,TO.plusSeconds(1));
|
||||
transaction(28,101,10,"2","batch",1001L,FROM.plusDays(2));
|
||||
transaction(29,101,10,"2","batch",-101L,FROM.plusDays(2));
|
||||
transaction(30,101,10,"1","update",2002L,FROM.plusDays(2));
|
||||
transaction(31,101,10,"3","recharge",-202L,FROM.plusDays(2));
|
||||
transaction(32,101,10,"8","return",303L,FROM.plusDays(2));
|
||||
transaction(33,101,10,"8","return",-103L,FROM.plusDays(2));
|
||||
transaction(34,101,10,"8","return",0L,FROM.plusDays(2));
|
||||
transaction(35,101,10,"2","batch",0L,FROM.plusDays(2));
|
||||
transaction(36,101,10,"6","transfer",-999L,FROM.plusDays(2));
|
||||
transaction(37,101,10,"7","transfer-refund",999L,FROM.plusDays(2));
|
||||
transaction(38,104,30,"4","11",-17L,FROM.plusDays(2));
|
||||
transaction(39,101,10,"5","201",null,FROM.plusDays(2));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void close() { if (session != null) session.close(); }
|
||||
|
||||
public static String dateFormat(Timestamp value, String format) {
|
||||
if (value == null) return null;
|
||||
if (!"%Y-%m".equals(format)) throw new IllegalArgumentException(format);
|
||||
return value.toLocalDateTime().format(DateTimeFormatter.ofPattern("yyyy-MM"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void bothDimensionsReconcileToRawLedgerIncludingZeroCorrectionsAndUnassigned() throws Exception {
|
||||
var query = query();
|
||||
var merchantRows = mapper.merchantStatistics(query, all);
|
||||
var departments = mapper.departmentStatistics(query, all);
|
||||
assertEquals(rawTotals(""), merchantTotals(merchantRows));
|
||||
assertEquals(rawTotals(""), departmentTotals(departments));
|
||||
assertTrue(merchantRows.stream().anyMatch(row -> row.getMerchantId() == null));
|
||||
assertTrue(departments.stream().anyMatch(row -> row.getDeptId() == null));
|
||||
var merchant = merchant(merchantRows, 100L);
|
||||
assertEquals(new Totals(136,5,40,3), merchantTotals(List.of(merchant)));
|
||||
assertEquals(new BigDecimal("96"), merchant.getTotalProfitPrice());
|
||||
assertEquals(new BigDecimal("2"), merchant.getTotalProfitCount());
|
||||
assertEquals(-8L, merchantRows.stream().filter(row -> row.getMerchantId() == null).findFirst().orElseThrow().getTotalRefundPrice().longValueExact());
|
||||
}
|
||||
|
||||
@Test
|
||||
void historicalLedgerDoesNotDependOnCurrentOrderAmountsStatusesDatesOrDepartments() throws Exception {
|
||||
var before = merchantTotals(mapper.merchantStatistics(query(), all));
|
||||
var departments = departmentTotals(mapper.departmentStatistics(query(), all));
|
||||
execute("update hard_order set pay_price=0, order_status='30', user_dept_id=10, create_time='2030-01-01', pay_time='2030-01-01'");
|
||||
execute("update hard_after_sale set refund_price=0, status='1', audit_time='2030-01-01'");
|
||||
execute("update pjcloudx_wallet.wallet_manage set dept_id=888");
|
||||
assertEquals(before, merchantTotals(mapper.merchantStatistics(query(), all)));
|
||||
assertEquals(departments, departmentTotals(mapper.departmentStatistics(query(), all)));
|
||||
assertEquals(new Totals(40,1,5,1), merchantTotals(List.of(merchant(mapper.merchantStatistics(query(), all),200L))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ledgerDepartmentIncludeExcludeAndNullDepartmentAreConsistentAcrossBothDimensions() throws Exception {
|
||||
var query = query(); query.setUserDeptIds(List.of(20L)); query.setIsInclude(false);
|
||||
var expected = rawTotals("and (dept_id is null or dept_id not in (20))");
|
||||
assertEquals(expected, merchantTotals(mapper.merchantStatistics(query, all)));
|
||||
assertEquals(expected, departmentTotals(mapper.departmentStatistics(query, all)));
|
||||
query.setIsInclude(true);
|
||||
expected = rawTotals("and dept_id in (20)");
|
||||
assertEquals(expected, merchantTotals(mapper.merchantStatistics(query, all)));
|
||||
assertEquals(expected, departmentTotals(mapper.departmentStatistics(query, all)));
|
||||
assertEquals(new Totals(70,4,0,3), expected);
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyActualTransactionCountsAndExactEndIsExcludedFromAdjacentSlices() throws Exception {
|
||||
Totals sum = new Totals(0,0,0,0);
|
||||
for (LocalDateTime from = FROM; from.isBefore(TO); from = from.plusDays(1)) {
|
||||
var slice = query(); slice.setPayTimeList(new LocalDateTime[]{from,from.plusDays(1)});
|
||||
sum = sum.plus(merchantTotals(mapper.merchantStatistics(slice, all)));
|
||||
}
|
||||
assertEquals(rawTotals(""), sum);
|
||||
var next = query(); next.setPayTimeList(new LocalDateTime[]{TO,TO.plusSeconds(1)});
|
||||
assertEquals(new Totals(0,0,1000,1), merchantTotals(mapper.merchantStatistics(next,all)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void exactLargeBusinessIdsAndSameWalletTenantPreventFalseAttribution() {
|
||||
var rows = mapper.merchantStatistics(query(),all);
|
||||
assertEquals(new Totals(21,1,2,1), merchantTotals(List.of(merchant(rows,300L))));
|
||||
assertFalse(rows.stream().anyMatch(row -> Long.valueOf(301L).equals(row.getMerchantId())));
|
||||
assertFalse(rows.stream().anyMatch(row -> Long.valueOf(800L).equals(row.getMerchantId())));
|
||||
assertEquals(new Totals(136,5,40,3), merchantTotals(List.of(merchant(rows,100L))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void walletTypesUseCurrentSameTenantWalletsAndPreserveDeletedWalletHistory() throws Exception {
|
||||
var query = query(); query.setWalletTypeIdList(List.of(11L));
|
||||
var expected = rawTotals("and wallet_id in (101,103)");
|
||||
assertEquals(expected, merchantTotals(mapper.merchantStatistics(query,all)));
|
||||
assertEquals(expected, departmentTotals(mapper.departmentStatistics(query,all)));
|
||||
assertEquals(new Totals(12,1,0,0), merchantTotals(List.of(merchant(mapper.merchantStatistics(query,all),500L))));
|
||||
query.setWalletTypeId(22L);
|
||||
assertTrue(mapper.merchantStatistics(query,all).isEmpty(), "标量与列表取交集");
|
||||
execute("update pjcloudx_wallet.wallet_manage set wallet_type_id=22 where id=101");
|
||||
query.setWalletTypeId(null);
|
||||
assertEquals(new Totals(12,1,0,0),merchantTotals(mapper.merchantStatistics(query,all)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void restrictedMerchantAndSelfScopesApplyBeforeAggregationAndHideUnassigned() {
|
||||
var dept = new HardOrderStatisticsScope(7L,false,List.of(10L),null);
|
||||
var rows = mapper.merchantStatistics(query(),dept);
|
||||
assertEquals(List.of(100L),rows.stream().map(HardOrderCsVo::getMerchantId).toList());
|
||||
assertEquals(new Totals(136,5,40,3),merchantTotals(rows));
|
||||
assertEquals(merchantTotals(rows),departmentTotals(mapper.departmentStatistics(query(),dept)));
|
||||
var self = new HardOrderStatisticsScope(7L,false,List.of(),"alice");
|
||||
assertEquals(merchantTotals(rows),merchantTotals(mapper.merchantStatistics(query(),self)));
|
||||
var combined = new HardOrderStatisticsScope(7L,false,List.of(10L),"bob");
|
||||
assertEquals(new Totals(176,6,45,4),merchantTotals(mapper.merchantStatistics(query(),combined)));
|
||||
var denied = new HardOrderStatisticsScope(7L,false,List.of(),null);
|
||||
assertTrue(mapper.merchantStatistics(query(),denied).isEmpty());
|
||||
assertEquals(new Totals(0,0,0,0),departmentTotals(mapper.departmentStatistics(query(),denied)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void departmentRechargeCategoriesRetainTheirExistingScopeAndSignedRules() {
|
||||
var query = query(); query.setMchTypeCodes(List.of("2"));
|
||||
var denied = new HardOrderStatisticsScope(7L,false,List.of(),null);
|
||||
var row = mapper.departmentStatistics(query,denied).stream().filter(item -> Long.valueOf(10).equals(item.getDeptId())).findFirst().orElseThrow();
|
||||
assertEquals(new BigDecimal("1001"),row.getWalletPrice()); assertEquals(1,row.getWalletCount());
|
||||
assertEquals(101,row.getWalletReducePrice()); assertEquals(1,row.getWalletReduceCount());
|
||||
assertEquals(new BigDecimal("2002"),row.getWalletOncePrice()); assertEquals(1,row.getWalletOnceCount());
|
||||
assertEquals(new BigDecimal("202"),row.getWalletOnceReducePrice()); assertEquals(1,row.getWalletOnceReduceCount());
|
||||
assertEquals(new BigDecimal("200"),row.getWalletReturnCanteenPrice()); assertEquals(3,row.getWalletReturnCanteenCount());
|
||||
assertEquals(BigDecimal.ZERO,row.getOrderPayPrice()); assertEquals(0,row.getOrderRefundCont());
|
||||
}
|
||||
|
||||
@Test
|
||||
void explicitMerchantDimensionsExcludeUnassignedAndBothReportsStillReconcile() {
|
||||
var query = query(); query.setMchTypeCodes(List.of("1"));
|
||||
var rows = mapper.merchantStatistics(query,all);
|
||||
assertFalse(rows.stream().anyMatch(row -> row.getMerchantId() == null));
|
||||
assertEquals(new Totals(159,7,40,3),merchantTotals(rows));
|
||||
assertEquals(merchantTotals(rows),departmentTotals(mapper.departmentStatistics(query,all)));
|
||||
query.setMchTypeCodes(null); query.setHardTypeCodes(List.of("2"));
|
||||
assertEquals(new Totals(40,1,5,1),merchantTotals(mapper.merchantStatistics(query,all)));
|
||||
query.setHardTypeCodes(null); query.setMerchantId(200L);
|
||||
assertEquals(new Totals(40,1,5,1),merchantTotals(mapper.merchantStatistics(query,all)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void monthlyPaginationIncludesRefundOnlyMonthsAndMatchesUnpaginatedTotals() throws Exception {
|
||||
transaction(41,101,10,"5","201",8L,TO.plusDays(1));
|
||||
transaction(42,101,30,"5","recharge-refund-2",-9L,TO.plusDays(2));
|
||||
var query = query(); query.setPayTimeList(new LocalDateTime[]{FROM,TO.plusMonths(1)});
|
||||
Page<HardOrderCsVo> first = mapper.merchantStatisticsByMonth(new Page<>(1,1),query,all);
|
||||
long total = first.getTotal();
|
||||
assertTrue(total > 1); assertEquals(1,first.getRecords().size());
|
||||
Totals paged = new Totals(0,0,0,0); boolean refundOnly = false;
|
||||
for (long current = 1; current <= total; current++) {
|
||||
var page = mapper.merchantStatisticsByMonth(new Page<>(current,1),query,all);
|
||||
assertEquals(total,page.getTotal()); assertEquals(1,page.getRecords().size());
|
||||
var row = page.getRecords().get(0);
|
||||
if ("2026-10".equals(row.getDay()) && row.getTotalPayCount().signum() == 0) refundOnly = true;
|
||||
paged = paged.plus(merchantTotals(page.getRecords()));
|
||||
}
|
||||
assertTrue(refundOnly);
|
||||
assertEquals(merchantTotals(mapper.merchantStatistics(query,all)),paged);
|
||||
assertEquals(paged,departmentTotals(mapper.departmentStatistics(query,all)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailKeysetBatchesAndCountReconcileToExactlyTheSameAuthorizedLedger() {
|
||||
var details = session.getMapper(HardWalletLedgerDetailMapper.class);
|
||||
var query = query(); query.setMerchantId(100L);
|
||||
var restricted = new HardOrderStatisticsScope(7L,false,List.of(10L),null);
|
||||
var ids = new ArrayList<Long>();
|
||||
long pay = 0, paid = 0, refund = 0, refunded = 0;
|
||||
Long before = null;
|
||||
while (true) {
|
||||
var batch = details.listBatch(query,restricted,before,2);
|
||||
if (batch.isEmpty()) break;
|
||||
for (var row : batch) {
|
||||
assertEquals(100L,row.getMerchantId());
|
||||
assertEquals(11L,row.getOrderId());
|
||||
assertTrue(row.getCreateTime().compareTo(FROM)>=0 && row.getCreateTime().compareTo(TO)<0);
|
||||
if (before != null) assertTrue(row.getTransactionId()<before);
|
||||
ids.add(row.getTransactionId());
|
||||
long price = row.getPrice() == null ? 0 : row.getPrice();
|
||||
if ("4".equals(row.getBizType())) { pay -= price; paid++; }
|
||||
else { refund += price; refunded++; }
|
||||
}
|
||||
before = batch.get(batch.size()-1).getTransactionId();
|
||||
}
|
||||
assertEquals(ids.size(),new HashSet<>(ids).size());
|
||||
assertEquals(details.count(query,restricted),ids.size());
|
||||
assertEquals(new Totals(pay,paid,refund,refunded),merchantTotals(mapper.merchantStatistics(query,restricted)));
|
||||
query.setUserDeptIds(List.of(20L)); query.setIsInclude(true);
|
||||
assertEquals(4,details.count(query,restricted));
|
||||
query.setWalletTypeId(22L);
|
||||
assertEquals(0,details.count(query,restricted));
|
||||
assertTrue(details.listBatch(query,restricted,null,2).isEmpty());
|
||||
query.setWalletTypeId(null);
|
||||
var denied = new HardOrderStatisticsScope(7L,false,List.of(20L),null);
|
||||
assertEquals(0,details.count(query,denied));
|
||||
}
|
||||
|
||||
@Test
|
||||
void amountsAboveIntegerRangeRemainExactAcrossBothDimensions() throws Exception {
|
||||
execute("update pjcloudx_wallet.wallet_transaction set price=-2147483647 where id in (1,2)");
|
||||
var expected = rawTotals("");
|
||||
assertTrue(expected.pay()>Integer.MAX_VALUE);
|
||||
assertEquals(expected,merchantTotals(mapper.merchantStatistics(query(),all)));
|
||||
assertEquals(expected,departmentTotals(mapper.departmentStatistics(query(),all)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unmodifiedMySqlSqlIsAcceptedByTheProductionSqlParser() throws Exception {
|
||||
var configuration = new MybatisConfiguration();
|
||||
try (InputStream input = getClass().getClassLoader().getResourceAsStream(RESOURCE)) {
|
||||
assertNotNull(input);
|
||||
new XMLMapperBuilder(input,configuration,RESOURCE,configuration.getSqlFragments()).parse();
|
||||
}
|
||||
for (String method : List.of("merchantStatistics","merchantStatisticsByMonth","departmentStatistics")) {
|
||||
var statement = configuration.getMappedStatement(HardWalletLedgerStatisticsMapper.class.getName()+"."+method);
|
||||
String sql = statement.getBoundSql(Map.of("dto",query(),"scope",all)).getSql();
|
||||
assertNotNull(net.sf.jsqlparser.parser.CCJSqlParserUtil.parse(sql));
|
||||
}
|
||||
}
|
||||
|
||||
private HardOrderDto query() { var dto = new HardOrderDto(); dto.setPayTimeList(new LocalDateTime[]{FROM,TO}); return dto; }
|
||||
private static HardOrderCsVo merchant(List<HardOrderCsVo> rows, Long id) {
|
||||
return rows.stream().filter(row -> id.equals(row.getMerchantId())).findFirst().orElseThrow();
|
||||
}
|
||||
private static Totals merchantTotals(List<HardOrderCsVo> rows) {
|
||||
Totals result = new Totals(0,0,0,0);
|
||||
for (var row : rows) result = result.plus(new Totals(row.getTotalPayPrice().longValueExact(),row.getTotalPayCount().longValueExact(),
|
||||
row.getTotalRefundPrice().longValueExact(),row.getTotalRefundCount().longValueExact()));
|
||||
return result;
|
||||
}
|
||||
private static Totals departmentTotals(List<HardDeptTotalVo> rows) {
|
||||
Totals result = new Totals(0,0,0,0);
|
||||
for (var row : rows) {
|
||||
assertEquals(row.getOrderPayPrice().subtract(row.getOrderRefundPrice()),row.getOrderProfitPrice());
|
||||
assertEquals(row.getOrderPayCount()-row.getOrderRefundCont(),row.getOrderProfitCont());
|
||||
result = result.plus(new Totals(row.getOrderPayPrice().longValueExact(),row.getOrderPayCount(),
|
||||
row.getOrderRefundPrice().longValueExact(),row.getOrderRefundCont()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
private Totals rawTotals(String additional) throws Exception {
|
||||
try (var statement = connection.prepareStatement("""
|
||||
select coalesce(-sum(case when biz_type='4' then price else 0 end),0),
|
||||
sum(case when biz_type='4' then 1 else 0 end),
|
||||
coalesce(sum(case when biz_type='5' then price else 0 end),0),
|
||||
sum(case when biz_type='5' then 1 else 0 end)
|
||||
from pjcloudx_wallet.wallet_transaction
|
||||
where tenant_id=7 and del_flag='0' and biz_type in ('4','5') and create_time>=? and create_time<?
|
||||
""" + additional)) {
|
||||
statement.setObject(1,FROM); statement.setObject(2,TO);
|
||||
try (var rows = statement.executeQuery()) { assertTrue(rows.next()); return new Totals(rows.getLong(1),rows.getLong(2),rows.getLong(3),rows.getLong(4)); }
|
||||
}
|
||||
}
|
||||
private void transaction(long id, long wallet, Integer department, String type, String business, Long price, LocalDateTime time) throws Exception {
|
||||
try (var statement = connection.prepareStatement("insert into pjcloudx_wallet.wallet_transaction values (?,7,?,1,?,?,?,?,?,'0')")) {
|
||||
statement.setLong(1,id); statement.setLong(2,wallet); statement.setObject(3,department);
|
||||
statement.setString(4,type); statement.setString(5,business); statement.setObject(6,price); statement.setObject(7,time); statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
private void execute(String sql) throws Exception { try (var statement = connection.createStatement()) { statement.execute(sql); } }
|
||||
}
|
||||
+12
-12
@@ -242,7 +242,7 @@ public class ThirdPayApiServiceImpl implements ThirdPayApiService {
|
||||
long startTime = System.currentTimeMillis();
|
||||
ThirdPayLogEntity logObj = ThirdPayLogEntity.builder().apiUrl("/third/third-pay-api/water").bizType(ThirdPayApiBizTypeEnum.WATER.getType()).req(json).header(JSONUtil.toJsonStr(headers)).paySourceType(getPaySourceType(headers)).build();
|
||||
// 开始处理
|
||||
PayBaseVo baseVo = new PayBaseVo<PayWalletInfoVo>();
|
||||
PayBaseVo<List<PayWaterVo>> baseVo = new PayBaseVo<>();
|
||||
ThirdKeyManageEntity keySetting = keyManageService.checkSignAndReturnConfig(json, headers);
|
||||
if (ObjectUtil.isEmpty(keySetting)) {
|
||||
baseVo.set(ThirdPayVoStatusEnum.SIGN_ERROR);
|
||||
@@ -259,18 +259,18 @@ public class ThirdPayApiServiceImpl implements ThirdPayApiService {
|
||||
if (dto.getSize() > 200L) dto.setSize(200L);
|
||||
try {
|
||||
PayBaseVo<List<PayWaterVo>> pageList = hardThirdPayService.waterList(dto, ThirdPaySoureBizTypeEnum.valueOfByType(keySetting.getBizType()));
|
||||
if (ObjectUtil.isEmpty(pageList.getData())) {
|
||||
baseVo.set(ThirdPayVoStatusEnum.UNKNOWN_ERROR);
|
||||
baseVo.setData(pageList.getData());
|
||||
// 日志
|
||||
logObj.setRes(JSONUtil.toJsonStr(baseVo));
|
||||
logObj.setTimeConsuming(Long.toString(System.currentTimeMillis() - startTime));
|
||||
payLogService.save(logObj);
|
||||
return baseVo;
|
||||
if (ObjectUtil.isEmpty(pageList)) {
|
||||
throw new RuntimeException("流水查询失败");
|
||||
}
|
||||
// 加密一下手机号
|
||||
for (PayWaterVo item : pageList.getData()) {
|
||||
item.setSignPhone(LbRetailSignUtils.sm4Encrypt(item.getSignPhone(), keySetting.getFieldKey()));
|
||||
if (ObjectUtil.equal(pageList.getCode(), ThirdPayVoStatusEnum.SUCCESS.getType())) {
|
||||
// 查询成功但没有流水时,统一返回空数组。
|
||||
if (ObjectUtil.isEmpty(pageList.getData())) {
|
||||
pageList.setData(List.of());
|
||||
}
|
||||
// 加密一下手机号
|
||||
for (PayWaterVo item : pageList.getData()) {
|
||||
item.setSignPhone(LbRetailSignUtils.sm4Encrypt(item.getSignPhone(), keySetting.getFieldKey()));
|
||||
}
|
||||
}
|
||||
// 日志
|
||||
logObj.setRes(JSONUtil.toJsonStr(pageList));
|
||||
|
||||
+62
@@ -1,5 +1,6 @@
|
||||
package com.bsd.pjcloud.thirdparty.service.impl.pay;
|
||||
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import com.bsd.pjcloud.hard.api.feign.RemoteHardThirdPayService;
|
||||
import com.bsd.pjcloud.thirdparty.constant.ThirdKeyTypeEnum;
|
||||
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPaySoureBizTypeEnum;
|
||||
@@ -13,6 +14,7 @@ import com.bsd.pjcloud.thirdparty.vo.pay.PayWaterVo;
|
||||
import org.apache.commons.codec.digest.DigestUtils;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import org.junit.jupiter.params.provider.EmptySource;
|
||||
import org.junit.jupiter.params.provider.NullSource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
@@ -69,6 +71,66 @@ class ThirdPayWaterSourceTest {
|
||||
verify(fixture.logs).save(any(ThirdPayLogEntity.class));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@NullSource
|
||||
@EmptySource
|
||||
void successfulQueryWithoutOrdersReturnsAnEmptyArray(List<PayWaterVo> data) {
|
||||
Fixture fixture = new Fixture(ThirdPaySoureBizTypeEnum.LB);
|
||||
PayBaseVo<List<PayWaterVo>> response = new PayBaseVo<>();
|
||||
response.setCode(200);
|
||||
response.setMessage("成功");
|
||||
response.setData(data);
|
||||
when(fixture.hardware.waterList(any(), eq(ThirdPaySoureBizTypeEnum.LB))).thenReturn(response);
|
||||
Map<String, String> headers = fixture.headers();
|
||||
headers.put("BIZ-TYPE", "10");
|
||||
|
||||
PayBaseVo<List<PayWaterVo>> result = fixture.service.water(JSON, headers);
|
||||
|
||||
assertEquals(200, result.getCode());
|
||||
assertNotNull(result.getData());
|
||||
assertTrue(result.getData().isEmpty());
|
||||
ArgumentCaptor<ThirdPayLogEntity> log = ArgumentCaptor.forClass(ThirdPayLogEntity.class);
|
||||
verify(fixture.logs).save(log.capture());
|
||||
assertEquals(JSONUtil.toJsonStr(result), log.getValue().getRes());
|
||||
assertTrue(JSONUtil.parseObj(log.getValue().getRes()).getJSONArray("data").isEmpty());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(ints = {500, 503, 599})
|
||||
void downstreamBusinessErrorsKeepTheirOriginalCode(int code) {
|
||||
Fixture fixture = new Fixture(ThirdPaySoureBizTypeEnum.LB);
|
||||
PayBaseVo<List<PayWaterVo>> response = new PayBaseVo<>();
|
||||
response.setCode(code);
|
||||
response.setMessage("业务错误");
|
||||
when(fixture.hardware.waterList(any(), eq(ThirdPaySoureBizTypeEnum.LB))).thenReturn(response);
|
||||
Map<String, String> headers = fixture.headers();
|
||||
headers.put("BIZ-TYPE", "10");
|
||||
|
||||
PayBaseVo<List<PayWaterVo>> result = fixture.service.water(JSON, headers);
|
||||
|
||||
assertSame(response, result);
|
||||
assertEquals(code, result.getCode());
|
||||
assertEquals("业务错误", result.getMessage());
|
||||
verify(fixture.logs).save(any(ThirdPayLogEntity.class));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void failedRemoteCallsStillReturnAnError(boolean throwsException) {
|
||||
Fixture fixture = new Fixture(ThirdPaySoureBizTypeEnum.LB);
|
||||
if (throwsException) {
|
||||
when(fixture.hardware.waterList(any(), eq(ThirdPaySoureBizTypeEnum.LB)))
|
||||
.thenThrow(new IllegalStateException("模拟调用失败"));
|
||||
} else {
|
||||
when(fixture.hardware.waterList(any(), eq(ThirdPaySoureBizTypeEnum.LB))).thenReturn(null);
|
||||
}
|
||||
Map<String, String> headers = fixture.headers();
|
||||
headers.put("BIZ-TYPE", "10");
|
||||
|
||||
assertEquals(599, fixture.service.water(JSON, headers).getCode());
|
||||
verify(fixture.logs).save(any(ThirdPayLogEntity.class));
|
||||
}
|
||||
|
||||
private static class Fixture {
|
||||
final ThirdKeyManageEntity config = new ThirdKeyManageEntity();
|
||||
final RemoteHardThirdPayService hardware = mock(RemoteHardThirdPayService.class);
|
||||
|
||||
Reference in new issue
Block a user