54 KiB
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.lateFeeBeginDateonce. - Daily task uses persisted
lateFeeBeginDate, not current parameters. - Daily task recalculates and overwrites
ChargeDO.lateFeeidempotently. - Existing paid charges are not recalculated.
- Existing unsettled charges with missing
lateFeeBeginDateare 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
ReadsParameterSettingsService, applies fallback rules, returnsLateFeeConfig. CurrentParameterSettingsDOonly hasparameterValue, so resolver uses explicit business fallback constants whenparameterValueis invalid or blank.sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/charge/latefee/LateFeeBeginDateCalculator.java
Pure calculator forChargeDO.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, updatesChargeDO.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.javasw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/charge/latefee/LateFeeConfigResolverTest.javasw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/charge/latefee/LateFeeDailyCalculatorTest.javasw-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. SetlateFeeBeginDatebeforechargeMapper.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.javaor a new focusedChargeLateFeeBeginDateWiringTest.java
Verify billing path setslateFeeBeginDate.
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 --shortshows only intentional uncommitted work, or is clean after final commits.- Focused and affected tests pass.
sw-business-servercompiles.
Implementation Notes
- Do not rename existing
lateFeeTypefields. They belong to adjustment/reduction flows and are distinct fromCALC_LATE_FEE_TYPE. - Do not change
LateFeeDateModeCalculatorunless a test proves current late-fee-reduce behavior must share the new calculator. It currently serves the adjustment date-mode calculation. - Current
ParameterSettingsDOdoes not exposesettingDefault. This plan implements invalid/blank parameter fallback through explicit business defaults inLateFeeConfigResolver: type default3, decimal default0, cap default1, and no valid calc setting whenCALC_LATE_FEE_SETTINGis invalid. If a real default column is added later, changeLateFeeConfigResolverTestfirst to assert column-based fallback. - If partial-payment state is later modeled separately, update
ChargeMapper.selectUnsettledForLateFeeRecalculationto include that state. Current codebase exposesPayStateEnum.UNPAIDandPayStateEnum.PAID, so unsettled meansUNPAIDfor this implementation. - If the database has a more precise remaining principal field than
ChargeDO.extendedAmount, replace calculator input mapping inLateFeeRecalculationServicewith that field and add a regression test before changing the calculation.