ITN Dev 07-24
feat: 기관 배정에 아이티앤 담당자(ITN) 역할을 추가
신청기관/문정원/변호사 3종 배정과 동일한 패턴으로 itn_contact_id 컬럼과
배정 검증(구분 불일치 400), 조인 조회를 추가한다. 기존 배정 API/시드/
회원명단 자동배정 호출부의 updateAssignments 시그니처를 갱신하고,
관련 테스트를 확장한다.
@765fc8b17ffc194d5bfc913177d5cfd803425ed7
src/main/java/kr/itn/itnhub/contact/MemberImportService.java
--- src/main/java/kr/itn/itnhub/contact/MemberImportService.java
+++ src/main/java/kr/itn/itnhub/contact/MemberImportService.java
@@ -97,7 +97,8 @@
             return false;
         }
         orgMapper.updateAssignments(org.getId(), contact.getId(),
-                org.getMjContactId(), org.getLawyerContactId(), org.getLawyerAssignedDate());
+                org.getMjContactId(), org.getItnContactId(),
+                org.getLawyerContactId(), org.getLawyerAssignedDate());
         return true;
     }
 }
src/main/java/kr/itn/itnhub/org/AssignmentRequest.java
--- src/main/java/kr/itn/itnhub/org/AssignmentRequest.java
+++ src/main/java/kr/itn/itnhub/org/AssignmentRequest.java
@@ -1,13 +1,14 @@
 package kr.itn.itnhub.org;
 
 /**
- * 채널관리 화면에서 담당자 3종(신청기관/문정원/변호사)을 배정한다.
- * null은 "그 역할의 배정을 해제한다"는 뜻이며, 매 호출마다 세 배정값 + 배정일을 모두 보낸다
+ * 채널관리 화면에서 담당자 4종(신청기관/문정원/아이티앤/변호사)을 배정한다.
+ * null은 "그 역할의 배정을 해제한다"는 뜻이며, 매 호출마다 네 배정값 + 배정일을 모두 보낸다
  * (부분 갱신이 아니다 - PUT 하나가 항상 현재 상태 전체를 반영한다).
  */
 public record AssignmentRequest(
         Long applicantContactId,
         Long mjContactId,
+        Long itnContactId,
         Long lawyerContactId,
         String lawyerAssignedDate) {
 }
src/main/java/kr/itn/itnhub/org/OrgResponse.java
--- src/main/java/kr/itn/itnhub/org/OrgResponse.java
+++ src/main/java/kr/itn/itnhub/org/OrgResponse.java
@@ -9,6 +9,7 @@
         OrgStatus status,
         Contact applicant,
         Contact mj,
+        Contact itn,
         Contact lawyer,
         String lawyerAssignedDate,
         String channelIdMj,
@@ -22,6 +23,7 @@
                 org.getStatus(),
                 org.getApplicant(),
                 org.getMj(),
+                org.getItn(),
                 org.getLawyer(),
                 org.getLawyerAssignedDate(),
                 org.getChannelIdMj(),
src/main/java/kr/itn/itnhub/org/Organization.java
--- src/main/java/kr/itn/itnhub/org/Organization.java
+++ src/main/java/kr/itn/itnhub/org/Organization.java
@@ -10,6 +10,7 @@
     private String channelSlug;
     private Long applicantContactId;
     private Long mjContactId;
+    private Long itnContactId;
     private Long lawyerContactId;
     private String lawyerAssignedDate;
     private String channelIdMj;
@@ -18,6 +19,7 @@
     /** findAll/findById가 contact 테이블과 조인해 채워준다. 시드/upsert 경로에서는 비어 있다. */
     private Contact applicant;
     private Contact mj;
