fujian_water_biz_doc/docs/superpowers/plans/2026-06-30-late-fee-start-and-daily-recalculation.md

54 KiB
Raw Blame History

Late Fee Start and Daily Recalculation Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Implement fixed late-fee start-date calculation at billing time and idempotent daily recalculation of current receivable late fees.

Architecture: Add a focused service.charge.latefee package for parameter resolution, start-date calculation, amount calculation, and daily orchestration. Wire billing creation to save ChargeDO.lateFeeBeginDate, then add an XXL-JOB entry point that recalculates only unsettled charges from the persisted start date. Keep the existing late-fee-reduce date-mode calculator unchanged because it serves adjustment previews, not billing-time late-fee generation.

Tech Stack: Java 17, Spring Boot, MyBatis Plus, XXL-JOB, JUnit 5, Mockito, Maven.


Spec Source

Approved spec:

  • ../water-docs/docs/superpowers/specs/2026-06-30-late-fee-start-and-daily-recalculation-design.md

Confirmed implementation direction:

  • Billing saves ChargeDO.lateFeeBeginDate once.
  • Daily task uses persisted lateFeeBeginDate, not current parameters.
  • Daily task recalculates and overwrites ChargeDO.lateFee idempotently.
  • Existing paid charges are not recalculated.
  • Existing unsettled charges with missing lateFeeBeginDate are skipped and logged.

File Structure

Create:

  • sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/charge/latefee/LateFeeSettingCodes.java
    Constants for system parameter codes.
  • sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/charge/latefee/LateFeeConfig.java
    Immutable parsed config used by calculators.
  • sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/charge/latefee/LateFeeConfigResolver.java
    Reads ParameterSettingsService, applies fallback rules, returns LateFeeConfig. Current ParameterSettingsDO only has parameterValue, so resolver uses explicit business fallback constants when parameterValue is invalid or blank.
  • sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/charge/latefee/LateFeeBeginDateCalculator.java
    Pure calculator for ChargeDO.lateFeeBeginDate.
  • sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/charge/latefee/LateFeeDailyCalculator.java
    Pure calculator for theoretical/current receivable late fee.
  • sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/charge/latefee/LateFeeRecalculationService.java
    Loads unsettled charges and dependencies, invokes calculator, updates ChargeDO.lateFee.
  • sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/job/charge/LateFeeRecalculationJob.java
    XXL-JOB wrapper.
  • sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/charge/latefee/LateFeeBeginDateCalculatorTest.java
  • sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/charge/latefee/LateFeeConfigResolverTest.java
  • sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/charge/latefee/LateFeeDailyCalculatorTest.java
  • sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/charge/latefee/LateFeeRecalculationServiceTest.java

Modify:

  • sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/charge/ChargeServiceImpl.java
    Inject config resolver and begin-date calculator. Set lateFeeBeginDate before chargeMapper.insert(charge) in billing paths.
  • sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/mysql/charge/ChargeMapper.java
    Add selection helper for unsettled charges.
  • sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/mysql/paymentrecorddetail/PaymentRecordDetailMapper.java
    Add selection helper for applied successful late-fee details by charge IDs.
  • sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/mysql/latefeereducedetail/LateFeeReduceDetailMapper.java
    Add selection helper for effective approved reduction details by charge IDs.
  • sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/charge/ChargeServiceBillingValidationTest.java or a new focused ChargeLateFeeBeginDateWiringTest.java
    Verify billing path sets lateFeeBeginDate.

Task 1: Start-Date Calculator

Files:

  • Create: sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/charge/latefee/LateFeeSettingCodes.java

  • Create: sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/charge/latefee/LateFeeConfig.java

  • Create: sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/charge/latefee/LateFeeBeginDateCalculator.java

  • Test: sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/charge/latefee/LateFeeBeginDateCalculatorTest.java

  • Step 1: Write failing tests for the four start-date types

Create LateFeeBeginDateCalculatorTest.java:

package cn.com.emsoft.sw.business.service.charge.latefee;

import org.junit.jupiter.api.Test;

import java.time.LocalDate;
import java.time.LocalDateTime;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

class LateFeeBeginDateCalculatorTest {

    private final LateFeeBeginDateCalculator calculator = new LateFeeBeginDateCalculator();

    @Test
    void type1_shouldUseBillMonthCurrentMonthAndSkipDueDay() {
        LateFeeConfig config = LateFeeConfig.builder()
                .calcType(1)
                .calcSetting(5)
                .lateFeeDecimal(1)
                .principalCapMultiplier(java.math.BigDecimal.ONE)
                .build();

        assertEquals(LocalDate.of(2026, 6, 6),
                calculator.calculateBeginDate(202606, LocalDateTime.of(2026, 6, 1, 9, 0), config).orElseThrow());
    }

    @Test
    void type2_shouldTreatSettingZeroAsBillDateDueDay() {
        LateFeeConfig config = LateFeeConfig.builder()
                .calcType(2)
                .calcSetting(0)
                .lateFeeDecimal(1)
                .principalCapMultiplier(java.math.BigDecimal.ONE)
                .build();

        assertEquals(LocalDate.of(2026, 6, 2),
                calculator.calculateBeginDate(202606, LocalDateTime.of(2026, 6, 1, 9, 0), config).orElseThrow());
    }

    @Test
    void type2_shouldExcludeBillDateFromGraceDays() {
        LateFeeConfig config = LateFeeConfig.builder()
                .calcType(2)
                .calcSetting(5)
                .lateFeeDecimal(1)
                .principalCapMultiplier(java.math.BigDecimal.ONE)
                .build();

        assertEquals(LocalDate.of(2026, 6, 7),
                calculator.calculateBeginDate(202606, LocalDateTime.of(2026, 6, 1, 9, 0), config).orElseThrow());
    }

    @Test
    void type3_shouldUseNextMonthLastDayWhenSettingDayDoesNotExist() {
        LateFeeConfig config = LateFeeConfig.builder()
                .calcType(3)
                .calcSetting(31)
                .lateFeeDecimal(1)
                .principalCapMultiplier(java.math.BigDecimal.ONE)
                .build();

        assertEquals(LocalDate.of(2026, 3, 1),
                calculator.calculateBeginDate(202602, LocalDateTime.of(2026, 2, 10, 9, 0), config).orElseThrow());
    }

    @Test
    void type4_shouldUseMonthAfterNextMonth() {
        LateFeeConfig config = LateFeeConfig.builder()
                .calcType(4)
                .calcSetting(10)
                .lateFeeDecimal(1)
                .principalCapMultiplier(java.math.BigDecimal.ONE)
                .build();

        assertEquals(LocalDate.of(2026, 8, 11),
                calculator.calculateBeginDate(202606, LocalDateTime.of(2026, 6, 20, 9, 0), config).orElseThrow());
    }

