# ๐Ÿ“ ์ˆ˜์ • ํžˆ์Šคํ† ๋ฆฌ ์ €์žฅ ๋ฐฉ์•ˆ ## ๐ŸŽฏ ๋ชฉ์  - ๊ฐ ํŽ˜์ด์ง€์—์„œ ์ˆ˜์ •ํ•œ ๋‚ด์šฉ์˜ ๋ณ€๊ฒฝ ์ด๋ ฅ ์ถ”์  - ๋ˆ„๊ฐ€, ์–ธ์ œ, ๋ฌด์—‡์„, ์™œ ๋ณ€๊ฒฝํ–ˆ๋Š”์ง€ ๊ธฐ๋ก - ๋ฐ์ดํ„ฐ ๋ฌด๊ฒฐ์„ฑ ๋ฐ ๊ฐ์‚ฌ ์ถ”์  (Audit Trail) ์ œ๊ณต ## ๐Ÿ—„๏ธ DB ๊ตฌ์กฐ ๋ฐฉ์•ˆ ### ๋ฐฉ์•ˆ 1: ๋‹จ์ผ ํžˆ์Šคํ† ๋ฆฌ ํ…Œ์ด๋ธ” (๊ถŒ์žฅ) ```sql CREATE TABLE issue_history ( id SERIAL PRIMARY KEY, issue_id INTEGER NOT NULL REFERENCES issues(id), changed_by_id INTEGER NOT NULL REFERENCES users(id), changed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), change_type VARCHAR(50) NOT NULL, -- 'UPDATE', 'STATUS_CHANGE', 'COMPLETION_REQUEST' ๋“ฑ page_source VARCHAR(50) NOT NULL, -- 'INBOX', 'MANAGEMENT', 'DASHBOARD' ๋“ฑ field_name VARCHAR(100), -- ๋ณ€๊ฒฝ๋œ ํ•„๋“œ๋ช… old_value TEXT, -- ์ด์ „ ๊ฐ’ (JSON ํ˜•ํƒœ๋กœ ์ €์žฅ ๊ฐ€๋Šฅ) new_value TEXT, -- ์ƒˆ๋กœ์šด ๊ฐ’ (JSON ํ˜•ํƒœ๋กœ ์ €์žฅ ๊ฐ€๋Šฅ) change_reason TEXT, -- ๋ณ€๊ฒฝ ์‚ฌ์œ  (์„ ํƒ์‚ฌํ•ญ) session_id VARCHAR(100), -- ๋™์ผ ์„ธ์…˜์—์„œ ์—ฌ๋Ÿฌ ํ•„๋“œ ๋ณ€๊ฒฝ ์‹œ ๊ทธ๋ฃนํ•‘ ip_address INET, -- ๋ณ€๊ฒฝ์ž IP ์ฃผ์†Œ user_agent TEXT -- ๋ธŒ๋ผ์šฐ์ € ์ •๋ณด ); -- ์ธ๋ฑ์Šค ์ƒ์„ฑ CREATE INDEX idx_issue_history_issue_id ON issue_history(issue_id); CREATE INDEX idx_issue_history_changed_at ON issue_history(changed_at); CREATE INDEX idx_issue_history_changed_by ON issue_history(changed_by_id); ``` ### ๋ฐฉ์•ˆ 2: ํŽ˜์ด์ง€๋ณ„ ํžˆ์Šคํ† ๋ฆฌ ํ…Œ์ด๋ธ” ```sql -- ์ˆ˜์‹ ํ•จ ํžˆ์Šคํ† ๋ฆฌ CREATE TABLE inbox_history ( id SERIAL PRIMARY KEY, issue_id INTEGER NOT NULL REFERENCES issues(id), action_type VARCHAR(50), -- 'REVIEW', 'DISPOSE', 'STATUS_CHANGE' old_data JSONB, new_data JSONB, changed_by_id INTEGER NOT NULL REFERENCES users(id), changed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); -- ๊ด€๋ฆฌํ•จ ํžˆ์Šคํ† ๋ฆฌ CREATE TABLE management_history ( id SERIAL PRIMARY KEY, issue_id INTEGER NOT NULL REFERENCES issues(id), action_type VARCHAR(50), -- 'UPDATE', 'COMPLETION_REQUEST', 'APPROVAL' old_data JSONB, new_data JSONB, changed_by_id INTEGER NOT NULL REFERENCES users(id), changed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); ``` ## ๐Ÿ”ง ๊ตฌํ˜„ ๋ฐฉ์•ˆ ### 1๏ธโƒฃ ๋ฐฑ์—”๋“œ ๊ตฌํ˜„ (FastAPI) #### ํžˆ์Šคํ† ๋ฆฌ ์„œ๋น„์Šค ํด๋ž˜์Šค ```python # services/history_service.py from typing import Dict, Any, Optional import json from datetime import datetime from sqlalchemy.orm import Session class HistoryService: @staticmethod def log_change( db: Session, issue_id: int, changed_by_id: int, change_type: str, page_source: str, old_data: Dict[str, Any], new_data: Dict[str, Any], change_reason: Optional[str] = None, session_id: Optional[str] = None ): """๋ณ€๊ฒฝ ์ด๋ ฅ ๊ธฐ๋ก""" # ๋ณ€๊ฒฝ๋œ ํ•„๋“œ๋“ค ์ฐพ๊ธฐ changed_fields = [] for field, new_value in new_data.items(): old_value = old_data.get(field) if old_value != new_value: history_entry = IssueHistory( issue_id=issue_id, changed_by_id=changed_by_id, change_type=change_type, page_source=page_source, field_name=field, old_value=json.dumps(old_value) if old_value else None, new_value=json.dumps(new_value) if new_value else None, change_reason=change_reason, session_id=session_id ) db.add(history_entry) changed_fields.append(field) db.commit() return changed_fields @staticmethod def get_issue_history(db: Session, issue_id: int, limit: int = 50): """์ด์Šˆ์˜ ๋ณ€๊ฒฝ ์ด๋ ฅ ์กฐํšŒ""" return db.query(IssueHistory)\ .filter(IssueHistory.issue_id == issue_id)\ .order_by(IssueHistory.changed_at.desc())\ .limit(limit)\ .all() ``` #### API ์—”๋“œํฌ์ธํŠธ์—์„œ ํžˆ์Šคํ† ๋ฆฌ ๊ธฐ๋ก ```python # routers/management.py from services.history_service import HistoryService @router.put("/{issue_id}") async def update_issue( issue_id: int, update_request: ManagementUpdateRequest, request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): # ๊ธฐ์กด ์ด์Šˆ ๋ฐ์ดํ„ฐ ๋ฐฑ์—… issue = db.query(Issue).filter(Issue.id == issue_id).first() old_data = { "final_description": issue.final_description, "solution": issue.solution, "responsible_department": issue.responsible_department, "responsible_person": issue.responsible_person, "expected_completion_date": issue.expected_completion_date.isoformat() if issue.expected_completion_date else None } # ์—…๋ฐ์ดํŠธ ์ˆ˜ํ–‰ update_data = update_request.dict(exclude_unset=True) for field, value in update_data.items(): setattr(issue, field, value) db.commit() db.refresh(issue) # ์ƒˆ๋กœ์šด ๋ฐ์ดํ„ฐ new_data = { "final_description": issue.final_description, "solution": issue.solution, "responsible_department": issue.responsible_department, "responsible_person": issue.responsible_person, "expected_completion_date": issue.expected_completion_date.isoformat() if issue.expected_completion_date else None } # ํžˆ์Šคํ† ๋ฆฌ ๊ธฐ๋ก session_id = str(uuid.uuid4()) HistoryService.log_change( db=db, issue_id=issue_id, changed_by_id=current_user.id, change_type="UPDATE", page_source="MANAGEMENT", old_data=old_data, new_data=new_data, session_id=session_id ) return {"message": "์—…๋ฐ์ดํŠธ ์™„๋ฃŒ", "changed_fields": list(update_data.keys())} ``` ### 2๏ธโƒฃ ํ”„๋ก ํŠธ์—”๋“œ ๊ตฌํ˜„ #### ํžˆ์Šคํ† ๋ฆฌ ์กฐํšŒ ๋ชจ๋‹ฌ ```javascript // ํžˆ์Šคํ† ๋ฆฌ ์กฐํšŒ ํ•จ์ˆ˜ async function showIssueHistory(issueId) { try { const response = await fetch(`/api/issues/${issueId}/history`, { headers: { 'Authorization': `Bearer ${localStorage.getItem('access_token')}` } }); if (response.ok) { const history = await response.json(); openHistoryModal(issueId, history); } } catch (error) { console.error('ํžˆ์Šคํ† ๋ฆฌ ์กฐํšŒ ์‹คํŒจ:', error); } } // ํžˆ์Šคํ† ๋ฆฌ ๋ชจ๋‹ฌ ์ƒ์„ฑ function openHistoryModal(issueId, history) { const modalContent = `