+    private Contact itn;
     private Contact lawyer;
 
     /**
@@ -64,6 +66,9 @@
     public Long getMjContactId() { return mjContactId; }
     public void setMjContactId(Long mjContactId) { this.mjContactId = mjContactId; }
 
+    public Long getItnContactId() { return itnContactId; }
+    public void setItnContactId(Long itnContactId) { this.itnContactId = itnContactId; }
+
     public Long getLawyerContactId() { return lawyerContactId; }
     public void setLawyerContactId(Long lawyerContactId) { this.lawyerContactId = lawyerContactId; }
 
@@ -82,6 +87,9 @@
     public Contact getMj() { return mj; }
     public void setMj(Contact mj) { this.mj = mj; }
 
+    public Contact getItn() { return itn; }
+    public void setItn(Contact itn) { this.itn = itn; }
+
     public Contact getLawyer() { return lawyer; }
     public void setLawyer(Contact lawyer) { this.lawyer = lawyer; }
 }
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
@@ -24,6 +24,7 @@
     int updateAssignments(@Param("id") Long id,
                           @Param("applicantContactId") Long applicantContactId,
                           @Param("mjContactId") Long mjContactId,
+                          @Param("itnContactId") Long itnContactId,
                           @Param("lawyerContactId") Long lawyerContactId,
                           @Param("lawyerAssignedDate") String lawyerAssignedDate);
 
src/main/java/kr/itn/itnhub/org/OrganizationService.java
--- src/main/java/kr/itn/itnhub/org/OrganizationService.java
+++ src/main/java/kr/itn/itnhub/org/OrganizationService.java
@@ -24,8 +24,8 @@
     }
 
     /**
-     * 담당자관리에서 이미 등록된 담당자를 신청기관/문정원/변호사 역할에 배정한다.
-     * null인 필드는 그 역할의 배정을 해제한다는 뜻이며, 매 호출은 세 배정값과
+     * 담당자관리에서 이미 등록된 담당자를 신청기관/문정원/아이티앤/변호사 역할에 배정한다.
+     * null인 필드는 그 역할의 배정을 해제한다는 뜻이며, 매 호출은 네 배정값과
      * 배정일을 통째로 덮어쓴다(부분 갱신이 아니다).
      */
     @Transactional
@@ -37,10 +37,11 @@
 
         validateAssignment(request.applicantContactId(), "APPLICANT", "신청기관 담당자");
         validateAssignment(request.mjContactId(), "MJ", "문정원 담당자");
