diff --git a/db/20261109_add_mch_checkout_bill.sql b/db/20261109_add_mch_checkout_bill.sql new file mode 100644 index 000000000..224a21b03 --- /dev/null +++ b/db/20261109_add_mch_checkout_bill.sql @@ -0,0 +1,156 @@ +-- 酒店商户结账一期:仅在钱包业务库执行。本脚本可重复执行。 +-- 要求MySQL 8.0.16或更新版本,使金额CHECK约束实际生效。 +-- 酒店表作为只读数据源;财务快照通过独立结账表保存。 +-- 脚本交付与数据库实际执行分别授权。 + +CREATE TABLE IF NOT EXISTS `mch_checkout_bill` ( + `id` BIGINT NOT NULL COMMENT '应用分配的雪花主键', + `bill_no` VARCHAR(64) NOT NULL COMMENT '结账单编号,使用业务前缀和雪花主键', + `source_type` VARCHAR(32) NOT NULL COMMENT '订单业务类型,当前为HOTEL', + `relation_id` BIGINT NOT NULL COMMENT '发起时的结账关系主键', + `relation_version` INT NOT NULL COMMENT '发起时的结账关系版本', + `intermediary_name` VARCHAR(100) NOT NULL COMMENT '发起时的中间方名称快照', + `period_start` DATE NOT NULL COMMENT '离店周期开始日期,包含当天', + `period_end` DATE NOT NULL COMMENT '离店周期结束日期,包含当天', + `rule_version` INT NOT NULL COMMENT '金额计算规则版本,当前为1', + `fee_per_order_fen` BIGINT NOT NULL COMMENT '每笔订单抽成,单位分', + `order_count` INT NOT NULL COMMENT '实际纳入账单的订单笔数', + `merchant_count` INT NOT NULL COMMENT '关系全部商户快照数量,含零订单商户', + `gross_amount_fen` BIGINT NOT NULL COMMENT '原始订单金额合计,单位分', + `fee_amount_fen` BIGINT NOT NULL COMMENT '原始抽成合计,单位分', + `payable_amount_fen` BIGINT NOT NULL COMMENT '原始商户应付合计,单位分', + `state` VARCHAR(32) NOT NULL COMMENT 'WAIT_PAY待付款、PART_PAID部分已付、FINISHED已结清、VOID已作废', + `version` INT NOT NULL DEFAULT 0 COMMENT '每次账单状态汇总更新时递增的并发版本', + `create_request_id` VARCHAR(64) NOT NULL COMMENT '创建请求幂等号,租户内唯一', + `request_hash` CHAR(64) NOT NULL COMMENT '规范化创建内容的SHA256摘要', + `preview_token` VARCHAR(64) NOT NULL COMMENT '创建时的预览令牌,供缓存失效后幂等重试匹配', + `source_verified_at` DATETIME(6) NOT NULL COMMENT '创建前最近一次酒店源数据复核时间', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '本次结账备注,最多500字', + `create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人账号', + `create_time` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,服务端时间', + `update_by` VARCHAR(64) DEFAULT NULL COMMENT '最后修改人账号', + `update_time` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后修改时间,服务端时间', + `tenant_id` BIGINT NOT NULL COMMENT '所属租户,由平台上下文自动填写', + PRIMARY KEY (`id`), + UNIQUE KEY uk_checkout_bill_no (tenant_id, bill_no), + UNIQUE KEY uk_checkout_bill_request (tenant_id, create_request_id), + KEY idx_checkout_bill_page (tenant_id, create_time, id), + KEY idx_checkout_bill_state (tenant_id, state, create_time, id), + CONSTRAINT ck_checkout_bill_amount CHECK (gross_amount_fen > 0 AND fee_per_order_fen >= 0 AND fee_amount_fen >= 0 AND payable_amount_fen >= 0 AND order_count BETWEEN 1 AND 10000 AND merchant_count BETWEEN 1 AND 1000 AND version >= 0 AND gross_amount_fen = fee_amount_fen + payable_amount_fen) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='商户结账账单,原始财务汇总及归属快照固定保存'; + +CREATE TABLE IF NOT EXISTS `mch_checkout_bill_merchant` ( + `id` BIGINT NOT NULL COMMENT '应用分配的雪花主键', + `bill_id` BIGINT NOT NULL COMMENT '所属结账账单主键', + `merchant_id` BIGINT NOT NULL COMMENT '绑定商户主键快照', + `merchant_name` VARCHAR(255) NOT NULL COMMENT '绑定商户名称快照', + `dept_id` BIGINT NOT NULL COMMENT '发起时的商户直属部门,用于历史数据权限', + `dept_name` VARCHAR(255) DEFAULT NULL COMMENT '发起时的部门名称快照', + `merchant_create_by` VARCHAR(64) DEFAULT NULL COMMENT '商户创建人快照,用于本人数据权限', + `order_count` INT NOT NULL COMMENT '本次选择的订单笔数,零表示无结账订单', + `gross_amount_fen` BIGINT NOT NULL COMMENT '该商户原订单合计,单位分', + `fee_amount_fen` BIGINT NOT NULL COMMENT '该商户抽成合计,单位分', + `payable_amount_fen` BIGINT NOT NULL COMMENT '该商户应付,单位分', + `state` VARCHAR(32) NOT NULL COMMENT 'NO_ORDERS无订单、WAIT_PAY待付款、PAID已付款或结清、VOID已作废', + `version` INT NOT NULL DEFAULT 0 COMMENT '付款和作废操作使用的并发版本', + `payment_time` DATETIME(6) DEFAULT NULL COMMENT '实际线下付款时间,零元时为结清时间', + `paid_amount_fen` BIGINT DEFAULT NULL COMMENT '登记的全额实付,单位分,未付款为空', + `payment_no` VARCHAR(100) DEFAULT NULL COMMENT '线下付款流水号,与凭证至少提供一种', + `payment_pics` JSON DEFAULT NULL COMMENT '付款凭证地址JSON数组,最多9张', + `payment_remark` VARCHAR(500) DEFAULT NULL COMMENT '付款备注,最多500字', + `payment_request_id` VARCHAR(64) DEFAULT NULL COMMENT '付款请求幂等号', + `payment_request_hash` CHAR(64) DEFAULT NULL COMMENT '付款请求规范化内容摘要', + `void_reason` VARCHAR(500) DEFAULT NULL COMMENT '作废原因,最多500字', + `void_request_id` VARCHAR(64) DEFAULT NULL COMMENT '作废请求幂等号', + `void_request_hash` CHAR(64) DEFAULT NULL COMMENT '作废请求规范化内容摘要', + `create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人账号', + `create_time` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,服务端时间', + `update_by` VARCHAR(64) DEFAULT NULL COMMENT '最后修改人账号', + `update_time` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后修改时间,服务端时间', + `tenant_id` BIGINT NOT NULL COMMENT '所属租户,由平台上下文自动填写', + PRIMARY KEY (`id`), + UNIQUE KEY uk_checkout_bill_merchant (tenant_id, bill_id, merchant_id), + UNIQUE KEY uk_checkout_merchant_pay_request (tenant_id, payment_request_id), + UNIQUE KEY uk_checkout_merchant_void_request (tenant_id, void_request_id), + KEY idx_checkout_merchant_scope (tenant_id, bill_id, dept_id), + KEY idx_checkout_merchant_state (tenant_id, bill_id, state), + CONSTRAINT ck_checkout_billmerchant_amount CHECK (gross_amount_fen >= 0 AND fee_amount_fen >= 0 AND payable_amount_fen >= 0 AND order_count >= 0 AND version >= 0 AND (paid_amount_fen IS NULL OR paid_amount_fen >= 0) AND gross_amount_fen = fee_amount_fen + payable_amount_fen), + CONSTRAINT ck_checkout_merchant_paid CHECK (state != 'PAID' OR (paid_amount_fen IS NOT NULL AND payment_time IS NOT NULL AND paid_amount_fen = payable_amount_fen)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='结账商户明细及完整关系权限快照'; + +CREATE TABLE IF NOT EXISTS `mch_checkout_bill_order` ( + `id` BIGINT NOT NULL COMMENT '应用分配的雪花主键', + `bill_id` BIGINT NOT NULL COMMENT '所属账单主键', + `bill_merchant_id` BIGINT NOT NULL COMMENT '所属商户结账明细主键', + `merchant_id` BIGINT NOT NULL COMMENT '归属商户主键快照', + `source_type` VARCHAR(32) NOT NULL COMMENT '源业务类型,当前为HOTEL', + `source_order_id` BIGINT NOT NULL COMMENT '酒店本地订单主键,唯一占用身份的一部分', + `hotel_id` BIGINT NOT NULL COMMENT '唯一解析的酒店主键快照', + `hotel_name` VARCHAR(255) DEFAULT NULL COMMENT '酒店名称快照', + `dept_id` BIGINT NOT NULL COMMENT '酒店直属归属部门快照', + `channel_no` VARCHAR(255) DEFAULT NULL COMMENT '渠道订单号快照', + `platform_no` VARCHAR(255) DEFAULT NULL COMMENT '上游订单号快照', + `check_in_date` DATE DEFAULT NULL COMMENT '源订单入住日期快照', + `check_out_date` DATE NOT NULL COMMENT '源订单离店日期快照', + `rooms` INT DEFAULT NULL COMMENT '房间数量快照,仅供核对', + `gross_amount_fen` BIGINT NOT NULL COMMENT '该笔订单售价,单位分', + `fee_amount_fen` BIGINT NOT NULL COMMENT '该笔抽成,单位分', + `payable_amount_fen` BIGINT NOT NULL COMMENT '该笔商户应付,单位分', + `order_status` VARCHAR(64) DEFAULT NULL COMMENT '酒店原始订单状态快照', + `book_status` VARCHAR(64) DEFAULT NULL COMMENT '酒店原始预订状态快照', + `pay_status` VARCHAR(64) DEFAULT NULL COMMENT '酒店原始支付状态快照', + `source_updated_at` DATETIME(6) DEFAULT NULL COMMENT '源订单最后更新时间快照', + `source_fingerprint` CHAR(64) NOT NULL COMMENT '源金额归属状态等关键事实的SHA256摘要', + `source_snapshot` JSON NOT NULL COMMENT '不含住客和联系人信息的酒店事实JSON快照', + `confirmed_user_id` BIGINT NOT NULL COMMENT '履约完成及收款核对人的管理用户主键', + `confirmed_by` VARCHAR(64) NOT NULL COMMENT '履约完成及收款核对人的账号', + `confirmed_at` DATETIME(6) NOT NULL COMMENT '服务端记录的履约与收款人工确认时间', + `last_check_status` VARCHAR(32) NOT NULL COMMENT 'NORMAL一致、DIFF差异、QUERY_FAILED查询失败、HANDLED人工处理', + `last_check_message` VARCHAR(500) DEFAULT NULL COMMENT '最近一次核对的可读结果', + `last_observed_fingerprint` CHAR(64) DEFAULT NULL COMMENT '最近观察到的源事实摘要', + `handled_fingerprint` CHAR(64) DEFAULT NULL COMMENT '已人工处理的差异摘要,新差异重新提醒', + `last_checked_at` DATETIME(6) DEFAULT NULL COMMENT '最近一次复核尝试时间', + `last_verified_at` DATETIME(6) DEFAULT NULL COMMENT '最近一次成功读取源数据时间,失败时保留', + `next_check_at` DATETIME(6) DEFAULT NULL COMMENT '下次后台复核时间,作废后为空', + `create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人账号', + `create_time` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,服务端时间', + `update_by` VARCHAR(64) DEFAULT NULL COMMENT '最后修改人账号', + `update_time` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后修改时间,服务端时间', + `tenant_id` BIGINT NOT NULL COMMENT '所属租户,由平台上下文自动填写', + PRIMARY KEY (`id`), + UNIQUE KEY uk_checkout_bill_order (tenant_id, bill_id, source_type, source_order_id), + KEY idx_checkout_order_page (tenant_id, bill_id, bill_merchant_id, id), + KEY idx_checkout_order_recheck (tenant_id, next_check_at, id), + CONSTRAINT ck_checkout_billorder_amount CHECK (gross_amount_fen > 0 AND fee_amount_fen >= 0 AND payable_amount_fen >= 0 AND gross_amount_fen = fee_amount_fen + payable_amount_fen) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='结账订单不可变金额快照及后续复核结果'; + +CREATE TABLE IF NOT EXISTS `mch_checkout_order_claim` ( + `id` BIGINT NOT NULL COMMENT '应用分配的雪花主键', + `source_type` VARCHAR(32) NOT NULL COMMENT '源业务类型,当前为HOTEL', + `source_order_id` BIGINT NOT NULL COMMENT '源订单主键,租户和类型下唯一占用', + `bill_id` BIGINT NOT NULL COMMENT '占用账单主键', + `bill_merchant_id` BIGINT NOT NULL COMMENT '占用的商户结账明细主键', + `create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人账号', + `create_time` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,服务端时间', + `tenant_id` BIGINT NOT NULL COMMENT '所属租户,由平台上下文自动填写', + PRIMARY KEY (`id`), + UNIQUE KEY uk_checkout_order_claim (tenant_id, source_type, source_order_id), + KEY idx_checkout_claim_merchant (tenant_id, bill_id, bill_merchant_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='当前有效结账订单占用,作废待付款明细时释放'; + +CREATE TABLE IF NOT EXISTS `mch_checkout_bill_event` ( + `id` BIGINT NOT NULL COMMENT '应用分配的雪花主键', + `bill_id` BIGINT NOT NULL COMMENT '事件所属账单主键', + `bill_merchant_id` BIGINT DEFAULT NULL COMMENT '商户明细主键,整单事件为空', + `source_order_id` BIGINT DEFAULT NULL COMMENT '源订单主键,非订单事件为空', + `action` VARCHAR(32) NOT NULL COMMENT 'CREATE、PAY、VOID、RECHECK、DIFF、QUERY_FAILED、HANDLE业务动作', + `request_id` VARCHAR(64) NOT NULL COMMENT '该动作的请求幂等号或系统事件号', + `request_hash` CHAR(64) NOT NULL COMMENT '该动作请求摘要,供审计核对', + `payload` JSON NOT NULL COMMENT '业务依据和结果JSON,不包含凭据或酒店住客资料', + `create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人账号', + `create_time` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,服务端时间', + `tenant_id` BIGINT NOT NULL COMMENT '所属租户,由平台上下文自动填写', + PRIMARY KEY (`id`), + UNIQUE KEY uk_checkout_event_request (tenant_id, action, request_id), + KEY idx_checkout_event_page (tenant_id, bill_id, id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='结账业务追加式审计事件'; diff --git a/db/20261109_add_mch_checkout_bill_menu.sql b/db/20261109_add_mch_checkout_bill_menu.sql new file mode 100644 index 000000000..30bd90da6 --- /dev/null +++ b/db/20261109_add_mch_checkout_bill_menu.sql @@ -0,0 +1,59 @@ +-- 在权限库中执行。先在同一会话明确设置目标租户和父菜单。 +-- SET @checkout_bill_tenant_id = 目标租户ID; +-- SET @checkout_bill_parent_id = 商户结账管理父菜单ID; +-- 脚本安装记录页和按钮权限,角色按实际职责另行授权。 +DELIMITER $$ +DROP PROCEDURE IF EXISTS migrate_20261109_mch_checkout_bill_menu$$ +CREATE PROCEDURE migrate_20261109_mch_checkout_bill_menu() +BEGIN + DECLARE v_parent BIGINT; + DECLARE v_setting BIGINT; + DECLARE v_record BIGINT; + DECLARE v_count INT; + DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END; + IF @checkout_bill_tenant_id IS NULL OR @checkout_bill_tenant_id <= 0 + OR @checkout_bill_parent_id IS NULL OR @checkout_bill_parent_id <= 0 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='请先设置结账菜单目标租户和父菜单ID'; + END IF; + START TRANSACTION; + SELECT menu_id INTO v_parent FROM sys_menu WHERE menu_id=@checkout_bill_parent_id + AND tenant_id=@checkout_bill_tenant_id AND del_flag='0' AND menu_type='0' FOR UPDATE; + IF v_parent IS NULL THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='指定父菜单不存在或类型不符'; END IF; + SELECT COUNT(*),MAX(menu_id) INTO v_count,v_setting FROM sys_menu + WHERE tenant_id=@checkout_bill_tenant_id AND del_flag='0' AND path='/mch/checkout/setting/index'; + IF v_count != 1 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='请先安装唯一的商户结账配置页面'; END IF; + SELECT COUNT(*),MAX(menu_id) INTO v_count,v_record FROM sys_menu + WHERE tenant_id=@checkout_bill_tenant_id AND del_flag='0' AND path='/mch/checkout/record/index'; + IF v_count > 1 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='存在重复结账记录菜单,请先核对'; END IF; + IF v_count = 1 AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id=v_record AND parent_id=v_parent + AND menu_type='0' AND (component IS NULL OR component='' OR component='mch/checkout/record/index')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='现有结账记录菜单位置或组件不符,请先核对'; + END IF; + IF v_count = 0 THEN + SET v_record=UUID_SHORT(); + INSERT INTO sys_menu(menu_id,name,path,component,parent_id,icon,visible,sort_order,keep_alive,menu_type,create_by,create_time,update_by,update_time,del_flag,tenant_id) + VALUES(v_record,'商户结账记录','/mch/checkout/record/index','mch/checkout/record/index',v_parent,'ele-Tickets','1',81,'1','0','admin',NOW(),'admin',NOW(),'0',@checkout_bill_tenant_id); + ELSE + UPDATE sys_menu SET component='mch/checkout/record/index' WHERE menu_id=v_record AND tenant_id=@checkout_bill_tenant_id; + END IF; + IF EXISTS (SELECT 1 FROM sys_menu WHERE tenant_id=@checkout_bill_tenant_id AND del_flag='0' + AND permission IN ('wallet_mchCheckoutBill_view','wallet_mchCheckoutBill_add','wallet_mchCheckoutBill_pay','wallet_mchCheckoutBill_void','wallet_mchCheckoutBill_recheck','wallet_mchCheckoutBill_handle') + AND (menu_type != '1' OR parent_id != CASE WHEN permission='wallet_mchCheckoutBill_add' THEN v_setting ELSE v_record END)) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='结账权限已被其他菜单占用,请先核对'; + END IF; + INSERT INTO sys_menu(menu_id,name,permission,parent_id,visible,sort_order,keep_alive,menu_type,create_by,create_time,update_by,update_time,del_flag,tenant_id) + SELECT UUID_SHORT(),p.name,p.permission,CASE WHEN p.permission='wallet_mchCheckoutBill_add' THEN v_setting ELSE v_record END, + '1',p.sort_order,'0','1','admin',NOW(),'admin',NOW(),'0',@checkout_bill_tenant_id + FROM ( + SELECT '查看结账账单' name,'wallet_mchCheckoutBill_view' permission,1 sort_order + UNION ALL SELECT '发起酒店结账','wallet_mchCheckoutBill_add',2 + UNION ALL SELECT '登记商户付款','wallet_mchCheckoutBill_pay',3 + UNION ALL SELECT '作废商户明细','wallet_mchCheckoutBill_void',4 + UNION ALL SELECT '复核订单事实','wallet_mchCheckoutBill_recheck',5 + UNION ALL SELECT '处理付款差异','wallet_mchCheckoutBill_handle',6 + ) p WHERE NOT EXISTS (SELECT 1 FROM sys_menu m WHERE m.tenant_id=@checkout_bill_tenant_id AND m.del_flag='0' AND m.permission=p.permission); + COMMIT; +END$$ +CALL migrate_20261109_mch_checkout_bill_menu()$$ +DROP PROCEDURE migrate_20261109_mch_checkout_bill_menu$$ +DELIMITER ; diff --git a/db/20261109_add_mch_scene.sql b/db/20261109_add_mch_scene.sql new file mode 100644 index 000000000..d9c9681f3 --- /dev/null +++ b/db/20261109_add_mch_scene.sql @@ -0,0 +1,18 @@ +-- 在钱包业务库执行,微服务通常为 pjcloudx_wallet,单体以实际业务库为准。 +-- 商户和场景各自独立配置,父店与分店使用各自 mch_id,首次保存时创建记录。 +-- 重复执行不会重复建表,应用通过唯一键保证同租户、商户和场景只有一条记录。 +CREATE TABLE IF NOT EXISTS `mch_scene` +( + `id` BIGINT NOT NULL COMMENT '配置主键,应用分配雪花ID', + `mch_id` BIGINT NOT NULL COMMENT '商户ID,父店和分店独立配置', + `scene_code` VARCHAR(32) NOT NULL COMMENT '场景编码,HOTEL_COMMISSION酒店抽成', + `enabled` CHAR(1) NOT NULL DEFAULT '0' COMMENT '是否开启,0关闭,1开启', + `create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人账号,公共填充器维护', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间,服务端时区', + `update_by` VARCHAR(64) DEFAULT NULL COMMENT '最后修改人账号,公共填充器维护', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '最后修改时间,服务端时区', + `del_flag` CHAR(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记,0有效,1已删除', + `tenant_id` BIGINT NOT NULL COMMENT '租户ID,公共租户组件维护', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_mch_scene` (`tenant_id`, `mch_id`, `scene_code`) + ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '商户场景配置'; diff --git a/db/20261109_add_mch_scene_commission_amount.sql b/db/20261109_add_mch_scene_commission_amount.sql new file mode 100644 index 000000000..77275ecd5 --- /dev/null +++ b/db/20261109_add_mch_scene_commission_amount.sql @@ -0,0 +1,12 @@ +-- 在钱包业务库执行;尚未建表时,先执行 20261109_add_mch_scene.sql。 +-- 酒店抽成按每单完成的酒店订单配置固定金额,单位元,保留两位小数。 +-- 旧配置默认获得 0.00 元;重复执行会跳过已有字段。 +SET @mch_scene_amount_ddl = IF( + (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'mch_scene' + AND column_name = 'commission_amount') = 0, + 'ALTER TABLE `mch_scene` ADD COLUMN `commission_amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT ''每单完成酒店订单的固定抽成金额,单位元'' AFTER `enabled`', + 'SELECT ''commission_amount 已存在'' AS message'); +PREPARE mch_scene_amount_stmt FROM @mch_scene_amount_ddl; +EXECUTE mch_scene_amount_stmt; +DEALLOCATE PREPARE mch_scene_amount_stmt; diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/api/feign/RemoteSmHotelOrderCheckoutQueryService.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/api/feign/RemoteSmHotelOrderCheckoutQueryService.java new file mode 100644 index 000000000..c397fbb35 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/api/feign/RemoteSmHotelOrderCheckoutQueryService.java @@ -0,0 +1,25 @@ +package com.bsd.pjcloud.supermarket.api.feign; + +import com.bsd.pjcloud.common.core.constant.ServiceNameConstants; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.feign.annotation.NoToken; +import com.bsd.pjcloud.supermarket.dto.SmHotelOrderCheckoutQueryDto; +import com.bsd.pjcloud.supermarket.vo.SmHotelOrderCheckoutQueryVo; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import java.util.List; + +/** 钱包结账独立只读酒店契约,调用方负责完整授权和业务状态检查。 */ +@FeignClient(contextId = "remoteSmHotelOrderCheckoutQueryService", value = ServiceNameConstants.SUPERMARKET_SERVICE) +public interface RemoteSmHotelOrderCheckoutQueryService { + /** 按部门、周期和固定上界游标读取最多500笔订单。 */ + @NoToken + @PostMapping("/inner/sm-hotel-order-checkout-query/scan") + R scan(@RequestBody SmHotelOrderCheckoutQueryDto.Scan request); + + /** 读取最多500个明确主键的当前事实,响应必须包含请求中的每个主键。 */ + @NoToken + @PostMapping("/inner/sm-hotel-order-checkout-query/by-ids") + R> byIds(@RequestBody SmHotelOrderCheckoutQueryDto.ByIds request); +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/dto/SmHotelOrderCheckoutQueryDto.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/dto/SmHotelOrderCheckoutQueryDto.java new file mode 100644 index 000000000..1c2e32d6c --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/dto/SmHotelOrderCheckoutQueryDto.java @@ -0,0 +1,38 @@ +package com.bsd.pjcloud.supermarket.dto; + +import jakarta.validation.constraints.*; +import lombok.Data; +import java.time.LocalDate; +import java.util.List; + +/** 酒店结账专用只读请求,部门范围由钱包服务计算。 */ +public final class SmHotelOrderCheckoutQueryDto { + /** 请求容器仅承载跨服务契约。 */ + private SmHotelOrderCheckoutQueryDto() { } + + /** 按离店周期和稳定主键游标读取,单批最多500笔。 */ + @Data + public static class Scan { + /** 精确酒店归属部门,空集合禁止退化为全租户查询。 */ + @NotEmpty @Size(max = 1000) + private List<@NotNull @Positive Long> deptIds; + /** 离店日期起点,包含当天。 */ + @NotNull private LocalDate periodStart; + /** 离店日期终点,包含当天。 */ + @NotNull private LocalDate periodEnd; + /** 已读最大订单主键,首批为0。 */ + @NotNull @Min(0) private Long afterId = 0L; + /** 首批返回的扫描上界,后续批次原样提交。 */ + @Positive private Long upperId; + /** 单批请求数量,最大500。 */ + @NotNull @Min(1) @Max(500) private Integer size = 500; + } + + /** 按明确主键读取现状,缺失项也逐项返回。 */ + @Data + public static class ByIds { + /** 酒店订单主键集合,单批最多500笔。 */ + @NotEmpty @Size(max = 500) + private List<@NotNull @Positive Long> orderIds; + } +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/vo/SmHotelOrderCheckoutQueryVo.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/vo/SmHotelOrderCheckoutQueryVo.java new file mode 100644 index 000000000..221f3355b --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/vo/SmHotelOrderCheckoutQueryVo.java @@ -0,0 +1,60 @@ +package com.bsd.pjcloud.supermarket.vo; + +import lombok.Data; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** 结账读取的最小酒店事实,不携带联系人、住客及支付配置。 */ +@Data +public class SmHotelOrderCheckoutQueryVo { + /** 本地酒店订单主键,始终用它识别订单。 */ + private Long orderId; + /** 当前订单是否存在且有效,缺失保留请求主键。 */ + private boolean found; + /** 渠道订单号,仅供财务核对。 */ + private String channelNo; + /** 上游订单号,仅供财务核对。 */ + private String platformNo; + /** 唯一解析的酒店主键。 */ + private Long hotelId; + /** 酒店名称,无法唯一解析时为空。 */ + private String hotelName; + /** 酒店直属归属部门。 */ + private Long deptId; + /** 酒店数据来源,沿用sheet_name原值。 */ + private String hotelDataSource; + /** 订单渠道编码,空值仍保留原语义。 */ + private String partnerCode; + /** 开放应用主键,空值表示未指定应用。 */ + private Long downstreamAppId; + /** 入住日期。 */ + private LocalDate checkInDate; + /** 离店日期。 */ + private LocalDate checkOutDate; + /** 房间数量,仅供展示,不作为抽成次数。 */ + private Integer rooms; + /** 原订单售价,单位元,原始精度通过十进制字符串传输。 */ + private String sellAmountYuan; + /** 酒店原始订单状态。 */ + private String orderStatus; + /** 酒店原始预订状态。 */ + private String bookStatus; + /** 酒店原始支付状态,渠道非标准编码原样保留。 */ + private String payStatus; + /** 源订单最后更新时间。 */ + private LocalDateTime updateTime; + /** 归属解析结果,OK或MISSING_HOTEL或AMBIGUOUS_HOTEL或MISSING_ORDER。 */ + private String resolutionStatus; + /** 归属异常原因,供结账侧明确展示。 */ + private String resolutionMessage; + + /** 一批稳定游标结果,跨批变化由钱包提交时逐项复核。 */ + @Data + public static class ScanResult { + /** 首批固定的订单主键上界,空范围为0。 */ + private Long upperId; + /** 当前批次全部记录,最大500。 */ + private List records; + } +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelOrderCheckoutQueryController.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelOrderCheckoutQueryController.java new file mode 100644 index 000000000..01b73c090 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelOrderCheckoutQueryController.java @@ -0,0 +1,35 @@ +package com.bsd.pjcloud.supermarket.controller; + +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.security.annotation.Inner; +import com.bsd.pjcloud.supermarket.dto.SmHotelOrderCheckoutQueryDto; +import com.bsd.pjcloud.supermarket.service.SmHotelOrderCheckoutQueryService; +import com.bsd.pjcloud.supermarket.vo.SmHotelOrderCheckoutQueryVo; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import io.swagger.v3.oas.annotations.Operation; +import org.springframework.web.bind.annotation.*; +import java.util.List; + +/** 结账专用内部只读入口,与酒店现有履约和支付入口隔离。 */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/inner/sm-hotel-order-checkout-query") +public class SmHotelOrderCheckoutQueryController { + /** 仅执行数据库读取的查询服务。 */ + private final SmHotelOrderCheckoutQueryService queryService; + + /** 读取固定周期内一批酒店事实。 */ + @Operation(summary = "读取固定周期内一批酒店事实") + @Inner @PostMapping("/scan") + public R scan(@Valid @RequestBody SmHotelOrderCheckoutQueryDto.Scan request) { + return R.ok(queryService.scan(request)); + } + + /** 批量读取当前事实,缺失项保留明确状态。 */ + @Operation(summary = "批量读取当前事实,缺失项保留明确状态") + @Inner @PostMapping("/by-ids") + public R> byIds(@Valid @RequestBody SmHotelOrderCheckoutQueryDto.ByIds request) { + return R.ok(queryService.byIds(request)); + } +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelOrderCheckoutQueryMapper.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelOrderCheckoutQueryMapper.java new file mode 100644 index 000000000..169c5500d --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelOrderCheckoutQueryMapper.java @@ -0,0 +1,18 @@ +package com.bsd.pjcloud.supermarket.mapper; + +import com.bsd.pjcloud.supermarket.dto.SmHotelOrderCheckoutQueryDto; +import com.bsd.pjcloud.supermarket.vo.SmHotelOrderCheckoutQueryVo; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import java.util.List; + +/** 结账专用事实查询,租户由现有拦截器自动处理。 */ +@Mapper +public interface SmHotelOrderCheckoutQueryMapper { + /** 读取当前筛选范围最大主键以固定扫描边界,空范围返回null。 */ + Long upperId(@Param("query") SmHotelOrderCheckoutQueryDto.Scan request); + /** 按主键升序读取一批事实,归属歧义保留异常记录。 */ + List scan(@Param("query") SmHotelOrderCheckoutQueryDto.Scan request); + /** 读取明确订单主键,软删除及物理缺失由服务层补为缺失结果。 */ + List byIds(@Param("ids") List ids); +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelOrderCheckoutQueryService.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelOrderCheckoutQueryService.java new file mode 100644 index 000000000..b7806fd22 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelOrderCheckoutQueryService.java @@ -0,0 +1,13 @@ +package com.bsd.pjcloud.supermarket.service; + +import com.bsd.pjcloud.supermarket.dto.SmHotelOrderCheckoutQueryDto; +import com.bsd.pjcloud.supermarket.vo.SmHotelOrderCheckoutQueryVo; +import java.util.List; + +/** 酒店结账事实读取接口,无订单更新及外部渠道调用。 */ +public interface SmHotelOrderCheckoutQueryService { + /** 按稳定主键顺序读取周期内最多500笔。 */ + SmHotelOrderCheckoutQueryVo.ScanResult scan(SmHotelOrderCheckoutQueryDto.Scan request); + /** 按请求顺序返回现状,重复主键去重,缺失项明确返回。 */ + List byIds(SmHotelOrderCheckoutQueryDto.ByIds request); +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOrderCheckoutQueryServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOrderCheckoutQueryServiceImpl.java new file mode 100644 index 000000000..7444eefe9 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOrderCheckoutQueryServiceImpl.java @@ -0,0 +1,61 @@ +package com.bsd.pjcloud.supermarket.service.impl; + +import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; +import com.bsd.pjcloud.supermarket.dto.SmHotelOrderCheckoutQueryDto; +import com.bsd.pjcloud.supermarket.mapper.SmHotelOrderCheckoutQueryMapper; +import com.bsd.pjcloud.supermarket.service.SmHotelOrderCheckoutQueryService; +import com.bsd.pjcloud.supermarket.vo.SmHotelOrderCheckoutQueryVo; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** 只读酒店结账查询,不调用含超时关单副作用的支付校验。 */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) +public class SmHotelOrderCheckoutQueryServiceImpl implements SmHotelOrderCheckoutQueryService { + /** 独立的数据库只读访问。 */ + private final SmHotelOrderCheckoutQueryMapper mapper; + + /** 在当前事务中确定上界并读取一批事实。 */ + @Override + public SmHotelOrderCheckoutQueryVo.ScanResult scan(SmHotelOrderCheckoutQueryDto.Scan request) { + requireTenant(); + if (request.getPeriodStart().isAfter(request.getPeriodEnd())) throw new IllegalArgumentException("离店周期起止不合法"); + if (request.getUpperId() == null) request.setUpperId(Optional.ofNullable(mapper.upperId(request)).orElse(0L)); + SmHotelOrderCheckoutQueryVo.ScanResult result = new SmHotelOrderCheckoutQueryVo.ScanResult(); + result.setUpperId(request.getUpperId()); + result.setRecords(request.getUpperId() == 0 ? List.of() : mapper.scan(request)); + return result; + } + + /** 请求中不存在的主键返回明确缺失项,避免批量复核静默漏单。 */ + @Override + public List byIds(SmHotelOrderCheckoutQueryDto.ByIds request) { + requireTenant(); + List ids = request.getOrderIds().stream().distinct().toList(); + Map existing = mapper.byIds(ids).stream().collect(Collectors.toMap(SmHotelOrderCheckoutQueryVo::getOrderId, Function.identity())); + List result = new ArrayList<>(); + for (Long id : ids) { + SmHotelOrderCheckoutQueryVo row = existing.get(id); + if (row == null) { + row = new SmHotelOrderCheckoutQueryVo(); + row.setOrderId(id); + row.setFound(false); + row.setResolutionStatus("MISSING_ORDER"); + row.setResolutionMessage("酒店订单已删除或不存在"); + } + result.add(row); + } + return result; + } + + /** 内部调用也必须具备有效租户上下文,避免空上下文扩大查询。 */ + private void requireTenant() { + if (TenantContextHolder.getTenantId() == null || TenantContextHolder.getTenantId() <= 0) throw new IllegalStateException("缺少租户上下文"); + } +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/resources/mapper/SmHotelOrderCheckoutQueryMapper.xml b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/resources/mapper/SmHotelOrderCheckoutQueryMapper.xml new file mode 100644 index 000000000..319f24f02 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/resources/mapper/SmHotelOrderCheckoutQueryMapper.xml @@ -0,0 +1,46 @@ + + + + + + FROM canteen_hotel_order o + LEFT JOIN canteen_hotel h ON h.id = CASE WHEN o.hotel_id IS NOT NULL THEN o.hotel_id ELSE + (SELECT MIN(legacy.id) FROM canteen_hotel legacy + WHERE legacy.hotel_code = o.hotel_code AND legacy.del_flag = '0' + AND (o.group_code IS NULL OR o.group_code = '' OR legacy.group_code = o.group_code) + HAVING COUNT(*) = 1) END AND h.del_flag = '0' + + + o.id AS order_id, 1 AS found, o.channel_no, o.platform_no, h.id AS hotel_id, + h.hotel_name, h.dept_id, h.sheet_name AS hotel_data_source, o.partner_code, o.downstream_app_id, + o.check_in_date, o.check_out_date, o.count AS rooms, CAST(o.sell_amount AS CHAR) AS sell_amount_yuan, + o.order_status, o.book_status, o.pay_status, o.update_time, + CASE WHEN h.id IS NOT NULL THEN 'OK' + WHEN o.hotel_id IS NULL AND (SELECT COUNT(*) FROM canteen_hotel hc + WHERE hc.hotel_code = o.hotel_code AND hc.del_flag = '0' + AND (o.group_code IS NULL OR o.group_code = '' OR hc.group_code = o.group_code)) > 1 + THEN 'AMBIGUOUS_HOTEL' ELSE 'MISSING_HOTEL' END AS resolution_status, + CASE WHEN h.id IS NULL THEN '酒店已失效或编码无法唯一确定归属' ELSE NULL END AS resolution_message + + + WHERE o.del_flag = '0' AND o.check_out_date BETWEEN #{query.periodStart} AND #{query.periodEnd} + AND (h.dept_id IN #{dept} + OR (o.hotel_id IS NULL AND h.id IS NULL AND EXISTS ( + SELECT 1 FROM canteen_hotel possible WHERE possible.hotel_code = o.hotel_code AND possible.del_flag = '0' + AND (o.group_code IS NULL OR o.group_code = '' OR possible.group_code = o.group_code) + AND possible.dept_id IN #{dept} + ))) + + + + + diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/mch/MchCheckoutBillStates.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/mch/MchCheckoutBillStates.java new file mode 100644 index 000000000..5a834bebb --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/mch/MchCheckoutBillStates.java @@ -0,0 +1,58 @@ +package com.bsd.pjcloud.wallet.api.constant.mch; + +/** 结账状态协议,枚举名称即持久化编码。 */ +public final class MchCheckoutBillStates { + /** 状态命名容器不创建实例。 */ + private MchCheckoutBillStates() { } + + /** 整单状态,由全部商户明细在事务中汇总。 */ + public enum Bill { + /** 有待付款明细且尚未登记付款。 */ + WAIT_PAY, + /** 部分商户已经付款,其余仍待付款。 */ + PART_PAID, + /** 全部有效商户明细已经付款或零元结清。 */ + FINISHED, + /** 全部有订单的商户明细已经作废。 */ + VOID + } + /** 单商户状态,零元结清沿用PAID并记录0分实付。 */ + public enum Merchant { + /** 当前关系成员在本单没有选择订单。 */ + NO_ORDERS, + /** 等待全额付款或零元确认结清。 */ + WAIT_PAY, + /** 已经登记全额付款或零元结清。 */ + PAID, + /** 待付款明细已经作废并释放订单。 */ + VOID + } + /** 源事实复核结果,原始金额快照持续保留。 */ + public enum Check { + /** 当前源事实与原账一致。 */ + NORMAL, + /** 当前事实已经产生影响结账的差异。 */ + DIFF, + /** 源数据读取失败,结果待重试。 */ + QUERY_FAILED, + /** 当前已付款差异已有财务处理记录。 */ + HANDLED + } + /** 追加式审计事件类型。 */ + public enum Action { + /** 创建完整结账单。 */ + CREATE, + /** 登记商户付款或零元结清。 */ + PAY, + /** 作废待付款商户明细。 */ + VOID, + /** 人工发起整单复核。 */ + RECHECK, + /** 源事实发生结账差异。 */ + DIFF, + /** 源事实查询失败。 */ + QUERY_FAILED, + /** 财务处理已付款差异。 */ + HANDLE + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/mch/MchCheckoutBillRequests.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/mch/MchCheckoutBillRequests.java new file mode 100644 index 000000000..270e01b72 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/mch/MchCheckoutBillRequests.java @@ -0,0 +1,104 @@ +package com.bsd.pjcloud.wallet.api.dto.mch; + +import jakarta.validation.constraints.*; +import lombok.Data; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** 结账请求模型,服务端独立计算金额、权限、审计和租户字段。 */ +public final class MchCheckoutBillRequests { + /** 请求命名容器不创建实例。 */ + private MchCheckoutBillRequests() { } + + /** 酒店候选范围,仅接受明确关系和离店周期。 */ + @Data + public static class Candidate { + /** 当前有效结账关系主键。 */ + @NotNull @Positive private Long relationId; + /** 页面读取的关系并发版本。 */ + @NotNull @Min(0) private Integer relationVersion; + /** 离店日期起点,包含当天。 */ + @NotNull private LocalDate periodStart; + /** 离店日期终点,包含当天。 */ + @NotNull private LocalDate periodEnd; + } + + /** 预览明确选择的订单,每笔订单均由操作者确认履约和收款。 */ + @Data + public static class Preview { + /** 当前用户10分钟内的候选令牌。 */ + @NotBlank @Size(max = 64) private String candidateToken; + /** 完整跨页订单主键集合,最多10000笔。 */ + @NotEmpty @Size(max = 10000) private List<@NotNull @Positive Long> orderIds; + /** 每单抽成,单位元,最多两位小数,使用字符串精确解析。 */ + @NotBlank @Pattern(regexp = "[0-9]{1,15}(\\.[0-9]{1,2})?") private String feePerOrderYuan; + /** 操作者明确确认所有所选订单已经实际履约完成。 */ + @NotNull @AssertTrue private Boolean fulfillmentConfirmed; + /** 操作者明确确认所有所选订单款项已经核对。 */ + @NotNull @AssertTrue private Boolean receiptConfirmed; + /** 本次结账备注。 */ + @Size(max = 500) private String remark; + } + + /** 创建只提交预览身份和稳定请求号,金额以服务端预览及复核为准。 */ + @Data + public static class Create { + /** 服务端生成的10分钟预览令牌。 */ + @NotBlank @Size(max = 64) private String previewToken; + /** 同一提交及其超时重试保持不变的幂等号。 */ + @NotBlank @Size(max = 64) private String requestId; + } + + /** 单商户全额线下付款登记,零元时表示确认结清。 */ + @Data + public static class Payment { + /** 已读取的商户结账明细版本。 */ + @NotNull @Min(0) private Integer version; + /** 本次付款登记的稳定幂等号。 */ + @NotBlank @Size(max = 64) private String requestId; + /** 实际付款或零元结清时间。 */ + @NotNull @PastOrPresent private LocalDateTime paymentTime; + /** 线下付款流水号,与凭证至少提供一种。 */ + @Size(max = 100) private String paymentNo; + /** 付款凭证地址,最多9张。 */ + @Size(max = 9) private List<@NotBlank @Size(max = 2048) String> paymentPics; + /** 付款登记备注。 */ + @Size(max = 500) private String remark; + } + + /** 作废一份待付款商户明细,保留原账并释放它的订单占用。 */ + @Data + public static class VoidMerchant { + /** 已读取的商户结账明细版本。 */ + @NotNull @Min(0) private Integer version; + /** 作废请求的稳定幂等号。 */ + @NotBlank @Size(max = 64) private String requestId; + /** 作废业务原因。 */ + @NotBlank @Size(max = 500) private String reason; + } + + /** 已付款差异的财务处理登记,原始账单金额持续保留。 */ + @Data + public static class Handle { + /** 本次处理请求的幂等号。 */ + @NotBlank @Size(max = 64) private String requestId; + /** 操作者看到的差异摘要,用于拒绝处理期间新出现的差异。 */ + @NotBlank @Size(min = 64, max = 64) private String observedFingerprint; + /** 财务核实与处理结果。 */ + @NotBlank @Size(max = 500) private String remark; + /** 处理凭据地址,最多9张。 */ + @Size(max = 9) private List<@NotBlank @Size(max = 2048) String> evidencePics; + } + + /** 账单列表筛选,历史权限按商户快照检查。 */ + @Data + public static class ListQuery { + /** 中间方名称快照模糊筛选。 */ + @Size(max = 100) private String intermediaryName; + /** 结账单编号模糊筛选。 */ + @Size(max = 64) private String billNo; + /** 账单当前状态,空值查询全部授权账单。 */ + @Pattern(regexp = "WAIT_PAY|PART_PAID|FINISHED|VOID") private String state; + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/mch/MchSceneDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/mch/MchSceneDto.java new file mode 100644 index 000000000..f2df4995a --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/mch/MchSceneDto.java @@ -0,0 +1,39 @@ +package com.bsd.pjcloud.wallet.api.dto.mch; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Digits; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.math.BigDecimal; + +/** + * 管理端保存商户场景的请求,记录由服务端按商户和场景唯一定位。 + */ +@Data +@Schema(description = "商户场景配置请求") +public class MchSceneDto { + + /** 待配置的商户 ID,必须处于当前登录用户的数据范围内。 */ + @NotNull(message = "商户不能为空") + @Schema(description = "商户ID") + private Long mchId; + + /** 场景持久化编码,首期仅允许 HOTEL_COMMISSION 酒店抽成。 */ + @NotBlank(message = "场景不能为空") + @Schema(description = "场景编码", allowableValues = "HOTEL_COMMISSION") + private String sceneCode; + + /** 是否开启,首期只接受 0 关闭、1 开启。 */ + @NotBlank(message = "是否开启不能为空") + @Schema(description = "是否开启", allowableValues = {"0", "1"}) + private String enabled; + + /** 每单完成酒店订单的固定抽成金额,单位元;缺省保留原金额,首次保存缺省时为 0.00 元。 */ + @DecimalMin(value = "0.00", message = "抽成金额不能小于0元") + @Digits(integer = 8, fraction = 2, message = "抽成金额最多8位整数和2位小数") + @Schema(description = "每单完成酒店订单的固定抽成金额(元)") + private BigDecimal commissionAmount; +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchCheckoutBillEntity.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchCheckoutBillEntity.java new file mode 100644 index 000000000..2a25b644a --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchCheckoutBillEntity.java @@ -0,0 +1,100 @@ +package com.bsd.pjcloud.wallet.api.entity.mch; + +import com.baomidou.mybatisplus.annotation.*; +import com.bsd.pjcloud.common.core.util.TenantTable; +import lombok.Data; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** 商户结账账单,原始财务汇总及归属快照固定保存。 */ +@Data +@TenantTable +@TableName("mch_checkout_bill") +public class MchCheckoutBillEntity { + /** 应用分配的雪花主键。 */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + /** 结账单编号,使用业务前缀和雪花主键。 */ + private String billNo; + + /** 订单业务类型,当前为HOTEL。 */ + private String sourceType; + + /** 发起时的结账关系主键。 */ + private Long relationId; + + /** 发起时的结账关系版本。 */ + private Integer relationVersion; + + /** 发起时的中间方名称快照。 */ + private String intermediaryName; + + /** 离店周期开始日期,包含当天。 */ + private LocalDate periodStart; + + /** 离店周期结束日期,包含当天。 */ + private LocalDate periodEnd; + + /** 金额计算规则版本,当前为1。 */ + private Integer ruleVersion; + + /** 每笔订单抽成,单位分。 */ + private Long feePerOrderFen; + + /** 实际纳入账单的订单笔数。 */ + private Integer orderCount; + + /** 关系全部商户快照数量,含零订单商户。 */ + private Integer merchantCount; + + /** 原始订单金额合计,单位分。 */ + private Long grossAmountFen; + + /** 原始抽成合计,单位分。 */ + private Long feeAmountFen; + + /** 原始商户应付合计,单位分。 */ + private Long payableAmountFen; + + /** WAIT_PAY待付款、PART_PAID部分已付、FINISHED已结清、VOID已作废。 */ + private String state; + + /** 每次账单状态汇总更新时递增的并发版本。 */ + private Integer version; + + /** 创建请求幂等号,租户内唯一。 */ + private String createRequestId; + + /** 规范化创建内容的SHA256摘要。 */ + private String requestHash; + + /** 创建时的预览令牌,供缓存失效后幂等重试匹配。 */ + private String previewToken; + + /** 创建前最近一次酒店源数据复核时间。 */ + private LocalDateTime sourceVerifiedAt; + + /** 本次结账备注,最多500字。 */ + private String remark; + + /** 创建人账号。 */ + @TableField(fill = FieldFill.INSERT) + private String createBy; + + /** 创建时间,服务端时间。 */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** 最后修改人账号。 */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private String updateBy; + + /** 最后修改时间,服务端时间。 */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + /** 所属租户,由平台上下文自动填写。 */ + private Long tenantId; + +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchCheckoutBillEventEntity.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchCheckoutBillEventEntity.java new file mode 100644 index 000000000..68dcafdb7 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchCheckoutBillEventEntity.java @@ -0,0 +1,49 @@ +package com.bsd.pjcloud.wallet.api.entity.mch; + +import com.baomidou.mybatisplus.annotation.*; +import com.bsd.pjcloud.common.core.util.TenantTable; +import lombok.Data; +import java.time.LocalDateTime; + +/** 结账业务追加式审计事件。 */ +@Data +@TenantTable +@TableName("mch_checkout_bill_event") +public class MchCheckoutBillEventEntity { + /** 应用分配的雪花主键。 */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + /** 事件所属账单主键。 */ + private Long billId; + + /** 商户明细主键,整单事件为空。 */ + private Long billMerchantId; + + /** 源订单主键,非订单事件为空。 */ + private Long sourceOrderId; + + /** CREATE、PAY、VOID、RECHECK、DIFF、QUERY_FAILED、HANDLE业务动作。 */ + private String action; + + /** 该动作的请求幂等号或系统事件号。 */ + private String requestId; + + /** 该动作请求摘要,供审计核对。 */ + private String requestHash; + + /** 业务依据和结果JSON,不包含凭据或酒店住客资料。 */ + private String payload; + + /** 创建人账号。 */ + @TableField(fill = FieldFill.INSERT) + private String createBy; + + /** 创建时间,服务端时间。 */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** 所属租户,由平台上下文自动填写。 */ + private Long tenantId; + +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchCheckoutBillMerchantEntity.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchCheckoutBillMerchantEntity.java new file mode 100644 index 000000000..4992b8e92 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchCheckoutBillMerchantEntity.java @@ -0,0 +1,102 @@ +package com.bsd.pjcloud.wallet.api.entity.mch; + +import com.baomidou.mybatisplus.annotation.*; +import com.bsd.pjcloud.common.core.util.TenantTable; +import lombok.Data; +import java.time.LocalDateTime; + +/** 结账商户明细及完整关系权限快照。 */ +@Data +@TenantTable +@TableName("mch_checkout_bill_merchant") +public class MchCheckoutBillMerchantEntity { + /** 应用分配的雪花主键。 */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + /** 所属结账账单主键。 */ + private Long billId; + + /** 绑定商户主键快照。 */ + private Long merchantId; + + /** 绑定商户名称快照。 */ + private String merchantName; + + /** 发起时的商户直属部门,用于历史数据权限。 */ + private Long deptId; + + /** 发起时的部门名称快照。 */ + private String deptName; + + /** 商户创建人快照,用于本人数据权限。 */ + private String merchantCreateBy; + + /** 本次选择的订单笔数,零表示无结账订单。 */ + private Integer orderCount; + + /** 该商户原订单合计,单位分。 */ + private Long grossAmountFen; + + /** 该商户抽成合计,单位分。 */ + private Long feeAmountFen; + + /** 该商户应付,单位分。 */ + private Long payableAmountFen; + + /** NO_ORDERS无订单、WAIT_PAY待付款、PAID已付款或结清、VOID已作废。 */ + private String state; + + /** 付款和作废操作使用的并发版本。 */ + private Integer version; + + /** 实际线下付款时间,零元时为结清时间。 */ + private LocalDateTime paymentTime; + + /** 登记的全额实付,单位分,未付款为空。 */ + private Long paidAmountFen; + + /** 线下付款流水号,与凭证至少提供一种。 */ + private String paymentNo; + + /** 付款凭证地址JSON数组,最多9张。 */ + private String paymentPics; + + /** 付款备注,最多500字。 */ + private String paymentRemark; + + /** 付款请求幂等号。 */ + private String paymentRequestId; + + /** 付款请求规范化内容摘要。 */ + private String paymentRequestHash; + + /** 作废原因,最多500字。 */ + private String voidReason; + + /** 作废请求幂等号。 */ + private String voidRequestId; + + /** 作废请求规范化内容摘要。 */ + private String voidRequestHash; + + /** 创建人账号。 */ + @TableField(fill = FieldFill.INSERT) + private String createBy; + + /** 创建时间,服务端时间。 */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** 最后修改人账号。 */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private String updateBy; + + /** 最后修改时间,服务端时间。 */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + /** 所属租户,由平台上下文自动填写。 */ + private Long tenantId; + +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchCheckoutBillOrderEntity.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchCheckoutBillOrderEntity.java new file mode 100644 index 000000000..99ee0b6dc --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchCheckoutBillOrderEntity.java @@ -0,0 +1,133 @@ +package com.bsd.pjcloud.wallet.api.entity.mch; + +import com.baomidou.mybatisplus.annotation.*; +import com.bsd.pjcloud.common.core.util.TenantTable; +import lombok.Data; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** 结账订单不可变金额快照及后续复核结果。 */ +@Data +@TenantTable +@TableName("mch_checkout_bill_order") +public class MchCheckoutBillOrderEntity { + /** 应用分配的雪花主键。 */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + /** 所属账单主键。 */ + private Long billId; + + /** 所属商户结账明细主键。 */ + private Long billMerchantId; + + /** 归属商户主键快照。 */ + private Long merchantId; + + /** 源业务类型,当前为HOTEL。 */ + private String sourceType; + + /** 酒店本地订单主键,唯一占用身份的一部分。 */ + private Long sourceOrderId; + + /** 唯一解析的酒店主键快照。 */ + private Long hotelId; + + /** 酒店名称快照。 */ + private String hotelName; + + /** 酒店直属归属部门快照。 */ + private Long deptId; + + /** 渠道订单号快照。 */ + private String channelNo; + + /** 上游订单号快照。 */ + private String platformNo; + + /** 源订单入住日期快照。 */ + private LocalDate checkInDate; + + /** 源订单离店日期快照。 */ + private LocalDate checkOutDate; + + /** 房间数量快照,仅供核对。 */ + private Integer rooms; + + /** 该笔订单售价,单位分。 */ + private Long grossAmountFen; + + /** 该笔抽成,单位分。 */ + private Long feeAmountFen; + + /** 该笔商户应付,单位分。 */ + private Long payableAmountFen; + + /** 酒店原始订单状态快照。 */ + private String orderStatus; + + /** 酒店原始预订状态快照。 */ + private String bookStatus; + + /** 酒店原始支付状态快照。 */ + private String payStatus; + + /** 源订单最后更新时间快照。 */ + private LocalDateTime sourceUpdatedAt; + + /** 源金额归属状态等关键事实的SHA256摘要。 */ + private String sourceFingerprint; + + /** 不含住客和联系人信息的酒店事实JSON快照。 */ + private String sourceSnapshot; + + /** 履约完成及收款核对人的管理用户主键。 */ + private Long confirmedUserId; + + /** 履约完成及收款核对人的账号。 */ + private String confirmedBy; + + /** 服务端记录的履约与收款人工确认时间。 */ + private LocalDateTime confirmedAt; + + /** NORMAL一致、DIFF差异、QUERY_FAILED查询失败、HANDLED人工处理。 */ + private String lastCheckStatus; + + /** 最近一次核对的可读结果。 */ + private String lastCheckMessage; + + /** 最近观察到的源事实摘要。 */ + private String lastObservedFingerprint; + + /** 已人工处理的差异摘要,新差异重新提醒。 */ + private String handledFingerprint; + + /** 最近一次复核尝试时间。 */ + private LocalDateTime lastCheckedAt; + + /** 最近一次成功读取源数据时间,失败时保留。 */ + private LocalDateTime lastVerifiedAt; + + /** 下次后台复核时间,作废后为空。 */ + private LocalDateTime nextCheckAt; + + /** 创建人账号。 */ + @TableField(fill = FieldFill.INSERT) + private String createBy; + + /** 创建时间,服务端时间。 */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** 最后修改人账号。 */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private String updateBy; + + /** 最后修改时间,服务端时间。 */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + /** 所属租户,由平台上下文自动填写。 */ + private Long tenantId; + +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchCheckoutOrderClaimEntity.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchCheckoutOrderClaimEntity.java new file mode 100644 index 000000000..a7e302ba6 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchCheckoutOrderClaimEntity.java @@ -0,0 +1,40 @@ +package com.bsd.pjcloud.wallet.api.entity.mch; + +import com.baomidou.mybatisplus.annotation.*; +import com.bsd.pjcloud.common.core.util.TenantTable; +import lombok.Data; +import java.time.LocalDateTime; + +/** 当前有效结账订单占用,作废待付款明细时释放。 */ +@Data +@TenantTable +@TableName("mch_checkout_order_claim") +public class MchCheckoutOrderClaimEntity { + /** 应用分配的雪花主键。 */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + /** 源业务类型,当前为HOTEL。 */ + private String sourceType; + + /** 源订单主键,租户和类型下唯一占用。 */ + private Long sourceOrderId; + + /** 占用账单主键。 */ + private Long billId; + + /** 占用的商户结账明细主键。 */ + private Long billMerchantId; + + /** 创建人账号。 */ + @TableField(fill = FieldFill.INSERT) + private String createBy; + + /** 创建时间,服务端时间。 */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** 所属租户,由平台上下文自动填写。 */ + private Long tenantId; + +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchSceneEntity.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchSceneEntity.java new file mode 100644 index 000000000..f409691c5 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchSceneEntity.java @@ -0,0 +1,78 @@ +package com.bsd.pjcloud.wallet.api.entity.mch; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.activerecord.Model; +import com.bsd.pjcloud.common.core.util.TenantTable; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 商户场景配置,同租户、商户和场景仅保存一条记录,父店与分店独立配置。 + */ +@Data +@TenantTable +@TableName("mch_scene") +@EqualsAndHashCode(callSuper = true) +@Schema(description = "商户场景配置") +public class MchSceneEntity extends Model { + + /** 配置主键,由 MyBatis-Plus 分配雪花 ID。 */ + @TableId(type = IdType.ASSIGN_ID) + @Schema(description = "配置主键") + private Long id; + + /** 配置所属商户 ID,来源于当前用户可访问的商户记录。 */ + @Schema(description = "商户ID") + private Long mchId; + + /** 场景持久化编码,首期使用 HOTEL_COMMISSION 表示酒店抽成。 */ + @Schema(description = "场景编码") + private String sceneCode; + + /** 是否开启该场景,持久化值为 0 关闭、1 开启,默认关闭。 */ + @Schema(description = "是否开启") + private String enabled; + + /** 每单完成酒店订单的固定抽成金额,单位元,最多两位小数,默认 0.00 元。 */ + @Schema(description = "每单完成酒店订单的固定抽成金额(元)") + private BigDecimal commissionAmount; + + /** 创建人账号,由公共字段填充器从登录上下文写入。 */ + @TableField(fill = FieldFill.INSERT) + @Schema(description = "创建人") + private String createBy; + + /** 创建时间,由公共字段填充器按服务端时区写入。 */ + @TableField(fill = FieldFill.INSERT) + @Schema(description = "创建时间") + private LocalDateTime createTime; + + /** 最后修改人账号,由公共字段填充器维护。 */ + @TableField(fill = FieldFill.INSERT_UPDATE) + @Schema(description = "修改人") + private String updateBy; + + /** 最后修改时间,由公共字段填充器按服务端时区维护。 */ + @TableField(fill = FieldFill.INSERT_UPDATE) + @Schema(description = "修改时间") + private LocalDateTime updateTime; + + /** 逻辑删除标记,0 有效、1 已删除,沿用平台通用表规范。 */ + @TableLogic + @TableField(fill = FieldFill.INSERT) + @Schema(description = "逻辑删除标记") + private String delFlag; + + /** 租户 ID,查询隔离和新增赋值由公共租户拦截器维护。 */ + @Schema(description = "租户ID") + private Long tenantId; +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchCheckoutBillCandidateOrderVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchCheckoutBillCandidateOrderVo.java new file mode 100644 index 000000000..8f4223252 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchCheckoutBillCandidateOrderVo.java @@ -0,0 +1,52 @@ +package com.bsd.pjcloud.wallet.api.vo.mch; + +import lombok.Data; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** 候选或预览订单展示,原始源事实仅在钱包内部缓存和复核。 */ +@Data +public class MchCheckoutBillCandidateOrderVo { + /** 酒店本地订单主键。 */ + private Long orderId; + /** 对应关系内商户主键,归属歧义时为空。 */ + private Long merchantId; + /** 商户名称。 */ + private String merchantName; + /** 部门名称。 */ + private String deptName; + /** 酒店主键。 */ + private Long hotelId; + /** 酒店名称。 */ + private String hotelName; + /** 渠道订单号。 */ + private String channelNo; + /** 上游订单号。 */ + private String platformNo; + /** 渠道编码或开放应用标识。 */ + private String sourceLabel; + /** 入住日期。 */ + private LocalDate checkInDate; + /** 离店日期。 */ + private LocalDate checkOutDate; + /** 房间数,仅供展示。 */ + private Integer rooms; + /** 原订单金额,单位分,异常金额为空。 */ + private Long grossAmountFen; + /** 预览每笔抽成,单位分,候选阶段为空。 */ + private Long feeAmountFen; + /** 预览每笔应付,单位分,候选阶段为空。 */ + private Long payableAmountFen; + /** 酒店原始订单状态。 */ + private String orderStatus; + /** 酒店原始支付状态,未知渠道编码保留原值。 */ + private String payStatus; + /** 是否允许加入本次结账。 */ + private boolean selectable; + /** 当前不可选择或待人工核对的原因。 */ + private String message; + /** 是否已被其他有效账单占用。 */ + private boolean claimed; + /** 最近读取的源更新时间,仅供核对。 */ + private LocalDateTime sourceUpdatedAt; +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchCheckoutBillEventVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchCheckoutBillEventVo.java new file mode 100644 index 000000000..321113340 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchCheckoutBillEventVo.java @@ -0,0 +1,36 @@ +package com.bsd.pjcloud.wallet.api.vo.mch; + +import lombok.Data; +import java.time.LocalDateTime; + +/** BillEvent的管理端独立展示模型,金额字段单位均为分。 */ +@Data +public class MchCheckoutBillEventVo { + /** 应用分配的雪花主键。 */ + private Long id; + + /** 事件所属账单主键。 */ + private Long billId; + + /** 商户明细主键,整单事件为空。 */ + private Long billMerchantId; + + /** 源订单主键,非订单事件为空。 */ + private Long sourceOrderId; + + /** CREATE、PAY、VOID、RECHECK、DIFF、QUERY_FAILED、HANDLE业务动作。 */ + private String action; + + /** 该动作的请求幂等号或系统事件号。 */ + private String requestId; + + /** 业务依据和结果JSON,不包含凭据或酒店住客资料。 */ + private String payload; + + /** 创建人账号。 */ + private String createBy; + + /** 创建时间,服务端时间。 */ + private LocalDateTime createTime; + +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchCheckoutBillMerchantVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchCheckoutBillMerchantVo.java new file mode 100644 index 000000000..1520f8735 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchCheckoutBillMerchantVo.java @@ -0,0 +1,72 @@ +package com.bsd.pjcloud.wallet.api.vo.mch; + +import lombok.Data; +import java.time.LocalDateTime; + +/** BillMerchant的管理端独立展示模型,金额字段单位均为分。 */ +@Data +public class MchCheckoutBillMerchantVo { + /** 应用分配的雪花主键。 */ + private Long id; + + /** 所属结账账单主键。 */ + private Long billId; + + /** 绑定商户主键快照。 */ + private Long merchantId; + + /** 绑定商户名称快照。 */ + private String merchantName; + + /** 发起时的商户直属部门,用于历史数据权限。 */ + private Long deptId; + + /** 发起时的部门名称快照。 */ + private String deptName; + + /** 本次选择的订单笔数,零表示无结账订单。 */ + private Integer orderCount; + + /** 该商户原订单合计,单位分。 */ + private Long grossAmountFen; + + /** 该商户抽成合计,单位分。 */ + private Long feeAmountFen; + + /** 该商户应付,单位分。 */ + private Long payableAmountFen; + + /** NO_ORDERS无订单、WAIT_PAY待付款、PAID已付款或结清、VOID已作废。 */ + private String state; + + /** 付款和作废操作使用的并发版本。 */ + private Integer version; + + /** 实际线下付款时间,零元时为结清时间。 */ + private LocalDateTime paymentTime; + + /** 登记的全额实付,单位分,未付款为空。 */ + private Long paidAmountFen; + + /** 线下付款流水号,与凭证至少提供一种。 */ + private String paymentNo; + + /** 付款凭证地址JSON数组,最多9张。 */ + private String paymentPics; + + /** 付款备注,最多500字。 */ + private String paymentRemark; + + /** 作废原因,最多500字。 */ + private String voidReason; + + /** 创建人账号。 */ + private String createBy; + + /** 创建时间,服务端时间。 */ + private LocalDateTime createTime; + + /** 最后修改时间,服务端时间。 */ + private LocalDateTime updateTime; + +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchCheckoutBillOrderVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchCheckoutBillOrderVo.java new file mode 100644 index 000000000..7d5af23d8 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchCheckoutBillOrderVo.java @@ -0,0 +1,109 @@ +package com.bsd.pjcloud.wallet.api.vo.mch; + +import lombok.Data; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** BillOrder的管理端独立展示模型,金额字段单位均为分。 */ +@Data +public class MchCheckoutBillOrderVo { + /** 当前商户结账状态,用于区分作废历史与可处理的已付款差异。 */ + private String merchantState; + + /** 应用分配的雪花主键。 */ + private Long id; + + /** 所属账单主键。 */ + private Long billId; + + /** 所属商户结账明细主键。 */ + private Long billMerchantId; + + /** 归属商户主键快照。 */ + private Long merchantId; + + /** 源业务类型,当前为HOTEL。 */ + private String sourceType; + + /** 酒店本地订单主键,唯一占用身份的一部分。 */ + private Long sourceOrderId; + + /** 唯一解析的酒店主键快照。 */ + private Long hotelId; + + /** 酒店名称快照。 */ + private String hotelName; + + /** 酒店直属归属部门快照。 */ + private Long deptId; + + /** 渠道订单号快照。 */ + private String channelNo; + + /** 上游订单号快照。 */ + private String platformNo; + + /** 源订单入住日期快照。 */ + private LocalDate checkInDate; + + /** 源订单离店日期快照。 */ + private LocalDate checkOutDate; + + /** 房间数量快照,仅供核对。 */ + private Integer rooms; + + /** 该笔订单售价,单位分。 */ + private Long grossAmountFen; + + /** 该笔抽成,单位分。 */ + private Long feeAmountFen; + + /** 该笔商户应付,单位分。 */ + private Long payableAmountFen; + + /** 酒店原始订单状态快照。 */ + private String orderStatus; + + /** 酒店原始预订状态快照。 */ + private String bookStatus; + + /** 酒店原始支付状态快照。 */ + private String payStatus; + + /** 源订单最后更新时间快照。 */ + private LocalDateTime sourceUpdatedAt; + + /** 履约完成及收款核对人的账号。 */ + private String confirmedBy; + + /** 服务端记录的履约与收款人工确认时间。 */ + private LocalDateTime confirmedAt; + + /** NORMAL一致、DIFF差异、QUERY_FAILED查询失败、HANDLED人工处理。 */ + private String lastCheckStatus; + + /** 最近一次核对的可读结果。 */ + private String lastCheckMessage; + + /** 最近观察到的源事实摘要。 */ + private String lastObservedFingerprint; + + /** 最近一次复核尝试时间。 */ + private LocalDateTime lastCheckedAt; + + /** 最近一次成功读取源数据时间,失败时保留。 */ + private LocalDateTime lastVerifiedAt; + + /** 下次后台复核时间,作废后为空。 */ + private LocalDateTime nextCheckAt; + + /** 创建人账号。 */ + private String createBy; + + /** 创建时间,服务端时间。 */ + private LocalDateTime createTime; + + /** 最后修改时间,服务端时间。 */ + private LocalDateTime updateTime; + +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchCheckoutBillSnapshotVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchCheckoutBillSnapshotVo.java new file mode 100644 index 000000000..53e44836f --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchCheckoutBillSnapshotVo.java @@ -0,0 +1,43 @@ +package com.bsd.pjcloud.wallet.api.vo.mch; + +import lombok.Data; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** 当前用户的临时候选或确认预览概要,10分钟后需重新核对。 */ +@Data +public class MchCheckoutBillSnapshotVo { + /** 临时候选或预览令牌,服务端校验所属用户。 */ + private String token; + /** 结账关系主键。 */ + private Long relationId; + /** 结账关系版本。 */ + private Integer relationVersion; + /** 中间方名称。 */ + private String intermediaryName; + /** 离店周期起点。 */ + private LocalDate periodStart; + /** 离店周期终点。 */ + private LocalDate periodEnd; + /** 令牌过期时间。 */ + private LocalDateTime expiresAt; + /** 本次源数据读取或复核时间。 */ + private LocalDateTime sourceVerifiedAt; + /** 全部订单数量,候选含异常和已占用订单。 */ + private Integer orderCount; + /** 候选可加入订单数量。 */ + private Integer selectableCount; + /** 全部关系商户快照,候选金额为零,预览包含所选订单汇总。 */ + private List merchants; + /** 原订单合计,单位分,候选阶段为零。 */ + private Long grossAmountFen; + /** 每单抽成,单位分,候选阶段为零。 */ + private Long feePerOrderFen; + /** 抽成合计,单位分。 */ + private Long feeAmountFen; + /** 商户应付合计,单位分。 */ + private Long payableAmountFen; + /** 本次结账备注。 */ + private String remark; +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchCheckoutBillVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchCheckoutBillVo.java new file mode 100644 index 000000000..13ef23ac5 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchCheckoutBillVo.java @@ -0,0 +1,93 @@ +package com.bsd.pjcloud.wallet.api.vo.mch; + +import lombok.Data; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** Bill的管理端独立展示模型,金额字段单位均为分。 */ +@Data +public class MchCheckoutBillVo { + /** 应用分配的雪花主键。 */ + private Long id; + + /** 结账单编号,使用业务前缀和雪花主键。 */ + private String billNo; + + /** 订单业务类型,当前为HOTEL。 */ + private String sourceType; + + /** 发起时的结账关系主键。 */ + private Long relationId; + + /** 发起时的结账关系版本。 */ + private Integer relationVersion; + + /** 发起时的中间方名称快照。 */ + private String intermediaryName; + + /** 离店周期开始日期,包含当天。 */ + private LocalDate periodStart; + + /** 离店周期结束日期,包含当天。 */ + private LocalDate periodEnd; + + /** 金额计算规则版本,当前为1。 */ + private Integer ruleVersion; + + /** 每笔订单抽成,单位分。 */ + private Long feePerOrderFen; + + /** 实际纳入账单的订单笔数。 */ + private Integer orderCount; + + /** 关系全部商户快照数量,含零订单商户。 */ + private Integer merchantCount; + + /** 原始订单金额合计,单位分。 */ + private Long grossAmountFen; + + /** 原始抽成合计,单位分。 */ + private Long feeAmountFen; + + /** 原始商户应付合计,单位分。 */ + private Long payableAmountFen; + + /** WAIT_PAY待付款、PART_PAID部分已付、FINISHED已结清、VOID已作废。 */ + private String state; + + /** 每次账单状态汇总更新时递增的并发版本。 */ + private Integer version; + + /** 创建前最近一次酒店源数据复核时间。 */ + private LocalDateTime sourceVerifiedAt; + + /** 本次结账备注,最多500字。 */ + private String remark; + + /** 创建人账号。 */ + private String createBy; + + /** 创建时间,服务端时间。 */ + private LocalDateTime createTime; + + /** 最后修改时间,服务端时间。 */ + private LocalDateTime updateTime; + + /** 排除作废后的有效应付合计,单位分。 */ + private Long effectivePayableAmountFen; + + /** 已登记付款或结清的金额合计,单位分。 */ + private Long paidAmountFen; + + /** 待付款商户的应付合计,单位分。 */ + private Long pendingAmountFen; + + /** 已作废商户的原应付合计,单位分。 */ + private Long voidAmountFen; + + /** 当前已作废的商户明细数量。 */ + private Integer voidMerchantCount; + + /** 当前未处理差异或查询失败的订单数量。 */ + private Integer anomalyCount; +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchSceneVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchSceneVo.java new file mode 100644 index 000000000..0ac7bdfe7 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchSceneVo.java @@ -0,0 +1,34 @@ +package com.bsd.pjcloud.wallet.api.vo.mch; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; + +/** + * 管理端查询和保存后的商户场景配置响应。 + */ +@Data +@Schema(description = "商户场景配置响应") +public class MchSceneVo { + + /** 服务端分配的场景配置主键。 */ + @Schema(description = "配置主键") + private Long id; + + /** 独立配置的商户 ID,父店与分店使用各自的 ID。 */ + @Schema(description = "商户ID") + private Long mchId; + + /** 场景持久化编码,HOTEL_COMMISSION 表示酒店抽成。 */ + @Schema(description = "场景编码") + private String sceneCode; + + /** 当前场景开关,0 关闭、1 开启。 */ + @Schema(description = "是否开启") + private String enabled; + + /** 每单完成酒店订单的固定抽成金额,单位元,最多两位小数,0 表示金额为零。 */ + @Schema(description = "每单完成酒店订单的固定抽成金额(元)") + private BigDecimal commissionAmount; +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/pom.xml b/pjcloud-wallet/pjcloud-wallet-biz/pom.xml index 27b854d32..9ef3f208d 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/pom.xml +++ b/pjcloud-wallet/pjcloud-wallet-biz/pom.xml @@ -78,6 +78,12 @@ pjcloud-hardware-api 5.7.0 + + + com.bsd + pjcloud-supermarket-api + 5.7.0 + com.bsd diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchCheckoutBillController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchCheckoutBillController.java new file mode 100644 index 000000000..4454ada7b --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchCheckoutBillController.java @@ -0,0 +1,163 @@ +package com.bsd.pjcloud.wallet.controller.mch; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.log.annotation.SysLog; +import com.bsd.pjcloud.wallet.api.dto.mch.MchCheckoutBillRequests; +import com.bsd.pjcloud.wallet.api.vo.mch.*; +import com.bsd.pjcloud.wallet.service.*; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import lombok.RequiredArgsConstructor; +import io.swagger.v3.oas.annotations.Operation; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import java.util.List; + +/** 钱包商户结账管理入口,功能权限和完整数据权限分别终审。 */ +@Validated +@RestController +@RequiredArgsConstructor +@RequestMapping("/mch-checkout-bill") +@Tag(name = "商户结账账单") +public class MchCheckoutBillController { + /** 账单事务和历史查询。 */ + private final MchCheckoutBillService bills; + /** 酒店只读候选及金额预览。 */ + private final MchCheckoutBillPreviewService previews; + /** 源事实复核及差异持久化。 */ + private final MchCheckoutBillRecheckService rechecks; + + /** 按完整关系查询酒店候选,失败不能作为空结果。 */ + @Operation(summary = "按完整关系查询酒店候选,失败不能作为空结果") + @PostMapping("/hotel/candidates") + @PreAuthorize("@pms.hasPermission('wallet_mchCheckoutBill_add')") + public R candidates(@Valid @RequestBody MchCheckoutBillRequests.Candidate request) { + return R.ok(previews.candidates(request)); + } + + /** 当前用户候选工作集内筛选后分页,显示不可选原因。 */ + @Operation(summary = "当前用户候选工作集内筛选后分页,显示不可选原因") + @GetMapping("/hotel/candidates/{token}/orders") + @PreAuthorize("@pms.hasPermission('wallet_mchCheckoutBill_add')") + public R> candidateOrders(@PathVariable String token, @ParameterObject Page page, + @RequestParam(required = false) Long merchantId, @RequestParam(required = false) String keyword, @RequestParam(defaultValue = "false") boolean selectableOnly) { + return R.ok(previews.orders(token, false, page, merchantId, keyword, selectableOnly)); + } + + /** 返回筛选范围完整可选主键,用于明确的跨页批量加入。 */ + @Operation(summary = "返回筛选范围完整可选主键,用于明确的跨页批量加入") + @GetMapping("/hotel/candidates/{token}/selectable-ids") + @PreAuthorize("@pms.hasPermission('wallet_mchCheckoutBill_add')") + public R> selectableIds(@PathVariable String token, @RequestParam(required = false) Long merchantId, @RequestParam(required = false) String keyword) { + return R.ok(previews.selectableIds(token, merchantId, keyword)); + } + + /** 复核明确名单及人工确认后计算逐商户和逐单金额。 */ + @Operation(summary = "复核明确名单及人工确认后计算逐商户和逐单金额") + @PostMapping("/hotel/preview") + @PreAuthorize("@pms.hasPermission('wallet_mchCheckoutBill_add')") + public R preview(@Valid @RequestBody MchCheckoutBillRequests.Preview request) { + return R.ok(previews.preview(request)); + } + + /** 按当前用户有效预览分页读取完整商户金额。 */ + @Operation(summary = "按当前用户有效预览分页读取完整商户金额") + @GetMapping("/preview/{token}/merchants") + @PreAuthorize("@pms.hasPermission('wallet_mchCheckoutBill_add')") + public R> previewMerchants(@PathVariable String token, @ParameterObject Page page) { + return R.ok(previews.merchants(token, page)); + } + + /** 按当前用户有效预览分页读取逐单抽成和应付。 */ + @Operation(summary = "按当前用户有效预览分页读取逐单抽成和应付") + @GetMapping("/preview/{token}/orders") + @PreAuthorize("@pms.hasPermission('wallet_mchCheckoutBill_add')") + public R> previewOrders(@PathVariable String token, @ParameterObject Page page, @RequestParam(required = false) Long merchantId) { + return R.ok(previews.orders(token, true, page, merchantId, null, false)); + } + + /** 创建本地完整账单与唯一占用,响应重试按请求号恢复。 */ + @Operation(summary = "创建本地完整账单与唯一占用,响应重试按请求号恢复") + @PostMapping("/hotel") @SysLog("发起酒店商户结账") + @PreAuthorize("@pms.hasPermission('wallet_mchCheckoutBill_add')") + public R create(@Valid @RequestBody MchCheckoutBillRequests.Create request) { + return R.ok(bills.create(request)); + } + + /** 账单列表在分页之前过滤整组历史快照权限。 */ + @Operation(summary = "账单列表在分页之前过滤整组历史快照权限") + @GetMapping("/page") + @PreAuthorize("@pms.hasPermission('wallet_mchCheckoutBill_view')") + public R> page(@ParameterObject Page page, @Valid @ParameterObject MchCheckoutBillRequests.ListQuery query) { + return R.ok(bills.page(page, query)); + } + + /** 读取历史账单原始金额及有效应付、实付和异常数量。 */ + @Operation(summary = "读取历史账单原始金额及有效应付、实付和异常数量") + @GetMapping("/{id}") + @PreAuthorize("@pms.hasPermission('wallet_mchCheckoutBill_view')") + public R detail(@PathVariable @Positive Long id) { + return R.ok(bills.detail(id)); + } + + /** 查询账单商户明细,含无订单商户快照。 */ + @Operation(summary = "查询账单商户明细,含无订单商户快照") + @GetMapping("/{id}/merchants") + @PreAuthorize("@pms.hasPermission('wallet_mchCheckoutBill_view')") + public R> merchants(@PathVariable @Positive Long id, @ParameterObject Page page) { + return R.ok(bills.merchants(id, page)); + } + + /** 查询逐单金额和后续复核状态。 */ + @Operation(summary = "查询逐单金额和后续复核状态") + @GetMapping("/{id}/orders") + @PreAuthorize("@pms.hasPermission('wallet_mchCheckoutBill_view')") + public R> orders(@PathVariable @Positive Long id, @ParameterObject Page page, + @RequestParam(required = false) Long merchantId, @RequestParam(defaultValue = "false") boolean anomaliesOnly) { + return R.ok(bills.orders(id, merchantId, anomaliesOnly, page)); + } + + /** 查询业务追加式审计轨迹。 */ + @Operation(summary = "查询业务追加式审计轨迹") + @GetMapping("/{id}/events") + @PreAuthorize("@pms.hasPermission('wallet_mchCheckoutBill_view')") + public R> events(@PathVariable @Positive Long id, @ParameterObject Page page) { + return R.ok(bills.events(id, page)); + } + + /** 复核后登记指定商户全额付款或零元结清。 */ + @Operation(summary = "复核后登记指定商户全额付款或零元结清") + @PostMapping("/{id}/merchants/{merchantId}/confirm-payment") @SysLog("登记商户结账付款") + @PreAuthorize("@pms.hasPermission('wallet_mchCheckoutBill_pay')") + public R pay(@PathVariable @Positive Long id, @PathVariable @Positive Long merchantId, @Valid @RequestBody MchCheckoutBillRequests.Payment request) { + return R.ok(bills.pay(id, merchantId, request)); + } + + /** 作废指定待付款商户明细并释放它的占用。 */ + @Operation(summary = "作废指定待付款商户明细并释放它的占用") + @PostMapping("/{id}/merchants/{merchantId}/void") @SysLog("作废商户结账明细") + @PreAuthorize("@pms.hasPermission('wallet_mchCheckoutBill_void')") + public R voidMerchant(@PathVariable @Positive Long id, @PathVariable @Positive Long merchantId, @Valid @RequestBody MchCheckoutBillRequests.VoidMerchant request) { + return R.ok(bills.voidMerchant(id, merchantId, request)); + } + + /** 人工重新读取源事实并持久化差异。 */ + @Operation(summary = "人工重新读取源事实并持久化差异") + @PostMapping("/{id}/recheck") @SysLog("复核商户结账订单") + @PreAuthorize("@pms.hasPermission('wallet_mchCheckoutBill_recheck')") + public R recheck(@PathVariable @Positive Long id) { + return R.ok(rechecks.recheck(id)); + } + + /** 已付款差异登记处理依据,原始账单金额保留。 */ + @Operation(summary = "已付款差异登记处理依据,原始账单金额保留") + @PostMapping("/{id}/orders/{orderId}/handle") @SysLog("处理商户结账差异") + @PreAuthorize("@pms.hasPermission('wallet_mchCheckoutBill_handle')") + public R handle(@PathVariable @Positive Long id, @PathVariable @Positive Long orderId, @Valid @RequestBody MchCheckoutBillRequests.Handle request) { + return R.ok(bills.handle(id, orderId, request)); + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchSceneController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchSceneController.java new file mode 100644 index 000000000..e069aec32 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchSceneController.java @@ -0,0 +1,63 @@ +package com.bsd.pjcloud.wallet.controller.mch; + +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.log.annotation.SysLog; +import com.bsd.pjcloud.wallet.api.dto.mch.MchSceneDto; +import com.bsd.pjcloud.wallet.api.vo.mch.MchSceneVo; +import com.bsd.pjcloud.wallet.service.MchSceneService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 管理端商户场景配置接口,读写共用既有商户设置权限。 + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/mch-scene") +@Tag(description = "mch-scene", name = "商户场景配置") +@SecurityRequirement(name = HttpHeaders.AUTHORIZATION) +public class MchSceneController { + + /** 商户场景服务,负责商户数据范围和配置保存规则。 */ + private final MchSceneService sceneService; + + /** + * 查询当前用户可访问商户的指定场景配置。 + * + * @param mchId 商户 ID + * @param sceneCode 场景持久化编码 + * @return 商户场景配置,未配置时返回空数据 + */ + @GetMapping("/mch/{mchId}") + @Operation(summary = "按商户查询场景配置") + @PreAuthorize("@pms.hasPermission('wallet_mchManage_setting')") + public R getForManagement(@PathVariable Long mchId, @RequestParam String sceneCode) { + return R.ok(sceneService.getForManagement(mchId, sceneCode)); + } + + /** + * 按商户和场景新增或更新配置,记录主键及租户信息由服务端维护。 + * + * @param dto 待保存的商户场景开关及固定抽成金额 + * @return 保存后的配置 + */ + @PostMapping + @Operation(summary = "保存商户场景配置") + @SysLog("保存商户场景配置") + @PreAuthorize("@pms.hasPermission('wallet_mchManage_setting')") + public R saveOrUpdate(@Valid @RequestBody MchSceneDto dto) { + return R.ok(sceneService.saveOrUpdateConfig(dto)); + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/job/MchCheckoutBillRecheckJob.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/job/MchCheckoutBillRecheckJob.java new file mode 100644 index 000000000..de720c523 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/job/MchCheckoutBillRecheckJob.java @@ -0,0 +1,62 @@ +package com.bsd.pjcloud.wallet.job; + +import com.bsd.pjcloud.admin.api.entity.SysTenant; +import com.bsd.pjcloud.admin.api.feign.RemoteTenantService; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.data.tenant.TenantBroker; +import com.bsd.pjcloud.wallet.service.MchCheckoutBillRecheckService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import java.time.Duration; +import java.util.*; + +/** 钱包侧酒店账单分批复核,按现有租户上下文切换,历史已付款订单持续参与。 */ +@Slf4j +@Component +@RequiredArgsConstructor +public class MchCheckoutBillRecheckJob { + /** 自动租户命名空间内的后台互斥业务Key。 */ + private static final String LOCK_KEY = "mch-checkout:hotel:recheck-job"; + /** 按持有者释放,避免误删过期后其他实例持有的锁。 */ + private static final DefaultRedisScript RELEASE = new DefaultRedisScript<>("if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end", Long.class); + /** 现有有效租户查询。 */ + private final RemoteTenantService tenants; + /** 自动租户隔离的Redis组件。 */ + private final StringRedisTemplate redis; + /** 单租户最大500条的复核能力。 */ + private final MchCheckoutBillRecheckService rechecks; + + /** 每分钟扫描一次,各租户内最多处理500条到期订单。 */ + @Scheduled(fixedDelay = 60000L, initialDelay = 60000L) + public void process() { + try { + R> response = tenants.list(); + if (response == null || !response.isOk() || response.getData() == null) throw new IllegalStateException("结账复核租户目录读取失败"); + for (SysTenant tenant : response.getData()) { + try { + if (tenant == null || tenant.getId() == null || tenant.getId() <= 0) throw new IllegalStateException("结账复核租户身份无效"); + TenantBroker.runAs(tenant.getId(), ignored -> processTenant()); + } catch (RuntimeException exception) { + log.error("结账后台复核租户执行失败", exception); + } + } + } catch (RuntimeException exception) { + log.error("结账后台复核扫描失败", exception); + } + } + + /** 使用带过期和持有者校验的锁防止同租户多实例重复扫描。 */ + private void processTenant() { + String owner = UUID.randomUUID().toString(); + if (!Boolean.TRUE.equals(redis.opsForValue().setIfAbsent(LOCK_KEY, owner, Duration.ofMinutes(5)))) return; + try { + rechecks.checkDue(); + } finally { + redis.execute(RELEASE, List.of(LOCK_KEY), owner); + } + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletManageMapper.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletManageMapper.java index c87e7ec5c..1d0a0529f 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletManageMapper.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletManageMapper.java @@ -4,6 +4,7 @@ import cn.hutool.core.util.ObjectUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.toolkit.Constants; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.bsd.pjcloud.common.core.util.BeanUtils; import com.bsd.pjcloud.common.data.datascope.DataScope; @@ -20,6 +21,7 @@ import com.github.yulichang.wrapper.MPJLambdaWrapper; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; +import java.time.LocalDateTime; import java.util.List; @Mapper @@ -202,6 +204,34 @@ public interface WalletManageMapper extends PjcloudBaseMapper scopeQbBalancePage(Page page, WalletManageDto dto, LocalDateTime monthEndExclusive, DataScope dataScope) { + LambdaQueryWrapper wrapper = commonQuery(dto); + // 仅清除本次包装器的默认顺序,由分页插件在数据权限外层应用余额排序。 + wrapper.getExpression().getOrderBy().clear(); + return selectQbBalancePage(page, dto, monthEndExclusive, wrapper, dataScope); + } + + /** + * 单次窗口计算用户与钱包类型的月末最新流水,再按钱包筛选、权限及分页参数返回记录。 + * + * @param page 可信排序及分页参数,调用方负责独立统计钱包总数 + * @param dto 身份预筛选后的条件,用于提前缩小流水的用户和钱包类型范围 + * @param monthEndExclusive 流水时间的排他上界 + * @param wrapper 仅由 commonQuery 生成、已清除默认排序的钱包筛选条件 + * @param dataScope 由统一拦截器应用的数据权限范围 + * @return 每个钱包最多一行的月末余额分页,不修改钱包或流水数据 + */ + Page selectQbBalancePage(Page page, @Param("dto") WalletManageDto dto, @Param("monthEndExclusive") LocalDateTime monthEndExclusive, @Param(Constants.WRAPPER) LambdaQueryWrapper wrapper, @Param("dataScope") DataScope dataScope); + default Long count(WalletManageDto dto, DataScope dataScope) { return selectCountByScope(commonQuery(dto), dataScope); } @@ -215,7 +245,14 @@ public interface WalletManageMapper extends PjcloudBaseMapper scopeUserList(@Param("dto") WalletManageDto dto, @Param("dataScope") DataScope dataScope); - List selectBalance(@Param("dto") WalletManageBalanceDto walletDto); + /** + * 查询指定用户和钱包类型在月末之前的最新有效流水余额,与全量余额排序共用取值规则。 + * + * @param walletDto 用户及钱包类型筛选条件,通常限定为当前页用户 + * @param monthEndExclusive 统计月份下月首日零点,按流水时间和主键倒序选择唯一最新记录 + * @return 每个用户与钱包类型最多一条余额记录;无有效流水时不返回记录 + */ + List selectBalance(@Param("dto") WalletManageBalanceDto walletDto, @Param("monthEndExclusive") LocalDateTime monthEndExclusive); Page activeBalancePage(Page page, @Param("dto") WalletTopupActionDto dto, @Param("unactiveDayNumberStr") String unactiveDayNumberStr); diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/mch/MchCheckoutBillEventMapper.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/mch/MchCheckoutBillEventMapper.java new file mode 100644 index 000000000..5dd0e75b9 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/mch/MchCheckoutBillEventMapper.java @@ -0,0 +1,10 @@ +package com.bsd.pjcloud.wallet.mapper.mch; + +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import com.bsd.pjcloud.wallet.api.entity.mch.MchCheckoutBillEventEntity; +import org.apache.ibatis.annotations.Mapper; + +/** BillEvent数据访问,全部SQL通过平台自动隔离租户。 */ +@Mapper +public interface MchCheckoutBillEventMapper extends PjcloudBaseMapper { +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/mch/MchCheckoutBillMapper.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/mch/MchCheckoutBillMapper.java new file mode 100644 index 000000000..0d093d12c --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/mch/MchCheckoutBillMapper.java @@ -0,0 +1,21 @@ +package com.bsd.pjcloud.wallet.mapper.mch; + +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import com.bsd.pjcloud.wallet.api.entity.mch.MchCheckoutBillEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.wallet.api.dto.mch.MchCheckoutBillRequests; +import com.bsd.pjcloud.wallet.api.vo.mch.MchCheckoutBillVo; +import com.bsd.pjcloud.wallet.support.mch.MchCheckoutRelationScope; + +/** Bill数据访问,全部SQL通过平台自动隔离租户。 */ +@Mapper +public interface MchCheckoutBillMapper extends PjcloudBaseMapper { + /** 在分页前按完整商户权限快照过滤,按创建时间和主键倒序。 */ + Page pageVisible(Page page, @Param("query") MchCheckoutBillRequests.ListQuery query, @Param("scope") MchCheckoutRelationScope scope); + /** 读取整单可见的历史账单及派生金额,无权和不存在统一返回null。 */ + MchCheckoutBillVo findVisible(@Param("id") Long id, @Param("scope") MchCheckoutRelationScope scope); + /** 锁定账单主体,付款、作废及异常登记统一先锁主体。 */ + MchCheckoutBillEntity lockBill(@Param("id") Long id); +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/mch/MchCheckoutBillMerchantMapper.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/mch/MchCheckoutBillMerchantMapper.java new file mode 100644 index 000000000..0278667b8 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/mch/MchCheckoutBillMerchantMapper.java @@ -0,0 +1,18 @@ +package com.bsd.pjcloud.wallet.mapper.mch; + +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import com.bsd.pjcloud.wallet.api.entity.mch.MchCheckoutBillMerchantEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import java.util.List; + +/** BillMerchant数据访问,全部SQL通过平台自动隔离租户。 */ +@Mapper +public interface MchCheckoutBillMerchantMapper extends PjcloudBaseMapper { + /** 锁定账单下指定商户明细,唯一键保证单行。 */ + MchCheckoutBillMerchantEntity lockMerchant(@Param("billId") Long billId, @Param("merchantId") Long merchantId); + /** 查找参与关系绑定的部门归属歧义,返回首个部门ID,空表示唯一。 */ + Long ambiguousDepartment(@Param("deptIds") List deptIds); + /** 同一本地事务内批量插入明确行,单批最多500行,租户列由平台逐行补充。 */ + int insertRows(@Param("rows") List rows); +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/mch/MchCheckoutBillOrderMapper.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/mch/MchCheckoutBillOrderMapper.java new file mode 100644 index 000000000..c5914dc8c --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/mch/MchCheckoutBillOrderMapper.java @@ -0,0 +1,16 @@ +package com.bsd.pjcloud.wallet.mapper.mch; + +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import com.bsd.pjcloud.wallet.api.entity.mch.MchCheckoutBillOrderEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import java.util.List; + +/** BillOrder数据访问,全部SQL通过平台自动隔离租户。 */ +@Mapper +public interface MchCheckoutBillOrderMapper extends PjcloudBaseMapper { + /** 在主体锁之后锁定一条账单订单,用于差异处理及状态更新。 */ + MchCheckoutBillOrderEntity lockOrder(@Param("billId") Long billId, @Param("id") Long id); + /** 同一本地事务内批量插入明确行,单批最多500行,租户列由平台逐行补充。 */ + int insertRows(@Param("rows") List rows); +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/mch/MchCheckoutOrderClaimMapper.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/mch/MchCheckoutOrderClaimMapper.java new file mode 100644 index 000000000..4e9eaa4c8 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/mch/MchCheckoutOrderClaimMapper.java @@ -0,0 +1,14 @@ +package com.bsd.pjcloud.wallet.mapper.mch; + +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import com.bsd.pjcloud.wallet.api.entity.mch.MchCheckoutOrderClaimEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import java.util.List; + +/** OrderClaim数据访问,全部SQL通过平台自动隔离租户。 */ +@Mapper +public interface MchCheckoutOrderClaimMapper extends PjcloudBaseMapper { + /** 同一本地事务内批量插入明确行,单批最多500行,租户列由平台逐行补充。 */ + int insertRows(@Param("rows") List rows); +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/mch/MchSceneMapper.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/mch/MchSceneMapper.java new file mode 100644 index 000000000..4e84f88c4 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/mch/MchSceneMapper.java @@ -0,0 +1,26 @@ +package com.bsd.pjcloud.wallet.mapper.mch; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import com.bsd.pjcloud.wallet.api.entity.mch.MchSceneEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 商户场景 Mapper,复用公共租户拦截器和逻辑删除规则。 + */ +@Mapper +public interface MchSceneMapper extends PjcloudBaseMapper { + + /** + * 按商户和场景查询当前租户的有效配置,唯一键保证最多返回一条。 + * + * @param mchId 商户 ID + * @param sceneCode 场景持久化编码 + * @return 匹配配置,未配置或已逻辑删除时返回空 + */ + default MchSceneEntity selectExact(Long mchId, String sceneCode) { + return selectOne(new LambdaQueryWrapper() + .eq(MchSceneEntity::getMchId, mchId) + .eq(MchSceneEntity::getSceneCode, sceneCode)); + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchCheckoutBillPreviewService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchCheckoutBillPreviewService.java new file mode 100644 index 000000000..a80368523 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchCheckoutBillPreviewService.java @@ -0,0 +1,216 @@ +package com.bsd.pjcloud.wallet.service; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.supermarket.vo.SmHotelOrderCheckoutQueryVo; +import com.bsd.pjcloud.wallet.api.dto.mch.MchCheckoutBillRequests; +import com.bsd.pjcloud.wallet.api.entity.mch.*; +import com.bsd.pjcloud.wallet.api.vo.mch.*; +import com.bsd.pjcloud.wallet.mapper.mch.MchCheckoutOrderClaimMapper; +import com.bsd.pjcloud.wallet.support.mch.*; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.BeanUtils; +import org.springframework.stereotype.Service; +import java.time.LocalDateTime; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** 候选与确认预览服务,完整名单先计算后分页,临时预览不占用订单。 */ +@Service +@RequiredArgsConstructor +public class MchCheckoutBillPreviewService { + /** 完整关系及商户部门快照。 */ + private final MchCheckoutBillContext context; + /** 本人临时工作集。 */ + private final MchCheckoutBillCache cache; + /** 酒店只读事实适配。 */ + private final MchCheckoutBillHotelSource source; + /** 当前有效结账占用,预览时只读。 */ + private final MchCheckoutOrderClaimMapper claims; + + /** 完整读取周期候选,包含不可选项,再提供分页。 */ + public MchCheckoutBillSnapshotVo candidates(MchCheckoutBillRequests.Candidate request) { + if (request.getPeriodStart().isAfter(request.getPeriodEnd())) throw new IllegalArgumentException("离店周期起止不合法"); + MchCheckoutBillSnapshot snapshot = context.read(request.getRelationId(), request.getRelationVersion()); + snapshot.setPeriodStart(request.getPeriodStart()); + snapshot.setPeriodEnd(request.getPeriodEnd()); + snapshot.setFeePerOrderFen(0L); + snapshot.setOrders(source.scan(snapshot.getMerchants().stream().map(MchCheckoutBillMerchantEntity::getDeptId).toList(), request.getPeriodStart(), request.getPeriodEnd())); + snapshot.setSourceVerifiedAt(LocalDateTime.now()); + snapshot.setPreview(false); + cache.save(snapshot); + return summary(snapshot); + } + + /** 逐项复核所选候选和人工确认,生成可提交的金额及归属快照。 */ + public MchCheckoutBillSnapshotVo preview(MchCheckoutBillRequests.Preview request) { + if (!Boolean.TRUE.equals(request.getFulfillmentConfirmed()) || !Boolean.TRUE.equals(request.getReceiptConfirmed())) throw new IllegalArgumentException("请明确确认所选订单已履约完成且款项已核对"); + MchCheckoutBillSnapshot original = require(request.getCandidateToken(), false); + List ids = request.getOrderIds().stream().distinct().sorted().toList(); + if (ids.isEmpty() || ids.size() != request.getOrderIds().size() || ids.size() > MchCheckoutBillHotelSource.MAX_ORDERS) throw new IllegalArgumentException("结账订单名单为空、重复或超过10000笔"); + Map candidateIndex = original.getOrders().stream().collect(Collectors.toMap(SmHotelOrderCheckoutQueryVo::getOrderId, Function.identity())); + if (!candidateIndex.keySet().containsAll(ids)) throw new IllegalArgumentException("所选订单超出当前候选范围,请重新查询"); + Map fresh = source.fetch(ids); + for (Long id : ids) { + if (!source.fingerprint(candidateIndex.get(id)).equals(source.fingerprint(fresh.get(id)))) throw new IllegalStateException("订单" + id + "在查询后发生变化,请重新查询并核对"); + } + MchCheckoutBillSnapshot snapshot = context.read(original.getRelationId(), original.getRelationVersion()); + snapshot.setPeriodStart(original.getPeriodStart()); + snapshot.setPeriodEnd(original.getPeriodEnd()); + snapshot.setFeePerOrderFen(MchCheckoutBillMoney.fen(request.getFeePerOrderYuan(), "每单抽成")); + snapshot.setRemark(request.getRemark() == null ? "" : request.getRemark().trim()); + snapshot.setOrders(ids.stream().map(fresh::get).toList()); + snapshot.setSourceVerifiedAt(LocalDateTime.now()); + snapshot.setPreview(true); + Set occupied = claimed(ids); + Map byDept = snapshot.getMerchants().stream().collect(Collectors.toMap(MchCheckoutBillMerchantEntity::getDeptId, Function.identity())); + for (SmHotelOrderCheckoutQueryVo order : snapshot.getOrders()) { + MchCheckoutBillMerchantEntity member = byDept.get(order.getDeptId()); + String reason = source.eligibility(order); + if (member == null || !reason.isEmpty() || occupied.contains(order.getOrderId())) throw new IllegalStateException("所选订单归属、状态或结账占用已变化,请重新查询"); + long gross = MchCheckoutBillMoney.fen(order.getSellAmountYuan(), "订单金额"); + long fee = snapshot.getFeePerOrderFen(); + long payable = MchCheckoutBillMoney.payable(gross, fee); + member.setOrderCount(Math.addExact(member.getOrderCount(), 1)); + member.setGrossAmountFen(MchCheckoutBillMoney.add(member.getGrossAmountFen(), gross)); + member.setFeeAmountFen(MchCheckoutBillMoney.add(member.getFeeAmountFen(), fee)); + member.setPayableAmountFen(MchCheckoutBillMoney.add(member.getPayableAmountFen(), payable)); + member.setState("WAIT_PAY"); + } + snapshot.setRequestHash(contentHash(snapshot)); + // 在写缓存前计算全部总额,使整单溢出也在预览阶段被拒绝。 + summary(snapshot); + cache.save(snapshot); + return summary(snapshot); + } + + /** 预览和创建共用的规范化内容摘要,校验缓存中的计费参数及源事实。 */ + public String contentHash(MchCheckoutBillSnapshot snapshot) { + return source.hash(Arrays.asList(snapshot.getRelationId(), snapshot.getRelationVersion(), snapshot.getIntermediaryName(), + context.membershipHash(snapshot.getMerchants()), snapshot.getPeriodStart(), snapshot.getPeriodEnd(), snapshot.getFeePerOrderFen(), + snapshot.getRemark(), snapshot.getOrders().stream().map(source::fingerprint).toList())); + } + + /** 当前用户读取工作集时重新检查关系及完整商户权限。 */ + public MchCheckoutBillSnapshot require(String token, boolean preview) { + MchCheckoutBillSnapshot snapshot = cache.require(token, preview); + context.validateSnapshot(snapshot); + return snapshot; + } + + /** 工作集完整订单先映射和筛选,之后分页,总数保持一致。 */ + public Page orders(String token, boolean preview, Page page, Long merchantId, String keyword, boolean selectableOnly) { + MchCheckoutBillSnapshot snapshot = require(token, preview); + Set occupied = claimed(snapshot.getOrders().stream().map(SmHotelOrderCheckoutQueryVo::getOrderId).toList()); + Map byDept = snapshot.getMerchants().stream().collect(Collectors.toMap(MchCheckoutBillMerchantEntity::getDeptId, Function.identity())); + String search = keyword == null ? "" : keyword.trim().toLowerCase(Locale.ROOT); + List rows = snapshot.getOrders().stream().map(row -> orderVo(row, byDept.get(row.getDeptId()), occupied, snapshot.isPreview() ? snapshot.getFeePerOrderFen() : null)) + .filter(row -> merchantId == null || Objects.equals(row.getMerchantId(), merchantId)) + .filter(row -> !selectableOnly || row.isSelectable()) + .filter(row -> search.isEmpty() || (row.getOrderId() + " " + row.getHotelName() + " " + row.getChannelNo() + " " + row.getMerchantName()).toLowerCase(Locale.ROOT).contains(search)).toList(); + return slice(page, rows); + } + + /** 全量商户金额已在预览阶段计算,展示时只做分页。 */ + public Page merchants(String token, Page page) { + return slice(page, require(token, true).getMerchants().stream().map(row -> copy(row, MchCheckoutBillMerchantVo.class)).toList()); + } + + /** 完整选中列表用于明确的跨页批量加入,容量仍由工作集上限约束。 */ + public List selectableIds(String token, Long merchantId, String keyword) { + MchCheckoutBillSnapshot snapshot = require(token, false); + Set occupied = claimed(snapshot.getOrders().stream().map(SmHotelOrderCheckoutQueryVo::getOrderId).toList()); + Map byDept = snapshot.getMerchants().stream().collect(Collectors.toMap(MchCheckoutBillMerchantEntity::getDeptId, Function.identity())); + String search = keyword == null ? "" : keyword.trim().toLowerCase(Locale.ROOT); + return snapshot.getOrders().stream().map(row -> orderVo(row, byDept.get(row.getDeptId()), occupied, null)) + .filter(MchCheckoutBillCandidateOrderVo::isSelectable).filter(row -> merchantId == null || Objects.equals(row.getMerchantId(), merchantId)) + .filter(row -> search.isEmpty() || (row.getOrderId() + " " + row.getHotelName() + " " + row.getChannelNo() + " " + row.getMerchantName()).toLowerCase(Locale.ROOT).contains(search)) + .map(MchCheckoutBillCandidateOrderVo::getOrderId).toList(); + } + + /** 当前占用按明确主键分批读取,空名单不生成无条件查询。 */ + public Set claimed(List ids) { + Set occupied = new HashSet<>(); + for (int start = 0; start < ids.size(); start += MchCheckoutBillHotelSource.BATCH_SIZE) { + List batch = ids.subList(start, Math.min(ids.size(), start + MchCheckoutBillHotelSource.BATCH_SIZE)); + claims.selectList(Wrappers.lambdaQuery().eq(MchCheckoutOrderClaimEntity::getSourceType, "HOTEL").in(MchCheckoutOrderClaimEntity::getSourceOrderId, batch)) + .forEach(row -> occupied.add(row.getSourceOrderId())); + } + return occupied; + } + + /** 工作集概要与整单金额由全部商户快照累加,零订单商户也保留。 */ + public MchCheckoutBillSnapshotVo summary(MchCheckoutBillSnapshot snapshot) { + MchCheckoutBillSnapshotVo result = copy(snapshot, MchCheckoutBillSnapshotVo.class); + result.setOrderCount(snapshot.getOrders().size()); + Set occupied = claimed(snapshot.getOrders().stream().map(SmHotelOrderCheckoutQueryVo::getOrderId).toList()); + Set departments = snapshot.getMerchants().stream().map(MchCheckoutBillMerchantEntity::getDeptId).collect(Collectors.toSet()); + result.setSelectableCount((int) snapshot.getOrders().stream().filter(row -> source.eligibility(row).isEmpty() && departments.contains(row.getDeptId()) && !occupied.contains(row.getOrderId())).count()); + result.setMerchants(snapshot.getMerchants().stream().map(row -> copy(row, MchCheckoutBillMerchantVo.class)).toList()); + long gross = 0, fee = 0, payable = 0; + for (MchCheckoutBillMerchantEntity row : snapshot.getMerchants()) { + gross = MchCheckoutBillMoney.add(gross, row.getGrossAmountFen()); + fee = MchCheckoutBillMoney.add(fee, row.getFeeAmountFen()); + payable = MchCheckoutBillMoney.add(payable, row.getPayableAmountFen()); + } + if (gross != MchCheckoutBillMoney.add(fee, payable)) throw new IllegalStateException("结账预览汇总不一致"); + result.setGrossAmountFen(gross); + result.setFeeAmountFen(fee); + result.setPayableAmountFen(payable); + return result; + } + + /** 源事实映射成最小展示模型,金额非法时保留异常说明。 */ + private MchCheckoutBillCandidateOrderVo orderVo(SmHotelOrderCheckoutQueryVo row, MchCheckoutBillMerchantEntity member, Set occupied, Long fee) { + MchCheckoutBillCandidateOrderVo result = copy(row, MchCheckoutBillCandidateOrderVo.class); + result.setSourceUpdatedAt(row.getUpdateTime()); + result.setSourceLabel(row.getPartnerCode() != null ? row.getPartnerCode() : row.getDownstreamAppId() != null ? "开放应用 " + row.getDownstreamAppId() : row.getChannelNo() != null && row.getChannelNo().startsWith("APP") ? "App" : "后台/其他"); + if (member != null) { + result.setMerchantId(member.getMerchantId()); + result.setMerchantName(member.getMerchantName()); + result.setDeptName(member.getDeptName()); + } + String reason = source.eligibility(row); + if (member == null) reason = "订单当前归属超出关系或存在歧义"; + boolean claimed = occupied.contains(row.getOrderId()); + if (claimed) reason = "订单已经被有效结账单占用"; + result.setClaimed(claimed); + result.setSelectable(reason.isEmpty()); + result.setMessage(reason.isEmpty() ? "请人工核对履约完成及收款情况" : reason); + try { + long gross = MchCheckoutBillMoney.fen(row.getSellAmountYuan(), "订单金额"); + result.setGrossAmountFen(gross); + if (fee != null) { + result.setFeeAmountFen(fee); + result.setPayableAmountFen(MchCheckoutBillMoney.payable(gross, fee)); + } + } catch (IllegalArgumentException exception) { + result.setSelectable(false); + result.setMessage(exception.getMessage()); + } + return result; + } + + /** 将确定性完整集合切为当前页,限制页面尺寸并清理外部排序。 */ + public static Page slice(Page page, List rows) { + page.setCurrent(Math.max(1, Math.min(page.getCurrent(), 1000000))); + page.setSize(Math.max(1, Math.min(page.getSize(), 100))); + page.orders().clear(); + int from = (int) Math.min((page.getCurrent() - 1) * page.getSize(), rows.size()); + page.setTotal(rows.size()); + page.setRecords(rows.subList(from, Math.min(from + (int) page.getSize(), rows.size()))); + return page; + } + + /** 只复制独立展示模型中声明的属性,隐藏内部缓存和权限字段。 */ + public static T copy(Object value, Class type) { + try { + T result = type.getDeclaredConstructor().newInstance(); + BeanUtils.copyProperties(value, result); + return result; + } catch (ReflectiveOperationException exception) { + throw new IllegalStateException("结账展示模型转换失败", exception); + } + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchCheckoutBillRecheckService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchCheckoutBillRecheckService.java new file mode 100644 index 000000000..df4c0403e --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchCheckoutBillRecheckService.java @@ -0,0 +1,165 @@ +package com.bsd.pjcloud.wallet.service; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; +import com.bsd.pjcloud.supermarket.vo.SmHotelOrderCheckoutQueryVo; +import com.bsd.pjcloud.wallet.api.entity.mch.*; +import com.bsd.pjcloud.wallet.api.vo.mch.MchCheckoutBillVo; +import com.bsd.pjcloud.wallet.mapper.mch.*; +import com.bsd.pjcloud.wallet.support.mch.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.*; + +/** 源事实复核及差异持久化;网络读取和钱包状态写入分为两个阶段。 */ +@Slf4j +@Service +@RequiredArgsConstructor +public class MchCheckoutBillRecheckService { + /** 订单快照及定时复核游标。 */ + private final MchCheckoutBillOrderMapper orders; + /** 商户当前付款状态。 */ + private final MchCheckoutBillMerchantMapper merchants; + /** 账单主体统一锁序。 */ + private final MchCheckoutBillMapper bills; + /** 授权及追加式审计。 */ + private final MchCheckoutBillLedger ledger; + /** 酒店源数据只读适配。 */ + private final MchCheckoutBillHotelSource source; + /** 当前用户角色数据范围。 */ + private final MchCheckoutRelationAccess access; + /** 每一批复核结果独立提交,付款失败仍保留差异事实。 */ + private final TransactionTemplate transaction; + + /** 用户发起整单复核,只处理当前有效的商户明细。 */ + public MchCheckoutBillVo recheck(Long id) { + MchCheckoutRelationScope scope = access.requireScope(); + ledger.requireVisible(id, scope); + List active = merchants.selectList(Wrappers.lambdaQuery().eq(MchCheckoutBillMerchantEntity::getBillId, id).in(MchCheckoutBillMerchantEntity::getState, List.of("WAIT_PAY", "PAID"))) + .stream().map(MchCheckoutBillMerchantEntity::getId).toList(); + if (!active.isEmpty()) { + List rows = orders.selectList(Wrappers.lambdaQuery().eq(MchCheckoutBillOrderEntity::getBillId, id).in(MchCheckoutBillOrderEntity::getBillMerchantId, active).orderByAsc(MchCheckoutBillOrderEntity::getId)); + check(rows, scope); + } + transaction.executeWithoutResult(status -> { + bills.lockBill(id); + ledger.requireVisible(id, scope); + ledger.event(id, null, null, "RECHECK", UUID.randomUUID().toString(), "人工发起整单源事实复核"); + }); + return ledger.requireVisible(id, scope); + } + + /** 付款前强制复核完整单商户订单,错误也记录为可见复核结果。 */ + public List checkMerchant(Long id, Long memberRowId) { + MchCheckoutRelationScope scope = access.requireScope(); + ledger.requireVisible(id, scope); + List rows = orders.selectList(Wrappers.lambdaQuery().eq(MchCheckoutBillOrderEntity::getBillId, id).eq(MchCheckoutBillOrderEntity::getBillMerchantId, memberRowId).orderByAsc(MchCheckoutBillOrderEntity::getId)); + check(rows, scope); + return orders.selectList(Wrappers.lambdaQuery().eq(MchCheckoutBillOrderEntity::getBillMerchantId, memberRowId)); + } + + /** 后台单租户读取最多500条到期记录,按到期时间和ID持续推进。 */ + public void checkDue() { + if (TenantContextHolder.getTenantId() == null || TenantContextHolder.getTenantId() <= 0) throw new IllegalStateException("缺少有效的结账复核租户上下文"); + List due = orders.selectList(Wrappers.lambdaQuery().eq(MchCheckoutBillOrderEntity::getSourceType, "HOTEL").isNotNull(MchCheckoutBillOrderEntity::getNextCheckAt) + .le(MchCheckoutBillOrderEntity::getNextCheckAt, LocalDateTime.now()).orderByAsc(MchCheckoutBillOrderEntity::getNextCheckAt).orderByAsc(MchCheckoutBillOrderEntity::getId).last("LIMIT 500")); + check(due, null); + } + + /** 一批源数据先完整读取,再按账单主体锁串行更新,旧响应不能覆盖新复核。 */ + private void check(List rows, MchCheckoutRelationScope scope) { + if (rows.stream().anyMatch(row -> !"HOTEL".equals(row.getSourceType()))) throw new IllegalStateException("酒店复核接口收到不支持的源订单类型"); + for (int from = 0; from < rows.size(); from += MchCheckoutBillHotelSource.BATCH_SIZE) { + List batch = rows.subList(from, Math.min(from + MchCheckoutBillHotelSource.BATCH_SIZE, rows.size())); + LocalDateTime startedAt = LocalDateTime.now().truncatedTo(ChronoUnit.MICROS); + Map facts; + try { + facts = source.fetch(batch.stream().map(MchCheckoutBillOrderEntity::getSourceOrderId).toList()); + } catch (RuntimeException exception) { + log.warn("酒店结账源事实读取失败,订单笔数={}", batch.size(), exception); + facts = null; + } + Map fetched = facts; + Map> byBill = new TreeMap<>(); + for (MchCheckoutBillOrderEntity row : batch) byBill.computeIfAbsent(row.getBillId(), key -> new ArrayList<>()).add(row); + for (Map.Entry> entry : byBill.entrySet()) { + transaction.executeWithoutResult(status -> { + if (bills.lockBill(entry.getKey()) == null) throw new IllegalStateException("复核账单不存在"); + if (scope != null) ledger.requireVisible(entry.getKey(), scope); + for (MchCheckoutBillOrderEntity original : entry.getValue()) { + MchCheckoutBillOrderEntity current = orders.lockOrder(entry.getKey(), original.getId()); + MchCheckoutBillMerchantEntity member = merchants.selectById(current.getBillMerchantId()); + if (!Set.of("WAIT_PAY", "PAID").contains(member.getState()) || (current.getLastCheckedAt() != null && current.getLastCheckedAt().isAfter(startedAt))) continue; + apply(current, member, fetched == null ? null : fetched.get(current.getSourceOrderId()), startedAt); + } + }); + } + } + } + + /** 更新复核元数据及必要事件,原金额和源快照不参与更新。 */ + private void apply(MchCheckoutBillOrderEntity row, MchCheckoutBillMerchantEntity member, SmHotelOrderCheckoutQueryVo fact, LocalDateTime startedAt) { + String previousState = row.getLastCheckStatus(); + String previousFingerprint = row.getLastObservedFingerprint(); + String checkState; + String message; + if (fact == null) { + checkState = "QUERY_FAILED"; + message = "酒店源数据查询失败,请重试;当前结果尚未完成复核"; + row.setNextCheckAt(LocalDateTime.now().plusMinutes(5)); + } else { + String fingerprint = source.fingerprint(fact); + row.setLastObservedFingerprint(fingerprint); + row.setLastVerifiedAt(LocalDateTime.now()); + checkState = Objects.equals(row.getSourceFingerprint(), fingerprint) ? "NORMAL" : "DIFF"; + message = "NORMAL".equals(checkState) ? "酒店源事实与原账一致" : difference(row, fact); + if ("DIFF".equals(checkState) && "PAID".equals(member.getState()) && Objects.equals(row.getHandledFingerprint(), fingerprint)) { + checkState = "HANDLED"; + message = row.getLastCheckMessage(); + } + row.setNextCheckAt("PAID".equals(member.getState()) ? LocalDateTime.now().plusHours(24) : LocalDateTime.now().plusMinutes(10)); + } + row.setLastCheckStatus(checkState); + row.setLastCheckMessage(message); + row.setLastCheckedAt(startedAt); + if (orders.updateById(row) != 1) throw new IllegalStateException("订单复核结果更新失败"); + if (!Objects.equals(previousState, checkState) || !Objects.equals(previousFingerprint, row.getLastObservedFingerprint())) { + String action = Set.of("DIFF", "QUERY_FAILED").contains(checkState) ? checkState : "RECHECK"; + Map evidence = new LinkedHashMap<>(); + evidence.put("previousState", previousState); + evidence.put("state", checkState); + evidence.put("message", message); + evidence.put("originalFingerprint", row.getSourceFingerprint()); + evidence.put("observedFingerprint", row.getLastObservedFingerprint()); + // 历史账单权限仅覆盖原归属,审计保留必要金额状态,避免暴露变更后其他部门的酒店资料。 + Map observed = new LinkedHashMap<>(); + if (fact != null) { + observed.put("orderId", fact.getOrderId()); + observed.put("found", fact.isFound()); + observed.put("sellAmountYuan", fact.getSellAmountYuan()); + observed.put("orderStatus", fact.getOrderStatus()); + observed.put("bookStatus", fact.getBookStatus()); + observed.put("payStatus", fact.getPayStatus()); + } + evidence.put("observedOrderFacts", observed); + ledger.event(row.getBillId(), member.getId(), row.getSourceOrderId(), action, UUID.randomUUID().toString(), evidence); + } + } + + /** 将影响结账的源变化转换为明确原因,避免静默舍弃异常订单。 */ + private String difference(MchCheckoutBillOrderEntity row, SmHotelOrderCheckoutQueryVo fact) { + if (!fact.isFound()) return "源酒店订单已删除或不存在,需核实原结账依据"; + if (!"OK".equals(fact.getResolutionStatus())) return "源酒店归属缺失或无法唯一解析"; + if (!Objects.equals(row.getDeptId(), fact.getDeptId()) || !Objects.equals(row.getHotelId(), fact.getHotelId())) return "酒店或归属部门已经变化"; + try { + if (row.getGrossAmountFen() != MchCheckoutBillMoney.fen(fact.getSellAmountYuan(), "源订单金额")) return "订单售价已经变化,需重新核对金额"; + } catch (IllegalArgumentException exception) { + return exception.getMessage(); + } + return "订单状态、支付状态、来源或入住信息已经变化,请核实履约和退款情况"; + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchCheckoutBillService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchCheckoutBillService.java new file mode 100644 index 000000000..f0b7de0e7 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchCheckoutBillService.java @@ -0,0 +1,27 @@ +package com.bsd.pjcloud.wallet.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.wallet.api.dto.mch.MchCheckoutBillRequests; +import com.bsd.pjcloud.wallet.api.vo.mch.*; + +/** 钱包结账账本公开业务契约,金额、权限、幂等和事务均由服务端终审。 */ +public interface MchCheckoutBillService { + /** 按完整历史商户权限快照分页查询。 */ + Page page(Page page, MchCheckoutBillRequests.ListQuery query); + /** 读取完整可见账单的原始与有效金额。 */ + MchCheckoutBillVo detail(Long id); + /** 查询账单下全部商户权限快照与金额明细。 */ + Page merchants(Long id, Page page); + /** 查询账单订单,按可选商户和异常状态筛选后分页。 */ + Page orders(Long id, Long merchantId, boolean anomaliesOnly, Page page); + /** 查询账单追加式审计事件。 */ + Page events(Long id, Page page); + /** 按预览创建完整待付款账单及唯一订单占用。 */ + MchCheckoutBillVo create(MchCheckoutBillRequests.Create request); + /** 复核源事实后登记单个商户全额付款或零元结清。 */ + MchCheckoutBillVo pay(Long id, Long merchantId, MchCheckoutBillRequests.Payment request); + /** 按原版本作废待付款商户明细并释放它的订单。 */ + MchCheckoutBillVo voidMerchant(Long id, Long merchantId, MchCheckoutBillRequests.VoidMerchant request); + /** 登记已付款差异的财务核实与处理依据。 */ + MchCheckoutBillVo handle(Long id, Long orderRowId, MchCheckoutBillRequests.Handle request); +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchSceneService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchSceneService.java new file mode 100644 index 000000000..80f070f1e --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchSceneService.java @@ -0,0 +1,29 @@ +package com.bsd.pjcloud.wallet.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.bsd.pjcloud.wallet.api.dto.mch.MchSceneDto; +import com.bsd.pjcloud.wallet.api.entity.mch.MchSceneEntity; +import com.bsd.pjcloud.wallet.api.vo.mch.MchSceneVo; + +/** + * 商户场景服务,负责场景协议、商户数据范围和唯一配置的保存。 + */ +public interface MchSceneService extends IService { + + /** + * 查询当前管理用户可访问商户的场景配置,读取操作不创建记录。 + * + * @param mchId 商户 ID + * @param sceneCode 场景持久化编码 + * @return 匹配配置,未配置时返回空 + */ + MchSceneVo getForManagement(Long mchId, String sceneCode); + + /** + * 按商户和场景新增或更新唯一配置,并发首次保存时复用已创建记录。 + * + * @param dto 管理端场景开关及固定抽成金额,金额缺省时保留已有值 + * @return 本次保存后的场景配置 + */ + MchSceneVo saveOrUpdateConfig(MchSceneDto dto); +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletManageService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletManageService.java index 8682feccd..0a1e158f0 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletManageService.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletManageService.java @@ -148,6 +148,14 @@ public interface WalletManageService extends IService { List scopeList(WalletManageDto dto, DataScope dataScope); + /** + * 查询月末钱包记录,余额排序覆盖当前筛选范围内的全部钱包后再分页。 + * + * @param page 分页及排序参数;balance 支持升序、降序,同额按钱包主键稳定排序 + * @param dto 月份及用户、部门、身份、钱包类型筛选条件 + * @param dataScope 当前操作者的数据权限范围 + * @return 仅包含当页记录且已装配用户资料的月末余额分页 + */ Page scopeQbPage(Page page, WalletManageBalanceDto dto, DataScope dataScope); /** diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletManageServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletManageServiceImpl.java index 80d7b9255..895866ee1 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletManageServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletManageServiceImpl.java @@ -11,6 +11,7 @@ import cn.hutool.core.util.ObjectUtil; import cn.hutool.extra.spring.SpringUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.metadata.OrderItem; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.bsd.pjcloud.admin.api.dto.UserInfo; @@ -30,6 +31,7 @@ import com.bsd.pjcloud.common.core.util.BeanUtils; 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.datascope.PjcloudDefaultDataScopeHandle; import com.bsd.pjcloud.common.data.resolver.ParamResolver; import com.bsd.pjcloud.common.data.tenant.TenantBroker; @@ -64,7 +66,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; +import java.time.DateTimeException; import java.time.LocalDateTime; +import java.time.YearMonth; import java.util.*; import java.util.stream.Collectors; @@ -693,11 +697,92 @@ public class WalletManageServiceImpl extends ServiceImpl scopeQbPage(Page page, WalletManageBalanceDto dto, DataScope dataScope) { + List orders = page.orders(); + Optional balanceOrder = orders.stream().filter(order -> "balance".equals(order.getColumn())).findFirst(); + if (balanceOrder.isPresent()) { + return queryWalletRecordBalancePage(page, dto, dataScope, balanceOrder.get().isAsc()); + } return queryWalletRecordPage(page, dto, dataScope, true); } + /** + * 对全部匹配钱包的月末余额排序后分页,仅回填当页用户资料,不把全量记录载入应用内存。 + * + * @param page 分页参数;复用全局每页最多 1000 条限制,临时关闭自动计数后恢复原标记 + * @param dto 月度钱包记录筛选条件 + * @param dataScope 当前操作者的数据权限范围;计数使用独立副本,避免权限计算污染列表范围 + * @param ascending 是否按余额升序;同额时按钱包主键倒序保证分页稳定 + * @return 数据库全量排序后的当页钱包记录 + */ + private Page queryWalletRecordBalancePage(Page page, WalletManageBalanceDto dto, DataScope dataScope, boolean ascending) { + if (page.getCurrent() < 1 || page.getSize() <= 0) { + throw new IllegalArgumentException("余额排序必须使用有效页码和正数分页大小"); + } + LocalDateTime monthEndExclusive = walletRecordMonthEnd(dto.getWalletRecordMonth()); + WalletManageDto walletDto = BeanUtils.toBean(dto, WalletManageDto.class); + prepareIdentityQuery(walletDto); + // 只接受服务端固定列名,避免将任意请求排序字段带入余额派生表。 + page.setOrders(new ArrayList<>(List.of(ascending ? OrderItem.asc("balance") : OrderItem.desc("balance"), OrderItem.desc("id")))); + boolean searchCount = page.searchCount(); + DataScope queryScope = dataScope == null ? DataScope.of() : dataScope; + if (searchCount) { + // 权限计算可能追加部门范围,计数必须使用独立副本,不能影响随后列表的全部数据权限。 + DataScope countScope = new DataScope(); + countScope.setScopeDeptName(queryScope.getScopeDeptName()); + countScope.setScopeUserName(queryScope.getScopeUserName()); + countScope.setDeptList(new ArrayList<>(queryScope.getDeptList())); + countScope.setUsername(queryScope.getUsername()); + countScope.setIsOnly(queryScope.getIsOnly()); + countScope.setFunc(DataScopeFuncEnum.COUNT); + // 数量只依赖钱包筛选,避免对月末流水窗口重复扫描和计数。 + page.setTotal(baseMapper.count(walletDto, countScope)); + if (page.getTotal() == 0) { + page.setRecords(Collections.emptyList()); + return page; + } + } + try { + page.setSearchCount(false); + Page result = baseMapper.scopeQbBalancePage(page, walletDto, monthEndExclusive, queryScope); + disposeList(result.getRecords()); + return result; + } finally { + page.setSearchCount(searchCount); + } + } + + /** + * 将合法统计月份转换为下月首日的排他上界,避免在流水时间列上使用日期格式化函数。 + * + * @param month 页面传入的 YYYY-MM 月份;缺失、格式错误或超出 MySQL 日期范围时拒绝查询 + * @return 下月首日零点,作为绑定参数限制需要参与月末余额计算的流水 + */ + private LocalDateTime walletRecordMonthEnd(String month) { + if (month == null || !month.matches("\\d{4}-\\d{2}")) { + throw new IllegalArgumentException("请选择有效月份(YYYY-MM)"); + } + try { + YearMonth recordMonth = YearMonth.parse(month); + YearMonth nextMonth = recordMonth.plusMonths(1); + if (recordMonth.getYear() < 1000 || nextMonth.getYear() > 9999) { + throw new IllegalArgumentException("统计月份超出支持范围"); + } + return nextMonth.atDay(1).atStartOfDay(); + } catch (DateTimeException exception) { + throw new IllegalArgumentException("请选择有效月份(YYYY-MM)", exception); + } + } + /** * 分页计算月末钱包余额导出数据,并把完整用户资料留给统一导出服务装配。 * @@ -722,6 +807,7 @@ public class WalletManageServiceImpl extends ServiceImpl queryWalletRecordPage(Page page, WalletManageBalanceDto dto, DataScope dataScope, boolean fillUserInfo) { + LocalDateTime monthEndExclusive = walletRecordMonthEnd(dto.getWalletRecordMonth()); WalletManageDto walletDto = BeanUtils.toBean(dto, WalletManageDto.class); prepareIdentityQuery(walletDto); Page walletPage = baseMapper.scopePage(page, walletDto, dataScope); @@ -743,10 +829,11 @@ public class WalletManageServiceImpl extends ServiceImpl itemList = baseMapper.selectBalance(walletBalanceDto); + List itemList = baseMapper.selectBalance(walletBalanceDto, monthEndExclusive); if (ObjectUtil.isEmpty(itemList)) continue; for (WalletManageBalanceVo j : itemList) { - walletPage.getRecords().stream().filter(v -> ObjectUtil.equals(i.getId(), v.getWalletTypeId()) && ObjectUtil.equals(v.getUserId(), j.getUserId())).findFirst().ifPresent(v -> { + // 同用户、同类型可能存在历史重复钱包,全部使用同一月末余额,保持切换排序前后一致。 + walletPage.getRecords().stream().filter(v -> ObjectUtil.equals(i.getId(), v.getWalletTypeId()) && ObjectUtil.equals(v.getUserId(), j.getUserId())).forEach(v -> { if (ObjectUtil.isNotEmpty(j.getBalance())) { v.setBalance(j.getBalance()); v.setLastTopUpTime(j.getCreateTime()); @@ -825,8 +912,16 @@ public class WalletManageServiceImpl extends ServiceImpl scopeQbList(WalletManageBalanceDto dto, DataScope dataScope) { + LocalDateTime monthEndExclusive = walletRecordMonthEnd(dto.getWalletRecordMonth()); WalletManageDto walletDto = BeanUtils.toBean(dto, WalletManageDto.class); prepareIdentityQuery(walletDto); BeanUtil.copyProperties(walletDto, dto); @@ -847,10 +942,11 @@ public class WalletManageServiceImpl extends ServiceImpl itemList = baseMapper.selectBalance(dto); + List itemList = baseMapper.selectBalance(dto, monthEndExclusive); if (ObjectUtil.isEmpty(itemList)) continue; for (WalletManageBalanceVo j : itemList) { - walletList.stream().filter(v -> ObjectUtil.equals(i.getId(), v.getWalletTypeId()) && ObjectUtil.equals(v.getUserId(), j.getUserId())).findFirst().ifPresent(v -> { + // 与分页、余额排序共用用户和钱包类型口径,不能只回填重复钱包中的第一条。 + walletList.stream().filter(v -> ObjectUtil.equals(i.getId(), v.getWalletTypeId()) && ObjectUtil.equals(v.getUserId(), j.getUserId())).forEach(v -> { if (ObjectUtil.isNotEmpty(j.getBalance())) { v.setBalance(j.getBalance()); v.setLastTopUpTime(j.getCreateTime()); diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchCheckoutBillServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchCheckoutBillServiceImpl.java new file mode 100644 index 000000000..06916f872 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchCheckoutBillServiceImpl.java @@ -0,0 +1,435 @@ +package com.bsd.pjcloud.wallet.service.impl.mch; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.common.security.util.SecurityUtils; +import com.bsd.pjcloud.supermarket.vo.SmHotelOrderCheckoutQueryVo; +import com.bsd.pjcloud.wallet.api.constant.mch.MchCheckoutBillStates; +import com.bsd.pjcloud.wallet.api.dto.mch.MchCheckoutBillRequests; +import com.bsd.pjcloud.wallet.api.entity.mch.*; +import com.bsd.pjcloud.wallet.api.vo.mch.*; +import com.bsd.pjcloud.wallet.mapper.mch.*; +import com.bsd.pjcloud.wallet.service.MchCheckoutBillPreviewService; +import com.bsd.pjcloud.wallet.service.MchCheckoutBillRecheckService; +import com.bsd.pjcloud.wallet.service.MchCheckoutBillService; +import com.bsd.pjcloud.wallet.support.mch.*; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +/** 酒店结账账本事务,远程事实读取在本地写事务之外执行。 */ +@Service +@RequiredArgsConstructor +public class MchCheckoutBillServiceImpl implements MchCheckoutBillService { + /** 账单主体访问。 */ + private final MchCheckoutBillMapper billMapper; + /** 商户权限快照及付款明细访问。 */ + private final MchCheckoutBillMerchantMapper merchantMapper; + /** 原订单金额快照访问。 */ + private final MchCheckoutBillOrderMapper orderMapper; + /** 当前订单唯一占用访问。 */ + private final MchCheckoutOrderClaimMapper claimMapper; + /** 追加式审计查询。 */ + private final MchCheckoutBillEventMapper eventMapper; + /** 完整候选与预览快照。 */ + private final MchCheckoutBillPreviewService previews; + /** 酒店只读事实读取与摘要。 */ + private final MchCheckoutBillHotelSource source; + /** 关系版本和当前商户行锁。 */ + private final MchCheckoutBillContext context; + /** 统一角色数据范围。 */ + private final MchCheckoutRelationAccess access; + /** 财务账本授权及审计。 */ + private final MchCheckoutBillLedger ledger; + /** 独立源事实复核及差异持久化。 */ + private final MchCheckoutBillRecheckService rechecks; + /** Spring本地事务,覆盖主体、明细、占用和事件。 */ + private final TransactionTemplate transaction; + + /** 整组历史权限在数据库分页之前计算。 */ + @Override + public Page page(Page page, MchCheckoutBillRequests.ListQuery query) { + safe(page); + return billMapper.pageVisible(page, query, access.requireScope()); + } + + /** 无权及不存在使用统一提示。 */ + @Override + public MchCheckoutBillVo detail(Long id) { + return ledger.requireVisible(id, access.requireScope()); + } + + /** 查询所有成员快照,零订单成员持续参与权限判断。 */ + @Override + public Page merchants(Long id, Page page) { + detail(id); + Page data = merchantMapper.selectPage(new Page<>(safe(page).getCurrent(), page.getSize()), + Wrappers.lambdaQuery().eq(MchCheckoutBillMerchantEntity::getBillId, id).orderByDesc(MchCheckoutBillMerchantEntity::getOrderCount).orderByAsc(MchCheckoutBillMerchantEntity::getMerchantId)); + return viewPage(data, MchCheckoutBillMerchantVo.class); + } + + /** 按商户及异常筛选在数据库分页之前处理。 */ + @Override + public Page orders(Long id, Long merchantId, boolean anomaliesOnly, Page page) { + detail(id); + List members = merchantMapper.selectList(Wrappers.lambdaQuery().eq(MchCheckoutBillMerchantEntity::getBillId, id)); + List activeIds = members.stream().filter(row -> !"VOID".equals(row.getState())).map(MchCheckoutBillMerchantEntity::getId).toList(); + if (anomaliesOnly && activeIds.isEmpty()) return new Page<>(safe(page).getCurrent(), page.getSize(), 0); + Page data = orderMapper.selectPage(new Page<>(safe(page).getCurrent(), page.getSize()), + Wrappers.lambdaQuery().eq(MchCheckoutBillOrderEntity::getBillId, id) + .eq(merchantId != null, MchCheckoutBillOrderEntity::getMerchantId, merchantId) + .in(anomaliesOnly, MchCheckoutBillOrderEntity::getBillMerchantId, activeIds) + .in(anomaliesOnly, MchCheckoutBillOrderEntity::getLastCheckStatus, List.of("DIFF", "QUERY_FAILED")) + .orderByAsc(MchCheckoutBillOrderEntity::getId)); + Map states = members.stream().collect(Collectors.toMap(MchCheckoutBillMerchantEntity::getId, MchCheckoutBillMerchantEntity::getState)); + Page result = viewPage(data, MchCheckoutBillOrderVo.class); + result.getRecords().forEach(row -> row.setMerchantState(states.get(row.getBillMerchantId()))); + return result; + } + + /** 按事件主键倒序读取完整业务轨迹。 */ + @Override + public Page events(Long id, Page page) { + detail(id); + Page data = eventMapper.selectPage(new Page<>(safe(page).getCurrent(), page.getSize()), + Wrappers.lambdaQuery().eq(MchCheckoutBillEventEntity::getBillId, id).orderByDesc(MchCheckoutBillEventEntity::getId)); + return viewPage(data, MchCheckoutBillEventVo.class); + } + + /** 幂等命中在临时令牌校验之前处理,缓存过期仍可恢复已提交结果。 */ + @Override + public MchCheckoutBillVo create(MchCheckoutBillRequests.Create request) { + MchCheckoutRelationScope scope = access.requireScope(); + MchCheckoutBillEntity existing = findCreated(request.getRequestId()); + if (existing != null) return replayCreate(existing, request, scope); + MchCheckoutBillSnapshot snapshot = previews.require(request.getPreviewToken(), true); + if (!Objects.equals(snapshot.getRequestHash(), previews.contentHash(snapshot))) throw new IllegalStateException("结账预览内容异常,请重新预览"); + assertFresh(snapshot.getOrders(), source.fetch(snapshot.getOrders().stream().map(SmHotelOrderCheckoutQueryVo::getOrderId).toList())); + snapshot.setSourceVerifiedAt(LocalDateTime.now()); + MchCheckoutRelationScope freshScope = access.requireScope(); + try { + Long id = transaction.execute(status -> createLocked(snapshot, request, freshScope)); + return detail(Objects.requireNonNull(id)); + } catch (DuplicateKeyException exception) { + // 唯一键冲突后的查询在已回滚事务之外进行,响应超时重试能恢复唯一成功结果。 + MchCheckoutBillEntity committed = findCreated(request.getRequestId()); + if (committed != null) return replayCreate(committed, request, freshScope); + throw new IllegalStateException("所选订单已被其他有效结账单占用,请重新查询", exception); + } + } + + /** 付款前保存独立复核结果,再在主体锁内终审当前明细版本和源摘要。 */ + @Override + public MchCheckoutBillVo pay(Long id, Long merchantId, MchCheckoutBillRequests.Payment request) { + detail(id); + MchCheckoutBillMerchantEntity original = requireMerchant(id, merchantId); + String digest = source.hash(Arrays.asList(id, merchantId, request.getVersion(), request.getPaymentTime(), trim(request.getPaymentNo()), normalizedPics(request.getPaymentPics()), trim(request.getRemark()))); + if (Objects.equals(original.getPaymentRequestId(), request.getRequestId())) { + assertDigest(original.getPaymentRequestHash(), digest); + return detail(id); + } + assertWaiting(original, request.getVersion()); + if (original.getPayableAmountFen() > 0 && trim(request.getPaymentNo()).isEmpty() && normalizedPics(request.getPaymentPics()).isEmpty()) throw new IllegalArgumentException("请提供付款流水号或至少一张凭证"); + if (request.getPaymentTime().isAfter(LocalDateTime.now())) throw new IllegalArgumentException("付款时间不能晚于当前时间"); + List checked = rechecks.checkMerchant(id, original.getId()); + MchCheckoutRelationScope scope = access.requireScope(); + transaction.executeWithoutResult(status -> { + MchCheckoutBillEntity bill = requireLocked(id, scope); + MchCheckoutBillMerchantEntity row = merchantMapper.lockMerchant(id, merchantId); + if (row == null) throw new IllegalStateException("商户结账明细不存在"); + if (Objects.equals(row.getPaymentRequestId(), request.getRequestId())) { + assertDigest(row.getPaymentRequestHash(), digest); + return; + } + assertWaiting(row, request.getVersion()); + List current = readOrders(row.getId()); + if (current.size() != checked.size() || current.size() != row.getOrderCount()) throw new IllegalStateException("订单明细数量异常,请先核对账单"); + if (current.stream().anyMatch(order -> "QUERY_FAILED".equals(order.getLastCheckStatus()))) throw new IllegalStateException("酒店源数据复核查询失败,请稍后重试登记付款"); + if (current.stream().anyMatch(order -> !"NORMAL".equals(order.getLastCheckStatus()))) throw new IllegalStateException("订单源事实已变化,请核对差异后作废待付款明细重新结账"); + // 主体行锁使复核与付款/作废串行,已确认差异也不能覆盖付款校验。 + row.setState(MchCheckoutBillStates.Merchant.PAID.name()); + row.setVersion(Math.addExact(row.getVersion(), 1)); + row.setPaymentTime(request.getPaymentTime()); + row.setPaidAmountFen(row.getPayableAmountFen()); + row.setPaymentNo(trim(request.getPaymentNo())); + row.setPaymentPics(ledger.encode(normalizedPics(request.getPaymentPics()))); + row.setPaymentRemark(trim(request.getRemark())); + row.setPaymentRequestId(request.getRequestId()); + row.setPaymentRequestHash(digest); + requireWrite(merchantMapper.updateById(row)); + orderMapper.update(null, Wrappers.lambdaUpdate().eq(MchCheckoutBillOrderEntity::getBillMerchantId, row.getId()).set(MchCheckoutBillOrderEntity::getNextCheckAt, LocalDateTime.now().plusHours(24))); + ledger.refreshState(bill); + ledger.event(id, row.getId(), null, MchCheckoutBillStates.Action.PAY.name(), request.getRequestId(), Map.of("request", request, "paidAmountFen", row.getPaidAmountFen(), "result", "PAID")); + }); + return detail(id); + } + + /** 作废只释放指定待付款商户的占用,已付款或旧版本不能进入。 */ + @Override + public MchCheckoutBillVo voidMerchant(Long id, Long merchantId, MchCheckoutBillRequests.VoidMerchant request) { + MchCheckoutRelationScope scope = access.requireScope(); + String digest = source.hash(Arrays.asList(id, merchantId, request.getVersion(), trim(request.getReason()))); + transaction.executeWithoutResult(status -> { + MchCheckoutBillEntity bill = requireLocked(id, scope); + MchCheckoutBillMerchantEntity row = merchantMapper.lockMerchant(id, merchantId); + if (row == null) throw new IllegalStateException("商户结账明细不存在"); + if (Objects.equals(row.getVoidRequestId(), request.getRequestId())) { + assertDigest(row.getVoidRequestHash(), digest); + return; + } + assertWaiting(row, request.getVersion()); + row.setState(MchCheckoutBillStates.Merchant.VOID.name()); + row.setVersion(Math.addExact(row.getVersion(), 1)); + row.setVoidReason(trim(request.getReason())); + row.setVoidRequestId(request.getRequestId()); + row.setVoidRequestHash(digest); + requireWrite(merchantMapper.updateById(row)); + int released = claimMapper.delete(Wrappers.lambdaQuery().eq(MchCheckoutOrderClaimEntity::getBillId, id).eq(MchCheckoutOrderClaimEntity::getBillMerchantId, row.getId())); + if (released != row.getOrderCount()) throw new IllegalStateException("订单占用数量异常,作废已回滚"); + orderMapper.update(null, Wrappers.lambdaUpdate().eq(MchCheckoutBillOrderEntity::getBillMerchantId, row.getId()).set(MchCheckoutBillOrderEntity::getNextCheckAt, null)); + ledger.refreshState(bill); + ledger.event(id, row.getId(), null, MchCheckoutBillStates.Action.VOID.name(), request.getRequestId(), Map.of("request", request, "releasedOrders", released)); + }); + return detail(id); + } + + /** 已付款差异按观察摘要登记处理,事件幂等且保留原始金额和源快照。 */ + @Override + public MchCheckoutBillVo handle(Long id, Long orderRowId, MchCheckoutBillRequests.Handle request) { + MchCheckoutRelationScope scope = access.requireScope(); + String digest = source.hash(Arrays.asList(id, orderRowId, request.getObservedFingerprint(), trim(request.getRemark()), normalizedPics(request.getEvidencePics()))); + Map evidence = Map.of("requestHash", digest, "request", request); + transaction.executeWithoutResult(status -> { + requireLocked(id, scope); + MchCheckoutBillEventEntity previous = eventMapper.selectOne(Wrappers.lambdaQuery().eq(MchCheckoutBillEventEntity::getAction, "HANDLE").eq(MchCheckoutBillEventEntity::getRequestId, request.getRequestId())); + if (previous != null) { + assertDigest(previous.getRequestHash(), source.hash(evidence)); + return; + } + MchCheckoutBillOrderEntity order = orderMapper.lockOrder(id, orderRowId); + if (order == null) throw new IllegalStateException("结账订单不存在"); + MchCheckoutBillMerchantEntity member = merchantMapper.selectById(order.getBillMerchantId()); + if (!"PAID".equals(member.getState()) || !"DIFF".equals(order.getLastCheckStatus()) || !Objects.equals(order.getLastObservedFingerprint(), request.getObservedFingerprint())) throw new IllegalStateException("仅可处理当前已付款差异,差异已变化时请重新核对"); + order.setHandledFingerprint(request.getObservedFingerprint()); + order.setLastCheckStatus(MchCheckoutBillStates.Check.HANDLED.name()); + order.setLastCheckMessage(trim(request.getRemark())); + requireWrite(orderMapper.updateById(order)); + ledger.event(id, member.getId(), order.getSourceOrderId(), MchCheckoutBillStates.Action.HANDLE.name(), request.getRequestId(), evidence); + }); + return detail(id); + } + + /** 事务内重算每一笔金额后批量写入,缓存中的汇总不能覆盖实际计费。 */ + private Long createLocked(MchCheckoutBillSnapshot snapshot, MchCheckoutBillRequests.Create request, MchCheckoutRelationScope scope) { + context.lockAndValidate(snapshot, scope); + LocalDateTime now = LocalDateTime.now(); + String actor = SecurityUtils.getUser().getUsername(); + Long billId = IdWorker.getId(); + Map membersByDept = new LinkedHashMap<>(); + for (MchCheckoutBillMerchantEntity original : snapshot.getMerchants()) { + MchCheckoutBillMerchantEntity member = MchCheckoutBillPreviewService.copy(original, MchCheckoutBillMerchantEntity.class); + member.setId(IdWorker.getId()); + member.setBillId(billId); + member.setOrderCount(0); + member.setGrossAmountFen(0L); + member.setFeeAmountFen(0L); + member.setPayableAmountFen(0L); + member.setState("NO_ORDERS"); + member.setCreateBy(actor); + member.setUpdateBy(actor); + member.setCreateTime(now); + member.setUpdateTime(now); + membersByDept.put(member.getDeptId(), member); + } + List orders = new ArrayList<>(); + List claims = new ArrayList<>(); + long grossTotal = 0, feeTotal = 0, payableTotal = 0; + for (SmHotelOrderCheckoutQueryVo fact : snapshot.getOrders()) { + MchCheckoutBillMerchantEntity member = membersByDept.get(fact.getDeptId()); + if (member == null || !source.eligibility(fact).isEmpty()) throw new IllegalStateException("订单归属或状态不允许结账"); + long gross = MchCheckoutBillMoney.fen(fact.getSellAmountYuan(), "订单金额"); + long fee = snapshot.getFeePerOrderFen(); + long payable = MchCheckoutBillMoney.payable(gross, fee); + member.setOrderCount(member.getOrderCount() + 1); + member.setGrossAmountFen(MchCheckoutBillMoney.add(member.getGrossAmountFen(), gross)); + member.setFeeAmountFen(MchCheckoutBillMoney.add(member.getFeeAmountFen(), fee)); + member.setPayableAmountFen(MchCheckoutBillMoney.add(member.getPayableAmountFen(), payable)); + member.setState("WAIT_PAY"); + grossTotal = MchCheckoutBillMoney.add(grossTotal, gross); + feeTotal = MchCheckoutBillMoney.add(feeTotal, fee); + payableTotal = MchCheckoutBillMoney.add(payableTotal, payable); + MchCheckoutBillOrderEntity order = new MchCheckoutBillOrderEntity(); + order.setId(IdWorker.getId()); + order.setBillId(billId); + order.setBillMerchantId(member.getId()); + order.setMerchantId(member.getMerchantId()); + order.setSourceType("HOTEL"); + order.setSourceOrderId(fact.getOrderId()); + order.setHotelId(fact.getHotelId()); + order.setHotelName(fact.getHotelName()); + order.setDeptId(fact.getDeptId()); + order.setChannelNo(fact.getChannelNo()); + order.setPlatformNo(fact.getPlatformNo()); + order.setCheckInDate(fact.getCheckInDate()); + order.setCheckOutDate(fact.getCheckOutDate()); + order.setRooms(fact.getRooms()); + order.setGrossAmountFen(gross); + order.setFeeAmountFen(fee); + order.setPayableAmountFen(payable); + order.setOrderStatus(fact.getOrderStatus()); + order.setBookStatus(fact.getBookStatus()); + order.setPayStatus(fact.getPayStatus()); + order.setSourceUpdatedAt(fact.getUpdateTime()); + order.setSourceFingerprint(source.fingerprint(fact)); + order.setSourceSnapshot(ledger.encode(fact)); + order.setConfirmedUserId(SecurityUtils.getUser().getId()); + order.setConfirmedBy(actor); + order.setConfirmedAt(now); + order.setLastCheckStatus("NORMAL"); + order.setLastCheckMessage("创建前源数据核对一致"); + order.setLastObservedFingerprint(order.getSourceFingerprint()); + order.setLastCheckedAt(snapshot.getSourceVerifiedAt()); + order.setLastVerifiedAt(snapshot.getSourceVerifiedAt()); + order.setNextCheckAt(now.plusMinutes(10)); + order.setCreateBy(actor); + order.setUpdateBy(actor); + order.setCreateTime(now); + order.setUpdateTime(now); + orders.add(order); + MchCheckoutOrderClaimEntity claim = new MchCheckoutOrderClaimEntity(); + claim.setId(IdWorker.getId()); + claim.setSourceType("HOTEL"); + claim.setSourceOrderId(fact.getOrderId()); + claim.setBillId(billId); + claim.setBillMerchantId(member.getId()); + claim.setCreateBy(actor); + claim.setCreateTime(now); + claims.add(claim); + } + if (orders.isEmpty() || grossTotal != MchCheckoutBillMoney.add(feeTotal, payableTotal)) throw new IllegalStateException("结账订单或金额汇总异常"); + MchCheckoutBillEntity bill = new MchCheckoutBillEntity(); + bill.setId(billId); + bill.setBillNo("HCO" + billId); + bill.setSourceType("HOTEL"); + bill.setRelationId(snapshot.getRelationId()); + bill.setRelationVersion(snapshot.getRelationVersion()); + bill.setIntermediaryName(snapshot.getIntermediaryName()); + bill.setPeriodStart(snapshot.getPeriodStart()); + bill.setPeriodEnd(snapshot.getPeriodEnd()); + bill.setRuleVersion(1); + bill.setFeePerOrderFen(snapshot.getFeePerOrderFen()); + bill.setOrderCount(orders.size()); + bill.setMerchantCount(membersByDept.size()); + bill.setGrossAmountFen(grossTotal); + bill.setFeeAmountFen(feeTotal); + bill.setPayableAmountFen(payableTotal); + bill.setState("WAIT_PAY"); + bill.setVersion(0); + bill.setCreateRequestId(request.getRequestId()); + bill.setRequestHash(snapshot.getRequestHash()); + bill.setPreviewToken(request.getPreviewToken()); + bill.setSourceVerifiedAt(snapshot.getSourceVerifiedAt()); + bill.setRemark(snapshot.getRemark()); + requireWrite(billMapper.insert(bill)); + List members = new ArrayList<>(membersByDept.values()); + for (int start = 0; start < members.size(); start += 500) { + List batch = members.subList(start, Math.min(start + 500, members.size())); + if (merchantMapper.insertRows(batch) != batch.size()) throw new IllegalStateException("商户结账快照写入不完整"); + } + for (int start = 0; start < orders.size(); start += 500) { + List batch = orders.subList(start, Math.min(start + 500, orders.size())); + if (orderMapper.insertRows(batch) != batch.size()) throw new IllegalStateException("结账订单明细写入不完整"); + List claimBatch = claims.subList(start, Math.min(start + 500, claims.size())); + if (claimMapper.insertRows(claimBatch) != claimBatch.size()) throw new IllegalStateException("结账订单占用写入不完整"); + } + ledger.event(billId, null, null, MchCheckoutBillStates.Action.CREATE.name(), request.getRequestId(), Map.of("requestHash", snapshot.getRequestHash(), "orderCount", orders.size(), "grossAmountFen", grossTotal, "feeAmountFen", feeTotal, "payableAmountFen", payableTotal)); + return billId; + } + + /** 所选订单按关键事实逐项重新检查,变化必须重新预览。 */ + private void assertFresh(List original, Map fresh) { + for (SmHotelOrderCheckoutQueryVo row : original) { + SmHotelOrderCheckoutQueryVo current = fresh.get(row.getOrderId()); + if (current == null || !source.eligibility(current).isEmpty() || !source.fingerprint(row).equals(source.fingerprint(current))) throw new IllegalStateException("订单" + row.getOrderId() + "的金额、归属或状态已变化,请重新预览"); + } + } + + /** 先锁主体再校验整组历史权限,后续状态操作共用这一锁序。 */ + private MchCheckoutBillEntity requireLocked(Long id, MchCheckoutRelationScope scope) { + MchCheckoutBillEntity row = billMapper.lockBill(id); + if (row == null) throw new IllegalStateException("结账单不存在或没有完整数据权限"); + ledger.requireVisible(id, scope); + return row; + } + + /** 按原商户主键读取单份账单明细。 */ + private MchCheckoutBillMerchantEntity requireMerchant(Long id, Long merchantId) { + MchCheckoutBillMerchantEntity row = merchantMapper.selectOne(Wrappers.lambdaQuery().eq(MchCheckoutBillMerchantEntity::getBillId, id).eq(MchCheckoutBillMerchantEntity::getMerchantId, merchantId)); + if (row == null) throw new IllegalStateException("商户结账明细不存在"); + return row; + } + + /** 读取同一商户结账明细完整订单,数量受单账单10000笔限制。 */ + private List readOrders(Long memberRowId) { + return orderMapper.selectList(Wrappers.lambdaQuery().eq(MchCheckoutBillOrderEntity::getBillMerchantId, memberRowId).orderByAsc(MchCheckoutBillOrderEntity::getId)); + } + + /** 幂等记录按租户自动隔离读取。 */ + private MchCheckoutBillEntity findCreated(String requestId) { + return billMapper.selectOne(Wrappers.lambdaQuery().eq(MchCheckoutBillEntity::getCreateRequestId, requestId)); + } + + /** 已提交重试校验请求内容,原预览令牌失效后仍可返回同一结果。 */ + private MchCheckoutBillVo replayCreate(MchCheckoutBillEntity existing, MchCheckoutBillRequests.Create request, MchCheckoutRelationScope scope) { + if (!Objects.equals(existing.getPreviewToken(), request.getPreviewToken())) { + MchCheckoutBillSnapshot snapshot = previews.require(request.getPreviewToken(), true); + assertDigest(existing.getRequestHash(), snapshot.getRequestHash()); + } + return ledger.requireVisible(existing.getId(), scope); + } + + /** 付款及作废只接受原版本的待付款明细。 */ + private void assertWaiting(MchCheckoutBillMerchantEntity row, Integer version) { + if (row == null || !Objects.equals(row.getVersion(), version) || !"WAIT_PAY".equals(row.getState())) throw new IllegalStateException("商户结账状态或版本已变化,请刷新后重试"); + } + + /** 同幂等号不同参数明确拒绝。 */ + private void assertDigest(String expected, String actual) { + if (!Objects.equals(expected, actual)) throw new IllegalStateException("同一请求号对应了不同的业务内容,请重新发起操作"); + } + + /** 单行写入失败整体回滚。 */ + private void requireWrite(int count) { + if (count != 1) throw new IllegalStateException("结账状态写入失败"); + } + + /** 可选文本统一去除首尾空格。 */ + private String trim(String value) { + return value == null ? "" : value.trim(); + } + + /** 凭证只保留明确填写的地址,前后顺序进入请求摘要。 */ + private List normalizedPics(List values) { + return values == null ? List.of() : values.stream().map(String::trim).filter(value -> !value.isEmpty()).distinct().toList(); + } + + /** 数据库分页清除外部排序并限制100条。 */ + private Page safe(Page page) { + page.setCurrent(Math.max(1, Math.min(page.getCurrent(), 1000000))); + page.setSize(Math.max(1, Math.min(page.getSize(), 100))); + page.orders().clear(); + return page; + } + + /** 已分页实体转换成独立VO,保持数据库总数。 */ + private Page viewPage(Page data, Class type) { + Page page = new Page<>(data.getCurrent(), data.getSize(), data.getTotal()); + page.setRecords(data.getRecords().stream().map(row -> MchCheckoutBillPreviewService.copy(row, type)).toList()); + return page; + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchSceneServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchSceneServiceImpl.java new file mode 100644 index 000000000..43f5f822e --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchSceneServiceImpl.java @@ -0,0 +1,138 @@ +package com.bsd.pjcloud.wallet.service.impl.mch; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; +import com.bsd.pjcloud.common.core.util.BeanUtils; +import com.bsd.pjcloud.common.data.datascope.DataScope; +import com.bsd.pjcloud.wallet.api.dto.MchManageDto; +import com.bsd.pjcloud.wallet.api.dto.mch.MchSceneDto; +import com.bsd.pjcloud.wallet.api.entity.mch.MchSceneEntity; +import com.bsd.pjcloud.wallet.api.vo.MchManageVo; +import com.bsd.pjcloud.wallet.api.vo.mch.MchSceneVo; +import com.bsd.pjcloud.wallet.mapper.mch.MchSceneMapper; +import com.bsd.pjcloud.wallet.service.MchManageService; +import com.bsd.pjcloud.wallet.service.MchSceneService; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Objects; + +/** + * 商户场景配置实现,依靠租户、商户和场景唯一键维护唯一配置。 + */ +@Service +@RequiredArgsConstructor +public class MchSceneServiceImpl extends ServiceImpl implements MchSceneService { + + /** 酒店抽成场景的持久化编码,首期仅支持该场景。 */ + private static final String HOTEL_COMMISSION_SCENE = "HOTEL_COMMISSION"; + + /** 首次保存未指定金额时使用的固定抽成金额,单位元。 */ + private static final BigDecimal DEFAULT_COMMISSION_AMOUNT = new BigDecimal("0.00"); + + /** 固定抽成金额上限,单位元,与数据库 DECIMAL(10,2) 的范围一致。 */ + private static final BigDecimal MAX_COMMISSION_AMOUNT = new BigDecimal("99999999.99"); + + /** 商户服务,复用当前登录用户的数据范围校验。 */ + private final MchManageService mchManageService; + + /** + * 校验商户访问范围和场景编码后读取唯一配置,未配置时返回空。 + * + * @param mchId 商户 ID + * @param sceneCode 场景持久化编码 + * @return 当前商户场景配置,未配置时返回空 + */ + @Override + public MchSceneVo getForManagement(Long mchId, String sceneCode) { + validateSceneCode(sceneCode); + validateMerchantScope(mchId); + MchSceneEntity entity = baseMapper.selectExact(mchId, sceneCode); + return entity == null ? null : BeanUtils.toBean(entity, MchSceneVo.class); + } + + /** + * 单行写入由 Mapper 原子提交,管理接口不启用跨语句事务。 + * 并发首次保存遇到重复键后重新读取已提交记录,再更新配置和公共审计字段。 + * 老客户端未传金额时保留原值,首次创建时使用零金额。 + * + * @param dto 管理端场景开关和固定抽成金额 + * @return 本次保存后的唯一配置 + */ + @Override + public MchSceneVo saveOrUpdateConfig(MchSceneDto dto) { + validateSceneCode(dto.getSceneCode()); + validateMerchantScope(dto.getMchId()); + if (!Objects.equals(dto.getEnabled(), YesNoEnum.YES.getCode()) + && !Objects.equals(dto.getEnabled(), YesNoEnum.NO.getCode())) { + throw new IllegalArgumentException("是否开启只能为0或1"); + } + BigDecimal commissionAmount = dto.getCommissionAmount(); + if (commissionAmount != null && (commissionAmount.signum() < 0 || commissionAmount.scale() > 2 + || commissionAmount.compareTo(MAX_COMMISSION_AMOUNT) > 0)) { + throw new IllegalArgumentException("抽成金额必须为0至99999999.99元,最多保留两位小数"); + } + + MchSceneEntity target = baseMapper.selectExact(dto.getMchId(), dto.getSceneCode()); + if (target == null) { + target = new MchSceneEntity(); + target.setMchId(dto.getMchId()); + target.setSceneCode(dto.getSceneCode()); + target.setEnabled(dto.getEnabled()); + target.setCommissionAmount(commissionAmount == null ? DEFAULT_COMMISSION_AMOUNT : commissionAmount); + try { + if (baseMapper.insert(target) != 1) { + throw new IllegalStateException("新增场景配置失败"); + } + return BeanUtils.toBean(target, MchSceneVo.class); + } catch (DuplicateKeyException exception) { + target = baseMapper.selectExact(dto.getMchId(), dto.getSceneCode()); + if (target == null) { + throw new IllegalStateException("场景配置已发生变化,请刷新后重试", exception); + } + } + } + + MchSceneEntity changes = new MchSceneEntity(); + changes.setId(target.getId()); + changes.setEnabled(dto.getEnabled()); + changes.setCommissionAmount(commissionAmount); + if (baseMapper.updateById(changes) != 1) { + throw new IllegalStateException("修改场景配置失败,请刷新后重试"); + } + target.setEnabled(dto.getEnabled()); + if (commissionAmount != null) { + target.setCommissionAmount(commissionAmount); + } + return BeanUtils.toBean(target, MchSceneVo.class); + } + + /** + * 复用商户列表的数据范围能力,父店与分店按各自 ID 单独验证访问权限。 + * + * @param mchId 待配置商户 ID + */ + private void validateMerchantScope(Long mchId) { + if (mchId == null) { + throw new IllegalArgumentException("商户不能为空"); + } + List merchants = mchManageService.scopeList(MchManageDto.builder().ids(List.of(mchId)).build(), DataScope.of().only(true)); + if (merchants == null || merchants.stream().noneMatch(item -> Objects.equals(item.getId(), mchId))) { + throw new IllegalArgumentException("无权配置当前商户"); + } + } + + /** + * 校验已支持的持久化场景编码,查询和保存共用同一协议。 + * + * @param sceneCode 场景编码,首期只支持酒店抽成 + */ + private void validateSceneCode(String sceneCode) { + if (!Objects.equals(HOTEL_COMMISSION_SCENE, sceneCode)) { + throw new IllegalArgumentException("当前仅支持酒店抽成场景"); + } + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillCache.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillCache.java new file mode 100644 index 000000000..5106d67a2 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillCache.java @@ -0,0 +1,57 @@ +package com.bsd.pjcloud.wallet.support.mch; + +import com.bsd.pjcloud.common.security.util.SecurityUtils; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Objects; +import java.util.UUID; + +/** 10分钟候选与预览缓存;Redis组件自动加租户命名空间。 */ +@Component +@RequiredArgsConstructor +public class MchCheckoutBillCache { + /** 临时工作集业务命名空间。 */ + private static final String KEY_PREFIX = "mch-checkout:hotel:snapshot:"; + /** 工作集最长存活时间,十分钟。 */ + private static final Duration TTL = Duration.ofMinutes(10); + /** 已有字符串Redis组件,自动应用租户前缀。 */ + private final StringRedisTemplate redis; + /** 已配置时间与长整数协议的统一JSON映射器。 */ + private final ObjectMapper json; + + /** 保存当前操作者工作集,缓存失败时阻止继续提交。 */ + public void save(MchCheckoutBillSnapshot snapshot) { + if (SecurityUtils.getUser() == null) throw new IllegalStateException("缺少有效的管理用户上下文"); + snapshot.setOwnerId(SecurityUtils.getUser().getId()); + snapshot.setToken(UUID.randomUUID().toString()); + snapshot.setExpiresAt(LocalDateTime.now().plus(TTL)); + try { + redis.opsForValue().set(KEY_PREFIX + snapshot.getToken(), json.writeValueAsString(snapshot), TTL); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("结账预览缓存写入失败,请重试", exception); + } + } + + /** 读取本人有效工作集,类别、过期时间及归属全部校验。 */ + public MchCheckoutBillSnapshot require(String token, boolean preview) { + if (SecurityUtils.getUser() == null) throw new IllegalStateException("缺少有效的管理用户上下文"); + if (token == null || !token.matches("[0-9a-fA-F-]{36}")) throw new IllegalArgumentException("结账预览令牌无效"); + String value = redis.opsForValue().get(KEY_PREFIX + token); + if (value == null) throw new IllegalStateException("结账预览已过期,请重新查询并核对"); + try { + MchCheckoutBillSnapshot snapshot = json.readValue(value, MchCheckoutBillSnapshot.class); + if (snapshot.isPreview() != preview || snapshot.getExpiresAt() == null || !snapshot.getExpiresAt().isAfter(LocalDateTime.now()) + || !Objects.equals(snapshot.getOwnerId(), SecurityUtils.getUser().getId())) { + throw new IllegalStateException("结账预览无效或不属于当前用户,请重新查询"); + } + return snapshot; + } catch (JsonProcessingException exception) { + throw new IllegalStateException("结账预览读取失败,请重新查询", exception); + } + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillContext.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillContext.java new file mode 100644 index 000000000..001007d4e --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillContext.java @@ -0,0 +1,100 @@ +package com.bsd.pjcloud.wallet.support.mch; + +import com.bsd.pjcloud.wallet.api.entity.mch.MchCheckoutBillMerchantEntity; +import com.bsd.pjcloud.wallet.api.entity.mch.MchCheckoutRelationEntity; +import com.bsd.pjcloud.wallet.api.vo.mch.MchCheckoutRelationMerchantVo; +import com.bsd.pjcloud.wallet.api.vo.mch.MchCheckoutRelationVo; +import com.bsd.pjcloud.wallet.mapper.mch.*; +import com.bsd.pjcloud.wallet.service.MchCheckoutRelationService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import java.util.*; + +/** 结账关系完整权限及商户部门快照,复用现有范围计算和关系服务。 */ +@Component +@RequiredArgsConstructor +public class MchCheckoutBillContext { + /** 已有关系读取服务,负责完整数据权限及部门名称。 */ + private final MchCheckoutRelationService relationService; + /** 现有关系行锁及版本能力。 */ + private final MchCheckoutRelationMapper relationMapper; + /** 现有商户及绑定读取能力。 */ + private final MchCheckoutRelationMerchantMapper memberMapper; + /** 参与结账的部门唯一性检查。 */ + private final MchCheckoutBillMerchantMapper billMemberMapper; + /** 统一管理用户数据权限。 */ + private final MchCheckoutRelationAccess access; + /** 规范化快照摘要。 */ + private final MchCheckoutBillHotelSource source; + + /** 在一致快照中读取完整关系并拒绝失效、空部门及归属歧义。 */ + @Transactional(readOnly = true) + public MchCheckoutBillSnapshot read(Long relationId, Integer version) { + MchCheckoutRelationVo relation = relationService.detail(relationId); + if (!Objects.equals(relation.getVersion(), version)) throw new IllegalStateException("结账关系已变化,请刷新页面"); + List members = members(relation.getMerchants(), access.requireScope()); + assertUniqueDepartments(members); + MchCheckoutBillSnapshot result = new MchCheckoutBillSnapshot(); + result.setRelationId(relationId); + result.setRelationVersion(version); + result.setIntermediaryName(relation.getIntermediaryName()); + result.setMerchants(members); + return result; + } + + /** 提交事务内先锁关系,再按商户ID锁定并读取当前归属,防止旧快照覆盖变更。 */ + @Transactional(propagation = Propagation.MANDATORY) + public void lockAndValidate(MchCheckoutBillSnapshot snapshot, MchCheckoutRelationScope scope) { + MchCheckoutRelationEntity relation = relationMapper.lockRelation(snapshot.getRelationId()); + if (relation == null || !Objects.equals(relation.getVersion(), snapshot.getRelationVersion())) throw new IllegalStateException("结账关系已变化,请重新预览"); + List ids = memberMapper.listMembers(relation.getId()).stream().map(MchCheckoutRelationMerchantVo::getMerchantId).sorted().toList(); + List current = memberMapper.listExact(ids, true); + if (current.size() != ids.size() || current.stream().anyMatch(row -> !Objects.equals(row.getBoundRelationId(), relation.getId()))) throw new IllegalStateException("结账关系商户绑定已变化"); + List locked = members(current, scope); + assertUniqueDepartments(locked); + if (!membershipHash(locked).equals(membershipHash(snapshot.getMerchants()))) throw new IllegalStateException("商户名称、归属或权限资料已变化,请重新预览"); + } + + /** 读取临时工作集时再次校验当前关系,撤权和关系编辑立即使工作集失效。 */ + public void validateSnapshot(MchCheckoutBillSnapshot snapshot) { + MchCheckoutBillSnapshot current = read(snapshot.getRelationId(), snapshot.getRelationVersion()); + if (!membershipHash(current.getMerchants()).equals(membershipHash(snapshot.getMerchants()))) throw new IllegalStateException("关系商户资料已变化,请重新查询"); + } + + /** 摘要包含完整权限归属,不依赖商户分页或当前金额。 */ + public String membershipHash(List members) { + return source.hash(members.stream().sorted(Comparator.comparing(MchCheckoutBillMerchantEntity::getMerchantId)) + .map(row -> Arrays.asList(row.getMerchantId(), row.getMerchantName(), row.getDeptId(), row.getMerchantCreateBy())).toList()); + } + + /** 构造全部商户权限快照,任何异常成员都使本次发起失败。 */ + private List members(List rows, MchCheckoutRelationScope scope) { + if (rows == null || rows.isEmpty() || rows.size() > 1000) throw new IllegalStateException("结账关系商户名单无效"); + List result = new ArrayList<>(); + for (MchCheckoutRelationMerchantVo row : rows) { + if (!scope.allows(row) || row.getRecordId() == null || !"0".equals(row.getMerchantDelFlag()) || row.getDeptId() == null || row.getMerchantName() == null || row.getMerchantName().isBlank()) throw new IllegalStateException("结账关系包含无权、失效、部门或名称不完整的商户,请先维护关系"); + MchCheckoutBillMerchantEntity member = new MchCheckoutBillMerchantEntity(); + member.setMerchantId(row.getMerchantId()); + member.setMerchantName(row.getMerchantName()); + member.setDeptId(row.getDeptId()); + member.setDeptName(row.getDeptName()); + member.setMerchantCreateBy(row.getMerchantCreateBy()); + member.setOrderCount(0); + member.setGrossAmountFen(0L); + member.setFeeAmountFen(0L); + member.setPayableAmountFen(0L); + member.setState("NO_ORDERS"); + member.setVersion(0); + result.add(member); + } + return result; + } + + /** 同部门多商户会造成收款归属歧义,必须在结账前清理。 */ + private void assertUniqueDepartments(List members) { + List ids = members.stream().map(MchCheckoutBillMerchantEntity::getDeptId).toList(); + if (new HashSet<>(ids).size() != ids.size() || billMemberMapper.ambiguousDepartment(ids) != null) throw new IllegalStateException("参与结账的部门对应多个绑定商户,请先明确唯一归属"); + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillHotelSource.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillHotelSource.java new file mode 100644 index 000000000..b60765c92 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillHotelSource.java @@ -0,0 +1,112 @@ +package com.bsd.pjcloud.wallet.support.mch; + +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.supermarket.api.feign.RemoteSmHotelOrderCheckoutQueryService; +import com.bsd.pjcloud.supermarket.dto.SmHotelOrderCheckoutQueryDto; +import com.bsd.pjcloud.supermarket.vo.SmHotelOrderCheckoutQueryVo; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDate; +import java.util.*; + +/** 酒店只读适配,严格校验完整批次,禁止把异常或漏单当成空结果。 */ +@Component +@RequiredArgsConstructor +public class MchCheckoutBillHotelSource { + /** 单次完整工作集上限,超量明确拆批。 */ + public static final int MAX_ORDERS = 10000; + /** Feign及后台核对的单批订单上限。 */ + public static final int BATCH_SIZE = 500; + /** 酒店独立只读Feign。 */ + private final RemoteSmHotelOrderCheckoutQueryService remote; + /** 稳定源事实序列化。 */ + private final ObjectMapper json; + + /** 完整扫描部门及周期范围,后续批次必须保持首批主键上界。 */ + public List scan(List departments, LocalDate start, LocalDate end) { + SmHotelOrderCheckoutQueryDto.Scan request = new SmHotelOrderCheckoutQueryDto.Scan(); + request.setDeptIds(departments); + request.setPeriodStart(start); + request.setPeriodEnd(end); + List rows = new ArrayList<>(); + Set seen = new HashSet<>(); + while (true) { + SmHotelOrderCheckoutQueryVo.ScanResult result = require(remote.scan(request)); + if (result.getUpperId() == null || result.getRecords() == null || result.getRecords().size() > BATCH_SIZE + || (request.getUpperId() != null && !request.getUpperId().equals(result.getUpperId()))) { + throw new IllegalStateException("酒店订单扫描协议异常,请重试"); + } + request.setUpperId(result.getUpperId()); + for (SmHotelOrderCheckoutQueryVo row : result.getRecords()) { + if (row.getOrderId() == null || row.getOrderId() <= request.getAfterId() || row.getOrderId() > request.getUpperId() || !seen.add(row.getOrderId())) { + throw new IllegalStateException("酒店订单游标异常,请重试"); + } + request.setAfterId(row.getOrderId()); + rows.add(row); + if (rows.size() > MAX_ORDERS) throw new IllegalArgumentException("周期内酒店订单超过10000笔,请缩小周期或拆分结账"); + } + if (result.getRecords().size() < BATCH_SIZE) return rows; + } + } + + /** 分批读取全部明确主键,缺失项也必须有逐项响应。 */ + public Map fetch(List ids) { + Map result = new LinkedHashMap<>(); + for (int start = 0; start < ids.size(); start += BATCH_SIZE) { + List batch = ids.subList(start, Math.min(ids.size(), start + BATCH_SIZE)); + SmHotelOrderCheckoutQueryDto.ByIds request = new SmHotelOrderCheckoutQueryDto.ByIds(); + request.setOrderIds(batch); + Set expected = new HashSet<>(batch); + for (SmHotelOrderCheckoutQueryVo row : require(remote.byIds(request))) { + if (row.getOrderId() == null || !expected.remove(row.getOrderId()) || result.put(row.getOrderId(), row) != null) { + throw new IllegalStateException("酒店订单批量复核出现重复或额外记录"); + } + } + if (!expected.isEmpty()) throw new IllegalStateException("酒店订单批量复核漏单,请重试"); + } + return result; + } + + /** 只使用影响结账的事实计算摘要,联系人及无关补偿日志不参与。 */ + public String fingerprint(SmHotelOrderCheckoutQueryVo row) { + return hash(Arrays.asList(row.getOrderId(), row.isFound(), row.getResolutionStatus(), row.getHotelId(), row.getDeptId(), + row.getCheckInDate(), row.getCheckOutDate(), row.getRooms(), row.getSellAmountYuan(), row.getOrderStatus(), row.getBookStatus(), + row.getPayStatus(), row.getPartnerCode(), row.getDownstreamAppId(), row.getChannelNo(), row.getPlatformNo(), row.getHotelDataSource())); + } + + /** 确定性JSON摘要,用于源事实和客户端请求幂等。 */ + public String hash(Object value) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(json.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS).writeValueAsBytes(value)); + return HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException | JsonProcessingException exception) { + throw new IllegalStateException("结账内容摘要计算失败", exception); + } + } + + /** 返回不可进入人工核对池的原因,空字符串表示允许人工确认。 */ + public String eligibility(SmHotelOrderCheckoutQueryVo row) { + if (!row.isFound()) return "酒店订单已删除或不存在"; + if (!"OK".equals(row.getResolutionStatus()) || row.getDeptId() == null) return "酒店归属缺失或无法唯一确定"; + if (!Set.of("UPSTREAM_OK", "CHECKED_IN").contains(Objects.toString(row.getOrderStatus(), ""))) return "订单尚未成功履约或已经取消、失败"; + String pay = Objects.toString(row.getPayStatus(), "").trim().toUpperCase(Locale.ROOT); + if (Set.of("UNPAID", "REFUNDED", "REFUNDING", "REFUND_PROCESSING").contains(pay) || "X".equalsIgnoreCase(row.getBookStatus())) return "订单明确未支付、退款中或已取消退款"; + try { + if (MchCheckoutBillMoney.fen(row.getSellAmountYuan(), "订单金额") <= 0) return "订单金额必须大于0"; + } catch (IllegalArgumentException exception) { + return exception.getMessage(); + } + return ""; + } + + /** 远程业务失败或空数据必须传播,不能当作空订单集合。 */ + private T require(R response) { + if (response == null || !response.isOk() || response.getData() == null) throw new IllegalStateException("酒店订单读取失败,请重试"); + return response.getData(); + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillLedger.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillLedger.java new file mode 100644 index 000000000..9282f6c38 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillLedger.java @@ -0,0 +1,71 @@ +package com.bsd.pjcloud.wallet.support.mch; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.bsd.pjcloud.common.security.util.SecurityUtils; +import com.bsd.pjcloud.wallet.api.entity.mch.*; +import com.bsd.pjcloud.wallet.api.vo.mch.MchCheckoutBillVo; +import com.bsd.pjcloud.wallet.mapper.mch.*; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import java.time.LocalDateTime; +import java.util.*; + +/** 财务账本的授权、状态汇总和追加式审计,写入由调用方本地事务保护。 */ +@Component +@RequiredArgsConstructor +public class MchCheckoutBillLedger { + /** 账单主体访问。 */ + private final MchCheckoutBillMapper bills; + /** 全部商户明细访问。 */ + private final MchCheckoutBillMerchantMapper merchants; + /** 追加式审计访问。 */ + private final MchCheckoutBillEventMapper events; + /** 审计JSON编码。 */ + private final ObjectMapper json; + /** 规范化请求摘要能力。 */ + private final MchCheckoutBillHotelSource source; + + /** 同一错误覆盖无权和不存在,避免泄露历史中间方信息。 */ + public MchCheckoutBillVo requireVisible(Long id, MchCheckoutRelationScope scope) { + MchCheckoutBillVo bill = bills.findVisible(id, scope); + if (bill == null) throw new IllegalStateException("结账单不存在或没有完整数据权限"); + return bill; + } + + /** 在账单主体锁内汇总有效明细,原始金额快照保持原值。 */ + public void refreshState(MchCheckoutBillEntity bill) { + List rows = merchants.selectList(Wrappers.lambdaQuery().eq(MchCheckoutBillMerchantEntity::getBillId, bill.getId())); + long waiting = rows.stream().filter(row -> "WAIT_PAY".equals(row.getState())).count(); + long paid = rows.stream().filter(row -> "PAID".equals(row.getState())).count(); + String state = waiting > 0 ? (paid > 0 ? "PART_PAID" : "WAIT_PAY") : (paid > 0 ? "FINISHED" : "VOID"); + bill.setState(state); + bill.setVersion(Math.addExact(bill.getVersion(), 1)); + bill.setUpdateTime(LocalDateTime.now()); + if (bills.updateById(bill) != 1) throw new IllegalStateException("账单状态更新失败"); + } + + /** 在当前业务事务中记录依据和结果,事件与状态一起提交。 */ + public void event(Long billId, Long merchantRowId, Long orderId, String action, String requestId, Object payload) { + MchCheckoutBillEventEntity event = new MchCheckoutBillEventEntity(); + event.setBillId(billId); + event.setBillMerchantId(merchantRowId); + event.setSourceOrderId(orderId); + event.setAction(action); + event.setRequestId(requestId); + event.setRequestHash(source.hash(payload)); + event.setPayload(encode(payload)); + event.setCreateBy(SecurityUtils.getUser() == null ? "system" : SecurityUtils.getUser().getUsername()); + if (events.insert(event) != 1) throw new IllegalStateException("结账审计事件写入失败"); + } + + /** 编码限定的业务模型,JSON错误会回滚当前事务。 */ + public String encode(Object value) { + try { + return json.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("结账业务依据编码失败", exception); + } + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillMoney.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillMoney.java new file mode 100644 index 000000000..65305baa7 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillMoney.java @@ -0,0 +1,38 @@ +package com.bsd.pjcloud.wallet.support.mch; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +/** 结账精确金额运算,所有持久化与汇总均使用分。 */ +public final class MchCheckoutBillMoney { + /** 工具类不创建实例。 */ + private MchCheckoutBillMoney() { } + + /** 将元精确转换为分,超精度、负值及溢出明确拒绝。 */ + public static long fen(String yuan, String field) { + if (yuan == null || yuan.isBlank()) throw new IllegalArgumentException(field + "缺失"); + try { + long value = new BigDecimal(yuan).setScale(2, RoundingMode.UNNECESSARY).movePointRight(2).longValueExact(); + if (value < 0) throw new IllegalArgumentException(field + "不能为负数"); + return value; + } catch (NumberFormatException | ArithmeticException exception) { + throw new IllegalArgumentException(field + "必须精确到分且在支持范围内", exception); + } + } + + /** 正价订单减去固定每单抽成,结果可以为零。 */ + public static long payable(long gross, long fee) { + if (gross <= 0) throw new IllegalArgumentException("订单金额必须大于0"); + if (fee < 0 || fee > gross) throw new IllegalArgumentException("每单抽成不能超过任一所选订单金额"); + return Math.subtractExact(gross, fee); + } + + /** 安全加总分金额,溢出时整次计算失败。 */ + public static long add(long left, long right) { + try { + return Math.addExact(left, right); + } catch (ArithmeticException exception) { + throw new IllegalArgumentException("结账合计超出金额支持范围,请拆分结账", exception); + } + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillSnapshot.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillSnapshot.java new file mode 100644 index 000000000..7f77806bd --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillSnapshot.java @@ -0,0 +1,43 @@ +package com.bsd.pjcloud.wallet.support.mch; + +import com.bsd.pjcloud.supermarket.vo.SmHotelOrderCheckoutQueryVo; +import com.bsd.pjcloud.wallet.api.entity.mch.MchCheckoutBillMerchantEntity; +import lombok.Data; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** 钱包内部临时工作集,通过令牌访问,完整名单与源事实不依赖单页结果。 */ +@Data +public class MchCheckoutBillSnapshot { + /** 令牌所属管理用户,禁止跨用户读取。 */ + private Long ownerId; + /** 随机令牌,仅作为临时工作集身份。 */ + private String token; + /** 是否为已经人工核对的确认预览。 */ + private boolean preview; + /** 原结账关系主键。 */ + private Long relationId; + /** 原结账关系版本。 */ + private Integer relationVersion; + /** 原中间方名称。 */ + private String intermediaryName; + /** 离店周期起点,包含当天。 */ + private LocalDate periodStart; + /** 离店周期终点,包含当天。 */ + private LocalDate periodEnd; + /** 服务端令牌过期时间。 */ + private LocalDateTime expiresAt; + /** 最近一次完整读取或复核源事实的时间。 */ + private LocalDateTime sourceVerifiedAt; + /** 每单抽成,单位分,候选为0。 */ + private Long feePerOrderFen; + /** 本次结账备注。 */ + private String remark; + /** 创建内容摘要,包含明确名单、源事实及关系完整归属快照。 */ + private String requestHash; + /** 全部关系成员权限快照,预览包含各商户金额。 */ + private List merchants; + /** 完整源事实,最多10000笔,预览仅包含明确选择的订单。 */ + private List orders; +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/WalletManageMapper.xml b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/WalletManageMapper.xml index 659f04072..8b7c2725c 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/WalletManageMapper.xml +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/WalletManageMapper.xml @@ -46,51 +46,85 @@ + + + SELECT tx.user_id, + tx.wallet_id, + source_wallet.wallet_type_id, + tx.balance, + tx.create_time, + ROW_NUMBER() OVER ( + PARTITION BY tx.user_id, source_wallet.wallet_type_id + ORDER BY tx.create_time DESC, tx.id DESC + ) AS row_num + FROM wallet_transaction tx + INNER JOIN wallet_manage source_wallet ON source_wallet.id = tx.wallet_id + WHERE tx.del_flag = '0' + AND tx.create_time < #{monthEndExclusive} + + AND tx.user_id = #{dto.userId} + + + AND tx.user_id IN + + #{userId} + + + + AND source_wallet.wallet_type_id = #{dto.walletTypeId} + + + + + + @@ -218,7 +252,7 @@ m.wallet_type_id, FIRST_VALUE(a.balance) OVER ( PARTITION BY a.user_id, m.wallet_type_id - ORDER BY a.create_time DESC + ORDER BY a.create_time DESC, a.id DESC ) as balance FROM wallet_transaction a INNER JOIN wallet_manage m diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/mch/MchCheckoutBillMapper.xml b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/mch/MchCheckoutBillMapper.xml new file mode 100644 index 000000000..fb3c240cc --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/mch/MchCheckoutBillMapper.xml @@ -0,0 +1,39 @@ + + + + + + 1 = 1 + + AND NOT EXISTS (SELECT 1 FROM mch_checkout_bill_merchant m WHERE m.bill_id = b.id AND NOT COALESCE((1 = 0 + + OR m.dept_id IN #{dept} + + OR m.merchant_create_by = #{scope.username} + ), 0)) + + + + b.*, + (SELECT COALESCE(SUM(m.payable_amount_fen), 0) FROM mch_checkout_bill_merchant m WHERE m.bill_id=b.id AND m.state != 'VOID') AS effective_payable_amount_fen, + (SELECT COALESCE(SUM(m.paid_amount_fen), 0) FROM mch_checkout_bill_merchant m WHERE m.bill_id=b.id AND m.state='PAID') AS paid_amount_fen, + (SELECT COALESCE(SUM(m.payable_amount_fen), 0) FROM mch_checkout_bill_merchant m WHERE m.bill_id=b.id AND m.state='WAIT_PAY') AS pending_amount_fen, + (SELECT COALESCE(SUM(m.payable_amount_fen), 0) FROM mch_checkout_bill_merchant m WHERE m.bill_id=b.id AND m.state='VOID') AS void_amount_fen, + (SELECT COUNT(*) FROM mch_checkout_bill_merchant m WHERE m.bill_id=b.id AND m.state='VOID') AS void_merchant_count, + (SELECT COUNT(*) FROM mch_checkout_bill_order o JOIN mch_checkout_bill_merchant m ON m.id=o.bill_merchant_id + WHERE o.bill_id=b.id AND m.state != 'VOID' AND o.last_check_status IN ('DIFF','QUERY_FAILED')) AS anomaly_count + + + + + diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/mch/MchCheckoutBillMerchantMapper.xml b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/mch/MchCheckoutBillMerchantMapper.xml new file mode 100644 index 000000000..15dd60762 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/mch/MchCheckoutBillMerchantMapper.xml @@ -0,0 +1,18 @@ + + + + + + + INSERT INTO mch_checkout_bill_merchant (id, bill_id, merchant_id, merchant_name, dept_id, dept_name, merchant_create_by, order_count, gross_amount_fen, fee_amount_fen, payable_amount_fen, state, version, payment_time, paid_amount_fen, payment_no, payment_pics, payment_remark, payment_request_id, payment_request_hash, void_reason, void_request_id, void_request_hash, create_by, create_time, update_by, update_time) + VALUES (#{row.id}, #{row.billId}, #{row.merchantId}, #{row.merchantName}, #{row.deptId}, #{row.deptName}, #{row.merchantCreateBy}, #{row.orderCount}, #{row.grossAmountFen}, #{row.feeAmountFen}, #{row.payableAmountFen}, #{row.state}, #{row.version}, #{row.paymentTime}, #{row.paidAmountFen}, #{row.paymentNo}, #{row.paymentPics}, #{row.paymentRemark}, #{row.paymentRequestId}, #{row.paymentRequestHash}, #{row.voidReason}, #{row.voidRequestId}, #{row.voidRequestHash}, #{row.createBy}, #{row.createTime}, #{row.updateBy}, #{row.updateTime}) + + diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/mch/MchCheckoutBillOrderMapper.xml b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/mch/MchCheckoutBillOrderMapper.xml new file mode 100644 index 000000000..50c7337f8 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/mch/MchCheckoutBillOrderMapper.xml @@ -0,0 +1,11 @@ + + + + + + INSERT INTO mch_checkout_bill_order (id, bill_id, bill_merchant_id, merchant_id, source_type, source_order_id, hotel_id, hotel_name, dept_id, channel_no, platform_no, check_in_date, check_out_date, rooms, gross_amount_fen, fee_amount_fen, payable_amount_fen, order_status, book_status, pay_status, source_updated_at, source_fingerprint, source_snapshot, confirmed_user_id, confirmed_by, confirmed_at, last_check_status, last_check_message, last_observed_fingerprint, handled_fingerprint, last_checked_at, last_verified_at, next_check_at, create_by, create_time, update_by, update_time) + VALUES (#{row.id}, #{row.billId}, #{row.billMerchantId}, #{row.merchantId}, #{row.sourceType}, #{row.sourceOrderId}, #{row.hotelId}, #{row.hotelName}, #{row.deptId}, #{row.channelNo}, #{row.platformNo}, #{row.checkInDate}, #{row.checkOutDate}, #{row.rooms}, #{row.grossAmountFen}, #{row.feeAmountFen}, #{row.payableAmountFen}, #{row.orderStatus}, #{row.bookStatus}, #{row.payStatus}, #{row.sourceUpdatedAt}, #{row.sourceFingerprint}, #{row.sourceSnapshot}, #{row.confirmedUserId}, #{row.confirmedBy}, #{row.confirmedAt}, #{row.lastCheckStatus}, #{row.lastCheckMessage}, #{row.lastObservedFingerprint}, #{row.handledFingerprint}, #{row.lastCheckedAt}, #{row.lastVerifiedAt}, #{row.nextCheckAt}, #{row.createBy}, #{row.createTime}, #{row.updateBy}, #{row.updateTime}) + + diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/mch/MchCheckoutOrderClaimMapper.xml b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/mch/MchCheckoutOrderClaimMapper.xml new file mode 100644 index 000000000..c4f4e6039 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/mch/MchCheckoutOrderClaimMapper.xml @@ -0,0 +1,8 @@ + + + + + INSERT INTO mch_checkout_order_claim (id, source_type, source_order_id, bill_id, bill_merchant_id, create_by, create_time) + VALUES (#{row.id}, #{row.sourceType}, #{row.sourceOrderId}, #{row.billId}, #{row.billMerchantId}, #{row.createBy}, #{row.createTime}) + + diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/mapper/mch/MchCheckoutBillDatabaseTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/mapper/mch/MchCheckoutBillDatabaseTest.java new file mode 100644 index 000000000..2e62ccdeb --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/mapper/mch/MchCheckoutBillDatabaseTest.java @@ -0,0 +1,168 @@ +package com.bsd.pjcloud.wallet.mapper.mch; + +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import java.nio.file.*; +import java.sql.*; +import java.util.*; +import java.util.concurrent.*; +import static org.junit.jupiter.api.Assertions.*; + +/** 真实MySQL财务约束和行锁回归,只在显式指定的隔离测试库中执行。 */ +@EnabledIfEnvironmentVariable(named = "CHECKOUT_BILL_TEST_JDBC_URL", matches = "jdbc:mysql:.*") +class MchCheckoutBillDatabaseTest { + /** 实际迁移中的五张表名,本次均替换为唯一随机表名。 */ + private static final List TABLES = List.of("mch_checkout_bill", "mch_checkout_bill_merchant", "mch_checkout_bill_order", "mch_checkout_order_claim", "mch_checkout_bill_event"); + /** 仅允许清理本测试创建的随机表。 */ + private static final Map fixtures = new LinkedHashMap<>(); + /** 通过隔离测试库名称检查后才允许建表。 */ + private static boolean fixtureAllowed; + + /** 读取实际迁移并重复建表,校验脚本可重跑。 */ + @BeforeAll + static void createFixture() throws Exception { + String suffix = UUID.randomUUID().toString().replace("-", "").substring(0, 20); + Path cursor = Path.of(System.getProperty("user.dir")).toAbsolutePath(); + while (cursor != null && !Files.exists(cursor.resolve("db/20261109_add_mch_checkout_bill.sql"))) cursor = cursor.getParent(); + assertNotNull(cursor, "从源码工程执行以读取实际迁移"); + String sql = Files.readString(cursor.resolve("db/20261109_add_mch_checkout_bill.sql")).replaceAll("(?m)^--.*$", ""); + for (String table : TABLES) { + fixtures.put(table, table + "_" + suffix); + sql = sql.replace("`" + table + "`", "`" + fixtures.get(table) + "`"); + } + sql = sql.replaceAll("CONSTRAINT (ck_checkout_[a-z_]+)", "CONSTRAINT $1_" + suffix); + try (Connection connection = connection()) { + assertNotNull(connection.getCatalog()); + assertTrue(connection.getCatalog().endsWith("_checkout_bill_test"), "测试库名必须以_checkout_bill_test结尾"); + fixtureAllowed = true; + for (int repeat = 0; repeat < 2; repeat++) { + for (String statement : sql.split(";")) if (!statement.isBlank()) execute(connection, statement); + } + } + } + + /** 清理范围仅限本次生成的随机表。 */ + @AfterAll + static void dropFixture() throws Exception { + if (!fixtureAllowed) return; + try (Connection connection = connection()) { + for (String table : fixtures.values()) execute(connection, "DROP TABLE IF EXISTS `" + table + "`"); + } + } + + /** 两个真实连接并发占用同一源订单,唯一键只允许一方提交。 */ + @Test + void concurrentOrderClaimsShouldAllowOneWinner() throws Exception { + ExecutorService executor = Executors.newSingleThreadExecutor(); + try (Connection first = connection()) { + first.setAutoCommit(false); + execute(first, claim(101, 901, 1, 11)); + CountDownLatch attempting = new CountDownLatch(1); + Future other = executor.submit(() -> { + try (Connection second = connection()) { + attempting.countDown(); + try { execute(second, claim(102, 901, 2, 22)); return "unexpected-success"; } + catch (SQLException exception) { return exception.getSQLState(); } + } + }); + assertTrue(attempting.await(5, TimeUnit.SECONDS)); + first.commit(); + assertEquals("23000", other.get(10, TimeUnit.SECONDS)); + assertEquals(1, scalar(first, "SELECT COUNT(*) FROM " + table("mch_checkout_order_claim") + " WHERE source_order_id=901")); + } finally { executor.shutdownNow(); } + } + + /** 主表插入后的占用冲突回滚必须撤销账单,原占用保留。 */ + @Test + void orderConflictShouldRollBackTheWholeBill() throws Exception { + try (Connection connection = connection()) { + execute(connection, claim(201, 902, 3, 33)); + connection.setAutoCommit(false); + execute(connection, bill(202, "rollback-request")); + assertThrows(SQLException.class, () -> execute(connection, claim(203, 902, 202, 44))); + connection.rollback(); + assertEquals(0, scalar(connection, "SELECT COUNT(*) FROM " + table("mch_checkout_bill") + " WHERE id=202")); + assertEquals(1, scalar(connection, "SELECT COUNT(*) FROM " + table("mch_checkout_order_claim") + " WHERE id=201")); + } + } + + /** 真实行锁下付款与旧版本作废竞争,已付款明细保留原占用。 */ + @Test + void paymentShouldPreventAConcurrentStaleVoid() throws Exception { + ExecutorService executor = Executors.newSingleThreadExecutor(); + try (Connection first = connection()) { + execute(first, merchant(301, 300, 1)); + execute(first, claim(302, 903, 300, 301)); + first.setAutoCommit(false); + try (Statement statement = first.createStatement(); ResultSet result = statement.executeQuery("SELECT id FROM " + table("mch_checkout_bill_merchant") + " WHERE id=301 FOR UPDATE")) { assertTrue(result.next()); } + execute(first, "UPDATE " + table("mch_checkout_bill_merchant") + " SET state='PAID',paid_amount_fen=9500,payment_time=NOW(),version=1 WHERE id=301 AND version=0 AND state='WAIT_PAY'"); + CountDownLatch attempting = new CountDownLatch(1); + Future other = executor.submit(() -> { + try (Connection second = connection()) { + attempting.countDown(); + return execute(second, "UPDATE " + table("mch_checkout_bill_merchant") + " SET state='VOID',version=1 WHERE id=301 AND version=0 AND state='WAIT_PAY'"); + } + }); + assertTrue(attempting.await(5, TimeUnit.SECONDS)); + first.commit(); + assertEquals(0, other.get(10, TimeUnit.SECONDS)); + assertEquals(1, scalar(first, "SELECT COUNT(*) FROM " + table("mch_checkout_order_claim") + " WHERE bill_merchant_id=301")); + } finally { executor.shutdownNow(); } + } + + /** 创建请求号和每层金额等式由真实数据库兜底。 */ + @Test + void idempotencyAndAmountInvariantsShouldBeEnforced() throws Exception { + try (Connection connection = connection()) { + execute(connection, bill(401, "same-request")); + assertThrows(SQLException.class, () -> execute(connection, bill(402, "same-request"))); + execute(connection, merchant(403, 401, 1)); + assertThrows(SQLException.class, () -> execute(connection, "UPDATE " + table("mch_checkout_bill_merchant") + " SET payable_amount_fen=1 WHERE id=403")); + assertEquals(9500, scalar(connection, "SELECT payable_amount_fen FROM " + table("mch_checkout_bill_merchant") + " WHERE id=403")); + } + } + + /** 同一源主键允许不同租户独立占用,同租户重复仍由唯一键拒绝。 */ + @Test + void orderClaimsShouldBeIsolatedAcrossTenants() throws Exception { + try (Connection connection = connection()) { + execute(connection, claim(501, 904, 500, 5001)); + execute(connection, "INSERT INTO " + table("mch_checkout_order_claim") + " (id,source_type,source_order_id,bill_id,bill_merchant_id,tenant_id) VALUES (502,'HOTEL',904,501,5002,2)"); + assertThrows(SQLException.class, () -> execute(connection, claim(503, 904, 502, 5003))); + assertEquals(2, scalar(connection, "SELECT COUNT(*) FROM " + table("mch_checkout_order_claim") + " WHERE source_order_id=904")); + } + } + + /** 仅使用显式测试环境变量,禁止读取应用实际数据库配置。 */ + private static Connection connection() throws SQLException { + return DriverManager.getConnection(System.getenv("CHECKOUT_BILL_TEST_JDBC_URL"), Objects.toString(System.getenv("CHECKOUT_BILL_TEST_JDBC_USER"), ""), Objects.toString(System.getenv("CHECKOUT_BILL_TEST_JDBC_PASSWORD"), "")); + } + + /** 仅返回本测试生成的标识符。 */ + private static String table(String name) { return "`" + fixtures.get(name) + "`"; } + + /** 执行固定测试SQL,返回影响行数。 */ + private static int execute(Connection connection, String sql) throws SQLException { + try (Statement statement = connection.createStatement()) { return statement.executeUpdate(sql); } + } + + /** 查询一个整型约束验证值。 */ + private static long scalar(Connection connection, String sql) throws SQLException { + try (Statement statement = connection.createStatement(); ResultSet result = statement.executeQuery(sql)) { assertTrue(result.next()); return result.getLong(1); } + } + + /** 构造固定酒店占用测试值。 */ + private static String claim(long id, long orderId, long billId, long merchantRowId) { + return "INSERT INTO " + table("mch_checkout_order_claim") + " (id,source_type,source_order_id,bill_id,bill_merchant_id,tenant_id) VALUES (" + id + ",'HOTEL'," + orderId + "," + billId + "," + merchantRowId + ",1)"; + } + + /** 构造金额满足等式的商户明细。 */ + private static String merchant(long id, long billId, long merchantId) { + return "INSERT INTO " + table("mch_checkout_bill_merchant") + " (id,bill_id,merchant_id,merchant_name,dept_id,order_count,gross_amount_fen,fee_amount_fen,payable_amount_fen,state,version,tenant_id) VALUES (" + id + "," + billId + "," + merchantId + ",'测试商户',11,1,10000,500,9500,'WAIT_PAY',0,1)"; + } + + /** 构造最小真实账单,参数均由本测试固定生成。 */ + private static String bill(long id, String requestId) { + return "INSERT INTO " + table("mch_checkout_bill") + " (id,bill_no,source_type,relation_id,relation_version,intermediary_name,period_start,period_end,rule_version,fee_per_order_fen,order_count,merchant_count,gross_amount_fen,fee_amount_fen,payable_amount_fen,state,version,create_request_id,request_hash,preview_token,source_verified_at,tenant_id) VALUES (" + id + ",'TEST" + id + "','HOTEL',1,0,'测试结账人','2026-10-01','2026-10-31',1,500,1,1,10000,500,9500,'WAIT_PAY',0,'" + requestId + "','" + "0".repeat(64) + "','preview',NOW(),1)"; + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/mapper/mch/MchCheckoutBillScopeSqlTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/mapper/mch/MchCheckoutBillScopeSqlTest.java new file mode 100644 index 000000000..ed0c962d5 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/mapper/mch/MchCheckoutBillScopeSqlTest.java @@ -0,0 +1,64 @@ +package com.bsd.pjcloud.wallet.mapper.mch; + +import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler; +import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor; +import com.bsd.pjcloud.wallet.api.dto.mch.MchCheckoutBillRequests; +import com.bsd.pjcloud.wallet.api.entity.mch.MchCheckoutOrderClaimEntity; +import com.bsd.pjcloud.wallet.support.mch.MchCheckoutRelationScope; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.LongValue; +import org.apache.ibatis.builder.xml.XMLMapperBuilder; +import org.apache.ibatis.mapping.BoundSql; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.Test; +import java.io.InputStream; +import java.util.List; +import java.util.Map; +import static org.junit.jupiter.api.Assertions.*; + +/** 实际Mapper动态SQL回归,检查整组权限位置及批量VALUES租户补全。 */ +class MchCheckoutBillScopeSqlTest { + /** 部门和本人权限都在数据库分页前通过完整商户快照限制。 */ + @Test + void groupScopeShouldBeAppliedBeforePagination() throws Exception { + Configuration configuration = mapper("MchCheckoutBillMapper"); + BoundSql department = configuration.getMappedStatement("com.bsd.pjcloud.wallet.mapper.mch.MchCheckoutBillMapper.pageVisible") + .getBoundSql(Map.of("query", new MchCheckoutBillRequests.ListQuery(), "scope", new MchCheckoutRelationScope(false, List.of(11L), null))); + String sql = department.getSql(); + assertTrue(sql.contains("NOT EXISTS")); + assertTrue(sql.contains("m.dept_id IN")); + assertTrue(sql.indexOf("NOT EXISTS") < sql.indexOf("ORDER BY")); + assertTrue(department.getParameterMappings().stream().anyMatch(parameter -> parameter.getProperty().contains("dept"))); + BoundSql self = configuration.getMappedStatement("com.bsd.pjcloud.wallet.mapper.mch.MchCheckoutBillMapper.pageVisible") + .getBoundSql(Map.of("query", new MchCheckoutBillRequests.ListQuery(), "scope", new MchCheckoutRelationScope(false, List.of(), "account"))); + assertTrue(self.getSql().contains("m.merchant_create_by = ?")); + assertTrue(self.getParameterMappings().stream().anyMatch(parameter -> parameter.getProperty().equals("scope.username"))); + } + + /** 真实双行插入经同类租户拦截器重写后,两行均有租户值。 */ + @Test + void everyClaimRowShouldReceiveTenantValue() throws Exception { + Configuration configuration = mapper("MchCheckoutOrderClaimMapper"); + BoundSql bound = configuration.getMappedStatement("com.bsd.pjcloud.wallet.mapper.mch.MchCheckoutOrderClaimMapper.insertRows") + .getBoundSql(Map.of("rows", List.of(new MchCheckoutOrderClaimEntity(), new MchCheckoutOrderClaimEntity()))); + TenantLineInnerInterceptor interceptor = new TenantLineInnerInterceptor(new TenantLineHandler() { + /** 固定测试租户值,业务代码仍由项目上下文提供。 */ + @Override + public Expression getTenantId() { return new LongValue(77); } + }); + String rewritten = interceptor.parserSingle(bound.getSql(), null); + assertTrue(rewritten.contains("tenant_id")); + assertEquals(2, rewritten.split("\\b77\\b", -1).length - 1); + } + + /** 从实际资源读取Mapper,验证动态片段和参数绑定,不连接数据库。 */ + private Configuration mapper(String name) throws Exception { + Configuration configuration = new Configuration(); + String resource = "mapper/mch/" + name + ".xml"; + try (InputStream input = getClass().getClassLoader().getResourceAsStream(resource)) { + assertNotNull(input); + new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse(); + } + return configuration; + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillHotelSourceTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillHotelSourceTest.java new file mode 100644 index 000000000..c1d6b7919 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillHotelSourceTest.java @@ -0,0 +1,66 @@ +package com.bsd.pjcloud.wallet.support.mch; + +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.supermarket.api.feign.RemoteSmHotelOrderCheckoutQueryService; +import com.bsd.pjcloud.supermarket.vo.SmHotelOrderCheckoutQueryVo; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +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 MchCheckoutBillHotelSourceTest { + /** 批量响应遗漏任何一个明确主键都必须拒绝。 */ + @Test + void missingResponseItemShouldFailRatherThanDisappear() { + RemoteSmHotelOrderCheckoutQueryService remote = mock(RemoteSmHotelOrderCheckoutQueryService.class); + when(remote.byIds(any())).thenReturn(R.ok(List.of(order(1L)))); + MchCheckoutBillHotelSource source = new MchCheckoutBillHotelSource(remote, new ObjectMapper().findAndRegisterModules()); + assertThrows(IllegalStateException.class, () -> source.fetch(List.of(1L, 2L))); + } + + /** 渠道未知支付值保留人工核对,明确未付及退款状态拒绝。 */ + @Test + void knownRefundAndUnpaidStatusesShouldBeBlocked() { + MchCheckoutBillHotelSource source = new MchCheckoutBillHotelSource(mock(RemoteSmHotelOrderCheckoutQueryService.class), new ObjectMapper().findAndRegisterModules()); + SmHotelOrderCheckoutQueryVo row = order(1L); + row.setPayStatus("channel-custom"); + assertEquals("", source.eligibility(row)); + row.setPayStatus("REFUNDED"); + assertFalse(source.eligibility(row).isEmpty()); + row.setPayStatus("UNPAID"); + assertFalse(source.eligibility(row).isEmpty()); + } + + /** 源事实摘要检测金额及归属变化,无关更新时间不使金额失效。 */ + @Test + void fingerprintShouldFollowFinancialFacts() { + MchCheckoutBillHotelSource source = new MchCheckoutBillHotelSource(mock(RemoteSmHotelOrderCheckoutQueryService.class), new ObjectMapper().findAndRegisterModules()); + SmHotelOrderCheckoutQueryVo row = order(1L); + String original = source.fingerprint(row); + row.setUpdateTime(LocalDateTime.now()); + assertEquals(original, source.fingerprint(row)); + row.setSellAmountYuan("99.00"); + assertNotEquals(original, source.fingerprint(row)); + row.setSellAmountYuan("100.00"); + row.setDeptId(22L); + assertNotEquals(original, source.fingerprint(row)); + } + + /** 构造可进入人工核对池的最小酒店事实。 */ + private SmHotelOrderCheckoutQueryVo order(Long id) { + SmHotelOrderCheckoutQueryVo row = new SmHotelOrderCheckoutQueryVo(); + row.setOrderId(id); + row.setFound(true); + row.setResolutionStatus("OK"); + row.setHotelId(10L); + row.setDeptId(11L); + row.setSellAmountYuan("100.00"); + row.setOrderStatus("CHECKED_IN"); + row.setPayStatus("PAID"); + return row; + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillMoneyTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillMoneyTest.java new file mode 100644 index 000000000..7d1c672b8 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillMoneyTest.java @@ -0,0 +1,42 @@ +package com.bsd.pjcloud.wallet.support.mch; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +/** 酒店结账金额回归,覆盖固定每单抽成及精度边界。 */ +class MchCheckoutBillMoneyTest { + /** 两笔100元订单固定各抽5元,应付总额严格为190元。 */ + @Test + void twoOrdersShouldPayOneHundredNinetyYuan() { + long gross = MchCheckoutBillMoney.fen("100.00", "订单金额"); + long fee = MchCheckoutBillMoney.fen("5.00", "每单抽成"); + assertEquals(20000L, MchCheckoutBillMoney.add(gross, gross)); + assertEquals(1000L, MchCheckoutBillMoney.add(fee, fee)); + assertEquals(19000L, MchCheckoutBillMoney.add(MchCheckoutBillMoney.payable(gross, fee), MchCheckoutBillMoney.payable(gross, fee))); + } + + /** 小数只能精确转分,禁止隐式四舍五入。 */ + @Test + void decimalsShouldBeExact() { + assertEquals(10001L, MchCheckoutBillMoney.fen("100.0100", "订单金额")); + assertThrows(IllegalArgumentException.class, () -> MchCheckoutBillMoney.fen("100.005", "订单金额")); + assertThrows(IllegalArgumentException.class, () -> MchCheckoutBillMoney.fen("-1.00", "订单金额")); + assertThrows(IllegalArgumentException.class, () -> MchCheckoutBillMoney.fen(null, "订单金额")); + } + + /** 零抽成和零应付均允许,零订单金额及超额抽成拒绝。 */ + @Test + void zeroFeeAndZeroPayableShouldHaveExplicitBoundaries() { + assertEquals(10000L, MchCheckoutBillMoney.payable(10000L, 0L)); + assertEquals(0L, MchCheckoutBillMoney.payable(500L, 500L)); + assertThrows(IllegalArgumentException.class, () -> MchCheckoutBillMoney.payable(0L, 0L)); + assertThrows(IllegalArgumentException.class, () -> MchCheckoutBillMoney.payable(499L, 500L)); + } + + /** 单笔和总额溢出都必须失败,防止金额绕回负数。 */ + @Test + void overflowShouldFailTheWholeCalculation() { + assertThrows(IllegalArgumentException.class, () -> MchCheckoutBillMoney.fen("92233720368547758.08", "订单金额")); + assertThrows(IllegalArgumentException.class, () -> MchCheckoutBillMoney.add(Long.MAX_VALUE, 1L)); + } +}