feat: 수신함 완료 사진 업로드 및 최종 부적합 사항 DB 구조 구현

📸 Completion Photo Upload:
- 수신함에서 '완료됨' 상태 선택 시 완료 사진 업로드 기능 추가 (1장 제한)
- Base64 인코딩으로 사진 업로드 및 미리보기 기능
- 완료 상태 변경 시 actual_completion_date 자동 설정

🗄️ Final Issue DB Structure:
- 최종 부적합 사항을 위한 포괄적인 DB 스키마 설계 및 구현
- 프로젝트별 순번 (project_sequence_no) 자동 생성 시스템

📋 New Database Fields:
- completion_photo_path: 완료 사진 경로
- solution: 해결방안 (관리함에서 입력)
- responsible_department: 담당부서 (department_type ENUM)
- responsible_person: 담당자 (VARCHAR 100)
- expected_completion_date: 조치 예상일 (DATE)
- actual_completion_date: 완료 확인일 (DATE, 자동 설정)
- cause_department: 원인부서 (department_type ENUM)
- management_comment: ISSUE에 대한 의견 (TEXT)
- final_description: 최종 내용 (수정본 또는 원본)
- final_category: 최종 카테고리 (수정본 또는 원본)

🔧 Backend Implementation:
- Issue 모델에 11개 새 필드 추가
- IssueStatusUpdateRequest에 completion_photo 필드 추가
- ManagementUpdateRequest 스키마 신규 생성
- update_issue_status API에 완료 사진 처리 로직 추가
- generate_project_sequence_no() 함수로 프로젝트별 순번 자동 생성

🎨 Frontend Implementation:
- 상태 결정 모달에 완료 사진 업로드 섹션 추가
- 완료 상태 선택 시에만 사진 업로드 UI 표시
- 파일 크기 제한 (5MB), 이미지 파일 검증
- 사진 미리보기 및 Base64 변환 처리
- 완료 사진 없이 완료 처리 시 확인 다이얼로그

🚀 User Experience:
- 직관적인 완료 사진 업로드 워크플로우
- 실시간 사진 미리보기로 업로드 확인 가능
- 완료 처리 시 자동으로 완료 확인일 기록
- 프로젝트별 순번으로 체계적인 이슈 관리

🔍 Database Migration:
- 016_add_management_fields.sql 마이그레이션 성공적으로 실행
- 멱등성 보장 및 기존 데이터 보존
- 인덱스 최적화 (project_sequence, responsible_department, expected_completion)
- 기존 이슈들의 final_description/final_category 자동 초기화

Expected Result:
 수신함에서 완료 상태 선택 시 완료 사진 업로드 가능
 완료 처리 시 완료 확인일 자동 기록
 프로젝트별 순번으로 체계적인 이슈 번호 관리
 관리함에서 사용할 모든 필드 준비 완료
 최종 부적합 사항 리포트 생성을 위한 DB 구조 완성
This commit is contained in:
Hyungi Ahn
2025-10-25 14:18:24 +09:00
parent 8996f5b432
commit d77a71493a
8 changed files with 365 additions and 7 deletions

View File

@@ -119,6 +119,19 @@ class Issue(Base):
duplicate_of_issue_id = Column(Integer, ForeignKey("issues.id")) # 중복 대상 이슈 ID
duplicate_reporters = Column(JSONB, default=lambda: []) # 중복 신고자 목록
# 관리함에서 사용할 추가 필드들
completion_photo_path = Column(String) # 완료 사진 경로
solution = Column(Text) # 해결방안 (관리함에서 입력)
responsible_department = Column(Enum(DepartmentType)) # 담당부서
responsible_person = Column(String(100)) # 담당자
expected_completion_date = Column(DateTime) # 조치 예상일
actual_completion_date = Column(DateTime) # 완료 확인일
cause_department = Column(Enum(DepartmentType)) # 원인부서
management_comment = Column(Text) # ISSUE에 대한 의견
project_sequence_no = Column(Integer) # 프로젝트별 순번 (No)
final_description = Column(Text) # 최종 내용 (수정본 또는 원본)
final_category = Column(Enum(IssueCategory)) # 최종 카테고리 (수정본 또는 원본)
# Relationships
reporter = relationship("User", back_populates="issues", foreign_keys=[reporter_id])
reviewer = relationship("User", foreign_keys=[reviewed_by_id], overlaps="reviewed_issues")