    @Test
    void shouldNeverBeginBeforeBillDateNextDay() {
        LateFeeConfig config = LateFeeConfig.builder()
                .calcType(1)
                .calcSetting(5)
                .lateFeeDecimal(1)
                .principalCapMultiplier(java.math.BigDecimal.ONE)
                .build();

        assertEquals(LocalDate.of(2026, 6, 11),
                calculator.calculateBeginDate(202606, LocalDateTime.of(2026, 6, 10, 9, 0), config).orElseThrow());
    }

    @Test
    void shouldReturnEmptyWhenBillDateMissing() {
        LateFeeConfig config = LateFeeConfig.builder()
                .calcType(1)
                .calcSetting(5)
                .lateFeeDecimal(1)
                .principalCapMultiplier(java.math.BigDecimal.ONE)
                .build();

        assertTrue(calculator.calculateBeginDate(202606, null, config).isEmpty());
    }
}
  • Step 2: Run test to verify it fails

Run:

mvn -pl sw-business/sw-business-server -am -Dtest=LateFeeBeginDateCalculatorTest test

Expected: FAIL because LateFeeConfig and LateFeeBeginDateCalculator do not exist.

  • Step 3: Add config and start-date calculator

Create LateFeeSettingCodes.java:

package cn.com.emsoft.sw.business.service.charge.latefee;

public final class LateFeeSettingCodes {

    public static final String CALC_LATE_FEE_TYPE = "CALC_LATE_FEE_TYPE";
    public static final String CALC_LATE_FEE_SETTING = "CALC_LATE_FEE_SETTING";
    public static final String LATE_FEE_DECIMAL = "LATE_FEE_DECIMAL";
    public static final String LATE_FEE_MORE_PRINCIPAL = "LATE_FEE_MORE_PRINCIPAL";

    private LateFeeSettingCodes() {
    }
}

Create LateFeeConfig.java:

package cn.com.emsoft.sw.business.service.charge.latefee;

import lombok.Builder;
import lombok.Value;

import java.math.BigDecimal;

@Value
@Builder
public class LateFeeConfig {

    Integer calcType;

    Integer calcSetting;

    Integer lateFeeDecimal;

    BigDecimal principalCapMultiplier;

    public boolean hasNoPrincipalCap() {
        return principalCapMultiplier != null && principalCapMultiplier.compareTo(BigDecimal.ZERO) == 0;
    }
}

Create LateFeeBeginDateCalculator.java:

package cn.com.emsoft.sw.business.service.charge.latefee;

import org.springframework.stereotype.Component;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.YearMonth;
import java.util.Optional;

@Component
public class LateFeeBeginDateCalculator {

    public Optional<LocalDate> calculateBeginDate(Integer billMonth, LocalDateTime billDate, LateFeeConfig config) {
        if (billMonth == null || billDate == null || config == null
                || config.getCalcType() == null || config.getCalcSetting() == null) {
            return Optional.empty();
        }

        LocalDate dueDate = switch (config.getCalcType()) {
            case 1 -> dayInBillMonth(billMonth, 0, config.getCalcSetting());
            case 2 -> billDate.toLocalDate().plusDays(config.getCalcSetting());
            case 3 -> dayInBillMonth(billMonth, 1, config.getCalcSetting());
            case 4 -> dayInBillMonth(billMonth, 2, config.getCalcSetting());
            default -> null;
        };
        if (dueDate == null) {
            return Optional.empty();
        }

        LocalDate configuredBegin = dueDate.plusDays(1);
        LocalDate billDateBegin = billDate.toLocalDate().plusDays(1);
        return Optional.of(configuredBegin.isAfter(billDateBegin) ? configuredBegin : billDateBegin);
    }

    private LocalDate dayInBillMonth(Integer billMonth, int monthOffset, int configuredDay) {
        int year = billMonth / 100;
        int month = billMonth % 100;
        if (month < 1 || month > 12 || configuredDay < 1 || configuredDay > 31) {
            return null;
        }
        YearMonth target = YearMonth.of(year, month).plusMonths(monthOffset);
        int day = Math.min(configuredDay, target.lengthOfMonth());
        return target.atDay(day);
    }
}
  • Step 4: Run test to verify it passes

Run:

mvn -pl sw-business/sw-business-server -am -Dtest=LateFeeBeginDateCalculatorTest test

Expected: PASS.

  • Step 5: Commit
git add sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/charge/latefee/LateFeeSettingCodes.java \
  sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/charge/latefee/LateFeeConfig.java \
  sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/charge/latefee/LateFeeBeginDateCalculator.java \
  sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/charge/latefee/LateFeeBeginDateCalculatorTest.java
git commit -m "feat: add late fee begin date calculator"

Task 2: Parameter Resolver

Files:

  • Create: sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/charge/latefee/LateFeeConfigResolver.java

  • Test: sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/charge/latefee/LateFeeConfigResolverTest.java

  • Step 1: Write failing resolver tests

Create LateFeeConfigResolverTest.java:

package cn.com.emsoft.sw.business.service.charge.latefee;

import cn.com.emsoft.sw.business.dal.dataobject.parametersettings.ParameterSettingsDO;
import cn.com.emsoft.sw.business.service.parametersettings.ParameterSettingsService;
import org.junit.jupiter.api.Test;

import java.math.BigDecimal;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

class LateFeeConfigResolverTest {

    private final ParameterSettingsService parameterSettingsService = mock(ParameterSettingsService.class);
    private final LateFeeConfigResolver resolver = new LateFeeConfigResolver(parameterSettingsService);

    @Test
    void shouldResolveValidValues() {
        mockSetting(LateFeeSettingCodes.CALC_LATE_FEE_TYPE, "3");
        mockSetting(LateFeeSettingCodes.CALC_LATE_FEE_SETTING, "10");
        mockSetting(LateFeeSettingCodes.LATE_FEE_DECIMAL, "1");
        mockSetting(LateFeeSettingCodes.LATE_FEE_MORE_PRINCIPAL, "2");

        LateFeeConfig config = resolver.resolve();

        assertEquals(3, config.getCalcType());
        assertEquals(10, config.getCalcSetting());
        assertEquals(1, config.getLateFeeDecimal());
        assertEquals(new BigDecimal("2"), config.getPrincipalCapMultiplier());
    }

