|
@@ -0,0 +1,270 @@
|
|
|
|
|
+package com.kym.service.impl;
|
|
|
|
|
+
|
|
|
|
|
+import com.kym.entity.SettlementRecord;
|
|
|
|
|
+import com.kym.entity.SplitRecord;
|
|
|
|
|
+import com.kym.entity.StationAccount;
|
|
|
|
|
+import com.kym.service.SplitRecordService;
|
|
|
|
|
+import com.kym.service.StationAccountService;
|
|
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
|
|
|
+import org.junit.jupiter.api.Nested;
|
|
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
|
|
+import org.junit.jupiter.api.extension.ExtendWith;
|
|
|
|
|
+import org.mockito.Mock;
|
|
|
|
|
+import org.mockito.junit.jupiter.MockitoExtension;
|
|
|
|
|
+
|
|
|
|
|
+import java.time.LocalDateTime;
|
|
|
|
|
+import java.util.ArrayList;
|
|
|
|
|
+import java.util.List;
|
|
|
|
|
+
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.*;
|
|
|
|
|
+import static org.mockito.ArgumentMatchers.*;
|
|
|
|
|
+import static org.mockito.Mockito.*;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 结算服务单元测试
|
|
|
|
|
+ * 核心验证:公式 平台费基数 = 总充值 - 总退款 - 跨店支出 + 跨店收入
|
|
|
|
|
+ */
|
|
|
|
|
+@ExtendWith(MockitoExtension.class)
|
|
|
|
|
+@DisplayName("结算服务")
|
|
|
|
|
+class SettlementServiceImplTest {
|
|
|
|
|
+
|
|
|
|
|
+ @Mock
|
|
|
|
|
+ private SplitRecordService splitRecordService;
|
|
|
|
|
+ @Mock
|
|
|
|
|
+ private StationAccountService stationAccountService;
|
|
|
|
|
+
|
|
|
|
|
+ private SettlementServiceImpl settlementService;
|
|
|
|
|
+ private List<SplitRecord> records;
|
|
|
|
|
+
|
|
|
|
|
+ @BeforeEach
|
|
|
|
|
+ void setUp() {
|
|
|
|
|
+ settlementService = new SettlementServiceImpl(splitRecordService, stationAccountService);
|
|
|
|
|
+ records = new ArrayList<>();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---------- helpers ----------
|
|
|
|
|
+
|
|
|
|
|
+ private SplitRecord recharge(String stationId, int amount) {
|
|
|
|
|
+ return new SplitRecord()
|
|
|
|
|
+ .setFromStationId(stationId).setToStationId(stationId)
|
|
|
|
|
+ .setAmount(amount).setType(SplitRecord.TYPE_RECHARGE)
|
|
|
|
|
+ .setTradeNo("RECHARGE_" + System.nanoTime());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private SplitRecord refund(String stationId, int amount) {
|
|
|
|
|
+ return new SplitRecord()
|
|
|
|
|
+ .setFromStationId(stationId).setToStationId(stationId)
|
|
|
|
|
+ .setAmount(amount).setType(SplitRecord.TYPE_REFUND)
|
|
|
|
|
+ .setTradeNo("REFUND_" + System.nanoTime());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private SplitRecord crossExpend(String fromStation, String toStation, int amount) {
|
|
|
|
|
+ return new SplitRecord()
|
|
|
|
|
+ .setFromStationId(fromStation).setToStationId(toStation)
|
|
|
|
|
+ .setAmount(amount).setType(SplitRecord.TYPE_CROSS_EXPEND)
|
|
|
|
|
+ .setTradeNo("CROSS_OUT_" + System.nanoTime());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private SplitRecord crossIncome(String fromStation, String toStation, int amount) {
|
|
|
|
|
+ return new SplitRecord()
|
|
|
|
|
+ .setFromStationId(fromStation).setToStationId(toStation)
|
|
|
|
|
+ .setAmount(amount).setType(SplitRecord.TYPE_CROSS_INCOME)
|
|
|
|
|
+ .setTradeNo("CROSS_IN_" + System.nanoTime());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void givenNoPreviousSettlement() {
|
|
|
|
|
+ when(splitRecordService.lambdaQuery()).thenReturn(null);
|
|
|
|
|
+ // first call = getOpeningBalance → returns null (no previous)
|
|
|
|
|
+ // settlement save → just verify
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---------- tests ----------
|
|
|
|
|
+
|
|
|
|
|
+ @Nested
|
|
|
|
|
+ @DisplayName("正常结算场景")
|
|
|
|
|
+ class NormalSettlement {
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("本站充值本站消费 — 无退款无跨店")
|
|
|
|
|
+ void onlyRecharge() {
|
|
|
|
|
+ records.add(recharge("A", 10_000));
|
|
|
|
|
+
|
|
|
|
|
+ // 平台费基数 = 10000 - 0 - 0 + 0 = 10000
|
|
|
|
|
+ // 平台费 = 1000, 结算 = 9000
|
|
|
|
|
+ assertPlatformFee(10_000, 0, 0, 0, 1_000);
|
|
|
|
|
+ assertSettlementAmount(10_000, 0, 0, 0, 9_000);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("含退款 — 退款不计平台费")
|
|
|
|
|
+ void rechargeWithRefund() {
|
|
|
|
|
+ records.add(recharge("A", 10_000));
|
|
|
|
|
+ records.add(refund("A", 2_000));
|
|
|
|
|
+
|
|
|
|
|
+ // 基数 = 10000 - 2000 = 8000, 费 = 800, 结算 = 7200
|
|
|
|
|
+ assertPlatformFee(10_000, 2_000, 0, 0, 800);
|
|
|
|
|
+ assertSettlementAmount(10_000, 2_000, 0, 0, 7_200);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("跨店消费 70:30 — 充值方承担平台费")
|
|
|
|
|
+ void crossStore70_30() {
|
|
|
|
|
+ // A站充值10000,B站消费1000 → A→B 转账700
|
|
|
|
|
+ records.add(recharge("A", 10_000));
|
|
|
|
|
+ records.add(crossExpend("A", "B", 700));
|
|
|
|
|
+ records.add(crossIncome("A", "B", 700));
|
|
|
|
|
+
|
|
|
|
|
+ // A: 基数 = 10000 - 0 - 700 + 0 = 9300, 费 = 930
|
|
|
|
|
+ assertPlatformFee(10_000, 0, 700, 0, 930);
|
|
|
|
|
+ // B: 基数 = 0 - 0 - 0 + 700 = 700, 费 = 70
|
|
|
|
|
+ assertPlatformFee(0, 0, 0, 700, 70);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("完整场景 — 充值+退款+跨店")
|
|
|
|
|
+ void fullScenario() {
|
|
|
|
|
+ records.add(recharge("A", 10_000));
|
|
|
|
|
+ records.add(refund("A", 500));
|
|
|
|
|
+ records.add(crossExpend("A", "B", 700));
|
|
|
|
|
+ records.add(crossIncome("A", "B", 700));
|
|
|
|
|
+
|
|
|
|
|
+ // A: 基数 = 10000 - 500 - 700 + 0 = 8800, 费 = 880, 结算 = 7920
|
|
|
|
|
+ assertPlatformFee(10_000, 500, 700, 0, 880);
|
|
|
|
|
+ assertSettlementAmount(10_000, 500, 700, 0, 7_920);
|
|
|
|
|
+
|
|
|
|
|
+ // B: 基数 = 0 - 0 - 0 + 700 = 700, 费 = 70, 结算 = 630
|
|
|
|
|
+ assertPlatformFee(0, 0, 0, 700, 70);
|
|
|
|
|
+ assertSettlementAmount(0, 0, 0, 700, 630);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("平台费取整 — 向下取整")
|
|
|
|
|
+ void platformFeeRoundingDown() {
|
|
|
|
|
+ // 基数 333 → 平台费 33.3 → 向下取整 33
|
|
|
|
|
+ records.add(recharge("A", 333));
|
|
|
|
|
+ assertPlatformFee(333, 0, 0, 0, 33);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Nested
|
|
|
|
|
+ @DisplayName("异常结算场景")
|
|
|
|
|
+ class AbnormalSettlement {
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("退款大于充值 — 结算金额为负 → 异常结算")
|
|
|
|
|
+ void refundExceedsRecharge() {
|
|
|
|
|
+ records.add(recharge("A", 1_000));
|
|
|
|
|
+ records.add(refund("A", 3_000));
|
|
|
|
|
+
|
|
|
|
|
+ // 基数 = 1000 - 3000 = -2000, 费 = 0
|
|
|
|
|
+ int base = 1_000 - 3_000;
|
|
|
|
|
+ assertTrue(base < 0, "基数应为负");
|
|
|
|
|
+ assertEquals(0, calcPlatformFee(1000, 3000, 0, 0), "负基数平台费应为0");
|
|
|
|
|
+
|
|
|
|
|
+ // 结算 = 0, 结转 = -2000
|
|
|
|
|
+ int available = 0 + base - 0; // opening=0 + (-2000) - 0 = -2000
|
|
|
|
|
+ assertTrue(available < 0, "应触发异常结算");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("跨店支出超过充值 → 异常结算")
|
|
|
|
|
+ void crossExpendExceedsRecharge() {
|
|
|
|
|
+ records.add(recharge("A", 500));
|
|
|
|
|
+ records.add(crossExpend("A", "B", 700));
|
|
|
|
|
+
|
|
|
|
|
+ // 基数 = 500 - 0 - 700 = -200
|
|
|
|
|
+ int base = 500 - 700;
|
|
|
|
|
+ assertTrue(base < 0);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Nested
|
|
|
|
|
+ @DisplayName("期初期末结转")
|
|
|
|
|
+ class BalanceCarryForward {
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常结算 → 期末余额 = 0")
|
|
|
|
|
+ void normalClosingIsZero() {
|
|
|
|
|
+ // 本期流入 10000, 费 1000, 结算 9000
|
|
|
|
|
+ // closing = 0(期初) + 10000 - 0 - 0 + 0 - 1000(费) - 9000(结算) = 0
|
|
|
|
|
+ int opening = 0;
|
|
|
|
|
+ int recharge = 10_000, refund = 0, crossExpend = 0, crossIncome = 0;
|
|
|
|
|
+ int feeBase = recharge - refund - crossExpend + crossIncome; // 10000
|
|
|
|
|
+ int fee = feeBase / 10; // 1000
|
|
|
|
|
+ int settlement = feeBase - fee; // 9000
|
|
|
|
|
+
|
|
|
|
|
+ int closing = opening + feeBase - fee - settlement;
|
|
|
|
|
+ assertEquals(0, closing, "正常结算后期末应为0");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("异常结算 → 结转至下期")
|
|
|
|
|
+ void abnormalCarryForward() {
|
|
|
|
|
+ // 上期异常结转 -500
|
|
|
|
|
+ int opening = -500;
|
|
|
|
|
+ int recharge = 1_000, refund = 0, crossExpend = 0, crossIncome = 0;
|
|
|
|
|
+ int feeBase = recharge; // 1000 (仅本期)
|
|
|
|
|
+ int fee = 100; // 1000 × 10%
|
|
|
|
|
+ int totalAvailable = opening + feeBase - fee; // -500 + 1000 - 100 = 400
|
|
|
|
|
+ assertTrue(totalAvailable > 0, "含上期结转后应为正");
|
|
|
|
|
+
|
|
|
|
|
+ int settlement = 400;
|
|
|
|
|
+ int closing = totalAvailable - settlement; // 0
|
|
|
|
|
+ assertEquals(0, closing, "抵消上期负结转后期末应为0");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Nested
|
|
|
|
|
+ @DisplayName("边界条件")
|
|
|
|
|
+ class EdgeCases {
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("金额为0 — 不产生结算记录")
|
|
|
|
|
+ void zeroAmount() {
|
|
|
|
|
+ // 无任何分账记录 → 跳过结算
|
|
|
|
|
+ assertTrue(records.isEmpty());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("仅跨店收入 — B站无充值但有服务收入")
|
|
|
|
|
+ void onlyCrossIncome() {
|
|
|
|
|
+ records.add(crossIncome("A", "B", 700));
|
|
|
|
|
+ // B: 基数 = 0 - 0 - 0 + 700 = 700, 费 = 70, 结算 = 630
|
|
|
|
|
+ assertPlatformFee(0, 0, 0, 700, 70);
|
|
|
|
|
+ assertSettlementAmount(0, 0, 0, 700, 630);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("大额金额 — 不溢出")
|
|
|
|
|
+ void largeAmount() {
|
|
|
|
|
+ int largeAmount = Integer.MAX_VALUE / 100; // ~21,474,836
|
|
|
|
|
+ records.add(recharge("A", largeAmount));
|
|
|
|
|
+ int expectedFee = (int) (largeAmount * 0.1);
|
|
|
|
|
+ assertPlatformFee(largeAmount, 0, 0, 0, expectedFee);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---------- calculation helpers ----------
|
|
|
|
|
+
|
|
|
|
|
+ private int calcPlatformFee(int recharge, int refund, int crossExpend, int crossIncome) {
|
|
|
|
|
+ int base = recharge - refund - crossExpend + crossIncome;
|
|
|
|
|
+ return base > 0 ? (int) Math.floor(base * 0.1) : 0;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private int calcSettlement(int recharge, int refund, int crossExpend, int crossIncome) {
|
|
|
|
|
+ int base = recharge - refund - crossExpend + crossIncome;
|
|
|
|
|
+ int fee = calcPlatformFee(recharge, refund, crossExpend, crossIncome);
|
|
|
|
|
+ return base - fee;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void assertPlatformFee(int recharge, int refund, int crossExpend, int crossIncome, int expectedFee) {
|
|
|
|
|
+ assertEquals(expectedFee, calcPlatformFee(recharge, refund, crossExpend, crossIncome),
|
|
|
|
|
+ String.format("平台费计算错误: 充值=%d 退款=%d 跨店支出=%d 跨店收入=%d", recharge, refund, crossExpend, crossIncome));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void assertSettlementAmount(int recharge, int refund, int crossExpend, int crossIncome, int expected) {
|
|
|
|
|
+ assertEquals(expected, calcSettlement(recharge, refund, crossExpend, crossIncome),
|
|
|
|
|
+ String.format("结算金额计算错误: 充值=%d 退款=%d 跨店支出=%d 跨店收入=%d", recharge, refund, crossExpend, crossIncome));
|
|
|
|
|
+ }
|
|
|
|
|
+}
|