Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
* 제도의 지역명({@code ctpvNm} 시도 / {@code sggNm} 시군구)을 법정동 코드로 바꾼다.
* 결과는 {@code welfare_program.region_code}에 들어가고, 나중에 유저 거주지와 대조하는 데 쓴다.
*
* <p><b>v1 — 시도 단위(2자리)만.</b> 시군구 5자리 정밀도는 자격 매칭이 실제로 붙을 때 넣는다.
* 지금은 이 값을 읽는 쪽이 없어서(매칭 미구현), "용산구 사업"이 "서울(11)"로 풀려도 문제가 없다.
* 분해능을 올리면 {@code refreshRegionCode()}가 매 수집마다 전 행을 다시 파생하므로 자동 반영된다.
* <p><b>시도 2자리가 기본, 일부 시군구만 10자리.</b> 자격 매칭이 먼저 붙는 용산구·서대문구만
* {@link #SIGUNGU}에 10자리로 넣고 나머지는 시도로 떨어뜨린다. 대상 구가 늘거나 전국 매핑
* 테이블이 생기면 {@code refreshRegionCode()}가 매 수집마다 전 행을 다시 파생하므로 자동 반영된다.
*
* <p>표에 없는 시도명(오타·신설 미반영)은 {@code null}을 돌려주고, LOCAL 제도가 이러면
* {@link WelfareIssue#REGION_UNRESOLVED}로 리뷰 큐에 올린다 — 값을 추측해 넣지 않는다.
Expand Down Expand Up @@ -42,18 +42,42 @@ public final class RegionCodeResolver {
Map.entry("강원도", "51"),
Map.entry("전라북도", "52"));

/**
* {@code "시도|시군구"} → 법정동 10자리 코드. 지역 매칭이 시군구 단위로 붙는 구만 넣는다.
* 여기 없으면 {@link #resolve}는 시도 2자리로 떨어진다.
*
* <p><b>프론트 {@code stores/onboarding.ts}의 {@code HARDCODED_REGION_CODES}와 값이 같아야 한다.</b>
* 유저는 그 코드로, 제도는 이 표로 각각 채워져 자격 판별이 두 값을 접두 비교한다.
* 전국 법정동 매핑 테이블이 생기면 이 표와 프론트 하드코딩을 둘 다 지운다.
*/
private static final Map<String, String> 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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -21,10 +22,13 @@
* 청년월세·청년적금처럼 자립준비청년이 아니어도 받는 제도가 여기 해당한다</li>
* </ul>
*
* <p>일반 제도를 "적격"으로 내보내는 건 낙관적이다 — 나이·소득·지역을 아직 못 본다
* ({@code User}에 생년월일이 없고, 소득 기준은 {@code eligibility_criteria}가, 지역은 코드↔이름
* 매핑 테이블이 필요하다). 그래서 홈은 이걸 곧장 "받는 지원"이 아니라 "확인 필요"로 흘려보내
* 사용자에게 되묻는다. 정밀 판정은 후속.
* <p>지역은 법정동 코드 접두 비교로 본다: 지역 한정 제도({@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 {
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down