    @Test
    void shouldFallbackInvalidValuesToBusinessDefaults() {
        mockSetting(LateFeeSettingCodes.CALC_LATE_FEE_TYPE, "x");
        mockSetting(LateFeeSettingCodes.CALC_LATE_FEE_SETTING, "x");
        mockSetting(LateFeeSettingCodes.LATE_FEE_DECIMAL, "-1");
        mockSetting(LateFeeSettingCodes.LATE_FEE_MORE_PRINCIPAL, "");

        LateFeeConfig config = resolver.resolve();

        assertEquals(3, config.getCalcType());
        assertEquals(null, config.getCalcSetting());
        assertEquals(0, config.getLateFeeDecimal());
        assertEquals(BigDecimal.ONE, config.getPrincipalCapMultiplier());
    }

    @Test
    void shouldRejectInvalidDayAndAllowType2Zero() {
        mockSetting(LateFeeSettingCodes.CALC_LATE_FEE_TYPE, "1");
        mockSetting(LateFeeSettingCodes.CALC_LATE_FEE_SETTING, "0");
        mockSetting(LateFeeSettingCodes.LATE_FEE_DECIMAL, "");
        mockSetting(LateFeeSettingCodes.LATE_FEE_MORE_PRINCIPAL, "-9");

        LateFeeConfig invalidDayConfig = resolver.resolve();

        assertEquals(1, invalidDayConfig.getCalcType());
        assertEquals(null, invalidDayConfig.getCalcSetting());
        assertEquals(0, invalidDayConfig.getLateFeeDecimal());
        assertEquals(BigDecimal.ONE, invalidDayConfig.getPrincipalCapMultiplier());

        mockSetting(LateFeeSettingCodes.CALC_LATE_FEE_TYPE, "2");
        mockSetting(LateFeeSettingCodes.CALC_LATE_FEE_SETTING, "0");

        LateFeeConfig zeroGraceConfig = resolver.resolve();
        assertEquals(2, zeroGraceConfig.getCalcType());
        assertEquals(0, zeroGraceConfig.getCalcSetting());
    }

    private void mockSetting(String code, String value) {
        ParameterSettingsDO setting = new ParameterSettingsDO();
        setting.setParameterCode(code);
        setting.setParameterValue(value);
        when(parameterSettingsService.getParameterSettingsByCode(code)).thenReturn(setting);
    }
}
  • Step 2: Run test to verify it fails

Run:

mvn -pl sw-business/sw-business-server -am -Dtest=LateFeeConfigResolverTest test

Expected: FAIL because LateFeeConfigResolver does not exist.

  • Step 3: Implement resolver

Create LateFeeConfigResolver.java:

package cn.com.emsoft.sw.business.service.charge.latefee;

import cn.com.emsoft.sw.business.dal.dataobject.parametersettings.ParameterSettingsDO;
import cn.com.emsoft.sw.business.service.parametersettings.ParameterSettingsService;
import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import java.math.BigDecimal;

@Component
@RequiredArgsConstructor
public class LateFeeConfigResolver {

    private final ParameterSettingsService parameterSettingsService;

    public LateFeeConfig resolve() {
        Integer calcType = validCalcType(resolveInteger(LateFeeSettingCodes.CALC_LATE_FEE_TYPE), 3);
        Integer calcSetting = validCalcSetting(calcType, resolveInteger(LateFeeSettingCodes.CALC_LATE_FEE_SETTING));
        Integer decimal = resolveNonNegativeInteger(LateFeeSettingCodes.LATE_FEE_DECIMAL, 0);
        BigDecimal cap = resolveCapMultiplier();
        return LateFeeConfig.builder()
                .calcType(calcType)
                .calcSetting(calcSetting)
                .lateFeeDecimal(decimal)
                .principalCapMultiplier(cap)
                .build();
    }

    private Integer resolveInteger(String code) {
        ParameterSettingsDO setting = parameterSettingsService.getParameterSettingsByCode(code);
        return parseInteger(setting == null ? null : setting.getParameterValue());
    }

    private Integer resolveNonNegativeInteger(String code, int finalFallback) {
        Integer value = resolveInteger(code);
        return value == null || value < 0 ? finalFallback : value;
    }

    private BigDecimal resolveCapMultiplier() {
        ParameterSettingsDO setting = parameterSettingsService.getParameterSettingsByCode(LateFeeSettingCodes.LATE_FEE_MORE_PRINCIPAL);
        BigDecimal value = parseDecimal(setting == null ? null : setting.getParameterValue());
        if (value == null || value.compareTo(BigDecimal.ZERO) < 0) {
            return BigDecimal.ONE;
        }
        return value;
    }

    private Integer validCalcType(Integer value, int fallback) {
        if (value == null || value < 1 || value > 4) {
            return fallback;
        }
        return value;
    }

    private Integer validCalcSetting(Integer calcType, Integer value) {
        if (calcType == null || value == null) {
            return null;
        }
        if (calcType == 2) {
            return value >= 0 ? value : null;
        }
        return value >= 1 && value <= 31 ? value : null;
    }

    private Integer parseInteger(String raw) {
        if (StrUtil.isBlank(raw)) {
            return null;
        }
        try {
            return Integer.valueOf(raw.trim());
        } catch (NumberFormatException ignored) {
            return null;
        }
    }

    private BigDecimal parseDecimal(String raw) {
        if (StrUtil.isBlank(raw)) {
            return null;
        }
        try {
            return new BigDecimal(raw.trim());
        } catch (NumberFormatException ignored) {
            return null;
        }
    }
}
  • Step 4: Run test to verify it passes

Run:

mvn -pl sw-business/sw-business-server -am -Dtest=LateFeeConfigResolverTest test

Expected: PASS.

  • Step 5: Commit
git add sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/charge/latefee/LateFeeConfigResolver.java \
  sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/charge/latefee/LateFeeConfigResolverTest.java
git commit -m "feat: resolve late fee parameter config"

Task 3: Daily Amount Calculator

Files:

  • Create: sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/charge/latefee/LateFeeDailyCalculator.java

  • Test: sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/charge/latefee/LateFeeDailyCalculatorTest.java

  • Step 1: Write failing amount calculator tests

Create LateFeeDailyCalculatorTest.java:

package cn.com.emsoft.sw.business.service.charge.latefee;

import cn.com.emsoft.sw.business.dal.dataobject.charge.ChargeDO;
import cn.com.emsoft.sw.business.dal.dataobject.chargedetail.ChargeDetailDO;
import cn.com.emsoft.sw.business.dal.dataobject.costcomponent.CostComponentDO;
import org.junit.jupiter.api.Test;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;

import static org.junit.jupiter.api.Assertions.assertEquals;

class LateFeeDailyCalculatorTest {

    private final LateFeeDailyCalculator calculator = new LateFeeDailyCalculator();

    @Test
    void shouldReturnZeroBeforeBeginDate() {
        ChargeDO charge = charge(new BigDecimal("100.00"), LocalDateTime.of(2026, 6, 10, 0, 0));

        LateFeeDailyCalculator.Result result = calculator.calculate(charge, details(), components(),
                LateFeeConfig.builder().lateFeeDecimal(1).principalCapMultiplier(BigDecimal.ONE).build(),
                LocalDate.of(2026, 6, 9), BigDecimal.ZERO, BigDecimal.ZERO);

        assertEquals(0, result.overdueDays());
        assertEquals(new BigDecimal("0.0"), result.currentLateFee());
    }

    @Test
    void shouldIncludeBeginDateAsFirstOverdueDay() {
        ChargeDO charge = charge(new BigDecimal("100.00"), LocalDateTime.of(2026, 6, 10, 0, 0));

        LateFeeDailyCalculator.Result result = calculator.calculate(charge, details(), components(),
                LateFeeConfig.builder().lateFeeDecimal(1).principalCapMultiplier(BigDecimal.ONE).build(),
                LocalDate.of(2026, 6, 10), BigDecimal.ZERO, BigDecimal.ZERO);

        assertEquals(1, result.overdueDays());
        assertEquals(new BigDecimal("0.1"), result.currentLateFee());
    }

    @Test
    void shouldRoundEachItemThenSumAndApplyPrincipalCap() {
        ChargeDO charge = charge(new BigDecimal("100.00"), LocalDateTime.of(2026, 6, 1, 0, 0));

        LateFeeDailyCalculator.Result result = calculator.calculate(charge, details(), components(),
                LateFeeConfig.builder().lateFeeDecimal(1).principalCapMultiplier(new BigDecimal("0.01")).build(),
                LocalDate.of(2026, 6, 10), BigDecimal.ZERO, BigDecimal.ZERO);

        assertEquals(10, result.overdueDays());
        assertEquals(new BigDecimal("1.0"), result.theoreticalLateFee());
        assertEquals(new BigDecimal("1.0"), result.currentLateFee());
    }

    @Test
    void shouldDeductPaidAndApprovedReducedLateFee() {
        ChargeDO charge = charge(new BigDecimal("100.00"), LocalDateTime.of(2026, 6, 1, 0, 0));

        LateFeeDailyCalculator.Result result = calculator.calculate(charge, details(), components(),
                LateFeeConfig.builder().lateFeeDecimal(1).principalCapMultiplier(BigDecimal.ZERO).build(),
                LocalDate.of(2026, 6, 10), new BigDecimal("0.4"), new BigDecimal("0.3"));

        assertEquals(new BigDecimal("1.2"), result.theoreticalLateFee());
        assertEquals(new BigDecimal("0.5"), result.currentLateFee());
    }

    private ChargeDO charge(BigDecimal unpaidPrincipal, LocalDateTime beginDate) {
        ChargeDO charge = new ChargeDO();
        charge.setId(1001L);
        charge.setExtendedAmount(unpaidPrincipal);
        charge.setLateFeeBeginDate(beginDate);
        return charge;
    }

    private List<ChargeDetailDO> details() {
        ChargeDetailDO water = new ChargeDetailDO();
        water.setCostComponentCode("101");
        water.setMoney(new BigDecimal("80.00"));
        ChargeDetailDO sewage = new ChargeDetailDO();
        sewage.setCostComponentCode("102");
        sewage.setMoney(new BigDecimal("20.00"));
        return List.of(water, sewage);
    }

    private Map<String, CostComponentDO> components() {
        CostComponentDO water = new CostComponentDO();
        water.setCode("101");
        water.setPenaltyCoefficient(new BigDecimal("0.0010"));
        CostComponentDO sewage = new CostComponentDO();
        sewage.setCode("102");
        sewage.setPenaltyCoefficient(new BigDecimal("0.0020"));
        return Map.of("101", water, "102", sewage);
    }
}
  • Step 2: Run test to verify it fails

Run:

mvn -pl sw-business/sw-business-server -am -Dtest=LateFeeDailyCalculatorTest test

Expected: FAIL because LateFeeDailyCalculator does not exist.

  • Step 3: Implement daily amount calculator

Create LateFeeDailyCalculator.java:

package cn.com.emsoft.sw.business.service.charge.latefee;

import cn.com.emsoft.sw.business.dal.dataobject.charge.ChargeDO;
import cn.com.emsoft.sw.business.dal.dataobject.chargedetail.ChargeDetailDO;
import cn.com.emsoft.sw.business.dal.dataobject.costcomponent.CostComponentDO;
import cn.hutool.core.collection.CollUtil;
import org.springframework.stereotype.Component;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Map;

@Component
public class LateFeeDailyCalculator {

    public Result calculate(ChargeDO charge,
                            List<ChargeDetailDO> chargeDetails,
                            Map<String, CostComponentDO> costComponentMap,
                            LateFeeConfig config,
                            LocalDate currentDate,
                            BigDecimal paidLateFee,
                            BigDecimal approvedReducedLateFee) {
        int scale = config == null || config.getLateFeeDecimal() == null ? 0 : config.getLateFeeDecimal();
        BigDecimal zero = BigDecimal.ZERO.setScale(scale, RoundingMode.HALF_UP);
        if (charge == null || charge.getLateFeeBeginDate() == null || currentDate == null
                || currentDate.isBefore(charge.getLateFeeBeginDate().toLocalDate())
                || CollUtil.isEmpty(chargeDetails)) {
            return new Result(0, zero, zero);
        }

        long overdueDays = ChronoUnit.DAYS.between(charge.getLateFeeBeginDate().toLocalDate(), currentDate) + 1;
        BigDecimal currentPrincipal = nonNull(charge.getExtendedAmount());
        if (currentPrincipal.compareTo(BigDecimal.ZERO) <= 0) {
            return new Result((int) overdueDays, zero, zero);
        }

        BigDecimal detailTotal = chargeDetails.stream()
                .map(this::detailPrincipal)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
        if (detailTotal.compareTo(BigDecimal.ZERO) <= 0) {
            return new Result((int) overdueDays, zero, zero);
        }

        BigDecimal theoretical = BigDecimal.ZERO;
        for (int i = 0; i < chargeDetails.size(); i++) {
            ChargeDetailDO detail = chargeDetails.get(i);
            BigDecimal principal = allocatedPrincipal(detail, currentPrincipal, detailTotal, i == chargeDetails.size() - 1,
                    chargeDetails.subList(0, i));
            BigDecimal coefficient = coefficient(detail.getCostComponentCode(), costComponentMap);
            BigDecimal itemLateFee = principal.multiply(coefficient)
                    .multiply(BigDecimal.valueOf(overdueDays))
                    .setScale(scale, RoundingMode.HALF_UP);
            theoretical = theoretical.add(itemLateFee);
        }

        theoretical = applyCap(theoretical, currentPrincipal, config).setScale(scale, RoundingMode.HALF_UP);
        BigDecimal current = theoretical
                .subtract(nonNull(paidLateFee))
                .subtract(nonNull(approvedReducedLateFee));
        if (current.compareTo(BigDecimal.ZERO) < 0) {
            current = BigDecimal.ZERO;
        }
        return new Result((int) overdueDays, theoretical, current.setScale(scale, RoundingMode.HALF_UP));
    }

