ITN Dev 07-23
feat: 담당자 중복 방지 조회와 수행기관 구분을 추가
시드와 회원명단 업로드가 같은 사람을 여러 기관 행에서 반복해 만나도(예: 경찰청
계열 27곳이 담당자 1명을 공유) 중복 생성하지 않도록 (구분,성명,전화,이메일) 기준
조회(ContactMapper.findMatching)를 추가하고 SeedService가 이를 쓰도록 바꿨다.
담당자 구분에 수행기관(OPERATOR)을 추가하고, 회원명단의 신청기관 자동배정을 위해
기관명 단독 조회(OrganizationMapper.findByOrgName)를 추가했다.
@a600101e20d0892c3f1b53fd51dcfbe903c907c5
src/main/java/kr/itn/itnhub/config/GlobalExceptionHandler.java
--- src/main/java/kr/itn/itnhub/config/GlobalExceptionHandler.java
+++ src/main/java/kr/itn/itnhub/config/GlobalExceptionHandler.java
@@ -45,7 +45,7 @@
  * <p>{@link ContactNotFoundException}은 존재하지 않는 담당자 id로 조회/수정/삭제를
  * 시도했을 때 던진다 - URL의 id가 단순히 틀린 것뿐이므로 404가 맞다.</p>
  *
- * <p>{@link InvalidContactException}은 담당자 구분(category)이 APPLICANT/MJ/LAWYER
+ * <p>{@link InvalidContactException}은 담당자 구분(category)이 APPLICANT/MJ/LAWYER/OPERATOR
  * 중 하나가 아니거나, 기관에 배정하려는 담당자의 구분이 그 역할과 맞지 않을 때
  * 던진다 - 흔한 사용자 실수이므로 400을 쓴다.</p>
  *
src/main/java/kr/itn/itnhub/contact/ContactMapper.java
--- src/main/java/kr/itn/itnhub/contact/ContactMapper.java
+++ src/main/java/kr/itn/itnhub/contact/ContactMapper.java
@@ -13,6 +13,14 @@
 
     Contact findById(@Param("id") Long id);
 
+    /**
+     * (구분, 성명, 전화, 이메일)이 모두 같은 기존 담당자를 찾는다(전화/이메일은 null-safe
+     * 비교). 시드·회원명단 업로드가 같은 사람을 재입력해도 중복 생성하지 않고 재사용하기
+     * 위한 조회다.
+     */
+    Contact findMatching(@Param("category") String category, @Param("name") String name,
+                          @Param("phone") String phone, @Param("email") String email);
+
     int insert(Contact contact);
 
     int update(Contact contact);
