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 일반 제도를 "적격"으로 내보내는 건 낙관적이다 — 나이·소득·지역을 아직 못 본다
- * ({@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;
}