    private BigDecimal allocatedPrincipal(ChargeDetailDO detail, BigDecimal currentPrincipal, BigDecimal detailTotal,
                                          boolean last, List<ChargeDetailDO> previousDetails) {
        if (last) {
            BigDecimal previous = previousDetails.stream()
                    .map(previousDetail -> detailPrincipal(previousDetail).multiply(currentPrincipal)
                            .divide(detailTotal, 10, RoundingMode.HALF_UP)
                            .setScale(2, RoundingMode.HALF_UP))
                    .reduce(BigDecimal.ZERO, BigDecimal::add);
            return currentPrincipal.subtract(previous);
        }
        return detailPrincipal(detail).multiply(currentPrincipal)
                .divide(detailTotal, 10, RoundingMode.HALF_UP)
                .setScale(2, RoundingMode.HALF_UP);
    }

    private BigDecimal detailPrincipal(ChargeDetailDO detail) {
        BigDecimal money = detail.getMoney() == null ? detail.getOriginalMoney() : detail.getMoney();
        return nonNull(money);
    }

    private BigDecimal coefficient(String code, Map<String, CostComponentDO> costComponentMap) {
        if (code == null || costComponentMap == null || !costComponentMap.containsKey(code)
                || costComponentMap.get(code).getPenaltyCoefficient() == null) {
            return BigDecimal.ZERO;
        }
        return costComponentMap.get(code).getPenaltyCoefficient();
    }

    private BigDecimal applyCap(BigDecimal theoretical, BigDecimal currentPrincipal, LateFeeConfig config) {
        if (config == null || config.hasNoPrincipalCap()) {
            return theoretical;
        }
        BigDecimal multiplier = config.getPrincipalCapMultiplier() == null ? BigDecimal.ONE : config.getPrincipalCapMultiplier();
        BigDecimal cap = currentPrincipal.multiply(multiplier);
        return theoretical.min(cap);
    }

    private BigDecimal nonNull(BigDecimal value) {
        return value == null ? BigDecimal.ZERO : value;
    }

    public record Result(int overdueDays, BigDecimal theoreticalLateFee, BigDecimal currentLateFee) {
    }
}
  • Step 4: Run test to verify it passes

Run:

mvn -pl sw-business/sw-business-server -am -Dtest=LateFeeDailyCalculatorTest test

Expected: PASS.

  • Step 5: Commit
git add sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/charge/latefee/LateFeeDailyCalculator.java \
  sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/charge/latefee/LateFeeDailyCalculatorTest.java
git commit -m "feat: add late fee daily calculator"

Task 4: Mapper Helpers for Recalculation Inputs

Files:

  • Modify: sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/mysql/charge/ChargeMapper.java

  • Modify: sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/mysql/paymentrecorddetail/PaymentRecordDetailMapper.java

  • Modify: sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/mysql/latefeereducedetail/LateFeeReduceDetailMapper.java

  • Step 1: Add mapper helper methods

In ChargeMapper.java, add this default method near other charge lookup helpers:

default List<ChargeDO> selectUnsettledForLateFeeRecalculation(int limit) {
    return selectList(new LambdaQueryWrapperX<ChargeDO>()
            .eq(ChargeDO::getPayState, PayStateEnum.UNPAID.getValue())
            .orderByAsc(ChargeDO::getId)
            .last("limit " + Math.max(1, limit)));
}

Ensure ChargeMapper.java imports cn.com.emsoft.sw.business.enums.charge.PayStateEnum; it already uses that enum later in the file, so preserve the existing import if present.

In PaymentRecordDetailMapper.java, add:

default List<PaymentRecordDetailDO> selectLateFeeDetailsByChargeIds(List<Long> chargeIds) {
    if (chargeIds == null || chargeIds.isEmpty()) {
        return List.of();
    }
    return selectList(new LambdaQueryWrapperX<PaymentRecordDetailDO>()
            .in(PaymentRecordDetailDO::getChargeId, chargeIds)
            .eq(PaymentRecordDetailDO::getDetailType, "LATE_FEE")
            .gt(PaymentRecordDetailDO::getLateFeeAmount, java.math.BigDecimal.ZERO)
            .orderByAsc(PaymentRecordDetailDO::getChargeId)
            .orderByAsc(PaymentRecordDetailDO::getId));
}

In LateFeeReduceDetailMapper.java, add:

default List<LateFeeReduceDetailDO> selectApprovedSuccessByChargeIds(List<Long> chargeIds) {
    if (chargeIds == null || chargeIds.isEmpty()) {
        return Collections.emptyList();
    }
    return selectList(new LambdaQueryWrapperX<LateFeeReduceDetailDO>()
            .in(LateFeeReduceDetailDO::getChargeId, chargeIds)
            .eq(LateFeeReduceDetailDO::getDetailStatus, "SUCCESS")
            .gt(LateFeeReduceDetailDO::getReduceAmount, java.math.BigDecimal.ZERO)
            .orderByAsc(LateFeeReduceDetailDO::getChargeId)
            .orderByAsc(LateFeeReduceDetailDO::getId));
}
  • Step 2: Compile modified mappers

Run:

mvn -pl sw-business/sw-business-server -am -DskipTests compile

Expected: SUCCESS. If LambdaQueryWrapperX.gt is unavailable in this codebase, replace .gt(..., BigDecimal.ZERO) with a Java-side filter in LateFeeRecalculationService and keep only the in/eq conditions in mapper helpers.

  • Step 3: Commit
git add sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/mysql/charge/ChargeMapper.java \
  sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/mysql/paymentrecorddetail/PaymentRecordDetailMapper.java \
  sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/mysql/latefeereducedetail/LateFeeReduceDetailMapper.java
git commit -m "feat: add late fee recalculation mapper helpers"

Task 5: Billing-Time lateFeeBeginDate Wiring

Files:

  • Modify: sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/charge/ChargeServiceImpl.java

  • Test: sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/charge/ChargeLateFeeBeginDateWiringTest.java

  • Step 1: Write a focused wiring test

Create ChargeLateFeeBeginDateWiringTest.java as a source-contract test. This keeps the first wiring check narrow and avoids building the full generateSingleChargeWithCache fixture.

package cn.com.emsoft.sw.business.service.charge;

import org.junit.jupiter.api.Test;

import java.nio.file.Files;
import java.nio.file.Path;