View File

@@ -128,6 +128,19 @@ class Issue(IssueBase):
duplicate_of_issue_id: Optional[int] = None
duplicate_reporters: Optional[List[Dict[str, Any]]] = None
# 관리함에서 사용할 추가 필드들
completion_photo_path: Optional[str] = None # 완료 사진 경로
solution: Optional[str] = None # 해결방안
responsible_department: Optional[DepartmentType] = None # 담당부서
responsible_person: Optional[str] = None # 담당자
expected_completion_date: Optional[datetime] = None # 조치 예상일
actual_completion_date: Optional[datetime] = None # 완료 확인일
cause_department: Optional[DepartmentType] = None # 원인부서
management_comment: Optional[str] = None # ISSUE에 대한 의견
project_sequence_no: Optional[int] = None # 프로젝트별 순번
final_description: Optional[str] = None # 최종 내용
final_category: Optional[IssueCategory] = None # 최종 카테고리
class Config:
from_attributes = True
@@ -149,6 +162,17 @@ class IssueStatusUpdateRequest(BaseModel):
"""부적합 상태 변경 요청"""
review_status: ReviewStatus
notes: Optional[str] = None
completion_photo: Optional[str] = None # 완료 사진 (Base64)
class ManagementUpdateRequest(BaseModel):
"""관리함에서 사용할 필드 업데이트 요청"""
solution: Optional[str] = None # 해결방안
responsible_department: Optional[DepartmentType] = None # 담당부서
responsible_person: Optional[str] = None # 담당자
expected_completion_date: Optional[datetime] = None # 조치 예상일
cause_department: Optional[DepartmentType] = None # 원인부서
management_comment: Optional[str] = None # ISSUE에 대한 의견
completion_photo: Optional[str] = None # 완료 사진 (Base64)
class InboxIssue(BaseModel):
"""수신함용 부적합 정보 (간소화된 버전)"""

View File

