diff --git a/changes/n05-salary-calc/.spec-superflow.yaml b/changes/n05-salary-calc/.spec-superflow.yaml index 5c1bf15..d79235f 100644 --- a/changes/n05-salary-calc/.spec-superflow.yaml +++ b/changes/n05-salary-calc/.spec-superflow.yaml @@ -14,7 +14,7 @@ contract_hash: sha256:c85abc5d5ddd79b03da8ff42816436f4389bb99a5739d4fcb2a960c3c8 execution_mode: sdd execution_plan_hash: sha256:77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5 execution_plan_revision: 1 -batches_completed: 0 +batches_completed: 1 test_result: null spec_merged: false spec_publication_receipt: null diff --git a/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/checkpoints/2.md b/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/checkpoints/2.md new file mode 100644 index 0000000..9b5011c --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/checkpoints/2.md @@ -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 diff --git a/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/reviews/VzE.json b/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/reviews/VzE.json new file mode 100644 index 0000000..9b3ae1e --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/reviews/VzE.json @@ -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" +} diff --git a/changes/n05-salary-calc/.superpowers/sdd/progress.md b/changes/n05-salary-calc/.superpowers/sdd/progress.md index fa513ac..cccfb68 100644 --- a/changes/n05-salary-calc/.superpowers/sdd/progress.md +++ b/changes/n05-salary-calc/.superpowers/sdd/progress.md @@ -3,14 +3,29 @@ ## Task 1 - V40 表结构和薪资领域持久化基础 - 状态:完成 -- 变更:新增 `salary_city_policy`、`salary_calculation_history`、`data_level`、实体、Repository、迁移与持久化测试。 - 验证:`mvn -Dtest=SalaryMigrationTest,SalaryRepositoryIntegrationTest test`,2/2 通过。 - 提交:`837c0632c66e6cb9a00e522944f73045258557d6`。 -- 风险:无。 ## Task 2 - V41 分层城市政策种子 - 状态:完成 -- 变更:新增 359 条目标城市政策;12 条 `CITY`,347 条 `PROVINCE_FALLBACK`;覆盖 31 个大陆省级行政区、直辖市和省直辖县级市。 +- 结果:359 条目标城市政策;12 条 `CITY`,347 条 `PROVINCE_FALLBACK`。 - 验证:`mvn -Dtest=SalaryCityPolicySeedTest,SalaryMigrationTest,SalaryRepositoryIntegrationTest test`,3/3 通过。 -- 风险:347 条回退记录使用省级基线,必须在接口和前端明确展示 `PROVINCE_FALLBACK`;后续城市级核验需新迁移。 +- 提交:`2d315f90e46982e9658da6483f46e2acbe21bcca`。 + +## Task 3 - 城市政策查询接口 + +- 状态:完成 +- 结果:`GET /salary-calculator/cities` 与 `GET /salary-calculator/cities/{cityCode}`;仅 STUDENT;返回 `dataLevel`。 +- 验证:`mvn -Dtest=SalaryCityPolicyServiceTest,SalaryCityPolicyControllerTest test`,9/9 通过。 + +## Task 4 - BigDecimal 薪资计算引擎 + +- 状态:完成 +- 结果:基数截断、覆盖、五险一金、大病费、累计月份、专项扣除、累计预扣、税档和错误码。 +- 验证:`mvn -Dtest=SalaryCalculationEngineTest test`,6/6 通过。 + +## Wave W2 + +- 状态:完成待 review +- 证据:`mvn -Dtest=SalaryCityPolicyServiceTest,SalaryCityPolicyControllerTest,SalaryCalculationEngineTest test`,15/15 通过。 diff --git a/changes/n05-salary-calc/.superpowers/sdd/reviews/T3.md b/changes/n05-salary-calc/.superpowers/sdd/reviews/T3.md new file mode 100644 index 0000000..0d4eb9f --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/reviews/T3.md @@ -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. diff --git a/changes/n05-salary-calc/.superpowers/sdd/reviews/T4.md b/changes/n05-salary-calc/.superpowers/sdd/reviews/T4.md new file mode 100644 index 0000000..691d06c --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/reviews/T4.md @@ -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. diff --git a/changes/n05-salary-calc/.superpowers/sdd/reviews/VzE.json b/changes/n05-salary-calc/.superpowers/sdd/reviews/VzE.json new file mode 100644 index 0000000..9b3ae1e --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/reviews/VzE.json @@ -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" +} diff --git a/changes/n05-salary-calc/.superpowers/sdd/reviews/W2.md b/changes/n05-salary-calc/.superpowers/sdd/reviews/W2.md new file mode 100644 index 0000000..bf4361f --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/reviews/W2.md @@ -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. diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java index ae3b69c..d8dac91 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java @@ -178,7 +178,14 @@ public enum ErrorCode { ONBOARDING_ITEM_LIMIT_EXCEEDED(9703, "onboarding checklist item limit exceeded"), ONBOARDING_IDEMPOTENCY_CONFLICT(9704, "idempotency key conflict: request body differs"), ONBOARDING_PROGRESS_CONCURRENT_UPDATE(9705, "onboarding progress was modified concurrently"), - ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE(9706, "company knowledge was modified concurrently"); + ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE(9706, "company knowledge was modified concurrently"), + + // 薪资计算器 98xx + SALARY_CITY_NOT_FOUND(9801, "salary city not found or disabled"), + SALARY_INVALID_PARAMETER(9802, "invalid salary calculation parameter"), + SALARY_HISTORY_NOT_FOUND(9803, "salary calculation history not found"), + SALARY_IDEMPOTENCY_CONFLICT(9804, "salary idempotency key conflict: request body differs"), + SALARY_POLICY_VERSION_CONFLICT(9805, "salary city policy version conflict"); private final int code; private final String message; @@ -196,3 +203,4 @@ public enum ErrorCode { return message; } } + diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java index 7743c3e..2f72730 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java @@ -77,7 +77,9 @@ public class GlobalExceptionHandler { || errorCode == ErrorCode.CONVERSATION_NOT_FOUND.getCode() || errorCode == ErrorCode.APPEAL_NOT_FOUND.getCode() || errorCode == ErrorCode.DATA_EXPORT_TASK_NOT_FOUND.getCode() - || errorCode == ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND.getCode()) return HttpStatus.NOT_FOUND; + || errorCode == ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND.getCode() + || errorCode == ErrorCode.SALARY_CITY_NOT_FOUND.getCode() + || errorCode == ErrorCode.SALARY_HISTORY_NOT_FOUND.getCode()) return HttpStatus.NOT_FOUND; // 冲突 → 409 if (errorCode == ErrorCode.COMPANY_ALREADY_EXISTS.getCode() || errorCode == ErrorCode.APPLICATION_DUPLICATE.getCode() @@ -169,7 +171,9 @@ public class GlobalExceptionHandler { || errorCode == ErrorCode.ONBOARDING_ITEM_LIMIT_EXCEEDED.getCode() || errorCode == ErrorCode.ONBOARDING_IDEMPOTENCY_CONFLICT.getCode() || errorCode == ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE.getCode() - || errorCode == ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE.getCode()) + || errorCode == ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE.getCode() + || errorCode == ErrorCode.SALARY_IDEMPOTENCY_CONFLICT.getCode() + || errorCode == ErrorCode.SALARY_POLICY_VERSION_CONFLICT.getCode()) return HttpStatus.CONFLICT; // 举报限流 → 429 if (errorCode == ErrorCode.REPORT_RATE_LIMITED.getCode()) @@ -255,3 +259,7 @@ public class GlobalExceptionHandler { return request.getHeader("X-Request-Id"); } } + + + + diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java b/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java index 148f8c9..fe2cb40 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java @@ -128,6 +128,8 @@ public class SecurityConfig { .requestMatchers("/interviews/**").authenticated() // 入职清单与公司知识(需登录;细粒度角色/所有权在 Service 校验) .requestMatchers("/onboarding/**").authenticated() + // 薪资计算器(需登录;仅 STUDENT 在 Service 校验) + .requestMatchers("/salary-calculator/**").authenticated() // API 信息入口 .requestMatchers("/").permitAll() .anyRequest().authenticated() @@ -157,3 +159,4 @@ public class SecurityConfig { return new BCryptPasswordEncoder(); } } + diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationEngine.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationEngine.java new file mode 100644 index 0000000..39b4bbf --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationEngine.java @@ -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 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) { + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationInput.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationInput.java new file mode 100644 index 0000000..f7b0f56 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationInput.java @@ -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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationResult.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationResult.java new file mode 100644 index 0000000..d35fc9c --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationResult.java @@ -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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicyController.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicyController.java new file mode 100644 index 0000000..f59d723 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicyController.java @@ -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>> 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> 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"); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicyService.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicyService.java new file mode 100644 index 0000000..b87c16e --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicyService.java @@ -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 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); + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryOverrideParameters.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryOverrideParameters.java new file mode 100644 index 0000000..6ae17a4 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryOverrideParameters.java @@ -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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCityDetailResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCityDetailResponse.java new file mode 100644 index 0000000..f35c101 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCityDetailResponse.java @@ -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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCitySummaryResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCitySummaryResponse.java new file mode 100644 index 0000000..2d85502 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCitySummaryResponse.java @@ -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; +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCalculationEngineTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCalculationEngineTest.java new file mode 100644 index 0000000..67c037a --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCalculationEngineTest.java @@ -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(); + } +} + + diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCityPolicyControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCityPolicyControllerTest.java new file mode 100644 index 0000000..48f5129 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCityPolicyControllerTest.java @@ -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 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")); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCityPolicyServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCityPolicyServiceTest.java new file mode 100644 index 0000000..bf2fe8d --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCityPolicyServiceTest.java @@ -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 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(); + } +}