import static org.junit.jupiter.api.Assertions.assertTrue;

class ChargeLateFeeBeginDateWiringTest {

    @Test
    void chargeServiceShouldSetLateFeeBeginDateBeforeBillingInsert() throws Exception {
        String source = Files.readString(Path.of(
                "src/main/java/cn/com/emsoft/sw/business/service/charge/ChargeServiceImpl.java"));

        assertTrue(source.contains("LateFeeConfigResolver"), "ChargeServiceImpl should inject LateFeeConfigResolver");
        assertTrue(source.contains("LateFeeBeginDateCalculator"), "ChargeServiceImpl should inject LateFeeBeginDateCalculator");
        assertTrue(source.contains("applyLateFeeBeginDate(charge)"), "billing path should apply late fee begin date before insert");

        int methodStart = source.indexOf("private boolean generateSingleChargeWithCache");
        int applyIndex = source.indexOf("applyLateFeeBeginDate(charge)", methodStart);
        int insertIndex = source.indexOf("chargeMapper.insert(charge)", methodStart);
        assertTrue(methodStart >= 0, "generateSingleChargeWithCache must exist");
        assertTrue(applyIndex > methodStart, "generateSingleChargeWithCache must apply late fee begin date");
        assertTrue(insertIndex > applyIndex, "lateFeeBeginDate must be applied before charge insert");
    }
}
  • Step 2: Run test to verify it fails

Run:

mvn -pl sw-business/sw-business-server -am -Dtest=ChargeLateFeeBeginDateWiringTest test

Expected: FAIL because ChargeServiceImpl does not yet contain the wiring.

  • Step 3: Inject dependencies and helper method

In ChargeServiceImpl.java, add imports:

import cn.com.emsoft.sw.business.service.charge.latefee.LateFeeBeginDateCalculator;
import cn.com.emsoft.sw.business.service.charge.latefee.LateFeeConfig;
import cn.com.emsoft.sw.business.service.charge.latefee.LateFeeConfigResolver;

Add fields near other @Resource dependencies:

@Resource
private LateFeeConfigResolver lateFeeConfigResolver;
@Resource
private LateFeeBeginDateCalculator lateFeeBeginDateCalculator;

Add helper method inside ChargeServiceImpl:

private void applyLateFeeBeginDate(ChargeDO charge) {
    LateFeeConfig config = lateFeeConfigResolver.resolve();
    lateFeeBeginDateCalculator.calculateBeginDate(charge.getBillMonth(), charge.getBillDate(), config)
            .ifPresent(beginDate -> charge.setLateFeeBeginDate(beginDate.atStartOfDay()));
    if (charge.getLateFeeBeginDate() == null) {
        log.warn("未生成违约金起算日chargeId={}, custId={}, billMonth={}, billDate={}",
                charge.getId(), charge.getCustId(), charge.getBillMonth(), charge.getBillDate());
    }
}
  • Step 4: Call helper before charge insert

In generateSingleChargeWithCache, after charge.setInvoiceState(...) and before chargeMapper.insert(charge), add:

applyLateFeeBeginDate(charge);

In createCharge, after converting the request to ChargeDO and before chargeMapper.insert(charge), add:

applyLateFeeBeginDate(charge);

If createCharge is used for manual historical import where callers explicitly set lateFeeBeginDate, guard the helper:

if (charge.getLateFeeBeginDate() == null) {
    applyLateFeeBeginDate(charge);
}
  • Step 5: Run focused tests

Run:

mvn -pl sw-business/sw-business-server -am -Dtest=ChargeLateFeeBeginDateWiringTest,LateFeeBeginDateCalculatorTest,LateFeeConfigResolverTest test

Expected: PASS.

  • Step 6: Commit
git add sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/charge/ChargeServiceImpl.java \
  sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/charge/ChargeLateFeeBeginDateWiringTest.java
git commit -m "feat: save late fee begin date during billing"

Task 6: Daily Recalculation Service and Job

Files:

  • Create: sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/charge/latefee/LateFeeRecalculationService.java

  • Create: sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/job/charge/LateFeeRecalculationJob.java

  • Test: sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/charge/latefee/LateFeeRecalculationServiceTest.java

  • Step 1: Write failing service test

Create LateFeeRecalculationServiceTest.java:

package cn.com.emsoft.sw.business.service.charge.latefee;

import cn.com.emsoft.sw.business.dal.dataobject.charge.ChargeDO;
import cn.com.emsoft.sw.business.dal.dataobject.chargedetail.ChargeDetailDO;
import cn.com.emsoft.sw.business.dal.dataobject.costcomponent.CostComponentDO;
import cn.com.emsoft.sw.business.dal.dataobject.latefeereducedetail.LateFeeReduceDetailDO;
import cn.com.emsoft.sw.business.dal.dataobject.paymentrecorddetail.PaymentRecordDetailDO;
import cn.com.emsoft.sw.business.dal.mysql.charge.ChargeMapper;
import cn.com.emsoft.sw.business.dal.mysql.chargedetail.ChargeDetailMapper;
import cn.com.emsoft.sw.business.dal.mysql.costcomponent.CostComponentMapper;
import cn.com.emsoft.sw.business.dal.mysql.latefeereducedetail.LateFeeReduceDetailMapper;
import cn.com.emsoft.sw.business.dal.mysql.paymentrecorddetail.PaymentRecordDetailMapper;
import org.junit.jupiter.api.Test;

import java.math.BigDecimal;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

class LateFeeRecalculationServiceTest {

    private final ChargeMapper chargeMapper = mock(ChargeMapper.class);
    private final ChargeDetailMapper chargeDetailMapper = mock(ChargeDetailMapper.class);
    private final CostComponentMapper costComponentMapper = mock(CostComponentMapper.class);
    private final PaymentRecordDetailMapper paymentRecordDetailMapper = mock(PaymentRecordDetailMapper.class);
    private final LateFeeReduceDetailMapper lateFeeReduceDetailMapper = mock(LateFeeReduceDetailMapper.class);
    private final LateFeeConfigResolver configResolver = mock(LateFeeConfigResolver.class);
    private final LateFeeDailyCalculator dailyCalculator = new LateFeeDailyCalculator();
    private final Clock clock = Clock.fixed(Instant.parse("2026-06-10T00:00:00Z"), ZoneId.of("UTC"));

    private final LateFeeRecalculationService service = new LateFeeRecalculationService(
            chargeMapper, chargeDetailMapper, costComponentMapper, paymentRecordDetailMapper,
            lateFeeReduceDetailMapper, configResolver, dailyCalculator, clock);