+        validateAssignment(request.itnContactId(), "ITN", "아이티앤 담당자");
         validateAssignment(request.lawyerContactId(), "LAWYER", "담당 변호사");
 
         mapper.updateAssignments(id, request.applicantContactId(), request.mjContactId(),
-                request.lawyerContactId(), request.lawyerAssignedDate());
+                request.itnContactId(), request.lawyerContactId(), request.lawyerAssignedDate());
 
         return OrgResponse.of(mapper.findById(id));
     }
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
@@ -98,7 +98,8 @@
         }
 
         mapper.updateAssignments(saved.getId(), contact.getId(),
-                saved.getMjContactId(), saved.getLawyerContactId(), saved.getLawyerAssignedDate());
+                saved.getMjContactId(), saved.getItnContactId(),
+                saved.getLawyerContactId(), saved.getLawyerAssignedDate());
     }
 
     private String key(String orgNo, String orgName) {
 
src/main/resources/db/migration/V7__itn_assignment.sql (added)
+++ src/main/resources/db/migration/V7__itn_assignment.sql
@@ -0,0 +1,2 @@
+alter table organization
+    add column itn_contact_id bigint references contact (id) on delete set null;
src/main/resources/mapper/OrganizationMapper.xml
--- src/main/resources/mapper/OrganizationMapper.xml
+++ src/main/resources/mapper/OrganizationMapper.xml
@@ -4,9 +4,9 @@
 <mapper namespace="kr.itn.itnhub.org.OrganizationMapper">
 
   <!--
-    담당자 3종은 contact 테이블을 세 번 조인해서 채운다(신청기관/문정원/변호사 배정이 각각
-    다른 contact 행을 가리킬 수 있으므로). 각 조인에 별칭 컬럼을 붙여야 resultMap의
-    association 세 개가 서로 컬럼을 침범하지 않는다.
+    담당자 4종은 contact 테이블을 네 번 조인해서 채운다(신청기관/문정원/아이티앤/변호사 배정이
+    각각 다른 contact 행을 가리킬 수 있으므로). 각 조인에 별칭 컬럼을 붙여야 resultMap의
+    association 네 개가 서로 컬럼을 침범하지 않는다.
   -->
   <resultMap id="organizationResultMap" type="kr.itn.itnhub.org.Organization">
     <id property="id" column="id"/>
@@ -15,6 +15,7 @@
     <result property="channelSlug" column="channel_slug"/>
     <result property="applicantContactId" column="applicant_contact_id"/>
     <result property="mjContactId" column="mj_contact_id"/>
+    <result property="itnContactId" column="itn_contact_id"/>
     <result property="lawyerContactId" column="lawyer_contact_id"/>
     <result property="lawyerAssignedDate" column="lawyer_assigned_date"/>
     <result property="channelIdMj" column="channel_id_mj"/>
@@ -42,6 +43,17 @@
       <result property="email" column="mj_c_email"/>
     </association>
 
+    <association property="itn" javaType="kr.itn.itnhub.contact.Contact">
+      <id property="id" column="itn_c_id"/>
+      <result property="category" column="itn_c_category"/>
+      <result property="name" column="itn_c_name"/>
+      <result property="affiliation" column="itn_c_affiliation"/>
+      <result property="deptName" column="itn_c_dept_name"/>
+      <result property="title" column="itn_c_title"/>
+      <result property="phone" column="itn_c_phone"/>
+      <result property="email" column="itn_c_email"/>
+    </association>
+
     <association property="lawyer" javaType="kr.itn.itnhub.contact.Contact">
       <id property="id" column="lawyer_c_id"/>
       <result property="category" column="lawyer_c_category"/>
@@ -56,7 +68,7 @@
 
   <sql id="joinedColumns">
     o.id, o.org_no, o.org_name, o.channel_slug,
-    o.applicant_contact_id, o.mj_contact_id, o.lawyer_contact_id,
+    o.applicant_contact_id, o.mj_contact_id, o.itn_contact_id, o.lawyer_contact_id,
     o.lawyer_assigned_date, o.channel_id_mj, o.channel_id_law,
     ac.id as applicant_id, ac.category as applicant_category, ac.name as applicant_name,
     ac.affiliation as applicant_affiliation, ac.dept_name as applicant_dept_name,
@@ -64,6 +76,9 @@
     mc.id as mj_c_id, mc.category as mj_c_category, mc.name as mj_c_name,
     mc.affiliation as mj_c_affiliation, mc.dept_name as mj_c_dept_name,
     mc.title as mj_c_title, mc.phone as mj_c_phone, mc.email as mj_c_email,
+    ic.id as itn_c_id, ic.category as itn_c_category, ic.name as itn_c_name,
+    ic.affiliation as itn_c_affiliation, ic.dept_name as itn_c_dept_name,
+    ic.title as itn_c_title, ic.phone as itn_c_phone, ic.email as itn_c_email,
     lc.id as lawyer_c_id, lc.category as lawyer_c_category, lc.name as lawyer_c_name,
     lc.affiliation as lawyer_c_affiliation, lc.dept_name as lawyer_c_dept_name,
     lc.title as lawyer_c_title, lc.phone as lawyer_c_phone, lc.email as lawyer_c_email
@@ -73,6 +88,7 @@
     from organization o
     left join contact ac on ac.id = o.applicant_contact_id
     left join contact mc on mc.id = o.mj_contact_id
+    left join contact ic on ic.id = o.itn_contact_id
     left join contact lc on lc.id = o.lawyer_contact_id
   </sql>
 
@@ -118,6 +134,7 @@
     update organization set
       applicant_contact_id = #{applicantContactId},
       mj_contact_id         = #{mjContactId},
+      itn_contact_id        = #{itnContactId},
       lawyer_contact_id     = #{lawyerContactId},
       lawyer_assigned_date  = #{lawyerAssignedDate},
       updated_at            = now()
src/test/java/kr/itn/itnhub/contact/ContactControllerTest.java
--- src/test/java/kr/itn/itnhub/contact/ContactControllerTest.java
+++ src/test/java/kr/itn/itnhub/contact/ContactControllerTest.java
@@ -166,7 +166,7 @@
         org.setChannelSlug("001");
         orgMapper.upsertBySeed(org);
         Long orgId = orgMapper.findAll().get(0).getId();
-        orgMapper.updateAssignments(orgId, contact.getId(), null, null, null);
+        orgMapper.updateAssignments(orgId, contact.getId(), null, null, null, null);
 
         assertThat(orgMapper.findById(orgId).getStatus().name()).isEqualTo("READY");
 
src/test/java/kr/itn/itnhub/org/OrganizationControllerTest.java
--- src/test/java/kr/itn/itnhub/org/OrganizationControllerTest.java
+++ src/test/java/kr/itn/itnhub/org/OrganizationControllerTest.java
@@ -101,6 +101,7 @@
     void 문정원_담당자와_변호사를_함께_배정하면_응답과_재조회에_모두_반영된다() throws Exception {
         Long applicantId = createContact("APPLICANT", "송민지");
         Long mjId = createContact("MJ", "김문정");
+        Long itnId = createContact("ITN", "박아이티");
         Long lawyerId = createContact("LAWYER", "이변호");
 
         mvc.perform(put("/api/orgs/{id}/assignments", orgId)
@@ -110,18 +111,21 @@
                                 {
                                   "applicantContactId": %d,
                                   "mjContactId": %d,
+                                  "itnContactId": %d,
                                   "lawyerContactId": %d,
                                   "lawyerAssignedDate": "2026-07-21"
                                 }
-                                """.formatted(applicantId, mjId, lawyerId)))
+                                """.formatted(applicantId, mjId, itnId, lawyerId)))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.mj.name").value("김문정"))
+                .andExpect(jsonPath("$.itn.name").value("박아이티"))
                 .andExpect(jsonPath("$.lawyer.name").value("이변호"))
                 .andExpect(jsonPath("$.lawyerAssignedDate").value("2026-07-21"));
 
         mvc.perform(get("/api/orgs"))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$[0].mj.name").value("김문정"))
+                .andExpect(jsonPath("$[0].itn.name").value("박아이티"))
                 .andExpect(jsonPath("$[0].lawyer.name").value("이변호"));
     }
 
