diff --git a/DB_CHANGES_LOG.md b/DB_CHANGES_LOG.md index 00af767..ec564d9 100644 --- a/DB_CHANGES_LOG.md +++ b/DB_CHANGES_LOG.md @@ -4,6 +4,109 @@ ## π λ³κ²½μ¬ν λͺ©λ‘ +### 2025.10.26 - κ΄λ¦¬ν¨ μλ£ μ μ² μ 보 μμ κΈ°λ₯ μΆκ° + +**π― λͺ©μ **: κ΄λ¦¬ν¨μμ μλ£ μ¬μ§ μ λ‘λ/κ΅μ²΄ λ° μλ£ μ½λ©νΈ μμ κΈ°λ₯ ꡬν + +#### π νμΌ λ³κ²½μ¬ν + +**1. λ°±μλ μ€ν€λ§ μΆκ°** +- **νμΌ**: `backend/database/schemas.py` +- **λ³κ²½λ΄μ©**: `ManagementUpdateRequest` ν΄λμ€ μΆκ° +- **μ½λ**: + ```python + class ManagementUpdateRequest(BaseModel): + """κ΄λ¦¬ν¨μμ μ΄μ μ λ°μ΄νΈ μμ²""" + final_description: Optional[str] = None + final_category: Optional[IssueCategory] = None + solution: Optional[str] = None + responsible_department: Optional[DepartmentType] = None + responsible_person: Optional[str] = None + expected_completion_date: Optional[str] = None + cause_department: Optional[DepartmentType] = None + management_comment: Optional[str] = None + completion_comment: Optional[str] = None + completion_photo: Optional[str] = None # Base64 + review_status: Optional[ReviewStatus] = None + ``` + +**2. λ°±μλ API κ°μ ** +- **νμΌ**: `backend/routers/management.py` +- **λ³κ²½λ΄μ©**: `PUT /api/management/{issue_id}` μλν¬μΈνΈμ μλ£ μ¬μ§ μ²λ¦¬ λ‘μ§ μΆκ° +- **μ½λ**: + ```python + if field == 'completion_photo' and value: + # μλ£ μ¬μ§ Base64 μ²λ¦¬ + from services.file_service import save_base64_image + try: + photo_path = save_base64_image(value, "completion_") + issue.completion_photo_path = photo_path + except Exception as e: + print(f"μλ£ μ¬μ§ μ μ₯ μ€ν¨: {e}") + continue + ``` + +**3. νλ‘ νΈμλ ν΅ν© λͺ¨λ¬** +- **νμΌ**: `frontend/issues-management.html` +- **λ³κ²½λ΄μ©**: + - μ§ν μ€/μλ£ λκΈ° μν λͺ¨λ λμΌν μμ κ°λ₯ν λͺ¨λ¬ μ¬μ© + - μλ£ μ¬μ§ μ λ‘λ/κ΅μ²΄ λ²νΌ μΆκ° + - μλ£ μ½λ©νΈ ν μ€νΈ μμ μμ κ°λ₯ + - `loadManagementData()` β `initializeManagement()` ν¨μλͺ μμ + +**4. DB λ§μ΄κ·Έλ μ΄μ ** +- **νμΌ**: `backend/migrations/020_add_management_completion_fields.sql` +- **λͺ©μ **: μλ£ μ μ² κ΄λ ¨ 컬λΌλ€μ΄ λλ½λ κ²½μ° μΆκ° (μ΄λ―Έ μ‘΄μ¬ν μ μμ) + +#### π λ°°ν¬ μ μ€ν μμ + +```bash +# 1. λ°μ΄ν°λ² μ΄μ€ λ°±μ +docker-compose exec postgres pg_dump -U postgres -d m_project > backup_$(date +%Y%m%d_%H%M%S).sql + +# 2. λ°±μλ μ½λ λ°°ν¬ +git pull origin master +docker-compose down +docker-compose build backend +docker-compose up -d + +# 3. DB λ§μ΄κ·Έλ μ΄μ μ€ν +docker-compose exec backend python -c " +import sys +sys.path.append('/app') +import psycopg2 +import os + +conn = psycopg2.connect( + host=os.getenv('DB_HOST', 'postgres'), + database=os.getenv('DB_NAME', 'm_project'), + user=os.getenv('DB_USER', 'postgres'), + password=os.getenv('DB_PASSWORD', 'password') +) + +with open('/app/migrations/020_add_management_completion_fields.sql', 'r', encoding='utf-8') as f: + migration_sql = f.read() + +with conn.cursor() as cursor: + cursor.execute(migration_sql) + conn.commit() + +print('β λ§μ΄κ·Έλ μ΄μ μλ£') +conn.close() +" + +# 4. λ°°ν¬ ν κ²μ¦ +docker-compose logs backend --tail=20 +docker-compose exec postgres psql -U postgres -d m_project -c "SELECT COUNT(*) FROM migration_log WHERE migration_file = '020_add_management_completion_fields.sql';" +``` + +#### β οΈ μ£Όμμ¬ν +- μλ£ μ μ² κ΄λ ¨ 컬λΌλ€μ΄ μ΄λ―Έ μ‘΄μ¬ν μ μμΌλ―λ‘ λ§μ΄κ·Έλ μ΄μ μ€ν¬λ¦½νΈλ μμ νκ² μμ±λ¨ +- λΈλΌμ°μ μΊμ λ¬Έμ λ‘ μΈν΄ κ°μ μλ‘κ³ μΉ¨(Ctrl+Shift+F5) νμν μ μμ +- 422 μλ¬ λ°μ μ λ°±μλ μ¬μμ νμ + +--- + ### 2025.10.26 - νλ‘μ νΈλ³ μλ² μλ ν λΉ κ°μ **π― λͺ©μ **: μμ ν¨μμ μ§ν μ€/μλ£λ‘ μν λ³κ²½ μ νλ‘μ νΈλ³ μλ²μ΄ μλ ν λΉλλλ‘ κ°μ diff --git a/DEPLOYMENT_GUIDE_20251026.md b/DEPLOYMENT_GUIDE_20251026.md new file mode 100644 index 0000000..bacfafc --- /dev/null +++ b/DEPLOYMENT_GUIDE_20251026.md @@ -0,0 +1,191 @@ +# λ°°ν¬ κ°μ΄λ - 2025.10.26 μ λ°μ΄νΈ + +## π **λ³κ²½μ¬ν μμ½** + +### π― **μ£Όμ κΈ°λ₯ κ°μ ** +- **κ΄λ¦¬ν¨ μλ£ μ μ² μ 보 μμ κΈ°λ₯** μΆκ° +- **μ§ν μ€/μλ£ λκΈ° μν ν΅ν© λͺ¨λ¬** ꡬν +- **μλ£ μ¬μ§ μ λ‘λ/κ΅μ²΄** κΈ°λ₯ +- **μλ£ μ½λ©νΈ μμ ** κΈ°λ₯ + +--- + +## ποΈ **λ°μ΄ν°λ² μ΄μ€ λ³κ²½μ¬ν** + +### **μλ‘μ΄ λ§μ΄κ·Έλ μ΄μ ** +- `020_add_management_completion_fields.sql` + +### **μΆκ°λ 컬λΌλ€** (μ΄λ―Έ μ‘΄μ¬ν μ μμ) +```sql +-- issues ν μ΄λΈμ μΆκ°λ 컬λΌλ€ +completion_photo_path VARCHAR(500) -- μλ£ μ¬μ§ κ²½λ‘ +completion_comment TEXT -- μλ£ μ½λ©νΈ +completion_requested_at TIMESTAMP WITH TIME ZONE -- μλ£ μ μ² μκ° +completion_requested_by_id INTEGER REFERENCES users(id) -- μλ£ μ μ²μ +``` + +--- + +## π§ **λ°±μλ λ³κ²½μ¬ν** + +### **1. μ€ν€λ§ μ λ°μ΄νΈ** +- `backend/database/schemas.py` + - `ManagementUpdateRequest` ν΄λμ€ μΆκ° + - `completion_photo`, `completion_comment` νλ μ§μ + +### **2. API μλν¬μΈνΈ κ°μ ** +- `backend/routers/management.py` + - `PUT /api/management/{issue_id}` μλν¬μΈνΈ κ°μ + - Base64 μ΄λ―Έμ§ μ²λ¦¬ λ‘μ§ μΆκ° + - λ μ§ νλ μ²λ¦¬ κ°μ + +--- + +## π¨ **νλ‘ νΈμλ λ³κ²½μ¬ν** + +### **1. ν΅ν© λͺ¨λ¬ ꡬν** +- `frontend/issues-management.html` + - `openIssueEditModal()` ν¨μ κ°μ + - μλ£ μ μ² μ 보 μΉμ νμ νμ + - νμΌ μ λ‘λ UI κ°μ + +### **2. λ²νΌ μ°κ²° ν΅ν©** +- μ§ν μ€ "μλ£μ²λ¦¬" β `confirmCompletion()` +- μλ£ λκΈ° "μ΅μ’ νμΈ" β `confirmCompletion()` +- λͺ¨λ λ²νΌμ΄ λμΌν μμ κ°λ₯ν λͺ¨λ¬ μ¬μ© + +### **3. ν¨μλͺ μμ ** +- `loadManagementData()` β `initializeManagement()` + +--- + +## π **λ°°ν¬ μ μ°¨** + +### **1. μ¬μ μ€λΉ** +```bash +# 1. νμ¬ λ°μ΄ν°λ² μ΄μ€ λ°±μ +docker-compose exec postgres pg_dump -U postgres -d m_project > backup_$(date +%Y%m%d_%H%M%S).sql + +# 2. Git μ΅μ μ½λ pull +git pull origin master +``` + +### **2. λ°±μλ λ°°ν¬** +```bash +# 1. Docker 컨ν μ΄λ μ€μ§ +docker-compose down + +# 2. μ΄λ―Έμ§ μ¬λΉλ +docker-compose build backend + +# 3. 컨ν μ΄λ μμ +docker-compose up -d + +# 4. λ§μ΄κ·Έλ μ΄μ μ€ν +docker-compose exec backend python -c " +import sys +sys.path.append('/app') +from database.database import get_db +import psycopg2 +import os + +# λ§μ΄κ·Έλ μ΄μ μ€ν +conn = psycopg2.connect( + host=os.getenv('DB_HOST', 'postgres'), + database=os.getenv('DB_NAME', 'm_project'), + user=os.getenv('DB_USER', 'postgres'), + password=os.getenv('DB_PASSWORD', 'password') +) + +with open('/app/migrations/020_add_management_completion_fields.sql', 'r', encoding='utf-8') as f: + migration_sql = f.read() + +with conn.cursor() as cursor: + cursor.execute(migration_sql) + conn.commit() + +print('β λ§μ΄κ·Έλ μ΄μ μλ£') +conn.close() +" +``` + +### **3. νλ‘ νΈμλ λ°°ν¬** +```bash +# λΈλΌμ°μ μΊμ 무ν¨νλ₯Ό μν λ²μ μ λ°μ΄νΈ (νμμ) +# frontend/issues-management.htmlμ μΊμλ²μ€ν° νμΈ +``` + +### **4. λ°°ν¬ ν κ²μ¦** +```bash +# 1. λ°±μλ μν νμΈ +docker-compose logs backend --tail=20 + +# 2. λ°μ΄ν°λ² μ΄μ€ μ°κ²° νμΈ +docker-compose exec postgres psql -U postgres -d m_project -c "SELECT COUNT(*) FROM migration_log WHERE migration_file = '020_add_management_completion_fields.sql';" + +# 3. μλ‘μ΄ μ»¬λΌ νμΈ +docker-compose exec postgres psql -U postgres -d m_project -c "\\d issues" | grep completion +``` + +--- + +## β **κΈ°λ₯ ν μ€νΈ 체ν¬λ¦¬μ€νΈ** + +### **κ΄λ¦¬ν¨ νμ΄μ§ ν μ€νΈ** +- [ ] μ§ν μ€ μνμμ "μλ£μ²λ¦¬" λ²νΌ ν΄λ¦ +- [ ] μλ£ λκΈ° μνμμ "μ΅μ’ νμΈ" λ²νΌ ν΄λ¦ +- [ ] ν΅ν© λͺ¨λ¬μ΄ μ΄λ¦¬λμ§ νμΈ +- [ ] μλ£ μ¬μ§ μ λ‘λ/κ΅μ²΄ λ²νΌ μλ νμΈ +- [ ] μλ£ μ½λ©νΈ ν μ€νΈ μμ μμ κ°λ₯ νμΈ +- [ ] "μ μ₯" λ²νΌμΌλ‘ μμ μ¬ν μ μ₯ νμΈ +- [ ] "μ΅μ’ νμΈ" λ²νΌμΌλ‘ μλ£ μ²λ¦¬ νμΈ +- [ ] 422 μλ¬ μμ΄ μ μ μ μ₯ νμΈ + +--- + +## π **νΈλ¬λΈμν ** + +### **μΌλ°μ μΈ λ¬Έμ λ€** + +#### **1. 422 Unprocessable Entity μλ¬** +```bash +# μμΈ: λ°±μλ μ€ν€λ§ λΆμΌμΉ +# ν΄κ²°: λ°±μλ μ¬μμ +docker-compose restart backend +``` + +#### **2. ReferenceError: loadManagementData** +```bash +# μμΈ: ν¨μλͺ λ³κ²½ λ―Έμ μ© +# ν΄κ²°: λΈλΌμ°μ κ°μ μλ‘κ³ μΉ¨ (Ctrl+Shift+F5) +``` + +#### **3. μλ£ μ¬μ§ μ λ‘λ μ€ν¨** +```bash +# μμΈ: νμΌ μλΉμ€ λ¬Έμ +# ν΄κ²°: uploads λλ ν 리 κΆν νμΈ +docker-compose exec backend ls -la /uploads/ +``` + +#### **4. λ§μ΄κ·Έλ μ΄μ μ€ν¨** +```bash +# λ§μ΄κ·Έλ μ΄μ μν νμΈ +docker-compose exec postgres psql -U postgres -d m_project -c "SELECT * FROM migration_log ORDER BY started_at DESC LIMIT 5;" + +# μ€ν¨ν λ§μ΄κ·Έλ μ΄μ μ¬μ€ν +docker-compose exec postgres psql -U postgres -d m_project -c "DELETE FROM migration_log WHERE migration_file = '020_add_management_completion_fields.sql' AND status = 'failed';" +``` + +--- + +## π **μ§μ μ°λ½μ²** +- κ°λ°μ: [κ°λ°μ μ°λ½μ²] +- λ°°ν¬ λ΄λΉμ: [λ°°ν¬ λ΄λΉμ μ°λ½μ²] + +--- + +**β οΈ μ£Όμμ¬ν:** +1. λ°λμ λ°μ΄ν°λ² μ΄μ€ λ°±μ ν λ°°ν¬ μ§ν +2. λ§μ΄κ·Έλ μ΄μ μ€ν μ λ°±μλ λ‘κ·Έ νμΈ +3. λ°°ν¬ ν κΈ°λ₯ ν μ€νΈ νμ μν +4. λ¬Έμ λ°μ μ μ¦μ λ‘€λ°± μ€λΉ diff --git a/backend/database/__pycache__/schemas.cpython-311.pyc b/backend/database/__pycache__/schemas.cpython-311.pyc index 1895807..b07a5ff 100644 Binary files a/backend/database/__pycache__/schemas.cpython-311.pyc and b/backend/database/__pycache__/schemas.cpython-311.pyc differ diff --git a/backend/database/schemas.py b/backend/database/schemas.py index d89bed5..3f54649 100644 --- a/backend/database/schemas.py +++ b/backend/database/schemas.py @@ -200,6 +200,20 @@ class CompletionRequestRequest(BaseModel): completion_photo: str # μλ£ μ¬μ§ (Base64) completion_comment: Optional[str] = None # μλ£ μ½λ©νΈ +class ManagementUpdateRequest(BaseModel): + """κ΄λ¦¬ν¨μμ μ΄μ μ λ°μ΄νΈ μμ²""" + final_description: Optional[str] = None + final_category: Optional[IssueCategory] = None + solution: Optional[str] = None + responsible_department: Optional[DepartmentType] = None + responsible_person: Optional[str] = None + expected_completion_date: Optional[str] = None + cause_department: Optional[DepartmentType] = None + management_comment: Optional[str] = None + completion_comment: Optional[str] = None + completion_photo: Optional[str] = None # Base64 + review_status: Optional[ReviewStatus] = None + class InboxIssue(BaseModel): """μμ ν¨μ© λΆμ ν© μ 보 (κ°μνλ λ²μ )""" id: int diff --git a/backend/migrations/020_add_management_completion_fields.sql b/backend/migrations/020_add_management_completion_fields.sql new file mode 100644 index 0000000..bbef63a --- /dev/null +++ b/backend/migrations/020_add_management_completion_fields.sql @@ -0,0 +1,91 @@ +-- 020_add_management_completion_fields.sql +-- κ΄λ¦¬ν¨ μλ£ μ μ² μ 보 νλ μΆκ° +-- μμ±μΌ: 2025-10-26 +-- λͺ©μ : μλ£ μ¬μ§ λ° μ½λ©νΈ μμ κΈ°λ₯ μ§μ + +BEGIN; + +-- λ§μ΄κ·Έλ μ΄μ λ‘κ·Έ νμΈ +DO $$ +BEGIN + -- μ΄λ―Έ μ€νλ λ§μ΄κ·Έλ μ΄μ μΈμ§ νμΈ + IF EXISTS ( + SELECT 1 FROM migration_log + WHERE migration_file = '020_add_management_completion_fields.sql' + AND status = 'completed' + ) THEN + RAISE NOTICE 'λ§μ΄κ·Έλ μ΄μ μ΄ μ΄λ―Έ μ€νλμμ΅λλ€: 020_add_management_completion_fields.sql'; + RETURN; + END IF; + + -- λ§μ΄κ·Έλ μ΄μ μμ λ‘κ·Έ + INSERT INTO migration_log (migration_file, status, started_at, notes) + VALUES ('020_add_management_completion_fields.sql', 'running', NOW(), + 'κ΄λ¦¬ν¨ μλ£ μ μ² μ 보 νλ μΆκ° - completion_photo, completion_comment μμ κΈ°λ₯'); + + -- completion_photo_path 컬λΌμ΄ μμΌλ©΄ μΆκ° (μ΄λ―Έ μμ μ μμ) + 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(500); + RAISE NOTICE 'β completion_photo_path μ»¬λΌ μΆκ°λ¨'; + ELSE + RAISE NOTICE 'βΉοΈ completion_photo_path 컬λΌμ΄ μ΄λ―Έ μ‘΄μ¬ν¨'; + END IF; + + -- completion_comment 컬λΌμ΄ μμΌλ©΄ μΆκ° (μ΄λ―Έ μμ μ μμ) + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'issues' AND column_name = 'completion_comment' + ) THEN + ALTER TABLE issues ADD COLUMN completion_comment TEXT; + RAISE NOTICE 'β completion_comment μ»¬λΌ μΆκ°λ¨'; + ELSE + RAISE NOTICE 'βΉοΈ completion_comment 컬λΌμ΄ μ΄λ―Έ μ‘΄μ¬ν¨'; + END IF; + + -- completion_requested_at 컬λΌμ΄ μμΌλ©΄ μΆκ° (μ΄λ―Έ μμ μ μμ) + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'issues' AND column_name = 'completion_requested_at' + ) THEN + ALTER TABLE issues ADD COLUMN completion_requested_at TIMESTAMP WITH TIME ZONE; + RAISE NOTICE 'β completion_requested_at μ»¬λΌ μΆκ°λ¨'; + ELSE + RAISE NOTICE 'βΉοΈ completion_requested_at 컬λΌμ΄ μ΄λ―Έ μ‘΄μ¬ν¨'; + END IF; + + -- completion_requested_by_id 컬λΌμ΄ μμΌλ©΄ μΆκ° (μ΄λ―Έ μμ μ μμ) + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'issues' AND column_name = 'completion_requested_by_id' + ) THEN + ALTER TABLE issues ADD COLUMN completion_requested_by_id INTEGER REFERENCES users(id); + RAISE NOTICE 'β completion_requested_by_id μ»¬λΌ μΆκ°λ¨'; + ELSE + RAISE NOTICE 'βΉοΈ completion_requested_by_id 컬λΌμ΄ μ΄λ―Έ μ‘΄μ¬ν¨'; + END IF; + + -- λ§μ΄κ·Έλ μ΄μ μλ£ λ‘κ·Έ + UPDATE migration_log + SET status = 'completed', completed_at = NOW(), + notes = notes || ' - μλ£: λͺ¨λ νμν 컬λΌμ΄ μΆκ°λμμ΅λλ€.' + WHERE migration_file = '020_add_management_completion_fields.sql' + AND status = 'running'; + + RAISE NOTICE 'π λ§μ΄κ·Έλ μ΄μ μλ£: 020_add_management_completion_fields.sql'; + +EXCEPTION + WHEN OTHERS THEN + -- μλ¬ λ°μ μ λ‘κ·Έ μ λ°μ΄νΈ + UPDATE migration_log + SET status = 'failed', completed_at = NOW(), + notes = notes || ' - μ€ν¨: ' || SQLERRM + WHERE migration_file = '020_add_management_completion_fields.sql' + AND status = 'running'; + + RAISE EXCEPTION 'β λ§μ΄κ·Έλ μ΄μ μ€ν¨: %', SQLERRM; +END $$; + +COMMIT; diff --git a/backend/requirements.txt b/backend/requirements.txt index 426fa5c..12c373f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -9,4 +9,5 @@ alembic==1.12.1 pydantic==2.5.0 pydantic-settings==2.1.0 pillow==10.1.0 +pillow-heif==0.13.0 reportlab==4.0.7 diff --git a/backend/routers/__pycache__/management.cpython-311.pyc b/backend/routers/__pycache__/management.cpython-311.pyc index 783d4e6..97b5d63 100644 Binary files a/backend/routers/__pycache__/management.cpython-311.pyc and b/backend/routers/__pycache__/management.cpython-311.pyc differ diff --git a/backend/routers/management.py b/backend/routers/management.py index a8f9eaa..0ee0ac8 100644 --- a/backend/routers/management.py +++ b/backend/routers/management.py @@ -51,17 +51,35 @@ async def update_issue( if not issue: raise HTTPException(status_code=404, detail="λΆμ ν©μ μ°Ύμ μ μμ΅λλ€.") - # μ§ν μ€ μνμΈμ§ νμΈ - if issue.review_status != ReviewStatus.in_progress: + # μ§ν μ€ λλ μλ£ λκΈ° μνμΈμ§ νμΈ + if issue.review_status not in [ReviewStatus.in_progress]: raise HTTPException(status_code=400, detail="μ§ν μ€ μνμ λΆμ ν©λ§ μμ ν μ μμ΅λλ€.") # μ λ°μ΄νΈν λ°μ΄ν° μ²λ¦¬ update_data = update_request.dict(exclude_unset=True) for field, value in update_data.items(): - if field == 'completion_photo': - # μλ£ μ¬μ§μ λ³λ μ²λ¦¬ (νμμ) + if field == 'completion_photo' and value: + # μλ£ μ¬μ§ Base64 μ²λ¦¬ + from services.file_service import save_base64_image + try: + print(f"π μλ£ μ¬μ§ μ²λ¦¬ μμ - λ°μ΄ν° κΈΈμ΄: {len(value)}") + print(f"π Base64 λ°μ΄ν° μμ λΆλΆ: {value[:100]}...") + photo_path = save_base64_image(value, "completion_") + if photo_path: + issue.completion_photo_path = photo_path + print(f"β μλ£ μ¬μ§ μ μ₯ μ±κ³΅: {photo_path}") + else: + print("β μλ£ μ¬μ§ μ μ₯ μ€ν¨: photo_pathκ° None") + except Exception as e: + print(f"β μλ£ μ¬μ§ μ μ₯ μ€ν¨: {e}") + import traceback + traceback.print_exc() continue + elif field == 'expected_completion_date' and value: + # λ μ§ νλ μ²λ¦¬ + if not value.endswith('T00:00:00'): + value = value + 'T00:00:00' setattr(issue, field, value) db.commit() diff --git a/backend/services/__pycache__/file_service.cpython-311.pyc b/backend/services/__pycache__/file_service.cpython-311.pyc index 45d44ec..204bcbc 100644 Binary files a/backend/services/__pycache__/file_service.cpython-311.pyc and b/backend/services/__pycache__/file_service.cpython-311.pyc differ diff --git a/backend/services/file_service.py b/backend/services/file_service.py index 46fed89..838e504 100644 --- a/backend/services/file_service.py +++ b/backend/services/file_service.py @@ -6,6 +6,14 @@ import uuid from PIL import Image import io +# HEIF/HEIC μ§μμ μν λΌμ΄λΈλ¬λ¦¬ +try: + from pillow_heif import register_heif_opener + register_heif_opener() + HEIF_SUPPORTED = True +except ImportError: + HEIF_SUPPORTED = False + UPLOAD_DIR = "/app/uploads" def ensure_upload_dir(): @@ -18,15 +26,70 @@ def save_base64_image(base64_string: str, prefix: str = "image") -> Optional[str try: ensure_upload_dir() - # Base64 ν€λ μ κ±° + # Base64 ν€λ μ κ±° λ° μ 리 if "," in base64_string: base64_string = base64_string.split(",")[1] + # Base64 λ¬Έμμ΄ μ 리 (곡백, κ°ν μ κ±°) + base64_string = base64_string.strip().replace('\n', '').replace('\r', '').replace(' ', '') + + print(f"π μ 리λ Base64 κΈΈμ΄: {len(base64_string)}") + print(f"π Base64 μμ 20μ: {base64_string[:20]}") + # λμ½λ© - image_data = base64.b64decode(base64_string) + try: + image_data = base64.b64decode(base64_string) + print(f"π λμ½λ©λ λ°μ΄ν° κΈΈμ΄: {len(image_data)}") + print(f"π λ°μ΄λ리 μμ 20λ°μ΄νΈ: {image_data[:20]}") + except Exception as decode_error: + print(f"β Base64 λμ½λ© μ€ν¨: {decode_error}") + raise decode_error + + # νμΌ μκ·Έλμ² νμΈ + file_signature = image_data[:20] + print(f"π νμΌ μκ·Έλμ² (hex): {file_signature.hex()}") + + # HEIC νμΌ μκ·Έλμ² νμΈ + is_heic = b'ftyp' in image_data[:20] and (b'heic' in image_data[:50] or b'mif1' in image_data[:50]) + print(f"π HEIC νμΌ μ¬λΆ: {is_heic}") # μ΄λ―Έμ§ κ²μ¦ λ° νμ νμΈ - image = Image.open(io.BytesIO(image_data)) + try: + # HEIC νμΌμΈ κ²½μ° λ°λ‘ HEIF μ²λ¦¬ μλ + if is_heic and HEIF_SUPPORTED: + print("π HEIC νμΌ κ°μ§, HEIF μ²λ¦¬ μλ...") + image = Image.open(io.BytesIO(image_data)) + print(f"β HEIF μ΄λ―Έμ§ λ‘λ μ±κ³΅: {image.format}, λͺ¨λ: {image.mode}, ν¬κΈ°: {image.size}") + else: + # μΌλ° μ΄λ―Έμ§ μ²λ¦¬ + image = Image.open(io.BytesIO(image_data)) + print(f"π μ΄λ―Έμ§ νμ: {image.format}, λͺ¨λ: {image.mode}, ν¬κΈ°: {image.size}") + except Exception as e: + print(f"β μ΄λ―Έμ§ μ΄κΈ° μ€ν¨: {e}") + + # HEIC νμΌμΈ κ²½μ° μλ³Έ νμΌλ‘ μ μ₯ + if is_heic: + print("π HEIC νμΌ - μλ³Έ λ°μ΄λ리 νμΌλ‘ μ μ₯ μλ...") + filename = f"{prefix}_{datetime.now().strftime('%Y%m%d%H%M%S')}_{uuid.uuid4().hex[:8]}.heic" + filepath = os.path.join(UPLOAD_DIR, filename) + with open(filepath, 'wb') as f: + f.write(image_data) + print(f"β μλ³Έ HEIC νμΌ μ μ₯: {filepath}") + return f"/uploads/{filename}" + + # HEICκ° μλ κ²½μ°μλ§ HEIF μ¬μλ + elif HEIF_SUPPORTED: + print("π HEIF νμμΌλ‘ μ¬μλ...") + try: + image = Image.open(io.BytesIO(image_data)) + print(f"β HEIF μ¬μλ μ±κ³΅: {image.format}, λͺ¨λ: {image.mode}, ν¬κΈ°: {image.size}") + except Exception as heif_e: + print(f"β HEIF μ²λ¦¬λ μ€ν¨: {heif_e}") + print("β μ§μλμ§ μλ μ΄λ―Έμ§ νμ") + raise e + else: + print("β HEIF μ§μ λΌμ΄λΈλ¬λ¦¬κ° μ€μΉλμ§ μκ±°λ μ²λ¦¬ λΆκ°") + raise e # iPhoneμ .mpo νμΌμ΄λ κΈ°ν νμμ JPEGλ‘ κ°μ λ³ν # RGB λͺ¨λλ‘ λ³ν (RGBA, P λͺ¨λ λ±μ μ²λ¦¬) diff --git a/frontend/issues-management.html b/frontend/issues-management.html index 766535b..d0c4100 100644 --- a/frontend/issues-management.html +++ b/frontend/issues-management.html @@ -789,9 +789,6 @@ λ°λ € - - μμ - μ΅μ’ νμΈ @@ -800,10 +797,7 @@ μ μ₯ - - νμΈ - - + μλ£μ²λ¦¬ `} @@ -1748,8 +1742,13 @@ } } + // μλ£ νμΈ λͺ¨λ¬ μ΄κΈ° (μ§ν μ€ -> μλ£ μ²λ¦¬μ©) + function openCompletionConfirmModal(issueId) { + openIssueEditModal(issueId, true); // μλ£ μ²λ¦¬ λͺ¨λλ‘ μ΄κΈ° + } + // μ΄μ μμ λͺ¨λ¬ μ΄κΈ° (λͺ¨λ μ§ν μ€ μνμμ μ¬μ©) - function openIssueEditModal(issueId) { + function openIssueEditModal(issueId, isCompletionMode = false) { const issue = issues.find(i => i.id === issueId); if (!issue) return; @@ -1793,7 +1792,14 @@ μμΈλΆλ₯ - + + μμ¬ λλ½ + 곡μ μ€λ₯ + νμ§ κ²°ν¨ + μ€κ³ λ¬Έμ + μμ μλ° + κΈ°ν + @@ -1834,32 +1840,66 @@ μ‘°μΉμμμΌ + + μμΈλΆμ + + ${getDepartmentOptions().map(opt => + `${opt.text}` + ).join('')} + + + + κ΄λ¦¬ μ½λ©νΈ + ${issue.management_comment || ''} + - ${isPendingCompletion ? ` - - μλ£ μ μ² μ 보 - - - μλ£ μ¬μ§ + + + μλ£ μ μ² μ 보 + + + μλ£ μ¬μ§ + ${issue.completion_photo_path ? ` - - + + + + νμ¬ μλ£ μ¬μ§ + ν΄λ¦νλ©΄ ν¬κ² λ³Ό μ μμ΅λλ€ + - ` : 'μλ£ μ¬μ§ μμ'} - - - μλ£ μ½λ©νΈ - ${issue.completion_comment || 'μ½λ©νΈ μμ'} + ` : ` + + + + μ¬μ§ μμ + + + `} + + + + + ${issue.completion_photo_path ? 'μ¬μ§ κ΅μ²΄' : 'μ¬μ§ μ λ‘λ'} + + + + + + μλ£ μ½λ©νΈ + ${issue.completion_comment || ''} + + ${isPendingCompletion ? ` μ μ²μΌμ ${new Date(issue.completion_requested_at).toLocaleString('ko-KR')} - + ` : ''} - ` : ''} + @@ -1871,11 +1911,9 @@ μ μ₯ - ${isPendingCompletion ? ` - - μ΅μ’ νμΈ - - ` : ''} + + μ΅μ’ νμΈ + @@ -1884,6 +1922,22 @@ // λͺ¨λ¬μ bodyμ μΆκ° document.body.insertAdjacentHTML('beforeend', modalContent); + + // νμΌ μ ν μ΄λ²€νΈ 리μ€λ μΆκ° + const fileInput = document.getElementById(`edit-completion-photo-${issue.id}`); + const filenameSpan = document.getElementById(`photo-filename-${issue.id}`); + + if (fileInput && filenameSpan) { + fileInput.addEventListener('change', function(e) { + if (e.target.files && e.target.files[0]) { + filenameSpan.textContent = e.target.files[0].name; + filenameSpan.className = 'text-sm text-green-600 font-medium'; + } else { + filenameSpan.textContent = ''; + filenameSpan.className = 'text-sm text-gray-600'; + } + }); + } } // μ΄μ μμ λͺ¨λ¬ λ«κΈ° @@ -1894,14 +1948,62 @@ } } + // νμΌμ Base64λ‘ λ³ννλ ν¨μ + function fileToBase64(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => resolve(reader.result); + reader.onerror = error => reject(error); + }); + } + // λͺ¨λ¬μμ μ΄μ μ μ₯ async function saveIssueFromModal(issueId) { const title = document.getElementById(`edit-issue-title-${issueId}`).value.trim(); const detail = document.getElementById(`edit-issue-detail-${issueId}`).value.trim(); + const category = document.getElementById(`edit-category-${issueId}`).value; const solution = document.getElementById(`edit-solution-${issueId}`).value.trim(); const department = document.getElementById(`edit-department-${issueId}`).value; const person = document.getElementById(`edit-person-${issueId}`).value.trim(); const date = document.getElementById(`edit-date-${issueId}`).value; + const causeDepartment = document.getElementById(`edit-cause-department-${issueId}`).value; + const managementComment = document.getElementById(`edit-management-comment-${issueId}`).value.trim(); + + // μλ£ μ μ² μ 보 (μλ£ λκΈ° μνμΌ λλ§) + const completionCommentElement = document.getElementById(`edit-completion-comment-${issueId}`); + const completionPhotoElement = document.getElementById(`edit-completion-photo-${issueId}`); + + let completionComment = null; + let completionPhoto = null; + + if (completionCommentElement) { + completionComment = completionCommentElement.value.trim(); + } + + if (completionPhotoElement && completionPhotoElement.files[0]) { + try { + const file = completionPhotoElement.files[0]; + console.log('π μ λ‘λν νμΌ μ 보:', { + name: file.name, + size: file.size, + type: file.type, + lastModified: file.lastModified + }); + + const base64 = await fileToBase64(file); + console.log('π Base64 λ³ν μλ£ - μ 체 κΈΈμ΄:', base64.length); + console.log('π Base64 ν€λ:', base64.substring(0, 50)); + + completionPhoto = base64.split(',')[1]; // Base64 λ°μ΄ν°λ§ μΆμΆ + console.log('π ν€λ μ κ±° ν κΈΈμ΄:', completionPhoto.length); + console.log('π μ μ‘ν Base64 μμ λΆλΆ:', completionPhoto.substring(0, 50)); + } catch (error) { + console.error('νμΌ λ³ν μ€λ₯:', error); + alert('μλ£ μ¬μ§ μ λ‘λ μ€ μ€λ₯κ° λ°μνμ΅λλ€.'); + return; + } + } if (!title) { alert('λΆμ ν©λͺ μ μ λ ₯ν΄μ£ΌμΈμ.'); @@ -1909,6 +2011,25 @@ } const combinedDescription = title + (detail ? '\n' + detail : ''); + + const requestBody = { + final_description: combinedDescription, + final_category: category, + solution: solution || null, + responsible_department: department || null, + responsible_person: person || null, + expected_completion_date: date || null, + cause_department: causeDepartment || null, + management_comment: managementComment || null + }; + + // μλ£ μ μ² μ λ³΄κ° μμΌλ©΄ μΆκ° + if (completionComment !== null) { + requestBody.completion_comment = completionComment || null; + } + if (completionPhoto !== null) { + requestBody.completion_photo = completionPhoto; + } try { const response = await fetch(`/api/management/${issueId}`, { @@ -1917,19 +2038,59 @@ 'Authorization': `Bearer ${localStorage.getItem('access_token')}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ - final_description: combinedDescription, - solution: solution || null, - responsible_department: department || null, - responsible_person: person || null, - expected_completion_date: date || null - }) + body: JSON.stringify(requestBody) }); if (response.ok) { - alert('μ΄μκ° μ±κ³΅μ μΌλ‘ μ μ₯λμμ΅λλ€.'); - closeIssueEditModal(); - loadManagementData(); // νμ΄μ§ μλ‘κ³ μΉ¨ + // μ μ₯ μ±κ³΅ ν λ°μ΄ν° μλ‘κ³ μΉ¨νκ³ λͺ¨λ¬μ μ μ§ + await initializeManagement(); // νμ΄μ§ μλ‘κ³ μΉ¨ + + // μ μ₯λ μ΄μ μ 보 λ€μ λ‘λνμ¬ λͺ¨λ¬ μ λ°μ΄νΈ + const updatedIssue = issues.find(i => i.id === issueId); + if (updatedIssue) { + // μλ£ μ¬μ§μ΄ μ μ₯λμλμ§ νμΈ + if (updatedIssue.completion_photo_path) { + alert('β μλ£ μ¬μ§μ΄ μ±κ³΅μ μΌλ‘ μ μ₯λμμ΅λλ€!'); + } else { + alert('β οΈ μ μ₯μ μλ£λμμ§λ§ μλ£ μ¬μ§ μ μ₯μ μ€ν¨νμ΅λλ€. λ€μ μλν΄μ£ΌμΈμ.'); + } + + // λͺ¨λ¬ λ΄μ© μ λ°μ΄νΈ (μλ£ μ¬μ§ νμ κ°±μ ) + const photoContainer = document.querySelector(`#issueEditModal img[alt*="μλ£ μ¬μ§"]`)?.parentElement; + if (photoContainer && updatedIssue.completion_photo_path) { + // HEIC νμΌμΈμ§ νμΈ + const isHeic = updatedIssue.completion_photo_path.toLowerCase().endsWith('.heic'); + + if (isHeic) { + // HEIC νμΌμ λ€μ΄λ‘λ λ§ν¬λ‘ νμ + photoContainer.innerHTML = ` + + + + + + μλ£ μ¬μ§ (HEIC) + λ€μ΄λ‘λνμ¬ νμΈ + + + `; + } else { + // μΌλ° μ΄λ―Έμ§λ 미리보기 νμ + photoContainer.innerHTML = ` + + + + νμ¬ μλ£ μ¬μ§ + ν΄λ¦νλ©΄ ν¬κ² λ³Ό μ μμ΅λλ€ + + + `; + } + } + } else { + alert('μ΄μκ° μ±κ³΅μ μΌλ‘ μ μ₯λμμ΅λλ€.'); + closeIssueEditModal(); + } } else { const error = await response.json(); alert(`μ μ₯ μ€ν¨: ${error.detail || 'μ μ μλ μ€λ₯'}`); @@ -1958,8 +2119,8 @@ } function confirmCompletion(issueId) { - // λͺ¨λ μ 보 νμΈ λͺ¨λ¬ μ΄κΈ° - openCompletionConfirmModal(issueId); + // μλ£ νμΈ λͺ¨λ¬ μ΄κΈ° (μμ κ°λ₯) - ν΅ν© λͺ¨λ¬ μ¬μ© + openIssueEditModal(issueId, true); } // μλ£ μ μ² μ΄κΈ°ν (μμ λͺ¨λλ‘ μ ν) @@ -1975,7 +2136,7 @@ if (response.ok) { alert('μλ£ λκΈ° μνκ° ν΄μ λμμ΅λλ€. μμ μ΄ κ°λ₯ν©λλ€.'); - loadManagementData(); // νμ΄μ§ μλ‘κ³ μΉ¨ + initializeManagement(); // νμ΄μ§ μλ‘κ³ μΉ¨ } else { const error = await response.json(); alert(`μν λ³κ²½ μ€ν¨: ${error.detail || 'μ μ μλ μ€λ₯'}`); @@ -2002,7 +2163,7 @@ if (response.ok) { alert('μλ£ μ μ²μ΄ λ°λ €λμμ΅λλ€.'); - loadManagementData(); // νμ΄μ§ μλ‘κ³ μΉ¨ + initializeManagement(); // νμ΄μ§ μλ‘κ³ μΉ¨ } else { const error = await response.json(); alert(`λ°λ € μ²λ¦¬ μ€ν¨: ${error.detail || 'μ μ μλ μ€λ₯'}`); @@ -2121,7 +2282,110 @@ } } - // μ΅μ’ μλ£ νμΈ + // μ μ₯ ν μλ£ μ²λ¦¬ (μ΅μ’ νμΈ) + async function saveAndCompleteIssue(issueId) { + if (!confirm('μμ λ΄μ©μ μ μ₯νκ³ μ΄ λΆμ ν©μ μ΅μ’ μλ£ μ²λ¦¬νμκ² μ΅λκΉ?\nμλ£ μ²λ¦¬ νμλ μμ ν μ μμ΅λλ€.')) { + return; + } + + const title = document.getElementById(`edit-issue-title-${issueId}`).value.trim(); + const detail = document.getElementById(`edit-issue-detail-${issueId}`).value.trim(); + const category = document.getElementById(`edit-category-${issueId}`).value; + const solution = document.getElementById(`edit-solution-${issueId}`).value.trim(); + const department = document.getElementById(`edit-department-${issueId}`).value; + const person = document.getElementById(`edit-person-${issueId}`).value.trim(); + const date = document.getElementById(`edit-date-${issueId}`).value; + const causeDepartment = document.getElementById(`edit-cause-department-${issueId}`).value; + const managementComment = document.getElementById(`edit-management-comment-${issueId}`).value.trim(); + + // μλ£ μ μ² μ 보 (μλ£ λκΈ° μνμΌ λλ§) + const completionCommentElement = document.getElementById(`edit-completion-comment-${issueId}`); + const completionPhotoElement = document.getElementById(`edit-completion-photo-${issueId}`); + + let completionComment = null; + let completionPhoto = null; + + if (completionCommentElement) { + completionComment = completionCommentElement.value.trim(); + } + + if (completionPhotoElement && completionPhotoElement.files[0]) { + try { + const file = completionPhotoElement.files[0]; + console.log('π μ λ‘λν νμΌ μ 보:', { + name: file.name, + size: file.size, + type: file.type, + lastModified: file.lastModified + }); + + const base64 = await fileToBase64(file); + console.log('π Base64 λ³ν μλ£ - μ 체 κΈΈμ΄:', base64.length); + console.log('π Base64 ν€λ:', base64.substring(0, 50)); + + completionPhoto = base64.split(',')[1]; // Base64 λ°μ΄ν°λ§ μΆμΆ + console.log('π ν€λ μ κ±° ν κΈΈμ΄:', completionPhoto.length); + console.log('π μ μ‘ν Base64 μμ λΆλΆ:', completionPhoto.substring(0, 50)); + } catch (error) { + console.error('νμΌ λ³ν μ€λ₯:', error); + alert('μλ£ μ¬μ§ μ λ‘λ μ€ μ€λ₯κ° λ°μνμ΅λλ€.'); + return; + } + } + + if (!title) { + alert('λΆμ ν©λͺ μ μ λ ₯ν΄μ£ΌμΈμ.'); + return; + } + + const combinedDescription = title + (detail ? '\n' + detail : ''); + + const requestBody = { + final_description: combinedDescription, + final_category: category, + solution: solution || null, + responsible_department: department || null, + responsible_person: person || null, + expected_completion_date: date || null, + cause_department: causeDepartment || null, + management_comment: managementComment || null, + review_status: 'completed' // μλ£ μνλ‘ λ³κ²½ + }; + + // μλ£ μ μ² μ λ³΄κ° μμΌλ©΄ μΆκ° + if (completionComment !== null) { + requestBody.completion_comment = completionComment || null; + } + if (completionPhoto !== null) { + requestBody.completion_photo = completionPhoto; + } + + try { + // 1. λ¨Όμ μμ λ΄μ© μ μ₯ + const saveResponse = await fetch(`/api/management/${issueId}`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('access_token')}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(requestBody) + }); + + if (saveResponse.ok) { + alert('λΆμ ν©μ΄ μμ λκ³ μ΅μ’ μλ£ μ²λ¦¬λμμ΅λλ€.'); + closeIssueEditModal(); + initializeManagement(); // νμ΄μ§ μλ‘κ³ μΉ¨ + } else { + const error = await saveResponse.json(); + alert(`μ μ₯ λ° μλ£ μ²λ¦¬ μ€ν¨: ${error.detail || 'μ μ μλ μ€λ₯'}`); + } + } catch (error) { + console.error('μ μ₯ λ° μλ£ μ²λ¦¬ μ€λ₯:', error); + alert('μ μ₯ λ° μλ£ μ²λ¦¬ μ€ μ€λ₯κ° λ°μνμ΅λλ€.'); + } + } + + // μ΅μ’ μλ£ νμΈ (κΈ°μ‘΄ ν¨μ - νμμ μ¬μ©) async function finalConfirmCompletion(issueId) { if (!confirm('μ΄ λΆμ ν©μ μ΅μ’ μλ£ μ²λ¦¬νμκ² μ΅λκΉ?\nμλ£ μ²λ¦¬ νμλ μμ ν μ μμ΅λλ€.')) { return; @@ -2138,8 +2402,8 @@ if (response.ok) { alert('λΆμ ν©μ΄ μ΅μ’ μλ£ μ²λ¦¬λμμ΅λλ€.'); - closeCompletionConfirmModal(); - loadManagementData(); // νμ΄μ§ μλ‘κ³ μΉ¨ + closeIssueEditModal(); + initializeManagement(); // νμ΄μ§ μλ‘κ³ μΉ¨ } else { const error = await response.json(); alert(`μλ£ μ²λ¦¬ μ€ν¨: ${error.detail || 'μ μ μλ μ€λ₯'}`);
νμ¬ μλ£ μ¬μ§
ν΄λ¦νλ©΄ ν¬κ² λ³Ό μ μμ΅λλ€
μλ£ μ¬μ§ μμ
${issue.completion_comment || 'μ½λ©νΈ μμ'}
μ¬μ§ μμ
${new Date(issue.completion_requested_at).toLocaleString('ko-KR')}
μλ£ μ¬μ§ (HEIC)