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
... | ... | @@ -8,6 +8,7 @@ |
| 8 | 8 |
import OrgOverview from './components/OrgOverview' |
| 9 | 9 |
import ProjectsPage from './components/ProjectsPage' |
| 10 | 10 |
import SeedUpload from './components/SeedUpload' |
| 11 |
+import SharedFiles from './components/SharedFiles' |
|
| 11 | 12 |
import Sidebar from './components/Sidebar' |
| 12 | 13 |
import SystemPage from './components/SystemPage' |
| 13 | 14 |
import { CodeProvider } from './codes/CodeProvider'
|
... | ... | @@ -16,7 +17,7 @@ |
| 16 | 17 |
// 화면 4개: Dashboard(전체 기관 현황 - 좌측 기관 목록 없이 전폭으로 쓴다), |
| 17 | 18 |
// 기관관리(시안 레이아웃의 기관 상세), 채널관리(명부 시드 + 담당자 배정 + 채널 생성), |
| 18 | 19 |
// 담당자관리(담당자 3종 CRUD - 담당자 정보가 입력되는 유일한 화면). |
| 19 |
-type Page = 'dashboard' | 'orgs' | 'channels' | 'contacts' | 'projects' | 'system' |
|
| 20 |
+type Page = 'dashboard' | 'orgs' | 'channels' | 'contacts' | 'projects' | 'files' | 'system' |
|
| 20 | 21 |
|
| 21 | 22 |
const PAGE_TITLE: Record<Page, string> = {
|
| 22 | 23 |
dashboard: 'Dashboard', |
... | ... | @@ -24,6 +25,7 @@ |
| 24 | 25 |
channels: '채널관리', |
| 25 | 26 |
contacts: '담당자관리', |
| 26 | 27 |
projects: '사업관리', |
| 28 |
+ files: '자료실', |
|
| 27 | 29 |
system: '시스템관리', |
| 28 | 30 |
} |
| 29 | 31 |
|
... | ... | @@ -117,6 +119,7 @@ |
| 117 | 119 |
key === 'channels' || |
| 118 | 120 |
key === 'contacts' || |
| 119 | 121 |
key === 'projects' || |
| 122 |
+ key === 'files' || |
|
| 120 | 123 |
key === 'system' |
| 121 | 124 |
) {
|
| 122 | 125 |
setPage(key) |
... | ... | @@ -140,7 +143,8 @@ |
| 140 | 143 |
</header> |
| 141 | 144 |
|
| 142 | 145 |
<div className="flex min-h-0 flex-1"> |
| 143 |
- {page !== 'contacts' && page !== 'dashboard' && page !== 'system' && page !== 'projects' && (
|
|
| 146 |
+ {page !== 'contacts' && page !== 'dashboard' && page !== 'system'
|
|
| 147 |
+ && page !== 'projects' && page !== 'files' && ( |
|
| 144 | 148 |
<div |
| 145 | 149 |
className={`flex shrink-0 flex-col border-r border-gray-200 transition-all duration-200 ${
|
| 146 | 150 |
listCollapsed ? 'w-14' : 'w-80' |
... | ... | @@ -176,6 +180,8 @@ |
| 176 | 180 |
setPage('orgs')
|
| 177 | 181 |
}} |
| 178 | 182 |
/> |
| 183 |
+ ) : page === 'files' ? ( |
|
| 184 |
+ <SharedFiles /> |
|
| 179 | 185 |
) : page === 'system' ? ( |
| 180 | 186 |
<SystemPage onChanged={() => void reload()} />
|
| 181 | 187 |
) : page === 'contacts' ? ( |
--- frontend/src/api/client.ts
+++ frontend/src/api/client.ts
... | ... | @@ -171,6 +171,58 @@ |
| 171 | 171 |
return request<CodeMap>('/api/meta/codes')
|
| 172 | 172 |
} |
| 173 | 173 |
|
| 174 |
+/** 자료실에 올려둔 공용 서식(요구사항 [26]). 기관에 딸리지 않는다. */ |
|
| 175 |
+export interface SharedFile {
|
|
| 176 |
+ id: number |
|
| 177 |
+ fileName: string |
|
| 178 |
+ description: string | null |
|
| 179 |
+ byteSize: number |
|
| 180 |
+ uploadedBy: string |
|
| 181 |
+ uploadedAt: number |
|
| 182 |
+} |
|
| 183 |
+ |
|
| 184 |
+export function getSharedFiles(): Promise<SharedFile[]> {
|
|
| 185 |
+ return request<SharedFile[]>('/api/shared-files')
|
|
| 186 |
+} |
|
| 187 |
+ |
|
| 188 |
+export async function addSharedFile(file: File, description: string): Promise<SharedFile[]> {
|
|
| 189 |
+ const form = new FormData() |
|
| 190 |
+ form.append('file', file)
|
|
| 191 |
+ if (description) {
|
|
| 192 |
+ form.append('description', new Blob([description], { type: 'text/plain' }))
|
|
| 193 |
+ } |
|
| 194 |
+ const res = await fetch('/api/shared-files', {
|
|
| 195 |
+ method: 'POST', |
|
| 196 |
+ headers: { 'X-XSRF-TOKEN': csrfToken() },
|
|
| 197 |
+ credentials: 'same-origin', |
|
| 198 |
+ body: form, |
|
| 199 |
+ }) |
|
| 200 |
+ if (!res.ok) {
|
|
| 201 |
+ throw new ApiError(res.status, `자료 업로드 실패 (${res.status}): ${await res.text()}`)
|
|
| 202 |
+ } |
|
| 203 |
+ return res.json() as Promise<SharedFile[]> |
|
| 204 |
+} |
|
| 205 |
+ |
|
| 206 |
+export function updateSharedFileDescription(id: number, description: string): Promise<SharedFile[]> {
|
|
| 207 |
+ return request<SharedFile[]>(`/api/shared-files/${id}`, {
|
|
| 208 |
+ method: 'PUT', |
|
| 209 |
+ headers: { 'Content-Type': 'application/json' },
|
|
| 210 |
+ body: JSON.stringify({ description }),
|
|
| 211 |
+ }) |
|
| 212 |
+} |
|
| 213 |
+ |
|
| 214 |
+export async function deleteSharedFile(id: number): Promise<void> {
|
|
| 215 |
+ await fetch(`/api/shared-files/${id}`, {
|
|
| 216 |
+ method: 'DELETE', |
|
| 217 |
+ headers: { 'X-XSRF-TOKEN': csrfToken() },
|
|
| 218 |
+ credentials: 'same-origin', |
|
| 219 |
+ }) |
|
| 220 |
+} |
|
| 221 |
+ |
|
| 222 |
+export function sharedFileDownloadUrl(id: number): string {
|
|
| 223 |
+ return `/api/shared-files/${id}`
|
|
| 224 |
+} |
|
| 225 |
+ |
|
| 174 | 226 |
/** RE 메모 한 건. 요구사항 [4]대로 메모 1개가 RE 1개다. */ |
| 175 | 227 |
export interface ReMemo {
|
| 176 | 228 |
id: number |
--- frontend/src/components/Sidebar.test.tsx
+++ frontend/src/components/Sidebar.test.tsx
... | ... | @@ -40,13 +40,23 @@ |
| 40 | 40 |
expect(onSelect).toHaveBeenCalledWith('channels')
|
| 41 | 41 |
}) |
| 42 | 42 |
|
| 43 |
+ // 자료실은 요구사항 [26]으로 열렸다. 아직 준비 중인 메뉴는 권리확인·권리처리·Mattermost다. |
|
| 43 | 44 |
it('미구현 메뉴는 비활성으로 표시되고 링크가 아니다', () => {
|
| 44 | 45 |
render(<Sidebar />) |
| 45 | 46 |
|
| 46 |
- const files = screen.getByText('자료실')
|
|
| 47 |
- expect(files).toHaveAttribute('aria-disabled', 'true')
|
|
| 48 |
- expect(files).toHaveAttribute('title', '준비 중')
|
|
| 49 |
- expect(screen.queryByRole('link', { name: '자료실' })).toBeNull()
|
|
| 47 |
+ const mattermost = screen.getByText('Mattermost')
|
|
| 48 |
+ expect(mattermost).toHaveAttribute('aria-disabled', 'true')
|
|
| 49 |
+ expect(mattermost).toHaveAttribute('title', '준비 중')
|
|
| 50 |
+ expect(screen.queryByRole('link', { name: 'Mattermost' })).toBeNull()
|
|
| 51 |
+ }) |
|
| 52 |
+ |
|
| 53 |
+ it('자료실은 구현되어 링크로 열린다', () => {
|
|
| 54 |
+ const onSelect = vi.fn() |
|
| 55 |
+ render(<Sidebar onSelect={onSelect} />)
|
|
| 56 |
+ |
|
| 57 |
+ fireEvent.click(screen.getByRole('link', { name: '자료실' }))
|
|
| 58 |
+ |
|
| 59 |
+ expect(onSelect).toHaveBeenCalledWith('files')
|
|
| 50 | 60 |
}) |
| 51 | 61 |
|
| 52 | 62 |
it('Dashboard는 구현되어 링크로 열린다', () => {
|
... | ... | @@ -73,7 +83,7 @@ |
| 73 | 83 |
expect(screen.getByText('IH')).toBeTruthy()
|
| 74 | 84 |
|
| 75 | 85 |
expect(screen.getByRole('link', { name: '기관관리' })).toHaveAttribute('title', '기관관리')
|
| 76 |
- expect(screen.getByTitle('자료실 (준비 중)')).toBeTruthy()
|
|
| 86 |
+ expect(screen.getByTitle('Mattermost (준비 중)')).toBeTruthy()
|
|
| 77 | 87 |
}) |
| 78 | 88 |
|
| 79 | 89 |
it('접기 버튼을 누르면 onToggle이 호출된다', () => {
|
--- frontend/src/components/Sidebar.tsx
+++ frontend/src/components/Sidebar.tsx
... | ... | @@ -89,8 +89,9 @@ |
| 89 | 89 |
}, |
| 90 | 90 |
{
|
| 91 | 91 |
key: 'files', |
| 92 |
+ // 요구사항 [26]: 공용 양식 보관소. 기관에 딸리지 않는 서식을 여기 올려 둔다. |
|
| 92 | 93 |
label: '자료실', |
| 93 |
- enabled: false, |
|
| 94 |
+ enabled: true, |
|
| 94 | 95 |
icon: ( |
| 95 | 96 |
<svg className={ICON_CLASS} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
| 96 | 97 |
<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
... | ... | @@ -2,13 +2,23 @@ |
| 2 | 2 |
|
| 3 | 3 |
import org.springframework.boot.context.properties.ConfigurationProperties; |
| 4 | 4 |
|
| 5 |
+/** |
|
| 6 |
+ * @param displayName 업무메모·연락이력·보고서의 작성자로 찍히는 이름(요구사항 [22]). |
|
| 7 |
+ * 예전에는 로그인 아이디(admin)가 그대로 찍혔다. 비워 두면 아이디를 쓴다. |
|
| 8 |
+ */ |
|
| 5 | 9 |
@ConfigurationProperties(prefix = "app.admin") |
| 6 |
-public record AdminProperties(String username, String password) {
|
|
| 10 |
+public record AdminProperties(String username, String password, String displayName) {
|
|
| 11 |
+ |
|
| 12 |
+ /** 화면에 보일 이름. 설정이 없으면 로그인 아이디로 떨어진다. */ |
|
| 13 |
+ public String displayNameOrUsername() {
|
|
| 14 |
+ return (displayName == null || displayName.isBlank()) ? username : displayName; |
|
| 15 |
+ } |
|
| 7 | 16 |
|
| 8 | 17 |
// record 기본 toString()은 password를 그대로 노출한다. 로그에 실수로 찍혀도 |
| 9 | 18 |
// 원문이 남지 않도록 마스킹한다. |
| 10 | 19 |
@Override |
| 11 | 20 |
public String toString() {
|
| 12 |
- return "AdminProperties[username=%s, password=****]".formatted(username); |
|
| 21 |
+ return "AdminProperties[username=%s, password=****, displayName=%s]" |
|
| 22 |
+ .formatted(username, displayName); |
|
| 13 | 23 |
} |
| 14 | 24 |
} |
+++ src/main/java/kr/itn/itnhub/config/CurrentUser.java
... | ... | @@ -0,0 +1,35 @@ |
| 1 | +package kr.itn.itnhub.config; | |
| 2 | + | |
| 3 | +import org.springframework.stereotype.Component; | |
| 4 | + | |
| 5 | +import java.security.Principal; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * 기록에 남길 "작성자 이름"을 한 곳에서 정한다(요구사항 [22]). | |
| 9 | + * | |
| 10 | + * <p>예전에는 각 컨트롤러가 {@code principal.getName()}을 그대로 써서 업무메모·연락이력· | |
| 11 | + * 보고서에 로그인 아이디(admin)가 찍혔다. 지금은 설정에 넣어둔 표시 이름을 쓴다.</p> | |
| 12 | + * | |
| 13 | + * <p>나중에 담당자별 계정이 생기면 여기만 고쳐서 각자의 이름이 찍히게 하면 된다.</p> | |
| 14 | + */ | |
| 15 | +@Component | |
| 16 | +public class CurrentUser { | |
| 17 | + | |
| 18 | + private final AdminProperties admin; | |
| 19 | + | |
| 20 | + public CurrentUser(AdminProperties admin) { | |
| 21 | + this.admin = admin; | |
| 22 | + } | |
| 23 | + | |
| 24 | + /** 기록에 남길 이름. 로그인한 사람이 관리자면 설정된 표시 이름을 쓴다. */ | |
| 25 | + public String displayName(Principal principal) { | |
| 26 | + if (principal == null) { | |
| 27 | + return admin.displayNameOrUsername(); | |
| 28 | + } | |
| 29 | + if (principal.getName().equals(admin.username())) { | |
| 30 | + return admin.displayNameOrUsername(); | |
| 31 | + } | |
| 32 | + // 관리자 외 계정이 생기면 그 계정의 아이디를 그대로 쓴다. | |
| 33 | + return principal.getName(); | |
| 34 | + } | |
| 35 | +} |
--- src/main/java/kr/itn/itnhub/feed/ChannelFeedService.java
+++ src/main/java/kr/itn/itnhub/feed/ChannelFeedService.java
... | ... | @@ -35,12 +35,15 @@ |
| 35 | 35 |
private final OrganizationMapper orgMapper; |
| 36 | 36 |
private final MattermostClient mattermost; |
| 37 | 37 |
private final MattermostProperties mattermostProps; |
| 38 |
+ private final kr.itn.itnhub.stage.StageService stageService; |
|
| 38 | 39 |
|
| 39 | 40 |
public ChannelFeedService(OrganizationMapper orgMapper, MattermostClient mattermost, |
| 40 |
- MattermostProperties mattermostProps) {
|
|
| 41 |
+ MattermostProperties mattermostProps, |
|
| 42 |
+ kr.itn.itnhub.stage.StageService stageService) {
|
|
| 41 | 43 |
this.orgMapper = orgMapper; |
| 42 | 44 |
this.mattermost = mattermost; |
| 43 | 45 |
this.mattermostProps = mattermostProps; |
| 46 |
+ this.stageService = stageService; |
|
| 44 | 47 |
} |
| 45 | 48 |
|
| 46 | 49 |
public List<PostView> posts(Long orgId, String channel) {
|
... | ... | @@ -69,6 +72,13 @@ |
| 69 | 72 |
String postId = mattermost.createPost(channelId, message, fileIds); |
| 70 | 73 |
PostView post = fetchPost(channelId, postId, message); |
| 71 | 74 |
|
| 75 |
+ // 요구사항 [18]: 채널에 자료가 올라가면 ②목록접수로 저절로 넘어간다. |
|
| 76 |
+ // 파일이 실린 게시글만 계기로 본다 - 그냥 대화만 오간 것은 자료 접수가 아니다. |
|
| 77 |
+ if (!fileIds.isEmpty()) {
|
|
| 78 |
+ stageService.autoAdvance(orgId, kr.itn.itnhub.stage.AutoStage.LIST_RECEIVED, |
|
| 79 |
+ kr.itn.itnhub.stage.AutoStage.REASON_LIST_RECEIVED); |
|
| 80 |
+ } |
|
| 81 |
+ |
|
| 72 | 82 |
if (!notice) {
|
| 73 | 83 |
return new SendResult(post, null); |
| 74 | 84 |
} |
--- src/main/java/kr/itn/itnhub/memo/WorkMemoController.java
+++ src/main/java/kr/itn/itnhub/memo/WorkMemoController.java
... | ... | @@ -19,9 +19,12 @@ |
| 19 | 19 |
public class WorkMemoController {
|
| 20 | 20 |
|
| 21 | 21 |
private final WorkMemoService memoService; |
| 22 |
+ private final kr.itn.itnhub.config.CurrentUser currentUser; |
|
| 22 | 23 |
|
| 23 |
- public WorkMemoController(WorkMemoService memoService) {
|
|
| 24 |
+ public WorkMemoController(WorkMemoService memoService, |
|
| 25 |
+ kr.itn.itnhub.config.CurrentUser currentUser) {
|
|
| 24 | 26 |
this.memoService = memoService; |
| 27 |
+ this.currentUser = currentUser; |
|
| 25 | 28 |
} |
| 26 | 29 |
|
| 27 | 30 |
@GetMapping("/api/orgs/{id}/memos")
|
... | ... | @@ -29,11 +32,14 @@ |
| 29 | 32 |
return memoService.list(id); |
| 30 | 33 |
} |
| 31 | 34 |
|
| 32 |
- /** 작성자는 본문에서 받지 않고 로그인 사용자(principal)를 서버가 직접 기록한다. */ |
|
| 35 |
+ /** |
|
| 36 |
+ * 작성자는 본문에서 받지 않고 로그인 사용자를 서버가 직접 기록한다. |
|
| 37 |
+ * 요구사항 [22]로 아이디가 아니라 표시 이름이 찍힌다. |
|
| 38 |
+ */ |
|
| 33 | 39 |
@PostMapping("/api/orgs/{id}/memos")
|
| 34 | 40 |
public WorkMemo create(@PathVariable Long id, @Valid @RequestBody MemoRequest request, |
| 35 | 41 |
Principal principal) {
|
| 36 |
- return memoService.create(id, request, principal.getName()); |
|
| 42 |
+ return memoService.create(id, request, currentUser.displayName(principal)); |
|
| 37 | 43 |
} |
| 38 | 44 |
|
| 39 | 45 |
@PutMapping("/api/orgs/{id}/memos/{memoId}")
|
--- src/main/java/kr/itn/itnhub/process/ProcessImportService.java
+++ src/main/java/kr/itn/itnhub/process/ProcessImportService.java
... | ... | @@ -17,13 +17,16 @@ |
| 17 | 17 |
private final ProcessParser parser; |
| 18 | 18 |
private final ProcessItemMapper mapper; |
| 19 | 19 |
private final OrganizationMapper orgMapper; |
| 20 |
+ private final kr.itn.itnhub.stage.StageService stageService; |
|
| 20 | 21 |
private final TransactionTemplate transactionTemplate; |
| 21 | 22 |
|
| 22 | 23 |
public ProcessImportService(ProcessParser parser, ProcessItemMapper mapper, OrganizationMapper orgMapper, |
| 24 |
+ kr.itn.itnhub.stage.StageService stageService, |
|
| 23 | 25 |
PlatformTransactionManager transactionManager) {
|
| 24 | 26 |
this.parser = parser; |
| 25 | 27 |
this.mapper = mapper; |
| 26 | 28 |
this.orgMapper = orgMapper; |
| 29 |
+ this.stageService = stageService; |
|
| 27 | 30 |
this.transactionTemplate = new TransactionTemplate(transactionManager); |
| 28 | 31 |
} |
| 29 | 32 |
|
... | ... | @@ -35,7 +38,15 @@ |
| 35 | 38 |
throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId);
|
| 36 | 39 |
} |
| 37 | 40 |
List<ProcessRow> rows = parser.parse(xlsx); |
| 38 |
- return transactionTemplate.execute(status -> importRows(orgId, rows)); |
|
| 41 |
+ ProcessImportReport report = transactionTemplate.execute(status -> importRows(orgId, rows)); |
|
| 42 |
+ |
|
| 43 |
+ // 요구사항 [18]: 권리처리 자료가 올라오면 ⑧법률검토(권리처리)로 저절로 넘어간다. |
|
| 44 |
+ // 이미 그보다 앞서 있으면 아무 일도 하지 않는다. |
|
| 45 |
+ if (report != null && report.total() > 0) {
|
|
| 46 |
+ stageService.autoAdvance(orgId, kr.itn.itnhub.stage.AutoStage.PROCESS_STARTED, |
|
| 47 |
+ kr.itn.itnhub.stage.AutoStage.REASON_PROCESS_STARTED); |
|
| 48 |
+ } |
|
| 49 |
+ return report; |
|
| 39 | 50 |
} |
| 40 | 51 |
|
| 41 | 52 |
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
... | ... | @@ -29,9 +29,12 @@ |
| 29 | 29 |
public class ProjectController {
|
| 30 | 30 |
|
| 31 | 31 |
private final ProjectService projectService; |
| 32 |
+ private final kr.itn.itnhub.config.CurrentUser currentUser; |
|
| 32 | 33 |
|
| 33 |
- public ProjectController(ProjectService projectService) {
|
|
| 34 |
+ public ProjectController(ProjectService projectService, |
|
| 35 |
+ kr.itn.itnhub.config.CurrentUser currentUser) {
|
|
| 34 | 36 |
this.projectService = projectService; |
| 37 |
+ this.currentUser = currentUser; |
|
| 35 | 38 |
} |
| 36 | 39 |
|
| 37 | 40 |
@GetMapping("/api/projects")
|
... | ... | @@ -49,7 +52,7 @@ |
| 49 | 52 |
public List<ContactLog> addLog(@PathVariable("id") Long orgId,
|
| 50 | 53 |
@Valid @RequestBody ContactLogRequest request, |
| 51 | 54 |
Principal principal) {
|
| 52 |
- return projectService.addLog(orgId, request, principal.getName()); |
|
| 55 |
+ return projectService.addLog(orgId, request, currentUser.displayName(principal)); |
|
| 53 | 56 |
} |
| 54 | 57 |
|
| 55 | 58 |
@PutMapping("/api/orgs/{id}/contact-logs/{logId}")
|
... | ... | @@ -75,7 +78,7 @@ |
| 75 | 78 |
Principal principal) throws IOException {
|
| 76 | 79 |
String name = file.getOriginalFilename() == null ? "report" : file.getOriginalFilename(); |
| 77 | 80 |
return projectService.addReport(orgId, name, file.getContentType(), file.getBytes(), |
| 78 |
- principal.getName()); |
|
| 81 |
+ currentUser.displayName(principal)); |
|
| 79 | 82 |
} |
| 80 | 83 |
|
| 81 | 84 |
/** 한글 파일명이 깨지지 않게 ChannelFeedController.download와 같은 RFC 5987 인코딩을 쓴다. */ |
... | ... | @@ -103,7 +106,7 @@ |
| 103 | 106 |
Principal principal) throws IOException {
|
| 104 | 107 |
String name = file.getOriginalFilename() == null ? "report" : file.getOriginalFilename(); |
| 105 | 108 |
return projectService.replaceReport(orgId, reportId, name, file.getContentType(), |
| 106 |
- file.getBytes(), principal.getName()); |
|
| 109 |
+ file.getBytes(), currentUser.displayName(principal)); |
|
| 107 | 110 |
} |
| 108 | 111 |
|
| 109 | 112 |
@ResponseStatus(HttpStatus.NO_CONTENT) |
--- src/main/java/kr/itn/itnhub/provision/ChannelProvisionService.java
+++ src/main/java/kr/itn/itnhub/provision/ChannelProvisionService.java
... | ... | @@ -227,7 +227,8 @@ |
| 227 | 227 |
} |
| 228 | 228 |
if (filled(current.getChannelIdMj()) && filled(current.getChannelIdLaw())) {
|
| 229 | 229 |
mapper.updateStage(orgId, 1); |
| 230 |
- stageHistoryMapper.insert(orgId, 1); |
|
| 230 |
+ // 이것도 프로그램이 저절로 넣는 단계다(요구사항 [19]: 이력에 자동/수동 구분). |
|
| 231 |
+ stageHistoryMapper.insert(orgId, 1, true, "채널 생성"); |
|
| 231 | 232 |
} |
| 232 | 233 |
} catch (RuntimeException e) {
|
| 233 | 234 |
log.warn("진행단계 자동 시작 실패 org={}", orgId, e);
|
--- src/main/java/kr/itn/itnhub/review/ReviewImportService.java
+++ src/main/java/kr/itn/itnhub/review/ReviewImportService.java
... | ... | @@ -17,13 +17,16 @@ |
| 17 | 17 |
private final ReviewParser parser; |
| 18 | 18 |
private final ReviewItemMapper mapper; |
| 19 | 19 |
private final OrganizationMapper orgMapper; |
| 20 |
+ private final kr.itn.itnhub.stage.StageService stageService; |
|
| 20 | 21 |
private final TransactionTemplate transactionTemplate; |
| 21 | 22 |
|
| 22 | 23 |
public ReviewImportService(ReviewParser parser, ReviewItemMapper mapper, OrganizationMapper orgMapper, |
| 24 |
+ kr.itn.itnhub.stage.StageService stageService, |
|
| 23 | 25 |
PlatformTransactionManager transactionManager) {
|
| 24 | 26 |
this.parser = parser; |
| 25 | 27 |
this.mapper = mapper; |
| 26 | 28 |
this.orgMapper = orgMapper; |
| 29 |
+ this.stageService = stageService; |
|
| 27 | 30 |
this.transactionTemplate = new TransactionTemplate(transactionManager); |
| 28 | 31 |
} |
| 29 | 32 |
|
... | ... | @@ -35,7 +38,15 @@ |
| 35 | 38 |
throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId);
|
| 36 | 39 |
} |
| 37 | 40 |
List<ReviewRow> rows = parser.parse(xlsx); |
| 38 |
- return transactionTemplate.execute(status -> importRows(orgId, rows)); |
|
| 41 |
+ ReviewImportReport report = transactionTemplate.execute(status -> importRows(orgId, rows)); |
|
| 42 |
+ |
|
| 43 |
+ // 요구사항 [18]: 권리확인 자료가 올라오면 ⑤법률검토(권리확인)로 저절로 넘어간다. |
|
| 44 |
+ // 이미 그보다 앞서 있으면 아무 일도 하지 않는다. |
|
| 45 |
+ if (report != null && report.total() > 0) {
|
|
| 46 |
+ stageService.autoAdvance(orgId, kr.itn.itnhub.stage.AutoStage.REVIEW_STARTED, |
|
| 47 |
+ kr.itn.itnhub.stage.AutoStage.REASON_REVIEW_STARTED); |
|
| 48 |
+ } |
|
| 49 |
+ return report; |
|
| 39 | 50 |
} |
| 40 | 51 |
|
| 41 | 52 |
private ReviewImportReport importRows(Long orgId, List<ReviewRow> rows) {
|
+++ src/main/java/kr/itn/itnhub/stage/AutoStage.java
... | ... | @@ -0,0 +1,25 @@ |
| 1 | +package kr.itn.itnhub.stage; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * 자료가 올라온 것을 계기로 저절로 넘어가는 단계(요구사항 [18]). | |
| 5 | + * | |
| 6 | + * <p>기능확인 자료의 지시를 이 시스템의 실제 흐름에 맞춰 옮긴 것이다. | |
| 7 | + * 어느 자료가 어느 단계에 해당하는지는 답변 문서에 적어 확인 요청해 두었다.</p> | |
| 8 | + */ | |
| 9 | +public final class AutoStage { | |
| 10 | + | |
| 11 | + private AutoStage() { | |
| 12 | + } | |
| 13 | + | |
| 14 | + /** ②목록접수 — 기관 채널에 첫 자료가 올라갔을 때. */ | |
| 15 | + public static final int LIST_RECEIVED = 2; | |
| 16 | + public static final String REASON_LIST_RECEIVED = "채널에 자료 업로드"; | |
| 17 | + | |
| 18 | + /** ⑤법률검토(권리확인) — 권리확인 자료가 올라갔을 때. */ | |
| 19 | + public static final int REVIEW_STARTED = 5; | |
| 20 | + public static final String REASON_REVIEW_STARTED = "권리확인 자료 업로드"; | |
| 21 | + | |
| 22 | + /** ⑧법률검토(권리처리) — 권리처리 자료가 올라갔을 때. */ | |
| 23 | + public static final int PROCESS_STARTED = 8; | |
| 24 | + public static final String REASON_PROCESS_STARTED = "권리처리 자료 업로드"; | |
| 25 | +} |
--- src/main/java/kr/itn/itnhub/stage/StageHistoryMapper.java
+++ src/main/java/kr/itn/itnhub/stage/StageHistoryMapper.java
... | ... | @@ -11,7 +11,8 @@ |
| 11 | 11 |
/** 최신순. */ |
| 12 | 12 |
List<StageHistoryRow> findByOrg(@Param("orgId") Long orgId);
|
| 13 | 13 |
|
| 14 |
- int insert(@Param("orgId") Long orgId, @Param("stage") int stage);
|
|
| 14 |
+ int insert(@Param("orgId") Long orgId, @Param("stage") int stage,
|
|
| 15 |
+ @Param("auto") boolean auto, @Param("reason") String reason);
|
|
| 15 | 16 |
|
| 16 | 17 |
/** 채널 초기화용. 단계가 null로 돌아가므로 이력을 남겨두면 어긋난다. */ |
| 17 | 18 |
int deleteByOrg(@Param("orgId") Long orgId);
|
--- src/main/java/kr/itn/itnhub/stage/StageHistoryRow.java
+++ src/main/java/kr/itn/itnhub/stage/StageHistoryRow.java
... | ... | @@ -5,10 +5,20 @@ |
| 5 | 5 |
|
| 6 | 6 |
private int stage; |
| 7 | 7 |
private long changedAt; |
| 8 |
+ /** 프로그램이 저절로 넘긴 것인지(요구사항 [19]: 이력에 자동/수동 구분 표시). */ |
|
| 9 |
+ private boolean auto; |
|
| 10 |
+ /** 자동으로 넘어간 이유. 사람이 눌렀으면 null. */ |
|
| 11 |
+ private String reason; |
|
| 8 | 12 |
|
| 9 | 13 |
public int getStage() { return stage; }
|
| 10 | 14 |
public void setStage(int stage) { this.stage = stage; }
|
| 11 | 15 |
|
| 12 | 16 |
public long getChangedAt() { return changedAt; }
|
| 13 | 17 |
public void setChangedAt(long changedAt) { this.changedAt = changedAt; }
|
| 18 |
+ |
|
| 19 |
+ public boolean isAuto() { return auto; }
|
|
| 20 |
+ public void setAuto(boolean auto) { this.auto = auto; }
|
|
| 21 |
+ |
|
| 22 |
+ public String getReason() { return reason; }
|
|
| 23 |
+ public void setReason(String reason) { this.reason = reason; }
|
|
| 14 | 24 |
} |
--- src/main/java/kr/itn/itnhub/stage/StageService.java
+++ src/main/java/kr/itn/itnhub/stage/StageService.java
... | ... | @@ -45,11 +45,45 @@ |
| 45 | 45 |
} |
| 46 | 46 |
|
| 47 | 47 |
orgMapper.updateStage(orgId, stage); |
| 48 |
- historyMapper.insert(orgId, stage); |
|
| 48 |
+ // 사람이 눌러서 넘긴 것이다(요구사항 [19]: 이력에 자동/수동 구분). |
|
| 49 |
+ historyMapper.insert(orgId, stage, false, null); |
|
| 49 | 50 |
|
| 50 | 51 |
return OrgResponse.of(orgMapper.findById(orgId)); |
| 51 | 52 |
} |
| 52 | 53 |
|
| 54 |
+ /** |
|
| 55 |
+ * 자료가 올라온 것을 계기로 단계를 저절로 넘긴다(요구사항 [18]). |
|
| 56 |
+ * |
|
| 57 |
+ * <p>앞으로만 간다. 이미 목표 단계에 있거나 그보다 앞서 있으면 아무 일도 하지 않는다 - |
|
| 58 |
+ * 자료를 다시 올렸다고 단계가 뒤로 밀리면 안 되기 때문이다.</p> |
|
| 59 |
+ * |
|
| 60 |
+ * <p>채널이 없어 단계가 아직 없는 기관(stage=null)도 건드리지 않는다. 채널 생성 시 |
|
| 61 |
+ * ①신청으로 들어오는 것이 먼저다.</p> |
|
| 62 |
+ * |
|
| 63 |
+ * <p>사람이 이 단계를 다시 되돌리는 것은 막지 않는다(요구사항 [19]: 되돌림 허용). |
|
| 64 |
+ * 진행단계 변경에서 이전 단계를 고르면 그대로 내려간다.</p> |
|
| 65 |
+ * |
|
| 66 |
+ * @return 실제로 넘어갔으면 true |
|
| 67 |
+ */ |
|
| 68 |
+ @Transactional |
|
| 69 |
+ public boolean autoAdvance(Long orgId, int targetStage, String reason) {
|
|
| 70 |
+ Organization org = orgMapper.findById(orgId); |
|
| 71 |
+ if (org == null || org.getStage() == null) {
|
|
| 72 |
+ return false; |
|
| 73 |
+ } |
|
| 74 |
+ if (org.getStage() >= targetStage) {
|
|
| 75 |
+ return false; |
|
| 76 |
+ } |
|
| 77 |
+ int lastStage = codeService.group(Codes.STAGE).size(); |
|
| 78 |
+ if (targetStage < 1 || targetStage > lastStage) {
|
|
| 79 |
+ return false; |
|
| 80 |
+ } |
|
| 81 |
+ |
|
| 82 |
+ orgMapper.updateStage(orgId, targetStage); |
|
| 83 |
+ historyMapper.insert(orgId, targetStage, true, reason); |
|
| 84 |
+ return true; |
|
| 85 |
+ } |
|
| 86 |
+ |
|
| 53 | 87 |
public java.util.List<StageHistoryRow> history(Long orgId) {
|
| 54 | 88 |
if (orgMapper.findById(orgId) == null) {
|
| 55 | 89 |
throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId);
|
--- src/main/resources/application.yml
+++ src/main/resources/application.yml
... | ... | @@ -44,3 +44,6 @@ |
| 44 | 44 |
admin: |
| 45 | 45 |
username: ${APP_ADMIN_USERNAME}
|
| 46 | 46 |
password: ${APP_ADMIN_PASSWORD}
|
| 47 |
+ # 업무메모·연락이력·보고서에 작성자로 찍히는 이름(요구사항 [22]). |
|
| 48 |
+ # 비워 두면 로그인 아이디가 그대로 찍힌다. |
|
| 49 |
+ display-name: ${APP_ADMIN_DISPLAY_NAME:관리자}
|
--- src/main/resources/mapper/StageHistoryMapper.xml
+++ src/main/resources/mapper/StageHistoryMapper.xml
... | ... | @@ -7,15 +7,17 @@ |
| 7 | 7 |
changed_at을 밀리초 epoch로 내려준다 - WorkMemoMapper.created_at과 동일한 관례. |
| 8 | 8 |
--> |
| 9 | 9 |
<select id="findByOrg" resultType="kr.itn.itnhub.stage.StageHistoryRow"> |
| 10 |
- select stage, (extract(epoch from changed_at) * 1000)::bigint as changed_at |
|
| 10 |
+ select stage, (extract(epoch from changed_at) * 1000)::bigint as changed_at, |
|
| 11 |
+ auto, reason |
|
| 11 | 12 |
from stage_history |
| 12 | 13 |
where org_id = #{orgId}
|
| 13 | 14 |
order by changed_at desc, id desc |
| 14 | 15 |
</select> |
| 15 | 16 |
|
| 17 |
+ <!-- auto=true면 프로그램이 저절로 넘긴 것이다(요구사항 [18][19]). reason에 계기를 남긴다. --> |
|
| 16 | 18 |
<insert id="insert"> |
| 17 |
- insert into stage_history (org_id, stage) |
|
| 18 |
- values (#{orgId}, #{stage})
|
|
| 19 |
+ insert into stage_history (org_id, stage, auto, reason) |
|
| 20 |
+ values (#{orgId}, #{stage}, #{auto}, #{reason})
|
|
| 19 | 21 |
</insert> |
| 20 | 22 |
|
| 21 | 23 |
<!-- |
--- src/test/java/kr/itn/itnhub/memo/WorkMemoControllerTest.java
+++ src/test/java/kr/itn/itnhub/memo/WorkMemoControllerTest.java
... | ... | @@ -66,13 +66,13 @@ |
| 66 | 66 |
""")) |
| 67 | 67 |
.andExpect(status().isOk()) |
| 68 | 68 |
// 작성자는 요청 본문이 아니라 로그인 사용자에서 온다 |
| 69 |
- .andExpect(jsonPath("$.contactName").value("admin"))
|
|
| 69 |
+ .andExpect(jsonPath("$.contactName").value("관리자"))
|
|
| 70 | 70 |
.andExpect(jsonPath("$.body").value("채널 생성 일정 문의"))
|
| 71 | 71 |
.andExpect(jsonPath("$.createdAt").isNumber());
|
| 72 | 72 |
|
| 73 | 73 |
mvc.perform(get("/api/orgs/{id}/memos", orgId))
|
| 74 | 74 |
.andExpect(status().isOk()) |
| 75 |
- .andExpect(jsonPath("$[0].contactName").value("admin"))
|
|
| 75 |
+ .andExpect(jsonPath("$[0].contactName").value("관리자"))
|
|
| 76 | 76 |
.andExpect(jsonPath("$[0].body").value("채널 생성 일정 문의"));
|
| 77 | 77 |
} |
| 78 | 78 |
|
... | ... | @@ -192,7 +192,7 @@ |
| 192 | 192 |
""")) |
| 193 | 193 |
.andExpect(status().isOk()) |
| 194 | 194 |
.andExpect(jsonPath("$.body").value("고친 뒤"))
|
| 195 |
- .andExpect(jsonPath("$.contactName").value("admin"))
|
|
| 195 |
+ .andExpect(jsonPath("$.contactName").value("관리자"))
|
|
| 196 | 196 |
.andExpect(jsonPath("$.createdAt").value(createdAt));
|
| 197 | 197 |
} |
| 198 | 198 |
|
--- src/test/java/kr/itn/itnhub/project/ProjectControllerTest.java
+++ src/test/java/kr/itn/itnhub/project/ProjectControllerTest.java
... | ... | @@ -76,7 +76,7 @@ |
| 76 | 76 |
""")) |
| 77 | 77 |
.andExpect(status().isOk()) |
| 78 | 78 |
.andExpect(jsonPath("$.length()").value(1))
|
| 79 |
- .andExpect(jsonPath("$[0].author").value("admin"))
|
|
| 79 |
+ .andExpect(jsonPath("$[0].author").value("관리자"))
|
|
| 80 | 80 |
.andExpect(jsonPath("$[0].contactedOn").value("2026-07-20"));
|
| 81 | 81 |
|
| 82 | 82 |
mvc.perform(post("/api/orgs/{id}/contact-logs", orgId).with(csrf())
|
... | ... | @@ -112,7 +112,7 @@ |
| 112 | 112 |
.andExpect(status().isOk()) |
| 113 | 113 |
.andExpect(jsonPath("$[0].summary").value("고침"))
|
| 114 | 114 |
.andExpect(jsonPath("$[0].method").value("방문"))
|
| 115 |
- .andExpect(jsonPath("$[0].author").value("admin"));
|
|
| 115 |
+ .andExpect(jsonPath("$[0].author").value("관리자"));
|
|
| 116 | 116 |
} |
| 117 | 117 |
|
| 118 | 118 |
@Test |
... | ... | @@ -151,7 +151,7 @@ |
| 151 | 151 |
.andExpect(status().isOk()) |
| 152 | 152 |
.andExpect(jsonPath("$.length()").value(1))
|
| 153 | 153 |
.andExpect(jsonPath("$[0].fileName").value("최종보고서.pdf"))
|
| 154 |
- .andExpect(jsonPath("$[0].uploadedBy").value("admin"))
|
|
| 154 |
+ .andExpect(jsonPath("$[0].uploadedBy").value("관리자"))
|
|
| 155 | 155 |
.andReturn().getResponse().getContentAsString(); |
| 156 | 156 |
int reportId = com.jayway.jsonpath.JsonPath.parse(uploaded).read("$[0].id", Integer.class);
|
| 157 | 157 |
|
+++ src/test/java/kr/itn/itnhub/stage/AutoStageTest.java
... | ... | @@ -0,0 +1,127 @@ |
| 1 | +package kr.itn.itnhub.stage; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.AbstractDbTest; | |
| 4 | +import kr.itn.itnhub.org.Organization; | |
| 5 | +import kr.itn.itnhub.org.OrganizationMapper; | |
| 6 | +import org.junit.jupiter.api.BeforeEach; | |
| 7 | +import org.junit.jupiter.api.Test; | |
| 8 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 9 | +import org.springframework.jdbc.core.JdbcTemplate; | |
| 10 | + | |
| 11 | +import java.util.List; | |
| 12 | + | |
| 13 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 14 | + | |
| 15 | +/** | |
| 16 | + * 자료가 올라온 것을 계기로 단계가 저절로 넘어가는 규칙(요구사항 [18][19]). | |
| 17 | + * | |
| 18 | + * <p>핵심은 "앞으로만 간다"는 것이다. 자료를 다시 올렸다고 단계가 뒤로 밀리면 안 된다.</p> | |
| 19 | + */ | |
| 20 | +class AutoStageTest extends AbstractDbTest { | |
| 21 | + | |
| 22 | + @Autowired | |
| 23 | + StageService stageService; | |
| 24 | + | |
| 25 | + @Autowired | |
| 26 | + OrganizationMapper orgMapper; | |
| 27 | + | |
| 28 | + @Autowired | |
| 29 | + StageHistoryMapper historyMapper; | |
| 30 | + | |
| 31 | + @Autowired | |
| 32 | + JdbcTemplate jdbc; | |
| 33 | + | |
| 34 | + private Long orgId; | |
| 35 | + | |
| 36 | + @BeforeEach | |
| 37 | + void setUp() { | |
| 38 | + jdbc.update("delete from stage_history"); | |
| 39 | + jdbc.update("delete from organization where org_no = '930'"); | |
| 40 | + | |
| 41 | + Organization org = new Organization(); | |
| 42 | + org.setOrgNo("930"); | |
| 43 | + org.setOrgName("자동전환테스트기관"); | |
| 44 | + org.setChannelSlug("930"); | |
| 45 | + orgMapper.upsertBySeed(org); | |
| 46 | + orgId = orgMapper.findByOrgNoAndOrgName("930", "자동전환테스트기관").getId(); | |
| 47 | + } | |
| 48 | + | |
| 49 | + private void setStage(int stage) { | |
| 50 | + orgMapper.updateStage(orgId, stage); | |
| 51 | + historyMapper.insert(orgId, stage, false, null); | |
| 52 | + } | |
| 53 | + | |
| 54 | + @Test | |
| 55 | + void 뒤에_있으면_앞으로_넘어간다() { | |
| 56 | + setStage(1); | |
| 57 | + | |
| 58 | + boolean moved = stageService.autoAdvance(orgId, AutoStage.REVIEW_STARTED, | |
| 59 | + AutoStage.REASON_REVIEW_STARTED); | |
| 60 | + | |
| 61 | + assertThat(moved).isTrue(); | |
| 62 | + assertThat(orgMapper.findById(orgId).getStage()).isEqualTo(AutoStage.REVIEW_STARTED); | |
| 63 | + } | |
| 64 | + | |
| 65 | + @Test | |
| 66 | + void 이미_앞서_있으면_뒤로_밀지_않는다() { | |
| 67 | + setStage(10); | |
| 68 | + | |
| 69 | + boolean moved = stageService.autoAdvance(orgId, AutoStage.REVIEW_STARTED, | |
| 70 | + AutoStage.REASON_REVIEW_STARTED); | |
| 71 | + | |
| 72 | + assertThat(moved).isFalse(); | |
| 73 | + assertThat(orgMapper.findById(orgId).getStage()).isEqualTo(10); | |
| 74 | + } | |
| 75 | + | |
| 76 | + @Test | |
| 77 | + void 같은_단계면_이력을_또_쌓지_않는다() { | |
| 78 | + setStage(AutoStage.REVIEW_STARTED); | |
| 79 | + int before = historyMapper.findByOrg(orgId).size(); | |
| 80 | + | |
| 81 | + boolean moved = stageService.autoAdvance(orgId, AutoStage.REVIEW_STARTED, | |
| 82 | + AutoStage.REASON_REVIEW_STARTED); | |
| 83 | + | |
| 84 | + assertThat(moved).isFalse(); | |
| 85 | + assertThat(historyMapper.findByOrg(orgId)).hasSize(before); | |
| 86 | + } | |
| 87 | + | |
| 88 | + @Test | |
| 89 | + void 채널이_없어_단계가_아직_없으면_건드리지_않는다() { | |
| 90 | + // 채널 생성으로 ①신청이 들어오는 것이 먼저다. | |
| 91 | + boolean moved = stageService.autoAdvance(orgId, AutoStage.LIST_RECEIVED, | |
| 92 | + AutoStage.REASON_LIST_RECEIVED); | |
| 93 | + | |
| 94 | + assertThat(moved).isFalse(); | |
| 95 | + assertThat(orgMapper.findById(orgId).getStage()).isNull(); | |
| 96 | + } | |
| 97 | + | |
| 98 | + @Test | |
| 99 | + void 이력에_자동_여부와_계기가_남는다() { | |
| 100 | + setStage(1); | |
| 101 | + stageService.autoAdvance(orgId, AutoStage.PROCESS_STARTED, AutoStage.REASON_PROCESS_STARTED); | |
| 102 | + | |
| 103 | + List<StageHistoryRow> history = historyMapper.findByOrg(orgId); | |
| 104 | + | |
| 105 | + assertThat(history).hasSize(2); | |
| 106 | + // 최신순이라 자동 전환이 앞에 온다. | |
| 107 | + assertThat(history.get(0).getStage()).isEqualTo(AutoStage.PROCESS_STARTED); | |
| 108 | + assertThat(history.get(0).isAuto()).isTrue(); | |
| 109 | + assertThat(history.get(0).getReason()).isEqualTo(AutoStage.REASON_PROCESS_STARTED); | |
| 110 | + // 사람이 넣은 것은 자동이 아니다. | |
| 111 | + assertThat(history.get(1).isAuto()).isFalse(); | |
| 112 | + assertThat(history.get(1).getReason()).isNull(); | |
| 113 | + } | |
| 114 | + | |
| 115 | + @Test | |
| 116 | + void 자동으로_올라간_단계를_사람이_되돌릴_수_있다() { | |
| 117 | + // 요구사항 [19]: 되돌림 허용. | |
| 118 | + setStage(1); | |
| 119 | + stageService.autoAdvance(orgId, AutoStage.REVIEW_STARTED, AutoStage.REASON_REVIEW_STARTED); | |
| 120 | + assertThat(orgMapper.findById(orgId).getStage()).isEqualTo(AutoStage.REVIEW_STARTED); | |
| 121 | + | |
| 122 | + stageService.updateStage(orgId, new StageRequest(2)); | |
| 123 | + | |
| 124 | + assertThat(orgMapper.findById(orgId).getStage()).isEqualTo(2); | |
| 125 | + assertThat(historyMapper.findByOrg(orgId).get(0).isAuto()).isFalse(); | |
| 126 | + } | |
| 127 | +} |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?