feat:流水错误修改

This commit is contained in:
dzc committed 2026-10-22 19:22:16 +08:00
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 应配套发布以保持时间标签和实际语义一致。本次尚未发布上线。
@@ -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;
}
@@ -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 = "商户交易记录导出")
@@ -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,
@@ -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);
}
@@ -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);
}
@@ -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;
}
@@ -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());
}
@@ -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;
}
}
@@ -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();
}
}
@@ -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,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,
@@ -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 &lt; #{beforeId}</if>
order by ledger.id desc
limit #{size}
</select>
</mapper>
@@ -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 &gt;= #{dto.payTimeList[0]}
and ledger.create_time &lt; #{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 &gt; 0 then ledger.price else 0 end), 0) as walletPrice,
sum(case when ledger.biz_type = '2' and ledger.price &gt; 0 then 1 else 0 end) as walletCount,
coalesce(-sum(case when ledger.biz_type = '2' and ledger.price &lt; 0 then ledger.price else 0 end), 0) as walletReducePrice,
sum(case when ledger.biz_type = '2' and ledger.price &lt; 0 then 1 else 0 end) as walletReduceCount,
coalesce(sum(case when ledger.biz_type in ('1', '3') and ledger.price &gt; 0 then ledger.price else 0 end), 0) as walletOncePrice,
sum(case when ledger.biz_type in ('1', '3') and ledger.price &gt; 0 then 1 else 0 end) as walletOnceCount,
coalesce(-sum(case when ledger.biz_type in ('1', '3') and ledger.price &lt; 0 then ledger.price else 0 end), 0) as walletOnceReducePrice,
sum(case when ledger.biz_type in ('1', '3') and ledger.price &lt; 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 &lt;&gt; 0 or ledger.biz_type in ('4', '5', '8'))
group by ledger.dept_id
order by ledger.dept_id
</select>
</mapper>
@@ -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 {
@@ -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;
}
}
@@ -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;
}
}
@@ -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";
@@ -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";
@@ -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;
}
@@ -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);
@@ -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;
}
}
@@ -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(); }
}
@@ -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));
}
}
@@ -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); } }
}
@@ -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));
@@ -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);