    @Test
    void shouldRecalculateUnsettledChargesAndDeductPaidAndReducedLateFee() {
        ChargeDO charge = new ChargeDO();
        charge.setId(1001L);
        charge.setExtendedAmount(new BigDecimal("100.00"));
        charge.setLateFeeBeginDate(LocalDateTime.of(2026, 6, 1, 0, 0));

        ChargeDetailDO detail = new ChargeDetailDO();
        detail.setFeeId(1001L);
        detail.setCostComponentCode("101");
        detail.setMoney(new BigDecimal("100.00"));

        CostComponentDO component = new CostComponentDO();
        component.setCode("101");
        component.setPenaltyCoefficient(new BigDecimal("0.0010"));

        PaymentRecordDetailDO paid = PaymentRecordDetailDO.builder()
                .chargeId(1001L)
                .lateFeeAmount(new BigDecimal("0.2"))
                .build();
        LateFeeReduceDetailDO reduced = LateFeeReduceDetailDO.builder()
                .chargeId(1001L)
                .reduceAmount(new BigDecimal("0.3"))
                .build();

        when(chargeMapper.selectUnsettledForLateFeeRecalculation(500)).thenReturn(List.of(charge));
        when(chargeDetailMapper.selectByFeeIds(List.of(1001L))).thenReturn(List.of(detail));
        when(costComponentMapper.selectByCodes(List.of("101"))).thenReturn(List.of(component));
        when(paymentRecordDetailMapper.selectLateFeeDetailsByChargeIds(List.of(1001L))).thenReturn(List.of(paid));
        when(lateFeeReduceDetailMapper.selectApprovedSuccessByChargeIds(List.of(1001L))).thenReturn(List.of(reduced));
        when(configResolver.resolve()).thenReturn(LateFeeConfig.builder()
                .lateFeeDecimal(1)
                .principalCapMultiplier(BigDecimal.ZERO)
                .build());

        service.recalculate(500);

        verify(chargeMapper).updateById(any(ChargeDO.class));
    }
}
  • Step 2: Run test to verify it fails

Run:

mvn -pl sw-business/sw-business-server -am -Dtest=LateFeeRecalculationServiceTest test

Expected: FAIL because LateFeeRecalculationService does not exist.

  • Step 3: Implement service

Create LateFeeRecalculationService.java:

package cn.com.emsoft.sw.business.service.charge.latefee;

import cn.com.emsoft.sw.business.dal.dataobject.charge.ChargeDO;
import cn.com.emsoft.sw.business.dal.dataobject.chargedetail.ChargeDetailDO;
import cn.com.emsoft.sw.business.dal.dataobject.costcomponent.CostComponentDO;
import cn.com.emsoft.sw.business.dal.dataobject.latefeereducedetail.LateFeeReduceDetailDO;
import cn.com.emsoft.sw.business.dal.dataobject.paymentrecorddetail.PaymentRecordDetailDO;
import cn.com.emsoft.sw.business.dal.mysql.charge.ChargeMapper;
import cn.com.emsoft.sw.business.dal.mysql.chargedetail.ChargeDetailMapper;
import cn.com.emsoft.sw.business.dal.mysql.costcomponent.CostComponentMapper;
import cn.com.emsoft.sw.business.dal.mysql.latefeereducedetail.LateFeeReduceDetailMapper;
import cn.com.emsoft.sw.business.dal.mysql.paymentrecorddetail.PaymentRecordDetailMapper;
import cn.hutool.core.collection.CollUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;
import java.time.Clock;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;

@Service
@Slf4j
@RequiredArgsConstructor
public class LateFeeRecalculationService {

    private final ChargeMapper chargeMapper;
    private final ChargeDetailMapper chargeDetailMapper;
    private final CostComponentMapper costComponentMapper;
    private final PaymentRecordDetailMapper paymentRecordDetailMapper;
    private final LateFeeReduceDetailMapper lateFeeReduceDetailMapper;
    private final LateFeeConfigResolver configResolver;
    private final LateFeeDailyCalculator dailyCalculator;
    private final Clock clock;

    @Transactional(rollbackFor = Exception.class)
    public int recalculate(int limit) {
        List<ChargeDO> charges = chargeMapper.selectUnsettledForLateFeeRecalculation(limit);
        if (CollUtil.isEmpty(charges)) {
            return 0;
        }
        List<Long> chargeIds = charges.stream().map(ChargeDO::getId).filter(Objects::nonNull).toList();
        Map<Long, List<ChargeDetailDO>> detailMap = chargeDetailMapper.selectByFeeIds(chargeIds).stream()
                .collect(Collectors.groupingBy(ChargeDetailDO::getFeeId));
        List<String> costCodes = detailMap.values().stream()
                .flatMap(List::stream)
                .map(ChargeDetailDO::getCostComponentCode)
                .filter(Objects::nonNull)
                .distinct()
                .toList();
        Map<String, CostComponentDO> componentMap = costComponentMapper.selectByCodes(costCodes).stream()
                .collect(Collectors.toMap(CostComponentDO::getCode, component -> component, (left, right) -> left));
        Map<Long, BigDecimal> paidLateFeeMap = paymentRecordDetailMapper.selectLateFeeDetailsByChargeIds(chargeIds).stream()
                .collect(Collectors.groupingBy(PaymentRecordDetailDO::getChargeId,
                        Collectors.mapping(PaymentRecordDetailDO::getLateFeeAmount,
                                Collectors.reducing(BigDecimal.ZERO, value -> value == null ? BigDecimal.ZERO : value, BigDecimal::add))));
        Map<Long, BigDecimal> reducedLateFeeMap = lateFeeReduceDetailMapper.selectApprovedSuccessByChargeIds(chargeIds).stream()
                .collect(Collectors.groupingBy(LateFeeReduceDetailDO::getChargeId,
                        Collectors.mapping(LateFeeReduceDetailDO::getReduceAmount,
                                Collectors.reducing(BigDecimal.ZERO, value -> value == null ? BigDecimal.ZERO : value, BigDecimal::add))));

        LateFeeConfig config = configResolver.resolve();
        LocalDate today = LocalDate.now(clock);
        int updated = 0;
        for (ChargeDO charge : charges) {
            if (charge.getLateFeeBeginDate() == null) {
                log.warn("跳过违约金重算lateFeeBeginDate为空chargeId={}, custId={}, billMonth={}",
                        charge.getId(), charge.getCustId(), charge.getBillMonth());
                continue;
            }
            LateFeeDailyCalculator.Result result = dailyCalculator.calculate(charge,
                    detailMap.getOrDefault(charge.getId(), List.of()),
                    componentMap,
                    config,
                    today,
                    paidLateFeeMap.getOrDefault(charge.getId(), BigDecimal.ZERO),
                    reducedLateFeeMap.getOrDefault(charge.getId(), BigDecimal.ZERO));
            ChargeDO update = new ChargeDO();
            update.setId(charge.getId());
            update.setLateFee(result.currentLateFee());
            chargeMapper.updateById(update);
            updated++;
        }
        return updated;
    }
}

