diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/admin/diagnostic/application/dto/response/AdminDiagnosticPolicyResponse.java b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/diagnostic/application/dto/response/AdminDiagnosticPolicyResponse.java new file mode 100644 index 00000000..9875c189 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/diagnostic/application/dto/response/AdminDiagnosticPolicyResponse.java @@ -0,0 +1,156 @@ +package co.kr.pinhouse.domain.admin.diagnostic.application.dto.response; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "청약진단 정책 설정값 응답 (어드민 시각화용)") +public record AdminDiagnosticPolicyResponse( + @Schema(description = "나이 관련 정책 (나이 기준 요약 카드용)") + AgePolicyResponse agePolicy, + @Schema(description = "자산 관련 정책 (막대 차트용)") + AssetPolicyResponse assetPolicy, + @Schema(description = "임대유형별 소득 기준 (임대유형 탭별 표 렌더링용)") + List incomePolicy, + @Schema(description = "소득 비율 교차 매트릭스 (히트맵/피벗 테이블용). 행=공급유형, 열=임대유형") + IncomeMatrixResponse incomeMatrix +) { + + // ─────────────────── 나이 정책 ─────────────────── + + @Schema(description = "나이 기준 정책") + public record AgePolicyResponse( + @Schema(description = "고령자 기준 나이 이상이면 고령자 특별공급 대상", example = "65") + int elderAgeThreshold, + @Schema(description = "청년 특별공급 최소 나이", example = "19") + int youthAgeMin, + @Schema(description = "청년 특별공급 최대 나이", example = "39") + int youthAgeMax, + @Schema(description = "미성년자 결혼 특별공급 최대 나이(이 나이 미만)", example = "18") + int marriedMinorAgeMax, + @Schema(description = "신혼부부 특별공급 최대 혼인 기간(년)", example = "7") + int newlyMarriedMaxYears, + @Schema(description = "나이 구간별 신청 가능 공급유형 가이드 (타임라인 차트용)") + List ageRangeGuides + ) { + } + + @Schema(description = "나이 구간별 신청 가능 공급유형") + public record AgeRangeGuideResponse( + @Schema(description = "나이 구간 레이블", example = "18세 이하") + String ageRangeLabel, + @Schema(description = "나이 범위 시작 (inclusive). null이면 하한 없음") + Integer ageFrom, + @Schema(description = "나이 범위 끝 (inclusive). null이면 상한 없음") + Integer ageTo, + @Schema(description = "이 구간에서 신청 가능한 공급 유형 코드 목록") + List eligibleSupplyTypeCodes, + @Schema(description = "이 구간에서 신청 가능한 공급 유형 명칭 목록") + List eligibleSupplyTypeNames + ) { + } + + // ─────────────────── 자산 정책 ─────────────────── + + @Schema(description = "자산 한도 정책") + public record AssetPolicyResponse( + @Schema(description = "자동차 자산 한도 (원)", example = "45420000") + long maxCarValueWon, + @Schema(description = "자동차 자산 한도 텍스트", example = "4,542만원") + String maxCarValueLabel, + @Schema(description = "임대유형별 자산 한도 목록 (막대 차트 데이터)") + List rentalLimits + ) { + } + + @Schema(description = "임대유형별 자산 한도 (막대 차트 1개 항목)") + public record RentalAssetLimitResponse( + @Schema(description = "임대 유형 코드", example = "PUBLIC_INTEGRATED") + String rentalType, + @Schema(description = "임대 유형 명칭", example = "통합공공임대") + String rentalLabel, + @Schema(description = "적용 자산 기준", example = "총자산") + String assetType, + @Schema(description = "자산 한도 (원) — 차트 수치 값", example = "345000000") + long limitWon, + @Schema(description = "자산 한도 텍스트 — 차트 레이블", example = "3억 4,500만원") + String limitLabel + ) { + } + + // ─────────────────── 임대유형별 소득 정책 ─────────────────── + + @Schema(description = "임대유형별 소득 기준 정책 (탭 전환 표용)") + public record RentalIncomePolicyResponse( + @Schema(description = "임대 유형 코드", example = "PUBLIC_INTEGRATED") + String rentalType, + @Schema(description = "임대 유형 명칭", example = "통합공공임대") + String rentalLabel, + @Schema(description = "소득 기준 기준점", example = "기준중위소득") + String incomeStandard, + @Schema(description = "공급유형별 소득 비율 목록 (표의 행)") + List supplyRatios + ) { + } + + @Schema(description = "공급유형별 소득 비율 (표 1행)") + public record SupplyIncomeRatioResponse( + @Schema(description = "공급 유형 코드", example = "YOUTH_SPECIAL") + String supplyType, + @Schema(description = "공급 유형 명칭", example = "청년 특별공급") + String supplyLabel, + @Schema(description = "1인 가구 소득 비율 (%)", example = "170.0") + double family1Percent, + @Schema(description = "2인 가구 소득 비율 (%)", example = "160.0") + double family2Percent, + @Schema(description = "3인 이상 가구 소득 비율 (%)", example = "150.0") + double family3PlusPercent + ) { + } + + // ─────────────────── 소득 교차 매트릭스 ─────────────────── + + @Schema(description = "소득 비율 교차 매트릭스 (히트맵/피벗 테이블 렌더링용)") + public record IncomeMatrixResponse( + @Schema(description = "열 헤더: 임대 유형 목록 (코드 + 명칭)") + List rentalTypeHeaders, + @Schema(description = "행: 공급 유형별로 각 임대유형의 소득 비율 셀 목록") + List rows + ) { + } + + @Schema(description = "매트릭스 헤더 항목") + public record MatrixHeader( + String code, + String label + ) { + } + + @Schema(description = "교차 매트릭스 행 (공급 유형 기준)") + public record IncomeMatrixRowResponse( + @Schema(description = "공급 유형 코드") + String supplyTypeCode, + @Schema(description = "공급 유형 명칭") + String supplyTypeLabel, + @Schema(description = "각 임대유형별 셀. rentalTypeHeaders와 동일 순서로 정렬") + List cells + ) { + } + + @Schema(description = "교차 매트릭스 셀") + public record IncomeMatrixCellResponse( + @Schema(description = "임대 유형 코드") + String rentalTypeCode, + @Schema(description = "해당 조합이 존재하면 true") + boolean applicable, + @Schema(description = "소득 기준 기준점. applicable=false면 null") + String incomeStandard, + @Schema(description = "1인 가구 소득 비율 (%). applicable=false면 -1") + double family1Percent, + @Schema(description = "2인 가구 소득 비율 (%). applicable=false면 -1") + double family2Percent, + @Schema(description = "3인 이상 가구 소득 비율 (%). applicable=false면 -1") + double family3PlusPercent + ) { + } +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/admin/diagnostic/application/service/AdminDiagnosticPolicyService.java b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/diagnostic/application/service/AdminDiagnosticPolicyService.java new file mode 100644 index 00000000..e06c6aa8 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/diagnostic/application/service/AdminDiagnosticPolicyService.java @@ -0,0 +1,261 @@ +package co.kr.pinhouse.domain.admin.diagnostic.application.service; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.springframework.stereotype.Service; + +import co.kr.pinhouse.domain.admin.diagnostic.application.dto.response.AdminDiagnosticPolicyResponse; +import co.kr.pinhouse.domain.admin.diagnostic.application.dto.response.AdminDiagnosticPolicyResponse.AgePolicyResponse; +import co.kr.pinhouse.domain.admin.diagnostic.application.dto.response.AdminDiagnosticPolicyResponse.AgeRangeGuideResponse; +import co.kr.pinhouse.domain.admin.diagnostic.application.dto.response.AdminDiagnosticPolicyResponse.AssetPolicyResponse; +import co.kr.pinhouse.domain.admin.diagnostic.application.dto.response.AdminDiagnosticPolicyResponse.IncomeMatrixCellResponse; +import co.kr.pinhouse.domain.admin.diagnostic.application.dto.response.AdminDiagnosticPolicyResponse.IncomeMatrixResponse; +import co.kr.pinhouse.domain.admin.diagnostic.application.dto.response.AdminDiagnosticPolicyResponse.IncomeMatrixRowResponse; +import co.kr.pinhouse.domain.admin.diagnostic.application.dto.response.AdminDiagnosticPolicyResponse.MatrixHeader; +import co.kr.pinhouse.domain.admin.diagnostic.application.dto.response.AdminDiagnosticPolicyResponse.RentalAssetLimitResponse; +import co.kr.pinhouse.domain.admin.diagnostic.application.dto.response.AdminDiagnosticPolicyResponse.RentalIncomePolicyResponse; +import co.kr.pinhouse.domain.admin.diagnostic.application.dto.response.AdminDiagnosticPolicyResponse.SupplyIncomeRatioResponse; +import co.kr.pinhouse.domain.admin.diagnostic.application.usecase.AdminDiagnosticPolicyUseCase; +import co.kr.pinhouse.domain.diagnostic.rule.application.usecase.PolicyUseCase; +import co.kr.pinhouse.domain.diagnostic.rule.domain.entity.SupplyType; +import co.kr.pinhouse.domain.housing.notice.domain.entity.NoticeType; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AdminDiagnosticPolicyService implements AdminDiagnosticPolicyUseCase { + + private final PolicyUseCase policyUseCase; + + /// 임대 유형 표시 순서 + private static final List RENTAL_ORDER = List.of( + NoticeType.PUBLIC_INTEGRATED, + NoticeType.PERMANENT_RENTAL, + NoticeType.NATIONAL_RENTAL, + NoticeType.LONG_TERM_JEONSE, + NoticeType.PUBLIC_RENTAL, + NoticeType.HAPPY_HOUSING + ); + + /// 임대 유형별 공급 유형 매핑 (표시 순서 유지) + private static final Map> RENTAL_SUPPLY_MAP; + + /// 임대 유형별 소득 기준 기준점 + private static final Map INCOME_STANDARD_MAP = Map.of( + NoticeType.PUBLIC_INTEGRATED, "기준중위소득", + NoticeType.PERMANENT_RENTAL, "전년도 도시근로자 월평균소득", + NoticeType.NATIONAL_RENTAL, "전년도 도시근로자 월평균소득", + NoticeType.LONG_TERM_JEONSE, "전년도 도시근로자 월평균소득", + NoticeType.PUBLIC_RENTAL, "기준중위소득", + NoticeType.HAPPY_HOUSING, "기준중위소득" + ); + + /// 임대 유형별 자산 기준 유형 + private static final Map ASSET_TYPE_MAP = Map.of( + NoticeType.PUBLIC_INTEGRATED, "총자산", + NoticeType.PERMANENT_RENTAL, "총자산", + NoticeType.NATIONAL_RENTAL, "총자산", + NoticeType.LONG_TERM_JEONSE, "부동산자산", + NoticeType.PUBLIC_RENTAL, "부동산자산", + NoticeType.HAPPY_HOUSING, "총자산" + ); + + static { + Map> map = new LinkedHashMap<>(); + map.put(NoticeType.PUBLIC_INTEGRATED, List.of( + SupplyType.YOUTH_SPECIAL, SupplyType.ELDER_SPECIAL, SupplyType.NEWCOUPLE_SPECIAL, + SupplyType.MULTICHILD_SPECIAL, SupplyType.SPECIAL, SupplyType.SINGLE_PARENT_SPECIAL, + SupplyType.MINOR_SPECIAL, SupplyType.FIRST_SPECIAL, SupplyType.ELDER_SUPPORT_SPECIAL, + SupplyType.GENERAL + )); + map.put(NoticeType.PERMANENT_RENTAL, List.of( + SupplyType.GENERAL, SupplyType.NATIONAL_MERIT, SupplyType.NORTH_DEFECTOR, SupplyType.DISABLED + )); + map.put(NoticeType.NATIONAL_RENTAL, List.of( + SupplyType.GENERAL, SupplyType.MULTICHILD_SPECIAL, SupplyType.NEWCOUPLE_SPECIAL, SupplyType.NATIONAL_MERIT + )); + map.put(NoticeType.LONG_TERM_JEONSE, List.of( + SupplyType.GENERAL, SupplyType.MULTICHILD_SPECIAL, SupplyType.NEWCOUPLE_SPECIAL, SupplyType.NATIONAL_MERIT + )); + map.put(NoticeType.PUBLIC_RENTAL, List.of( + SupplyType.STUDENT_SPECIAL, SupplyType.YOUTH_SPECIAL, SupplyType.ELDER_SPECIAL, + SupplyType.ELDER_SUPPORT_SPECIAL, SupplyType.MULTICHILD_SPECIAL, + SupplyType.NEWCOUPLE_SPECIAL, SupplyType.FIRST_SPECIAL, SupplyType.GENERAL + )); + map.put(NoticeType.HAPPY_HOUSING, List.of( + SupplyType.STUDENT_SPECIAL, SupplyType.YOUTH_SPECIAL, SupplyType.ELDER_SPECIAL, + SupplyType.NEWCOUPLE_SPECIAL, SupplyType.GENERAL + )); + RENTAL_SUPPLY_MAP = map; + } + + @Override + public AdminDiagnosticPolicyResponse getPolicy() { + return new AdminDiagnosticPolicyResponse( + buildAgePolicy(), + buildAssetPolicy(), + buildIncomePolicy(), + buildIncomeMatrix() + ); + } + + private AgePolicyResponse buildAgePolicy() { + int elderAge = policyUseCase.elderAge(); + int youthMin = policyUseCase.youthAgeMin(); + int minorMax = policyUseCase.marriedYouthAgeMin() - 1; + int newlyMarriedMax = policyUseCase.newlyMarriedMaxYears(); + + List guides = List.of( + new AgeRangeGuideResponse( + minorMax + "세 이하", + null, minorMax, + List.of(SupplyType.SPECIAL.name()), + List.of(SupplyType.SPECIAL.getValue()) + ), + new AgeRangeGuideResponse( + youthMin + "~39세", + youthMin, 39, + List.of(SupplyType.YOUTH_SPECIAL.name(), SupplyType.NEWCOUPLE_SPECIAL.name(), + SupplyType.FIRST_SPECIAL.name(), SupplyType.MULTICHILD_SPECIAL.name(), + SupplyType.SINGLE_PARENT_SPECIAL.name(), SupplyType.MINOR_SPECIAL.name(), + SupplyType.GENERAL.name()), + List.of(SupplyType.YOUTH_SPECIAL.getValue(), SupplyType.NEWCOUPLE_SPECIAL.getValue(), + SupplyType.FIRST_SPECIAL.getValue(), SupplyType.MULTICHILD_SPECIAL.getValue(), + SupplyType.SINGLE_PARENT_SPECIAL.getValue(), SupplyType.MINOR_SPECIAL.getValue(), + SupplyType.GENERAL.getValue()) + ), + new AgeRangeGuideResponse( + "40~" + (elderAge - 1) + "세", + 40, elderAge - 1, + List.of(SupplyType.NEWCOUPLE_SPECIAL.name(), SupplyType.FIRST_SPECIAL.name(), + SupplyType.MULTICHILD_SPECIAL.name(), SupplyType.SINGLE_PARENT_SPECIAL.name(), + SupplyType.MINOR_SPECIAL.name(), SupplyType.ELDER_SUPPORT_SPECIAL.name(), + SupplyType.GENERAL.name()), + List.of(SupplyType.NEWCOUPLE_SPECIAL.getValue(), SupplyType.FIRST_SPECIAL.getValue(), + SupplyType.MULTICHILD_SPECIAL.getValue(), SupplyType.SINGLE_PARENT_SPECIAL.getValue(), + SupplyType.MINOR_SPECIAL.getValue(), SupplyType.ELDER_SUPPORT_SPECIAL.getValue(), + SupplyType.GENERAL.getValue()) + ), + new AgeRangeGuideResponse( + elderAge + "세 이상", + elderAge, null, + List.of(SupplyType.ELDER_SPECIAL.name(), SupplyType.ELDER_SUPPORT_SPECIAL.name(), + SupplyType.NEWCOUPLE_SPECIAL.name(), SupplyType.FIRST_SPECIAL.name(), + SupplyType.MULTICHILD_SPECIAL.name(), SupplyType.GENERAL.name()), + List.of(SupplyType.ELDER_SPECIAL.getValue(), SupplyType.ELDER_SUPPORT_SPECIAL.getValue(), + SupplyType.NEWCOUPLE_SPECIAL.getValue(), SupplyType.FIRST_SPECIAL.getValue(), + SupplyType.MULTICHILD_SPECIAL.getValue(), SupplyType.GENERAL.getValue()) + ) + ); + + return new AgePolicyResponse(elderAge, youthMin, 39, minorMax, newlyMarriedMax, guides); + } + + private AssetPolicyResponse buildAssetPolicy() { + long carMax = policyUseCase.checkMaxCarValue(); + + List limits = RENTAL_ORDER.stream() + .map(rental -> { + long won = policyUseCase.maxTotalAsset(SupplyType.GENERAL, rental, 1); + return new RentalAssetLimitResponse( + rental.name(), + rental.getValue(), + ASSET_TYPE_MAP.getOrDefault(rental, "총자산"), + won, + formatWon(won) + ); + }) + .toList(); + + return new AssetPolicyResponse(carMax, formatWon(carMax), limits); + } + + private List buildIncomePolicy() { + return RENTAL_ORDER.stream() + .map(rental -> { + List supplies = RENTAL_SUPPLY_MAP.getOrDefault(rental, List.of()); + List ratios = supplies.stream() + .map(supply -> new SupplyIncomeRatioResponse( + supply.name(), + supply.getValue(), + policyUseCase.maxIncomeRatio(supply, rental, 1), + policyUseCase.maxIncomeRatio(supply, rental, 2), + policyUseCase.maxIncomeRatio(supply, rental, 3) + )) + .toList(); + + return new RentalIncomePolicyResponse( + rental.name(), + rental.getValue(), + INCOME_STANDARD_MAP.getOrDefault(rental, "기준중위소득"), + ratios + ); + }) + .toList(); + } + + private IncomeMatrixResponse buildIncomeMatrix() { + /// 매트릭스에 포함할 공급 유형 (전체 임대 유형에 걸쳐 등장하는 것들의 합집합, 표시 순서 유지) + List allSupplyTypes = new ArrayList<>(); + Set seen = new java.util.LinkedHashSet<>(); + RENTAL_ORDER.forEach(rental -> + RENTAL_SUPPLY_MAP.getOrDefault(rental, List.of()).forEach(s -> { + if (seen.add(s)) { + allSupplyTypes.add(s); + } + }) + ); + + /// 열 헤더 + List rentalHeaders = RENTAL_ORDER.stream() + .map(r -> new MatrixHeader(r.name(), r.getValue())) + .toList(); + + /// 행 구성 + List rows = allSupplyTypes.stream() + .map(supply -> { + List cells = RENTAL_ORDER.stream() + .map(rental -> { + boolean applicable = RENTAL_SUPPLY_MAP + .getOrDefault(rental, List.of()) + .contains(supply); + + if (!applicable) { + return new IncomeMatrixCellResponse(rental.name(), false, null, -1, -1, -1); + } + + return new IncomeMatrixCellResponse( + rental.name(), + true, + INCOME_STANDARD_MAP.getOrDefault(rental, "기준중위소득"), + policyUseCase.maxIncomeRatio(supply, rental, 1), + policyUseCase.maxIncomeRatio(supply, rental, 2), + policyUseCase.maxIncomeRatio(supply, rental, 3) + ); + }) + .toList(); + + return new IncomeMatrixRowResponse(supply.name(), supply.getValue(), cells); + }) + .toList(); + + return new IncomeMatrixResponse(rentalHeaders, rows); + } + + private String formatWon(long won) { + long uk = won / 100_000_000L; + long man = (won % 100_000_000L) / 10_000L; + + if (uk > 0 && man > 0) { + return uk + "억 " + String.format("%,d", man) + "만원"; + } else if (uk > 0) { + return uk + "억원"; + } else { + return String.format("%,d", man) + "만원"; + } + } +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/admin/diagnostic/application/usecase/AdminDiagnosticPolicyUseCase.java b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/diagnostic/application/usecase/AdminDiagnosticPolicyUseCase.java new file mode 100644 index 00000000..8514f46e --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/diagnostic/application/usecase/AdminDiagnosticPolicyUseCase.java @@ -0,0 +1,8 @@ +package co.kr.pinhouse.domain.admin.diagnostic.application.usecase; + +import co.kr.pinhouse.domain.admin.diagnostic.application.dto.response.AdminDiagnosticPolicyResponse; + +public interface AdminDiagnosticPolicyUseCase { + + AdminDiagnosticPolicyResponse getPolicy(); +} diff --git a/module-presentation/src/main/java/co/kr/pinhouse/domain/admin/diagnostic/AdminDiagnosticPolicyApi.java b/module-presentation/src/main/java/co/kr/pinhouse/domain/admin/diagnostic/AdminDiagnosticPolicyApi.java new file mode 100644 index 00000000..85f4dff8 --- /dev/null +++ b/module-presentation/src/main/java/co/kr/pinhouse/domain/admin/diagnostic/AdminDiagnosticPolicyApi.java @@ -0,0 +1,30 @@ +package co.kr.pinhouse.domain.admin.diagnostic; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import co.kr.pinhouse.common.response.ApiResponse; +import co.kr.pinhouse.domain.admin.diagnostic.application.dto.response.AdminDiagnosticPolicyResponse; +import co.kr.pinhouse.domain.admin.diagnostic.application.usecase.AdminDiagnosticPolicyUseCase; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/v1/admin/diagnostic/policy") +@RequiredArgsConstructor +@Tag(name = "관리자 청약진단 정책 API", description = "청약진단 규칙의 기준값(나이·소득·자산) 조회 API") +public class AdminDiagnosticPolicyApi { + + private final AdminDiagnosticPolicyUseCase adminDiagnosticPolicyService; + + @GetMapping + @Operation( + summary = "청약진단 정책 설정값 조회", + description = "현재 적용 중인 청약진단 기준값(나이 기준, 자산 한도, 임대유형별 소득 비율)을 조회합니다." + ) + public ApiResponse getPolicy() { + return ApiResponse.ok(adminDiagnosticPolicyService.getPolicy()); + } +}