src/main/java/kr/itn/itnhub/contact/ContactRequest.java
--- src/main/java/kr/itn/itnhub/contact/ContactRequest.java
+++ src/main/java/kr/itn/itnhub/contact/ContactRequest.java
@@ -4,7 +4,7 @@
 
 /**
  * 담당자 추가/수정 요청. category는 여기서는 형식(공백 아님)만 검증하고,
- * APPLICANT/MJ/LAWYER 중 하나인지는 {@link ContactService}가 검증해
+ * APPLICANT/MJ/LAWYER/OPERATOR 중 하나인지는 {@link ContactService}가 검증해
  * "구분 값이 올바르지 않습니다" 같은 도메인 메시지를 붙일 수 있게 한다.
  */
 public record ContactRequest(
src/main/java/kr/itn/itnhub/contact/ContactService.java
--- src/main/java/kr/itn/itnhub/contact/ContactService.java
+++ src/main/java/kr/itn/itnhub/contact/ContactService.java
@@ -9,7 +9,7 @@
 @Service
 public class ContactService {
 
-    private static final Set<String> CATEGORIES = Set.of("APPLICANT", "MJ", "LAWYER");
+    private static final Set<String> CATEGORIES = Set.of("APPLICANT", "MJ", "LAWYER", "OPERATOR");
 
     private final ContactMapper mapper;
 
src/main/java/kr/itn/itnhub/org/OrganizationMapper.java
--- src/main/java/kr/itn/itnhub/org/OrganizationMapper.java
+++ src/main/java/kr/itn/itnhub/org/OrganizationMapper.java
@@ -15,6 +15,9 @@
     /** 연번+기관명 유니크 키로 단건 조회. 시드가 upsert 직후 방금 반영된 행을 다시 읽을 때 쓴다. */
     Organization findByOrgNoAndOrgName(@Param("orgNo") String orgNo, @Param("orgName") String orgName);
 
+    /** 기관명 단독(정확 일치) 조회. 회원명단 업로드가 신청기관 담당자를 자동 배정할 때 쓴다. */
+    Organization findByOrgName(@Param("orgName") String orgName);
+
     /** 시드 전용. 비어 있는 칸만 채우고 채널ID·기입된 값은 그대로 둔다. */
     int upsertBySeed(Organization org);
 
src/main/java/kr/itn/itnhub/seed/SeedService.java
--- src/main/java/kr/itn/itnhub/seed/SeedService.java
+++ src/main/java/kr/itn/itnhub/seed/SeedService.java
@@ -66,8 +66,12 @@
 
     /**
      * 시트에 담당자 이름이 있고 그 기관이 아직 신청기관 담당자가 배정되지 않았을 때만
-     * 새 담당자를 만들어 연결한다. 이미 배정돼 있으면(담당자관리 화면에서 사람이 고쳤을 수도
+     * 담당자를 연결한다. 이미 배정돼 있으면(담당자관리 화면에서 사람이 고쳤을 수도
      * 있는 값이므로) 절대 건드리지 않는다 - 재시드가 배정을 되돌리거나 중복 생성하면 안 된다.
+     *
+     * <p>연결할 담당자는 새로 만들기 전에 (구분, 성명, 전화, 이메일)이 같은 기존 담당자가
+     * 있는지 먼저 찾는다 - 경찰청 계열 27개 하위기관이 같은 담당자를 공유하는 것처럼,
+     * 여러 기관 행이 동일 인물을 가리키는 경우 재시드 때마다 담당자가 중복 생성되면 안 된다.</p>
      */
     private void linkApplicantContactIfMissing(SeedRow row) {
         if (row.managerName() == null) {
@@ -79,15 +83,19 @@
             return;
         }
 
-        Contact contact = new Contact();
-        contact.setCategory("APPLICANT");
-        contact.setName(row.managerName());
-        contact.setAffiliation(row.orgName());
-        contact.setDeptName(row.deptName());
-        contact.setTitle(row.managerTitle());
-        contact.setPhone(row.managerPhone());
-        contact.setEmail(row.managerEmail());
-        contactMapper.insert(contact);
+        Contact contact = contactMapper.findMatching(
+                "APPLICANT", row.managerName(), row.managerPhone(), row.managerEmail());
+        if (contact == null) {
+            contact = new Contact();
+            contact.setCategory("APPLICANT");
+            contact.setName(row.managerName());
+            contact.setAffiliation(row.orgName());
+            contact.setDeptName(row.deptName());
+            contact.setTitle(row.managerTitle());
+            contact.setPhone(row.managerPhone());
+            contact.setEmail(row.managerEmail());
+            contactMapper.insert(contact);
+        }
 
         mapper.updateAssignments(saved.getId(), contact.getId(),
                 saved.getMjContactId(), saved.getLawyerContactId(), saved.getLawyerAssignedDate());
src/main/resources/mapper/ContactMapper.xml
--- src/main/resources/mapper/ContactMapper.xml
+++ src/main/resources/mapper/ContactMapper.xml
@@ -22,6 +22,16 @@
     where id = #{id}
   </select>
 
+  <select id="findMatching" resultType="kr.itn.itnhub.contact.Contact">
+    select <include refid="columns"/>
+    from contact
+    where category = #{category}
+      and name = #{name}
+      and phone is not distinct from #{phone}
+      and email is not distinct from #{email}
+    limit 1
+  </select>
+
   <insert id="insert" parameterType="kr.itn.itnhub.contact.Contact"
           useGeneratedKeys="true" keyProperty="id">
     insert into contact (category, name, affiliation, dept_name, title, phone, email)
src/main/resources/mapper/OrganizationMapper.xml
--- src/main/resources/mapper/OrganizationMapper.xml
+++ src/main/resources/mapper/OrganizationMapper.xml
@@ -94,6 +94,13 @@
     where o.org_no = #{orgNo} and o.org_name = #{orgName}
   </select>
 
+  <select id="findByOrgName" resultMap="organizationResultMap">
+    select <include refid="joinedColumns"/>
+    <include refid="joins"/>
+    where o.org_name = #{orgName}
+    limit 1
+  </select>
+
   <!--
     시드 upsert. 담당자 정보는 이제 여기서 다루지 않는다(담당자관리에서만 입력) -
     SeedService가 upsert 이후 별도로 신청기관 담당자 연결 여부를 확인해 필요할 때만
src/test/java/kr/itn/itnhub/seed/SeedServiceTest.java
--- src/test/java/kr/itn/itnhub/seed/SeedServiceTest.java
+++ src/test/java/kr/itn/itnhub/seed/SeedServiceTest.java
@@ -171,6 +171,29 @@
     }
 
     @Test
+    void 같은_담당자를_공유하는_하위기관_여러곳은_담당자를_한_행만_만들어_모두_연결한다() throws Exception {
+        byte[] xlsx = workbook(
+                new String[]{"008", "00", "경찰청", "데이터정책계", "박진우", "경위", "02-3150-3205", "hi@police.go.kr"},
+                new String[]{"008", "02", "서울청", "데이터정책계", "박진우", "경위", "02-3150-3205", "hi@police.go.kr"},
+                new String[]{"008", "03", "부산청", "데이터정책계", "박진우", "경위", "02-3150-3205", "hi@police.go.kr"});
+
+        SeedReport report = seedService.seed(new ByteArrayInputStream(xlsx));
+
+        assertThat(report.created()).isEqualTo(3);
+        assertThat(jdbc.queryForObject("select count(*) from contact", Integer.class)).isEqualTo(1);
+
+        List<Organization> all = mapper.findAll();
+        assertThat(all).hasSize(3);
+        assertThat(all).extracting(Organization::getStatus)
+                .containsOnly(OrgStatus.READY);
+
+        Long contactId = all.get(0).getApplicantContactId();
+        assertThat(contactId).isNotNull();
+        assertThat(all).extracting(Organization::getApplicantContactId)
+                .containsOnly(contactId);
+    }
+
+    @Test
     void 이미_다른_담당자로_배정된_기관은_재시드가_배정을_바꾸지_않는다() throws Exception {
         byte[] xlsx = workbook(
                 new String[]{"001", "00", "국제방송교류재단", "팀", "송민지", "과장", "02-1", "a@b.kr"});
Add a comment
List