# 삼성화재 기업회원 계정등록·삭제 요청의 시스템 처리 — 되살리기 경로·입력 3경로·`s` 접두 정정

**Status**: active
**Date**: 2026-09-16
**Topic**: 삼성화재 기업회원 계정등록·삭제 요청의 시스템 처리 — 되살리기 경로·입력 3경로·`s` 접두 정정

조사 질문: **삼성화재가 보내는 계정등록·계정삭제 요청 엑셀을 "시스템적으로 파싱해 사용자 검토 후
반영"하는 기능을 만들려 할 때, 무엇부터 고쳐야 하는가.** 요청은 새 화면(최고관리자 전용 페이지)과 새
파서를 말하지만, 이 저장소에는 같은 목적의 업로드 경로가 이미 **4종** 서 있다. 그래서 이 조사는
"새로 무엇을 만드는가"가 아니라 **"기존 4종 중 무엇이 요구를 이미 만족하고, 무엇이 요구와 반대로
동작하는가"** 를 먼저 판정한다.

요구의 최종 형태는 둘이다 — 등록요청 = (사용:사용, 구독:구독), 삭제요청 = (사용:중지, 구독:해지).
이 두 형태를 **한 번의 업로드로** 달성하는 연산이 현재 존재하는가가 판정의 축이다.

## Premises

| # | 참조 | 시점 | 무엇을 전제하는가 |
|---|---|---|---|
| P1 | `insuniverse-server/drizzle/schema.ts` | 4d798f55 | `tn_member` 에 "사용여부" 컬럼이 없다. 사용상태를 지는 칸은 `mem_is_resigned`(tinyint, default 0)와 `mem_resigned_at` 둘이다 |
| P2 | `insuniverse-server/drizzle/schema.ts` | 4d798f55 | 구독상태는 별도 테이블 `tn_subscribe.sub_state`(varchar 15)가 진다. `tn_member` 와 `tn_subscribe` 는 `mem_id` 로 1:N 이고 FK 는 `onDelete: set null` 이다 |
| P3 | `insuniverse-server/drizzle/schema.ts` | 4d798f55 | `tn_member` 의 유니크는 `unique("adm_login_id").on(mem_login_id, agc_id)` 다. 로그인아이디는 전역이 아니라 **기업(agcId) 안에서만** 유일하다 |
| P4 | `insuniverse-server/drizzle/schema.ts` | 4d798f55 | `tn_agency.agc_corporate_code` 는 전역 UNIQUE varchar(20)다. 기업 식별의 공개 키다 |
| P5 | `insuniverse-server/apps/api/src/modules/sales-agency/samsung-s2-report.service.ts` | 4d798f55 | 삼성화재 축이 `SAMSUNG_S2_REPORT_AGENCY_CODE` 환경변수로 이미 외부화돼 있고 기본값 리터럴이 `'s2'` 다. `s2` 는 로그인아이디 접두가 아니라 **`agcCorporateCode` 값**이다 |
| P6 | `insuniverse-server/apps/api/src/modules/fc/agency/agency-fc-admin.controller.ts` | 4d798f55 | 기업FC 관리자 컨트롤러에 파일 업로드 엔드포인트가 4종 있다 — `POST upload`(신규계정) · `POST account-update-upload`(계정수정) · `POST subscribe/upload`(구독처리) · `POST org-upload`(조직권한) |
| P7 | `insuniverse-server/apps/api/src/modules/fc/agency/agency-fc-admin.controller.ts` | 4d798f55 | `POST resign/cancel`(탈퇴철회)이 주석으로 막혀 있다. 서비스 메서드 `resignCancel` 은 살아 있으나 HTTP 로 도달할 경로가 없다 |
| P8 | `insuniverse-server/apps/api/src/modules/fc/agency/fc-agency.service.ts` | 4d798f55 | `extractDataFromExcel` 은 헤더 텍스트를 읽지 않는다. `row.values.slice(1)` 을 **위치 기준 구조분해**로 7열에 매핑하고, 1행은 `skipHeaders` 로 버리고, `※` 를 포함한 행은 샘플로 보고 버린다 |
| P9 | `insuniverse-server/apps/api/src/modules/fc/agency/fc-agency.service.ts` | 4d798f55 | `isValidRow` 는 필수열(0=로그인아이디, 2=비밀번호) 결측 시 `return false` 가 아니라 **`BadRequestException` 을 throw** 한다. 한 행의 결측이 업로드 전체를 중단시킨다 |
| P10 | `insuniverse-server/apps/api/src/modules/fc/agency/fc-agency.service.ts` | 4d798f55 | `buildUploadFormExcelColumns` 가 내는 정본 양식 헤더는 7열이며 앞 4열이 `로그인아이디(*필수)`·`회원명`·`비밀번호(*필수)`·`연락처` 다 |
| P11 | `insuniverse-server/apps/api/src/modules/fc/agency/fc-agency.service.ts` | 4d798f55 | `duplicateInDatabase` 는 로그인아이디·연락처·이메일 중 하나라도 같은 기업 안에 이미 있으면 `BadRequestException` 으로 **업로드 전체를 거부**한다. 행 단위 분기가 없다 |
| P12 | `insuniverse-server/apps/api/src/modules/fc/agency/agency-fc.repository.ts` | 4d798f55 | `checkDuplicate(agcId, loginIds, phones, emails)` 의 WHERE 는 `eq(agcId)` AND `or(...)` 다. `mem_is_resigned` 를 보지 않는다 — 중지된 계정도 중복으로 잡힌다 |
| P13 | `insuniverse-server/apps/api/src/modules/fc/agency/fc-agency.service.ts` | 4d798f55 | `resign(dto)` 는 `agencyResigned(dto)` 를 부른 뒤 **`dto.useAt` 값과 무관하게** `getAgencySubscribe(dto.ids)` 결과 전건에 `unsubscribe({isUnsubscribed: 1})` 를 돌린다 |
| P14 | `insuniverse-server/apps/api/src/modules/subscribe/subscribe/subscribe.repository.ts` | 4d798f55 | `agencyResigned` 는 `memIsResigned = useAt === 0 ? 1 : 0` 으로 쓴다. 즉 `useAt=1` 경로가 사용상태 복원을 이미 수행한다 |
| P15 | `insuniverse-server/apps/api/src/modules/fc/agency/agency-fc.repository.ts` | 4d798f55 | `getAgencySubscribe(fcIds)` 는 `inArray(memId, fcIds)` 만 걸고 구독상태로 필터하지 않는다. leftJoin 이라 구독 이력이 없으면 `subId` 가 null 로 나온다 |
| P16 | `insuniverse-server/apps/api/src/modules/subscribe/subscribe/subscribe.service.ts` | 4d798f55 | `unsubscribe({subId, isUnsubscribed})` 는 `isUnsubscribed===1` 이면 `sub_state='해지'`, `0` 이면 `'구독'` 으로 쓴다. 되살리기 원시연산이 이미 존재한다 |
| P17 | `insuniverse-server/apps/api/src/modules/fc/agency/fc-agency.service.ts` | 4d798f55 | `processIndividualSubscription` 은 `sub_state='해지'` 이고 `sub_disable_completed_at` 이 비었으면 `unsubscribe({isUnsubscribed: 0})` 로 **기존 구독을 되살린다**. 만료됐으면 신규 구독을 만든다 |
| P18 | `insuniverse-server/apps/api/src/modules/fc/agency/fc-agency.service.ts` | 4d798f55 | `accountUpdateUpload` 는 `useAt===0 && memIsResigned===0` 인 행만 `stopIds`, `useAt===1 && memIsResigned===1` 인 행만 `useIds` 로 모아 각각 `resign()` 을 부른다. 구독 복원 호출은 이 경로에 없다 |
| P19 | `insuniverse-server/apps/api/src/modules/fc/agency/agency-fc.repository.ts` | 4d798f55 | `updateAccount(memId, row)` 가 쓰는 칸은 `memName`·`memPhone`·`memEmail`·`memPassword` 4개다. `useAt` 도 `memIsResigned` 도 이 함수는 건드리지 않는다 |
| P20 | `insuniverse-server/apps/api/src/modules/fc/agency/fc-agency.service.ts` | 4d798f55 | `assertUploadOrganizationsWritableByAdmin` 은 첫 줄이 `if (admin?.role !== 'corp') return;` 이다. 내부 관리자(`admin`)에게는 조직 쓰기 범위 검증이 돌지 않는다 |
| P21 | `insuniverse-server/apps/api/src/modules/fc/agency/fc-agency.service.ts` | 4d798f55 | `validateUploadOrganizations` 는 `orgCode` 가 있는 행만 검증한다. `orgCode` 가 비면 `continue` 로 건너뛰고 조직 미배정으로 생성된다 |
| P22 | `insuniverse-server/apps/api/src/modules/auth/admin/car-view-admin.guard.ts` | 4d798f55 | "최고관리자 전용"의 저장소 선례는 `CAR_VIEW_ADMIN_IDS` 환경변수 allowlist 기반 가드이며, 빈 allowlist 는 `admin` 에 대해 fail-closed 다 |
| P23 | `insuniverse-server/apps/api/src/modules/fc/agency/fc-agency-account-update.spec.ts` | 4d798f55 | 계정수정 업로드에 단위 테스트가 있고, 업로드 결과가 `adminAlarmService.addAlarm` 의 `aadDetail.rows[]`(행별 status·reason)로 적재됨을 검증한다 |
| P24 | `docs/30-admin/04-authority-management.md` | 3666cd5 | 정책 `authority-api-exposure` 가 **"메뉴 노출은 인가가 아니다"** 를 중심 주장으로 든다. 메뉴 미배분 화면도 URL 직접 입력으로 열리고 `@Roles` 만 맞으면 API 가 응답한다 |
| P25 | `docs/30-admin/04-authority-management.md` | 3666cd5 | 같은 문서가 `roles.guard.ts:22,27` 에 대해 **`@Roles` 선언이 없으면 통과시킨다**고 적는다. 인가는 opt-in 이다 |
| P26 | `docs/30-admin/01-admin-console.md` | 3666cd5 | 정책 `admin-console-member-lifecycle` 이 "정지가 구독을 자동으로 무엇으로 바꾸는지는 이 창에서 확정하지 못했다"를 명시적 미상으로 남긴다 |
| P27 | `docs/30-admin/01-admin-console.md` | 3666cd5 | 정책 `admin-console-bulk-operation` 이 "일부 행만 실패" 시의 확인 지점을 미상으로 남긴다. 부분 실패 표시 규약이 정책 층에 없다 |
| P28 | `insuniverse-admin/src/shared/ui/ExcelUploadModal.tsx` | fb001e1d | 공용 업로드 모달이 이미 있다. `.xlsx` 확장자만 받고, 업로드 응답을 `{success, message}` 또는 레거시 문자열(`E0807`="이미 존재하는 계정이 있습니다")로 해석한다. **프리뷰 단계가 없다** |
| P29 | `insuniverse-admin/src/layouts/AdminLayout.tsx` | fb001e1d | 상단바 경로 표시는 `<Header.Breadcrumb menuItem={menuItem} />` 로 이미 서 있다. 새 화면은 이 컴포넌트를 재사용하면 된다 |
| P30 | `insuniverse-admin/src/features/authority/constants/menu.ts` | fb001e1d | 메뉴는 코드(`USR003` 등)를 키로 갖고 DB(`tn_menu`·`tn_menu_auth_map`) 배분과 맞물린다. 영업대리점 메뉴만 하드코딩 예외다 |
| P31 | `insuniverse-admin/src/app/router/routes/domains/subscription.routes.tsx` | fb001e1d | 진료 모니터링 화면(`subscription/treat-monitor`)의 라우트는 **무조건 등록**된다. 환경 분기가 없다 |
| P32 | `insuniverse-admin/src/features/authority/constants/menu.ts` | fb001e1d | 진료 모니터링 메뉴 항목의 코드가 `SUB004` 이고, 이는 바로 위 "분석 관리" 항목의 코드와 **같은 값**이다. 두 화면이 하나의 배분 단위를 공유한다 |
| P33 | `insuniverse-admin/src/app/router/routes/page.routes.tsx` | fb001e1d | 환경변수로 노출을 끄는 선례는 진료내역 오류 테스터 하나다. `REACT_APP_BACKEND_HOST` 가 `s-api.insuniverse.com` 을 포함할 때만 라우트를 등록하고 아니면 빈 배열이다(stg 전용 fail-closed) |
| P34 | `insuniverse-admin/src/app/router/routes/page.routes.tsx` | fb001e1d | 같은 파일의 주석이 `NODE_ENV` 를 쓰지 못하는 이유를 적는다 — vite build 가 `'production'` 으로 강제해 stg 를 구분할 수 없어 백엔드 호스트로 판정한다 |
| P35 | `insuniverse-server/apps/api/src/modules/treat/monitor/treat-monitor.controller.ts` | 4d798f55 | 진료 모니터링 컨트롤러에 `AdminJwtAuthGuard` 와 `RolesGuard` 가 걸려 있으나 **`@Roles` 선언이 없다.** P25 에 따라 role 대조 없이 통과한다 |
| P36 | `insuniverse-server/apps/api/src/modules/fc/agency/fc-agency.service.ts` | 4d798f55 | `assertOrgWritableByAdmin` 은 `org.orgPath?.startsWith(admin.orgPath)` 로 **materialized path 부분트리** 판정을 한다. 관할 판정의 기존 구현이다 |
| P37 | `insuniverse-server/drizzle/schema.ts` | 4d798f55 | `tn_agency_organization` 은 `org_parent_id`·`org_depth`·`org_path`(varchar 1024) 로 트리를 표현하고 `org_code` 는 NULL 허용이며 NOT NULL 일 때만 기업 안에서 UNIQUE 다 |