Add a Clock bean if none exists. Create sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/config/ClockConfig.java:

package cn.com.emsoft.sw.business.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.time.Clock;

@Configuration
public class ClockConfig {

    @Bean
    public Clock systemClock() {
        return Clock.systemDefaultZone();
    }
}
  • Step 4: Create XXL-JOB wrapper

Create LateFeeRecalculationJob.java:

package cn.com.emsoft.sw.business.job.charge;

import cn.com.emsoft.sw.business.service.charge.latefee.LateFeeRecalculationService;
import cn.com.emsoft.sw.framework.tenant.core.job.TenantJob;
import com.xxl.job.core.handler.annotation.XxlJob;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

@Component
@Slf4j
@RequiredArgsConstructor
public class LateFeeRecalculationJob {

    private static final int DEFAULT_LIMIT = 500;

    private final LateFeeRecalculationService lateFeeRecalculationService;

    @XxlJob("lateFeeDailyRecalculation")
    @TenantJob
    public String execute() {
        int updated = lateFeeRecalculationService.recalculate(DEFAULT_LIMIT);
        String result = "late fee daily recalculation updated=" + updated;
        log.info(result);
        return result;
    }
}
  • Step 5: Run service and compile tests

Run:

mvn -pl sw-business/sw-business-server -am -Dtest=LateFeeRecalculationServiceTest,LateFeeDailyCalculatorTest test
mvn -pl sw-business/sw-business-server -am -DskipTests compile

Expected: both commands SUCCESS.

  • Step 6: Commit
git add sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/charge/latefee/LateFeeRecalculationService.java \
  sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/job/charge/LateFeeRecalculationJob.java \
  sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/config/ClockConfig.java \
  sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/charge/latefee/LateFeeRecalculationServiceTest.java
git commit -m "feat: add daily late fee recalculation job"

Task 7: Verification and Evidence

Files:

  • Create: ../water-docs/docs/evidence/rev004-accounting/late-fee-start-and-daily-recalculation-2026-06-30.md

  • Step 1: Run focused test suite

Run:

mvn -pl sw-business/sw-business-server -am \
  -Dtest=LateFeeBeginDateCalculatorTest,LateFeeConfigResolverTest,LateFeeDailyCalculatorTest,LateFeeRecalculationServiceTest,ChargeLateFeeBeginDateWiringTest \
  test

Expected: SUCCESS.

  • Step 2: Run broader affected tests

Run:

mvn -pl sw-business/sw-business-server -am \
  -Dtest=LateFeeDateModeCalculatorTest,PaymentRecordServiceImplTest,AccountingAdjustActionServiceImplTest,ChargeServiceBillingValidationTest \
  test

Expected: SUCCESS. These tests cover existing date-mode reduce behavior, payment late-fee detail behavior, adjustment approval write-back, and charge billing validation.

  • Step 3: Compile module

Run:

mvn -pl sw-business/sw-business-server -am -DskipTests compile

Expected: SUCCESS.

  • Step 4: Write evidence

Create ../water-docs/docs/evidence/rev004-accounting/late-fee-start-and-daily-recalculation-2026-06-30.md:

# 违约金起算与每日重算实施证据

日期2026-06-30

## 实施范围

- 开账时固化 `ChargeDO.lateFeeBeginDate`
- 每日任务 `lateFeeDailyRecalculation` 幂等重算未结清账单 `ChargeDO.lateFee`
- 按当前未缴本金、逐费用项系数、已收违约金、已审批生效减免金额计算当前应收违约金

## 验证命令

```bash
mvn -pl sw-business/sw-business-server -am \
  -Dtest=LateFeeBeginDateCalculatorTest,LateFeeConfigResolverTest,LateFeeDailyCalculatorTest,LateFeeRecalculationServiceTest,ChargeLateFeeBeginDateWiringTest \
  test
```

结果:执行后记录 Maven 输出中的 `BUILD SUCCESS` 或失败摘要。

```bash
mvn -pl sw-business/sw-business-server -am \
  -Dtest=LateFeeDateModeCalculatorTest,PaymentRecordServiceImplTest,AccountingAdjustActionServiceImplTest,ChargeServiceBillingValidationTest \
  test
```

结果:执行后记录 Maven 输出中的 `BUILD SUCCESS` 或失败摘要。

```bash
mvn -pl sw-business/sw-business-server -am -DskipTests compile
```

结果:执行后记录 Maven 输出中的 `BUILD SUCCESS` 或失败摘要。
  • Step 5: Commit evidence
git add ../water-docs/docs/evidence/rev004-accounting/late-fee-start-and-daily-recalculation-2026-06-30.md
git commit -m "docs: add late fee recalculation evidence"

Final Verification

After all tasks are implemented, run:

git status --short
mvn -pl sw-business/sw-business-server -am \
  -Dtest=LateFeeBeginDateCalculatorTest,LateFeeConfigResolverTest,LateFeeDailyCalculatorTest,LateFeeRecalculationServiceTest,ChargeLateFeeBeginDateWiringTest,LateFeeDateModeCalculatorTest,PaymentRecordServiceImplTest,AccountingAdjustActionServiceImplTest,ChargeServiceBillingValidationTest \
  test
mvn -pl sw-business/sw-business-server -am -DskipTests compile

Expected:

  • git status --short shows only intentional uncommitted work, or is clean after final commits.
  • Focused and affected tests pass.
  • sw-business-server compiles.

Implementation Notes

  • Do not rename existing lateFeeType fields. They belong to adjustment/reduction flows and are distinct from CALC_LATE_FEE_TYPE.
  • Do not change LateFeeDateModeCalculator unless a test proves current late-fee-reduce behavior must share the new calculator. It currently serves the adjustment date-mode calculation.
  • Current ParameterSettingsDO does not expose settingDefault. This plan implements invalid/blank parameter fallback through explicit business defaults in LateFeeConfigResolver: type default 3, decimal default 0, cap default 1, and no valid calc setting when CALC_LATE_FEE_SETTING is invalid. If a real default column is added later, change LateFeeConfigResolverTest first to assert column-based fallback.
  • If partial-payment state is later modeled separately, update ChargeMapper.selectUnsettledForLateFeeRecalculation to include that state. Current codebase exposes PayStateEnum.UNPAID and PayStateEnum.PAID, so unsettled means UNPAID for this implementation.
  • If the database has a more precise remaining principal field than ChargeDO.extendedAmount, replace calculator input mapping in LateFeeRecalculationService with that field and add a regression test before changing the calculation.