diff --git a/backend/src/main/java/com/dday/domain/welfare/collector/curation/RegionCodeResolver.java b/backend/src/main/java/com/dday/domain/welfare/collector/curation/RegionCodeResolver.java index e8d4ed0..af3fb6e 100644 --- a/backend/src/main/java/com/dday/domain/welfare/collector/curation/RegionCodeResolver.java +++ b/backend/src/main/java/com/dday/domain/welfare/collector/curation/RegionCodeResolver.java @@ -6,9 +6,9 @@ * 제도의 지역명({@code ctpvNm} 시도 / {@code sggNm} 시군구)을 법정동 코드로 바꾼다. * 결과는 {@code welfare_program.region_code}에 들어가고, 나중에 유저 거주지와 대조하는 데 쓴다. * - *

v1 — 시도 단위(2자리)만. 시군구 5자리 정밀도는 자격 매칭이 실제로 붙을 때 넣는다. - * 지금은 이 값을 읽는 쪽이 없어서(매칭 미구현), "용산구 사업"이 "서울(11)"로 풀려도 문제가 없다. - * 분해능을 올리면 {@code refreshRegionCode()}가 매 수집마다 전 행을 다시 파생하므로 자동 반영된다. + *

시도 2자리가 기본, 일부 시군구만 10자리. 자격 매칭이 먼저 붙는 용산구·서대문구만 + * {@link #SIGUNGU}에 10자리로 넣고 나머지는 시도로 떨어뜨린다. 대상 구가 늘거나 전국 매핑 + * 테이블이 생기면 {@code refreshRegionCode()}가 매 수집마다 전 행을 다시 파생하므로 자동 반영된다. * *

표에 없는 시도명(오타·신설 미반영)은 {@code null}을 돌려주고, LOCAL 제도가 이러면 * {@link WelfareIssue#REGION_UNRESOLVED}로 리뷰 큐에 올린다 — 값을 추측해 넣지 않는다. @@ -42,18 +42,42 @@ public final class RegionCodeResolver { Map.entry("강원도", "51"), Map.entry("전라북도", "52")); + /** + * {@code "시도|시군구"} → 법정동 10자리 코드. 지역 매칭이 시군구 단위로 붙는 구만 넣는다. + * 여기 없으면 {@link #resolve}는 시도 2자리로 떨어진다. + * + *

프론트 {@code stores/onboarding.ts}의 {@code HARDCODED_REGION_CODES}와 값이 같아야 한다. + * 유저는 그 코드로, 제도는 이 표로 각각 채워져 자격 판별이 두 값을 접두 비교한다. + * 전국 법정동 매핑 테이블이 생기면 이 표와 프론트 하드코딩을 둘 다 지운다. + */ + private static final Map SIGUNGU = Map.of( + "서울특별시|용산구", "1117000000", + "서울특별시|서대문구", "1141000000"); + private RegionCodeResolver() { } /** + * 지역명을 법정동 코드로 푼다. {@link #SIGUNGU}에 있는 시군구면 10자리, 아니면 시도 2자리. + * * @param ctpvNm 시도명. {@code null}이면(CENTRAL/전국) {@code null} 반환 - * @param sggNm 시군구명. v1에서는 쓰지 않는다 (시도 단위 코드만) - * @return 법정동 2자리 시도 코드, 또는 못 풀면 {@code null} + * @param sggNm 시군구명. {@link #SIGUNGU}에 등록된 곳만 10자리로, 나머지는 무시 + * @return 법정동 코드(2자리 또는 10자리), 또는 못 풀면 {@code null} */ public static String resolve(String ctpvNm, String sggNm) { if (ctpvNm == null || ctpvNm.isBlank()) { return null; } - return SIDO.get(ctpvNm.strip()); + String sido = SIDO.get(ctpvNm.strip()); + if (sido == null) { + return null; + } + if (sggNm != null && !sggNm.isBlank()) { + String sigungu = SIGUNGU.get(ctpvNm.strip() + "|" + sggNm.strip()); + if (sigungu != null) { + return sigungu; + } + } + return sido; } } diff --git a/backend/src/main/java/com/dday/domain/welfare/collector/eligibility/EligibilityEvaluator.java b/backend/src/main/java/com/dday/domain/welfare/collector/eligibility/EligibilityEvaluator.java index a7c46cd..a1c62b1 100644 --- a/backend/src/main/java/com/dday/domain/welfare/collector/eligibility/EligibilityEvaluator.java +++ b/backend/src/main/java/com/dday/domain/welfare/collector/eligibility/EligibilityEvaluator.java @@ -1,6 +1,7 @@ package com.dday.domain.welfare.collector.eligibility; import com.dday.domain.user.entity.User; +import com.dday.domain.welfare.collector.curation.RegionCodeResolver; import com.dday.domain.welfare.entity.ProtectionPhase; import com.dday.domain.welfare.entity.WelfareProgram; import org.springframework.stereotype.Component; @@ -21,10 +22,13 @@ * 청년월세·청년적금처럼 자립준비청년이 아니어도 받는 제도가 여기 해당한다 * * - *

일반 제도를 "적격"으로 내보내는 건 낙관적이다 — 나이·소득·지역을 아직 못 본다 - * ({@code User}에 생년월일이 없고, 소득 기준은 {@code eligibility_criteria}가, 지역은 코드↔이름 - * 매핑 테이블이 필요하다). 그래서 홈은 이걸 곧장 "받는 지원"이 아니라 "확인 필요"로 흘려보내 - * 사용자에게 되묻는다. 정밀 판정은 후속. + *

지역은 법정동 코드 접두 비교로 본다: 지역 한정 제도({@code region_code != null})는 거주지가 + * 그 코드 범위 안에 들어야 적격이고({@code userCode.startsWith(programCode)}), 들면 {@code region}을 + * 근거로 남긴다. 제도는 시·도(2자리) 또는 등록된 시·군·구(10자리, {@code RegionCodeResolver.SIGUNGU}) + * 단위다. 전국·중앙부처 제도({@code region_code == null})는 지역을 보지 않는다. 나이·소득은 아직 + * 못 본다({@code User}에 생년월일이 없고, 소득 기준은 {@code eligibility_criteria}가 필요하다). + * 그래서 홈은 이걸 곧장 "받는 지원"이 아니라 "확인 필요"로 흘려보내 사용자에게 되묻는다. + * 정밀 판정은 후속. */ @Component public class EligibilityEvaluator { @@ -48,6 +52,23 @@ public EligibilityResult evaluate(User user, WelfareProgram program) { matched.add("protectionPhase"); } } + + // 지역 게이트: 지역 한정 제도(region_code != null)는 거주지가 그 안에 들어야 한다. + // 제도 코드가 유저 법정동 코드의 접두면 포함으로 본다: + // 제도 "11"(서울) ⊃ 유저 "1117000000"(용산) → 매칭 + // 제도 "1141000000"(서대문) vs 유저 "1117000000"(용산) → 불일치 + // 유저 region_code는 형식이 들쭉날쭉해(시드=코드, 온보딩=문자열) 신뢰하지 않고, + // region_name+district_name을 RegionCodeResolver로 다시 푼다. + String programRegion = program.getRegionCode(); + if (programRegion != null && !programRegion.isBlank()) { + String userRegion = RegionCodeResolver.resolve(user.getRegionName(), user.getDistrictName()); + if (userRegion == null || !userRegion.startsWith(programRegion)) { + return EligibilityResult.ineligible(matched, + "%s 거주자 대상 제도입니다.".formatted(program.getCtpvNm())); + } + matched.add("region"); + } + return EligibilityResult.eligible(matched); } } diff --git a/backend/src/test/java/com/dday/domain/welfare/collector/curation/RegionCodeResolverTest.java b/backend/src/test/java/com/dday/domain/welfare/collector/curation/RegionCodeResolverTest.java index d1338d4..164186d 100644 --- a/backend/src/test/java/com/dday/domain/welfare/collector/curation/RegionCodeResolverTest.java +++ b/backend/src/test/java/com/dday/domain/welfare/collector/curation/RegionCodeResolverTest.java @@ -5,15 +5,23 @@ import static org.assertj.core.api.Assertions.assertThat; /** - * 지역명 → 법정동 시도 코드. v1은 시도 2자리만. + * 지역명 → 법정동 코드. 시도 2자리가 기본, 등록된 시군구는 10자리. */ class RegionCodeResolverTest { @Test void 시도명을_2자리_코드로_푼다() { - assertThat(RegionCodeResolver.resolve("서울특별시", "용산구")).isEqualTo("11"); assertThat(RegionCodeResolver.resolve("서울특별시", null)).isEqualTo("11"); assertThat(RegionCodeResolver.resolve("경기도", "수원시")).isEqualTo("41"); + // 등록 안 된 시군구는 시도로 떨어진다 + assertThat(RegionCodeResolver.resolve("서울특별시", "강남구")).isEqualTo("11"); + } + + @Test + void 등록된_시군구는_10자리_법정동_코드() { + // 프론트 stores/onboarding.ts HARDCODED_REGION_CODES와 같아야 한다 + assertThat(RegionCodeResolver.resolve("서울특별시", "용산구")).isEqualTo("1117000000"); + assertThat(RegionCodeResolver.resolve("서울특별시", "서대문구")).isEqualTo("1141000000"); } @Test diff --git a/backend/src/test/java/com/dday/domain/welfare/collector/eligibility/EligibilityEvaluatorTest.java b/backend/src/test/java/com/dday/domain/welfare/collector/eligibility/EligibilityEvaluatorTest.java index fc9c9bc..4923473 100644 --- a/backend/src/test/java/com/dday/domain/welfare/collector/eligibility/EligibilityEvaluatorTest.java +++ b/backend/src/test/java/com/dday/domain/welfare/collector/eligibility/EligibilityEvaluatorTest.java @@ -57,9 +57,79 @@ class EligibilityEvaluatorTest { assertThat(result.matchedCriteria()).containsExactly("protectionEndDate"); } + @Test + void 특정_시도_대상_제도는_거주_시도가_다르면_부적격() { + WelfareProgram seoulOnly = program(null); + ReflectionTestUtils.setField(seoulOnly, "regionCode", "11"); + ReflectionTestUtils.setField(seoulOnly, "ctpvNm", "서울특별시"); + + var result = evaluator.evaluate(user(null, "경기도"), seoulOnly); + + assertThat(result.eligible()).isFalse(); + assertThat(result.ineligibleReason()).contains("서울특별시"); + } + + @Test + void 특정_시도_대상_제도는_거주_시도가_같으면_region을_근거로_남긴다() { + WelfareProgram seoulOnly = program(null); + ReflectionTestUtils.setField(seoulOnly, "regionCode", "11"); + ReflectionTestUtils.setField(seoulOnly, "ctpvNm", "서울특별시"); + + var result = evaluator.evaluate(user(null, "서울특별시"), seoulOnly); + + assertThat(result.eligible()).isTrue(); + assertThat(result.matchedCriteria()).containsExactly("region"); + } + + @Test + void 거주지_미등록이면_특정_시도_대상_제도는_부적격() { + WelfareProgram seoulOnly = program(null); + ReflectionTestUtils.setField(seoulOnly, "regionCode", "11"); + ReflectionTestUtils.setField(seoulOnly, "ctpvNm", "서울특별시"); + + var result = evaluator.evaluate(user(null), seoulOnly); + + assertThat(result.eligible()).isFalse(); + } + + @Test + void 시군구_한정_제도는_같은_구_거주자에게만_적격() { + WelfareProgram yongsanOnly = program(null); + ReflectionTestUtils.setField(yongsanOnly, "regionCode", "1117000000"); + ReflectionTestUtils.setField(yongsanOnly, "ctpvNm", "서울특별시"); + ReflectionTestUtils.setField(yongsanOnly, "sggNm", "용산구"); + + assertThat(evaluator.evaluate(user(null, "서울특별시", "용산구"), yongsanOnly).eligible()).isTrue(); + assertThat(evaluator.evaluate(user(null, "서울특별시", "서대문구"), yongsanOnly).eligible()).isFalse(); + // 시도만 아는 서울 거주자는 용산 한정 제도를 확정할 수 없다 → 부적격 + assertThat(evaluator.evaluate(user(null, "서울특별시", "강남구"), yongsanOnly).eligible()).isFalse(); + } + + @Test + void 시도_제도는_그_시도의_시군구_거주자도_적격() { + WelfareProgram seoulWide = program(null); + ReflectionTestUtils.setField(seoulWide, "regionCode", "11"); + ReflectionTestUtils.setField(seoulWide, "ctpvNm", "서울특별시"); + + var result = evaluator.evaluate(user(null, "서울특별시", "용산구"), seoulWide); + + assertThat(result.eligible()).isTrue(); + assertThat(result.matchedCriteria()).containsExactly("region"); + } + private static User user(LocalDate protectionEndDate) { + return user(protectionEndDate, null, null); + } + + private static User user(LocalDate protectionEndDate, String regionName) { + return user(protectionEndDate, regionName, null); + } + + private static User user(LocalDate protectionEndDate, String regionName, String districtName) { User u = BeanUtils.instantiateClass(User.class); ReflectionTestUtils.setField(u, "protectionEndDate", protectionEndDate); + ReflectionTestUtils.setField(u, "regionName", regionName); + ReflectionTestUtils.setField(u, "districtName", districtName); return u; }