feat:每个酒店设置抽成金额并应用

This commit is contained in:
dzc committed 2026-11-10 10:04:42 +08:00
1 parent a2e2a2a8ca
commit 01e899461d
14 files changed
+444 -33

No files matched your search

@@ -120,7 +120,7 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
private final SmHotelChannelDeptService channelDeptService;
// private final RemoterThirdAppPlusPayService remoterThirdAppPlusPayService;
private final RemoterThirdAppPlusPayService remoterThirdAppPlusPayService;
private final DownstreamOpenCallbackClient openCallbackClient;
@@ -31,8 +31,8 @@ public final class MchCheckoutBillRequests {
@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;
/** 旧客户端整单抽成字段,继续传值时提示刷新,金额改由各商户场景确定。 */
@Deprecated private String feePerOrderYuan;
/** 操作者明确确认所有所选订单已经实际履约完成。 */
@NotNull @AssertTrue private Boolean fulfillmentConfirmed;
/** 操作者明确确认所有所选订单款项已经核对。 */
@@ -36,10 +36,10 @@ public class MchCheckoutBillEntity {
/** 离店周期结束日期,包含当天。 */
private LocalDate periodEnd;
/** 金额计算规则版本,当前为1。 */
/** 金额计算规则版本,1为历史整单统一抽成,2为各商户场景抽成。 */
private Integer ruleVersion;
/** 每笔订单抽成,单位分。 */
/** 历史规则1的整单每单抽成,单位分;规则2使用0占位,以逐单金额快照为准。 */
private Long feePerOrderFen;
/** 实际纳入账单的订单笔数。 */
@@ -33,9 +33,9 @@ public class MchCheckoutBillCandidateOrderVo {
private Integer rooms;
/** 原订单金额,单位分,异常金额为空。 */
private Long grossAmountFen;
/** 预览每笔抽成,单位分,候选阶段为空。 */
/** 候选及预览的逐单场景抽成,单位分,归属或金额异常时为空。 */
private Long feeAmountFen;
/** 预览每笔应付,单位分,候选阶段为空。 */
/** 候选及预览的逐单应付,单位分,归属或金额校验失败时为空。 */
private Long payableAmountFen;
/** 酒店原始订单状态。 */
private String orderStatus;
@@ -30,6 +30,12 @@ public class MchCheckoutBillMerchantVo {
/** 该商户原订单合计,单位分。 */
private Long grossAmountFen;
/** 该商户每单抽成,单位分;历史账单按保存的合计及笔数还原,无订单时为空。 */
private Long feePerOrderFen;
/** 预览时说明场景开关及零抽成原因,历史查询说明金额快照来源。 */
private String commissionMessage;
/** 该商户抽成合计,单位分。 */
private Long feeAmountFen;
@@ -32,8 +32,8 @@ public class MchCheckoutBillSnapshotVo {
private List<MchCheckoutBillMerchantVo> merchants;
/** 原订单合计,单位分,候选阶段为零。 */
private Long grossAmountFen;
/** 每单抽成,单位分,候选阶段为零。 */
private Long feePerOrderFen;
/** 计费规则版本,2表示各商户使用独立的酒店场景配置。 */
private Integer ruleVersion;
/** 抽成合计,单位分。 */
private Long feeAmountFen;
/** 商户应付合计,单位分。 */
@@ -31,10 +31,10 @@ public class MchCheckoutBillVo {
/** 离店周期结束日期,包含当天。 */
private LocalDate periodEnd;
/** 金额计算规则版本,当前为1。 */
/** 金额计算规则版本,1为历史整单统一抽成,2为各商户场景抽成。 */
private Integer ruleVersion;
/** 每笔订单抽成,单位分。 */
/** 历史规则1的整单每单抽成,单位分;规则2固定为0,实际金额见商户及订单明细。 */
private Long feePerOrderFen;
/** 实际纳入账单的订单笔数。 */
@@ -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<Long> 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<Long> occupied = claimed(snapshot.getOrders().stream().map(SmHotelOrderCheckoutQueryVo::getOrderId).toList());
Map<Long, MchCheckoutBillMerchantEntity> byDept = snapshot.getMerchants().stream().collect(Collectors.toMap(MchCheckoutBillMerchantEntity::getDeptId, Function.identity()));
String search = keyword == null ? "" : keyword.trim().toLowerCase(Locale.ROOT);
List<MchCheckoutBillCandidateOrderVo> rows = snapshot.getOrders().stream().map(row -> orderVo(row, byDept.get(row.getDeptId()), occupied, snapshot.isPreview() ? snapshot.getFeePerOrderFen() : null))
List<MchCheckoutBillCandidateOrderVo> 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<MchCheckoutBillMerchantVo> merchants(String token, Page<MchCheckoutBillMerchantVo> 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<Long> occupied = claimed(snapshot.getOrders().stream().map(SmHotelOrderCheckoutQueryVo::getOrderId).toList());
Map<Long, MchCheckoutBillMerchantEntity> 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<Long> occupied = claimed(snapshot.getOrders().stream().map(SmHotelOrderCheckoutQueryVo::getOrderId).toList());
Set<Long> 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<Long, MchCheckoutBillMerchantEntity> 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<Long> 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<Long> occupied, Map<Long, MchCheckoutBillCommission.Rule> 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);
@@ -70,7 +70,17 @@ public class MchCheckoutBillServiceImpl implements MchCheckoutBillService {
detail(id);
Page<MchCheckoutBillMerchantEntity> data = merchantMapper.selectPage(new Page<>(safe(page).getCurrent(), page.getSize()),
Wrappers.<MchCheckoutBillMerchantEntity>lambdaQuery().eq(MchCheckoutBillMerchantEntity::getBillId, id).orderByDesc(MchCheckoutBillMerchantEntity::getOrderCount).orderByAsc(MchCheckoutBillMerchantEntity::getMerchantId));
return viewPage(data, MchCheckoutBillMerchantVo.class);
Page<MchCheckoutBillMerchantVo> 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<MchCheckoutOrderClaimEntity> 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;
}
@@ -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<Long, Rule> read(List<Long> merchantIds, boolean lock) {
if (merchantIds.isEmpty() || merchantIds.size() > 1000) throw new IllegalStateException("结账商户抽成查询范围无效");
List<Long> ids = merchantIds.stream().distinct().sorted().toList();
List<MchSceneEntity> rows = scenes.selectList(Wrappers.<MchSceneEntity>lambdaQuery()
.in(MchSceneEntity::getMchId, ids).eq(MchSceneEntity::getSceneCode, SCENE_CODE)
.orderByAsc(MchSceneEntity::getMchId).last(lock ? "FOR UPDATE" : ""));
Map<Long, MchSceneEntity> indexed = new HashMap<>();
for (MchSceneEntity row : rows) {
if (indexed.putIfAbsent(row.getMchId(), row) != null) throw new IllegalStateException("商户酒店抽成配置重复,请先维护场景配置");
}
Map<Long, Rule> 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;
}
}
@@ -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<MchCheckoutBillMerchantEntity> 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<Long, MchCheckoutBillCommission.Rule> current) {
if (!Objects.equals(expected.getRuleVersion(), MchCheckoutBillCommission.RULE_VERSION) || expected.getCommissionRules() == null) throw new IllegalStateException("酒店结账计费规则已更新,请重新查询订单");
if (!Objects.equals(expected.getCommissionRules(), current)) throw new IllegalStateException("商户酒店抽成配置已变化,请重新查询订单并核对金额");
}
/** 摘要包含完整权限归属,不依赖商户分页或当前金额。 */
@@ -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<Long, MchCheckoutBillCommission.Rule> commissionRules;
/** 本次结账备注。 */
private String remark;
/** 创建内容摘要,包含明确名单、源事实及关系完整归属快照。 */
@@ -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<Long, MchCheckoutBillCommission.Rule> 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;
}
}
@@ -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<Long, MchCheckoutBillMerchantVo> 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<MchCheckoutBillCandidateOrderVo> 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<MchCheckoutBillSnapshot> 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<List<MchCheckoutBillOrderEntity>> 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<MchCheckoutBillOrderEntity> 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<MchCheckoutBillEntity> 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;
}
}