# 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`: ```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: ```bash 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`: ```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`: ```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`: ```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 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: ```bash mvn -pl sw-business/sw-business-server -am -Dtest=LateFeeBeginDateCalculatorTest test ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash 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`: ```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: ```bash 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`: ```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: ```bash mvn -pl sw-business/sw-business-server -am -Dtest=LateFeeConfigResolverTest test ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash 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`: ```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 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 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: ```bash 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`: ```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 chargeDetails, Map 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 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 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: ```bash mvn -pl sw-business/sw-business-server -am -Dtest=LateFeeDailyCalculatorTest test ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash 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: ```java default List selectUnsettledForLateFeeRecalculation(int limit) { return selectList(new LambdaQueryWrapperX() .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: ```java default List selectLateFeeDetailsByChargeIds(List chargeIds) { if (chargeIds == null || chargeIds.isEmpty()) { return List.of(); } return selectList(new LambdaQueryWrapperX() .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: ```java default List selectApprovedSuccessByChargeIds(List chargeIds) { if (chargeIds == null || chargeIds.isEmpty()) { return Collections.emptyList(); } return selectList(new LambdaQueryWrapperX() .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: ```bash 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** ```bash 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. ```java 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: ```bash 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: ```java 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: ```java @Resource private LateFeeConfigResolver lateFeeConfigResolver; @Resource private LateFeeBeginDateCalculator lateFeeBeginDateCalculator; ``` Add helper method inside `ChargeServiceImpl`: ```java 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: ```java applyLateFeeBeginDate(charge); ``` In `createCharge`, after converting the request to `ChargeDO` and before `chargeMapper.insert(charge)`, add: ```java applyLateFeeBeginDate(charge); ``` If `createCharge` is used for manual historical import where callers explicitly set `lateFeeBeginDate`, guard the helper: ```java if (charge.getLateFeeBeginDate() == null) { applyLateFeeBeginDate(charge); } ``` - [ ] **Step 5: Run focused tests** Run: ```bash mvn -pl sw-business/sw-business-server -am -Dtest=ChargeLateFeeBeginDateWiringTest,LateFeeBeginDateCalculatorTest,LateFeeConfigResolverTest test ``` Expected: PASS. - [ ] **Step 6: Commit** ```bash 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`: ```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: ```bash 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`: ```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 charges = chargeMapper.selectUnsettledForLateFeeRecalculation(limit); if (CollUtil.isEmpty(charges)) { return 0; } List chargeIds = charges.stream().map(ChargeDO::getId).filter(Objects::nonNull).toList(); Map> detailMap = chargeDetailMapper.selectByFeeIds(chargeIds).stream() .collect(Collectors.groupingBy(ChargeDetailDO::getFeeId)); List costCodes = detailMap.values().stream() .flatMap(List::stream) .map(ChargeDetailDO::getCostComponentCode) .filter(Objects::nonNull) .distinct() .toList(); Map componentMap = costComponentMapper.selectByCodes(costCodes).stream() .collect(Collectors.toMap(CostComponentDO::getCode, component -> component, (left, right) -> left)); Map 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 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`: ```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`: ```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: ```bash 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** ```bash 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: ```bash mvn -pl sw-business/sw-business-server -am \ -Dtest=LateFeeBeginDateCalculatorTest,LateFeeConfigResolverTest,LateFeeDailyCalculatorTest,LateFeeRecalculationServiceTest,ChargeLateFeeBeginDateWiringTest \ test ``` Expected: SUCCESS. - [ ] **Step 2: Run broader affected tests** Run: ```bash 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: ```bash 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`: ````markdown # 违约金起算与每日重算实施证据 日期: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** ```bash 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: ```bash 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.