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

1378 lines
54 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<LocalDate> calculateBeginDate(Integer billMonth, LocalDateTime billDate, LateFeeConfig config) {
if (billMonth == null || billDate == null || config == null
|| config.getCalcType() == null || config.getCalcSetting() == null) {
return Optional.empty();
}
LocalDate dueDate = switch (config.getCalcType()) {
case 1 -> dayInBillMonth(billMonth, 0, config.getCalcSetting());
case 2 -> billDate.toLocalDate().plusDays(config.getCalcSetting());
case 3 -> dayInBillMonth(billMonth, 1, config.getCalcSetting());
case 4 -> dayInBillMonth(billMonth, 2, config.getCalcSetting());
default -> null;
};
if (dueDate == null) {
return Optional.empty();
}
LocalDate configuredBegin = dueDate.plusDays(1);
LocalDate billDateBegin = billDate.toLocalDate().plusDays(1);
return Optional.of(configuredBegin.isAfter(billDateBegin) ? configuredBegin : billDateBegin);
}
private LocalDate dayInBillMonth(Integer billMonth, int monthOffset, int configuredDay) {
int year = billMonth / 100;
int month = billMonth % 100;
if (month < 1 || month > 12 || configuredDay < 1 || configuredDay > 31) {
return null;
}
YearMonth target = YearMonth.of(year, month).plusMonths(monthOffset);
int day = Math.min(configuredDay, target.lengthOfMonth());
return target.atDay(day);
}
}
```
- [ ] **Step 4: Run test to verify it passes**
Run:
```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<ChargeDetailDO> details() {
ChargeDetailDO water = new ChargeDetailDO();
water.setCostComponentCode("101");
water.setMoney(new BigDecimal("80.00"));
ChargeDetailDO sewage = new ChargeDetailDO();
sewage.setCostComponentCode("102");
sewage.setMoney(new BigDecimal("20.00"));
return List.of(water, sewage);
}
private Map<String, CostComponentDO> components() {
CostComponentDO water = new CostComponentDO();
water.setCode("101");
water.setPenaltyCoefficient(new BigDecimal("0.0010"));
CostComponentDO sewage = new CostComponentDO();
sewage.setCode("102");
sewage.setPenaltyCoefficient(new BigDecimal("0.0020"));
return Map.of("101", water, "102", sewage);
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```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<ChargeDetailDO> chargeDetails,
Map<String, CostComponentDO> costComponentMap,
LateFeeConfig config,
LocalDate currentDate,
BigDecimal paidLateFee,
BigDecimal approvedReducedLateFee) {
int scale = config == null || config.getLateFeeDecimal() == null ? 0 : config.getLateFeeDecimal();
BigDecimal zero = BigDecimal.ZERO.setScale(scale, RoundingMode.HALF_UP);
if (charge == null || charge.getLateFeeBeginDate() == null || currentDate == null
|| currentDate.isBefore(charge.getLateFeeBeginDate().toLocalDate())
|| CollUtil.isEmpty(chargeDetails)) {
return new Result(0, zero, zero);
}
long overdueDays = ChronoUnit.DAYS.between(charge.getLateFeeBeginDate().toLocalDate(), currentDate) + 1;
BigDecimal currentPrincipal = nonNull(charge.getExtendedAmount());
if (currentPrincipal.compareTo(BigDecimal.ZERO) <= 0) {
return new Result((int) overdueDays, zero, zero);
}
BigDecimal detailTotal = chargeDetails.stream()
.map(this::detailPrincipal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
if (detailTotal.compareTo(BigDecimal.ZERO) <= 0) {
return new Result((int) overdueDays, zero, zero);
}
BigDecimal theoretical = BigDecimal.ZERO;
for (int i = 0; i < chargeDetails.size(); i++) {
ChargeDetailDO detail = chargeDetails.get(i);
BigDecimal principal = allocatedPrincipal(detail, currentPrincipal, detailTotal, i == chargeDetails.size() - 1,
chargeDetails.subList(0, i));
BigDecimal coefficient = coefficient(detail.getCostComponentCode(), costComponentMap);
BigDecimal itemLateFee = principal.multiply(coefficient)
.multiply(BigDecimal.valueOf(overdueDays))
.setScale(scale, RoundingMode.HALF_UP);
theoretical = theoretical.add(itemLateFee);
}
theoretical = applyCap(theoretical, currentPrincipal, config).setScale(scale, RoundingMode.HALF_UP);
BigDecimal current = theoretical
.subtract(nonNull(paidLateFee))
.subtract(nonNull(approvedReducedLateFee));
if (current.compareTo(BigDecimal.ZERO) < 0) {
current = BigDecimal.ZERO;
}
return new Result((int) overdueDays, theoretical, current.setScale(scale, RoundingMode.HALF_UP));
}
private BigDecimal allocatedPrincipal(ChargeDetailDO detail, BigDecimal currentPrincipal, BigDecimal detailTotal,
boolean last, List<ChargeDetailDO> previousDetails) {
if (last) {
BigDecimal previous = previousDetails.stream()
.map(previousDetail -> detailPrincipal(previousDetail).multiply(currentPrincipal)
.divide(detailTotal, 10, RoundingMode.HALF_UP)
.setScale(2, RoundingMode.HALF_UP))
.reduce(BigDecimal.ZERO, BigDecimal::add);
return currentPrincipal.subtract(previous);
}
return detailPrincipal(detail).multiply(currentPrincipal)
.divide(detailTotal, 10, RoundingMode.HALF_UP)
.setScale(2, RoundingMode.HALF_UP);
}
private BigDecimal detailPrincipal(ChargeDetailDO detail) {
BigDecimal money = detail.getMoney() == null ? detail.getOriginalMoney() : detail.getMoney();
return nonNull(money);
}
private BigDecimal coefficient(String code, Map<String, CostComponentDO> costComponentMap) {
if (code == null || costComponentMap == null || !costComponentMap.containsKey(code)
|| costComponentMap.get(code).getPenaltyCoefficient() == null) {
return BigDecimal.ZERO;
}
return costComponentMap.get(code).getPenaltyCoefficient();
}
private BigDecimal applyCap(BigDecimal theoretical, BigDecimal currentPrincipal, LateFeeConfig config) {
if (config == null || config.hasNoPrincipalCap()) {
return theoretical;
}
BigDecimal multiplier = config.getPrincipalCapMultiplier() == null ? BigDecimal.ONE : config.getPrincipalCapMultiplier();
BigDecimal cap = currentPrincipal.multiply(multiplier);
return theoretical.min(cap);
}
private BigDecimal nonNull(BigDecimal value) {
return value == null ? BigDecimal.ZERO : value;
}
public record Result(int overdueDays, BigDecimal theoreticalLateFee, BigDecimal currentLateFee) {
}
}
```
- [ ] **Step 4: Run test to verify it passes**
Run:
```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<ChargeDO> selectUnsettledForLateFeeRecalculation(int limit) {
return selectList(new LambdaQueryWrapperX<ChargeDO>()
.eq(ChargeDO::getPayState, PayStateEnum.UNPAID.getValue())
.orderByAsc(ChargeDO::getId)
.last("limit " + Math.max(1, limit)));
}
```
Ensure `ChargeMapper.java` imports `cn.com.emsoft.sw.business.enums.charge.PayStateEnum`; it already uses that enum later in the file, so preserve the existing import if present.
In `PaymentRecordDetailMapper.java`, add:
```java
default List<PaymentRecordDetailDO> selectLateFeeDetailsByChargeIds(List<Long> chargeIds) {
if (chargeIds == null || chargeIds.isEmpty()) {
return List.of();
}
return selectList(new LambdaQueryWrapperX<PaymentRecordDetailDO>()
.in(PaymentRecordDetailDO::getChargeId, chargeIds)
.eq(PaymentRecordDetailDO::getDetailType, "LATE_FEE")
.gt(PaymentRecordDetailDO::getLateFeeAmount, java.math.BigDecimal.ZERO)
.orderByAsc(PaymentRecordDetailDO::getChargeId)
.orderByAsc(PaymentRecordDetailDO::getId));
}
```
In `LateFeeReduceDetailMapper.java`, add:
```java
default List<LateFeeReduceDetailDO> selectApprovedSuccessByChargeIds(List<Long> chargeIds) {
if (chargeIds == null || chargeIds.isEmpty()) {
return Collections.emptyList();
}
return selectList(new LambdaQueryWrapperX<LateFeeReduceDetailDO>()
.in(LateFeeReduceDetailDO::getChargeId, chargeIds)
.eq(LateFeeReduceDetailDO::getDetailStatus, "SUCCESS")
.gt(LateFeeReduceDetailDO::getReduceAmount, java.math.BigDecimal.ZERO)
.orderByAsc(LateFeeReduceDetailDO::getChargeId)
.orderByAsc(LateFeeReduceDetailDO::getId));
}
```
- [ ] **Step 2: Compile modified mappers**
Run:
```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<ChargeDO> charges = chargeMapper.selectUnsettledForLateFeeRecalculation(limit);
if (CollUtil.isEmpty(charges)) {
return 0;
}
List<Long> chargeIds = charges.stream().map(ChargeDO::getId).filter(Objects::nonNull).toList();
Map<Long, List<ChargeDetailDO>> detailMap = chargeDetailMapper.selectByFeeIds(chargeIds).stream()
.collect(Collectors.groupingBy(ChargeDetailDO::getFeeId));
List<String> costCodes = detailMap.values().stream()
.flatMap(List::stream)
.map(ChargeDetailDO::getCostComponentCode)
.filter(Objects::nonNull)
.distinct()
.toList();
Map<String, CostComponentDO> componentMap = costComponentMapper.selectByCodes(costCodes).stream()
.collect(Collectors.toMap(CostComponentDO::getCode, component -> component, (left, right) -> left));
Map<Long, BigDecimal> paidLateFeeMap = paymentRecordDetailMapper.selectLateFeeDetailsByChargeIds(chargeIds).stream()
.collect(Collectors.groupingBy(PaymentRecordDetailDO::getChargeId,
Collectors.mapping(PaymentRecordDetailDO::getLateFeeAmount,
Collectors.reducing(BigDecimal.ZERO, value -> value == null ? BigDecimal.ZERO : value, BigDecimal::add))));
Map<Long, BigDecimal> reducedLateFeeMap = lateFeeReduceDetailMapper.selectApprovedSuccessByChargeIds(chargeIds).stream()
.collect(Collectors.groupingBy(LateFeeReduceDetailDO::getChargeId,
Collectors.mapping(LateFeeReduceDetailDO::getReduceAmount,
Collectors.reducing(BigDecimal.ZERO, value -> value == null ? BigDecimal.ZERO : value, BigDecimal::add))));
LateFeeConfig config = configResolver.resolve();
LocalDate today = LocalDate.now(clock);
int updated = 0;
for (ChargeDO charge : charges) {
if (charge.getLateFeeBeginDate() == null) {
log.warn("跳过违约金重算lateFeeBeginDate为空chargeId={}, custId={}, billMonth={}",
charge.getId(), charge.getCustId(), charge.getBillMonth());
continue;
}
LateFeeDailyCalculator.Result result = dailyCalculator.calculate(charge,
detailMap.getOrDefault(charge.getId(), List.of()),
componentMap,
config,
today,
paidLateFeeMap.getOrDefault(charge.getId(), BigDecimal.ZERO),
reducedLateFeeMap.getOrDefault(charge.getId(), BigDecimal.ZERO));
ChargeDO update = new ChargeDO();
update.setId(charge.getId());
update.setLateFee(result.currentLateFee());
chargeMapper.updateById(update);
updated++;
}
return updated;
}
}
```
Add a `Clock` bean if none exists. Create `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/config/ClockConfig.java`:
```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.