Tickmark 화면설계서
S1-S6 6개 화면을 영역 번호, 상태 변형, 사용자 행동, 권한, 감사 로그 기준으로 정리했다. 디자인 시스템 시안이 화면마다 대표 상태 하나를 보여 준다면, 이 문서는 구현할 때 만들어야 하는 상태를 화면별로 모두 보여 준다. 값이나 규칙이 다르면 디자인 시스템 문서와 설계 문서가 우선한다.
0. 문서 정보
이 문서는 MVP(2026-10-23) 구현과 리뷰의 기준 화면이다. 구현이 바뀌면 같은 파일을 고치고 맨 아래 Changelog에 남긴다.
- 문서
- 2026-10-17-13-화면설계서
- 버전
- v1.1, 2026-10-19
- 대상 범위
- MVP 화면 S1-S6, 데스크톱 1024px 이상
- 데이터
- 합성 데이터. 누리정밀 주식회사 FY2025, 시드 20251231
읽는 법
- 전체 프레임은 1024px 캔버스에 상단 바부터 그린 화면이다. 좁은 창에서는 프레임 안에서만 가로로 스크롤한다.
- 부분 프레임은 같은 화면에서 상태에 따라 달라지는 영역만 그린다. 나머지 영역은 전체 프레임과 같다.
- 표식은 프레임 바로 아래 영역 표의 번호다. 상단 "영역 번호" 버튼으로 숨길 수 있다.
- 프레임 막대의 "김작성으로 로그인"은 그 화면을 보는 사람과 역할이다. 같은 화면도 역할에 따라 버튼이 다르다.
- S1, S2는 구현된 코드의 문구를 옮겼다. S3-S6은 아직 스텁이라 설계 계약을 기준으로 한 제안이고,
phx-이벤트 이름은 구현할 때 확정한다.
등장인물 (합성)
| 이름 | 이메일 | 역할 | 이 문서에서 하는 일 |
|---|---|---|---|
| 김작성 | kim.writer@example.com | 작성자 | 감사 대상을 만든 사람. 멤버 추가, GL 적재, 테스트 실행, AI 트리아지 요청, 결론 제출 |
| 이작성 | lee.writer@example.com | 작성자 | 추가된 작성자. 생성자가 아니므로 멤버 추가 폼이 없다 |
| 박검토 | park.reviewer@example.com | 검토자 | 승인, 반려. 적재, 실행, 트리아지, 결론 버튼이 없다 |
| 비멤버 | -없음 | -없음 | 로그인했지만 이 감사 대상의 멤버가 아닌 사용자. S2-S6 주소로 들어오면 404 |
1. 화면 목록
라우트는 모두 로그인 필요 live_session :require_authenticated_user 안에 있다. S2-S6은 get_engagement!/2가 멤버가 아니면 Ecto.NoResultsError를 내서 404가 된다.
| ID | LiveView (action) | 누가 쓰나 | 구독 토픽 | 주요 컨텍스트 함수 | 상태 |
|---|---|---|---|---|---|
| S1 | EngagementLive.Index (:index, :new) | 로그인 사용자 전원 | 없음 | Engagements.list_engagements/1 create_engagement/2, change_engagement/2 | 구현됨 |
| S2 | EngagementLive.Show (:show) | 멤버 (추가 폼은 생성자만) | engagement:#{id} | get_engagement!/2, list_members/2, add_member/4 Ledger.list_imports/2, Testing.list_test_runs/2, start_test_run/3 | 일부 |
| S3 | ImportLive.New (:new) | 작성자 | engagement:#{id} | Ledger.create_import/3, generate_sample_import/3, get_import!/2 | 구현됨 |
| S4 | TestRunLive.Show (:show) | 멤버 (트리아지는 작성자) | test_run:#{run_id} engagement:#{id} | Testing.get_test_run!/2, subscribe_test_run/2, list_findings/3 Triage.request_triage/2 | 일부 |
| S5 | FindingLive.Show (:show) | 작성자 (판단), 검토자 (서명) | engagement:#{id} engagement:#{id}:presence | Testing.get_finding!/2, Triage.request_finding_triage/2 Review.submit_conclusion/3, decide/4 | 스텁 |
| S6 | AuditLive.Index (:index) | 멤버 (읽기 전용) | engagement:#{id} | AuditTrail.list_events/3 | 스텁 |
presence는 S2-S6 모든 engagement 하위 화면이 engagement:#{id}:presence에 들어간다. meta는 %{email, role, view}이고, S5만 view = "finding:#{id}"로 같은 전표를 보는 사람을 거른다.
2. 흐름
한 감사 대상에서 GL을 적재하고 승인까지 가는 경로다. 점선 상자는 AI, 회색 상자는 백그라운드 작업이다. 상자 안의 코드는 그 단계에서 남는 감사 로그 action이다.
2.1 주 흐름
김작성
Oban
ledger_import.succeeded또는failed
RuleWorker 8개, 동시 4개test_run_rule.failed(실패 시)
finding 집계, 점수test_run.completed
TriageWorker
ai_triage.completed또는failed
제안만 한다. finding.status를 바꾸지 않는다.
박검토
2.2 상태 기계
finding.status (Review만 바꾼다)
| 전이 | 행위 | 화면 표시 |
|---|---|---|
| open -> concluded | 작성자 결론 제출 | 미판단 -> 판단 배지 + 검토 대기 |
| concluded -> concluded | 검토 전 재제출 (새 행 추가) | 판단 배지만 바뀜 |
| concluded -> approved | 검토자 승인 (끝 상태) | 승인, 잠금 안내 |
| concluded -> rejected | 검토자 반려, 사유 필수 | 반려 + 사유 |
| rejected -> concluded | 작성자 재제출 | 검토 대기 |
| approved -> 없음 | 제출, 결정 모두 :invalid_state | 폼 대신 읽기 전용 |
finding.triage_status (Triage만 바꾼다)
| 전이 | 원인 | 화면 표시 |
|---|---|---|
| none -> queued | S4 일괄 요청 또는 S5 건별 요청 | 분석 중 |
| queued -> queued | 429, 5xx 등 재시도 가능한 오류 (Oban backoff) | 분석 중 유지 |
| queued -> succeeded | ai_triages 행 저장 | 위험도 배지 |
| queued -> failed | 스키마 불일치, 금지 표현, 4xx 등 | AI 실패 + 다시 요청 |
| failed -> queued | S5 다시 요청 (새 ai_triages 행) | 분석 중 |
ledger_imports.status
배지: 대기 (clock), 적재 중 (arrow-path, 회전), 완료 (check), 실패 (x-circle). 실패한 적재로는 테스트를 실행할 수 없다 (:import_not_ready).
test_runs.status, test_run_rules.status
룰 하나라도 failed면 실행은 completed_with_errors. skipped는 실행 조건 미달(예: 희귀 계정 조합의 모집단 부족)이고 0점으로 본다. 같은 적재에 running 실행이 있으면 새 실행은 :run_in_progress.
3. 공통
여러 화면에 똑같이 적용되는 상단 바, 권한, 공통 상태, 실시간 갱신, 반응형, 값 형식을 여기서 정한다. S1부터 S6까지의 절에는 여기와 다른 점만 적는다.
3.1 상단 바
상단 바는 48px 높이로 위에 붙어 있다. 들어 있는 항목은 감사 대상 안인지 밖인지에 따라 달라진다. 번호는 아래 영역 표의 번호다. 화면 위 오른쪽의 "영역 번호" 버튼으로 번호를 숨길 수 있다.
| 번호 | 영역 | 내용과 데이터 | 권한, 조건 |
|---|---|---|---|
| 브랜드 | 로고와 Tickmark. 누르면 S1(/engagements)로 간다. | 모든 화면 | |
| 감사 대상 이름 | "회사명 FY연도". 누르면 S2로 간다. S2에서는 aria-current="page". | 감사 대상 안에서만 | |
| 감사 로그 | 누르면 S6으로 간다. S6에서는 aria-current="page". | 멤버 전원 (:view) | |
| presence | engagement:#{id}:presence. 탭을 여러 개 열어도 사람마다 아바타 1개. 최대 4개, 넘으면 "+N". 내 아바타는 avatar-self. 이름 목록은 sr-only로 읽힌다. 들어오고 나가면 바로 바뀐다. | S2, S3, S4, S6. S5는 페이지 머리로 옮긴다 | |
| 테마 | 시스템, 라이트, 다크. localStorage의 tk:theme에 저장한다. 저장할 수 없으면 이번 페이지에만 적용한다. | 모든 화면 | |
| 사용자 메뉴 | 내 이메일과 펼침 단추. 항목은 설정(/users/settings)과 로그아웃(DELETE /users/log-out). 한글 문구는 확인 필요. | 로그인 사용자 |
아직 구현되지 않았다. 지금은 Phoenix 기본 헤더(Layouts.app의 Website, GitHub, Get Started)가 보이고, 루트 레이아웃의 Settings, Log out은 영어다. 부록 C의 C-01.
3.2 권한
역할은 감사 대상마다 따로 정해진다. 한 사람은 한 감사 대상에서 한 역할만 맡는다. 아래 표의 세 사람은 누리정밀 FY2025의 멤버다.
| 행동 | 김작성 생성자, 작성자 | 이작성 작성자 | 박검토 검토자 | 비멤버 | 권한 키 | 거부될 때 |
|---|---|---|---|---|---|---|
| S1 목록 보기 | 가능 | 가능 | 가능 | 가능 | 로그인만 | 목록에는 내가 멤버인 감사 대상만 보인다 |
| S1 감사 대상 만들기 | 가능 | 가능 | 가능 | 가능 | 로그인만 | 만든 사람이 작성자 멤버가 된다 |
| S2-S6 보기 | 가능 | 가능 | 가능 | -불가 | :view | 404. 없는 감사 대상과 구별되지 않는다 |
| 멤버 추가 (S2) | 가능 | -불가 | -불가 | -불가 | :manage_members(created_by) | 폼을 그리지 않는다. 이벤트를 직접 보내면 flash "멤버를 추가할 권한이 없습니다." |
| GL 불러오기 (S2, S3) | 가능 | 가능 | -불가 | -불가 | :import_ledger | 단추를 숨긴다. S3 주소로 바로 오면 S2로 보내고 flash (제안) |
| 테스트 실행 (S2) | 가능 | 가능 | -불가 | -불가 | :start_test_run | 단추를 숨긴다 |
| AI 트리아지 요청 (S4, S5) | 가능 | 가능 | -불가 | -불가 | :request_triage | 자리는 두고 비활성. 이유 "작성자만 요청할 수 있습니다" |
| 결론 제출 (S5) | 가능 | 가능 | -불가 | -불가 | :submit_conclusion | 판단 폼 대신 읽기 전용 요약 |
| 승인, 반려 (S5) | -불가 | -불가 | 가능 | -불가 | :review_conclusion | 자리는 두고 비활성. 이유 "검토자만 승인할 수 있습니다" |
| 감사 로그 보기 (S6) | 가능 | 가능 | 가능 | -불가 | :view | 404 |
| 계층 | 무엇을 막나 | 방법 |
|---|---|---|
| 라우터 | 로그인하지 않은 사용자 | live_session, require_authenticated_user |
| 컨텍스트 | 비멤버, 역할 밖 행동 | authorize/3가 Policy.allowed?/2를 부른다 |
| 컨텍스트 | 자기 결론 승인 | decide/4의 self_review 검사와 review.denied 기록 |
| DB | 우회 코드 | CHECK reviewer_id <> preparer_id, 복합 FK |
| DB | 한 사람이 두 역할 | unique (engagement_id, user_id) |
| LiveView | 단추 노출 | UX일 뿐이다. 보안 경계가 아니다 |
- 역할 밖의 보조 행동(멤버 추가, GL 불러오기, 테스트 실행)은 숨긴다.
- 화면의 핵심 행동 영역(S4 트리아지 바, S5 판단과 서명)은 자리를 남기고 비활성으로 둔다. 이유는
aria-describedby로 단추에 연결한다. 역할마다 레이아웃이 바뀌지 않고, 이 일을 누가 하는지가 화면에 드러난다. - 모든 행동은 컨텍스트 함수가 다시 확인한다. 단추를 숨긴 것만으로 막았다고 보지 않는다.
- 비멤버에게는 403이 아니라 404를 준다. 감사 대상이 있는지조차 알리지 않는다.
decide/4는 권한을 먼저 확인하므로 작성자가 승인하려 하면:forbidden이 먼저 나온다. self_review와review.denied는 권한 검사를 지나온 우회 경로를 위한 이중 방어다.
3.3 공통 상태
:info, role=status:error, role=alert:forbidden 같은 값)은 문구에 넣지 않는다.작성자만 요청할 수 있습니다
disabled와 이유 문구를 함께 둔다. 툴팁에만 이유를 숨기지 않는다.3.4 실시간 갱신
화면은 새로고침 없이 바뀐다. 다른 사람의 행동으로 내가 보던 내용이 바뀌어도 입력하던 내용과 스크롤 위치는 그대로 둔다.
| 토픽 | 메시지 | 받는 화면 | 화면 변화 |
|---|---|---|---|
engagement:#{id} | import_updated, %{import_id} | S2, S3 | S2는 적재 행을 바꾼다(stream_insert, at: 0). S3는 진행 패널을 결과 패널로 바꾼다 |
engagement:#{id} | test_run_updated | S2 | 실행 행과 통계를 바꾼다. 지금은 받기만 하고 무시한다 |
engagement:#{id} | finding_updated | S4, S5 | 발견 행의 AI, 판단, 검토 배지. S5는 큐 항목과 이력 |
engagement:#{id} | audit_appended | S6 | 목록 위에 "새 이벤트 N건" 단추만 띄운다. 누르면 그때 넣는다 |
test_run:#{id} | rule_progress | S4 | 룰별 진행 목록과 진행률 |
test_run:#{id} | run_completed | S4 | 진행 패널을 접고 발견 표를 연다 |
engagement:#{id}:presence | presence_diff | S2-S6 | 상단 바 아바타. S5는 같은 전표를 보는 사람(meta view가 finding:#{id}) |
- S6은 새 이벤트를 자동으로 끼워 넣지 않는다. 읽던 줄이 밀려 내려가지 않게 하기 위해서다.
- S5에서 판단을 쓰는 중에 다른 작성자가 결론을 제출하면 폼을 지우지 않는다. 폼 위에 info alert "이작성이 방금 결론을 제출했습니다. 제출하면 새 결론으로 남습니다."를 띄운다.
- S5에서 검토자가 승인이나 반려를 하면 작성자 화면의 상태 배지와 이력이 바로 바뀐다. 승인된 전표의 판단 폼은 읽기 전용으로 바뀐다.
3.5 반응형
기준 폭은 1024px다. 아래 폭 구간은 제안이고, 구현하면서 확정한다.
| 폭 | 규칙 |
|---|---|
| 1024px 이상 | 기준 레이아웃. S5는 왼쪽 큐 232px + 본문 |
| 768-1023px | 표는 tbl-wrap 안에서만 가로로 스크롤한다. 페이지 전체는 가로로 스크롤하지 않는다. 상단 바의 감사 대상 이름은 말줄임 |
| 767px 이하 | S5 큐는 본문 위의 접는 목록(details)으로 옮긴다. 통계는 2열. 폼은 1열. 앱 영역 좌우 여백 16px |
좁은 화면의 S5는 S5.4에 그렸다.
3.6 값 형식
| 값 | 형식 | 예 |
|---|---|---|
| 금액 | 천 단위 쉼표, 오른쪽 정렬, 단위는 열 머리에 "(원)" | 386,214,905,000 |
| 0인 금액, 0건 | "-"를 보이고 sr-only로 "0" | -0 |
| 값 없음 | "-"를 보이고 sr-only로 "없음" | -없음 |
| 건수 | 천 단위 쉼표, 단위는 작게 | 8,341건 |
| 시각 | KST로 보이고, datetime은 UTC ISO 8601, title은 "YYYY-MM-DD HH:MM:SS UTC" | |
| 날짜 | ISO 형식 | 2025-12-31 |
| 회계연도, 회계기간 | FY + 연도. 13기는 "13(결산조정)" | FY2025, 13(결산조정) |
| 전표 번호, 코드, 해시 | 고정폭. 해시는 앞 8자 | JE-000123, 3f9a0c7e |
| 점수 | 숫자, 5칸 미터(점수/20 올림), 구간 이름 | 55상 |
| 사람 | 이름. 감사 로그에서는 이름과 역할 | 김작성, 박검토 |
지금의 kst_time은 title도 ISO 8601이다 (C-04). 사람 이름은 users 테이블에 없다. 지금은 이메일만 있으므로 users.display_name을 더할지 정해야 한다 (C-05).
S1. 감사 대상 목록
- 경로
/engagements,/engagements/new- LiveView
- EngagementLive.Index
- 권한
- 로그인 사용자 전원
- 데이터
- Engagements.list_engagements/1
- 구독
- 없음
- 구현
- 구현됨 (최근 테스트, 검토 큐 열은 WBS 2.5)
S1.1 기본 화면
/engagementsEngagementLive.Index :index, 김작성으로 로그인| 번호 | 영역 | 내용과 데이터 | 권한, 조건 |
|---|---|---|---|
| 페이지 제목 | "감사 대상". 페이지의 h1. | - | |
| 새 감사 대상 | patch로 /engagements/new. 목록 위에 인라인 패널을 연다. 모달이 아니다. 패널이 열려 있으면 단추를 숨긴다. | 로그인 사용자 전원 | |
| 목록 표 | 내가 멤버인 감사 대상만. 최근에 만든 순(inserted_at 내림차순). 회사명을 누르면 S2. | - | |
| 최근 테스트 | 가장 최근 실행의 상태. 실행 중, 완료, 일부 실패(completed_with_errors). 실행이 없으면 "-". | Testing.latest_run_summaries/1 | |
| 검토 큐(건) | 최근 실행에서 점수가 triage_threshold(25) 이상인 발견 수. 실행 중이거나 실행이 없으면 "-". | Testing.latest_run_summaries/1 | |
| 내 역할 | 작성자 또는 검토자. 테두리 배지. | - |
S1.2 상태 변형
/engagements/new/engagements로 돌아간다.phx-disable-with="만드는 중". 두 번 눌러도 한 번만 만들어진다.S1.3 이벤트
| 트리거 | 처리 | 성공 | 실패 | 감사 로그 |
|---|---|---|---|---|
| "새 감사 대상" | patch /engagements/new, handle_params :new | 패널을 연다 | - | - |
| 취소 | patch /engagements | 패널을 닫고 입력을 버린다 | - | - |
입력 (phx-change="validate") | Engagements.change_engagement/2, action :validate | 칸별 오류 | - | - |
만들기 (phx-submit="save") | Engagements.create_engagement/2 | flash "감사 대상을 만들었습니다.", S2로 이동 | changeset 오류를 칸 아래에 보인다 | engagement.created. 생성자를 작성자 멤버로 넣는 일은 같은 트랜잭션이고 따로 이벤트를 남기지 않는다 |
| 회사명 누름 | navigate S2 | - | - | - |
S2. 감사 대상 상세
- 경로
/engagements/:id- LiveView
- EngagementLive.Show
- 권한
- 멤버 (
:view). 멤버 추가는 생성자만 - 데이터
- Engagements.get_engagement!/2, list_members/2, Ledger.list_imports/2, Testing.list_test_runs/2
- 구독
- engagement:#{id}
- 구현
- 구현됨 (통계, 실행 목록, 행 행동은 WBS 2.6). presence는 아직 없다
S2.1 기본 화면
/engagements/:idEngagementLive.Show, 김작성(생성자, 작성자)멤버
| 이름 | 이메일 | 역할 |
|---|---|---|
| 김작성 (나) | kim.writer@example.com | 작성자 |
| 이작성 | lee.writer@example.com | 작성자 |
| 박검토 | park.reviewer@example.com | 검토자 |
GL 적재
| 출처 | 상태 | 전표 | 라인 | 차변 합계(원) | 대변 합계(원) | 오류 | 경고 | 해시 | 적재 시각 | 행동 |
|---|---|---|---|---|---|---|---|---|---|---|
| 합성 GL, 시드 20251231 | 완료 | 8,341 | 19,804 | 386,214,905,000 | 386,214,905,000 | -0 | -0 | 3f9a0c7e | ||
| gl_2025_draft.csv | 실패 | -없음 | -없음 | -없음 | -없음 | 4 | 1 | b71d02e5 |
테스트 실행
| 시작 시각 | 상태 | 룰 | 적중 전표 | 검토 큐 | 행동 |
|---|---|---|---|---|---|
| 완료 | 8 / 8 | 265 | 52 | 열기 |
| 번호 | 영역 | 내용과 데이터 | 권한, 조건 |
|---|---|---|---|
| 이동 경로 | 감사 대상 / 회사명 FY연도. 첫 항목은 S1 링크. | - | |
| 제목과 개요 | 회사명과 회계연도가 h1. 아래 줄에 결산일과 수행중요성. | - | |
| 머리 행동 | 감사 로그(보조, 멤버 전원)와 GL 불러오기(primary, S3). | GL 불러오기는 :import_ledger | |
| 통계 | 전표와 라인은 가장 최근에 성공한 적재 기준. 검토 큐, 경미 적중, 승인은 가장 최근에 끝난 실행 기준. 기준값은 실행 시점의 triage_threshold 스냅샷. 끝난 실행이 없으면 "-". | Testing.count_findings/2 (C-07 해결) | |
| 멤버 표 | 이름, 이메일, 역할. 내 행에 "(나)". 추가한 순서(inserted_at 오름차순). | 이름 열은 C-05 결정에 따른다 | |
| 멤버 추가 폼 | 이메일, 역할(기본값 검토자), 추가. 제출 중에는 "추가 중". | 생성자만 (:manage_members) | |
| GL 적재 표 | 새 적재가 위. 출처는 CSV면 파일명, 합성이면 "합성 GL, 시드 N". 상태는 대기, 적재 중, 완료, 실패. 실패 행의 파일명은 S3 결과로 가는 링크이고 오류 수는 빨간 숫자. 완료 행에 테스트 실행 단추. | 단추는 :start_test_run, 그 적재에 실행 중인 테스트가 없을 때만 | |
| 테스트 실행 표 | 새 실행이 위. 시작 시각, 상태(실행 중, 완료, 일부 실패), 끝난 룰 / 전체 룰, 적중 전표, 검토 큐, 열기(S4). | 룰 열은 실패한 룰도 끝난 룰로 센다 (S4 진행 요약과 같은 기준). S2.2 "실행 일부 실패"의 7 / 8은 구현에서 8 / 8로 보인다 |
S2.2 상태 변형
누리정밀 주식회사 FY2025
결산일 2025-12-31수행중요성 225,000,000원
| 이름 | 역할 |
|---|---|
| 김작성 | 작성자 |
| 이작성 | 작성자 |
| 박검토 (나) | 검토자 |
| 출처 | 상태 | 전표 | 오류 | 적재 시각 |
|---|---|---|---|---|
| gl_2025_v2.csv | 적재 중 | -없음 | -없음 | |
| gl_2025_v3.csv | 대기 | -없음 | -없음 |
import_updated를 받아 같은 행이 완료나 실패로 바뀐다. 새로고침하지 않는다.| 시작 시각 | 상태 | 룰 | 행동 |
|---|---|---|---|
| 실행 중 | 5 / 8 | 열기 |
이 GL에 실행 중인 테스트가 있습니다.
:run_in_progress).| 상태 | 룰 | 적중 전표 |
|---|---|---|
| 일부 실패 | 7 / 8 | 249 |
| 박검토 | park.reviewer@example.com | 검토자 |
S2.3 이벤트
| 트리거 | 처리 | 성공 | 실패 | 감사 로그 |
|---|---|---|---|---|
| mount | get_engagement!/2. 연결된 mount에서만 Engagements.subscribe/2 | 화면 표시 | 비멤버는 Ecto.NoResultsError, 404 | - |
추가 (phx-submit="add_member") | Engagements.add_member/4 | flash "멤버를 추가했습니다.", 표에 행 추가 | :forbidden flash. :user_not_found와 unique 위반은 이메일 칸. 그 밖은 역할 칸 | engagement.member_added (user_id, role) |
| GL 불러오기 | navigate S3 | - | - | - |
| 감사 로그 | navigate S6 | - | - | - |
테스트 실행 (제안: phx-click="start_run") | Testing.start_test_run(scope, import, :all) | S4로 push_navigate | :import_not_ready "적재가 끝난 GL만 테스트할 수 있습니다.", :run_in_progress "이 GL에 실행 중인 테스트가 있습니다.", :forbidden "테스트를 실행할 권한이 없습니다." | test_run.started |
import_updated | Ledger.get_import!/2, stream_insert at: 0 | 행 상태와 합계가 바뀐다 | - | - |
test_run_updated (제안) | Testing.get_test_run!/2, stream_insert | 실행 행과 통계가 바뀐다 | - | - |
| 열기 | navigate S4 | - | - | - |
S3. GL 불러오기
- 경로
/engagements/:id/imports/new- LiveView
- ImportLive.New
- 권한
- 작성자 (
:import_ledger) - 데이터
- Ledger.create_import/3, Ledger.generate_sample_import/3, Ledger.get_import!/2, Testing.start_test_run/3
- 구독
- engagement:#{id}
- 구현
- 구현됨 (WBS 2.3)
S3.1 기본 화면
/engagements/:id/imports/newImportLive.New, CSV 업로드 탭, 파일을 고르기 전감사 대상누리정밀 주식회사 FY2025GL 불러오기
GL 불러오기
| 번호 | 영역 | 내용과 데이터 | 권한, 조건 |
|---|---|---|---|
| 이동 경로와 제목 | 감사 대상 / 회사명 FY연도 / GL 불러오기. 제목 "GL 불러오기". | - | |
| 적재 방식 탭 | CSV 업로드와 합성 GL 생성. 탭은 patch로 ?source=upload, ?source=generated를 바꾼다 (제안). 진행 중인 업로드가 있으면 탭을 바꿔도 취소하지 않는다. | - | |
| 파일 놓는 곳 | allow_upload(:gl, accept: ~w(.csv), max_entries: 1, max_file_size: 20_000_000). 끌어 놓기와 파일 선택 둘 다. 키보드로는 Enter, Space로 파일 선택 창을 연다. | - | |
| 형식 안내 | 컬럼 규칙 요약. 전체 계약은 도메인 문서의 GL 계약. | - | |
| 업로드 | 파일이 없으면 비활성이고 이유를 옆에 둔다. 누르면 업로드를 마치고 적재를 맡긴 뒤 같은 화면에 결과 패널을 연다. | - |
S3.2 적재 실패 결과
검증에서 하나라도 오류가 나면 아무 전표도 적재하지 않는다. 결과 패널은 업로드 폼 아래에 붙는다.
/engagements/:id/imports/new검증 실패, 오류 4건과 경고 1건| 행 | 컬럼 | 코드 | 내용 |
|---|---|---|---|
| 214 | -없음 | both_sides | 한 행에는 차변과 대변 중 하나만 있어야 합니다 |
| 1,032 | entered_at | invalid_date | 날짜 또는 일시 형식이 잘못되었습니다 |
| 3,377 | -없음 | unbalanced_entry | 전표의 차변 합계와 대변 합계가 다릅니다 |
| 5,881 | entry_number | non_contiguous_entry | 같은 전표의 행이 이어져 있지 않습니다 |
경고 1건
| 행 | 컬럼 | 코드 | 내용 |
|---|---|---|---|
| 7,410 | fiscal_period | period_mismatch | 전기일의 월과 회계기간이 다릅니다 |
| 번호 | 영역 | 내용과 데이터 | 권한, 조건 |
|---|---|---|---|
| 적재 머리 | 파일명(업로드) 또는 "합성 GL, 시드 N"(생성), 상태 배지, 등록 시각, 해시 앞 8자. | - | |
| 요약 문구 | 오류 수(error_count)를 넣은 고정 문구. | - | |
| 오류 표 | 행, 컬럼, 코드, 내용. 저장된 순서(파일 순서). 최대 100건이고, error_count가 더 크면 표 아래에 "그 밖에 N건". 내용은 코드별 고정 문구다. | - | |
| 경고 표 | 접힌 채로 둔다. 경고만 있으면 적재는 성공하고 완료 요약 아래에 같은 모양으로 붙는다. | - |
"행"은 머리글을 1로 센 CSV 레코드 순번이다. 따옴표 안의 줄바꿈은 행으로 세지 않는다. 컬럼이 없는 코드(both_sides, unbalanced_entry, parse_error, too_many_rows, unknown_column)는 컬럼 칸에 "-"를 쓴다. 시안은 이 자리에 debit, entry_number를 썼고, 경고 컬럼을 posting_date로 썼다. 구현 기준은 이 표다.
S3.3 상태 변형
live_file_input의 entry 진행률. 취소는 cancel_upload.20MB보다 큰 파일은 올릴 수 없습니다.
:too_large. :not_accepted는 ".csv 파일만 올릴 수 있습니다.", :too_many_files는 "파일은 한 번에 1개만 올릴 수 있습니다.". 서버의 :file_too_large도 첫 문구를 쓴다.앞의 적재가 끝나면 시작합니다. 이 화면을 떠나도 계속됩니다.
ingest 큐. 시작하면 import_updated를 받아 적재 중으로 바뀐다.검증하고 적재하는 중입니다. 이 화면을 떠나도 계속됩니다.
| 8,912 | -없음 | unbalanced_entry | 전표의 차변 합계와 대변 합계가 다릅니다 |
그 밖에 1,240건
worker_failed, file_missingerror_count 0). 원인은 서버 로그에만 남긴다. 작업은 3번까지 다시 시도한 뒤 실패로 바꾼다.:import_ledger 없음 (제안)감사 대상누리정밀 주식회사 FY2025
push_navigate하고 flash를 남긴다. 비멤버는 404.S3.4 합성 GL 생성
/engagements/:id/imports/new합성 GL 생성 탭, 생성이 끝난 상태감사 대상누리정밀 주식회사 FY2025GL 불러오기
GL 불러오기
| 번호 | 영역 | 내용과 데이터 | 권한, 조건 |
|---|---|---|---|
| 시드 | 정수만. 비우면 Generator.default_seed/0(20251231). 정수가 아니면 "정수로 입력하세요.". | - | |
| 재현성 안내 | 같은 시드면 같은 GL. 도메인 문서 7장. | - | |
| 생성 | generate_sample_import/3에 seed:만 넘긴다. 생성기는 라인 수를 받지 않으므로 시안의 "목표 라인 수" 칸은 없앤다. | - | |
| 결과 머리 | 출처 이름은 "합성 GL, 시드 N". 저장 파일명은 synthetic-gl-seed-N.csv지만 화면에는 쓰지 않는다. | - | |
| 테스트 실행 | 완료 상태에서만. S2의 같은 단추와 같은 이벤트. | :start_test_run |
S3.5 오류 코드와 문구
| 코드 | 행 | 컬럼 | 내용 | 비고 |
|---|---|---|---|---|
missing_column | 1 | 빠진 컬럼 이름 | 필수 컬럼이 없습니다 | 빈 파일이면 16건 |
unknown_column | 1 | -없음 | 알 수 없거나 겹친 컬럼이 있습니다 | 헤더 이름은 다시 쓰지 않는다 |
required | 해당 행 | 해당 컬럼 | 필수 값이 비어 있습니다 | - |
too_long | 해당 행 | 해당 컬럼 | 값이 너무 깁니다 | 적요 500자, 그 밖 200자 |
invalid_value | 해당 행 | source | 허용되지 않는 값입니다 | manual, system만 |
invalid_date | 해당 행 | 해당 컬럼 | 날짜 또는 일시 형식이 잘못되었습니다 | 일시는 오프셋 없는 로컬 시각 |
invalid_number | 해당 행 | 해당 컬럼 | 금액은 원 단위 정수여야 합니다 | 회계기간 1-13 밖도 이 코드. 문구 확인 필요 |
both_sides | 해당 행 | -없음 | 한 행에는 차변과 대변 중 하나만 있어야 합니다 | 둘 다 0이어도 이 코드 |
unbalanced_entry | 전표의 첫 행 | -없음 | 전표의 차변 합계와 대변 합계가 다릅니다 | - |
inconsistent_header | 다른 행마다 | 다른 컬럼마다 | 같은 전표의 헤더 값이 행마다 다릅니다 | - |
non_contiguous_entry | 돌아온 행 | entry_number | 같은 전표의 행이 이어져 있지 않습니다 | - |
duplicate_line_number | 해당 행 | line_number | 같은 전표에 같은 라인 번호가 있습니다 | - |
too_many_rows | 100,002 | -없음 | 행 수가 상한(100,000)을 넘었습니다 | 이 오류 1건만 남는다 |
parse_error | 멈춘 행 | -없음 또는 해당 컬럼 | CSV 형식을 읽을 수 없습니다 | 따옴표, 칸 수, 인코딩, NUL |
period_mismatch (경고) | 전표의 첫 행 | fiscal_period | 전기일의 월과 회계기간이 다릅니다 | 13(결산조정)은 12월이면 경고 없음 |
디자인 시스템 문서 5.4의 표에는 unknown_column, too_long, invalid_value, duplicate_line_number가 없다. 이 표로 맞춘다.
S3.6 이벤트
| 트리거 | 처리 | 성공 | 실패 | 감사 로그 |
|---|---|---|---|---|
| mount | get_engagement!/2, authorize(:import_ledger), allow_upload. 연결된 mount에서 구독 | 폼 표시 | 비멤버 404. 검토자는 S2로 (제안) | - |
파일 고름 (phx-change="validate") | upload_errors/2 | 이름, 크기, 진행률 | 거부 문구 | - |
| 선택 취소 | cancel_upload/3 | 폼을 비운다 | - | - |
업로드 (phx-submit="save") | consume_uploaded_entries 안에서 Ledger.create_import/3 | 결과 패널(대기) | :forbidden flash, :file_too_large 문구 | ledger_import.created (source_kind, original_filename, byte_size, sha256) |
생성 (phx-submit="generate") | Ledger.generate_sample_import/3 | 결과 패널(대기) | 시드 칸 오류 | ledger_import.created |
import_updated | 보고 있는 적재면 get_import!/2 | 패널이 적재 중, 완료, 실패로 바뀐다 | - | 시스템이 ledger_import.succeeded 또는 ledger_import.failed (reason) |
| 테스트 실행 | Testing.start_test_run(scope, import, :all) | S4로 push_navigate | S2와 같은 문구 | test_run.started |
S4. 테스트 실행
- 경로
/engagements/:id/runs/:run_id- LiveView
- TestRunLive.Show
- 권한
- 멤버 (
:view). 트리아지 요청은 작성자 - 데이터
- Testing.get_test_run!/2, Testing.list_findings/3, Triage.request_triage/2
- 구독
- test_run:#{run_id}, engagement:#{id}
- 구현
- 구현됨 (WBS 2.3). AI 위험도, 작성자 판단 열은 M3, M4에서 채운다
S4.1 기본 화면
실행이 끝나고 트리아지를 한 번 요청한 뒤의 화면이다. 배지 종류가 모두 보이도록 골랐다.
/engagements/:id/runs/:run_idTestRunLive.Show, 검토 큐 탭, 김작성(작성자)감사 대상누리정밀 주식회사 FY2025테스트 실행
테스트 실행
합성 GL, 시드 20251231기준 점수 25실행 김작성
- 완료주말/공휴일 전기46건
- 완료업무시간 외 입력42건
- 완료라운드 금액68건
- 완료기말 수기 전표45건
- 완료승인 통제 이탈40건
- 완료증빙일 괴리29건
- 완료희귀 계정 조합16건
- 완료중복 전표 의심15건
1회 최대 100건까지 요청합니다. 요청하면 감사 로그에 요청자가 남습니다. 결과가 오는 대로 표의 AI 열이 바뀝니다.
| 전표 | 전기일 | 금액(원) | 점수 | 적중 룰 | AI | 판단 | 검토 |
|---|---|---|---|---|---|---|---|
| JE-000123 | 2025-12-30 | 40,000,000 | 55상 | 기말 수기, 라운드, 승인 이탈 | 높음 | 미판단 | -검토 전 |
| JE-004415 | 2025-12-31 | 30,000,000 | 55상 | 기말 수기, 라운드, 승인 이탈 | 높음 | 예외 | 검토 대기 |
| JE-002961 | 2025-08-14 | 25,000,000 | 50상 | 증빙일, 라운드, 승인 이탈 | 중간 | 이상 없음 | 승인 |
| JE-007702 | 2025-12-29 | 18,350,000 | 50상 | 희귀 조합, 기말 수기, 시간 외 | 높음 | 추가 확인 필요 | 반려 |
| JE-001846 | 2025-03-11 | 12,840,000 | 25중 | 승인 이탈 | 낮음 | 미판단 | -검토 전 |
| JE-003307 | 2025-06-17 | 4,730,000 | 25중 | 승인 이탈 | 분석 중 | 미판단 | -검토 전 |
| JE-005590 | 2025-09-22 | 1,260,000 | 25중 | 승인 이탈 | AI 실패 | 미판단 | -검토 전 |
| JE-006118 | 2025-10-04 | 2,480,000 | 25중 | 휴일, 시간 외 | 낮음 | 이상 없음 | 승인 |
| 번호 | 영역 | 내용과 데이터 | 권한, 조건 |
|---|---|---|---|
| 제목과 개요 | "테스트 실행" + 시작 시각(KST). 아래 줄에 적재 출처, 기준 점수(triage_threshold 스냅샷), 실행한 사람. | - | |
| 룰 진행 | 룰 8개. 동시에 최대 4개(rules 큐). 상태 문구는 aria-live="polite". 끝난 뒤에도 룰별 적중 수가 남는다. 시각은 시작과 끝, 초까지. | - | |
| 트리아지 바 | 검토 큐의 triage_status 집계: 완료(succeeded), 분석 중(queued), 실패(failed), 미요청(none). 미요청이 있으면 단추가 켜진다. 실행이 끝나기 전에는 비활성. | 요청은 :request_triage. 검토자에게는 비활성과 이유 | |
| 목록 탭 | 검토 큐(점수가 기준 이상), 경미 적중(기준 미만). 숫자는 건수. 탭은 URL의 ?tab=queue, ?tab=minor (제안). | - | |
| 검토 상태 필터 | 전체, 미판단(open), 검토 대기(concluded), 승인(approved), 반려(rejected). 한 번에 하나. URL ?status=. 숫자는 탭 안의 건수. | - | |
| 발견 표 | 전표번호(S5 링크), 전기일, 금액(차변 합계), 점수, 적중 룰(짧은 이름, 전체 이름은 title). 점수 내림차순, 같으면 전표번호 오름차순. | - | |
| AI 열 | 높음, 중간, 낮음, 분석 중, AI 실패, "-"(미요청). 검토 큐 탭에만 있다. | - | |
| 판단, 검토 열 | 판단: 미판단, 이상 없음, 추가 확인 필요, 예외. 검토: "-", 검토 대기, 승인, 반려. | - | |
| 더 보기 | 50건씩 이어 그린다 (제안). 계약의 list_findings/3에는 페이지 옵션이 없다 (C-08). | - |
S4.2 상태 변형
- 완료주말/공휴일 전기46건
- 완료업무시간 외 입력42건
- 완료라운드 금액68건
- 완료기말 수기 전표45건
- 완료승인 통제 이탈40건
- 실행 중증빙일 괴리집계 중
- 실행 중희귀 계정 조합집계 중
- 실행 중중복 전표 의심집계 중
- 완료주말/공휴일 전기46건
- 완료업무시간 외 입력42건
- 완료라운드 금액68건
- 완료기말 수기 전표45건
- 완료승인 통제 이탈40건
- 실패증빙일 괴리실행 실패, 로그 확인
- 미실행희귀 계정 조합미실행
- 완료중복 전표 의심15건
title로 보인다. 실패 이유는 감사 로그(test_run_rule.failed)에 있다.1회 최대 100건까지 요청합니다. 요청하면 감사 로그에 요청자가 남습니다.
| JE-000123 | 2025-12-30 | 40,000,000 | 55상 | 기말 수기, 라운드, 승인 이탈 | 분석 중 | 미판단 | -검토 전 |
llm 큐 동시 2개. finding_updated가 올 때마다 그 행의 AI 배지와 바의 숫자가 바뀐다.이번에는 점수가 높은 순으로 100건을 요청합니다. 남은 40건은 다시 눌러 요청합니다.
?status=를 지운다. 탭은 그대로 둔다.S4.3 이벤트
| 트리거 | 처리 | 성공 | 실패 | 감사 로그 |
|---|---|---|---|---|
| mount | get_test_run!/2(룰 preload). 연결된 mount에서 subscribe_test_run/2, Engagements.subscribe/2 | 화면 표시 | 비멤버, 다른 감사 대상의 실행은 404 | - |
탭, 필터 (patch) | handle_params에서 list_findings(scope, run_id, %{status, min_score}) | 표를 다시 그린다 | - | - |
rule_progress | %{rule_id, status, hit_count, done, total}로 그 룰 줄과 진행률만 바꾼다 | 진행 표시 | - | 시스템이 룰 실패 때 test_run_rule.failed |
run_completed | get_test_run!/2, list_findings/3 | 진행 패널은 요약으로, 표가 열린다 | - | 시스템이 test_run.completed |
| AI 트리아지 요청 | Triage.request_triage(scope, run_id) | %{enqueued: n}로 flash "AI 트리아지 n건을 요청했습니다.". n이 0이면 info "새로 요청할 항목이 없습니다." | :forbidden flash "AI 트리아지를 요청할 권한이 없습니다." | ai_triage.requested (subject는 test_run) |
finding_updated | 보이는 행이면 다시 읽어 stream_insert, 트리아지 바 다시 집계 | 배지가 바뀐다 | - | AI가 ai_triage.completed, 실패면 ai_triage.failed |
| 전표번호 누름 | navigate S5, 현재 ?tab, ?status를 붙인다 | S5 큐가 같은 목록을 이어받는다 | - | - |
S5. 전표 검토
- 경로
/engagements/:id/findings/:finding_id- LiveView
- FindingLive.Show
- 권한
- 멤버 (
:view). 결론은 작성자, 승인과 반려는 검토자 - 데이터
- Testing.get_finding!/2, Triage.request_finding_triage/2, Review.submit_conclusion/3, Review.decide/4
- 구독
- engagement:#{id}, engagement:#{id}:presence
- 구현
- 자리만 있다
S5.1 기본 화면
작성자가 AI 제안을 받은 뒤 아직 결론을 내지 않은 전표다. 위에서 아래 순서가 판단 순서다: 사실, 룰 근거, AI 제안(점선), 사람 판단(실선), 서명.
/engagements/:id/findings/:finding_idFindingLive.Show, JE-000123, 김작성(작성자)감사 대상누리정밀 주식회사 FY2025테스트 실행 2026-10-19 10:02JE-000123
JE-000123
55상높음미판단-검토 전전표 사실
- 전기일
- 2025-12-30
- 증빙일
- 2025-12-24
- 입력 일시 (KST)
- 2025-12-30 17:42:10
- 회계기간
- 12
- 입력자
- acct03
- 승인자
- acct03입력자와 같음
- 구분
- 수기기말 수기 전표 근거
- 거래처
- V012
- 적요
- 선급금 지급
전표 행
| 행 | 계정 | 적요 | 차변(원) | 대변(원) |
|---|---|---|---|---|
| L1 | 1310 선급금 | 선급금 지급 | 40,000,000 | -0 |
| L2 | 1020 보통예금 | 선급금 지급 | -0 | 40,000,000 |
| 차대 일치 | 40,000,000 | 40,000,000 | ||
룰 적중
- +20기말 수기 전표
period_end_manual결산일(2025-12-31) 3일 이내에 수기로 입력된 전표입니다.
근거 값
- posting_date
- 2025-12-30
- source
- manual
- period_end_window_days
- 3
- +10라운드 금액
round_amount금액 40,000,000이 1,000,000 단위로 떨어지고 기준(10,000,000) 이상입니다.
근거 값
- entry_amount
- 40,000,000
- round_unit
- 1,000,000
- round_min_amount
- 10,000,000
- +25승인 통제 이탈
approval_control_gap승인자가 입력자와 같습니다 (acct03).
근거 값
- entered_by
- acct03
- approved_by
- acct03
- entry_amount
- 40,000,000
- approval_min_amount
- 1,000,000
점수55 = 20 + 10 + 25
이 제안은 판단이 아닙니다. 결론은 작성자가 입력합니다.
근거
결산일 하루 전에 수기로 입력된 40,000,000원 선급금 지급 전표이며, 입력자와 승인자가 같은 사용자(acct03)로 기록되어 있습니다. 금액이 1,000,000원 단위로 떨어지고 수행중요성(225,000,000원)의 약 18% 수준입니다. 기말 수기 입력, 라운드 금액, 승인 통제 이탈이 겹쳐 있어 통제 우회 가능성을 배제하기 위한 추가 절차가 필요해 보입니다. 거래의 실재성과 승인 경위는 이 전표만으로 확인되지 않습니다.
추가 절차 제안
- 자기승인 경위를 입력자와 승인 권한자에게 질문하고 승인 권한표와 대조합니다.확보할 증거: 결재 문서, 승인 권한표
- 선급금 지급 근거를 계약 조건과 대사합니다.확보할 증거: V012 계약서, 지급 요청서, 이체 확인서
- 기말 이후 원재료 대체 여부를 확인합니다.확보할 증거: 2026년 1-2월 입고 내역, 대체 전표
AI가 판단에 부족하다고 본 정보
- V012와의 계약 조건
- 선급금 대체 예정 시기
생성 정보
- model
- claude-opus-5-5
- prompt_version
- triage-v1
- input_hash
- 9c41e2b7
- inserted_at
- 2026-10-19 10:43:18 KST
이력
작성자는 승인, 반려할 수 없습니다 (직무분리)
| 번호 | 영역 | 내용과 데이터 | 권한, 조건 |
|---|---|---|---|
| 검토 큐 | S4의 탭과 필터(?tab, ?status)를 이어받은 목록. 현재 항목은 aria-current. "다음 미판단"은 현재 항목 뒤의 첫 open 항목, 없으면 비활성. | - | |
| 헤더 | 전표번호, 점수, AI 위험도, 판단, 검토 배지. 오른쪽은 이 전표를 보는 사람(presence view = "finding:#{id}"). 상단 바의 presence는 이 화면에서 쓰지 않는다. | - | |
| 전표 사실 | 전기일, 증빙일, 입력 일시, 회계기간, 입력자, 승인자, 구분, 거래처, 적요. 룰 근거가 된 값은 경고 아이콘과 sr-only 설명. 적요와 거래처는 원문 그대로 이스케이프해 보인다. | - | |
| 전표 행 | 행, 계정(코드 + 이름), 적요, 차변, 대변. 합계 줄과 "차대 일치". 룰이 특정 행을 가리키면 그 행을 강조한다. | - | |
| 룰 적중 | rule_results. 가중치, 이름, rule_id, 사람이 읽는 이유, 근거 값(접힘). 맨 아래 점수 식. | - | |
| AI 제안 | 최신 ai_triages 1건. 위험도, 근거, 추가 절차(1-4개), 부족한 정보(0-5개), 생성 정보(접힘). response_model이 model과 다르면 둘 다 보인다. 점선 상자. | 요청 단추는 작성자 | |
| 작성자 판단 | conclusion_form. 라디오 3개는 미리 고르지 않는다. 코멘트 1-2000자 필수. 숨은 ai_triage_id는 화면에 보인 최신 제안 id(없으면 빈 값). 실선 상자. | edit: 작성자 + open, concluded, rejected. read_only: 검토자. locked: approved | |
| 이력 | 결론과 검토 결정을 시간순으로. 이전 결론도 지우지 않는다. 비면 "아직 결론이 없습니다." | - | |
| 서명 바 | 화면 아래 고정. can_decide는 서버가 계산한다. 이유 문구는 aria-describedby로 단추에 연결. | 결정: 검토자 + concluded |
S5.2 상태 변형
AI 제안 카드
룰 근거와 전표 사실을 바탕으로 위험도, 추가 절차, 부족한 정보를 제안합니다. 결론은 내리지 않습니다.
AI 제안을 받지 못했습니다.
판단은 AI 제안 없이도 진행할 수 있습니다.
stop_reason)은 보이지 않는다. 원인은 감사 로그의 ai_triage.failed로 본다.점수가 기준(25)보다 낮아 AI 트리아지 대상이 아닙니다.
작성자 판단
작성자 판단
예외, 코멘트 64자
작성자 판단
선급금 지급 결재 문서가 없고 승인자가 입력자와 같습니다. 대체 전표도 확인되지 않아 예외로 판단합니다.
김작성,
작성자 판단
이상 없음7월 계약분의 8월 지급 전표이며 증빙일은 계약일로 입력되었습니다. 승인자 공란은 전자결재 승인 기록(mgr01)으로 확인했습니다.
승인된 항목은 수정할 수 없습니다
:invalid_state로 막는다.서명 바와 이력
작성자 판단
예외선급금 지급 결재 문서가 없고 승인자가 입력자와 같습니다. 2026년 1-2월 대체 전표도 확인되지 않아 예외로 판단합니다.
김작성,
반려 사유를 입력하세요
comment_required_on_reject가 공백 사유도 막는다.작성자 결론이 제출되지 않았습니다
conclusion_id).decide/4는 검토자가 본 결론을 받지 않아, 다시 그리기 전에 누른 승인은 새 결론에 붙는다 (C-11).- 이작성결론 제출추가 확인 필요
12월 급여 미지급분 계상으로 보이나 급여대장을 받지 못했습니다. 인사팀에 요청했습니다.
- 박검토검토반려
급여대장과 대사한 결과를 받은 뒤 다시 제출해 주세요. 지금 코멘트만으로는 추가 절차의 범위를 알 수 없습니다.
S5.3 좁은 화면
폭 375-420px. 큐는 접고, 서명 바는 아래에 고정한다.
375px작성자, 큐 접힘검토 큐 52건, JE-000123 (1/52)
JE-000123
전표 사실
- 전기일
- 2025-12-30
- 증빙일
- 2025-12-24
- 입력자
- acct03
- 승인자
- acct03입력자와 같음
- 적요
- 선급금 지급
| 계정 | 차변(원) | 대변(원) |
|---|---|---|
| 1310 선급금 | 40,000,000 | -0 |
| 1020 보통예금 | -0 | 40,000,000 |
이 제안은 판단이 아닙니다. 결론은 작성자가 입력합니다.
결산일 하루 전에 수기로 입력된 40,000,000원 선급금 지급 전표이며, 입력자와 승인자가 같습니다.
작성자는 승인, 반려할 수 없습니다
- 큐는
details로 접고 요약에 현재 위치를 보인다. - 전표 행 표는 행 번호와 적요 열을 숨기고 가로 스크롤을 허용한다. 금액 열은 남긴다.
- AI 제안의 절차와 생성 정보는 접힌 채 시작한다.
- 서명 바의 단추는 줄을 바꿔 아래로 내려간다.
S5.4 이벤트
| 트리거 | 처리 | 성공 | 실패 | 감사 로그 |
|---|---|---|---|---|
| mount | get_finding!/2. 연결된 mount에서 presence track(view: "finding:#{id}"), Engagements.subscribe/2 | 화면 표시 | 비멤버, 다른 감사 대상의 항목은 404 | - |
| AI 제안 요청 | Triage.request_finding_triage(scope, id) | 카드가 분석 중으로 | :already_queued info "이미 AI가 분석 중입니다.", :forbidden "AI 제안을 요청할 권한이 없습니다." | ai_triage.requested (subject는 finding) |
판단 입력 (validate) | changeset만 검사 | 글자 수 갱신 | 필드 오류 | - |
| 결론 제출 (Ctrl+Enter 포함) | Review.submit_conclusion(scope, finding, %{outcome, comment, ai_triage_id}) | flash "결론을 제출했습니다. 검토자에게 검토 대기로 보입니다." | changeset은 필드 오류, :invalid_state "승인된 항목은 수정할 수 없습니다.", :forbidden "결론을 제출할 권한이 없습니다." | conclusion.submitted |
| 승인 확인 | Review.decide(scope, finding, :approved, nil) | 서명 바가 승인으로 | :invalid_state "이 항목은 이미 처리되었습니다. 최신 상태로 다시 불러왔습니다.", :forbidden "승인, 반려할 권한이 없습니다." | review.approved |
| 반려 확정 | Review.decide(scope, finding, :rejected, comment) | 서명 바가 반려로, 이력에 사유 | changeset "반려 사유를 입력하세요", 나머지는 승인과 같다 | review.rejected |
:self_review | 본인 결론을 결정하려 할 때 | - | flash "본인이 제출한 결론은 승인, 반려할 수 없습니다." | review.denied. 역할이 바뀐 사용자만 닿는다 (C-10) |
finding_updated (같은 항목) | get_finding!/2 다시 읽기. 작성자가 입력 중이면 경고만, 검토자는 확인 단계를 되돌린다 | 배지, AI 카드, 이력 갱신 | - | - |
presence_diff | 같은 view만 거른다 | 헤더 아바타 갱신 | - | - |
| 다음 미판단 | push_navigate, ?tab, ?status 유지 | 다음 항목 | 없으면 단추 비활성 | - |
S6. 감사 로그
- 경로
/engagements/:id/audit- LiveView
- AuditLive.Index
- 권한
- 멤버 (
:view). 모두 읽기 전용 - 데이터
- AuditTrail.list_events(scope, engagement_id, limit:, before_seq:, subject:, actor_type:)
- 구독
- engagement:#{id} (audit_appended)
- 구현
- 자리만 있다
S6.1 기본 화면
검토가 한창인 11:21 무렵의 로그다. 사람, AI, 시스템 행위자가 섞여 있다. 시안의 #74 review.denied는 지금 계약으로는 생길 수 없어서 승인 이벤트로 바꿨다 (C-10).
/engagements/:id/auditAuditLive.Index, 필터 없음, 김작성감사 로그
읽기 전용. 기록은 수정하거나 삭제할 수 없습니다.
- #80김작성결론 제출
conclusion.submittedpayload
- conclusion_id
- 5e0b9c7a-3d2f-4b61-9a8e-1c7f4d2b6e90
- outcome
- "exception"
- #79김작성AI 제안 요청
ai_triage.requested - #78triage:claude-opus-5-5AI 제안 실패
ai_triage.failed - #77김작성AI 제안 요청
ai_triage.requested - #76박검토반려
review.rejected - #75이작성결론 제출
conclusion.submittedpayload
- conclusion_id
- a2c4e6f8-1b3d-4f5a-8c7e-9d0b2a4c6e81
- outcome
- "no_exception"
- #74박검토승인
review.approved - #73이작성결론 제출
conclusion.submittedpayload
- conclusion_id
- 7f1e3d5c-9b2a-4c8e-a6f4-0d2b4e6a8c13
- outcome
- "needs_follow_up"
- #72김작성결론 제출
conclusion.submittedpayload
- conclusion_id
- c8a0e2b4-6d1f-4a3c-9e5b-7f2d4a6c8e05
- outcome
- "exception"
- #71박검토승인
review.approved - #70박검토승인
review.approved - #69박검토승인
review.approved
| 번호 | 영역 | 내용과 데이터 | 권한, 조건 |
|---|---|---|---|
| 헤더 | 제목과 읽기 전용 안내. 수정, 삭제 단추는 어디에도 없다. DB 트리거도 UPDATE와 DELETE를 거부한다. | - | |
| 행위자 필터 | 전체, 사람, AI, 시스템. aria-pressed로 하나만 켠다. URL은 ?actor=(제안, 값은 user, agent, system), 함수에는 actor_type:로 넘긴다. | - | |
| 대상 필터 | 전표번호를 받는다. 전표번호를 subject로 바꾸는 방법이 계약에 없다 (C-13). URL은 ?subject=(제안). | - | |
| 새 이벤트 | audit_appended를 셀 뿐 목록에 바로 끼우지 않는다. 읽던 위치가 밀리지 않게 하려는 것이다. 0이면 숨긴다. | - | |
| 이벤트 목록 | 최신순. 번호(seq), 시각(KST, title은 UTC), 행위자(아이콘 + 이름), 한글 동작 이름과 action 코드, 대상 링크와 한 줄 요약, payload(접힘). 반려 사유 요약은 payload에 없어 지금은 보일 수 없다 (C-12). | - | |
| 더 보기 | 마지막 번호를 before_seq로 넘겨 50건씩 더 읽는다. 받은 수가 50보다 적으면 목록 끝이다. | - |
행위자 이름은 사람이면 표시 이름(C-05), AI면 triage:<response_model>, 시스템이면 작업 이름(ledger.import_worker, testing.rule_worker)이다. AI 응답 없이 실패한 경우 행위자가 agent인지 system인지는 계약 4.2에 "agent / system"으로만 적혀 있다 (확인 필요).
S6.2 상태 변형
- #78triage:claude-opus-5-5AI 제안 실패
ai_triage.failed - #62triage:claude-opus-5-5AI 제안 완료
ai_triage.completed - #61triage:claude-opus-5-5AI 제안 실패
ai_triage.failed
risk_level이 있어야 한다 (C-12).- #76박검토반려
review.rejected - #65이작성결론 제출
conclusion.submitted - #24triage:claude-opus-5-5AI 제안 완료
ai_triage.completed
test_run이라 전표 하나로 거르면 나오지 않는다. 누가 요청했는지 보려면 실행으로 거른다.engagement.created가 남는다.- #7ledger.import_workerGL 적재 완료
ledger_import.succeededpayload
- entry_count
- 8341
- line_count
- 19804
- total_debit
- "386214905000"
- total_credit
- "386214905000"
- warning_count
- 0
- #5ledger.import_workerGL 적재 실패
ledger_import.failedpayload
- reason
- "validation_failed"
- error_count
- 4
- #1김작성감사 대상 생성
engagement.createdpayload
- client_name
- "누리정밀 주식회사"
- fiscal_year
- 2025
- period_end_date
- "2025-12-31"
- performance_materiality
- "225000000"
처음 기록까지 모두 보았습니다.
- 검토자와 작성자가 보는 화면은 같다. 역할에 따라 숨기는 이벤트는 없다.
- 이 화면에는 쓰기 이벤트가 없다. 화면을 여는 것 자체도 기록하지 않는다 (조회 기록은 2단계 검토).
- 좁은 화면에서는 필터가 두 줄로 내려가고, 대상 요약은 다음 줄로 넘어간다.
S6.3 이벤트
| 트리거 | 처리 | 성공 | 실패 | 감사 로그 |
|---|---|---|---|---|
| mount | list_events(scope, id, limit: 50)를 stream에 넣는다. 연결된 mount에서 Engagements.subscribe/2 | 목록 표시 | 비멤버는 404 | - |
필터 (patch) | handle_params에서 actor_type:, subject:를 붙여 다시 읽고 stream을 비운다 | 목록 교체, 새 이벤트 수 0 | - | - |
audit_appended | %{seq, action}. 보이는 첫 번호보다 크면 수를 1 올린다 | "새 이벤트 N건" 표시 | - | - |
| 새 이벤트 누름 | list_events(scope, id, limit: 50), stream 비우고 다시 넣기 | 첫 줄로 포커스 | - | - |
| 더 보기 | list_events(..., before_seq: 마지막 seq, limit: 50), stream 뒤에 붙이기 | 목록이 길어진다 | - | - |
| 대상 링크 | finding은 S5, test_run은 S4, ledger_import는 S2 적재 이력과 같은 링크로 navigate | 해당 화면 | 대상이 이 감사 대상 밖이면 404 | - |
| payload 펼침 | details만 연다. 서버 왕복 없음 | - | - | - |
부록 A. 화면 문구
사용자에게 보이는 문구를 한곳에 모았다. 예외 이름, HTTP 상태, SQL 같은 내부 구현은 보이지 않는다. gettext에는 en만 있어서 지금 구현은 공통, S1, S2에서 영어 기본 문구를 보인다 (C-02, C-03, C-06). S3 오류 코드별 문구는 S3.5에 있다.
| 화면 | 상황 | 종류 | 문구 | 근거 |
|---|---|---|---|---|
| 공통 | 연결 끊김 | 배너 | 인터넷 연결을 찾을 수 없습니다. 다시 연결하는 중 | LiveSocket 끊김 |
| 공통 | 서버 오류 | 배너 | 서버에 문제가 생겼습니다. 다시 연결하는 중 | LiveView 프로세스 종료 |
| 공통 | 없는 주소, 비멤버 | 페이지 | 페이지를 찾을 수 없습니다. 없는 주소이거나 내가 멤버가 아닌 감사 대상입니다. | 404 |
| S1 | 회사명 빈 값 | 필드 | 회사명을 입력하세요. | changeset client_name |
| S1 | 회계연도 범위 밖 | 필드 | 2000에서 2100 사이로 입력하세요. | CHECK fiscal_year_range |
| S1 | 결산일 없음 | 필드 | 결산일을 선택하세요. | changeset period_end_date |
| S1 | 수행중요성 0 이하 | 필드 | 0보다 큰 금액을 입력하세요. | CHECK performance_materiality_positive |
| S1 | 만듦 | flash | 감사 대상을 만들었습니다. | :ok |
| S2 | 멤버 추가 | flash | 멤버를 추가했습니다. | :ok |
| S2 | 없는 이메일 | 필드 | 가입한 사용자 중 이 이메일이 없습니다. | 사용자 조회 실패 |
| S2 | 권한 없음 | flash | 멤버를 추가할 권한이 없습니다. | :forbidden |
| S3 | 파일이 큼 | 필드 | 20MB보다 큰 파일은 올릴 수 없습니다. | allow_upload :too_large |
| S3 | 권한 없음 | flash | GL을 불러올 권한이 없습니다. | :forbidden |
| S3 | 검증 실패 | 경고 | GL을 적재하지 못했습니다. 아래 오류 N건을 고친 뒤 다시 올리세요. 파일의 원래 값은 화면에 다시 쓰지 않습니다. | validation_failed |
| S3 | 시스템 실패 | 경고 | 파일을 처리하지 못했습니다. 같은 파일을 다시 올려 주세요. 계속 실패하면 관리자에게 알려 주세요. | worker_failed, file_missing |
| S4 | 일괄 요청 | flash | AI 트리아지 N건을 요청했습니다. | enqueued: n |
| S4 | 요청할 것 없음 | flash (info) | 새로 요청할 항목이 없습니다. | enqueued: 0 |
| S4 | 권한 없음 | flash | AI 트리아지를 요청할 권한이 없습니다. | :forbidden |
| S4 | 룰 실패 | 경고 | 룰 N개를 실행하지 못했습니다. 그 룰은 점수에 넣지 않았습니다. 같은 GL로 다시 실행하면 처음부터 다시 계산합니다. | completed_with_errors |
| S4 | 필터 결과 없음 | 빈 상태 | 조건에 맞는 전표가 없습니다. | - |
| S5 | 이미 요청됨 | flash (info) | 이미 AI가 분석 중입니다. | :already_queued |
| S5 | 요청 권한 없음 | flash | AI 제안을 요청할 권한이 없습니다. | :forbidden |
| S5 | 판단 없음 | 필드 | 판단을 선택하세요 | changeset outcome |
| S5 | 코멘트 없음, 너무 김 | 필드 | 코멘트를 입력하세요 (1-2000자) | changeset comment |
| S5 | 제출 | flash | 결론을 제출했습니다. 검토자에게 검토 대기로 보입니다. | :ok |
| S5 | 승인된 항목에 제출 | flash | 승인된 항목은 수정할 수 없습니다. | submit_conclusion :invalid_state |
| S5 | 제출 권한 없음 | flash | 결론을 제출할 권한이 없습니다. | :forbidden |
| S5 | 다른 사람이 먼저 제출 | 경고 | 이름이 방금 결론을 제출했습니다. 제출하면 새 결론으로 남습니다. | finding_updated |
| S5 | 검토 중 결론이 바뀜 | 경고 | 이름이 결론을 다시 제출했습니다. 최신 결론을 확인한 뒤 결정하세요. | finding_updated |
| S5 | 반려 사유 없음 | 필드 | 반려 사유를 입력하세요 | CHECK comment_required_on_reject |
| S5 | 이미 처리됨 | flash | 이 항목은 이미 처리되었습니다. 최신 상태로 다시 불러왔습니다. | decide :invalid_state |
| S5 | 결정 권한 없음 | flash | 승인, 반려할 권한이 없습니다. | :forbidden |
| S5 | 자기 검토 | flash | 본인이 제출한 결론은 승인, 반려할 수 없습니다. | :self_review (C-10) |
| S6 | 필터 결과 없음 | 빈 상태 | 조건에 맞는 이벤트가 없습니다. | - |
| S6 | 새 이벤트 | 단추 | 새 이벤트 N건. 필터 중이면 새 이벤트가 있습니다 | audit_appended |
| S6 | 목록 끝 | 안내 | 처음 기록까지 모두 보았습니다. | 받은 수가 limit보다 적음 |
문장형 flash와 경고는 마침표로 끝내고, 필드 아래 짧은 오류는 마침표 없이 쓴다. 시안의 S5 컴포넌트 문구를 따랐다.
부록 B. 감사 이벤트와 화면
계약 문서 4.2의 이벤트가 어느 화면의 어떤 행동에서 생기는지, S6에서 어떤 이름으로 보이는지 정리했다. payload 열은 계약 그대로다.
| action | 행위자 | subject | payload (계약) | 생기는 곳 | S6 이름 |
|---|---|---|---|---|---|
engagement.created | 사람 | engagement | client_name, fiscal_year, period_end_date, performance_materiality | S1 만들기 | 감사 대상 생성 |
engagement.member_added | 사람 | engagement | user_id, role | S2 멤버 추가 | 멤버 추가 |
ledger_import.created | 사람 | ledger_import | source_kind, original_filename, byte_size, sha256 | S3 업로드, 생성 | GL 적재 시작 |
ledger_import.succeeded | 시스템 ledger.import_worker | ledger_import | entry_count, line_count, total_debit, total_credit, warning_count | ImportWorker | GL 적재 완료 |
ledger_import.failed | 시스템 ledger.import_worker | ledger_import | reason, error_count | ImportWorker | GL 적재 실패 |
test_run.started | 사람 | test_run | -없음 | S2 테스트 실행 | 테스트 실행 시작 |
test_run_rule.failed | 시스템 testing.rule_worker | test_run | -없음 | RuleWorker | 룰 실행 실패 |
test_run.completed | 시스템 testing.rule_worker | test_run | -없음 | 마지막 RuleWorker | 테스트 실행 완료 |
ai_triage.requested | 사람 | test_run (일괄), finding (건별) | -없음 | S4 일괄, S5 건별 | AI 제안 요청 |
ai_triage.completed | AI triage:<response_model> | finding | -없음 | TriageWorker | AI 제안 완료 |
ai_triage.failed | AI 또는 시스템 | finding | -없음 | TriageWorker | AI 제안 실패 |
conclusion.submitted | 사람 | finding | conclusion_id, outcome (4.1 코드 예시. 4.2 표는 빈 칸) | S5 결론 제출 | 결론 제출 |
review.approved | 사람 | finding | -없음 | S5 승인 | 승인 |
review.rejected | 사람 | finding | -없음 | S5 반려 | 반려 |
review.denied | 사람 | finding | -없음 | S5 자기 검토 시도 (C-10) | 검토 거부됨 |
S6가 한 줄 요약을 보이려면 빈 칸에 다음 키가 필요하다 (C-12, 제안).
| action | 더할 키 | S6 요약 | 이유 |
|---|---|---|---|
test_run.started | ledger_import_id, rule_ids, triage_threshold | 룰 8개, 기준 점수 25 | 실행 조건은 나중에 바뀔 수 있어 당시 값을 남긴다 |
test_run.completed | status, finding_count | 적중 전표 265건 | - |
test_run_rule.failed | rule_id | 룰 이름 | 원인 원문은 넣지 않는다 |
ai_triage.requested | 일괄이면 enqueued | 52건 요청 | - |
ai_triage.completed | ai_triage_id, risk_level | 위험도 높음 | 모델, 프롬프트 버전, 입력 해시는 ai_triages 행에 있다 |
ai_triage.failed | reason (분류 값만) | 실패 이유 분류 | HTTP 본문, 키 같은 원문은 넣지 않는다 |
review.approved | conclusion_id, review_decision_id | - | 어느 결론을 승인했는지 로그만으로 알 수 있게 (C-11) |
review.rejected | conclusion_id, review_decision_id, comment | 반려 사유 첫 줄 | 감사 로그는 append-only라 사유를 복사해도 어긋나지 않는다 |
부록 C. 미결 사항과 차이
설계서를 쓰면서 찾은, 지금 구현이나 계약과 이 설계서가 다른 곳이다. 계약을 바꾸는 항목은 메인 세션이 계약 문서에 반영한 뒤 구현한다.
| ID | 영역 | 지금 | 제안 |
|---|---|---|---|
| C-01 | 상단 바 | Phoenix 기본 헤더(Website, GitHub, Get Started)와 영어 Settings, Log out | 3.1의 앱 바로 바꾼다. 레이아웃 파일 한 곳 |
| C-02 | 연결 끊김 문구 | 영어 기본 flash | 부록 A 문구. gettext ko를 더하거나 레이아웃 문자열을 바로 바꾼다 |
| C-03 | 404 | "Not Found" 한 줄 | 안내 페이지와 목록 링크. 비멤버도 같은 404로 감사 대상이 있는지 숨긴다 |
| C-04 | 시각 title | kst_time의 title이 ISO 8601 | title을 "YYYY-MM-DD HH:MM:SS UTC"로 맞춘다 |
| C-05 | 사람 이름 | users에 이름이 없고 이메일만 있다 | users.display_name을 더할지 정한다. 안 더하면 이메일을 그대로 쓴다 |
| C-06 | 검증 문구 | Ecto 영어 기본 문구 | changeset에서 한국어 메시지를 지정하거나 gettext ko |
| C-07 | S2 통계 | 해결 (2026-10-19). count_findings/2가 review_queue, minor, approved를 돌려준다 | S2 실행 행과 통계, S4 탭이 함께 쓴다 |
| C-08 | S4 더 보기 | list_findings/3에 페이지 옵션이 없다 | limit와 커서(점수, 금액, id) 옵션. 검토 큐 52건이면 MVP는 한 번에 읽어도 된다 |
| C-09 | 경미 적중 AI | request_finding_triage/2가 기준 미만을 거부하지 않는다 | :below_threshold 오류를 더해 서버에서도 막는다. 화면에서 단추만 숨기면 직접 호출로 우회된다 |
| C-10 | 자기 검토 기록 | authorize(:review_conclusion)가 먼저라 작성자는 :forbidden이 된다. :self_review와 review.denied는 역할이 바뀐 사용자만 닿는다 | 방어 코드로 남긴다. 시안 S6 #74는 바꿨다. 작성자의 시도까지 기록하려면 :forbidden에서도 review.denied를 남긴다 |
| C-11 | 낡은 결론 결정 | decide/4가 검토자가 본 conclusion_id를 받지 않는다. 화면을 다시 그리기 전에 누른 승인은 새 결론에 붙는다 | decide가 conclusion_id를 받고, 최신 결론이 아니면 :stale로 거부한다 |
| C-12 | 감사 payload | 4.2의 여러 payload가 빈 칸이다 | 부록 B 제안 키 |
| C-13 | S6 대상 필터 | subject 옵션 모양이 정해지지 않았다. 전표번호를 finding으로 바꾸는 함수가 없고, 한 전표가 여러 실행의 finding일 수 있다. audit_appended에 행위자와 대상이 없다 | subject: type, id 모양으로 확정. 전표번호는 이 감사 대상의 전표에 걸린 finding 전부로 바꾼다. 브로드캐스트에 actor_type, subject_type, subject_id를 더한다 |
디자인 문서, 시안과 다른 점
- S3: 디자인 문서 5.4의 오류 코드 표에
unknown_column,too_long,invalid_value,duplicate_line_number가 없다. 이 설계서의 S3.5가 15개 전부를 적었다. - S3:
fiscal_period가 숫자가 아닐 때의 코드가invalid_number인지 확인 필요. - S3: 검토자가 주소로 들어오면 S2로 보내는 것, 탭을
?source=로 바꾸는 것은 제안이다. - S4: 트리아지 바 문구가 디자인 문서 5.5와 시안에서 조금 다르다. 이 설계서는 시안을 따랐다.
- S4: 탭
?tab=, 필터?status=, 50건씩 더 보기는 제안이다. - S5: 디자인 문서 4.2는 concluded에서 바로 edit 모드다. 이 설계서는 제출 직후 read_only로 보이고 "결론 다시 쓰기"를 눌러야 열린다 (제안).
- 시안은 2026-10-19에 고쳤다. 라인 수를 19,804로, 출처를 "시드 20251231"로 바꾸고 "목표 라인 수" 칸을 지웠다. S5 숨은
ai_triage_id에 보인 제안의 id를 넣고, S6 #74review.denied를 박검토의 승인으로 바꿨다 (C-10). 짝 없는</main>도 지웠다.
Changelog
- 2026-10-19 v1.2: 구현 상태 갱신 (S1 열 2개, S2 통계와 실행 목록과 행 행동, S3, S4). C-07 해결. S2 룰 열 기준을 S4와 맞췄다.
- 2026-10-19 v1.1: 시안의 데이터, 마크업 오류를 고친 결과를 부록 C에 반영.
- 2026-10-17 v1: 최초 작성. S1-S6 기본 화면, 상태 변형, 좁은 화면(S5), 이벤트 표, 부록 A 문구, B 감사 이벤트, C 미결 사항 C-01-C-13.