@@ -142,7 +146,7 @@
     @Test
     void 배정_해제는_null을_보내면_반영되어_정보대기로_돌아간다() throws Exception {
         Long applicantId = createContact("APPLICANT", "송민지");
-        mapper.updateAssignments(orgId, applicantId, null, null, null);
+        mapper.updateAssignments(orgId, applicantId, null, null, null, null);
 
         mvc.perform(put("/api/orgs/{id}/assignments", orgId)
                         .with(csrf())
@@ -151,6 +155,49 @@
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.status").value("INFO_PENDING"))
                 .andExpect(jsonPath("$.applicant").value(nullValue()));
+    }
+
+    @Test
+    void 아이티앤_담당자를_배정하면_응답과_재조회에_반영된다() throws Exception {
+        Long itnId = createContact("ITN", "박아이티");
+
+        mvc.perform(put("/api/orgs/{id}/assignments", orgId)
+                        .with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"itnContactId\": " + itnId + "}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.itn.name").value("박아이티"));
+
+        mvc.perform(get("/api/orgs"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$[0].itn.name").value("박아이티"));
+    }
+
+    @Test
+    void 구분이_맞지_않는_담당자를_아이티앤에_배정하면_400이다() throws Exception {
+        Long lawyerId = createContact("LAWYER", "이변호");
+
+        mvc.perform(put("/api/orgs/{id}/assignments", orgId)
+                        .with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"itnContactId\": " + lawyerId + "}"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.message").value(containsString("아이티앤 담당자")));
+    }
+
+    @Test
+    void 아이티앤_담당자_배정_해제는_null을_보내면_반영된다() throws Exception {
+        Long itnId = createContact("ITN", "박아이티");
+        mapper.updateAssignments(orgId, null, null, itnId, null, null);
+
+        mvc.perform(put("/api/orgs/{id}/assignments", orgId)
+                        .with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"itnContactId\": null}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.itn").value(nullValue()));
+
+        assertThat(mapper.findById(orgId).getItnContactId()).isNull();
     }
 
     @Test
@@ -191,7 +238,7 @@
     @Test
     void 채널생성을_요청하면_결과를_돌려준다() throws Exception {
         Long applicantId = createContact("APPLICANT", "송민지");
-        mapper.updateAssignments(orgId, applicantId, null, null, null);
+        mapper.updateAssignments(orgId, applicantId, null, null, null, null);
 
         when(mattermost.findChannelIdByInternalName(anyString())).thenReturn(Optional.empty());
         when(mattermost.findChannelIdByDisplayName(anyString())).thenReturn(Optional.empty());
@@ -221,7 +268,7 @@
     @Test
     void 법률검토만_실패해도_200과_부분실패_결과를_돌려준다() throws Exception {
         Long applicantId = createContact("APPLICANT", "송민지");
-        mapper.updateAssignments(orgId, applicantId, null, null, null);
+        mapper.updateAssignments(orgId, applicantId, null, null, null, null);
 
         when(mattermost.findChannelIdByInternalName(anyString())).thenReturn(Optional.empty());
         when(mattermost.findChannelIdByDisplayName(anyString())).thenReturn(Optional.empty());
src/test/java/kr/itn/itnhub/org/OrganizationMapperTest.java
--- src/test/java/kr/itn/itnhub/org/OrganizationMapperTest.java
+++ src/test/java/kr/itn/itnhub/org/OrganizationMapperTest.java
@@ -84,7 +84,7 @@
     void 채널ID를_한쪽만_저장할_수_있다() {
         mapper.upsertBySeed(seedRow("002", "세종학당재단"));
         Long id = mapper.findAll().get(0).getId();
-        mapper.updateAssignments(id, insertContact("APPLICANT", "담당자"), null, null, null);
+        mapper.updateAssignments(id, insertContact("APPLICANT", "담당자"), null, null, null, null);
 
         mapper.updateChannelIdMj(id, "only-mj");
 
@@ -95,25 +95,28 @@
     }
 
     @Test
-    void 담당자_3종을_배정하면_조인된_담당자_정보가_그대로_읽힌다() {
+    void 담당자_4종을_배정하면_조인된_담당자_정보가_그대로_읽힌다() {
         mapper.upsertBySeed(seedRow("003", "한국문학번역원"));
         Organization org = mapper.findAll().get(0);
 
         Long applicantId = insertContact("APPLICANT", "송민지");
         Long mjId = insertContact("MJ", "김문정");
+        Long itnId = insertContact("ITN", "박아이티");
         Long lawyerId = insertContact("LAWYER", "이변호");
 
-        mapper.updateAssignments(org.getId(), applicantId, mjId, lawyerId, "2026-07-21");
+        mapper.updateAssignments(org.getId(), applicantId, mjId, itnId, lawyerId, "2026-07-21");
 
         Organization after = mapper.findById(org.getId());
         assertThat(after.getApplicantContactId()).isEqualTo(applicantId);
         assertThat(after.getMjContactId()).isEqualTo(mjId);
+        assertThat(after.getItnContactId()).isEqualTo(itnId);
         assertThat(after.getLawyerContactId()).isEqualTo(lawyerId);
         assertThat(after.getLawyerAssignedDate()).isEqualTo("2026-07-21");
 
         assertThat(after.getApplicant().getName()).isEqualTo("송민지");
         assertThat(after.getApplicant().getCategory()).isEqualTo("APPLICANT");
         assertThat(after.getMj().getName()).isEqualTo("김문정");
+        assertThat(after.getItn().getName()).isEqualTo("박아이티");
         assertThat(after.getLawyer().getName()).isEqualTo("이변호");
         assertThat(after.getStatus()).isEqualTo(OrgStatus.READY);
     }
@@ -124,11 +127,12 @@
         Organization org = mapper.findAll().get(0);
 
         Long applicantId = insertContact("APPLICANT", "담당자");
-        mapper.updateAssignments(org.getId(), applicantId, null, null, null);
+        mapper.updateAssignments(org.getId(), applicantId, null, null, null, null);
 
         Organization after = mapper.findById(org.getId());
         assertThat(after.getApplicant()).isNotNull();
         assertThat(after.getMj()).isNull();
+        assertThat(after.getItn()).isNull();
         assertThat(after.getLawyer()).isNull();
     }
 
@@ -138,8 +142,8 @@
         Organization org = mapper.findAll().get(0);
 
         Long applicantId = insertContact("APPLICANT", "담당자");
-        mapper.updateAssignments(org.getId(), applicantId, null, null, null);
-        mapper.updateAssignments(org.getId(), null, null, null, null);
+        mapper.updateAssignments(org.getId(), applicantId, null, null, null, null);
+        mapper.updateAssignments(org.getId(), null, null, null, null, null);
 
         Organization after = mapper.findById(org.getId());
         assertThat(after.getApplicantContactId()).isNull();
src/test/java/kr/itn/itnhub/provision/ChannelProvisionServiceTest.java
--- src/test/java/kr/itn/itnhub/provision/ChannelProvisionServiceTest.java
+++ src/test/java/kr/itn/itnhub/provision/ChannelProvisionServiceTest.java
@@ -65,7 +65,7 @@
         contact.setCategory("APPLICANT");
         contact.setName(name);
         contactMapper.insert(contact);
-        mapper.updateAssignments(id, contact.getId(), null, null, null);
+        mapper.updateAssignments(id, contact.getId(), null, null, null, null);
         return contact.getId();
     }
 
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
@@ -204,7 +204,7 @@
         manual.setCategory("APPLICANT");
         manual.setName("사람이 직접 배정한 담당자");
         contactMapper.insert(manual);
-        mapper.updateAssignments(id, manual.getId(), null, null, null);
+        mapper.updateAssignments(id, manual.getId(), null, null, null, null);
 
         seedService.seed(new ByteArrayInputStream(xlsx));
 
Add a comment
List