**전제의 사정거리** — P1~P23 은 서브모듈 `insuniverse-server` 핀 `4d798f55`, P28~P30 은
`insuniverse-admin` 핀 `fb001e1d` 의 파일이다. 두 핀은 부모 `3666cd5` 의 gitlink 값이자
`docs/` 정책서들의 `last_verified` 값과 같다 — **정책서와 코드가 같은 시점을 본다.** 이 조사는
그 두 서브모듈을 worktree `meta/samsung-account-intake` 에 체크아웃해 읽었다.

**전제에 넣지 않은 것** — 실제 요청 파일 2건(`양식 계정등록(17차 요청 0908).xlsx - 계정등록.tsv` ·
`양식 계정삭제(17차 요청 0908).xlsx - 계정등록.tsv`)은 주 worktree 의 **untracked 파일**이라 이
worktree 에 없고 이력에도 없다. 재검증이 불가능하므로 전제 표에서 뺐다. 그 파일에서 얻은 관측은
§Evidence E3 에 **관측으로만** 적고, 실명·평문 비밀번호는 옮기지 않는다. 또한 삼성화재의 실제
`agc_corporate_code` 집합과 계정 수는 운영 DB 에만 있고 이 창에서 조회하지 않았다 — §Open Questions Q1.

## Evidence

### E1 — 요구의 두 "최종 형태"를 한 번에 만드는 연산이 없다

요구는 상태를 두 축의 곱으로 정의한다. 저장소는 그 두 축을 **다른 테이블**에 둔다(P1·P2). 그래서
"최종 형태"를 만들려면 두 축을 각각 쓰는 연산이 하나의 트랜잭션 안에 있어야 하는데, 현재 업로드
4종(P6) 중 두 축을 함께 쓰는 것은 하나도 없다.

| 업로드 | 사용상태(`mem_is_resigned`) | 구독상태(`sub_state`) |
|---|---|---|
| `POST upload` (신규계정) | 신규 행이므로 default 0 | 최초구독 생성 |
| `POST account-update-upload` (계정수정) | `resign()` 경유로 쓴다 (P18) | **쓰지 않는다** (P18·P19) |
| `POST subscribe/upload` (구독처리) | 쓰지 않는다 | `구독`/`해지` 쓴다 (P17) |
| `POST org-upload` (조직권한) | 쓰지 않는다 | 쓰지 않는다 |

**삭제요청(중지+해지)은 우연히 한 경로로 닫힌다.** `resign(useAt=0)` 이 `mem_is_resigned=1` 을 쓰고
(P14) 이어서 전건 `unsubscribe(1)` 로 `sub_state='해지'` 를 쓴다(P13·P16). 사용자가 말한 조건 3
("사용상태 중지가 되면 구독상태도 같이 해지")이 **이미 구현돼 있다.**

**등록요청(사용+구독)은 닫히지 않는다.** 같은 `resign()` 이 `useAt=1` 일 때 `mem_is_resigned=0` 은
쓰지만(P14), 그 **직후 같은 대상 전건에 `unsubscribe({isUnsubscribed: 1})` 를 돌린다**(P13). 조건
분기가 없다. 즉 사용상태는 복원되고 구독은 해지 방향으로 한 번 더 밀린다 — **요구 1번의 절반을
정확히 반대로 수행한다.**

### E2 — `resign(useAt=1)` 의 구독 해지가 지금 무해해 보이는 이유와, 그것이 방어가 아닌 이유

`accountUpdateUpload` 는 `useIds` 를 `memIsResigned === 1` 인 행으로 좁힌다(P18). 그 회원들은 과거
`resign(useAt=0)` 을 거쳤으므로 구독이 이미 `해지`일 가능성이 높고, 이미 해지된 구독을 다시
`해지`로 쓰는 것은 겉보기에 no-op 이다. 구독 이력이 아예 없으면 `subId` 가 null 이라(P15)
`where sub_id = NULL` 이 0행을 갱신하고 조용히 지나간다.

**그러나 이것은 방어가 아니라 우연이다.** `getAgencySubscribe` 는 구독상태로 필터하지 않으므로
(P15), `mem_is_resigned=1` 이면서 `sub_state='구독'` 인 행이 하나라도 있으면 그 구독은 **해지된다.**
**운영에는 그 조합이 0건이다**(§E12 관측 4) — 사용자 예상과 달랐고, 지금은 피해가 나지 않는다.
그러나 코드에 그 조합을 막는 장치는 없고, 무엇보다 **되살리기를 구현하면 그 조합이 생긴다**:
사용상태를 먼저 복원하고 구독을 복원하기까지의 중간 상태가 정확히 그것이다. 0건은 불변식이 아니라
현재 값일 뿐이다. 존재 여부와 무관하게, 사용자가 요구한
"되살린다"를 이 코드 위에 얹으면 `useAt=1` 경로가 되살린 것을 같은 함수의 다음 줄이 되돌린다.

또 `unsubscribe({subId: null})` 가 예외 없이 0행 갱신으로 지나가는 것 자체가 침묵 실패다.
`resign()` 은 몇 건을 실제로 바꿨는지 돌려주지 않는다.

### E3 — "양식이 제각각"의 실체는 열 순서가 아니라 **양식의 의미 충돌**이다

실제 요청 파일 2건을 구조만 관측했다(값은 옮기지 않는다).

- 두 파일의 1행 헤더가 **동일**하다: `로그인아이디(*필수)` · `회원명` · `비밀번호(*필수)` · `연락처`.
  이는 정본 양식(P10)의 앞 4열과 글자까지 같다. 뒤 3열(`이메일`·`소속조직코드`·`조직관리자(Y/N)`)이 없다.
- 두 파일의 **시트 이름이 둘 다 `계정등록`** 이다. 삭제 요청 파일도 그렇다.
- 등록 파일은 2행에 `※ …` 안내가 남아 있고 3행이 빈 행이며 4행부터 데이터다. 삭제 파일은 2·3행이
  비고 4행부터 데이터이며 **비밀번호 열이 전건 비어 있다.**
- 행수는 등록 158, 삭제 7,788(헤더 포함 TSV 기준)이다.

따라서 위치 기준 파싱(P8)은 이 두 파일에 대해 앞 4열이 맞아떨어져 **그대로 통한다.** 진짜 문제는 셋이다.

1. **파일을 여는 쪽이 등록인지 삭제인지 구분할 근거가 파일 안에 없다.** 헤더도 시트명도 같다.
   현재는 사람이 올바른 버튼을 고르는 것에 전적으로 의존한다 — 삭제 목록을 신규계정 업로드에 넣으면
   비밀번호 결측으로 P9 가 전체를 중단시키므로 최악은 막히지만, 그 반대(등록 목록을 계정수정에 투입)는
   막히지 않는다.
2. ~~**빈 행이 P9 의 throw 를 일으킨다.**~~ **이 우려는 실측으로 철회한다 — §E10.** exceljs 는 완전
   빈 행을 `eachRow` 에서 건너뛰므로 등록 파일 3행은 실리지 않는다. 안내 행(`※`)도 설계대로
   걸러진다. 등록 파일은 현행 파서를 그대로 통과한다.
3. **7,788행을 받는 경로가 없다.** 삭제 요청을 반영하려면 계정수정 업로드(사용여부)와 구독 업로드
   (해지)를 **두 번** 올려야 하고, 두 업로드 사이에 실패하면 중간 상태가 남는다. 부분 실패의 표시
   규약은 정책 층에서도 미상이다(P27).

### E4 — 중복·타 조직·s 접두: 요구 3건의 현재 위치가 서로 다르다

**회원번호 중복.** 엑셀 안 중복과 DB 중복 모두 검출된다. 그러나 결과는 행 표시가 아니라
**전체 거부**다(P11). 중복 검사가 `mem_is_resigned` 를 보지 않으므로(P12), 중지된 계정을 다시
등록요청에 넣는 것(= 사용자가 원하는 "되살리기"의 입력)이 정확히 이 거부에 걸린다. 프런트는 그것을
`E0807`="이미 존재하는 계정이 있습니다"로 띄운다(P28). **요구 1번이 현행 UI 에서 보이는 모습이
바로 이 에러다.**

**타 조직 사용자.** 조직 검증은 두 겹인데 둘 다 이 용도에 닿지 않는다. `validateUploadOrganizations`
는 `orgCode` 가 **있는 행만** 본다(P21) — 삼성 양식에는 그 열이 아예 없으므로 전 행이 검증을
건너뛴다. `assertUploadOrganizationsWritableByAdmin` 은 `role !== 'corp'` 면 즉시 return 한다(P20)
— 이 기능을 내부 최고관리자 전용으로 만들면 **이 방어가 통째로 꺼진다.** 게다가 "자기 관할이 아닌
타 조직"의 판정 기준이 조직(`org_id`)인지 기업(`agc_id`)인지가 요구에서 갈리지 않았다(Q4).

**s 접두.** `s2` 는 로그인아이디 접두가 아니라 `agcCorporateCode` 값이고(P5), 기업코드로 기업을
찾는 조회(`getAgencyListByCorporateCode`)가 이미 있다. 로그인아이디가 기업 안에서만 유일하므로(P3)
같은 사번이 여러 기업에 각각 존재할 수 있다. 현행 `upload` 는 대상 기업을 화면이 보낸 `dto.agcId` 로
받는다 — 즉 접두가 파일에 있어도 읽지 않고, 없어도 묻지 않는다.

**이 절의 초판은 요구의 "s1/s2/s3" 를 그대로 받아 적었다. 실측이 그 전제를 뒤집었다 — §E9 를 본다.**
접두는 `s` 한 글자이고 detect 대상은 "무엇이 붙었는가"가 아니라 "붙어 있는가"다.

### E5 — "최고관리자에게만 노출"은 메뉴로 못 막는다 (정책서가 이미 판정함)

정책 `authority-api-exposure` 의 중심 주장이 **"메뉴 노출은 인가가 아니다"** 다(P24). 메뉴에서 빠진
화면도 URL 직접 입력으로 열리고, 서버는 `@Roles` 목록만 본다. 더구나 `@Roles` 선언이 없으면
`RolesGuard` 는 **통과시킨다**(P25) — 새 컨트롤러에 데코레이터를 빠뜨리면 조용히 열린다.

용어도 갈린다. 이 저장소에서 "최고관리자"는 **기업관리자의 `CORP_SUPER`** 를 가리키는 말로 쓰인다
(`corpRole.ts`·`settlement-admin.controller.ts` 주석). 사용자가 말한 "최고관리자"는 문맥상
(“…이후 기업관리자에 기능으로 제공할 예정”) **내부 운영사 관리자 중 최상위**다. 같은 낱말이 두
대상을 가리킨다 — Q5.

내부 관리자 중 일부만 여는 선례는 하나뿐이고 역할이 아니라 **계정 id allowlist** 다:
`CAR_VIEW_ADMIN_IDS` 환경변수, 빈 값이면 `admin` 에 대해 fail-closed(P22). 새 기능을 이 형태로
만들면 선례를 따르는 것이 되고, 역할을 새로 만들면 `tn_auth` 권한 그룹 체계와 겹친다.

상단바 경로 요구는 비용이 없다 — `Header.Breadcrumb` 이 이미 레이아웃에 붙어 있다(P29).

### E6 — 재사용 가능한 자산 목록 (새로 만들 필요가 없는 것)

| 요구 | 이미 있는 것 | 근거 |
|---|---|---|
| 구독 해지 계정 되살리기 | `processIndividualSubscription` 의 만료 전/후 분기 | P17 |
| 사용상태 복원 원시연산 | `agencyResigned(useAt=1)` · `agencyResignedCancel` | P14 |
| 구독 상태 토글 원시연산 | `unsubscribe({isUnsubscribed: 0\|1})` | P16 |
| 엑셀 업로드 UI | `ExcelUploadModal` | P28 |
| 상단바 이동 경로 | `Header.Breadcrumb` | P29 |
| 행별 결과 적재 | `adminAlarmService.addAlarm` 의 `aadDetail.rows[]` | P23 |
| 기업코드→기업 조회 | `getAgencyListByCorporateCode` | P4·E4 |
| seat 하한 판정 | `assertSeatLowerBound` · `reconcileSubscribeUploadTransitions` | 본문 §E1 표 |

행별 결과 적재(P23)가 특히 크다. "사용자 최종 검토"의 **출력 쪽 자료구조가 이미 있다** — 없는 것은
그것을 **반영 전에** 보여주는 단계다.

### E7 — Q4·Q5 해소 후: 답 하나는 설계를 열었고, 답 하나는 전제가 어긋났다

2026-09-16 사용자 답변으로 두 질문이 닫혔다. 그 답을 코드에 대보니 결과가 갈린다.

**Q4 답(조직 `org_id` 기준, 운영 조직은 '지방사업부'·'수도권사업부' 2개) — 판정 수단은 이미 있다.**
`assertOrgWritableByAdmin` 이 `orgPath.startsWith()` 부분트리 판정을 하고(P36) 스키마가 materialized
path 를 든다(P37). 기준이 정해졌으므로 Verdict 6 은 이제 설계 가능하다.

**그러나 입력이 없다.** 삼성 양식에는 조직 열이 없다(E3). 그래서 판정은 두 경우로 갈린다.

- **기존 계정**: `tn_member.org_id` 가 있으므로 "이번 요청의 관할 조직"과 대조할 수 있다. 검출 가능.
- **신규 계정**: 파일에 조직 정보가 없다. 타 조직인지 알 수 없을 뿐 아니라 **어느 조직으로 만들지도
  모른다.** 현행 `upload` 는 `orgCode` 가 비면 조직 미배정으로 생성한다(P21).

따라서 "타 조직" 판정에는 **이번 업로드가 어느 조직 소관인가**라는 입력이 새로 필요하다. 파일에서
얻을 수 없으므로 업로드 화면에서 고르는 형태가 된다 — Q8.

**Q5 답(내부 운영사 관리자, "모니터링 페이지와 같은 조건, `.env` 관리") — 전제가 코드와 어긋난다.**
실측 결과 진료 모니터링 페이지에는 `.env` 조건이 **없다.**

| | 진료 모니터링 `/subscription/treat-monitor` | 진료내역 오류 테스터 `/test/treat-error-tester` |
|---|---|---|
| 라우트 등록 | 무조건 (P31) | `REACT_APP_BACKEND_HOST` 가 stg 일 때만, 아니면 빈 배열 (P33) |
| 사이드바 노출 | 메뉴 코드 `SUB004` 배분 (P32) | 배분 대상 아님 — 숨김 라우트 |
| 서버 인가 | `@Roles` 없음 → role 대조 없이 통과 (P35·P25) | 이 조사에서 확인하지 않음 |

`.env` 로 노출을 끄는 선례는 **테스터 쪽**이다(P33). 두 화면이 섞인 기억으로 보인다.

게다가 모니터링 쪽 조건은 그대로 베낄 수 없다. 메뉴 코드 `SUB004` 가 "분석 관리"와 **중복**이라
(P32) 두 화면이 한 배분 단위를 공유한다 — 분석 관리를 받은 계정은 진료 모니터링도 함께 본다.
"이 기능만 특정 관리자에게" 를 이 방식으로는 표현할 수 없다.

**어느 쪽을 베껴도 서버는 열린 채다.** 두 방식 모두 프런트 라우트·메뉴 층이고, 정책
`authority-api-exposure`(P24)가 그 층이 인가가 아님을 이미 판정했다. 모니터링 컨트롤러가 `@Roles`
없이 도는 것(P35)이 그 판정의 실례다. 조회만 하는 모니터링은 그 상태로 견딜 수 있지만, 이번 기능은
계정을 만들고 지우고 구독을 바꾼다 — **프런트 조건은 노출 제어로만 쓰고 인가는 서버 가드로 따로
건다.** 선례는 `CAR_VIEW_ADMIN_IDS`(P22) 하나뿐이며 그것도 `.env` 기반이다.

### E8 — 입력은 파일 2종이 아니라 **경로 3종**이고, 세 번째는 스키마가 다르다

2026-09-16 사용자가 실제 전달 양식 3건을 제시했다. 삼성화재는 원래 `.xlsx` 로 보내며 이 창에 온
`.tsv` 2건은 읽기 편하게 변환한 사본이다. **세 번째는 파일이 아니라 메일 본문의 표**다. 구조만
관측하고 값(실명·사번·비밀번호)은 옮기지 않는다.

| | 계정등록 tsv | 계정삭제 tsv | 메일 본문 표 |
|---|---|---|---|
| 열 | 로그인아이디 / 회원명 / 비밀번호 / 연락처 | 좌동(헤더 동일) | **순번 / 지점 / 코드 / 성명 / 비번** |
| 로그인아이디 | 있음 | 있음 | **없음 — `코드`에서 유도해야 한다** |
| 조직 | 없음 | 없음 | **`지점` 으로 있다** |
| 비밀번호 | 전건 **동일한 1개 값** | 전건 공란 | 행마다 다름(`코드` 파생형으로 보인다) |
| 데이터 행 | 156 | 7,786 | 38 + 끝의 순번만 있는 빈 행 1 |

세 번째 경로는 **정본 양식과 열 이름이 하나도 겹치지 않는다.** 헤더 기반 매핑으로 바꿔도(Verdict 4)
`코드`→로그인아이디, `지점`→조직, `성명`→회원명 은 **이름만으로 이어지지 않는다.** 값의 모양을 보고
추론하거나 사용자가 열을 지정해야 한다 — 요구가 말한 "사용자 확인 시 detect" 의 실체가 이것이다.

**부수 관측 둘.** 메일 표의 `성명` 열에는 개인 이름이 아닌 법인·대리점 상호가 섞여 들어온다(2건).
그리고 표 끝에 `순번` 만 있고 나머지가 빈 행이 하나 있다 — P9 의 throw 조건에 그대로 걸리는 모양이다.

### E9 — 회원번호 접두는 `s1`·`s2`·`s3` 가 아니다 (전제 정정)

요구는 "회원번호에 s1, s2, s3가 붙어야 한다"고 했다. 실제 파일 2건을 전수 분류했다.

| 파일 | 데이터 행 | `^s[0-9]{8}$` 일치 | `s` 로 시작하나 길이가 다름 | `s` 없음 |
|---|---|---|---|---|
| 계정등록 | 156 | **156** | 0 | 0 |
| 계정삭제 | 7,786 | **7,786** | 0 | 0 |

전건이 `s` + 8자리, 길이 9 로 **완전히 균일**하다. 접두는 **`s` 한 글자**이고 그 뒤는 삼성 사번
그대로다. 실제로 둘째 자리 분포는 등록이 `s3` 140 · `s5` 16, 삭제가 `s3` 6,666 · `s5` 1,063 ·
`s8` 57 로 **1·2·3 이 아니다** — 그 자리는 사번의 첫 숫자이지 기업 구분자가 아니다. 메일 표가 이를
직접 보여준다: `코드` 열에 `s` 없는 8자리 숫자가 있고 `비번` 이 `s`+코드 형태로 시작한다.

**`s2` 와 혼동된 것으로 보인다.** `s2` 는 `agcCorporateCode` 값이고(P5) 회원 로그인아이디와는 다른
축이다. 문자열이 닮아 두 축이 섞였다. 판정에 미치는 영향:

- **"엑셀에 s1~3가 있을 때도 없을 때도 있다"** 는 이번 두 파일에서 관측되지 않았다(결측 0건).
  과거 회차나 메일 표 경로에서 나타나는 현상이다 — 메일 표에는 실제로 `s` 가 없다(E8).
- **detect 해야 할 것은 "s1/s2/s3 중 무엇인가"가 아니라 "`s` 접두가 이미 붙어 있는가"** 다.
  붙어 있으면 그대로, 없으면(메일 표) `s` 를 붙여 유도한다. 훨씬 단순한 규칙이다.
- **대상 기업 선택은 접두로 풀리지 않는다.** 로그인아이디가 기업 안에서만 유일하므로(P3) 같은 사번이
  여러 기업에 있을 수 있고, 파일 어디에도 기업을 가리키는 값이 없다. 화면에서 고르는 수밖에 없다.
- `checkLoginIdPattern`(`^(?=.*[a-z])[a-z0-9]{4,10}$`)에 길이 9 · 소문자 1개는 **전건 통과**한다.

**중복 회원번호는 이번 회차에 0건이다.** 삭제 7,786행에 로그인아이디 중복이 없다. 요구가 말한 중복은
다른 회차의 현상이므로, 검출은 여전히 필요하되 **이번 파일로는 회귀 검증을 할 수 없다.**

### E10 — 원본 xlsx 를 실제 파서로 돌렸다: 진짜 장벽은 빈 행이 아니라 **암호화**다

2026-09-16 사용자가 원본 `.xlsx` 2건을 worktree 로 옮기고 열람 암호(`2026`, 공개 가능)를 제공했다.
런타임과 같은 파서(`exceljs` 4.4.0, 주 worktree 서브모듈의 설치본)로 `extractDataFromExcel` 과
`isValidRow` 를 재현했다. 값은 출력하지 않고 셀 충전 여부만 관측했다.

**관측 1 — 두 원본은 암호화된 CFB 컨테이너다.** 파일 시그니처가 `d0cf11e0a1b11ae1` 로, ZIP(OOXML)이
아니라 OLE 복합문서다. `workbook.xlsx.load(buffer)` 를 그대로 먹이면 두 건 모두 실패한다.

```
Error: Can't find end of central directory : is this a zip file ?
```

이 예외는 `upload()` 의 try/catch 에 잡혀 `BadRequestException(errorMessage)` 로 나간다 — 즉 화면에
**"암호를 풀고 올리세요"가 아니라 저 영어 문장 그대로** 뜬다. 현행 기능은 삼성이 보내는 원본을
**열지도 못한다.** 지금 운영에서 업로드가 되고 있다면 누군가 매번 암호를 풀어 다시 저장하고 있다는
뜻이고, 그 단계는 어느 화면에도 없다.

**관측 2 — 복호화 후 등록 파일은 현행 파서를 그대로 통과한다.**

| | 계정등록 | 계정삭제 |
|---|---|---|
| 시트 이름 | `계정등록` | **`계정등록`** (동일) |
| `rowCount` | 1,729 | 7,789 |
| `eachRow` 실제 방문 | **158** | 7,787 |
| 첫 `THROW` | **없음** | **행 4** (첫 데이터 행) |
| 수용된 행 | **156** | 0 |

`rowCount` 와 방문 행 수가 크게 벌어진다 — 서식만 남은 꼬리 행은 `eachRow` 가 돌지 않는다. 등록
파일의 3행(빈 행)도 같은 이유로 **건너뛴다.** 행 번호가 1→2→4 로 점프하는 것이 그 증거다. Q3 의
답은 "throw 하지 않는다"이며 §E3 의 2번 우려를 철회한다.

**관측 3 — 삭제 파일은 첫 데이터 행에서 즉시 중단된다.** 채워진 칸이 2개(로그인아이디·회원명)뿐이라
`requiredFields[2]`(비밀번호)가 결측이고, P9 가 `return false` 가 아니라 throw 이므로 행 4 에서
`NOT_FOUND_REQUIRED_PARAMETER` 로 끝난다. **7,786행 중 한 건도 읽히지 않는다.** 이는 오작동이
아니라 설계대로의 거부다 — 삭제 명단을 신규계정 업로드에 넣었기 때문이고, 애초에 그 경로가 아니다.

**재현 방법** — 원본 2건은 untracked 이고 실명·평문 비밀번호를 담아 전제 표에 넣지 않았다.
재현하려면 `msoffcrypto-tool` 로 암호 `2026` 을 풀고, `insuniverse-server/node_modules/exceljs`
4.4.0 으로 위 두 함수를 그대로 흉내내면 된다.

### E11 — Q10·Q11·Q12 해소: 수기 단계가 어디에 숨어 있는지가 드러났다

사용자 답변(2026-09-16)으로 현행 운영 절차가 확정됐다.

| 전달 경로 | 실제 형태 | 지금 사람이 하는 일 |
|---|---|---|
| xlsx 첨부 | **암호 걸린** xlsx | 암호 해제 → 저장 → 업로드 |
| 메일 본문 | **텍스트 에디터의 표**(이미지 아님, 붙여넣기 가능) | 우리 양식으로 **전부 수기 매핑** → xlsx 저장 → 업로드 |

메일 표 경로를 쓰는 이유는 **일부 지점이 xlsx 를 보낼 수 없는 환경**이기 때문이다(사용자 관측).
즉 이 경로는 예외가 아니라 구조적으로 남는다.

**Q11 의 답이 설계를 바꾼다.** 표가 텍스트라면 붙여넣기를 **직접 입력으로 받을 수 있다.** 지금
사람이 하는 "수기 매핑"이 곧 Verdict 4 의 열 매핑 확정 단계이고, 그것을 화면으로 옮기면 xlsx 로
옮겨 적는 중간 단계가 통째로 사라진다. 현행 모달은 `.xlsx` 확장자만 받으므로(P28) 붙여넣기 입력은
새 진입이 된다.

**Q10 은 닫히지 않았고 성격이 바뀌었다.** 메일 표의 `지점` 9종과 운영 조직 2종의 대응을 **사용자도
모른다.** 지금은 수기 판단으로 흡수되고 있다. 그러므로 이 매핑은 구현이 풀 문제가 아니라 **삼성화재와
정해야 할 업무 규약**이다. 정해지기 전까지 `지점` 은 조직으로 자동 변환할 수 없고, 프리뷰에 원문
그대로 보여 주고 사람이 판단하게 두는 것이 정확하다.

**Q12 의 답은 P9 와 정면으로 만난다.** "비밀번호는 받은 대로 쓰되, 신규 등록인데 없으면 사용자에게
알린다" — 현행은 알리는 대신 **업로드 전체를 throw 로 끊는다**(E10 관측 3). 요구를 만족하려면 행
단위 결과로 내려야 하고, 이는 Verdict 3(프리뷰)의 같은 요구다. 별도 작업이 아니다.

### E12 — 운영 DB 실측: 같은 로그인아이디가 **여러 기업에 동시에 존재한다**

2026-09-16 사용자가 AWS 프로필 `inpas` 접근을 제공했다. basti 로 Aurora 클러스터 `insuniverse-prod`
의 **리더**(`insuniverse-prod-instance-1`)에 터널을 띄우고 `@@innodb_read_only=1` 을 확인한 뒤
`SELECT` 만 발행했다. 이름·연락처·이메일 열은 어느 문장에도 넣지 않았고 집계만 받았다. 자격은 S3
(`s3://insuniverse-prod-envs/api/prod.env`)에서 받아 사용 후 `shred` 했다.

**관측 1 — 삼성화재는 기업 4행이다.**

| `agc_id` | `agc_corporate_code` | 사업자번호 |
|---|---|---|
| 30 | `s1` | 202-28-14561 |
| 34 | `s2` | 314-41-20121 |
| 38 | `s3` | 202-28-14562 |
| 21 | `sf` | 111-11-11114 |

사용자 구술은 3개였다. `sf` 의 사업자번호가 `111-11-11114` 라 시험 행으로 보이나 **단정하지
않는다**(Q15). P5 의 `SAMSUNG_S2_REPORT_AGENCY_CODE` 기본값 `'s2'` 는 `agc_id` 34 를 가리킨다.

**관측 2 — 조직은 `s2` 에만 있다.** `수도권사업부`(`SF01`) 7,446명 · `지방사업부`(`SF02`) 7,513명,
둘 다 `org_depth=0`·`org_use_yn='Y'`. **`s1`·`s3`·`sf` 에는 조직 행이 0건이다.** 즉 Q4 가 지정한
`org_id` 판정은 `s2` 축에서만 의미를 갖는다.

**관측 3 — 접두와 기업코드는 무관함이 확정됐다.** 기업별 로그인아이디 앞 2글자 분포:

| 기업코드 | `s3` | `s5` | `s8` | `s1` | 기타 |
|---|---|---|---|---|---|
| `s1` | 3,868 | 498 | — | — | — |
| `s2` | 12,997 | 2,001 | 67 | 12 | — |
| `s3` | 258 | 54 | — | — | — |
| `sf` | — | — | — | — | `sf` 600 · 숫자 시작 504 · `at` 1 |

**기업코드 `s1` 의 회원 아이디가 `s3…`·`s5…` 로 시작한다.** §E9 의 판정 — 접두 둘째 자리는 사번의
첫 숫자이지 기업 구분자가 아니다 — 이 운영 데이터로 확정된다.

**관측 4 — Q2 의 조합은 0건이다.** 삼성 4개 기업의 (사용상태 × 최신 구독상태) 교차:

| `mem_is_resigned` | 구독상태 | 건수 |
|---|---|---|
| 0 (사용) | 구독 | 6,001 |
| 0 (사용) | 해지 | 4,629 |
| 1 (중지) | 해지 | 10,230 |
| **1 (중지)** | **구독** | **0** |

사용자 예상("있을걸")과 다르다. §E2 의 우려는 **현재 데이터에서는 실현되지 않는다** — 그 절을
정정했다.

**관측 5 — 그리고 이것이 가장 중요하다: 같은 로그인아이디가 여러 기업에 있다.**
파일의 아이디를 그대로 `IN` 절에 넣어 기업별로 세었다.

| 명단 | 기업 | 사용상태 | 구독상태 | 건수 |
|---|---|---|---|---|
| 계정등록 (156행) | `s2` | 사용 | 구독 | **156** |
| | `s1` | 사용 | 해지 | **62** |
| 계정삭제 (7,786행) | `s2` | 중지 | 해지 | 7,583 |
| | `s1` | **사용** | 해지 | **1,158** |
| | `s3` | 중지 | 해지 | 64 |
| | `s2` | **사용** | **구독** | **33** |
| | `s2` | 사용 | 해지 | 3 |

P3(로그인아이디는 기업 안에서만 유일)이 **운영에서 실제로 그렇게 쓰이고 있다.** 파일 어디에도
기업을 가리키는 값이 없으므로(§E9) **명단만으로는 대상 기업을 절대 알 수 없다.**

**틀린 기업을 고르면 규모가 크다.** 삭제 명단으로 `s1` 을 고르면 거기서 **사용중인 1,158명**이
중지된다. 등록 명단으로 `s1` 을 고르면 62명의 상태가 바뀐다. 이는 요구가 말한 "타 조직 사용자
정보를 제공할 때가 있다"보다 **한 단계 위의 축** — 조직(`org_id`)이 아니라 기업(`agc_id`) 교차다.
Q4 가 지정한 조직 판정만으로는 이 사고를 막지 못한다.

**관측 6 — 17차 요청은 이미 반영돼 있고, 33건이 남아 있다.** 등록 156 전건이 `s2` 에서
(사용, 구독)이고 삭제 7,583 이 (중지, 해지)다. 그런데 **삭제 요청 33건이 아직 (사용, 구독)** 이다.
누락인지 의도적 보류인지 이 창은 판정하지 않는다 — 운영에 확인할 사항으로 보고한다(Q16).

**재현** — 위 집계는 `MAX(sub_id) GROUP BY mem_id` 로 최신 구독을 잡고 `tc_common` 으로 코드를
이름으로 바꾼다. 코드의 `buildSubscribeStateQuery` 와 같은 정의다. `sub_state` 는 이름이 아니라
코드값(`SUB001` 구독 · `SUB002` 해지)임에 주의한다.

## Prior Art

**미조사.** 외부 문헌(엑셀 스키마 추론·헤더 매핑 UX·대량 업서트 프리뷰 패턴)은 이번 창에서 조사하지
않았다. `/deep-research` 등으로 별도 수행하면 이 절에 배치한다. 조사하지 않은 것을 비워 두지 않고
미조사로 적는다.

## Precedent

**같은 문제를 판정한 조사 문서는 없다.** `.claude/_meta/` 에는 `2026-08-26-70-viewer-reanalysis.md`
한 건뿐이고 주제가 겹치지 않는다. 밑줄 없는 `.claude/meta/` 14문서와 `.claude/prds/` 5건,
`.claude/plans/` 를 `계정등록`·`계정삭제`·`신규계정업로드`·`기업회원 계정` 으로 훑어 본문 일치는
`.claude/meta/backup-2026-08-05/insuniverse-admin/unstaged.patch` 1건뿐이며 이는 조사 문서가 아니라
백업 패치다. **선행 판정과 충돌하지 않는다.**

**선행 문서의 전제가 무효화된 자리는 없다.** 이 조사가 인용한 정책서 3건(`docs/30-admin/01-admin-console.md`
· `docs/30-admin/04-authority-management.md` · `docs/03-contracts/04-state-transitions.md`)의
`last_verified` 는 `4d798f55`·`fb001e1d` 이고, 이 조사가 읽은 서브모듈 핀과 같다. 상태 갱신을 제안할
문서가 없다.

**다만 이 조사는 정책서의 미상 하나를 메운다.** `admin-console-member-lifecycle` 이 "정지가 구독을
자동으로 무엇으로 바꾸는지 확정하지 못했다"고 남긴 자리(P26)에 대해, 기업FC 축에 한해 답이 나왔다 —
`resign()` 이 `useAt` 과 무관하게 전건을 `해지`로 민다(E1). 이는 그 문서가 다루는 `admin-user-user`
(일반회원) 축이 아니라 `agency-fc` 축의 관측이므로 **그 정책의 미상을 닫지는 않는다.** 정책서 갱신은
이 조사의 범위 밖이며, 해당 축의 문서를 고칠 때 이 문서를 인용하면 된다.

## Verdict

**새 파서를 먼저 만들면 안 된다. 고칠 순서는 아래 6단계이며, 1~2 를 건너뛰면 그 위에 무엇을 얹어도
요구 1번이 성립하지 않는다.**

**1. `resign()` 의 무조건 구독 해지를 `useAt` 으로 분기시킨다.** (E1·E2)
현재 `useAt=1` 이 사용상태를 되살리고 곧바로 구독을 해지한다. 요구 1번은 이 한 줄이 반대 방향인 한
어떤 화면을 만들어도 달성되지 않는다. 가장 작고 가장 앞선 수정이며, 이 저장소에서 "되살리기"의
원시연산은 이미 전부 있다(P16·P17) — 없는 것은 **호출 방향**이다.

**2. 등록요청을 "생성 또는 되살리기"로 받는 단일 진입을 정한다 — 단 대상 기업 확정이 선행한다.** (E4·E12)

**대상 기업을 틀리면 이 항목 전체가 흉기가 된다.** 같은 로그인아이디가 `s1`·`s2`·`s3` 에 동시에
존재하고(§E12 관측 5), 삭제 명단으로 `s1` 을 고르면 **사용중인 1,158명**이 중지된다. 파일에는 기업을
가리키는 값이 없다. 따라서 업로드는 기업 선택을 **필수 입력**으로 받고, 프리뷰(3)는 선택한 기업에서
몇 건이 매칭됐는지와 **다른 기업에서 몇 건이 매칭되는지를 함께** 보여야 한다 — 후자가 크면 기업을
잘못 고른 것이다. 이 대조는 `checkDuplicate` 가 `eq(agcId)` 로 이미 좁혀 보는 것과 반대 방향의
조회이므로 새로 만들어야 한다.

현행 `upload` 는 기존 계정 발견 시 전체를 거부한다(P11·P12). 요구 1·2번은 같은 파일 안에서 행마다
갈리는 업서트다. 선택지는 둘 — (a) `upload` 의 중복 판정을 행 단위 분기로 바꾼다, (b) 새 진입을
만들고 내부에서 기존 3경로(`create` · `resign(useAt=1)` · 구독 되살리기)를 조합한다. **(b)를 권한다.**
`upload` 의 "중복이면 거부"는 기업관리자가 쓰는 기존 화면의 계약이고, 그것을 바꾸면 이번 기능과
무관한 사용자에게 의미가 바뀐다.

**3. 반영 전 프리뷰(드라이런)를 만든다 — 이것이 요구의 본체다.** (E3·E6)
"테이블 첫 열 기준 사용자 최종 검토"는 파싱 기능이 아니라 **2단계 커밋**이다. 현행은 전건 검증 후
전건 반영이거나 전건 거부라 검토할 중간 상태가 없다. 출력 자료구조는 이미 있다(P23) — 그것을 반영
후가 아니라 **반영 전에** 내고, 사용자가 행을 고른 뒤 확정하는 단계를 추가한다. 이 단계가 서면
중복·타 조직·s 접두 판정이 전부 "거부 사유"가 아니라 "검토 항목"으로 바뀐다. 비밀번호 결측도 같다 —
"신규인데 비밀번호가 없다"는 전체 중단(E10 관측 3)이 아니라 그 행의 검토 항목이어야 한다(Q12).

**4. 파싱은 "헤더 매핑"이 아니라 "열 매핑을 사람이 확정하는 단계"다.** (E3·E8·E9)
입력 경로가 셋이고(E8) 그중 메일 표는 정본 양식과 **열 이름이 하나도 겹치지 않는다.** 위치 기준
파싱(P8)을 헤더 기반으로 바꾸는 것만으로는 `코드`→로그인아이디, `지점`→조직이 이어지지 않는다.
필요한 것은 **자동 추정 + 사람의 확정**이다 — 열 이름과 값의 모양으로 후보를 제시하고(`s`+8자리면
로그인아이디, 8자리 숫자면 `s` 를 붙일 코드), 화면에서 고치게 한다. 3의 프리뷰와 같은 화면에 놓인다.

**입력을 받는 문턱부터 둘 낮춘다.** 파싱 이전 단계이며, 지금 사람이 손으로 메우고 있는 자리다(E11).

- **암호 걸린 xlsx 를 그대로 받는다.** 삼성 원본은 암호화 CFB 이고 현행은 jszip 에러로 거부한다
  (E10). 업로드 화면에서 암호를 입력받아 서버에서 풀면, 지금의 "풀어서 다시 저장" 단계가 사라진다.
  최소한 **에러 메시지만이라도** 영어 예외 대신 "암호가 걸린 파일입니다"로 바꾼다 — 이것만으로도
  현행 실패가 해석 가능해진다.
- **메일 표 붙여넣기를 입력으로 받는다.** 표가 텍스트라 붙여넣기가 가능하고(Q11), 지금은 그것을
  xlsx 로 옮겨 적는 수기 단계가 통째로 들어가 있다. 붙여넣기를 받으면 그 단계가 사라진다.
  일부 지점이 xlsx 를 못 보내는 환경이라 이 경로는 구조적으로 남는다.

**동시에 정해야 할 것 셋.**

- **의도 구분**: 등록/삭제 두 파일의 헤더와 시트명이 같아(E3·E10) 파일만으로는 알 수 없다. 화면에서
  명시적으로 고르게 하고 파싱은 그 선택 아래에서 돈다.
- **`s` 접두**: detect 대상은 "s1/s2/s3 중 무엇인가"가 아니라 "이미 붙어 있는가"다(E9). 붙어 있으면
  그대로, 없으면 붙인다. 대상 기업은 접두로 풀리지 않으므로 화면에서 고른다.
- **`지점` 은 자동 변환하지 않는다**: 지점 9종과 조직 2종의 대응은 **아직 아무도 모른다**(E11).
  원문 그대로 프리뷰에 실어 사람이 판단하게 두고, 규약이 정해지면 그때 매핑을 넣는다. 모르는 것을
  추측으로 채우면 틀린 조직에 계정이 생긴다.

파서 4종(P8 외 3종)이 같은 모양이라 유틸 하나로 묶을 수 있으나, 기존 3종을 함께 바꾸면 기업관리자
화면의 동작이 바뀐다 — **이번 범위에서는 새 진입에만 적용한다.**

**5. 접근 제어는 두 층으로 나눈다 — 노출은 프런트, 인가는 서버 가드.** (E5·E7)
사용자가 지목한 "모니터링 페이지와 같은 조건"은 실제로 `.env` 가 아니라 메뉴 배분이고, 그 배분 코드가
분석 관리와 중복이라 이 기능만 따로 열 수 없다(P32). `.env` 선례는 테스터 라우트 쪽이다(P33).

- **노출**: 테스터와 같은 형태로 라우트를 조건부 등록한다(P33). 다만 `REACT_APP_BACKEND_HOST` 는
  stg/prod 를 가르는 값이지 사람을 가르는 값이 아니다 — 요구는 "최고관리자에게만"이므로 환경이 아니라
  **계정**으로 갈라야 하고, 그 판정은 프런트가 서버에서 받아야 한다.
- **인가**: 새 컨트롤러에 전용 가드를 명시한다. `@Roles` 누락이 곧 개방이므로(P25) 선언을 빠뜨리면
  조용히 열린다. 선례는 `CAR_VIEW_ADMIN_IDS` 형태의 `.env` allowlist + 빈 값 fail-closed(P22)이며,
  사용자가 기대한 "`.env` 로 관리"와 형태가 일치하는 것도 이쪽이다.

상단바 경로는 기존 Breadcrumb 재사용(P29)이라 비용이 없다.

**6. 타 조직 판정은 만들 수 있다 — 단 "이번 요청의 관할 조직"을 입력으로 받아야 한다.** (E4·E7)
Q4 가 `org_id` 로 닫혀 판정 수단이 확정됐다. `orgPath.startsWith()` 부분트리 비교(P36)가 이미 있고
운영 조직은 2개다. 기존 두 겹(P20·P21)은 내부 관리자 경로에서 꺼지므로 새로 걸어야 한다.

문제는 입력이다. 삼성 양식에 조직 열이 없어(E3) **신규 계정은 소속을 알 수 없고 기존 계정만
`tn_member.org_id` 로 대조된다.** 그래서 업로드 화면에서 관할 조직을 고르게 하고(Q8), 대조 결과를
차단이 아니라 3의 프리뷰에 검토 항목으로 싣는다 — 요구가 말한 것은 거부가 아니라 "제공할 때가
있다"는 사실의 가시화다.

**범위에 관한 판정 하나 더.** 요구는 "운영 수준 평가 후 기업관리자에 제공"을 말한다. 그렇다면 이번
구현은 기존 4개 업로드 경로를 **건드리지 않고 그 옆에 서야 한다.** 기존 경로를 고쳐 겸용하면, 평가가
끝나기 전에 기업관리자 화면의 계약이 먼저 바뀐다. 위 1번만이 기존 경로를 고치는 항목인데, 그것은
겸용을 위한 변경이 아니라 **현행 동작이 스스로와 모순되는 자리의 수정**이다.

### MVP 경계와 이월 — 후속 PRD 로 반드시 넘길 항목

> **후속 PRD 노티 (M-last).** 아래 항목은 이번 MVP 범위 **밖**이다. PRD 를 쓸 때 **마일스톤 표의
> 마지막 행**으로 싣되 `status` 를 `deferred` 로 두고, 본문에 "별도 PRD 필요"를 명시한다.
> 이 조사 문서를 인용하는 PRD 가 이 절을 옮겨 싣지 않으면 **누락이다.**

**M-last — 최고관리자 계정을 구분·생성하는 관리 액션 (MVP 제외 · 별도 PRD 필요)**

2026-09-16 사용자 지시: **`.env` 에 계정 id 를 적는 방식을 걷어내고 싶다.** 1차는 그 방식으로
진행하되(Verdict 5 · Q9 답), 최고관리자를 **화면에서 구분하고 생성하는 액션**을 후속으로 세운다.

- **왜 MVP 에서 빼는가** — 이 항목은 계정 등록 인테이크가 아니라 **관리자 권한 모델**의 변경이다.
  `tn_auth` 권한 그룹(P30)·`tn_admin.athId`·`RolesGuard`(P25)·`CAR_VIEW_ADMIN_IDS`(P22)가 함께
  걸리고, 지금 이 기능 하나 때문에 그 모델을 바꾸면 권한 관리 화면 전체의 계약이 따라 움직인다.
- **왜 잊으면 안 되는가** — `.env` allowlist 는 배포 없이 사람을 못 바꾼다. 운영 평가 단계에서
  대상자가 늘거나 바뀌면 그때마다 배포가 필요하고, 그 불편이 곧 "걷어내고 싶다"의 이유다.
  MVP 가 굳으면 그 불편이 영구화된다.
- **후속 PRD 가 답해야 할 것** — 최고관리자를 어떤 층으로 표현하는가(새 role · `tn_auth` 그룹 ·
  `tn_admin` 플래그), 누가 그것을 부여하는가, 부여 변경이 열린 세션에 언제 반영되는가
  (정책 `authority-session-propagation` 과 맞물린다), 그리고 **`.env` allowlist 를 어떻게
  철거하는가**(이행 기간 동안 두 근거가 공존하면 어느 쪽이 이기는지 정해야 한다).
- **철거 대상이 코드에 남는다** — 1차에서 도입할 `.env` allowlist 는 이 후속이 끝나면 지워질
  임시물이다. 도입 지점에 그 사실을 주석으로 남겨 두면 후속에서 찾기 쉽다.

**M-last+1 — 요청 접수 창구 자체를 메일에서 관리자로 옮기는 방향 (MVP 제외 · 별도 PRD 필요)**

이 항목은 사용자가 이월을 지시한 것이 아니라 **이 조사가 이월을 권하는 것**이다. 2026-09-16 사용자
관측: "관리자를 제공하니까 메일이 아닌 여기서 처리하는 게 제일 좋다 — 고객센터 같은 느낌으로.
사이트에 메일을 올리면 자동 처리하거나, 우리에게 포맷팅된 메일로 전달하거나, 여러 방법이 있을 것."

- **왜 MVP 에서 빼는가** — 이번 MVP 는 *이미 도착한 요청*을 처리하는 기능이고, 이 항목은 *요청이
  도착하는 방식*을 바꾼다. 상대가 삼성화재 지점 담당자들이라 우리 쪽 구현만으로 닫히지 않는다.
- **왜 지금 적어 두는가** — Q10(지점↔조직 매핑)이 열린 채 남는 이유가 바로 이것이다. 창구를 옮기면
  지점을 **자유 텍스트가 아니라 선택지**로 받을 수 있어 매핑 문제가 발생하지 않는다. 즉 이 항목은
  Q10 의 우회가 아니라 **근본 해결**이며, MVP 의 수기 판단은 그때까지의 임시 조치다.
- **후속 PRD 가 답해야 할 것** — 지점 담당자에게 계정을 주는가(그러면 기업관리자 권한 모델과 만난다),
  아니면 로그인 없는 제출 폼인가. 제출 후 우리 쪽 검토 흐름은 지금의 프리뷰와 같은 것인가 다른 것인가.
  기존 메일 경로를 언제까지 병행하는가.

## Open Questions

- ~~**Q1 — 삼성화재가 실제로 여러 기업(`agc_id`)으로 서 있는가.**~~
  **닫힘 (2026-09-16, 운영 DB 실측)** — `agc_id` 30/34/38 이 각각 코드 `s1`/`s2`/`s3` 이고 사업자번호가
  모두 다르다. 여기에 `sf`(21)가 하나 더 있다(Q15). 조직은 `s2` 에만 2개다. **명단이 어느 기업 건인지는
  파일로 알 수 없음이 확정됐다** — 같은 아이디가 여러 기업에 있다(§E12 관측 5). 귀결은 Verdict 2.
- ~~**Q2 — `mem_is_resigned=1` 이면서 `sub_state='구독'` 인 행이 운영에 있는가.**~~
  **닫힘 (2026-09-16, 운영 DB 실측)** — **0건이다**(§E12 관측 4). 사용자 예상과 달랐다. 따라서
  `resign(useAt=1)` 의 무조건 해지는 지금 피해를 내지 않는다. 다만 0건은 불변식이 아니라 현재 값이고,
  **되살리기를 구현하면 그 중간 상태가 곧 이 조합이다** — Verdict 1 은 그대로 유효하다.
- ~~**Q3 — 실제 `.xlsx` 의 빈 행이 `eachRow` 에 실려 P9 의 throw 를 일으키는가.**~~
  **닫힘 (2026-09-16, 실측)** — 일으키지 않는다. exceljs 가 완전 빈 행을 건너뛴다(§E10).
  대신 **원본이 암호화돼 있어 파서가 열지도 못한다**는 더 앞선 장벽이 드러났다. Verdict 4.
- ~~**Q4 — "자기 관할이 아닌 타 조직"의 기준이 기업(`agc_id`)인가 조직(`org_id`)인가.**~~
  **닫힘 (2026-09-16, 사용자)** — `org_id` 기준이며 운영 조직은 '지방사업부'·'수도권사업부' 2개다.
  귀결은 §E7 과 Verdict 6. 후속으로 Q8 이 열렸다.
- ~~**Q5 — 요구의 "최고관리자"가 내부 운영사 관리자인가.**~~
  **닫힘 (2026-09-16, 사용자)** — 내부 운영사 관리자가 맞다. 함께 지시된 "모니터링 페이지와 같은
  조건(`.env` 관리)"은 **실측과 어긋난다** — 그 페이지는 `.env` 가 아니라 메뉴 배분으로 노출되고
  배분 코드가 분석 관리와 중복이다(§E7). 귀결은 Verdict 5. 후속으로 Q9 가 열렸다.
- **Q6 — 7,788행 규모의 처리 시간과 부분 실패 복구를 어디까지 보장하는가.** 정책 층에도 부분 실패
  표시 규약이 없다(P27). 프리뷰(3번)가 서면 "검토 시점의 스냅샷과 확정 시점의 DB 가 어긋나는" 창도
  함께 생긴다.
- **Q7 — 비밀번호를 평문으로 받는 현행 양식을 유지하는가.** 요청 파일에 평문 비밀번호가 들어 있다.
  이 조사는 그 값을 옮기지 않았으나, 기능이 서면 그 파일이 서버로 업로드된다. 취급 규약이 필요하다.
- **Q8 — 업로드 시 "이번 요청의 관할 조직"을 무엇으로 정하는가.** Q4 가 `org_id` 로 닫혔으나 파일에
  조직 정보가 없다(E7). 화면에서 조직을 고르게 하는가, 로그인한 관리자에 조직을 매어 두는가, 아니면
  신규 계정은 조직 미배정으로 두고 기존 계정만 대조하는가. 이것이 정해져야 Verdict 6 을 만든다.
- ~~**Q9 — 최고관리자 판정을 무엇으로 하는가.**~~
  **닫힘 (2026-09-16, 사용자)** — 1차는 `.env` 계정 allowlist(선택 1). 다만 **그 방식 자체를 걷어내는
  것이 목표**이며, 최고관리자를 화면에서 구분·생성하는 액션은 MVP 밖 후속 PRD 로 세운다.
  귀결은 Verdict §MVP 경계와 이월 `M-last`.
- **Q10 — 메일 표의 `지점`(9종 관측)과 운영 조직 2종은 어떻게 대응하는가.** **열린 채로 남는다 —
  성격이 바뀌었다.** 2026-09-16 사용자 답: 자신도 모르며 지금은 수기 판단으로 흡수하고 있다. 즉
  구현이 풀 문제가 아니라 **삼성화재와 정할 업무 규약**이다. 정해지기 전까지 자동 변환하지 않는다
  (Verdict 4). 근본 해결은 Verdict §MVP 경계와 이월 `M-last+1`.
- ~~**Q11 — 메일 본문 표를 어떤 형태로 받는가.**~~
  **닫힘 (2026-09-16, 사용자)** — 이미지가 아니라 텍스트 표라 **붙여넣기가 가능하다.** 현행은 그것을
  우리 양식으로 전부 수기 매핑해 xlsx 로 만든 뒤 업로드한다. 귀결은 Verdict 4(§E11).
- ~~**Q12 — 비밀번호 생성 규칙을 시스템이 정하는가.**~~
  **닫힘 (2026-09-16, 사용자)** — 받은 대로 쓴다. 다만 **신규 등록인데 없으면 사용자에게 알린다** —
  현행은 알리는 대신 업로드 전체를 끊는다(§E10 관측 3). 귀결은 Verdict 3.
- ~~**Q13 — xlsx 열람 암호를 매번 어떻게 얻는가.**~~
  **닫힘 (2026-09-16, 사용자)** — 값은 늘 같을 것으로 예상하나 **하드코딩하지 않고 화면에서 입력받는다.**
  근거 둘: `2026` 은 연도로 보여 해가 바뀌면 달라질 수 있고, 삼성화재 외 기업도 같은 경로를 쓸 수
  있다. 즉 기본값 없는 입력 필드이며 기업·회차마다 다른 값을 허용해야 한다. 귀결은 Verdict 4.
- ~~**Q14 — 지점↔조직 규약을 누가 삼성화재와 정하는가.**~~
  **닫힘 (2026-09-16, 사용자)** — **삼성화재가 정한다.** 다만 시점은 지금이 아니라 **MVP 구현 후**이며,
  신규 건의 지점 처리 방식을 그때 추가 논의한다. 따라서 Q10 은 MVP 기간 내내 열린 채로 두고 수기
  판단으로 간다 — 이것이 Verdict 4 의 "자동 변환하지 않는다"를 뒷받침한다.
- **Q15 — `sf`(agc_id 21, 사업자번호 111-11-11114)는 시험 기업인가.** 사업자번호 형태와 로그인아이디
  분포(숫자 시작 504건 · `at` 1건)가 다른 세 기업과 다르다. 운영 행이라면 이 기능의 기업 선택
  목록에 실려야 하고, 시험 행이라면 빼야 한다.
- **Q16 — 17차 삭제 요청 중 33건이 아직 (사용, 구독) 상태인 이유는.** 나머지 7,583건은 (중지, 해지)로
  처리됐다(§E12 관측 6). 처리 누락이면 이번 기능이 잡아야 할 사고 유형이고, 의도적 보류면 그 판단을
  어디에 기록하는지가 프리뷰 설계에 들어간다.