๋ณ€๊ฒฝ ์ด๋ ฅ - No.${issueId}

${history.map(entry => `
${entry.field_name} ${entry.page_source}
${new Date(entry.changed_at).toLocaleString('ko-KR')}
์ด์ „:

${entry.old_value || '์—†์Œ'}

๋ณ€๊ฒฝ:

${entry.new_value || '์—†์Œ'}

๋ณ€๊ฒฝ์ž: ${entry.changed_by_name}
`).join('')}
`; document.body.insertAdjacentHTML('beforeend', modalContent); } ``` ## ๐Ÿ“Š ํžˆ์Šคํ† ๋ฆฌ ํ™œ์šฉ ๋ฐฉ์•ˆ ### 1๏ธโƒฃ ๊ด€๋ฆฌ์ž ๋Œ€์‹œ๋ณด๋“œ - ์ตœ๊ทผ ๋ณ€๊ฒฝ ์‚ฌํ•ญ ์š”์•ฝ - ์‚ฌ์šฉ์ž๋ณ„ ํ™œ๋™ ํ†ต๊ณ„ - ํŽ˜์ด์ง€๋ณ„ ์ˆ˜์ • ๋นˆ๋„ ### 2๏ธโƒฃ ๊ฐ์‚ฌ ๋ณด๊ณ ์„œ - ํŠน์ • ๊ธฐ๊ฐ„ ๋™์•ˆ์˜ ๋ชจ๋“  ๋ณ€๊ฒฝ ์‚ฌํ•ญ - ์ค‘์š” ํ•„๋“œ ๋ณ€๊ฒฝ ์•Œ๋ฆผ - ๊ทœ์ • ์ค€์ˆ˜ ํ™•์ธ ### 3๏ธโƒฃ ๋ฐ์ดํ„ฐ ๋ณต๊ตฌ - ์ž˜๋ชป๋œ ๋ณ€๊ฒฝ ์‚ฌํ•ญ ๋กค๋ฐฑ - ํŠน์ • ์‹œ์ ์œผ๋กœ ๋ฐ์ดํ„ฐ ๋ณต์› - ๋ณ€๊ฒฝ ์‚ฌํ•ญ ๋น„๊ต ๋ฐ ๋ถ„์„ ## ๐Ÿ”’ ๋ณด์•ˆ ๊ณ ๋ ค์‚ฌํ•ญ ### 1๏ธโƒฃ ์ ‘๊ทผ ๊ถŒํ•œ - ํžˆ์Šคํ† ๋ฆฌ ์กฐํšŒ ๊ถŒํ•œ ๋ถ„๋ฆฌ - ๋ฏผ๊ฐํ•œ ์ •๋ณด ๋งˆ์Šคํ‚น - ๊ด€๋ฆฌ์ž๋งŒ ์ „์ฒด ํžˆ์Šคํ† ๋ฆฌ ์ ‘๊ทผ ### 2๏ธโƒฃ ๋ฐ์ดํ„ฐ ๋ณดํ˜ธ - ํžˆ์Šคํ† ๋ฆฌ ๋ฐ์ดํ„ฐ ์•”ํ˜ธํ™” - ๊ฐœ์ธ์ •๋ณด ์ž๋™ ์‚ญ์ œ ์ •์ฑ… - ๋ฐฑ์—… ๋ฐ ์•„์นด์ด๋น™ ## ๐Ÿš€ ๊ตฌํ˜„ ์šฐ์„ ์ˆœ์œ„ ### Phase 1 (ํ•„์ˆ˜) 1. ๊ธฐ๋ณธ ํžˆ์Šคํ† ๋ฆฌ ํ…Œ์ด๋ธ” ์ƒ์„ฑ 2. ๊ด€๋ฆฌํ•จ ์ˆ˜์ • ์ด๋ ฅ ๊ธฐ๋ก 3. ๊ฐ„๋‹จํ•œ ํžˆ์Šคํ† ๋ฆฌ ์กฐํšŒ API ### Phase 2 (ํ™•์žฅ) 1. ์ˆ˜์‹ ํ•จ ์ฒ˜๋ฆฌ ์ด๋ ฅ ๊ธฐ๋ก 2. ํžˆ์Šคํ† ๋ฆฌ ์กฐํšŒ UI ๊ตฌํ˜„ 3. ๋ณ€๊ฒฝ ์‚ฌ์œ  ์ž…๋ ฅ ๊ธฐ๋Šฅ ### Phase 3 (๊ณ ๊ธ‰) 1. ๋ฐ์ดํ„ฐ ๋ณต๊ตฌ ๊ธฐ๋Šฅ 2. ๊ฐ์‚ฌ ๋ณด๊ณ ์„œ ์ƒ์„ฑ 3. ์‹ค์‹œ๊ฐ„ ๋ณ€๊ฒฝ ์•Œ๋ฆผ ## ๐Ÿ’ก ์ถ”๊ฐ€ ์•„์ด๋””์–ด ### 1๏ธโƒฃ ๋ณ€๊ฒฝ ์Šน์ธ ์›Œํฌํ”Œ๋กœ์šฐ - ์ค‘์š”ํ•œ ๋ณ€๊ฒฝ์‚ฌํ•ญ์€ ์Šน์ธ ํ›„ ์ ์šฉ - ๋ณ€๊ฒฝ ์š”์ฒญ โ†’ ๊ฒ€ํ†  โ†’ ์Šน์ธ/๋ฐ˜๋ ค ### 2๏ธโƒฃ ์ž๋™ ๋ฐฑ์—… - ์ค‘์š” ๋ณ€๊ฒฝ ์ „ ์ž๋™ ์Šค๋ƒ…์ƒท - ์ผ์ • ์ฃผ๊ธฐ๋ณ„ ์ „์ฒด ๋ฐ์ดํ„ฐ ๋ฐฑ์—… ### 3๏ธโƒฃ ๋ณ€๊ฒฝ ์˜ํ–ฅ๋„ ๋ถ„์„ - ์—ฐ๊ด€๋œ ๋‹ค๋ฅธ ์ด์Šˆ์— ๋ฏธ์น˜๋Š” ์˜ํ–ฅ - ๋ณ€๊ฒฝ์œผ๋กœ ์ธํ•œ ํ†ต๊ณ„ ๋ณ€ํ™” ์ด๋Ÿฌํ•œ ํžˆ์Šคํ† ๋ฆฌ ์‹œ์Šคํ…œ์„ ํ†ตํ•ด **ํˆฌ๋ช…ํ•˜๊ณ  ์ถ”์  ๊ฐ€๋Šฅํ•œ ์ด์Šˆ ๊ด€๋ฆฌ**๊ฐ€ ๊ฐ€๋Šฅํ•ด์ง‘๋‹ˆ๋‹ค! ๐ŸŽฏ