From 6964ef1a54aeec31199e30f63c010e2944c0f163 Mon Sep 17 00:00:00 2001 From: tangweijie <877588133@qq.com> Date: Tue, 30 Jun 2026 12:02:53 +0800 Subject: [PATCH] docs: add late fee recalculation implementation plan --- ...-late-fee-start-and-daily-recalculation.md | 1377 +++++++++++++++++ 1 file changed, 1377 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-30-late-fee-start-and-daily-recalculation.md diff --git a/docs/superpowers/plans/2026-06-30-late-fee-start-and-daily-recalculation.md b/docs/superpowers/plans/2026-06-30-late-fee-start-and-daily-recalculation.md new file mode 100644 index 0000000..50535b9 --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-late-fee-start-and-daily-recalculation.md @@ -0,0 +1,1377 @@ +# 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.