22 changed files with 1042 additions and 8 deletions
@ -0,0 +1,37 @@ |
|||||
|
--- |
||||
|
task_id: "2" |
||||
|
task_hash: "sha256:4395a095f2f5a85abccfe61bca9647cdc1a92ad9febef404ff8726432b4e25f8" |
||||
|
next: "Wave W2 task 3: city policy query service/controller/DTOs" |
||||
|
completed: "V41 layered seed: 359 cities, 12 CITY, 347 PROVINCE_FALLBACK" |
||||
|
evidence: "mvn -Dtest=SalaryCityPolicySeedTest,SalaryMigrationTest,SalaryRepositoryIntegrationTest test: 3/3 passed" |
||||
|
review: "changes/n05-salary-calc/.superpowers/sdd/reviews/T2.md" |
||||
|
risk: "Fallback rows require explicit disclosure and later city-level verification" |
||||
|
commit_start: "79f0646da8a2fa027b8ef9e025ae1096b8ed3903" |
||||
|
commit_end: "2d315f90e46982e9658da6483f46e2acbe21bcca" |
||||
|
created_at: "2026-09-20T08:23:29.908Z" |
||||
|
plan_hash: "sha256:77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5" |
||||
|
plan_revision: 1 |
||||
|
--- |
||||
|
|
||||
|
# Checkpoint: 2 |
||||
|
|
||||
|
## Next |
||||
|
Wave W2 task 3: city policy query service/controller/DTOs |
||||
|
|
||||
|
## Completed |
||||
|
V41 layered seed: 359 cities, 12 CITY, 347 PROVINCE_FALLBACK |
||||
|
|
||||
|
## Evidence |
||||
|
mvn -Dtest=SalaryCityPolicySeedTest,SalaryMigrationTest,SalaryRepositoryIntegrationTest test: 3/3 passed |
||||
|
|
||||
|
## Review |
||||
|
changes/n05-salary-calc/.superpowers/sdd/reviews/T2.md |
||||
|
|
||||
|
## Risk |
||||
|
Fallback rows require explicit disclosure and later city-level verification |
||||
|
|
||||
|
## Commit Start |
||||
|
79f0646da8a2fa027b8ef9e025ae1096b8ed3903 |
||||
|
|
||||
|
## Commit End |
||||
|
2d315f90e46982e9658da6483f46e2acbe21bcca |
||||
@ -0,0 +1,10 @@ |
|||||
|
{ |
||||
|
"status": "pass", |
||||
|
"base": "36c0214723a3fdd1714ff7923a4f38de07d7b042", |
||||
|
"head": "2d315f90e46982e9658da6483f46e2acbe21bcca", |
||||
|
"report": ".superpowers\\sdd\\reviews\\W1.md", |
||||
|
"report_sha256": "sha256:afb12884b78fdfd3b0edb199900c84f19a130e8bb95c1a680255d59aee43343c", |
||||
|
"plan_hash": "sha256:77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5", |
||||
|
"plan_revision": 1, |
||||
|
"recorded_at": "2026-09-20T08:23:35.131Z" |
||||
|
} |
||||
@ -0,0 +1,7 @@ |
|||||
|
# Task 3 self-review |
||||
|
|
||||
|
- Verdict: pass |
||||
|
- Scope: city list/detail service and controller, STUDENT authorization, data-level response. |
||||
|
- Evidence: `mvn -Dtest=SalaryCityPolicyServiceTest,SalaryCityPolicyControllerTest test` -> 9 tests, 0 failures, 0 errors. |
||||
|
- Contract check: all enabled cities returned; unknown/disabled city 9801; COMPANY 403; anonymous 401; detail includes city defaults and dataLevel. |
||||
|
- Blockers: none. |
||||
@ -0,0 +1,7 @@ |
|||||
|
# Task 4 self-review |
||||
|
|
||||
|
- Verdict: pass |
||||
|
- Scope: BigDecimal salary calculation engine and input/override/result models. |
||||
|
- Evidence: `mvn -Dtest=SalaryCalculationEngineTest test` -> 6 tests, 0 failures, 0 errors. |
||||
|
- Contract check: base floor/ceiling, user overrides, contribution rates, special deduction, cumulative month 12, prior withheld tax, annual tax brackets, zero contribution, and 9802 validation. |
||||
|
- Blockers: none. |
||||
@ -0,0 +1,10 @@ |
|||||
|
{ |
||||
|
"status": "pass", |
||||
|
"base": "36c0214723a3fdd1714ff7923a4f38de07d7b042", |
||||
|
"head": "2d315f90e46982e9658da6483f46e2acbe21bcca", |
||||
|
"report": ".superpowers\\sdd\\reviews\\W1.md", |
||||
|
"report_sha256": "sha256:afb12884b78fdfd3b0edb199900c84f19a130e8bb95c1a680255d59aee43343c", |
||||
|
"plan_hash": "sha256:77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5", |
||||
|
"plan_revision": 1, |
||||
|
"recorded_at": "2026-09-20T08:23:35.131Z" |
||||
|
} |
||||
@ -0,0 +1,8 @@ |
|||||
|
# Wave W2 review |
||||
|
|
||||
|
- Verdict: pass |
||||
|
- Scope: city policy query APIs and BigDecimal calculation engine. |
||||
|
- Tasks: 3, 4. |
||||
|
- Evidence: `mvn -Dtest=SalaryCityPolicyServiceTest,SalaryCityPolicyControllerTest,SalaryCalculationEngineTest test` -> 15 tests, 0 failures, 0 errors. |
||||
|
- Requirements covered: enabled city catalogue, city detail defaults, dataLevel, STUDENT authorization, base clamping, override precedence, personal contributions, serious illness, month index, special deduction, cumulative withholding, tax brackets, result breakdown. |
||||
|
- Review blockers: none. |
||||
@ -0,0 +1,262 @@ |
|||||
|
package com.mydreamjob.salarycalculator; |
||||
|
|
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import com.mydreamjob.common.exception.ErrorCode; |
||||
|
import org.springframework.stereotype.Component; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
import java.math.RoundingMode; |
||||
|
import java.util.List; |
||||
|
|
||||
|
@Component |
||||
|
public class SalaryCalculationEngine { |
||||
|
|
||||
|
public static final String TAX_RULES_VERSION = "CN_IIT_2019_V1"; |
||||
|
|
||||
|
private static final BigDecimal ZERO = new BigDecimal("0"); |
||||
|
private static final BigDecimal MONTHLY_BASIC_DEDUCTION = new BigDecimal("5000"); |
||||
|
private static final BigDecimal MAX_INPUT_AMOUNT = new BigDecimal("10000000"); |
||||
|
private static final BigDecimal MAX_ILLNESS_FEE = new BigDecimal("10000"); |
||||
|
private static final BigDecimal MAX_PENSION_RATE = new BigDecimal("0.20"); |
||||
|
private static final BigDecimal MAX_MEDICAL_RATE = new BigDecimal("0.20"); |
||||
|
private static final BigDecimal MAX_UNEMPLOYMENT_RATE = new BigDecimal("0.10"); |
||||
|
private static final BigDecimal MAX_HOUSING_FUND_RATE = new BigDecimal("0.12"); |
||||
|
private static final int MONEY_SCALE = 2; |
||||
|
|
||||
|
private static final List<TaxBracket> ANNUAL_BRACKETS = List.of( |
||||
|
new TaxBracket(new BigDecimal("36000"), new BigDecimal("0.03"), new BigDecimal("0")), |
||||
|
new TaxBracket(new BigDecimal("144000"), new BigDecimal("0.10"), new BigDecimal("2520")), |
||||
|
new TaxBracket(new BigDecimal("300000"), new BigDecimal("0.20"), new BigDecimal("16920")), |
||||
|
new TaxBracket(new BigDecimal("420000"), new BigDecimal("0.25"), new BigDecimal("31920")), |
||||
|
new TaxBracket(new BigDecimal("660000"), new BigDecimal("0.30"), new BigDecimal("52920")), |
||||
|
new TaxBracket(new BigDecimal("960000"), new BigDecimal("0.35"), new BigDecimal("85920")), |
||||
|
new TaxBracket(null, new BigDecimal("0.45"), new BigDecimal("181920"))); |
||||
|
|
||||
|
public SalaryCalculationResult calculate(SalaryCalculationInput input, SalaryCityPolicy policy) { |
||||
|
if (input == null || policy == null) { |
||||
|
throw invalidParameter("input and city policy are required"); |
||||
|
} |
||||
|
int monthIndex = input.getMonthIndex() == null ? 1 : input.getMonthIndex(); |
||||
|
BigDecimal gross = requiredMoney(input.getGrossMonthlySalary(), "grossMonthlySalary"); |
||||
|
BigDecimal specialDeduction = optionalMoney( |
||||
|
input.getSpecialAdditionalDeduction(), "specialAdditionalDeduction", MAX_INPUT_AMOUNT); |
||||
|
SalaryOverrideParameters overrides = input.getOverrides(); |
||||
|
|
||||
|
if (gross.compareTo(ZERO) <= 0 || gross.compareTo(MAX_INPUT_AMOUNT) > 0) { |
||||
|
throw invalidParameter("grossMonthlySalary must be within (0, 10000000]"); |
||||
|
} |
||||
|
if (monthIndex < 1 || monthIndex > 12) { |
||||
|
throw invalidParameter("monthIndex must be between 1 and 12"); |
||||
|
} |
||||
|
|
||||
|
BigDecimal pensionRate = effectiveRate( |
||||
|
overrides == null ? null : overrides.getPensionRate(), |
||||
|
policy.getPensionRate(), MAX_PENSION_RATE, "pensionRate"); |
||||
|
BigDecimal medicalRate = effectiveRate( |
||||
|
overrides == null ? null : overrides.getMedicalRate(), |
||||
|
policy.getMedicalRate(), MAX_MEDICAL_RATE, "medicalRate"); |
||||
|
BigDecimal unemploymentRate = effectiveRate( |
||||
|
overrides == null ? null : overrides.getUnemploymentRate(), |
||||
|
policy.getUnemploymentRate(), MAX_UNEMPLOYMENT_RATE, "unemploymentRate"); |
||||
|
BigDecimal housingFundRate = effectiveRate( |
||||
|
overrides == null ? null : overrides.getHousingFundRate(), |
||||
|
policy.getHousingFundRate(), MAX_HOUSING_FUND_RATE, "housingFundRate"); |
||||
|
BigDecimal illnessFee = effectiveFee( |
||||
|
overrides == null ? null : overrides.getSeriousIllnessMonthlyFee(), |
||||
|
policy.getSeriousIllnessMonthlyFee()); |
||||
|
|
||||
|
BigDecimal socialSecurityBase = effectiveBase( |
||||
|
overrides == null ? null : overrides.getSocialSecurityBase(), |
||||
|
gross, |
||||
|
policy.getSocialSecurityBaseMin(), |
||||
|
policy.getSocialSecurityBaseMax(), |
||||
|
"socialSecurityBase"); |
||||
|
BigDecimal housingFundBase = effectiveBase( |
||||
|
overrides == null ? null : overrides.getHousingFundBase(), |
||||
|
gross, |
||||
|
policy.getHousingFundBaseMin(), |
||||
|
policy.getHousingFundBaseMax(), |
||||
|
"housingFundBase"); |
||||
|
|
||||
|
if (socialSecurityBase.compareTo(ZERO) == 0 |
||||
|
&& (pensionRate.signum() != 0 || medicalRate.signum() != 0 |
||||
|
|| unemploymentRate.signum() != 0)) { |
||||
|
throw invalidParameter("socialSecurityBase may be zero only when social insurance rates are zero"); |
||||
|
} |
||||
|
if (housingFundBase.compareTo(ZERO) == 0 && housingFundRate.signum() != 0) { |
||||
|
throw invalidParameter("housingFundBase may be zero only when housingFundRate is zero"); |
||||
|
} |
||||
|
|
||||
|
BigDecimal pension = socialSecurityBase.multiply(pensionRate); |
||||
|
BigDecimal medical = socialSecurityBase.multiply(medicalRate); |
||||
|
BigDecimal unemployment = socialSecurityBase.multiply(unemploymentRate); |
||||
|
BigDecimal housingFund = housingFundBase.multiply(housingFundRate); |
||||
|
BigDecimal monthlySocialInsurance = pension.add(medical).add(unemployment); |
||||
|
|
||||
|
BigDecimal cumulativeGross = gross.multiply(BigDecimal.valueOf(monthIndex)); |
||||
|
BigDecimal cumulativeSocialInsurance = monthlySocialInsurance.multiply(BigDecimal.valueOf(monthIndex)); |
||||
|
BigDecimal cumulativeHousingFund = housingFund.multiply(BigDecimal.valueOf(monthIndex)); |
||||
|
BigDecimal cumulativeIllness = illnessFee.multiply(BigDecimal.valueOf(monthIndex)); |
||||
|
BigDecimal cumulativeBasicDeduction = MONTHLY_BASIC_DEDUCTION.multiply(BigDecimal.valueOf(monthIndex)); |
||||
|
BigDecimal cumulativeSpecialDeduction = specialDeduction.multiply(BigDecimal.valueOf(monthIndex)); |
||||
|
|
||||
|
BigDecimal cumulativeTaxable = nonNegative(cumulativeGross |
||||
|
.subtract(cumulativeSocialInsurance) |
||||
|
.subtract(cumulativeHousingFund) |
||||
|
.subtract(cumulativeIllness) |
||||
|
.subtract(cumulativeBasicDeduction) |
||||
|
.subtract(cumulativeSpecialDeduction)); |
||||
|
BigDecimal cumulativeTaxDue = taxFor(cumulativeTaxable); |
||||
|
|
||||
|
int priorMonths = monthIndex - 1; |
||||
|
BigDecimal priorTaxable = nonNegative(gross.multiply(BigDecimal.valueOf(priorMonths)) |
||||
|
.subtract(monthlySocialInsurance.multiply(BigDecimal.valueOf(priorMonths))) |
||||
|
.subtract(housingFund.multiply(BigDecimal.valueOf(priorMonths))) |
||||
|
.subtract(illnessFee.multiply(BigDecimal.valueOf(priorMonths))) |
||||
|
.subtract(MONTHLY_BASIC_DEDUCTION.multiply(BigDecimal.valueOf(priorMonths))) |
||||
|
.subtract(specialDeduction.multiply(BigDecimal.valueOf(priorMonths)))); |
||||
|
BigDecimal priorTax = taxFor(priorTaxable); |
||||
|
BigDecimal currentTax = nonNegative(cumulativeTaxDue.subtract(priorTax)); |
||||
|
BigDecimal netSalary = nonNegative(gross |
||||
|
.subtract(pension) |
||||
|
.subtract(medical) |
||||
|
.subtract(unemployment) |
||||
|
.subtract(housingFund) |
||||
|
.subtract(illnessFee) |
||||
|
.subtract(currentTax)); |
||||
|
|
||||
|
return SalaryCalculationResult.builder() |
||||
|
.cityCode(policy.getCityCode()) |
||||
|
.cityName(policy.getCityName()) |
||||
|
.dataLevel(policy.getDataLevel()) |
||||
|
.policyVersion(policy.getPolicyVersion()) |
||||
|
.policyEffectiveDate(policy.getPolicyEffectiveDate()) |
||||
|
.taxRulesVersion(TAX_RULES_VERSION) |
||||
|
.estimate(true) |
||||
|
.monthIndex(monthIndex) |
||||
|
.grossMonthlySalary(money(gross)) |
||||
|
.specialAdditionalDeduction(money(specialDeduction)) |
||||
|
.socialSecurityBase(money(socialSecurityBase)) |
||||
|
.housingFundBase(money(housingFundBase)) |
||||
|
.pensionRate(rate(pensionRate)) |
||||
|
.medicalRate(rate(medicalRate)) |
||||
|
.unemploymentRate(rate(unemploymentRate)) |
||||
|
.housingFundRate(rate(housingFundRate)) |
||||
|
.seriousIllnessMonthlyFee(money(illnessFee)) |
||||
|
.pension(money(pension)) |
||||
|
.medical(money(medical)) |
||||
|
.unemployment(money(unemployment)) |
||||
|
.housingFund(money(housingFund)) |
||||
|
.cumulativeGrossIncome(money(cumulativeGross)) |
||||
|
.cumulativeSocialInsurance(money(cumulativeSocialInsurance)) |
||||
|
.cumulativeHousingFund(money(cumulativeHousingFund)) |
||||
|
.cumulativeSeriousIllness(money(cumulativeIllness)) |
||||
|
.cumulativeBasicDeduction(money(cumulativeBasicDeduction)) |
||||
|
.cumulativeSpecialAdditionalDeduction(money(cumulativeSpecialDeduction)) |
||||
|
.cumulativeTaxableIncome(money(cumulativeTaxable)) |
||||
|
.cumulativeTaxDue(money(cumulativeTaxDue)) |
||||
|
.previouslyWithheldTax(money(priorTax)) |
||||
|
.currentMonthTax(money(currentTax)) |
||||
|
.netSalary(money(netSalary)) |
||||
|
.build(); |
||||
|
} |
||||
|
|
||||
|
private BigDecimal effectiveBase( |
||||
|
BigDecimal override, BigDecimal gross, BigDecimal min, BigDecimal max, String field) { |
||||
|
validatePolicyRange(min, max, field); |
||||
|
if (override != null) { |
||||
|
BigDecimal value = requiredMoney(override, field); |
||||
|
if (value.compareTo(ZERO) < 0) { |
||||
|
throw invalidParameter(field + " must not be negative"); |
||||
|
} |
||||
|
if (value.compareTo(ZERO) > 0 && (value.compareTo(min) < 0 || value.compareTo(max) > 0)) { |
||||
|
throw invalidParameter(field + " must be within city range or zero for an exempt contribution"); |
||||
|
} |
||||
|
return value; |
||||
|
} |
||||
|
return clamp(gross, min, max); |
||||
|
} |
||||
|
|
||||
|
private BigDecimal effectiveRate( |
||||
|
BigDecimal override, BigDecimal fallback, BigDecimal max, String field) { |
||||
|
BigDecimal value = override == null ? fallback : requiredRate(override, field); |
||||
|
if (value == null || value.compareTo(ZERO) < 0 || value.compareTo(max) > 0) { |
||||
|
throw invalidParameter(field + " is outside the allowed range"); |
||||
|
} |
||||
|
return value; |
||||
|
} |
||||
|
|
||||
|
private BigDecimal effectiveFee(BigDecimal override, BigDecimal fallback) { |
||||
|
BigDecimal value = override == null ? fallback : requiredMoney(override, "seriousIllnessMonthlyFee"); |
||||
|
if (value == null || value.compareTo(ZERO) < 0 || value.compareTo(MAX_ILLNESS_FEE) > 0) { |
||||
|
throw invalidParameter("seriousIllnessMonthlyFee is outside the allowed range"); |
||||
|
} |
||||
|
return value; |
||||
|
} |
||||
|
|
||||
|
private BigDecimal taxFor(BigDecimal taxableIncome) { |
||||
|
if (taxableIncome.compareTo(ZERO) <= 0) { |
||||
|
return ZERO; |
||||
|
} |
||||
|
for (TaxBracket bracket : ANNUAL_BRACKETS) { |
||||
|
if (bracket.upperBound == null || taxableIncome.compareTo(bracket.upperBound) <= 0) { |
||||
|
return nonNegative(taxableIncome.multiply(bracket.rate).subtract(bracket.quickDeduction)); |
||||
|
} |
||||
|
} |
||||
|
return ZERO; |
||||
|
} |
||||
|
|
||||
|
private BigDecimal clamp(BigDecimal value, BigDecimal min, BigDecimal max) { |
||||
|
if (value.compareTo(min) < 0) { |
||||
|
return min; |
||||
|
} |
||||
|
return value.compareTo(max) > 0 ? max : value; |
||||
|
} |
||||
|
|
||||
|
private void validatePolicyRange(BigDecimal min, BigDecimal max, String field) { |
||||
|
if (min == null || max == null || min.compareTo(ZERO) < 0 || max.compareTo(min) < 0) { |
||||
|
throw invalidParameter(field + " city range is invalid"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private BigDecimal requiredMoney(BigDecimal value, String field) { |
||||
|
if (value == null || value.scale() > MONEY_SCALE) { |
||||
|
throw invalidParameter(field + " must have at most two decimal places"); |
||||
|
} |
||||
|
return value; |
||||
|
} |
||||
|
|
||||
|
private BigDecimal optionalMoney(BigDecimal value, String field, BigDecimal max) { |
||||
|
BigDecimal effective = value == null ? ZERO : requiredMoney(value, field); |
||||
|
if (effective.compareTo(ZERO) < 0 || effective.compareTo(max) > 0) { |
||||
|
throw invalidParameter(field + " is outside the allowed range"); |
||||
|
} |
||||
|
return effective; |
||||
|
} |
||||
|
|
||||
|
private BigDecimal requiredRate(BigDecimal value, String field) { |
||||
|
if (value == null || value.scale() > 6) { |
||||
|
throw invalidParameter(field + " must have at most six decimal places"); |
||||
|
} |
||||
|
return value; |
||||
|
} |
||||
|
|
||||
|
private BigDecimal nonNegative(BigDecimal value) { |
||||
|
return value.compareTo(ZERO) < 0 ? ZERO : value; |
||||
|
} |
||||
|
|
||||
|
private BigDecimal money(BigDecimal value) { |
||||
|
return value.setScale(MONEY_SCALE, RoundingMode.HALF_UP); |
||||
|
} |
||||
|
|
||||
|
private BigDecimal rate(BigDecimal value) { |
||||
|
return value.setScale(6, RoundingMode.HALF_UP); |
||||
|
} |
||||
|
|
||||
|
private BusinessException invalidParameter(String detail) { |
||||
|
return new BusinessException(ErrorCode.SALARY_INVALID_PARAMETER, detail); |
||||
|
} |
||||
|
|
||||
|
private record TaxBracket(BigDecimal upperBound, BigDecimal rate, BigDecimal quickDeduction) { |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,19 @@ |
|||||
|
package com.mydreamjob.salarycalculator; |
||||
|
|
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Data; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
|
||||
|
@Data |
||||
|
@Builder |
||||
|
@NoArgsConstructor |
||||
|
@AllArgsConstructor |
||||
|
public class SalaryCalculationInput { |
||||
|
private BigDecimal grossMonthlySalary; |
||||
|
private Integer monthIndex; |
||||
|
private BigDecimal specialAdditionalDeduction; |
||||
|
private SalaryOverrideParameters overrides; |
||||
|
} |
||||
@ -0,0 +1,53 @@ |
|||||
|
package com.mydreamjob.salarycalculator; |
||||
|
|
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Data; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
import java.time.LocalDate; |
||||
|
|
||||
|
@Data |
||||
|
@Builder |
||||
|
@NoArgsConstructor |
||||
|
@AllArgsConstructor |
||||
|
public class SalaryCalculationResult { |
||||
|
|
||||
|
private String cityCode; |
||||
|
private String cityName; |
||||
|
private SalaryDataLevel dataLevel; |
||||
|
private String policyVersion; |
||||
|
private LocalDate policyEffectiveDate; |
||||
|
private String taxRulesVersion; |
||||
|
private boolean estimate; |
||||
|
|
||||
|
private Integer monthIndex; |
||||
|
private BigDecimal grossMonthlySalary; |
||||
|
private BigDecimal specialAdditionalDeduction; |
||||
|
|
||||
|
private BigDecimal socialSecurityBase; |
||||
|
private BigDecimal housingFundBase; |
||||
|
private BigDecimal pensionRate; |
||||
|
private BigDecimal medicalRate; |
||||
|
private BigDecimal unemploymentRate; |
||||
|
private BigDecimal housingFundRate; |
||||
|
private BigDecimal seriousIllnessMonthlyFee; |
||||
|
|
||||
|
private BigDecimal pension; |
||||
|
private BigDecimal medical; |
||||
|
private BigDecimal unemployment; |
||||
|
private BigDecimal housingFund; |
||||
|
|
||||
|
private BigDecimal cumulativeGrossIncome; |
||||
|
private BigDecimal cumulativeSocialInsurance; |
||||
|
private BigDecimal cumulativeHousingFund; |
||||
|
private BigDecimal cumulativeSeriousIllness; |
||||
|
private BigDecimal cumulativeBasicDeduction; |
||||
|
private BigDecimal cumulativeSpecialAdditionalDeduction; |
||||
|
private BigDecimal cumulativeTaxableIncome; |
||||
|
private BigDecimal cumulativeTaxDue; |
||||
|
private BigDecimal previouslyWithheldTax; |
||||
|
private BigDecimal currentMonthTax; |
||||
|
private BigDecimal netSalary; |
||||
|
} |
||||
@ -0,0 +1,64 @@ |
|||||
|
package com.mydreamjob.salarycalculator; |
||||
|
|
||||
|
import com.mydreamjob.common.api.ApiResponse; |
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import com.mydreamjob.common.exception.ErrorCode; |
||||
|
import com.mydreamjob.salarycalculator.dto.SalaryCityDetailResponse; |
||||
|
import com.mydreamjob.salarycalculator.dto.SalaryCitySummaryResponse; |
||||
|
import io.swagger.v3.oas.annotations.Operation; |
||||
|
import io.swagger.v3.oas.annotations.tags.Tag; |
||||
|
import jakarta.servlet.http.HttpServletRequest; |
||||
|
import lombok.RequiredArgsConstructor; |
||||
|
import org.springframework.http.ResponseEntity; |
||||
|
import org.springframework.security.core.Authentication; |
||||
|
import org.springframework.security.core.context.SecurityContextHolder; |
||||
|
import org.springframework.web.bind.annotation.GetMapping; |
||||
|
import org.springframework.web.bind.annotation.PathVariable; |
||||
|
import org.springframework.web.bind.annotation.RequestMapping; |
||||
|
import org.springframework.web.bind.annotation.RestController; |
||||
|
|
||||
|
import java.util.List; |
||||
|
|
||||
|
@Tag(name = "薪资计算器", description = "城市政策、到手工资估算与历史记录") |
||||
|
@RestController |
||||
|
@RequestMapping("/salary-calculator") |
||||
|
@RequiredArgsConstructor |
||||
|
public class SalaryCityPolicyController { |
||||
|
|
||||
|
private final SalaryCityPolicyService cityPolicyService; |
||||
|
|
||||
|
@Operation(summary = "查询启用城市列表", description = "仅 STUDENT;返回全部启用城市摘要,不分页") |
||||
|
@GetMapping("/cities") |
||||
|
public ResponseEntity<ApiResponse<List<SalaryCitySummaryResponse>>> listCities( |
||||
|
HttpServletRequest request) { |
||||
|
Long userId = resolveUserId(); |
||||
|
return ResponseEntity.ok(ApiResponse.ok( |
||||
|
cityPolicyService.listCities(userId), getRequestId(request))); |
||||
|
} |
||||
|
|
||||
|
@Operation(summary = "查询城市默认参数", description = "仅 STUDENT;返回城市元数据和当前整套默认参数") |
||||
|
@GetMapping("/cities/{cityCode}") |
||||
|
public ResponseEntity<ApiResponse<SalaryCityDetailResponse>> getCity( |
||||
|
@PathVariable String cityCode, HttpServletRequest request) { |
||||
|
Long userId = resolveUserId(); |
||||
|
return ResponseEntity.ok(ApiResponse.ok( |
||||
|
cityPolicyService.getCity(userId, cityCode), getRequestId(request))); |
||||
|
} |
||||
|
|
||||
|
private Long resolveUserId() { |
||||
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication(); |
||||
|
if (auth == null || !auth.isAuthenticated() |
||||
|
|| "anonymousUser".equals(String.valueOf(auth.getPrincipal()))) { |
||||
|
throw new BusinessException(ErrorCode.UNAUTHORIZED); |
||||
|
} |
||||
|
try { |
||||
|
return Long.valueOf(auth.getName()); |
||||
|
} catch (NumberFormatException ex) { |
||||
|
throw new BusinessException(ErrorCode.UNAUTHORIZED); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private String getRequestId(HttpServletRequest request) { |
||||
|
return request.getHeader("X-Request-Id"); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,81 @@ |
|||||
|
package com.mydreamjob.salarycalculator; |
||||
|
|
||||
|
import com.mydreamjob.auth.User; |
||||
|
import com.mydreamjob.auth.UserRepository; |
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import com.mydreamjob.common.exception.ErrorCode; |
||||
|
import com.mydreamjob.salarycalculator.dto.SalaryCityDetailResponse; |
||||
|
import com.mydreamjob.salarycalculator.dto.SalaryCitySummaryResponse; |
||||
|
import lombok.RequiredArgsConstructor; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
import org.springframework.transaction.annotation.Transactional; |
||||
|
|
||||
|
import java.util.List; |
||||
|
|
||||
|
@Service |
||||
|
@RequiredArgsConstructor |
||||
|
public class SalaryCityPolicyService { |
||||
|
|
||||
|
private final SalaryCityPolicyRepository cityPolicyRepository; |
||||
|
private final UserRepository userRepository; |
||||
|
|
||||
|
@Transactional(readOnly = true) |
||||
|
public List<SalaryCitySummaryResponse> listCities(Long userId) { |
||||
|
requireStudent(userId); |
||||
|
return cityPolicyRepository.findByEnabledTrueOrderByProvinceCodeAscCityCodeAsc() |
||||
|
.stream() |
||||
|
.map(this::toSummary) |
||||
|
.toList(); |
||||
|
} |
||||
|
|
||||
|
@Transactional(readOnly = true) |
||||
|
public SalaryCityDetailResponse getCity(Long userId, String cityCode) { |
||||
|
requireStudent(userId); |
||||
|
if (cityCode == null || !cityCode.matches("\\d{6}")) { |
||||
|
throw new BusinessException(ErrorCode.SALARY_CITY_NOT_FOUND); |
||||
|
} |
||||
|
SalaryCityPolicy policy = cityPolicyRepository |
||||
|
.findByCityCodeAndEnabledTrue(cityCode) |
||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.SALARY_CITY_NOT_FOUND)); |
||||
|
return SalaryCityDetailResponse.builder() |
||||
|
.cityCode(policy.getCityCode()) |
||||
|
.provinceCode(policy.getProvinceCode()) |
||||
|
.provinceName(policy.getProvinceName()) |
||||
|
.cityName(policy.getCityName()) |
||||
|
.policyVersion(policy.getPolicyVersion()) |
||||
|
.policyEffectiveDate(policy.getPolicyEffectiveDate()) |
||||
|
.dataLevel(policy.getDataLevel()) |
||||
|
.dataSource(policy.getDataSource()) |
||||
|
.socialSecurityBaseMin(policy.getSocialSecurityBaseMin()) |
||||
|
.socialSecurityBaseMax(policy.getSocialSecurityBaseMax()) |
||||
|
.housingFundBaseMin(policy.getHousingFundBaseMin()) |
||||
|
.housingFundBaseMax(policy.getHousingFundBaseMax()) |
||||
|
.pensionRate(policy.getPensionRate()) |
||||
|
.medicalRate(policy.getMedicalRate()) |
||||
|
.unemploymentRate(policy.getUnemploymentRate()) |
||||
|
.housingFundRate(policy.getHousingFundRate()) |
||||
|
.seriousIllnessMonthlyFee(policy.getSeriousIllnessMonthlyFee()) |
||||
|
.build(); |
||||
|
} |
||||
|
|
||||
|
private SalaryCitySummaryResponse toSummary(SalaryCityPolicy policy) { |
||||
|
return SalaryCitySummaryResponse.builder() |
||||
|
.cityCode(policy.getCityCode()) |
||||
|
.provinceCode(policy.getProvinceCode()) |
||||
|
.provinceName(policy.getProvinceName()) |
||||
|
.cityName(policy.getCityName()) |
||||
|
.policyVersion(policy.getPolicyVersion()) |
||||
|
.policyEffectiveDate(policy.getPolicyEffectiveDate()) |
||||
|
.dataLevel(policy.getDataLevel()) |
||||
|
.build(); |
||||
|
} |
||||
|
|
||||
|
private void requireStudent(Long userId) { |
||||
|
User user = userRepository.findById(userId) |
||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); |
||||
|
if (user.getRoles() == null |
||||
|
|| user.getRoles().stream().noneMatch(role -> role == User.Role.STUDENT)) { |
||||
|
throw new BusinessException(ErrorCode.FORBIDDEN); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,22 @@ |
|||||
|
package com.mydreamjob.salarycalculator; |
||||
|
|
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Data; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
|
||||
|
@Data |
||||
|
@Builder |
||||
|
@NoArgsConstructor |
||||
|
@AllArgsConstructor |
||||
|
public class SalaryOverrideParameters { |
||||
|
private BigDecimal socialSecurityBase; |
||||
|
private BigDecimal housingFundBase; |
||||
|
private BigDecimal pensionRate; |
||||
|
private BigDecimal medicalRate; |
||||
|
private BigDecimal unemploymentRate; |
||||
|
private BigDecimal housingFundRate; |
||||
|
private BigDecimal seriousIllnessMonthlyFee; |
||||
|
} |
||||
@ -0,0 +1,34 @@ |
|||||
|
package com.mydreamjob.salarycalculator.dto; |
||||
|
|
||||
|
import com.mydreamjob.salarycalculator.SalaryDataLevel; |
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Data; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
import java.time.LocalDate; |
||||
|
|
||||
|
@Data |
||||
|
@Builder |
||||
|
@NoArgsConstructor |
||||
|
@AllArgsConstructor |
||||
|
public class SalaryCityDetailResponse { |
||||
|
private String cityCode; |
||||
|
private String provinceCode; |
||||
|
private String provinceName; |
||||
|
private String cityName; |
||||
|
private String policyVersion; |
||||
|
private LocalDate policyEffectiveDate; |
||||
|
private SalaryDataLevel dataLevel; |
||||
|
private String dataSource; |
||||
|
private BigDecimal socialSecurityBaseMin; |
||||
|
private BigDecimal socialSecurityBaseMax; |
||||
|
private BigDecimal housingFundBaseMin; |
||||
|
private BigDecimal housingFundBaseMax; |
||||
|
private BigDecimal pensionRate; |
||||
|
private BigDecimal medicalRate; |
||||
|
private BigDecimal unemploymentRate; |
||||
|
private BigDecimal housingFundRate; |
||||
|
private BigDecimal seriousIllnessMonthlyFee; |
||||
|
} |
||||
@ -0,0 +1,23 @@ |
|||||
|
package com.mydreamjob.salarycalculator.dto; |
||||
|
|
||||
|
import com.mydreamjob.salarycalculator.SalaryDataLevel; |
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Data; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
|
||||
|
import java.time.LocalDate; |
||||
|
|
||||
|
@Data |
||||
|
@Builder |
||||
|
@NoArgsConstructor |
||||
|
@AllArgsConstructor |
||||
|
public class SalaryCitySummaryResponse { |
||||
|
private String cityCode; |
||||
|
private String provinceCode; |
||||
|
private String provinceName; |
||||
|
private String cityName; |
||||
|
private String policyVersion; |
||||
|
private LocalDate policyEffectiveDate; |
||||
|
private SalaryDataLevel dataLevel; |
||||
|
} |
||||
@ -0,0 +1,150 @@ |
|||||
|
package com.mydreamjob.salarycalculator; |
||||
|
|
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import com.mydreamjob.common.exception.ErrorCode; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
import java.time.LocalDate; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
||||
|
|
||||
|
class SalaryCalculationEngineTest { |
||||
|
|
||||
|
private final SalaryCalculationEngine engine = new SalaryCalculationEngine(); |
||||
|
|
||||
|
@Test |
||||
|
void calculatesBaseCaseWithCumulativeWithholding() { |
||||
|
SalaryCalculationResult result = engine.calculate(input("10000", 1, "0", null), policy()); |
||||
|
|
||||
|
assertThat(result.getSocialSecurityBase()).isEqualByComparingTo("10000.00"); |
||||
|
assertThat(result.getHousingFundBase()).isEqualByComparingTo("10000.00"); |
||||
|
assertThat(result.getPension()).isEqualByComparingTo("800.00"); |
||||
|
assertThat(result.getMedical()).isEqualByComparingTo("200.00"); |
||||
|
assertThat(result.getUnemployment()).isEqualByComparingTo("50.00"); |
||||
|
assertThat(result.getHousingFund()).isEqualByComparingTo("1200.00"); |
||||
|
assertThat(result.getCurrentMonthTax()).isEqualByComparingTo("82.50"); |
||||
|
assertThat(result.getNetSalary()).isEqualByComparingTo("7667.50"); |
||||
|
assertThat(result.isEstimate()).isTrue(); |
||||
|
assertThat(result.getDataLevel()).isEqualTo(SalaryDataLevel.CITY); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void clampsBothBasesToFloorAndCeiling() { |
||||
|
SalaryCalculationResult low = engine.calculate(input("3000", 1, "0", null), policy()); |
||||
|
SalaryCalculationResult high = engine.calculate(input("30000", 1, "0", null), policy()); |
||||
|
|
||||
|
assertThat(low.getSocialSecurityBase()).isEqualByComparingTo("5000.00"); |
||||
|
assertThat(low.getHousingFundBase()).isEqualByComparingTo("5000.00"); |
||||
|
assertThat(low.getCurrentMonthTax()).isEqualByComparingTo("0.00"); |
||||
|
assertThat(low.getNetSalary()).isEqualByComparingTo("1875.00"); |
||||
|
|
||||
|
assertThat(high.getSocialSecurityBase()).isEqualByComparingTo("20000.00"); |
||||
|
assertThat(high.getHousingFundBase()).isEqualByComparingTo("20000.00"); |
||||
|
assertThat(high.getCurrentMonthTax()).isEqualByComparingTo("615.00"); |
||||
|
assertThat(high.getNetSalary()).isEqualByComparingTo("24885.00"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void usesPriorCumulativeTaxForTwelfthMonth() { |
||||
|
SalaryCalculationResult result = engine.calculate(input("20000", 12, "0", null), policy()); |
||||
|
|
||||
|
assertThat(result.getCumulativeTaxableIncome()).isEqualByComparingTo("126000.00"); |
||||
|
assertThat(result.getCumulativeTaxDue()).isEqualByComparingTo("10080.00"); |
||||
|
assertThat(result.getPreviouslyWithheldTax()).isEqualByComparingTo("9030.00"); |
||||
|
assertThat(result.getCurrentMonthTax()).isEqualByComparingTo("1050.00"); |
||||
|
assertThat(result.getNetSalary()).isEqualByComparingTo("14450.00"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void appliesSpecialDeductionAndOverrides() { |
||||
|
SalaryOverrideParameters overrides = SalaryOverrideParameters.builder() |
||||
|
.socialSecurityBase(new BigDecimal("8000.00")) |
||||
|
.housingFundBase(new BigDecimal("10000.00")) |
||||
|
.pensionRate(new BigDecimal("0.08")) |
||||
|
.medicalRate(new BigDecimal("0.02")) |
||||
|
.unemploymentRate(new BigDecimal("0.005")) |
||||
|
.housingFundRate(new BigDecimal("0.12")) |
||||
|
.seriousIllnessMonthlyFee(BigDecimal.ZERO) |
||||
|
.build(); |
||||
|
|
||||
|
SalaryCalculationResult result = engine.calculate( |
||||
|
input("10000", 1, "2000", overrides), policy()); |
||||
|
|
||||
|
assertThat(result.getSocialSecurityBase()).isEqualByComparingTo("8000.00"); |
||||
|
assertThat(result.getPension()).isEqualByComparingTo("640.00"); |
||||
|
assertThat(result.getCurrentMonthTax()).isEqualByComparingTo("28.80"); |
||||
|
assertThat(result.getNetSalary()).isEqualByComparingTo("7931.20"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void rejectsOverrideOutsideCityBaseRange() { |
||||
|
SalaryOverrideParameters overrides = SalaryOverrideParameters.builder() |
||||
|
.socialSecurityBase(new BigDecimal("4999.99")) |
||||
|
.build(); |
||||
|
|
||||
|
assertThatThrownBy(() -> engine.calculate(input("10000", 1, "0", overrides), policy())) |
||||
|
.isInstanceOf(BusinessException.class) |
||||
|
.extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) |
||||
|
.isEqualTo(ErrorCode.SALARY_INVALID_PARAMETER.getCode()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void zeroContributionRateAllowsZeroBase() { |
||||
|
SalaryOverrideParameters overrides = SalaryOverrideParameters.builder() |
||||
|
.socialSecurityBase(BigDecimal.ZERO) |
||||
|
.housingFundBase(BigDecimal.ZERO) |
||||
|
.pensionRate(BigDecimal.ZERO) |
||||
|
.medicalRate(BigDecimal.ZERO) |
||||
|
.unemploymentRate(BigDecimal.ZERO) |
||||
|
.housingFundRate(BigDecimal.ZERO) |
||||
|
.seriousIllnessMonthlyFee(BigDecimal.ZERO) |
||||
|
.build(); |
||||
|
|
||||
|
SalaryCalculationResult result = engine.calculate( |
||||
|
input("10000", 1, "0", overrides), policy()); |
||||
|
|
||||
|
assertThat(result.getPension()).isEqualByComparingTo("0.00"); |
||||
|
assertThat(result.getMedical()).isEqualByComparingTo("0.00"); |
||||
|
assertThat(result.getUnemployment()).isEqualByComparingTo("0.00"); |
||||
|
assertThat(result.getHousingFund()).isEqualByComparingTo("0.00"); |
||||
|
assertThat(result.getCurrentMonthTax()).isEqualByComparingTo("150.00"); |
||||
|
assertThat(result.getNetSalary()).isEqualByComparingTo("9850.00"); |
||||
|
} |
||||
|
|
||||
|
private SalaryCalculationInput input( |
||||
|
String gross, int monthIndex, String specialDeduction, |
||||
|
SalaryOverrideParameters overrides) { |
||||
|
return SalaryCalculationInput.builder() |
||||
|
.grossMonthlySalary(new BigDecimal(gross)) |
||||
|
.monthIndex(monthIndex) |
||||
|
.specialAdditionalDeduction(new BigDecimal(specialDeduction)) |
||||
|
.overrides(overrides) |
||||
|
.build(); |
||||
|
} |
||||
|
|
||||
|
private SalaryCityPolicy policy() { |
||||
|
return SalaryCityPolicy.builder() |
||||
|
.cityCode("110000") |
||||
|
.provinceCode("110000") |
||||
|
.provinceName("北京市") |
||||
|
.cityName("北京市") |
||||
|
.policyVersion("2026-09-20-v1") |
||||
|
.policyEffectiveDate(LocalDate.of(2026, 7, 1)) |
||||
|
.socialSecurityBaseMin(new BigDecimal("5000.00")) |
||||
|
.socialSecurityBaseMax(new BigDecimal("20000.00")) |
||||
|
.housingFundBaseMin(new BigDecimal("5000.00")) |
||||
|
.housingFundBaseMax(new BigDecimal("20000.00")) |
||||
|
.pensionRate(new BigDecimal("0.080000")) |
||||
|
.medicalRate(new BigDecimal("0.020000")) |
||||
|
.unemploymentRate(new BigDecimal("0.005000")) |
||||
|
.housingFundRate(new BigDecimal("0.120000")) |
||||
|
.seriousIllnessMonthlyFee(BigDecimal.ZERO) |
||||
|
.dataLevel(SalaryDataLevel.CITY) |
||||
|
.enabled(true) |
||||
|
.build(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
|
||||
@ -0,0 +1,107 @@ |
|||||
|
package com.mydreamjob.salarycalculator; |
||||
|
|
||||
|
import com.mydreamjob.auth.JwtTokenProvider; |
||||
|
import com.mydreamjob.auth.User; |
||||
|
import com.mydreamjob.auth.UserRepository; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; |
||||
|
import org.springframework.boot.test.context.SpringBootTest; |
||||
|
import org.springframework.boot.test.mock.mockito.MockBean; |
||||
|
import org.springframework.data.redis.core.RedisTemplate; |
||||
|
import org.springframework.test.context.ActiveProfiles; |
||||
|
import org.springframework.test.web.servlet.MockMvc; |
||||
|
|
||||
|
import java.util.Map; |
||||
|
import java.util.Set; |
||||
|
import java.util.concurrent.atomic.AtomicLong; |
||||
|
|
||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; |
||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
||||
|
|
||||
|
@SpringBootTest |
||||
|
@AutoConfigureMockMvc |
||||
|
@ActiveProfiles("integration-test") |
||||
|
class SalaryCityPolicyControllerTest { |
||||
|
|
||||
|
private static final AtomicLong PHONE = new AtomicLong(950_000_000L); |
||||
|
|
||||
|
@Autowired private MockMvc mockMvc; |
||||
|
@Autowired private JwtTokenProvider jwtTokenProvider; |
||||
|
@Autowired private UserRepository userRepository; |
||||
|
|
||||
|
@MockBean private RedisTemplate<String, Object> redisTemplate; |
||||
|
|
||||
|
@Test |
||||
|
void cities_returnsAllSeededCitiesForStudent() throws Exception { |
||||
|
long uid = createUser(User.Role.STUDENT); |
||||
|
|
||||
|
mockMvc.perform(get("/salary-calculator/cities") |
||||
|
.header("Authorization", token(uid))) |
||||
|
.andExpect(status().isOk()) |
||||
|
.andExpect(jsonPath("$.code").value(200)) |
||||
|
.andExpect(jsonPath("$.data.length()").value(359)) |
||||
|
.andExpect(jsonPath("$.data[0].cityCode").value("110000")) |
||||
|
.andExpect(jsonPath("$.data[0].dataLevel").value("CITY")); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void cityDetail_returnsDefaultsAndFallbackLevel() throws Exception { |
||||
|
long uid = createUser(User.Role.STUDENT); |
||||
|
|
||||
|
mockMvc.perform(get("/salary-calculator/cities/110000") |
||||
|
.header("Authorization", token(uid))) |
||||
|
.andExpect(status().isOk()) |
||||
|
.andExpect(jsonPath("$.data.cityCode").value("110000")) |
||||
|
.andExpect(jsonPath("$.data.dataLevel").value("CITY")) |
||||
|
.andExpect(jsonPath("$.data.socialSecurityBaseMin").value(7270.00)) |
||||
|
.andExpect(jsonPath("$.data.housingFundRate").value(0.120000)); |
||||
|
|
||||
|
mockMvc.perform(get("/salary-calculator/cities/130100") |
||||
|
.header("Authorization", token(uid))) |
||||
|
.andExpect(status().isOk()) |
||||
|
.andExpect(jsonPath("$.data.dataLevel").value("PROVINCE_FALLBACK")); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void missingCity_returns404And9801() throws Exception { |
||||
|
long uid = createUser(User.Role.STUDENT); |
||||
|
|
||||
|
mockMvc.perform(get("/salary-calculator/cities/999999") |
||||
|
.header("Authorization", token(uid))) |
||||
|
.andExpect(status().isNotFound()) |
||||
|
.andExpect(jsonPath("$.code").value(9801)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void companyUser_returns403() throws Exception { |
||||
|
long uid = createUser(User.Role.COMPANY); |
||||
|
|
||||
|
mockMvc.perform(get("/salary-calculator/cities") |
||||
|
.header("Authorization", token(uid))) |
||||
|
.andExpect(status().isForbidden()) |
||||
|
.andExpect(jsonPath("$.code").value(2002)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void anonymous_returns401() throws Exception { |
||||
|
mockMvc.perform(get("/salary-calculator/cities")) |
||||
|
.andExpect(status().isUnauthorized()); |
||||
|
} |
||||
|
|
||||
|
private long createUser(User.Role role) { |
||||
|
User user = userRepository.save(User.builder() |
||||
|
.phone(String.valueOf(PHONE.getAndIncrement())) |
||||
|
.roles(Set.of(role)) |
||||
|
.role(role) |
||||
|
.active(true) |
||||
|
.build()); |
||||
|
return user.getId(); |
||||
|
} |
||||
|
|
||||
|
private String token(long userId) { |
||||
|
return "Bearer " + jwtTokenProvider.generateAccessToken( |
||||
|
userId, String.valueOf(PHONE.getAndIncrement()), Map.of("roles", "STUDENT")); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,106 @@ |
|||||
|
package com.mydreamjob.salarycalculator; |
||||
|
|
||||
|
import com.mydreamjob.auth.User; |
||||
|
import com.mydreamjob.auth.UserRepository; |
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import com.mydreamjob.common.exception.ErrorCode; |
||||
|
import com.mydreamjob.salarycalculator.dto.SalaryCityDetailResponse; |
||||
|
import com.mydreamjob.salarycalculator.dto.SalaryCitySummaryResponse; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.junit.jupiter.api.extension.ExtendWith; |
||||
|
import org.mockito.InjectMocks; |
||||
|
import org.mockito.Mock; |
||||
|
import org.mockito.junit.jupiter.MockitoExtension; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
import java.time.LocalDate; |
||||
|
import java.util.List; |
||||
|
import java.util.Optional; |
||||
|
import java.util.Set; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
||||
|
import static org.mockito.Mockito.when; |
||||
|
|
||||
|
@ExtendWith(MockitoExtension.class) |
||||
|
class SalaryCityPolicyServiceTest { |
||||
|
|
||||
|
@Mock private SalaryCityPolicyRepository cityRepository; |
||||
|
@Mock private UserRepository userRepository; |
||||
|
@InjectMocks private SalaryCityPolicyService service; |
||||
|
|
||||
|
@Test |
||||
|
void listCities_returnsStableEnabledSummariesForStudent() { |
||||
|
when(userRepository.findById(10L)).thenReturn(Optional.of(user(10L, User.Role.STUDENT))); |
||||
|
when(cityRepository.findByEnabledTrueOrderByProvinceCodeAscCityCodeAsc()) |
||||
|
.thenReturn(List.of(policy("110000", "北京市", SalaryDataLevel.CITY), |
||||
|
policy("130100", "石家庄市", SalaryDataLevel.PROVINCE_FALLBACK))); |
||||
|
|
||||
|
List<SalaryCitySummaryResponse> result = service.listCities(10L); |
||||
|
|
||||
|
assertThat(result).extracting(SalaryCitySummaryResponse::getCityCode) |
||||
|
.containsExactly("110000", "130100"); |
||||
|
assertThat(result).extracting(SalaryCitySummaryResponse::getDataLevel) |
||||
|
.containsExactly(SalaryDataLevel.CITY, SalaryDataLevel.PROVINCE_FALLBACK); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void getCity_returnsDefaultsAndDataLevel() { |
||||
|
when(userRepository.findById(10L)).thenReturn(Optional.of(user(10L, User.Role.STUDENT))); |
||||
|
when(cityRepository.findByCityCodeAndEnabledTrue("110000")) |
||||
|
.thenReturn(Optional.of(policy("110000", "北京市", SalaryDataLevel.CITY))); |
||||
|
|
||||
|
SalaryCityDetailResponse result = service.getCity(10L, "110000"); |
||||
|
|
||||
|
assertThat(result.getCityCode()).isEqualTo("110000"); |
||||
|
assertThat(result.getDataLevel()).isEqualTo(SalaryDataLevel.CITY); |
||||
|
assertThat(result.getSocialSecurityBaseMin()).isEqualByComparingTo("5000.00"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void nonStudent_isForbidden() { |
||||
|
when(userRepository.findById(10L)).thenReturn(Optional.of(user(10L, User.Role.COMPANY))); |
||||
|
|
||||
|
assertThatThrownBy(() -> service.listCities(10L)) |
||||
|
.isInstanceOf(BusinessException.class) |
||||
|
.extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) |
||||
|
.isEqualTo(ErrorCode.FORBIDDEN.getCode()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void missingOrDisabledCity_returns9801() { |
||||
|
when(userRepository.findById(10L)).thenReturn(Optional.of(user(10L, User.Role.STUDENT))); |
||||
|
when(cityRepository.findByCityCodeAndEnabledTrue("999999")).thenReturn(Optional.empty()); |
||||
|
|
||||
|
assertThatThrownBy(() -> service.getCity(10L, "999999")) |
||||
|
.isInstanceOf(BusinessException.class) |
||||
|
.extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) |
||||
|
.isEqualTo(ErrorCode.SALARY_CITY_NOT_FOUND.getCode()); |
||||
|
} |
||||
|
|
||||
|
private User user(Long id, User.Role role) { |
||||
|
return User.builder().id(id).roles(Set.of(role)).role(role).build(); |
||||
|
} |
||||
|
|
||||
|
private SalaryCityPolicy policy(String code, String name, SalaryDataLevel level) { |
||||
|
return SalaryCityPolicy.builder() |
||||
|
.cityCode(code) |
||||
|
.provinceCode("110000") |
||||
|
.provinceName("测试省") |
||||
|
.cityName(name) |
||||
|
.policyVersion("2026-09-20-v1") |
||||
|
.policyEffectiveDate(LocalDate.of(2026, 7, 1)) |
||||
|
.socialSecurityBaseMin(new BigDecimal("5000.00")) |
||||
|
.socialSecurityBaseMax(new BigDecimal("25000.00")) |
||||
|
.housingFundBaseMin(new BigDecimal("2000.00")) |
||||
|
.housingFundBaseMax(new BigDecimal("30000.00")) |
||||
|
.pensionRate(new BigDecimal("0.080000")) |
||||
|
.medicalRate(new BigDecimal("0.020000")) |
||||
|
.unemploymentRate(new BigDecimal("0.005000")) |
||||
|
.housingFundRate(new BigDecimal("0.120000")) |
||||
|
.seriousIllnessMonthlyFee(BigDecimal.ZERO) |
||||
|
.dataLevel(level) |
||||
|
.enabled(true) |
||||
|
.build(); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue