이호영 이호영 07-28
feat: 빨간 글씨 체크로 답 주신 항목 반영 (18·19·22·26)
검토본의 답변은 빨간 글씨 + 밑줄 체크로도 표시돼 있었는데, 처음 읽을 때 글자 색을
빼고 텍스트만 뽑아 10개 항목을 "무응답"으로 잘못 분류했다. 색까지 다시 읽어 확인한
결과 12번(이해 안 감)을 뺀 27개 항목에 모두 답이 있었다.

그중 손대야 하는 4개를 반영한다. 나머지 6개(1·3·9·13·14·24)는 "현행 유지"라
이미 맞는 상태다.

  [18] 자동 단계 전환 3종을 되살렸다.
       채널에 자료 업로드 → ②목록접수
       권리확인 자료 업로드 → ⑤법률검토(권리확인)
       권리처리 자료 업로드 → ⑧법률검토(권리처리)
       앞으로만 간다. 이미 그보다 앞서 있으면 아무 일도 하지 않는다 - 자료를 다시
       올렸다고 단계가 뒤로 밀리면 안 된다. 채널이 없어 단계가 아직 없는 기관도
       건드리지 않는다.
  [19] 되돌림을 허용하고, 이력에 자동/수동 구분과 계기를 남긴다.
  [22] 업무메모·연락이력·보고서 작성자가 로그인 아이디(admin)가 아니라 표시 이름으로
       찍힌다. 담당자별 계정이 생기면 CurrentUser 한 곳만 고치면 된다.
  [26] 자료실을 열었다. 기관에 딸리지 않는 공용 서식을 올려두고 내려받는다.

어느 자료가 어느 단계에 해당하는지는 자료의 지시를 이 시스템 흐름에 맞춰 옮긴 것이라
답변 문서에 적어 확인 요청해 둔다.

백엔드 281건, 프론트 209건 통과.

Co-Authored-By: Claude Opus 5 (1M context) 
@9e8c4cea4806d08c896f3956c32015de3920d75c
frontend/src/App.tsx
--- frontend/src/App.tsx
+++ frontend/src/App.tsx
@@ -8,6 +8,7 @@
 import OrgOverview from './components/OrgOverview'
 import ProjectsPage from './components/ProjectsPage'
 import SeedUpload from './components/SeedUpload'
+import SharedFiles from './components/SharedFiles'
 import Sidebar from './components/Sidebar'
 import SystemPage from './components/SystemPage'
 import { CodeProvider } from './codes/CodeProvider'
@@ -16,7 +17,7 @@
 // 화면 4개: Dashboard(전체 기관 현황 - 좌측 기관 목록 없이 전폭으로 쓴다),
 // 기관관리(시안 레이아웃의 기관 상세), 채널관리(명부 시드 + 담당자 배정 + 채널 생성),
 // 담당자관리(담당자 3종 CRUD - 담당자 정보가 입력되는 유일한 화면).
-type Page = 'dashboard' | 'orgs' | 'channels' | 'contacts' | 'projects' | 'system'
+type Page = 'dashboard' | 'orgs' | 'channels' | 'contacts' | 'projects' | 'files' | 'system'
 
 const PAGE_TITLE: Record<Page, string> = {
   dashboard: 'Dashboard',
@@ -24,6 +25,7 @@
   channels: '채널관리',
   contacts: '담당자관리',
   projects: '사업관리',
+  files: '자료실',
   system: '시스템관리',
 }
 
@@ -117,6 +119,7 @@
             key === 'channels' ||
             key === 'contacts' ||
             key === 'projects' ||
