From 01e899461d9963053e3c3e1b262437e42e5fa4c7 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Tue, 10 Nov 2026 10:04:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E6=AF=8F=E4=B8=AA=E9=85=92=E5=BA=97?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E6=8A=BD=E6=88=90=E9=87=91=E9=A2=9D=E5=B9=B6?= =?UTF-8?q?=E5=BA=94=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/impl/SmHotelOrderServiceImpl.java | 2 +- .../api/dto/mch/MchCheckoutBillRequests.java | 4 +- .../api/entity/mch/MchCheckoutBillEntity.java | 4 +- .../mch/MchCheckoutBillCandidateOrderVo.java | 4 +- .../api/vo/mch/MchCheckoutBillMerchantVo.java | 6 + .../api/vo/mch/MchCheckoutBillSnapshotVo.java | 4 +- .../wallet/api/vo/mch/MchCheckoutBillVo.java | 4 +- .../MchCheckoutBillPreviewService.java | 43 ++-- .../impl/mch/MchCheckoutBillServiceImpl.java | 22 +- .../mch/MchCheckoutBillCommission.java | 79 +++++++ .../support/mch/MchCheckoutBillContext.java | 12 + .../support/mch/MchCheckoutBillSnapshot.java | 7 +- .../mch/MchCheckoutBillCommissionTest.java | 75 +++++++ .../mch/MchCheckoutBillPreviewTest.java | 211 ++++++++++++++++++ 14 files changed, 444 insertions(+), 33 deletions(-) create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillCommission.java create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillCommissionTest.java create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillPreviewTest.java diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOrderServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOrderServiceImpl.java index 68a1ad133..377e092d0 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOrderServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOrderServiceImpl.java @@ -120,7 +120,7 @@ public class SmHotelOrderServiceImpl extends ServiceImpl orderIds; - /** 每单抽成,单位元,最多两位小数,使用字符串精确解析。 */ - @NotBlank @Pattern(regexp = "[0-9]{1,15}(\\.[0-9]{1,2})?") private String feePerOrderYuan; + /** 旧客户端整单抽成字段,继续传值时提示刷新,金额改由各商户场景确定。 */ + @Deprecated private String feePerOrderYuan; /** 操作者明确确认所有所选订单已经实际履约完成。 */ @NotNull @AssertTrue private Boolean fulfillmentConfirmed; /** 操作者明确确认所有所选订单款项已经核对。 */ 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 index 2a25b644a..fade2576e 100644 --- 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 @@ -36,10 +36,10 @@ public class MchCheckoutBillEntity { /** 离店周期结束日期,包含当天。 */ private LocalDate periodEnd; - /** 金额计算规则版本,当前为1。 */ + /** 金额计算规则版本,1为历史整单统一抽成,2为各商户场景抽成。 */ private Integer ruleVersion; - /** 每笔订单抽成,单位分。 */ + /** 历史规则1的整单每单抽成,单位分;规则2使用0占位,以逐单金额快照为准。 */ private Long feePerOrderFen; /** 实际纳入账单的订单笔数。 */ 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 index 8f4223252..f090d506a 100644 --- 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 @@ -33,9 +33,9 @@ public class MchCheckoutBillCandidateOrderVo { private Integer rooms; /** 原订单金额,单位分,异常金额为空。 */ private Long grossAmountFen; - /** 预览每笔抽成,单位分,候选阶段为空。 */ + /** 候选及预览的逐单场景抽成,单位分,归属或金额异常时为空。 */ private Long feeAmountFen; - /** 预览每笔应付,单位分,候选阶段为空。 */ + /** 候选及预览的逐单应付,单位分,归属或金额校验失败时为空。 */ private Long payableAmountFen; /** 酒店原始订单状态。 */ private String orderStatus; 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 index 1520f8735..31bcc7fc7 100644 --- 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 @@ -30,6 +30,12 @@ public class MchCheckoutBillMerchantVo { /** 该商户原订单合计,单位分。 */ private Long grossAmountFen; + /** 该商户每单抽成,单位分;历史账单按保存的合计及笔数还原,无订单时为空。 */ + private Long feePerOrderFen; + + /** 预览时说明场景开关及零抽成原因,历史查询说明金额快照来源。 */ + private String commissionMessage; + /** 该商户抽成合计,单位分。 */ private Long feeAmountFen; 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 index 53e44836f..b634ec7a9 100644 --- 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 @@ -32,8 +32,8 @@ public class MchCheckoutBillSnapshotVo { private List merchants; /** 原订单合计,单位分,候选阶段为零。 */ private Long grossAmountFen; - /** 每单抽成,单位分,候选阶段为零。 */ - private Long feePerOrderFen; + /** 计费规则版本,2表示各商户使用独立的酒店场景配置。 */ + private Integer ruleVersion; /** 抽成合计,单位分。 */ private Long feeAmountFen; /** 商户应付合计,单位分。 */ 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 index 13ef23ac5..7736f9a96 100644 --- 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 @@ -31,10 +31,10 @@ public class MchCheckoutBillVo { /** 离店周期结束日期,包含当天。 */ private LocalDate periodEnd; - /** 金额计算规则版本,当前为1。 */ + /** 金额计算规则版本,1为历史整单统一抽成,2为各商户场景抽成。 */ private Integer ruleVersion; - /** 每笔订单抽成,单位分。 */ + /** 历史规则1的整单每单抽成,单位分;规则2固定为0,实际金额见商户及订单明细。 */ private Long feePerOrderFen; /** 实际纳入账单的订单笔数。 */ 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 index a80368523..ebb214546 100644 --- 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 @@ -35,7 +35,6 @@ public class MchCheckoutBillPreviewService { 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); @@ -45,6 +44,7 @@ public class MchCheckoutBillPreviewService { /** 逐项复核所选候选和人工确认,生成可提交的金额及归属快照。 */ public MchCheckoutBillSnapshotVo preview(MchCheckoutBillRequests.Preview request) { + if (request.getFeePerOrderYuan() != null) throw new IllegalArgumentException("酒店抽成已改为读取各商户场景配置,请刷新页面后重新核对"); 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(); @@ -56,9 +56,9 @@ public class MchCheckoutBillPreviewService { if (!source.fingerprint(candidateIndex.get(id)).equals(source.fingerprint(fresh.get(id)))) throw new IllegalStateException("订单" + id + "在查询后发生变化,请重新查询并核对"); } MchCheckoutBillSnapshot snapshot = context.read(original.getRelationId(), original.getRelationVersion()); + context.assertCommissionUnchanged(original, snapshot.getCommissionRules()); 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()); @@ -70,7 +70,7 @@ public class MchCheckoutBillPreviewService { 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 fee = snapshot.getCommissionRules().get(member.getMerchantId()).getFeePerOrderFen(); long payable = MchCheckoutBillMoney.payable(gross, fee); member.setOrderCount(Math.addExact(member.getOrderCount(), 1)); member.setGrossAmountFen(MchCheckoutBillMoney.add(member.getGrossAmountFen(), gross)); @@ -88,7 +88,7 @@ public class MchCheckoutBillPreviewService { /** 预览和创建共用的规范化内容摘要,校验缓存中的计费参数及源事实。 */ 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(), + context.membershipHash(snapshot.getMerchants()), snapshot.getPeriodStart(), snapshot.getPeriodEnd(), snapshot.getRuleVersion(), snapshot.getCommissionRules(), snapshot.getRemark(), snapshot.getOrders().stream().map(source::fingerprint).toList())); } @@ -105,7 +105,7 @@ public class MchCheckoutBillPreviewService { 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)) + List rows = snapshot.getOrders().stream().map(row -> orderVo(row, byDept.get(row.getDeptId()), occupied, snapshot.getCommissionRules())) .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(); @@ -114,7 +114,8 @@ public class MchCheckoutBillPreviewService { /** 全量商户金额已在预览阶段计算,展示时只做分页。 */ public Page merchants(String token, Page page) { - return slice(page, require(token, true).getMerchants().stream().map(row -> copy(row, MchCheckoutBillMerchantVo.class)).toList()); + MchCheckoutBillSnapshot snapshot = require(token, true); + return slice(page, snapshot.getMerchants().stream().map(row -> merchantVo(row, snapshot.getCommissionRules().get(row.getMerchantId()))).toList()); } /** 完整选中列表用于明确的跨页批量加入,容量仍由工作集上限约束。 */ @@ -123,7 +124,7 @@ public class MchCheckoutBillPreviewService { 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)) + return snapshot.getOrders().stream().map(row -> orderVo(row, byDept.get(row.getDeptId()), occupied, snapshot.getCommissionRules())) .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(); @@ -145,9 +146,10 @@ public class MchCheckoutBillPreviewService { 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()); + Map byDept = snapshot.getMerchants().stream().collect(Collectors.toMap(MchCheckoutBillMerchantEntity::getDeptId, Function.identity())); + result.setSelectableCount((int) snapshot.getOrders().stream().map(row -> orderVo(row, byDept.get(row.getDeptId()), occupied, snapshot.getCommissionRules())) + .filter(MchCheckoutBillCandidateOrderVo::isSelectable).count()); + result.setMerchants(snapshot.getMerchants().stream().map(row -> merchantVo(row, snapshot.getCommissionRules().get(row.getMerchantId()))).toList()); long gross = 0, fee = 0, payable = 0; for (MchCheckoutBillMerchantEntity row : snapshot.getMerchants()) { gross = MchCheckoutBillMoney.add(gross, row.getGrossAmountFen()); @@ -161,8 +163,19 @@ public class MchCheckoutBillPreviewService { return result; } - /** 源事实映射成最小展示模型,金额非法时保留异常说明。 */ - private MchCheckoutBillCandidateOrderVo orderVo(SmHotelOrderCheckoutQueryVo row, MchCheckoutBillMerchantEntity member, Set occupied, Long fee) { + /** 商户展示使用本次场景快照,即使本次未选择该商户订单也展示其抽成规则。 */ + private MchCheckoutBillMerchantVo merchantVo(MchCheckoutBillMerchantEntity member, MchCheckoutBillCommission.Rule rule) { + if (rule == null) throw new IllegalStateException("商户酒店抽成快照不完整,请重新查询"); + MchCheckoutBillMerchantVo result = copy(member, MchCheckoutBillMerchantVo.class); + result.setFeePerOrderFen(rule.getFeePerOrderFen()); + result.setCommissionMessage(rule.getMessage()); + return result; + } + + /** 源事实与该商户抽成映射成展示模型,候选阶段即拒绝抽成超过售价的订单。 */ + private MchCheckoutBillCandidateOrderVo orderVo(SmHotelOrderCheckoutQueryVo row, MchCheckoutBillMerchantEntity member, Set occupied, Map rules) { + MchCheckoutBillCommission.Rule rule = member == null ? null : rules.get(member.getMerchantId()); + if (member != null && rule == null) throw new IllegalStateException("商户酒店抽成快照不完整,请重新查询"); 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" : "后台/其他"); @@ -181,9 +194,9 @@ public class MchCheckoutBillPreviewService { try { long gross = MchCheckoutBillMoney.fen(row.getSellAmountYuan(), "订单金额"); result.setGrossAmountFen(gross); - if (fee != null) { - result.setFeeAmountFen(fee); - result.setPayableAmountFen(MchCheckoutBillMoney.payable(gross, fee)); + if (rule != null) { + result.setFeeAmountFen(rule.getFeePerOrderFen()); + result.setPayableAmountFen(MchCheckoutBillMoney.payable(gross, rule.getFeePerOrderFen())); } } catch (IllegalArgumentException exception) { result.setSelectable(false); 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 index 06916f872..36e049773 100644 --- 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 @@ -70,7 +70,17 @@ public class MchCheckoutBillServiceImpl implements MchCheckoutBillService { 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); + Page result = viewPage(data, MchCheckoutBillMerchantVo.class); + for (MchCheckoutBillMerchantVo row : result.getRecords()) { + if (row.getOrderCount() > 0) { + if (row.getFeeAmountFen() == null || row.getFeeAmountFen() < 0 || row.getFeeAmountFen() % row.getOrderCount() != 0) throw new IllegalStateException("历史商户抽成快照异常,请先核对账单"); + row.setFeePerOrderFen(row.getFeeAmountFen() / row.getOrderCount()); + row.setCommissionMessage("创建账单时确认的每单抽成"); + } else { + row.setCommissionMessage("本次未选择订单"); + } + } + return result; } /** 按商户及异常筛选在数据库分页之前处理。 */ @@ -253,7 +263,7 @@ public class MchCheckoutBillServiceImpl implements MchCheckoutBillService { 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 fee = snapshot.getCommissionRules().get(member.getMerchantId()).getFeePerOrderFen(); long payable = MchCheckoutBillMoney.payable(gross, fee); member.setOrderCount(member.getOrderCount() + 1); member.setGrossAmountFen(MchCheckoutBillMoney.add(member.getGrossAmountFen(), gross)); @@ -321,8 +331,9 @@ public class MchCheckoutBillServiceImpl implements MchCheckoutBillService { bill.setIntermediaryName(snapshot.getIntermediaryName()); bill.setPeriodStart(snapshot.getPeriodStart()); bill.setPeriodEnd(snapshot.getPeriodEnd()); - bill.setRuleVersion(1); - bill.setFeePerOrderFen(snapshot.getFeePerOrderFen()); + bill.setRuleVersion(MchCheckoutBillCommission.RULE_VERSION); + // 版本2各商户费率独立;旧整单字段仅作兼容占位,实际抽成持久化在每笔订单明细中。 + bill.setFeePerOrderFen(0L); bill.setOrderCount(orders.size()); bill.setMerchantCount(membersByDept.size()); bill.setGrossAmountFen(grossTotal); @@ -347,7 +358,8 @@ public class MchCheckoutBillServiceImpl implements MchCheckoutBillService { 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)); + 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, + "ruleVersion", MchCheckoutBillCommission.RULE_VERSION, "commissionRules", snapshot.getCommissionRules())); return billId; } diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillCommission.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillCommission.java new file mode 100644 index 000000000..4680d00a0 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillCommission.java @@ -0,0 +1,79 @@ +package com.bsd.pjcloud.wallet.support.mch; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.bsd.pjcloud.wallet.api.entity.mch.MchSceneEntity; +import com.bsd.pjcloud.wallet.mapper.mch.MchSceneMapper; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import java.util.*; + +/** 酒店结账按商户读取场景抽成,结果进入预览摘要及创建事件快照。 */ +@Component +@RequiredArgsConstructor +public class MchCheckoutBillCommission { + /** 计费协议版本,2表示各商户独立场景抽成,1表示历史整单统一抽成。 */ + public static final int RULE_VERSION = 2; + /** 商户场景表中酒店抽成的持久化编码。 */ + private static final String SCENE_CODE = "HOTEL_COMMISSION"; + /** 已有场景查询,租户及逻辑删除由平台自动过滤。 */ + private final MchSceneMapper scenes; + + /** 按完整授权商户名单读取配置;提交时在当前事务中按商户顺序锁定配置行。 */ + public Map read(List merchantIds, boolean lock) { + if (merchantIds.isEmpty() || merchantIds.size() > 1000) throw new IllegalStateException("结账商户抽成查询范围无效"); + List ids = merchantIds.stream().distinct().sorted().toList(); + List rows = scenes.selectList(Wrappers.lambdaQuery() + .in(MchSceneEntity::getMchId, ids).eq(MchSceneEntity::getSceneCode, SCENE_CODE) + .orderByAsc(MchSceneEntity::getMchId).last(lock ? "FOR UPDATE" : "")); + Map indexed = new HashMap<>(); + for (MchSceneEntity row : rows) { + if (indexed.putIfAbsent(row.getMchId(), row) != null) throw new IllegalStateException("商户酒店抽成配置重复,请先维护场景配置"); + } + Map result = new LinkedHashMap<>(); + for (Long id : ids) result.put(id, fromScene(id, indexed.get(id))); + return result; + } + + /** 只有开关为1且金额大于0时抽成,空配置及空金额均按零处理,非法金额明确拒绝。 */ + public static Rule fromScene(Long merchantId, MchSceneEntity scene) { + Rule rule = new Rule(); + rule.setMerchantId(merchantId); + rule.setConfiguredAmountFen(0L); + rule.setFeePerOrderFen(0L); + if (scene == null) { + rule.setMessage("未配置酒店抽成,按0元抽成"); + return rule; + } + if (!Set.of("0", "1").contains(Objects.toString(scene.getEnabled(), ""))) throw new IllegalStateException("商户" + merchantId + "的酒店抽成开关异常,请先维护场景配置"); + rule.setSceneId(scene.getId()); + rule.setEnabled(scene.getEnabled()); + if (scene.getCommissionAmount() != null) rule.setConfiguredAmountFen(MchCheckoutBillMoney.fen(scene.getCommissionAmount().toPlainString(), "商户酒店抽成金额")); + if (!"1".equals(scene.getEnabled())) { + rule.setMessage("酒店抽成未开启,按0元抽成"); + } else if (rule.getConfiguredAmountFen() == 0L) { + rule.setMessage("抽成金额未设置或为0元,按0元抽成"); + } else { + rule.setFeePerOrderFen(rule.getConfiguredAmountFen()); + rule.setMessage("按商户酒店场景配置抽成"); + } + return rule; + } + + /** 一份商户场景事实及本次生效金额,临时缓存和创建审计均保存此结构。 */ + @Data + public static class Rule { + /** 独立配置所属商户ID,总店和分店按各自ID取值。 */ + private Long merchantId; + /** 原场景记录ID,未配置时为空,用于识别删除后重建。 */ + private Long sceneId; + /** 原场景开关,0关闭、1开启,未配置时为空。 */ + private String enabled; + /** 原配置金额的精确分值,未配置或空金额按0保存。 */ + private Long configuredAmountFen; + /** 本次实际每单抽成,单位分,关闭或非正配置时为0。 */ + private Long feePerOrderFen; + /** 管理端展示当前商户抽成生效或按零处理的原因。 */ + private String message; + } +} 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 index 001007d4e..837bf14d3 100644 --- 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 @@ -28,6 +28,8 @@ public class MchCheckoutBillContext { private final MchCheckoutRelationAccess access; /** 规范化快照摘要。 */ private final MchCheckoutBillHotelSource source; + /** 各商户酒店场景配置读取与事务内锁定。 */ + private final MchCheckoutBillCommission commissions; /** 在一致快照中读取完整关系并拒绝失效、空部门及归属歧义。 */ @Transactional(readOnly = true) @@ -41,6 +43,8 @@ public class MchCheckoutBillContext { result.setRelationVersion(version); result.setIntermediaryName(relation.getIntermediaryName()); result.setMerchants(members); + result.setRuleVersion(MchCheckoutBillCommission.RULE_VERSION); + result.setCommissionRules(commissions.read(members.stream().map(MchCheckoutBillMerchantEntity::getMerchantId).toList(), false)); return result; } @@ -55,12 +59,20 @@ public class MchCheckoutBillContext { List locked = members(current, scope); assertUniqueDepartments(locked); if (!membershipHash(locked).equals(membershipHash(snapshot.getMerchants()))) throw new IllegalStateException("商户名称、归属或权限资料已变化,请重新预览"); + assertCommissionUnchanged(snapshot, commissions.read(ids, true)); } /** 读取临时工作集时再次校验当前关系,撤权和关系编辑立即使工作集失效。 */ public void validateSnapshot(MchCheckoutBillSnapshot snapshot) { MchCheckoutBillSnapshot current = read(snapshot.getRelationId(), snapshot.getRelationVersion()); if (!membershipHash(current.getMerchants()).equals(membershipHash(snapshot.getMerchants()))) throw new IllegalStateException("关系商户资料已变化,请重新查询"); + assertCommissionUnchanged(snapshot, current.getCommissionRules()); + } + + /** 配置变化使旧候选及预览失效,禁止按新金额静默替换已经核对的抽成。 */ + public void assertCommissionUnchanged(MchCheckoutBillSnapshot expected, Map current) { + if (!Objects.equals(expected.getRuleVersion(), MchCheckoutBillCommission.RULE_VERSION) || expected.getCommissionRules() == null) throw new IllegalStateException("酒店结账计费规则已更新,请重新查询订单"); + if (!Objects.equals(expected.getCommissionRules(), current)) throw new IllegalStateException("商户酒店抽成配置已变化,请重新查询订单并核对金额"); } /** 摘要包含完整权限归属,不依赖商户分页或当前金额。 */ 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 index 7f77806bd..bdb85af12 100644 --- 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 @@ -6,6 +6,7 @@ import lombok.Data; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; /** 钱包内部临时工作集,通过令牌访问,完整名单与源事实不依赖单页结果。 */ @Data @@ -30,8 +31,10 @@ public class MchCheckoutBillSnapshot { private LocalDateTime expiresAt; /** 最近一次完整读取或复核源事实的时间。 */ private LocalDateTime sourceVerifiedAt; - /** 每单抽成,单位分,候选为0。 */ - private Long feePerOrderFen; + /** 计费规则版本,当前为2,缺失时表示规则升级前的临时工作集。 */ + private Integer ruleVersion; + /** 完整商户场景及生效抽成快照,键为商户ID,提交前再次读取比较。 */ + private Map commissionRules; /** 本次结账备注。 */ private String remark; /** 创建内容摘要,包含明确名单、源事实及关系完整归属快照。 */ diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillCommissionTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillCommissionTest.java new file mode 100644 index 000000000..331bdfaac --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillCommissionTest.java @@ -0,0 +1,75 @@ +package com.bsd.pjcloud.wallet.support.mch; + +import com.bsd.pjcloud.wallet.api.entity.mch.MchSceneEntity; +import com.bsd.pjcloud.wallet.mapper.mch.MchSceneMapper; +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** 商户酒店场景抽成回归,覆盖独立金额、零抽成及预览失效条件。 */ +class MchCheckoutBillCommissionTest { + /** 未配置、未开启、零金额及空金额均不抽成。 */ + @Test + void disabledMissingAndZeroAmountShouldChargeZero() { + assertEquals(0L, MchCheckoutBillCommission.fromScene(1L, null).getFeePerOrderFen()); + assertEquals(0L, MchCheckoutBillCommission.fromScene(1L, scene("0", "25.00")).getFeePerOrderFen()); + assertEquals(0L, MchCheckoutBillCommission.fromScene(1L, scene("1", "0.00")).getFeePerOrderFen()); + assertEquals(0L, MchCheckoutBillCommission.fromScene(1L, scene("1", null)).getFeePerOrderFen()); + } + + /** 开启后按各商户精确金额取值,任何固定默认费率都不能替代配置。 */ + @Test + void enabledMerchantsShouldUseTheirOwnExactAmount() { + assertEquals(123L, MchCheckoutBillCommission.fromScene(1L, scene("1", "1.23")).getFeePerOrderFen()); + assertEquals(789L, MchCheckoutBillCommission.fromScene(2L, scene("1", "7.89")).getFeePerOrderFen()); + assertEquals(9999999999L, MchCheckoutBillCommission.fromScene(3L, scene("1", "99999999.99")).getFeePerOrderFen()); + } + + /** 异常数据库值不能通过四舍五入或默认零金额掩盖。 */ + @Test + void invalidSceneValuesShouldFailExplicitly() { + assertThrows(IllegalArgumentException.class, () -> MchCheckoutBillCommission.fromScene(1L, scene("1", "0.001"))); + assertThrows(IllegalArgumentException.class, () -> MchCheckoutBillCommission.fromScene(1L, scene("1", "-0.01"))); + assertThrows(IllegalStateException.class, () -> MchCheckoutBillCommission.fromScene(1L, scene("unexpected", "5.00"))); + } + + /** 批量查询为每个关系商户建立快照,缺失配置的商户保留零抽成。 */ + @Test + void batchReadShouldRetainMerchantsWithoutConfiguration() { + MchSceneMapper mapper = mock(MchSceneMapper.class); + when(mapper.selectList(any())).thenReturn(List.of(scene("1", "1.23"))); + Map rules = new MchCheckoutBillCommission(mapper).read(List.of(2L, 1L), false); + assertEquals(List.of(1L, 2L), new ArrayList<>(rules.keySet())); + assertEquals(123L, rules.get(1L).getFeePerOrderFen()); + assertEquals(0L, rules.get(2L).getFeePerOrderFen()); + } + + /** 金额、开关和场景身份变化均拒绝旧预览,语义相同的配置允许继续使用。 */ + @Test + void changedCommissionShouldInvalidateTheWholeSnapshot() { + MchCheckoutBillContext context = new MchCheckoutBillContext(null, null, null, null, null, null, null); + MchCheckoutBillSnapshot snapshot = new MchCheckoutBillSnapshot(); + snapshot.setRuleVersion(MchCheckoutBillCommission.RULE_VERSION); + snapshot.setCommissionRules(Map.of(1L, MchCheckoutBillCommission.fromScene(1L, scene("1", "5.00")))); + assertDoesNotThrow(() -> context.assertCommissionUnchanged(snapshot, Map.of(1L, MchCheckoutBillCommission.fromScene(1L, scene("1", "5.0"))))); + assertThrows(IllegalStateException.class, () -> context.assertCommissionUnchanged(snapshot, Map.of(1L, MchCheckoutBillCommission.fromScene(1L, scene("1", "6.00"))))); + assertThrows(IllegalStateException.class, () -> context.assertCommissionUnchanged(snapshot, Map.of(1L, MchCheckoutBillCommission.fromScene(1L, scene("0", "5.00"))))); + assertThrows(IllegalStateException.class, () -> context.assertCommissionUnchanged(snapshot, Map.of(1L, MchCheckoutBillCommission.fromScene(1L, null)))); + snapshot.setRuleVersion(null); + assertThrows(IllegalStateException.class, () -> context.assertCommissionUnchanged(snapshot, snapshot.getCommissionRules())); + } + + /** 构造有效酒店场景,空金额用于覆盖历史缺省数据。 */ + private MchSceneEntity scene(String enabled, String amount) { + MchSceneEntity row = new MchSceneEntity(); + row.setId(10L); + row.setMchId(1L); + row.setSceneCode("HOTEL_COMMISSION"); + row.setEnabled(enabled); + row.setCommissionAmount(amount == null ? null : new BigDecimal(amount)); + return row; + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillPreviewTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillPreviewTest.java new file mode 100644 index 000000000..cfffda590 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillPreviewTest.java @@ -0,0 +1,211 @@ +package com.bsd.pjcloud.wallet.support.mch; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.common.security.service.PjcloudUser; +import com.bsd.pjcloud.supermarket.api.feign.RemoteSmHotelOrderCheckoutQueryService; +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.*; +import com.bsd.pjcloud.wallet.service.*; +import com.bsd.pjcloud.wallet.service.impl.mch.MchCheckoutBillServiceImpl; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionCallback; +import org.springframework.transaction.support.TransactionTemplate; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.*; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** 经候选、预览及创建业务入口验证各商户金额,事务竞争另由真实数据库用例验证。 */ +class MchCheckoutBillPreviewTest { + /** 关系和权限边界单独验证,此处提供确定的授权商户快照。 */ + private final MchCheckoutBillContext context = mock(MchCheckoutBillContext.class); + /** 临时工作集使用内存测试替身,避免访问Redis。 */ + private final MchCheckoutBillCache cache = mock(MchCheckoutBillCache.class); + /** 源事实摘要和金额资格使用真实实现,远程读取以固定事实替代。 */ + private final MchCheckoutBillHotelSource source = spy(new MchCheckoutBillHotelSource(mock(RemoteSmHotelOrderCheckoutQueryService.class), new ObjectMapper().findAndRegisterModules())); + /** 当前有效占用在此用例中为空。 */ + private final MchCheckoutOrderClaimMapper claims = mock(MchCheckoutOrderClaimMapper.class); + /** 正常预览入口,逐单和逐商户计算使用生产实现。 */ + private final MchCheckoutBillPreviewService previews = new MchCheckoutBillPreviewService(context, cache, source, claims); + + /** 多商户独立费率及关闭场景混合结账,零订单商户仍展示各自规则。 */ + @Test + void previewShouldAggregateDifferentMerchantRates() { + prepare(); + MchCheckoutBillSnapshotVo result = previews.preview(request()); + assertEquals(40000L, result.getGrossAmountFen()); + assertEquals(2250L, result.getFeeAmountFen()); + assertEquals(37750L, result.getPayableAmountFen()); + assertEquals(4, result.getOrderCount()); + Map members = result.getMerchants().stream().collect(Collectors.toMap(MchCheckoutBillMerchantVo::getMerchantId, row -> row)); + assertEquals(500L, members.get(1L).getFeePerOrderFen()); + assertEquals(1000L, members.get(1L).getFeeAmountFen()); + assertEquals(19000L, members.get(1L).getPayableAmountFen()); + assertEquals(1250L, members.get(2L).getFeePerOrderFen()); + assertEquals(8750L, members.get(2L).getPayableAmountFen()); + assertEquals(0L, members.get(3L).getFeeAmountFen()); + assertEquals(10000L, members.get(3L).getPayableAmountFen()); + assertEquals(399L, members.get(4L).getFeePerOrderFen()); + assertEquals("NO_ORDERS", members.get(4L).getState()); + } + + /** 候选查询、可选数量和跨页批量选择统一排除抽成超过售价的订单。 */ + @Test + void candidatesShouldRejectFeeAboveOrderAmount() { + MchCheckoutBillSnapshot original = prepare(); + original.getOrders().get(0).setSellAmountYuan("0.10"); + Page rows = previews.orders("candidate", false, new Page<>(1, 100), null, null, false); + assertFalse(rows.getRecords().get(0).isSelectable()); + assertTrue(rows.getRecords().get(0).getMessage().contains("抽成")); + assertEquals(List.of(2L, 3L, 4L), previews.selectableIds("candidate", null, null)); + assertEquals(3, previews.summary(original).getSelectableCount()); + } + + /** 继续发送整单手填费率的旧页面必须刷新,避免页面金额与实际扣费口径不一致。 */ + @Test + void legacyFeeOverrideShouldRequirePageRefresh() { + MchCheckoutBillRequests.Preview request = request(); + request.setFeePerOrderYuan("5.00"); + assertThrows(IllegalArgumentException.class, () -> previews.preview(request)); + verifyNoInteractions(cache); + } + + /** 创建入口重新计算并持久化各商户金额,主表规则2占位金额不参与逐单计费。 */ + @Test + void createShouldPersistMerchantSpecificOrderFees() { + prepare(); + previews.preview(request()); + ArgumentCaptor preview = ArgumentCaptor.forClass(MchCheckoutBillSnapshot.class); + verify(cache).save(preview.capture()); + when(cache.require("preview", true)).thenReturn(preview.getValue()); + MchCheckoutBillMapper bills = mock(MchCheckoutBillMapper.class); + MchCheckoutBillMerchantMapper merchants = mock(MchCheckoutBillMerchantMapper.class); + MchCheckoutBillOrderMapper orders = mock(MchCheckoutBillOrderMapper.class); + MchCheckoutBillLedger ledger = mock(MchCheckoutBillLedger.class); + MchCheckoutRelationAccess access = mock(MchCheckoutRelationAccess.class); + MchCheckoutRelationScope scope = new MchCheckoutRelationScope(true, List.of(), null); + TransactionTemplate transaction = mock(TransactionTemplate.class); + AtomicReference> persisted = new AtomicReference<>(); + when(access.requireScope()).thenReturn(scope); + when(bills.insert(any(MchCheckoutBillEntity.class))).thenReturn(1); + when(merchants.insertRows(anyList())).thenAnswer(call -> ((List) call.getArgument(0)).size()); + when(orders.insertRows(anyList())).thenAnswer(call -> { + List rows = call.getArgument(0); + persisted.set(rows); + return rows.size(); + }); + when(claims.insertRows(anyList())).thenAnswer(call -> ((List) call.getArgument(0)).size()); + when(ledger.requireVisible(anyLong(), any())).thenReturn(new MchCheckoutBillVo()); + when(transaction.execute(any())).thenAnswer(call -> { + TransactionCallback callback = call.getArgument(0); + return callback.doInTransaction(mock(TransactionStatus.class)); + }); + PjcloudUser user = mock(PjcloudUser.class); + when(user.getUsername()).thenReturn("checkout-test"); + when(user.getId()).thenReturn(9L); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user, "", List.of())); + try { + MchCheckoutBillService service = new MchCheckoutBillServiceImpl(bills, merchants, orders, claims, mock(MchCheckoutBillEventMapper.class), previews, source, context, access, ledger, mock(MchCheckoutBillRecheckService.class), transaction); + MchCheckoutBillRequests.Create request = new MchCheckoutBillRequests.Create(); + request.setPreviewToken("preview"); + request.setRequestId("request-one"); + service.create(request); + } finally { + SecurityContextHolder.clearContext(); + } + ArgumentCaptor bill = ArgumentCaptor.forClass(MchCheckoutBillEntity.class); + verify(bills).insert(bill.capture()); + assertEquals(2, bill.getValue().getRuleVersion()); + assertEquals(0L, bill.getValue().getFeePerOrderFen()); + assertEquals(2250L, bill.getValue().getFeeAmountFen()); + assertEquals(37750L, bill.getValue().getPayableAmountFen()); + assertEquals(List.of(500L, 500L, 1250L, 0L), persisted.get().stream().map(MchCheckoutBillOrderEntity::getFeeAmountFen).toList()); + verify(context).lockAndValidate(preview.getValue(), scope); + } + + /** 准备独立的候选与新预览快照,外部订单事实完全相同。 */ + private MchCheckoutBillSnapshot prepare() { + MchCheckoutBillSnapshot original = workingSet(); + when(cache.require("candidate", false)).thenReturn(original); + when(context.read(42L, 3)).thenAnswer(call -> workingSet()); + when(claims.selectList(any())).thenReturn(List.of()); + doReturn(original.getOrders().stream().collect(Collectors.toMap(SmHotelOrderCheckoutQueryVo::getOrderId, row -> row))).when(source).fetch(anyList()); + return original; + } + + /** 四个商户分别为5元、12.50元、关闭抽成及无订单的3.99元配置。 */ + private MchCheckoutBillSnapshot workingSet() { + MchCheckoutBillSnapshot snapshot = new MchCheckoutBillSnapshot(); + snapshot.setRelationId(42L); + snapshot.setRelationVersion(3); + snapshot.setIntermediaryName("结账测试"); + snapshot.setRuleVersion(2); + snapshot.setPeriodStart(LocalDate.of(2026, 10, 1)); + snapshot.setPeriodEnd(LocalDate.of(2026, 10, 31)); + snapshot.setMerchants(List.of(member(1), member(2), member(3), member(4))); + snapshot.setCommissionRules(Map.of(1L, commission(1, "1", "5.00"), 2L, commission(2, "1", "12.50"), 3L, commission(3, "0", "50.00"), 4L, commission(4, "1", "3.99"))); + snapshot.setOrders(List.of(order(1, 1), order(2, 1), order(3, 2), order(4, 3))); + return snapshot; + } + + /** 构造尚未累计订单的独立商户快照。 */ + private MchCheckoutBillMerchantEntity member(long id) { + MchCheckoutBillMerchantEntity member = new MchCheckoutBillMerchantEntity(); + member.setMerchantId(id); + member.setMerchantName("商户" + id); + member.setDeptId(id + 1000); + member.setOrderCount(0); + member.setGrossAmountFen(0L); + member.setFeeAmountFen(0L); + member.setPayableAmountFen(0L); + member.setState("NO_ORDERS"); + return member; + } + + /** 构造成功履约且属于确定商户部门的100元酒店订单。 */ + private SmHotelOrderCheckoutQueryVo order(long id, long merchantId) { + SmHotelOrderCheckoutQueryVo order = new SmHotelOrderCheckoutQueryVo(); + order.setOrderId(id); + order.setFound(true); + order.setResolutionStatus("OK"); + order.setHotelId(merchantId + 10000); + order.setDeptId(merchantId + 1000); + order.setSellAmountYuan("100.00"); + order.setOrderStatus("UPSTREAM_OK"); + order.setPayStatus("PAID"); + order.setCheckOutDate(LocalDate.of(2026, 10, 20)); + return order; + } + + /** 将场景记录转换为与生产一致的金额配置。 */ + private MchCheckoutBillCommission.Rule commission(long merchantId, String enabled, String amount) { + MchSceneEntity scene = new MchSceneEntity(); + scene.setId(merchantId + 100); + scene.setMchId(merchantId); + scene.setEnabled(enabled); + scene.setCommissionAmount(new BigDecimal(amount)); + return MchCheckoutBillCommission.fromScene(merchantId, scene); + } + + /** 操作者明确确认四笔订单,预览请求不携带任何手填费率。 */ + private MchCheckoutBillRequests.Preview request() { + MchCheckoutBillRequests.Preview request = new MchCheckoutBillRequests.Preview(); + request.setCandidateToken("candidate"); + request.setOrderIds(List.of(1L, 2L, 3L, 4L)); + request.setFulfillmentConfirmed(true); + request.setReceiptConfirmed(true); + return request; + } +}