@@ -0,0 +1,223 @@
-- 016_add_management_fields.sql
-- 관리함에서 사용할 추가 필드들과 완료 사진 업로드 기능 추가
BEGIN;
DO $$
DECLARE
migration_name VARCHAR(255) := '016_add_management_fields.sql';
migration_notes TEXT := '관리함 필드 추가: 원인/해결방안, 담당부서/담당자, 조치예상일, 완료확인일, 원인부서, 의견, 완료사진, 프로젝트별 No 등';
current_status VARCHAR(50);
BEGIN
-- migration_log 테이블이 없으면 생성 (멱등성)
CREATE TABLE IF NOT EXISTS migration_log (
id SERIAL PRIMARY KEY,
migration_file VARCHAR(255) NOT NULL UNIQUE,
executed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
status VARCHAR(50),
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
SELECT status INTO current_status FROM migration_log WHERE migration_file = migration_name;
IF current_status IS NULL THEN
RAISE NOTICE '--- 마이그레이션 % 시작 ---', migration_name;
-- issues 테이블에 관리함 관련 컬럼들 추가
-- 완료 사진 경로
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'completion_photo_path') THEN
ALTER TABLE issues ADD COLUMN completion_photo_path VARCHAR(255);
RAISE NOTICE '✅ issues.completion_photo_path 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE ' issues.completion_photo_path 컬럼이 이미 존재합니다.';
END IF;
-- 해결방안 (관리함에서 입력)
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'solution') THEN
ALTER TABLE issues ADD COLUMN solution TEXT;
RAISE NOTICE '✅ issues.solution 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE ' issues.solution 컬럼이 이미 존재합니다.';
END IF;
-- 담당부서 (관리함에서 선택)
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'responsible_department') THEN
ALTER TABLE issues ADD COLUMN responsible_department department_type;
RAISE NOTICE '✅ issues.responsible_department 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE ' issues.responsible_department 컬럼이 이미 존재합니다.';
END IF;
-- 담당자 (관리함에서 입력)
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'responsible_person') THEN
ALTER TABLE issues ADD COLUMN responsible_person VARCHAR(100);
RAISE NOTICE '✅ issues.responsible_person 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE ' issues.responsible_person 컬럼이 이미 존재합니다.';
END IF;
-- 조치 예상일 (관리함에서 입력)
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'expected_completion_date') THEN
ALTER TABLE issues ADD COLUMN expected_completion_date DATE;
RAISE NOTICE '✅ issues.expected_completion_date 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE ' issues.expected_completion_date 컬럼이 이미 존재합니다.';
END IF;
-- 완료 확인일 (완료 상태로 변경 시 자동 입력)
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'actual_completion_date') THEN
ALTER TABLE issues ADD COLUMN actual_completion_date DATE;
RAISE NOTICE '✅ issues.actual_completion_date 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE ' issues.actual_completion_date 컬럼이 이미 존재합니다.';
END IF;
-- 원인부서 (관리함에서 입력)
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'cause_department') THEN
ALTER TABLE issues ADD COLUMN cause_department department_type;
RAISE NOTICE '✅ issues.cause_department 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE ' issues.cause_department 컬럼이 이미 존재합니다.';
END IF;
-- ISSUE에 대한 의견 (관리함에서 입력)
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'management_comment') THEN
ALTER TABLE issues ADD COLUMN management_comment TEXT;
RAISE NOTICE '✅ issues.management_comment 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE ' issues.management_comment 컬럼이 이미 존재합니다.';
END IF;
-- 프로젝트별 순번 (No) - 프로젝트 내에서 1, 2, 3... 순으로 증가
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'project_sequence_no') THEN
ALTER TABLE issues ADD COLUMN project_sequence_no INTEGER;
RAISE NOTICE '✅ issues.project_sequence_no 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE ' issues.project_sequence_no 컬럼이 이미 존재합니다.';
END IF;
-- 최종 내용 (수정된 내용이 있으면 수정본, 없으면 원본)
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'final_description') THEN
ALTER TABLE issues ADD COLUMN final_description TEXT;
RAISE NOTICE '✅ issues.final_description 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE ' issues.final_description 컬럼이 이미 존재합니다.';
END IF;
-- 최종 카테고리 (수정된 카테고리가 있으면 수정본, 없으면 원본)
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'final_category') THEN
ALTER TABLE issues ADD COLUMN final_category issuecategory;
RAISE NOTICE '✅ issues.final_category 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE ' issues.final_category 컬럼이 이미 존재합니다.';
END IF;
-- 인덱스 추가
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE tablename = 'issues' AND indexname = 'idx_issues_project_sequence') THEN
CREATE INDEX idx_issues_project_sequence ON issues (project_id, project_sequence_no);
RAISE NOTICE '✅ idx_issues_project_sequence 인덱스가 생성되었습니다.';
ELSE
RAISE NOTICE ' idx_issues_project_sequence 인덱스가 이미 존재합니다.';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE tablename = 'issues' AND indexname = 'idx_issues_responsible_department') THEN
CREATE INDEX idx_issues_responsible_department ON issues (responsible_department) WHERE responsible_department IS NOT NULL;
RAISE NOTICE '✅ idx_issues_responsible_department 인덱스가 생성되었습니다.';
ELSE
RAISE NOTICE ' idx_issues_responsible_department 인덱스가 이미 존재합니다.';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE tablename = 'issues' AND indexname = 'idx_issues_expected_completion') THEN
CREATE INDEX idx_issues_expected_completion ON issues (expected_completion_date) WHERE expected_completion_date IS NOT NULL;
RAISE NOTICE '✅ idx_issues_expected_completion 인덱스가 생성되었습니다.';
ELSE
RAISE NOTICE ' idx_issues_expected_completion 인덱스가 이미 존재합니다.';
END IF;
-- 프로젝트별 순번 자동 생성 함수
CREATE OR REPLACE FUNCTION generate_project_sequence_no(p_project_id BIGINT) RETURNS INTEGER AS $func$
DECLARE
next_no INTEGER;
BEGIN
-- 해당 프로젝트의 최대 순번 + 1
SELECT COALESCE(MAX(project_sequence_no), 0) + 1
INTO next_no
FROM issues
WHERE project_id = p_project_id;
RETURN next_no;
END;
$func$ LANGUAGE plpgsql;
RAISE NOTICE '✅ generate_project_sequence_no 함수가 생성되었습니다.';
-- 기존 이슈들에 대해 프로젝트별 순번 설정
DO $update_sequence$
DECLARE
issue_record RECORD;
seq_no INTEGER;
BEGIN
FOR issue_record IN
SELECT id, project_id
FROM issues
WHERE project_sequence_no IS NULL
ORDER BY project_id, report_date
LOOP
SELECT generate_project_sequence_no(issue_record.project_id) INTO seq_no;
UPDATE issues
SET project_sequence_no = seq_no
WHERE id = issue_record.id;
END LOOP;
END $update_sequence$;
RAISE NOTICE '✅ 기존 이슈들의 프로젝트별 순번이 설정되었습니다.';
-- 기존 이슈들의 final_description과 final_category 초기화
UPDATE issues
SET
final_description = description,
final_category = category
WHERE final_description IS NULL OR final_category IS NULL;
RAISE NOTICE '✅ 기존 이슈들의 final_description과 final_category가 초기화되었습니다.';
-- 마이그레이션 검증
DECLARE
col_count INTEGER;
idx_count INTEGER;
func_count INTEGER;
BEGIN
SELECT COUNT(*) INTO col_count FROM information_schema.columns
WHERE table_name = 'issues' AND column_name IN (
'completion_photo_path', 'solution', 'responsible_department', 'responsible_person',
'expected_completion_date', 'actual_completion_date', 'cause_department',
'management_comment', 'project_sequence_no', 'final_description', 'final_category'
);
SELECT COUNT(*) INTO idx_count FROM pg_indexes
WHERE tablename = 'issues' AND indexname IN (
'idx_issues_project_sequence', 'idx_issues_responsible_department', 'idx_issues_expected_completion'
);
SELECT COUNT(*) INTO func_count FROM pg_proc WHERE proname = 'generate_project_sequence_no';
RAISE NOTICE '=== 마이그레이션 검증 결과 ===';
RAISE NOTICE '추가된 컬럼: %/11개', col_count;
RAISE NOTICE '생성된 인덱스: %/3개', idx_count;
RAISE NOTICE '생성된 함수: %/1개', func_count;
IF col_count = 11 AND idx_count = 3 AND func_count = 1 THEN
RAISE NOTICE '✅ 마이그레이션이 성공적으로 완료되었습니다!';
INSERT INTO migration_log (migration_file, status, notes) VALUES (migration_name, 'SUCCESS', migration_notes);
ELSE
RAISE EXCEPTION '❌ 마이그레이션 검증 실패!';
END IF;
END;
ELSIF current_status = 'SUCCESS' THEN
RAISE NOTICE ' 마이그레이션 %는 이미 성공적으로 실행되었습니다. 스킵합니다.', migration_name;
ELSE
RAISE NOTICE '⚠️ 마이그레이션 %는 이전에 실패했습니다. 수동 확인이 필요합니다.', migration_name;
END IF;
END $$;
COMMIT;