+            key === 'files' ||
             key === 'system'
           ) {
             setPage(key)
@@ -140,7 +143,8 @@
         </header>
 
         <div className="flex min-h-0 flex-1">
-          {page !== 'contacts' && page !== 'dashboard' && page !== 'system' && page !== 'projects' && (
+          {page !== 'contacts' && page !== 'dashboard' && page !== 'system'
+            && page !== 'projects' && page !== 'files' && (
             <div
               className={`flex shrink-0 flex-col border-r border-gray-200 transition-all duration-200 ${
                 listCollapsed ? 'w-14' : 'w-80'
@@ -176,6 +180,8 @@
                   setPage('orgs')
                 }}
               />
+            ) : page === 'files' ? (
+              <SharedFiles />
             ) : page === 'system' ? (
               <SystemPage onChanged={() => void reload()} />
             ) : page === 'contacts' ? (
frontend/src/api/client.ts
--- frontend/src/api/client.ts
+++ frontend/src/api/client.ts
@@ -171,6 +171,58 @@
   return request<CodeMap>('/api/meta/codes')
 }
 
+/** 자료실에 올려둔 공용 서식(요구사항 [26]). 기관에 딸리지 않는다. */
+export interface SharedFile {
+  id: number
+  fileName: string
+  description: string | null
+  byteSize: number
+  uploadedBy: string
+  uploadedAt: number
+}
+
+export function getSharedFiles(): Promise<SharedFile[]> {
+  return request<SharedFile[]>('/api/shared-files')
+}
+
+export async function addSharedFile(file: File, description: string): Promise<SharedFile[]> {
+  const form = new FormData()
+  form.append('file', file)
+  if (description) {
+    form.append('description', new Blob([description], { type: 'text/plain' }))
+  }
+  const res = await fetch('/api/shared-files', {
+    method: 'POST',
+    headers: { 'X-XSRF-TOKEN': csrfToken() },
+    credentials: 'same-origin',
+    body: form,
+  })
+  if (!res.ok) {
+    throw new ApiError(res.status, `자료 업로드 실패 (${res.status}): ${await res.text()}`)
+  }
+  return res.json() as Promise<SharedFile[]>
+}
+
+export function updateSharedFileDescription(id: number, description: string): Promise<SharedFile[]> {
+  return request<SharedFile[]>(`/api/shared-files/${id}`, {
+    method: 'PUT',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ description }),
+  })
+}
+
+export async function deleteSharedFile(id: number): Promise<void> {
+  await fetch(`/api/shared-files/${id}`, {
+    method: 'DELETE',
+    headers: { 'X-XSRF-TOKEN': csrfToken() },
+    credentials: 'same-origin',
+  })
+}
+
+export function sharedFileDownloadUrl(id: number): string {
+  return `/api/shared-files/${id}`
+}
+
 /** RE 메모 한 건. 요구사항 [4]대로 메모 1개가 RE 1개다. */
 export interface ReMemo {
   id: number
 
frontend/src/components/SharedFiles.tsx (added)
+++ frontend/src/components/SharedFiles.tsx
@@ -0,0 +1,213 @@
+import { useEffect, useRef, useState } from 'react'
+import {
+  addSharedFile,
+  deleteSharedFile,
+  getSharedFiles,
+  sharedFileDownloadUrl,
+  updateSharedFileDescription,
+  type SharedFile,
+} from '../api/client'
+
+/**
+ * 자료실 — 공용 양식 보관소(요구사항 [26]).
+ *
+ * 기관에 딸리지 않는 서식을 여기 올려 두고, 어느 기관 일을 하든 내려받아 씁니다.
+ * 기관별 자료는 기관관리 > 자료 탭에 그대로 있습니다.
+ */
+function formatSize(bytes: number): string {
+  if (bytes < 1024) {
+    return `${bytes} B`
+  }
+  if (bytes < 1024 * 1024) {
+    return `${(bytes / 1024).toFixed(1)} KB`
+  }
+  return `${(bytes / 1024 / 1024).toFixed(1)} MB`
+}
+
+function formatDate(ms: number): string {
+  const d = new Date(ms)
+  const pad = (n: number) => String(n).padStart(2, '0')
+  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
+}
+
+export default function SharedFiles() {
+  const [files, setFiles] = useState<SharedFile[]>([])
+  const [loading, setLoading] = useState(true)
+  const [error, setError] = useState<string | null>(null)
+  const [busy, setBusy] = useState(false)
+  const [description, setDescription] = useState('')
+  const [editingId, setEditingId] = useState<number | null>(null)
+  const [editingText, setEditingText] = useState('')
+  const inputRef = useRef<HTMLInputElement>(null)
+
+  async function load() {
+    setLoading(true)
+    try {
+      setFiles(await getSharedFiles())
+      setError(null)
+    } catch (e) {
+      setError((e as Error).message)
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  useEffect(() => {
+    void load()
+  }, [])
+
+  async function handleUpload(file: File) {
+    setBusy(true)
+    setError(null)
+    try {
+      setFiles(await addSharedFile(file, description.trim()))
+      setDescription('')
+    } catch (e) {
+      setError((e as Error).message)
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  async function handleDelete(file: SharedFile) {
+    if (!window.confirm(`「${file.fileName}」을(를) 지울까요?`)) {
+      return
+    }
+    setBusy(true)
+    try {
+      await deleteSharedFile(file.id)
+      await load()
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  async function saveDescription(file: SharedFile) {
+    setBusy(true)
+    try {
+      setFiles(await updateSharedFileDescription(file.id, editingText.trim()))
+      setEditingId(null)
+    } catch (e) {
+      setError((e as Error).message)
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  return (
+    <div className="p-8">
+      <h2 className="text-sm font-semibold">자료실</h2>
+      <p className="mt-1 text-xs text-gray-500">
+        기관에 딸리지 않는 공용 서식을 올려 둡니다. 기관별 자료는 기관관리 &gt; 자료 탭에 있습니다.
+      </p>
+
+      <div className="mt-4 flex flex-wrap items-center gap-2 rounded-lg border border-gray-200 p-3">
+        <input
+          type="text"
+          aria-label="자료 설명"
+          value={description}
+          onChange={(e) => setDescription(e.target.value)}
+          placeholder="설명(선택)"
+          className="w-72 rounded-md border border-gray-300 px-2 py-1.5 text-sm"
+        />
+        <input
+          ref={inputRef}
+          type="file"
+          className="hidden"
+          onChange={(e) => {
+            const file = e.target.files?.[0]
+            if (file) void handleUpload(file)
+            e.target.value = ''
+          }}
+        />
+        <button
+          type="button"
+          disabled={busy}
+          onClick={() => inputRef.current?.click()}
+          className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white disabled:bg-gray-300"
+        >
+          자료 올리기
+        </button>
+      </div>
+
+      {error && <p className="mt-2 text-sm text-red-600">{error}</p>}
+
+      {loading ? (
+        <p className="mt-4 text-sm text-gray-500">불러오는 중…</p>
+      ) : files.length === 0 ? (
+        <p className="mt-6 text-sm text-gray-500">올려둔 자료가 없습니다.</p>
+      ) : (
+        <table data-testid="shared-file-table" className="mt-4 w-full text-sm">
+          <thead className="border-b border-gray-200 text-left text-xs text-gray-500">
+            <tr>
+              <th className="px-3 py-2 font-medium">파일명</th>
+              <th className="px-3 py-2 font-medium">설명</th>
+              <th className="whitespace-nowrap px-3 py-2 font-medium">크기</th>
+              <th className="whitespace-nowrap px-3 py-2 font-medium">올린 사람</th>
+              <th className="whitespace-nowrap px-3 py-2 font-medium">올린 날짜</th>
+              <th className="px-3 py-2" />
+            </tr>
+          </thead>
+          <tbody>
+            {files.map((file) => (
+              <tr key={file.id} className="border-b border-gray-100 last:border-b-0">
+                <td className="px-3 py-2">
+                  <a
+                    href={sharedFileDownloadUrl(file.id)}
+                    className="font-medium text-blue-700 hover:underline"
+                  >
+                    {file.fileName}
+                  </a>
+                </td>
+                <td className="px-3 py-2 text-gray-600">
+                  {editingId === file.id ? (
+                    <div className="flex items-center gap-1.5">
+                      <input
+                        aria-label="설명 수정"
+                        value={editingText}
+                        onChange={(e) => setEditingText(e.target.value)}
+                        className="w-56 rounded-md border border-gray-300 px-2 py-1 text-sm"
+                      />
+                      <button
+                        type="button"
+                        disabled={busy}
+                        onClick={() => void saveDescription(file)}
+                        className="rounded-md border border-gray-300 px-2 py-0.5 text-xs"
+                      >
+                        저장
+                      </button>
+                    </div>
+                  ) : (
+                    <button
+                      type="button"
+                      onClick={() => {
+                        setEditingId(file.id)
+                        setEditingText(file.description ?? '')
+                      }}
+                      className="text-left hover:underline"
+                    >
+                      {file.description ?? <span className="text-gray-300">설명 없음</span>}
+                    </button>
+                  )}
+                </td>
+                <td className="whitespace-nowrap px-3 py-2 text-gray-500">{formatSize(file.byteSize)}</td>
+                <td className="whitespace-nowrap px-3 py-2 text-gray-500">{file.uploadedBy}</td>
+                <td className="whitespace-nowrap px-3 py-2 text-gray-500">{formatDate(file.uploadedAt)}</td>
+                <td className="px-3 py-2 text-right">
+                  <button
+                    type="button"
+                    disabled={busy}
+                    onClick={() => void handleDelete(file)}
+                    className="rounded-md border border-gray-300 px-2 py-0.5 text-xs text-red-600 hover:bg-red-50"
+                  >
+                    삭제
+                  </button>
+                </td>
+              </tr>
+            ))}
+          </tbody>
+        </table>
+      )}
+    </div>
+  )
+}
frontend/src/components/Sidebar.test.tsx
--- frontend/src/components/Sidebar.test.tsx
+++ frontend/src/components/Sidebar.test.tsx
@@ -40,13 +40,23 @@
     expect(onSelect).toHaveBeenCalledWith('channels')
   })
 
+  // 자료실은 요구사항 [26]으로 열렸다. 아직 준비 중인 메뉴는 권리확인·권리처리·Mattermost다.
   it('미구현 메뉴는 비활성으로 표시되고 링크가 아니다', () => {
     render(<Sidebar />)
 
-    const files = screen.getByText('자료실')
-    expect(files).toHaveAttribute('aria-disabled', 'true')
-    expect(files).toHaveAttribute('title', '준비 중')
-    expect(screen.queryByRole('link', { name: '자료실' })).toBeNull()
+    const mattermost = screen.getByText('Mattermost')
+    expect(mattermost).toHaveAttribute('aria-disabled', 'true')
+    expect(mattermost).toHaveAttribute('title', '준비 중')
+    expect(screen.queryByRole('link', { name: 'Mattermost' })).toBeNull()
+  })
+
+  it('자료실은 구현되어 링크로 열린다', () => {
+    const onSelect = vi.fn()
+    render(<Sidebar onSelect={onSelect} />)
+
+    fireEvent.click(screen.getByRole('link', { name: '자료실' }))
+
+    expect(onSelect).toHaveBeenCalledWith('files')
   })
 
   it('Dashboard는 구현되어 링크로 열린다', () => {
@@ -73,7 +83,7 @@
     expect(screen.getByText('IH')).toBeTruthy()
 
     expect(screen.getByRole('link', { name: '기관관리' })).toHaveAttribute('title', '기관관리')
-    expect(screen.getByTitle('자료실 (준비 중)')).toBeTruthy()
+    expect(screen.getByTitle('Mattermost (준비 중)')).toBeTruthy()
   })
 
   it('접기 버튼을 누르면 onToggle이 호출된다', () => {
frontend/src/components/Sidebar.tsx
--- frontend/src/components/Sidebar.tsx
+++ frontend/src/components/Sidebar.tsx
@@ -89,8 +89,9 @@
   },
   {
     key: 'files',
+    // 요구사항 [26]: 공용 양식 보관소. 기관에 딸리지 않는 서식을 여기 올려 둔다.
     label: '자료실',
-    enabled: false,
+    enabled: true,
     icon: (
       <svg className={ICON_CLASS} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
         <path d="M3 7h18v4H3zM5 11v9a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-9M10 15h4" />
src/main/java/kr/itn/itnhub/config/AdminProperties.java
--- src/main/java/kr/itn/itnhub/config/AdminProperties.java
+++ src/main/java/kr/itn/itnhub/config/AdminProperties.java
@@ -2,13 +2,23 @@
 
 import org.springframework.boot.context.properties.ConfigurationProperties;
 
+/**
+ * @param displayName 업무메모·연락이력·보고서의 작성자로 찍히는 이름(요구사항 [22]).
+ *                    예전에는 로그인 아이디(admin)가 그대로 찍혔다. 비워 두면 아이디를 쓴다.
+ */
 @ConfigurationProperties(prefix = "app.admin")
-public record AdminProperties(String username, String password) {
+public record AdminProperties(String username, String password, String displayName) {
+
+    /** 화면에 보일 이름. 설정이 없으면 로그인 아이디로 떨어진다. */
+    public String displayNameOrUsername() {
+        return (displayName == null || displayName.isBlank()) ? username : displayName;
+    }
 
     // record 기본 toString()은 password를 그대로 노출한다. 로그에 실수로 찍혀도
     // 원문이 남지 않도록 마스킹한다.
     @Override
     public String toString() {
-        return "AdminProperties[username=%s, password=****]".formatted(username);
+        return "AdminProperties[username=%s, password=****, displayName=%s]"
+                .formatted(username, displayName);
     }
 }
 
src/main/java/kr/itn/itnhub/config/CurrentUser.java (added)
+++ src/main/java/kr/itn/itnhub/config/CurrentUser.java
@@ -0,0 +1,35 @@
+package kr.itn.itnhub.config;
+
+import org.springframework.stereotype.Component;
+
+import java.security.Principal;
+
+/**
+ * 기록에 남길 "작성자 이름"을 한 곳에서 정한다(요구사항 [22]).
+ *
+ * <p>예전에는 각 컨트롤러가 {@code principal.getName()}을 그대로 써서 업무메모·연락이력·
+ * 보고서에 로그인 아이디(admin)가 찍혔다. 지금은 설정에 넣어둔 표시 이름을 쓴다.</p>
+ *
+ * <p>나중에 담당자별 계정이 생기면 여기만 고쳐서 각자의 이름이 찍히게 하면 된다.</p>
+ */
+@Component
+public class CurrentUser {
+
+    private final AdminProperties admin;
+
+    public CurrentUser(AdminProperties admin) {
+        this.admin = admin;
+    }
+
+    /** 기록에 남길 이름. 로그인한 사람이 관리자면 설정된 표시 이름을 쓴다. */
+    public String displayName(Principal principal) {
+        if (principal == null) {
+            return admin.displayNameOrUsername();
+        }
+        if (principal.getName().equals(admin.username())) {
+            return admin.displayNameOrUsername();
+        }
+        // 관리자 외 계정이 생기면 그 계정의 아이디를 그대로 쓴다.
+        return principal.getName();
+    }
+}
src/main/java/kr/itn/itnhub/feed/ChannelFeedService.java
--- src/main/java/kr/itn/itnhub/feed/ChannelFeedService.java
+++ src/main/java/kr/itn/itnhub/feed/ChannelFeedService.java
@@ -35,12 +35,15 @@
     private final OrganizationMapper orgMapper;
     private final MattermostClient mattermost;
     private final MattermostProperties mattermostProps;
+    private final kr.itn.itnhub.stage.StageService stageService;
 
     public ChannelFeedService(OrganizationMapper orgMapper, MattermostClient mattermost,
-                               MattermostProperties mattermostProps) {
+                               MattermostProperties mattermostProps,
+                               kr.itn.itnhub.stage.StageService stageService) {
         this.orgMapper = orgMapper;
         this.mattermost = mattermost;
         this.mattermostProps = mattermostProps;
+        this.stageService = stageService;
     }
 
     public List<PostView> posts(Long orgId, String channel) {
@@ -69,6 +72,13 @@
         String postId = mattermost.createPost(channelId, message, fileIds);
         PostView post = fetchPost(channelId, postId, message);
 
+        // 요구사항 [18]: 채널에 자료가 올라가면 ②목록접수로 저절로 넘어간다.
+        // 파일이 실린 게시글만 계기로 본다 - 그냥 대화만 오간 것은 자료 접수가 아니다.
+        if (!fileIds.isEmpty()) {
+            stageService.autoAdvance(orgId, kr.itn.itnhub.stage.AutoStage.LIST_RECEIVED,
+                    kr.itn.itnhub.stage.AutoStage.REASON_LIST_RECEIVED);
+        }
+
         if (!notice) {
             return new SendResult(post, null);
         }
src/main/java/kr/itn/itnhub/memo/WorkMemoController.java
--- src/main/java/kr/itn/itnhub/memo/WorkMemoController.java
+++ src/main/java/kr/itn/itnhub/memo/WorkMemoController.java
@@ -19,9 +19,12 @@
 public class WorkMemoController {
 
     private final WorkMemoService memoService;
+    private final kr.itn.itnhub.config.CurrentUser currentUser;
 
-    public WorkMemoController(WorkMemoService memoService) {
+    public WorkMemoController(WorkMemoService memoService,
+                              kr.itn.itnhub.config.CurrentUser currentUser) {
         this.memoService = memoService;
+        this.currentUser = currentUser;
     }
 
     @GetMapping("/api/orgs/{id}/memos")
@@ -29,11 +32,14 @@
         return memoService.list(id);
     }
 
-    /** 작성자는 본문에서 받지 않고 로그인 사용자(principal)를 서버가 직접 기록한다. */
+    /**
+     * 작성자는 본문에서 받지 않고 로그인 사용자를 서버가 직접 기록한다.
+     * 요구사항 [22]로 아이디가 아니라 표시 이름이 찍힌다.
+     */
     @PostMapping("/api/orgs/{id}/memos")
     public WorkMemo create(@PathVariable Long id, @Valid @RequestBody MemoRequest request,
                            Principal principal) {
-        return memoService.create(id, request, principal.getName());
+        return memoService.create(id, request, currentUser.displayName(principal));
     }
 
     @PutMapping("/api/orgs/{id}/memos/{memoId}")
src/main/java/kr/itn/itnhub/process/ProcessImportService.java
--- src/main/java/kr/itn/itnhub/process/ProcessImportService.java
+++ src/main/java/kr/itn/itnhub/process/ProcessImportService.java
@@ -17,13 +17,16 @@
     private final ProcessParser parser;
     private final ProcessItemMapper mapper;
     private final OrganizationMapper orgMapper;
+    private final kr.itn.itnhub.stage.StageService stageService;
     private final TransactionTemplate transactionTemplate;
 
     public ProcessImportService(ProcessParser parser, ProcessItemMapper mapper, OrganizationMapper orgMapper,
+                                 kr.itn.itnhub.stage.StageService stageService,
                                  PlatformTransactionManager transactionManager) {
         this.parser = parser;
         this.mapper = mapper;
         this.orgMapper = orgMapper;
+        this.stageService = stageService;
         this.transactionTemplate = new TransactionTemplate(transactionManager);
     }
 
@@ -35,7 +38,15 @@
             throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId);
         }
         List<ProcessRow> rows = parser.parse(xlsx);
-        return transactionTemplate.execute(status -> importRows(orgId, rows));
+        ProcessImportReport report = transactionTemplate.execute(status -> importRows(orgId, rows));
+
+        // 요구사항 [18]: 권리처리 자료가 올라오면 ⑧법률검토(권리처리)로 저절로 넘어간다.
+        // 이미 그보다 앞서 있으면 아무 일도 하지 않는다.
+        if (report != null && report.total() > 0) {
+            stageService.autoAdvance(orgId, kr.itn.itnhub.stage.AutoStage.PROCESS_STARTED,
+                    kr.itn.itnhub.stage.AutoStage.REASON_PROCESS_STARTED);
+        }
+        return report;
     }
 
     private ProcessImportReport importRows(Long orgId, List<ProcessRow> rows) {
src/main/java/kr/itn/itnhub/project/ProjectController.java
--- src/main/java/kr/itn/itnhub/project/ProjectController.java
+++ src/main/java/kr/itn/itnhub/project/ProjectController.java
@@ -29,9 +29,12 @@
 public class ProjectController {
 
     private final ProjectService projectService;
+    private final kr.itn.itnhub.config.CurrentUser currentUser;
 
-    public ProjectController(ProjectService projectService) {
+    public ProjectController(ProjectService projectService,
+                             kr.itn.itnhub.config.CurrentUser currentUser) {
         this.projectService = projectService;
+        this.currentUser = currentUser;
     }
 
     @GetMapping("/api/projects")
@@ -49,7 +52,7 @@
     public List<ContactLog> addLog(@PathVariable("id") Long orgId,
                                    @Valid @RequestBody ContactLogRequest request,
                                    Principal principal) {
-        return projectService.addLog(orgId, request, principal.getName());
+        return projectService.addLog(orgId, request, currentUser.displayName(principal));
     }
 
     @PutMapping("/api/orgs/{id}/contact-logs/{logId}")
@@ -75,7 +78,7 @@
                                      Principal principal) throws IOException {
         String name = file.getOriginalFilename() == null ? "report" : file.getOriginalFilename();
         return projectService.addReport(orgId, name, file.getContentType(), file.getBytes(),
-                principal.getName());
+                currentUser.displayName(principal));
     }
 
     /** 한글 파일명이 깨지지 않게 ChannelFeedController.download와 같은 RFC 5987 인코딩을 쓴다. */
@@ -103,7 +106,7 @@
                                          Principal principal) throws IOException {
         String name = file.getOriginalFilename() == null ? "report" : file.getOriginalFilename();
         return projectService.replaceReport(orgId, reportId, name, file.getContentType(),
-                file.getBytes(), principal.getName());
+                file.getBytes(), currentUser.displayName(principal));
     }
 
     @ResponseStatus(HttpStatus.NO_CONTENT)
src/main/java/kr/itn/itnhub/provision/ChannelProvisionService.java
--- src/main/java/kr/itn/itnhub/provision/ChannelProvisionService.java
+++ src/main/java/kr/itn/itnhub/provision/ChannelProvisionService.java
@@ -227,7 +227,8 @@
             }
             if (filled(current.getChannelIdMj()) && filled(current.getChannelIdLaw())) {
                 mapper.updateStage(orgId, 1);
-                stageHistoryMapper.insert(orgId, 1);
+                // 이것도 프로그램이 저절로 넣는 단계다(요구사항 [19]: 이력에 자동/수동 구분).
+                stageHistoryMapper.insert(orgId, 1, true, "채널 생성");
             }
         } catch (RuntimeException e) {
             log.warn("진행단계 자동 시작 실패 org={}", orgId, e);
src/main/java/kr/itn/itnhub/review/ReviewImportService.java
--- src/main/java/kr/itn/itnhub/review/ReviewImportService.java
+++ src/main/java/kr/itn/itnhub/review/ReviewImportService.java
@@ -17,13 +17,16 @@
     private final ReviewParser parser;
     private final ReviewItemMapper mapper;
     private final OrganizationMapper orgMapper;
+    private final kr.itn.itnhub.stage.StageService stageService;
     private final TransactionTemplate transactionTemplate;
 
     public ReviewImportService(ReviewParser parser, ReviewItemMapper mapper, OrganizationMapper orgMapper,
+                                kr.itn.itnhub.stage.StageService stageService,
                                 PlatformTransactionManager transactionManager) {
         this.parser = parser;
         this.mapper = mapper;
         this.orgMapper = orgMapper;
+        this.stageService = stageService;
         this.transactionTemplate = new TransactionTemplate(transactionManager);
     }
 
@@ -35,7 +38,15 @@
             throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId);
         }
         List<ReviewRow> rows = parser.parse(xlsx);
-        return transactionTemplate.execute(status -> importRows(orgId, rows));
+        ReviewImportReport report = transactionTemplate.execute(status -> importRows(orgId, rows));
+
+        // 요구사항 [18]: 권리확인 자료가 올라오면 ⑤법률검토(권리확인)로 저절로 넘어간다.
+        // 이미 그보다 앞서 있으면 아무 일도 하지 않는다.
+        if (report != null && report.total() > 0) {
+            stageService.autoAdvance(orgId, kr.itn.itnhub.stage.AutoStage.REVIEW_STARTED,
+                    kr.itn.itnhub.stage.AutoStage.REASON_REVIEW_STARTED);
+        }
+        return report;
     }
 
     private ReviewImportReport importRows(Long orgId, List<ReviewRow> rows) {
 
src/main/java/kr/itn/itnhub/shared/SharedFile.java (added)
+++ src/main/java/kr/itn/itnhub/shared/SharedFile.java
@@ -0,0 +1,15 @@
+package kr.itn.itnhub.shared;
+
+/**
+ * 자료실에 올려둔 공용 서식 한 건(요구사항 [26]: 공용 양식 보관소).
+ *
+ * <p>기관에 딸리지 않는다. 어느 기관 일을 하든 여기서 내려받아 쓴다.</p>
+ */
+public record SharedFile(
+        Long id,
+        String fileName,
+        String description,
+        long byteSize,
+        String uploadedBy,
+        Long uploadedAt) {
+}
 
src/main/java/kr/itn/itnhub/shared/SharedFileController.java (added)
+++ src/main/java/kr/itn/itnhub/shared/SharedFileController.java
@@ -0,0 +1,86 @@
+package kr.itn.itnhub.shared;
+
+import kr.itn.itnhub.config.CurrentUser;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestPart;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.IOException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.Principal;
+import java.util.List;
+import java.util.Map;
+
+/** 자료실. 기관에 딸리지 않는 공용 서식을 올려두고 내려받는다(요구사항 [26]). */
+@RestController
+public class SharedFileController {
+
+    private final SharedFileService service;
+    private final CurrentUser currentUser;
+
+    public SharedFileController(SharedFileService service, CurrentUser currentUser) {
+        this.service = service;
+        this.currentUser = currentUser;
+    }
+
+    @GetMapping("/api/shared-files")
+    public List<SharedFile> list() {
+        return service.list();
+    }
+
+    @PostMapping("/api/shared-files")
+    public List<SharedFile> add(@RequestPart("file") MultipartFile file,
+                                @RequestPart(value = "description", required = false) String description,
+                                Principal principal) throws IOException {
+        String name = file.getOriginalFilename() == null ? "file" : file.getOriginalFilename();
+        return service.add(name, description, file.getContentType(), file.getBytes(),
+                currentUser.displayName(principal));
+    }
+
+    @PutMapping("/api/shared-files/{id}")
+    public List<SharedFile> updateDescription(@PathVariable Long id,
+                                              @RequestBody Map<String, String> body) {
+        return service.updateDescription(id, body.get("description"));
+    }
+
+    /** 한글 파일명이 깨지지 않게 보고서 다운로드와 같은 RFC 5987 인코딩을 쓴다. */
+    @GetMapping("/api/shared-files/{id}")
+    public ResponseEntity<byte[]> download(@PathVariable Long id) {
+        SharedFileMapper.Content found = service.content(id);
+        String encodedName = URLEncoder.encode(found.fileName(), StandardCharsets.UTF_8)
+                .replace("+", "%20");
+        MediaType type = found.contentType() == null || found.contentType().isBlank()
+                ? MediaType.APPLICATION_OCTET_STREAM
+                : MediaType.parseMediaType(found.contentType());
+
+        return ResponseEntity.ok()
+                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encodedName)
+                .contentType(type)
+                .body(found.content());
+    }
+
+    @ResponseStatus(HttpStatus.NO_CONTENT)
+    @DeleteMapping("/api/shared-files/{id}")
+    public void delete(@PathVariable Long id) {
+        service.delete(id);
+    }
+
+    @ResponseStatus(HttpStatus.BAD_REQUEST)
+    @ExceptionHandler(IllegalArgumentException.class)
+    public Map<String, String> handleBadRequest(IllegalArgumentException e) {
+        return Map.of("message", e.getMessage());
+    }
+}
 
src/main/java/kr/itn/itnhub/shared/SharedFileMapper.java (added)
+++ src/main/java/kr/itn/itnhub/shared/SharedFileMapper.java
@@ -0,0 +1,27 @@
+package kr.itn.itnhub.shared;
+
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+@Mapper
+public interface SharedFileMapper {
+
+    List<SharedFile> findAll();
+
+    int insert(@Param("fileName") String fileName, @Param("description") String description,
+               @Param("contentType") String contentType, @Param("byteSize") long byteSize,
+               @Param("content") byte[] content, @Param("uploadedBy") String uploadedBy);
+
+    /** 설명만 고친다. 파일을 바꾸려면 새로 올리고 지운다. */
+    int updateDescription(@Param("id") Long id, @Param("description") String description);
+
+    Content findContent(@Param("id") Long id);
+
+    int delete(@Param("id") Long id);
+
+    /** 다운로드용. 파일 내용까지 읽는다. */
+    record Content(String fileName, String contentType, byte[] content) {
+    }
+}
 
src/main/java/kr/itn/itnhub/shared/SharedFileService.java (added)
+++ src/main/java/kr/itn/itnhub/shared/SharedFileService.java
@@ -0,0 +1,60 @@
+package kr.itn.itnhub.shared;
+
+import kr.itn.itnhub.org.OrgNotFoundException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+/** 자료실(요구사항 [26]: 공용 양식 보관소)이 쓰는 서비스. */
+@Service
+public class SharedFileService {
+
+    private final SharedFileMapper mapper;
+
+    public SharedFileService(SharedFileMapper mapper) {
+        this.mapper = mapper;
+    }
+
+    public List<SharedFile> list() {
+        return mapper.findAll();
+    }
+
+    @Transactional
+    public List<SharedFile> add(String fileName, String description, String contentType,
+                                byte[] content, String uploadedBy) {
+        if (content == null || content.length == 0) {
+            throw new IllegalArgumentException("빈 파일은 올릴 수 없습니다.");
+        }
+        mapper.insert(fileName, blankToNull(description), contentType, content.length,
+                content, uploadedBy);
+        return mapper.findAll();
+    }
+
+    @Transactional
+    public List<SharedFile> updateDescription(Long id, String description) {
+        if (mapper.updateDescription(id, blankToNull(description)) == 0) {
+            throw new OrgNotFoundException("자료를 찾을 수 없습니다: " + id);
+        }
+        return mapper.findAll();
+    }
+
+    public SharedFileMapper.Content content(Long id) {
+        SharedFileMapper.Content found = mapper.findContent(id);
+        if (found == null) {
+            throw new OrgNotFoundException("자료를 찾을 수 없습니다: " + id);
+        }
+        return found;
+    }
+
+    @Transactional
+    public void delete(Long id) {
+        if (mapper.delete(id) == 0) {
+            throw new OrgNotFoundException("자료를 찾을 수 없습니다: " + id);
+        }
+    }
+
+    private String blankToNull(String value) {
+        return (value == null || value.isBlank()) ? null : value.trim();
+    }
+}
 
src/main/java/kr/itn/itnhub/stage/AutoStage.java (added)
+++ src/main/java/kr/itn/itnhub/stage/AutoStage.java
@@ -0,0 +1,25 @@
+package kr.itn.itnhub.stage;
+
+/**
+ * 자료가 올라온 것을 계기로 저절로 넘어가는 단계(요구사항 [18]).
+ *
+ * <p>기능확인 자료의 지시를 이 시스템의 실제 흐름에 맞춰 옮긴 것이다.
+ * 어느 자료가 어느 단계에 해당하는지는 답변 문서에 적어 확인 요청해 두었다.</p>
+ */
+public final class AutoStage {
+
+    private AutoStage() {
+    }
+
+    /** ②목록접수 — 기관 채널에 첫 자료가 올라갔을 때. */
+    public static final int LIST_RECEIVED = 2;
+    public static final String REASON_LIST_RECEIVED = "채널에 자료 업로드";
+
+    /** ⑤법률검토(권리확인) — 권리확인 자료가 올라갔을 때. */
+    public static final int REVIEW_STARTED = 5;
+    public static final String REASON_REVIEW_STARTED = "권리확인 자료 업로드";
+
+    /** ⑧법률검토(권리처리) — 권리처리 자료가 올라갔을 때. */
+    public static final int PROCESS_STARTED = 8;
+    public static final String REASON_PROCESS_STARTED = "권리처리 자료 업로드";
+}
src/main/java/kr/itn/itnhub/stage/StageHistoryMapper.java
--- src/main/java/kr/itn/itnhub/stage/StageHistoryMapper.java
+++ src/main/java/kr/itn/itnhub/stage/StageHistoryMapper.java
@@ -11,7 +11,8 @@
     /** 최신순. */
     List<StageHistoryRow> findByOrg(@Param("orgId") Long orgId);
 
-    int insert(@Param("orgId") Long orgId, @Param("stage") int stage);
+    int insert(@Param("orgId") Long orgId, @Param("stage") int stage,
+               @Param("auto") boolean auto, @Param("reason") String reason);
 
     /** 채널 초기화용. 단계가 null로 돌아가므로 이력을 남겨두면 어긋난다. */
     int deleteByOrg(@Param("orgId") Long orgId);
src/main/java/kr/itn/itnhub/stage/StageHistoryRow.java
--- src/main/java/kr/itn/itnhub/stage/StageHistoryRow.java
+++ src/main/java/kr/itn/itnhub/stage/StageHistoryRow.java
@@ -5,10 +5,20 @@
 
     private int stage;
     private long changedAt;
+    /** 프로그램이 저절로 넘긴 것인지(요구사항 [19]: 이력에 자동/수동 구분 표시). */
+    private boolean auto;
+    /** 자동으로 넘어간 이유. 사람이 눌렀으면 null. */
+    private String reason;
 
     public int getStage() { return stage; }
     public void setStage(int stage) { this.stage = stage; }
 
     public long getChangedAt() { return changedAt; }
     public void setChangedAt(long changedAt) { this.changedAt = changedAt; }
+
+    public boolean isAuto() { return auto; }
+    public void setAuto(boolean auto) { this.auto = auto; }
+
+    public String getReason() { return reason; }
+    public void setReason(String reason) { this.reason = reason; }
 }
src/main/java/kr/itn/itnhub/stage/StageService.java
--- src/main/java/kr/itn/itnhub/stage/StageService.java
+++ src/main/java/kr/itn/itnhub/stage/StageService.java
@@ -45,11 +45,45 @@
         }
 
         orgMapper.updateStage(orgId, stage);
-        historyMapper.insert(orgId, stage);
+        // 사람이 눌러서 넘긴 것이다(요구사항 [19]: 이력에 자동/수동 구분).
+        historyMapper.insert(orgId, stage, false, null);
 
         return OrgResponse.of(orgMapper.findById(orgId));
     }
 
+    /**
+     * 자료가 올라온 것을 계기로 단계를 저절로 넘긴다(요구사항 [18]).
+     *
+     * <p>앞으로만 간다. 이미 목표 단계에 있거나 그보다 앞서 있으면 아무 일도 하지 않는다 -
+     * 자료를 다시 올렸다고 단계가 뒤로 밀리면 안 되기 때문이다.</p>
+     *
+     * <p>채널이 없어 단계가 아직 없는 기관(stage=null)도 건드리지 않는다. 채널 생성 시
+     * ①신청으로 들어오는 것이 먼저다.</p>
+     *
+     * <p>사람이 이 단계를 다시 되돌리는 것은 막지 않는다(요구사항 [19]: 되돌림 허용).
+     * 진행단계 변경에서 이전 단계를 고르면 그대로 내려간다.</p>
+     *
+     * @return 실제로 넘어갔으면 true
+     */
+    @Transactional
+    public boolean autoAdvance(Long orgId, int targetStage, String reason) {
+        Organization org = orgMapper.findById(orgId);
+        if (org == null || org.getStage() == null) {
+            return false;
+        }
+        if (org.getStage() >= targetStage) {
+            return false;
+        }
+        int lastStage = codeService.group(Codes.STAGE).size();
+        if (targetStage < 1 || targetStage > lastStage) {
+            return false;
+        }
+
+        orgMapper.updateStage(orgId, targetStage);
+        historyMapper.insert(orgId, targetStage, true, reason);
+        return true;
+    }
+
     public java.util.List<StageHistoryRow> history(Long orgId) {
         if (orgMapper.findById(orgId) == null) {
             throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId);
src/main/resources/application.yml
--- src/main/resources/application.yml
+++ src/main/resources/application.yml
@@ -44,3 +44,6 @@
   admin:
     username: ${APP_ADMIN_USERNAME}
     password: ${APP_ADMIN_PASSWORD}
+    # 업무메모·연락이력·보고서에 작성자로 찍히는 이름(요구사항 [22]).
+    # 비워 두면 로그인 아이디가 그대로 찍힌다.
+    display-name: ${APP_ADMIN_DISPLAY_NAME:관리자}
 
src/main/resources/db/migration/V17__auto_stage_and_shared_files.sql (added)
+++ src/main/resources/db/migration/V17__auto_stage_and_shared_files.sql
@@ -0,0 +1,24 @@
+-- 요구사항 [19] · [26] 반영.
+
+-- [19] 단계 이력에 "자동으로 넘어간 것인지" 표시를 남긴다.
+-- 회신: 되돌림 허용 + 이력에 구분 표시.
+-- 기존 이력은 전부 사람이 눌러서 넘긴 것이므로 false가 맞다.
+alter table stage_history add column auto boolean not null default false;
+
+-- 자동 전환이 무엇 때문에 일어났는지 남긴다. 나중에 "왜 저절로 넘어갔지?"를 설명할 수 있어야 한다.
+alter table stage_history add column reason varchar(100);
+
+-- [26] 자료실 = 공용 양식 보관소.
+-- 기관에 딸리지 않는 공용 서식을 올려두는 자리다. 파일은 org_report와 같은 이유로
+-- DB에 직접 담는다(Mattermost 채널을 초기화해도 사라지면 안 된다).
+create table shared_file (
+    id           bigserial primary key,
+    file_name    varchar(300) not null,
+    description  varchar(500),
+    content_type varchar(200),
+    byte_size    bigint       not null,
+    content      bytea        not null,
+    uploaded_by  varchar(100) not null,
+    uploaded_at  timestamptz  not null default now()
+);
+create index ix_shared_file_recent on shared_file (uploaded_at desc, id desc);
 
src/main/resources/mapper/SharedFileMapper.xml (added)
+++ src/main/resources/mapper/SharedFileMapper.xml
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="kr.itn.itnhub.shared.SharedFileMapper">
+
+  <!-- 목록에는 파일 내용을 싣지 않는다. 서식이 커지면 목록 조회가 무거워진다. -->
+  <select id="findAll" resultType="kr.itn.itnhub.shared.SharedFile">
+    select id, file_name, description, byte_size, uploaded_by,
+           (extract(epoch from uploaded_at) * 1000)::bigint as uploaded_at
+    from shared_file
+    order by uploaded_at desc, id desc
+  </select>
+
+  <insert id="insert">
+    insert into shared_file (file_name, description, content_type, byte_size, content, uploaded_by)
+    values (#{fileName}, #{description}, #{contentType}, #{byteSize}, #{content}, #{uploadedBy})
+  </insert>
+
+  <update id="updateDescription">
+    update shared_file set description = #{description} where id = #{id}
+  </update>
+
+  <select id="findContent" resultType="kr.itn.itnhub.shared.SharedFileMapper$Content">
+    select file_name, content_type, content from shared_file where id = #{id}
+  </select>
+
+  <delete id="delete">
+    delete from shared_file where id = #{id}
+  </delete>
+
+</mapper>
src/main/resources/mapper/StageHistoryMapper.xml
--- src/main/resources/mapper/StageHistoryMapper.xml
+++ src/main/resources/mapper/StageHistoryMapper.xml
@@ -7,15 +7,17 @@
     changed_at을 밀리초 epoch로 내려준다 - WorkMemoMapper.created_at과 동일한 관례.
   -->
   <select id="findByOrg" resultType="kr.itn.itnhub.stage.StageHistoryRow">
-    select stage, (extract(epoch from changed_at) * 1000)::bigint as changed_at
+    select stage, (extract(epoch from changed_at) * 1000)::bigint as changed_at,
+           auto, reason
     from stage_history
     where org_id = #{orgId}
     order by changed_at desc, id desc
   </select>
 
+  <!-- auto=true면 프로그램이 저절로 넘긴 것이다(요구사항 [18][19]). reason에 계기를 남긴다. -->
   <insert id="insert">
-    insert into stage_history (org_id, stage)
-    values (#{orgId}, #{stage})
+    insert into stage_history (org_id, stage, auto, reason)
+    values (#{orgId}, #{stage}, #{auto}, #{reason})
   </insert>
 
   <!--
src/test/java/kr/itn/itnhub/memo/WorkMemoControllerTest.java
--- src/test/java/kr/itn/itnhub/memo/WorkMemoControllerTest.java
+++ src/test/java/kr/itn/itnhub/memo/WorkMemoControllerTest.java
@@ -66,13 +66,13 @@
                                 """))
                 .andExpect(status().isOk())
                 // 작성자는 요청 본문이 아니라 로그인 사용자에서 온다
-                .andExpect(jsonPath("$.contactName").value("admin"))
+                .andExpect(jsonPath("$.contactName").value("관리자"))
                 .andExpect(jsonPath("$.body").value("채널 생성 일정 문의"))
                 .andExpect(jsonPath("$.createdAt").isNumber());
 
         mvc.perform(get("/api/orgs/{id}/memos", orgId))
                 .andExpect(status().isOk())
-                .andExpect(jsonPath("$[0].contactName").value("admin"))
+                .andExpect(jsonPath("$[0].contactName").value("관리자"))
                 .andExpect(jsonPath("$[0].body").value("채널 생성 일정 문의"));
     }
 
@@ -192,7 +192,7 @@
                                 """))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.body").value("고친 뒤"))
-                .andExpect(jsonPath("$.contactName").value("admin"))
+                .andExpect(jsonPath("$.contactName").value("관리자"))
                 .andExpect(jsonPath("$.createdAt").value(createdAt));
     }
 
src/test/java/kr/itn/itnhub/project/ProjectControllerTest.java
--- src/test/java/kr/itn/itnhub/project/ProjectControllerTest.java
+++ src/test/java/kr/itn/itnhub/project/ProjectControllerTest.java
@@ -76,7 +76,7 @@
                                 """))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.length()").value(1))
-                .andExpect(jsonPath("$[0].author").value("admin"))
+                .andExpect(jsonPath("$[0].author").value("관리자"))
                 .andExpect(jsonPath("$[0].contactedOn").value("2026-07-20"));
 
         mvc.perform(post("/api/orgs/{id}/contact-logs", orgId).with(csrf())
@@ -112,7 +112,7 @@
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$[0].summary").value("고침"))
                 .andExpect(jsonPath("$[0].method").value("방문"))
-                .andExpect(jsonPath("$[0].author").value("admin"));
+                .andExpect(jsonPath("$[0].author").value("관리자"));
     }
 
     @Test
@@ -151,7 +151,7 @@
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.length()").value(1))
                 .andExpect(jsonPath("$[0].fileName").value("최종보고서.pdf"))
-                .andExpect(jsonPath("$[0].uploadedBy").value("admin"))
+                .andExpect(jsonPath("$[0].uploadedBy").value("관리자"))
                 .andReturn().getResponse().getContentAsString();
         int reportId = com.jayway.jsonpath.JsonPath.parse(uploaded).read("$[0].id", Integer.class);
 
 
src/test/java/kr/itn/itnhub/stage/AutoStageTest.java (added)
+++ src/test/java/kr/itn/itnhub/stage/AutoStageTest.java
@@ -0,0 +1,127 @@
+package kr.itn.itnhub.stage;
+
+import kr.itn.itnhub.AbstractDbTest;
+import kr.itn.itnhub.org.Organization;
+import kr.itn.itnhub.org.OrganizationMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * 자료가 올라온 것을 계기로 단계가 저절로 넘어가는 규칙(요구사항 [18][19]).
+ *
+ * <p>핵심은 "앞으로만 간다"는 것이다. 자료를 다시 올렸다고 단계가 뒤로 밀리면 안 된다.</p>
+ */
+class AutoStageTest extends AbstractDbTest {
+
+    @Autowired
+    StageService stageService;
+
+    @Autowired
+    OrganizationMapper orgMapper;
+
+    @Autowired
+    StageHistoryMapper historyMapper;
+
+    @Autowired
+    JdbcTemplate jdbc;
+
+    private Long orgId;
+
+    @BeforeEach
+    void setUp() {
+        jdbc.update("delete from stage_history");
+        jdbc.update("delete from organization where org_no = '930'");
+
+        Organization org = new Organization();
+        org.setOrgNo("930");
+        org.setOrgName("자동전환테스트기관");
+        org.setChannelSlug("930");
+        orgMapper.upsertBySeed(org);
+        orgId = orgMapper.findByOrgNoAndOrgName("930", "자동전환테스트기관").getId();
+    }
+
+    private void setStage(int stage) {
+        orgMapper.updateStage(orgId, stage);
+        historyMapper.insert(orgId, stage, false, null);
+    }
+
+    @Test
+    void 뒤에_있으면_앞으로_넘어간다() {
+        setStage(1);
+
+        boolean moved = stageService.autoAdvance(orgId, AutoStage.REVIEW_STARTED,
+                AutoStage.REASON_REVIEW_STARTED);
+
+        assertThat(moved).isTrue();
+        assertThat(orgMapper.findById(orgId).getStage()).isEqualTo(AutoStage.REVIEW_STARTED);
+    }
+
+    @Test
+    void 이미_앞서_있으면_뒤로_밀지_않는다() {
+        setStage(10);
+
+        boolean moved = stageService.autoAdvance(orgId, AutoStage.REVIEW_STARTED,
+                AutoStage.REASON_REVIEW_STARTED);
+
+        assertThat(moved).isFalse();
+        assertThat(orgMapper.findById(orgId).getStage()).isEqualTo(10);
+    }
+
+    @Test
+    void 같은_단계면_이력을_또_쌓지_않는다() {
+        setStage(AutoStage.REVIEW_STARTED);
+        int before = historyMapper.findByOrg(orgId).size();
+
+        boolean moved = stageService.autoAdvance(orgId, AutoStage.REVIEW_STARTED,
+                AutoStage.REASON_REVIEW_STARTED);
+
+        assertThat(moved).isFalse();
+        assertThat(historyMapper.findByOrg(orgId)).hasSize(before);
+    }
+
+    @Test
+    void 채널이_없어_단계가_아직_없으면_건드리지_않는다() {
+        // 채널 생성으로 ①신청이 들어오는 것이 먼저다.
+        boolean moved = stageService.autoAdvance(orgId, AutoStage.LIST_RECEIVED,
+                AutoStage.REASON_LIST_RECEIVED);
+
+        assertThat(moved).isFalse();
+        assertThat(orgMapper.findById(orgId).getStage()).isNull();
+    }
+
+    @Test
+    void 이력에_자동_여부와_계기가_남는다() {
+        setStage(1);
+        stageService.autoAdvance(orgId, AutoStage.PROCESS_STARTED, AutoStage.REASON_PROCESS_STARTED);
+
+        List<StageHistoryRow> history = historyMapper.findByOrg(orgId);
+
+        assertThat(history).hasSize(2);
+        // 최신순이라 자동 전환이 앞에 온다.
+        assertThat(history.get(0).getStage()).isEqualTo(AutoStage.PROCESS_STARTED);
+        assertThat(history.get(0).isAuto()).isTrue();
+        assertThat(history.get(0).getReason()).isEqualTo(AutoStage.REASON_PROCESS_STARTED);
+        // 사람이 넣은 것은 자동이 아니다.
+        assertThat(history.get(1).isAuto()).isFalse();
+        assertThat(history.get(1).getReason()).isNull();
+    }
+
+    @Test
+    void 자동으로_올라간_단계를_사람이_되돌릴_수_있다() {
+        // 요구사항 [19]: 되돌림 허용.
+        setStage(1);
+        stageService.autoAdvance(orgId, AutoStage.REVIEW_STARTED, AutoStage.REASON_REVIEW_STARTED);
+        assertThat(orgMapper.findById(orgId).getStage()).isEqualTo(AutoStage.REVIEW_STARTED);
+
+        stageService.updateStage(orgId, new StageRequest(2));
+
+        assertThat(orgMapper.findById(orgId).getStage()).isEqualTo(2);
+        assertThat(historyMapper.findByOrg(orgId).get(0).isAuto()).isFalse();
+    }
+}
Add a comment
List