1378 lines
54 KiB
Markdown
1378 lines
54 KiB
Markdown
# 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.
|