View File

@@ -7,10 +7,11 @@ from database.database import get_db
from database.models import Issue, User, Project, ReviewStatus, DisposalReasonType
from database.schemas import (
InboxIssue, IssueDisposalRequest, IssueReviewRequest,
IssueStatusUpdateRequest, ModificationLogEntry
IssueStatusUpdateRequest, ModificationLogEntry, ManagementUpdateRequest
)
from routers.auth import get_current_user, get_current_admin
from routers.page_permissions import check_page_access
from services.file_service import save_base64_image
router = APIRouter(prefix="/api/inbox", tags=["inbox"])
@@ -260,6 +261,18 @@ async def update_issue_status(
issue.reviewed_by_id = current_user.id
issue.reviewed_at = datetime.now()
# 완료 사진 업로드 처리
if status_request.completion_photo and status_request.review_status == ReviewStatus.completed:
try:
completion_photo_path = save_base64_image(status_request.completion_photo, "completion")
issue.completion_photo_path = completion_photo_path
except Exception as e:
raise HTTPException(status_code=400, detail=f"완료 사진 저장 실패: {str(e)}")
# 완료 상태로 변경 시 완료 확인일 설정
if status_request.review_status == ReviewStatus.completed:
issue.actual_completion_date = datetime.now().date()
# 노트가 있으면 detail_notes에 추가
if status_request.notes:
current_notes = issue.detail_notes or ""

View File

@@ -411,11 +411,11 @@
<label class="block text-sm font-medium text-gray-700 mb-2">상태 선택</label>
<div class="space-y-2">
<label class="flex items-center">
<input type="radio" name="finalStatus" value="in_progress" class="mr-2">
<input type="radio" name="finalStatus" value="in_progress" class="mr-2" onchange="toggleCompletionPhotoSection()">
<span class="text-sm">🔄 진행 중 (관리함으로 이동)</span>
</label>
<label class="flex items-center">
<input type="radio" name="finalStatus" value="completed" class="mr-2">
<input type="radio" name="finalStatus" value="completed" class="mr-2" onchange="toggleCompletionPhotoSection()">
<span class="text-sm">✅ 완료됨 (관리함으로 이동)</span>
</label>
</div>
@@ -427,6 +427,21 @@
placeholder="처리 내용이나 특이사항을 입력하세요..."></textarea>
</div>
<!-- 완료 사진 업로드 (완료 상태 선택 시에만 표시) -->
<div id="completionPhotoSection" class="hidden">
<label class="block text-sm font-medium text-gray-700 mb-2">
<i class="fas fa-camera text-green-500 mr-1"></i>완료 사진 (1장)
</label>
<input type="file" id="completionPhotoInput" accept="image/*"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
onchange="handleCompletionPhotoSelect(event)">
<div id="completionPhotoPreview" class="mt-2 hidden">
<img id="completionPhotoImg" src="" alt="완료 사진 미리보기"
class="w-full max-h-40 object-cover rounded-lg border">
</div>
<p class="text-xs text-gray-500 mt-1">완료 상태로 변경 시 완료 사진을 업로드할 수 있습니다.</p>
</div>
<div class="flex justify-end space-x-3">
<button onclick="closeStatusModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">
취소
@@ -1210,6 +1225,62 @@
function closeStatusModal() {
currentIssueId = null;
document.getElementById('statusModal').classList.add('hidden');
// 완료 사진 관련 초기화
document.getElementById('completionPhotoSection').classList.add('hidden');
document.getElementById('completionPhotoInput').value = '';
document.getElementById('completionPhotoPreview').classList.add('hidden');
completionPhotoBase64 = null;
}
// 완료 사진 섹션 토글
function toggleCompletionPhotoSection() {
const selectedStatus = document.querySelector('input[name="finalStatus"]:checked');
const photoSection = document.getElementById('completionPhotoSection');
if (selectedStatus && selectedStatus.value === 'completed') {
photoSection.classList.remove('hidden');
} else {
photoSection.classList.add('hidden');
// 완료 사진 초기화
document.getElementById('completionPhotoInput').value = '';
document.getElementById('completionPhotoPreview').classList.add('hidden');
completionPhotoBase64 = null;
}
}
// 완료 사진 선택 처리
let completionPhotoBase64 = null;
function handleCompletionPhotoSelect(event) {
const file = event.target.files[0];
if (!file) {
completionPhotoBase64 = null;
document.getElementById('completionPhotoPreview').classList.add('hidden');
return;
}
// 파일 크기 체크 (5MB 제한)
if (file.size > 5 * 1024 * 1024) {
alert('파일 크기는 5MB 이하여야 합니다.');
event.target.value = '';
return;
}
// 이미지 파일인지 확인
if (!file.type.startsWith('image/')) {
alert('이미지 파일만 업로드 가능합니다.');
event.target.value = '';
return;
}
const reader = new FileReader();
reader.onload = function(e) {
completionPhotoBase64 = e.target.result.split(',')[1]; // Base64 부분만 추출
// 미리보기 표시
document.getElementById('completionPhotoImg').src = e.target.result;
document.getElementById('completionPhotoPreview').classList.remove('hidden');
};
reader.readAsDataURL(file);
}
// 상태 변경 확인
@@ -1225,17 +1296,31 @@
const reviewStatus = selectedStatus.value;
const notes = document.getElementById('statusNotes').value.trim();
// 완료 상태인데 완료 사진이 없으면 확인
if (reviewStatus === 'completed' && !completionPhotoBase64) {
if (!confirm('완료 사진을 업로드하지 않고 완료 처리하시겠습니까?')) {
return;
}
}
try {
const requestBody = {
review_status: reviewStatus,
notes: notes || null
};
// 완료 사진이 있으면 추가
if (reviewStatus === 'completed' && completionPhotoBase64) {
requestBody.completion_photo = completionPhotoBase64;
}
const response = await fetch(`/api/inbox/${currentIssueId}/status`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
review_status: reviewStatus,
notes: notes || null
})
body: JSON.stringify(requestBody)
});
if (response.ok) {