diff --git a/db/20261022_hard_dept_statistics_verification.md b/db/20261022_hard_dept_statistics_verification.md index 9971f966c..e4ace508b 100644 --- a/db/20261022_hard_dept_statistics_verification.md +++ b/db/20261022_hard_dept_statistics_verification.md @@ -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 测试库,未连接或修改生产数据库。 diff --git a/db/20261022_wallet_ledger_reconciliation.sql b/db/20261022_wallet_ledger_reconciliation.sql new file mode 100644 index 000000000..5a93bdd2a --- /dev/null +++ b/db/20261022_wallet_ledger_reconciliation.sql @@ -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; diff --git a/db/20261022_wallet_ledger_statistics_verification.md b/db/20261022_wallet_ledger_statistics_verification.md new file mode 100644 index 000000000..ef42ad72d --- /dev/null +++ b/db/20261022_wallet_ledger_statistics_verification.md @@ -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 应配套发布以保持时间标签和实际语义一致。本次尚未发布上线。 diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardWalletLedgerDetailExcelVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardWalletLedgerDetailExcelVo.java new file mode 100644 index 000000000..c8f5730fc --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardWalletLedgerDetailExcelVo.java @@ -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; +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderTotalController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderTotalController.java index c8fa063de..5558c3463 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderTotalController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderTotalController.java @@ -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 mchDtoIds = new ArrayList<>(); - if (ObjectUtil.isNotEmpty(dto.getMerchantId())) { - List mchList = mchService.getByParentId(dto.getMerchantId()).getData(); - if (ObjectUtil.isNotEmpty(mchList)) { - List 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 map = new HashMap(); - 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 page = hardOrderService.scopePageList(new Page(currentPage, pageSize), dto, DataScope.of()); - List records = page.getRecords(); - if (ObjectUtil.isEmpty(records)) break; - List 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 = "商户交易记录导出") diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardDepartmentStatisticsMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardDepartmentStatisticsMapper.java index b20cec17f..0d7eaf2b0 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardDepartmentStatisticsMapper.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardDepartmentStatisticsMapper.java @@ -7,7 +7,10 @@ import org.apache.ibatis.annotations.Param; import java.util.List; -/** 部门报表在数据库还原支付历史部门,只将部门级聚合结果返回应用。 */ +/** + * 旧订单统计口径,仅保留历史性能对照;当前部门页面和导出使用 HardWalletLedgerStatisticsMapper。 + * 此查询按订单创建时间、订单金额和每单一次统计,不代表钱包账本口径。 + */ @Mapper public interface HardDepartmentStatisticsMapper { List paymentStatistics(@Param("dto") HardOrderSumAndCountDto dto, diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardWalletLedgerDetailMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardWalletLedgerDetailMapper.java new file mode 100644 index 000000000..4aa1341da --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardWalletLedgerDetailMapper.java @@ -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 listBatch(@Param("dto") HardOrderDto dto, @Param("scope") HardOrderStatisticsScope scope, + @Param("beforeId") Long beforeId, @Param("size") int size); +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardWalletLedgerStatisticsMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardWalletLedgerStatisticsMapper.java new file mode 100644 index 000000000..3dce90850 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardWalletLedgerStatisticsMapper.java @@ -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 merchantStatistics(@Param("dto") HardOrderDto dto, + @Param("scope") HardOrderStatisticsScope scope); + + Page merchantStatisticsByMonth(Page page, + @Param("dto") HardOrderDto dto, + @Param("scope") HardOrderStatisticsScope scope); + + List departmentStatistics(@Param("dto") HardOrderDto dto, + @Param("scope") HardOrderStatisticsScope scope); +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/model/HardWalletLedgerDetail.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/model/HardWalletLedgerDetail.java new file mode 100644 index 000000000..96d3899e0 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/model/HardWalletLedgerDetail.java @@ -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; +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardBatchExportService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardBatchExportService.java index 87525e8de..91ccf6f8b 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardBatchExportService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardBatchExportService.java @@ -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 monthly(HardOrderDto query, AsyncTaskProgressReporter reporter, - BatchExcelExportTemplate.FileConsumer consumer) throws Exception { + BatchExcelExportTemplate.FileConsumer consumer) throws Exception { + return writeMerchantMonths(totals, query, reporter, consumer); + } + + /** 只保留每个商户的十二个月聚合值,明细始终在数据库聚合,金额累计全程使用分。 */ + static TResult writeMerchantMonths(HardOrderTotalServiceImpl totals, HardOrderDto query, + AsyncTaskProgressReporter reporter, + BatchExcelExportTemplate.FileConsumer 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 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 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 byMerchant = new LinkedHashMap<>(); + Map names = new HashMap<>(); + BigDecimal[] monthSums = sums(24); + Function> monthPages = totals.monthlyExportPages(query); + for (long current = 1; ; current++) { + Page 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 values = new HashMap<>(); - values.put("title", year + "年" + merchantName + "合作商家流水情况统计表"); - List 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>> fetcher = current -> { - int start = Math.toIntExact((current - 1) * BatchExportSupport.BATCH_SIZE); - int end = Math.min(start + BatchExportSupport.BATCH_SIZE, ids.size()); - List> rows = new ArrayList<>(); - for (int from = start; from < end; from += 5) { - List batch = ids.subList(from, Math.min(from + 5, end)); - HardOrderDto slice = BeanUtil.toBean(query, HardOrderDto.class); - slice.setMchIds(new ArrayList<>(batch)); - Page page = totals.pageListCsByMonth(new Page<>(1, 200), slice); - R> result = mchService.getByIds(batch); - if (result == null || !result.isOk()) throw new AsyncTaskException("查询商户名称失败,请重试"); - Map names = new HashMap<>(); - if (result.getData() != null) result.getData().forEach(item -> names.put(item.getId(), item.getName())); - for (Long id : batch) { - Map row = new LinkedHashMap<>(); - row.put("col0", names.getOrDefault(id, "未知商户")); - List> 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> rows = new ArrayList<>(); + byMerchant.forEach((id, columns) -> { + Map 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> 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 Function> pages(List rows) { return current -> { @@ -269,7 +280,8 @@ public class HardBatchExportService { } /** 沿用旧 exportTk 的字段、数值类型及合计规则。 */ - private Map merchantRow(HardOrderCsVo v, Map values, BigDecimal[] sums) { + private Map merchantRow(HardOrderCsVo source, Map values, BigDecimal[] sums) { + HardOrderCsVo v = BeanUtil.toBean(source, HardOrderCsVo.class); if (ObjectUtil.isNotEmpty(v.getHardType())) { v.setHardType(HardTypeEnum.valueOfByType(v.getHardType()).getDescription()); } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderTotalServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderTotalServiceImpl.java index dabea1caa..88150e2fd 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderTotalServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderTotalServiceImpl.java @@ -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 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 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 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 refundList = exporting ? queryCsExportSlices(refundDto, true, refundDataScope, reporter) : hardOrderMapper.listRefundCs(refundDto, refundDataScope); - timing.stop(); - timing.start("merge"); - // 保留支付查询的商户顺序,退款用 Map 线性合并;整个区间只有退款时也必须显示。 - Map 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 mchIds = totalList.stream().map(HardOrderCsVo::getMerchantId).distinct().toList(); - if (ObjectUtil.isNotEmpty(mchIds)) { - List mchList = getMchListByIds(mchIds); - if (ObjectUtil.isNotEmpty(mchList)) { - WalletDataUtil.convertMchList(totalList, CollectionUtils.convertMap(mchList, MchManageVo::getId)); - computeMchPlatformRanke(totalList, mchList); - } - } - timing.stop(); - timing.start("departmentMetadataRpc"); - // 商户部门 - List mchDeptIds = totalList.stream().map(HardOrderCsVo::getMerchantDeptId).distinct().toList(); - if (ObjectUtil.isNotEmpty(mchDeptIds)) { - Map 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 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> monthlyExportPages(HardOrderDto dto) { + HardWalletLedgerQuerySupport.QueryContext context = ledgerQuerySupport.merchant(dto); + return current -> queryLedgerMonths(new Page<>(current, 200), context); + } + + private Page queryLedgerMonths(Page page, HardWalletLedgerQuerySupport.QueryContext context) { + Page result = ledgerStatisticsMapper.merchantStatisticsByMonth(page, context.dto(), context.scope()); + enrichLedgerMerchants(result.getRecords()); + return result; + } + + /** 元数据缺失不删除账本行;远程失败则终止,避免将服务故障伪装成历史商户。 */ + private void enrichLedgerMerchants(List 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 totalPage = hardOrderMapper.pageCsByMonth(page, totalDto, totalDataScope); - List 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 ids = rows.stream().map(HardOrderCsVo::getMerchantId).filter(Objects::nonNull).distinct().toList(); + if (!ids.isEmpty()) { + List merchants = RemoteBatchQueryUtil.queryByIds(ids, batch -> { + R> 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 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 departmentIds = rows.stream().map(HardOrderCsVo::getMerchantDeptId).filter(Objects::nonNull).distinct().toList(); + if (!departmentIds.isEmpty()) { + Map departments = RemoteBatchQueryUtil.queryMapByIds(departmentIds, batch -> { + R> 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 mchIds = totalList.stream().map(HardOrderCsVo::getMerchantId).distinct().toList(); - if (ObjectUtil.isNotEmpty(mchIds)) { - List mchList = getMchListByIds(mchIds); - if (ObjectUtil.isNotEmpty(mchList)) { - WalletDataUtil.convertMchList(totalList, CollectionUtils.convertMap(mchList, MchManageVo::getId)); - computeMchPlatformRanke(totalList, mchList); - } - } - // 商户部门 - List mchDeptIds = totalList.stream().map(HardOrderCsVo::getMerchantDeptId).distinct().toList(); - if (ObjectUtil.isNotEmpty(mchDeptIds)) { - Map 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; } - /** - * 部门交易记录 - * - *

钱包操作与退款金额由一次 Wallet 聚合返回;订单支付在数据库按支付流水创建时固化的部门归集, - * 现金、第三方支付回退订单部门快照。应用内只保留部门级结果。 - * 退款金额保留 REFUND 流水原始正负号,审核中的退款尚未生成流水,不会计入任何金额。

- * - * @param dto 当前租户的部门、时间和钱包类型筛选条件 - * @return 部门交易统计;筛选范围内没有部门时返回空列表 - * @throws IllegalStateException 部门或钱包远程统计调用失败时抛出,避免返回不完整报表 - */ + /** 部门页面与导出均直接聚合钱包账本,不再混用订单支付和钱包退款。 */ @Override public List pageListDept(HardDeptTotalDto dto) { return departmentRows(dto, null); } - /** 导出和页面共用部门级聚合,原始订单与历史快照不跨服务传输。 */ public List listDeptForExport(HardDeptTotalDto dto, AsyncTaskProgressReporter reporter) { return departmentRows(dto, reporter); } private List 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 deptList = queryDepartmentList(dto); + List departments = queryDepartmentList(dto); timing.stop(); if (reporter != null) reporter.advance("部门范围已确认"); - if (ObjectUtil.isEmpty(deptList)) return List.of(); - - timing.start("walletAggregationRpc"); - List walletRows = queryWalletDepartmentStatistics( - buildWalletStatisticsDto(dto)); + timing.start("ledgerAggregation"); + List aggregates = ledgerStatisticsMapper.departmentStatistics(context.dto(), context.scope()); timing.stop(); - if (reporter != null) reporter.advance("钱包六类流水汇总完成"); - timing.start("paymentAggregation"); - List payRows = departmentStatisticsMapper.paymentStatistics(buildPayStatisticsDto(dto), tenantId); - timing.stop(); - if (reporter != null) reporter.advance("订单历史部门归集完成"); - - timing.start("merge"); - Map walletMap = CollectionUtils.convertMap(walletRows, WalletDepartmentStatisticsVo::getDeptId); - Map payMap = indexOrderStatistics(payRows); - List result = new ArrayList<>(deptList.size()); - for (SysDept dept : deptList) { + if (reporter != null) reporter.advance("钱包账本聚合完成"); + Map 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 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> 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 queryWalletDepartmentStatistics(WalletTransactionSumAndCountDto dto) { - R> 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 indexOrderStatistics(List statisticsList) { - Map 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 退款流水,并计算净金额和净次数。 - * - *

退款金额保留 REFUND 流水原始正负号,退款次数按流水条数统计,因此退款到账、充值退款扣回 - * 与退款记录页面保持完全相同的流水口径;退款审核中的售后单尚未生成流水,不会计入。

- * - * @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 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> chuckMchIds = ListUtil.split(mchIds, 5); - - List mchList = getMchListByIds(mchIds); - - Map map = new HashMap(); - - // 查询食堂 - 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> children = remoteMchService.getByParentId(query.getMerchantId()); + if (children == null || !children.isOk()) throw new IllegalStateException("查询导出商户范围失败"); + if (ObjectUtil.isNotEmpty(children.getData())) { + List 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> excelTempMapList = new ArrayList<>(); - for (int i = 0; i < mchIds.size(); i++) { - Long currentMchId = mchIds.get(i); - Map 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 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 ids : chuckMchIds) { - dto.setMchIds(ids); - Page page = pageListCsByMonth(new Page(1L, 999L), dto); - List records = page.getRecords(); - if (ObjectUtil.isEmpty(records)) break; - for (Long id : ids) { - List> excelMapList = new LinkedList>(); - // 先查询商户名 - 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> excelTotalMapList = new LinkedList>(); - 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> disposeCsByMonth(Long mchId, List list, List monthTotalList, Integer yearStr) { - List monthlyRanges = new LinkedList<>(); - for (Month month : Month.values()) { - LocalDate localDate = LocalDate.of(yearStr, month.getValue(), 1); - monthlyRanges.add(localDate); - } - List> listMap = new LinkedList<>(); - List 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 totalList, List mchList) { @@ -1071,39 +663,47 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService { return RemoteBatchQueryUtil.queryMapByIds(deptIds, ids -> remoteDeptService.getDeptByDeptIds(ids).getData()); } - /** 日分片只累计原始金额与次数;平台抽成在完整合并后按原函数计算。 */ - private List 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 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 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 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 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; + } + } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardWalletLedgerDetailExportService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardWalletLedgerDetailExportService.java new file mode 100644 index 000000000..9604aac8e --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardWalletLedgerDetailExportService.java @@ -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 details = detailMapper.listBatch(context.dto(), context.scope(), beforeId, BATCH_SIZE); + if (details == null || details.isEmpty() || details.size() > expected - written) { + throw new IllegalStateException("导出期间钱包流水发生变化,请重试"); + } + List 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(); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/order/HardWalletLedgerQuerySupport.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/order/HardWalletLedgerQuerySupport.java new file mode 100644 index 000000000..20f72f9db --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/order/HardWalletLedgerQuerySupport.java @@ -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 allowed = permission.getHardTypeList(); + List requested = query.getMchTypeCodes(); + List 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); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardDepartmentStatisticsMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardDepartmentStatisticsMapper.xml index c25c5db86..fc7e784c1 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardDepartmentStatisticsMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardDepartmentStatisticsMapper.xml @@ -1,6 +1,7 @@ + + select count(0) + + where + + + diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardWalletLedgerStatisticsMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardWalletLedgerStatisticsMapper.xml new file mode 100644 index 000000000..31aaa27c3 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardWalletLedgerStatisticsMapper.xml @@ -0,0 +1,213 @@ + + + + + + case when ledger.biz_id regexp '^[0-9]{1,19}$' then cast(ledger.biz_id as unsigned) else null end + + + + + 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 = + 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 + 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)) + + inner join pjcloudx_wallet.wallet_manage ledger_wallet + on ledger_wallet.id = ledger.wallet_id and ledger_wallet.tenant_id = ledger.tenant_id + + + + + + ledger.tenant_id = #{scope.tenantId} and ledger.del_flag = '0' + + and ledger.create_time >= #{dto.payTimeList[0]} + and ledger.create_time < #{dto.payTimeList[1]} + + and ledger.wallet_id = #{dto.walletId} + and ledger_wallet.wallet_type_id = #{dto.walletTypeId} + + and ledger_wallet.wallet_type_id in + #{id} + + and ledger.user_id = #{dto.userId} + + and ledger.user_id in + #{id} + + and ledger.dept_id = #{dto.userDeptId} + + + + and (ledger.dept_id is null or ledger.dept_id not in + #{id}) + + + and ledger.dept_id in + #{id} + + + + + + + + + and ledger_order.merchant_id is not null + + and ledger_order.id = #{dto.id} + + and ledger_order.id in + #{id} + + and ledger_order.order_no = #{dto.orderNo} + and ledger_order.merchant_id = #{dto.merchantId} + and ledger_order.merchant_type = #{dto.merchantType} + + and ledger_order.merchant_id in + #{id} + + + and ledger_order.merchant_type in + #{code} + + and ledger_order.merchant_dept_id = #{dto.merchantDeptId} + + + and ledger_order.merchant_dept_id not in + and ledger_order.merchant_dept_id in + + #{id} + + and ledger_order.hard_id = #{dto.hardId} + + and ledger_order.hard_id in + #{id} + + and ledger_order.dept_id = #{dto.deptId} + + + and ledger_order.dept_id not in + and ledger_order.dept_id in + + #{id} + + + 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 + #{code}) + + and ledger_order.order_type = #{dto.orderType} + and ledger_order.hour_type = #{dto.hourType} + and ledger_order.is_offline_order = #{dto.isOfflineOrder} + + and ledger_order.pay_type in + #{code} + + + + + + + and ledger_order.id is not null and ledger_order.merchant_id is not null + + + and ( + ledger_order.create_by = #{scope.username} + or + + ledger_order.merchant_dept_id in + #{id} + + ) + + and 1 = 0 + + + + + + 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 + + + + + + + + diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/export/HardBatchExportFileTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/export/HardBatchExportFileTest.java index 1570ba7ae..84d98d9c7 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/export/HardBatchExportFileTest.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/export/HardBatchExportFileTest.java @@ -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 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 request = new Page<>(current, 200); + int start = Math.toIntExact((request.getCurrent() - 1) * request.getSize()); + return new Page(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 { diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/export/HardBatchExportQueryTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/export/HardBatchExportQueryTest.java index 87bdd91fe..00368755f 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/export/HardBatchExportQueryTest.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/export/HardBatchExportQueryTest.java @@ -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 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 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 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; } } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/export/HardWalletLedgerDetailExportTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/export/HardWalletLedgerDetailExportTest.java new file mode 100644 index 000000000..e916efc36 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/export/HardWalletLedgerDetailExportTest.java @@ -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 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 rows : List.of(List.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; + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardDepartmentStatisticsMySqlBenchmarkTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardDepartmentStatisticsMySqlBenchmarkTest.java index c2cb8b11d..4f2f9b4a0 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardDepartmentStatisticsMySqlBenchmarkTest.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardDepartmentStatisticsMySqlBenchmarkTest.java @@ -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"; diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardDepartmentStatisticsMySqlTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardDepartmentStatisticsMySqlTest.java index 3c70092bd..3961a67fe 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardDepartmentStatisticsMySqlTest.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardDepartmentStatisticsMySqlTest.java @@ -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"; diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardDepartmentStatisticsServiceTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardDepartmentStatisticsServiceTest.java index 17408c3cd..9299b1b62 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardDepartmentStatisticsServiceTest.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardDepartmentStatisticsServiceTest.java @@ -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 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 page = service.pageListDept(dto); - AsyncTaskProgressReporter reporter = mock(AsyncTaskProgressReporter.class); - List 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 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; } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardDepartmentStatisticsSqlTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardDepartmentStatisticsSqlTest.java index 67e2bb9a0..700e63fc3 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardDepartmentStatisticsSqlTest.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardDepartmentStatisticsSqlTest.java @@ -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); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardOrderCsServiceTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardOrderCsServiceTest.java index bb2c94780..4a3d361b3 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardOrderCsServiceTest.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardOrderCsServiceTest.java @@ -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 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> batches = new ArrayList<>(); - when(merchants.getByIds(anyList())).thenAnswer(call -> { - List 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 requested = new Page<>(2, 2); + Page 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 requestedPages = new ArrayList<>(); + when(ledger.merchantStatisticsByMonth(any(), same(normalized), same(scope))).thenAnswer(call -> { + Page 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 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> merchantBatches = new ArrayList<>(), departmentBatches = new ArrayList<>(); + when(merchants.getByIds(anyList())).thenAnswer(call -> { + List 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 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; } } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardOrderCsSqlTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardOrderCsSqlTest.java index e27297cef..9d456b829 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardOrderCsSqlTest.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardOrderCsSqlTest.java @@ -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(); } } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardWalletLedgerQuerySupportTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardWalletLedgerQuerySupportTest.java new file mode 100644 index 000000000..58627c1db --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardWalletLedgerQuerySupportTest.java @@ -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)); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardWalletLedgerStatisticsSqlTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardWalletLedgerStatisticsSqlTest.java new file mode 100644 index 000000000..ab47f1a80 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardWalletLedgerStatisticsSqlTest.java @@ -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 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 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()(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 rows, Long id) { + return rows.stream().filter(row -> id.equals(row.getMerchantId())).findFirst().orElseThrow(); + } + private static Totals merchantTotals(List 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 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(); + PayBaseVo> 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> 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)); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayWaterSourceTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayWaterSourceTest.java index 6f58ee180..3d3cf0dcc 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayWaterSourceTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayWaterSourceTest.java @@ -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 data) { + Fixture fixture = new Fixture(ThirdPaySoureBizTypeEnum.LB); + PayBaseVo> response = new PayBaseVo<>(); + response.setCode(200); + response.setMessage("成功"); + response.setData(data); + when(fixture.hardware.waterList(any(), eq(ThirdPaySoureBizTypeEnum.LB))).thenReturn(response); + Map headers = fixture.headers(); + headers.put("BIZ-TYPE", "10"); + + PayBaseVo> result = fixture.service.water(JSON, headers); + + assertEquals(200, result.getCode()); + assertNotNull(result.getData()); + assertTrue(result.getData().isEmpty()); + ArgumentCaptor 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> response = new PayBaseVo<>(); + response.setCode(code); + response.setMessage("业务错误"); + when(fixture.hardware.waterList(any(), eq(ThirdPaySoureBizTypeEnum.LB))).thenReturn(response); + Map headers = fixture.headers(); + headers.put("BIZ-TYPE", "10"); + + PayBaseVo> 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 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);