From b98affbf99b996cea9314b1c8c16da2abb73c1dd Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 10 Jul 2026 19:01:33 +0900 Subject: [PATCH] 260710_1 --- .../CONFUSION_0OLD_vs_CURRENT.md | 0 .../plan_wf1_and_upload_improvements.md | 0 .../validation_wf1_and_upload.md | 0 .agent/db_schema.md | 1035 +++++++++-------- .agent/plan_workflow_state_management.md | 207 ++++ .claude/settings.json | 5 +- B01_Dashboard/B01_Dashboard_Api_Fetch.ts | 24 + B01_Dashboard/B01_Dashboard_Repository.py | 91 +- B01_Dashboard/B01_Dashboard_UI_Page.ts | 29 +- .../B02_ProjRegister_Repository.py | 4 + B03_FileInput/B03_FileInput_Router.py | 43 +- B04_wf1_Surface/B04_wf1_Surface_Engine.py | 15 + B04_wf1_Surface/B04_wf1_Surface_Repository.py | 4 +- B04_wf1_Surface/B04_wf1_Surface_Router.py | 161 ++- B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts | 184 +-- B05_wf2_Route/B05_wf2_Route_Router.py | 32 +- .../B06_wf3_ProfileCross_Router.py | 38 +- common_util/common_util_workflow_state.py | 210 ++++ db_management/006_workflow_state.sql | 23 + db_management/migrate_workflow_state.py | 158 +++ ui_template/ui_template_workflow_layout.css | 100 +- ui_template/ui_template_workflow_layout.ts | 70 +- 22 files changed, 1681 insertions(+), 752 deletions(-) rename .agent/{ => complete_and_old}/CONFUSION_0OLD_vs_CURRENT.md (100%) rename .agent/{ => complete_and_old}/plan_wf1_and_upload_improvements.md (100%) rename .agent/{ => complete_and_old}/validation_wf1_and_upload.md (100%) create mode 100644 .agent/plan_workflow_state_management.md create mode 100644 common_util/common_util_workflow_state.py create mode 100644 db_management/006_workflow_state.sql create mode 100644 db_management/migrate_workflow_state.py diff --git a/.agent/CONFUSION_0OLD_vs_CURRENT.md b/.agent/complete_and_old/CONFUSION_0OLD_vs_CURRENT.md similarity index 100% rename from .agent/CONFUSION_0OLD_vs_CURRENT.md rename to .agent/complete_and_old/CONFUSION_0OLD_vs_CURRENT.md diff --git a/.agent/plan_wf1_and_upload_improvements.md b/.agent/complete_and_old/plan_wf1_and_upload_improvements.md similarity index 100% rename from .agent/plan_wf1_and_upload_improvements.md rename to .agent/complete_and_old/plan_wf1_and_upload_improvements.md diff --git a/.agent/validation_wf1_and_upload.md b/.agent/complete_and_old/validation_wf1_and_upload.md similarity index 100% rename from .agent/validation_wf1_and_upload.md rename to .agent/complete_and_old/validation_wf1_and_upload.md diff --git a/.agent/db_schema.md b/.agent/db_schema.md index 3d6e686..5c6cace 100644 --- a/.agent/db_schema.md +++ b/.agent/db_schema.md @@ -1,23 +1,33 @@ # DB 스키마 (테이블 및 열 목록) +**최종 업데이트:** 2026-07-10 +**DB 명:** `aislo_db` (MariaDB v10.6+, utf8mb4_unicode_ci) +**총 테이블 수:** 34개 (33개 현재 운영 + 1개 향후 사용) + +--- + ## 1. 사용자 & 인증 & 조직 그룹 ### 1-1. users 테이블 ``` users ├── id (INT, PK) — 사용자 고유 번호 -├── email (TEXT, UNIQUE) — 로그인 이메일 -├── password_hash (TEXT) — 비밀번호 암호화 저장 -├── name (TEXT) — 사용자 이름 -├── position (TEXT) — 직급 (예: 과장, 대리, 사원) -├── department (TEXT) — 부서명 (예: 설계팀, 영업팀) -├── phone (TEXT) — 연락처 (예: 010-1234-5678) +├── email (VARCHAR(255), UNIQUE) — 로그인 이메일 +├── password_hash (VARCHAR(255)) — 비밀번호 암호화 저장 +├── name (VARCHAR(255)) — 사용자 이름 +├── position (VARCHAR(100), NULL) — 직급 (예: 과장, 대리, 사원) +├── department (VARCHAR(100), NULL) — 부서명 (예: 설계팀, 영업팀) +├── phone (VARCHAR(20), NULL) — 연락처 (예: 010-1234-5678) ├── company_id (INT, FK → companies.id, NULL) — 소속 회사 ├── role (ENUM) — 역할: SYSTEM_ADMIN, ADMIN, USER (기본값: USER) -├── is_master (BOOLEAN) — 회사 마스터 여부 (기본값: FALSE) -├── status (ENUM) — 상태: PENDING_EMAIL, NO_COMPANY, PENDING, ACTIVE, INACTIVE, REJECTED +├── is_master (TINYINT(1)) — 회사 마스터 여부 (기본값: 0) +├── status (ENUM) — 상태: PENDING_EMAIL, NO_COMPANY, PENDING, ACTIVE, INACTIVE, REJECTED (기본값: PENDING_EMAIL) ├── last_login (DATETIME, NULL) — 마지막 로그인 시간 ├── auth_expires_at (DATETIME, NULL) — 인증 유효 만료 시간 (3개월 주기) +├── last_email_verified_at (TIMESTAMP, NULL) — 마지막 이메일 인증 시간 +├── login_failures (INT) — 연속 로그인 실패 횟수 (기본값: 0, 보안) +├── last_failed_at (TIMESTAMP, NULL) — 마지막 로그인 실패 시간 +├── account_locked_until (TIMESTAMP, NULL) — 계정 잠금 해제 시간 ├── created_at (TIMESTAMP) — 가입일 ├── updated_at (TIMESTAMP) — 수정일 └── deleted_at (TIMESTAMP, NULL) — 삭제일 (soft delete) @@ -35,18 +45,24 @@ users ``` companies ├── id (INT, PK) — 회사 고유 번호 -├── name (TEXT, UNIQUE) — 회사명 -├── business_registration_number (TEXT, UNIQUE) — 사업자등록번호 -├── business_address (TEXT) — 사업장 주소 -├── business_owner (TEXT) — 사업주 이름 -├── business_status (TEXT, ENUM) — 기업 상태: 활동중, 폐업, 휴업 (기본값: 활동중) -├── created_by (INT, FK → users.id) — 회사 생성자 (첫 ADMIN) +├── name (VARCHAR(255), UNIQUE) — 회사명 +├── business_registration_number (VARCHAR(20), UNIQUE, NULL) — 사업자등록번호 +├── business_address (VARCHAR(255), NULL) — 사업장 주소 +├── business_owner (VARCHAR(100), NULL) — 사업주 이름 +├── business_status (VARCHAR(50), NULL) — 기업 상태 +├── company_code (VARCHAR(20), NULL) — 회사 코드 +├── phone_number (VARCHAR(20), NULL) — 회사 연락처 +├── representative_name (VARCHAR(100), NULL) — 대표자명 +├── master_user_id (INT, NULL) — 마스터 사용자 ID +├── status (ENUM) — 상태: ACTIVE, INACTIVE, SUSPENDED (기본값: ACTIVE) +├── subscription_status (ENUM) — 구독 상태: FREE, TRIAL, PAID, EXPIRED (기본값: FREE) +├── created_by (INT, FK → users.id, NULL) — 회사 생성자 ├── created_at (TIMESTAMP) — 생성일 ├── updated_at (TIMESTAMP) — 수정일 └── deleted_at (TIMESTAMP, NULL) — 삭제일 (soft delete) ``` -### 1-3. join_requests 테이블 (신규) +### 1-3. join_requests 테이블 (가입 신청 - 향후 사용) ``` join_requests ├── id (INT, PK) — 요청 고유 번호 @@ -56,104 +72,157 @@ join_requests ├── status (ENUM) — 상태: PENDING, APPROVED, REJECTED (기본값: PENDING) ├── reviewed_by (INT, FK → users.id, NULL) — 검토한 관리자 ├── reviewed_at (TIMESTAMP, NULL) — 검토 시간 -├── review_comment (TEXT, NULL) — 검토 의견/거부 사유 (신규) +├── review_comment (TEXT, NULL) — 검토 의견/거부 사유 └── UNIQUE KEY (user_id, company_id) — 중복 신청 방지 ``` -**동작:** +**동작 (향후 구현 예정):** - 사용자 가입 신청 → status = PENDING 생성 - 관리자 승인 → status = APPROVED, users.status = ACTIVE, users.company_id 세팅 - 관리자 거부 → status = REJECTED, review_comment에 거부 사유 기록, 이메일 발송 -### 1-4. system_audit_logs 테이블 (신규) +### 1-5. email_otps 테이블 (이메일 인증) +``` +email_otps +├── id (INT, PK) — OTP 고유 번호 +├── email (VARCHAR(255)) — 이메일 주소 +├── otp_code (VARCHAR(10)) — OTP 코드 +├── purpose (VARCHAR(50)) — 목적: REGISTRATION, PASSWORD_RESET, EMAIL_VERIFICATION +├── expires_at (TIMESTAMP) — 만료 시간 +├── verified_at (TIMESTAMP, NULL) — 인증 시간 +├── created_at (TIMESTAMP) — 생성일 +└── deleted_at (TIMESTAMP, NULL) — 삭제일 (soft delete) +``` + +### 1-6. sessions 테이블 (세션 관리) +``` +sessions +├── id (INT, PK) — 세션 고유 번호 +├── user_id (INT, FK → users.id) — 사용자 ID +├── session_token (VARCHAR(255), UNIQUE) — 세션 토큰 +├── expires_at (TIMESTAMP) — 세션 만료 시간 +├── created_at (TIMESTAMP) — 생성일 +├── last_accessed_at (TIMESTAMP, NULL) — 마지막 접근 시간 +└── deleted_at (TIMESTAMP, NULL) — 삭제일 (soft delete) +``` + +### 1-7. trusted_devices 테이블 (장치 신뢰 관리) +``` +trusted_devices +├── id (INT, PK) — 장치 고유 번호 +├── user_id (INT, FK → users.id) — 사용자 ID +├── device_fingerprint (VARCHAR(255)) — 장치 지문 +├── device_name (VARCHAR(100), NULL) — 장치명 +├── ip_address (VARCHAR(50)) — IP 주소 +├── user_agent (TEXT) — 브라우저/OS 정보 +├── trusted_until (TIMESTAMP, NULL) — 신뢰 만료 시간 +├── created_at (TIMESTAMP) — 생성일 +└── deleted_at (TIMESTAMP, NULL) — 삭제일 (soft delete) +``` + +### 1-8. user_consents 테이블 (사용자 동의 관리) +``` +user_consents +├── id (INT, PK) — 동의 고유 번호 +├── user_id (INT, FK → users.id) — 사용자 ID +├── consent_type (VARCHAR(100)) — 동의 종류: TERMS, PRIVACY, MARKETING, etc +├── consented (TINYINT(1)) — 동의 여부 (1 = 동의, 0 = 미동의) +├── consent_version (VARCHAR(20)) — 약관 버전 +├── created_at (TIMESTAMP) — 생성일 +├── updated_at (TIMESTAMP) — 수정일 +└── deleted_at (TIMESTAMP, NULL) — 삭제일 (soft delete) +``` + +### 1-9. login_logs 테이블 (로그인 로그) +``` +login_logs +├── id (INT, PK) — 로그 고유 번호 +├── user_id (INT, FK → users.id, NULL) — 사용자 ID +├── email (VARCHAR(255), NULL) — 로그인 시도 이메일 +├── status (VARCHAR(50)) — 상태: SUCCESS, FAILURE, LOCKED +├── ip_address (VARCHAR(50), NULL) — IP 주소 +├── user_agent (TEXT, NULL) — 브라우저/OS 정보 +├── reason (TEXT, NULL) — 실패 사유 +├── created_at (TIMESTAMP) — 기록 시간 +└── deleted_at (TIMESTAMP, NULL) — 삭제일 (soft delete) +``` + +### 1-10. activity_logs 테이블 (활동 로그) +``` +activity_logs +├── id (INT, PK) — 로그 고유 번호 +├── user_id (INT, FK → users.id, NULL) — 사용자 ID +├── action (VARCHAR(100)) — 행동: LOGIN, LOGOUT, FILE_UPLOAD, PROJECT_CREATE, etc +├── entity_type (VARCHAR(100), NULL) — 대상 타입: project, file, route, etc +├── entity_id (VARCHAR(50), NULL) — 대상 ID +├── description (TEXT, NULL) — 행동 설명 +├── ip_address (VARCHAR(50), NULL) — IP 주소 +├── created_at (TIMESTAMP) — 기록 시간 +└── deleted_at (TIMESTAMP, NULL) — 삭제일 (soft delete) +``` + +### 1-11. audit_logs 테이블 (감시 로그 - 보안) +``` +audit_logs +├── id (INT, PK) — 로그 고유 번호 +├── user_id (INT, FK → users.id) — 행동 수행자 +├── action (VARCHAR(100)) — 행동: CREATE, READ, UPDATE, DELETE, EXPORT, DOWNLOAD +├── entity_type (VARCHAR(100)) — 대상 타입: projects, routes, structures, outputs +├── entity_id (VARCHAR(50)) — 대상 ID +├── details (LONGTEXT, NULL) — 상세 정보 (JSON 형식) +├── ip_address (VARCHAR(50), NULL) — IP 주소 +├── timestamp (TIMESTAMP) — 기록 시간 +└── deleted_at (TIMESTAMP, NULL) — 삭제일 (soft delete) +``` + +### 1-12. system_audit_logs 테이블 (시스템 감시) ``` system_audit_logs ├── id (INT, PK) — 로그 고유 번호 ├── user_id (INT, FK → users.id) — 행동 수행자 -├── action (VARCHAR(50)) — 행동: LOGIN, LOGOUT, USER_CREATE, USER_UPDATE, COMPANY_CREATE, ROLE_CHANGE, etc +├── action (VARCHAR(50)) — 행동: LOGIN, LOGOUT, USER_CREATE, COMPANY_CREATE, ROLE_CHANGE ├── resource_type (VARCHAR(50)) — 대상 타입: user, company, project, join_request ├── resource_id (INT, NULL) — 대상 ID -├── timestamp (TIMESTAMP, DEFAULT CURRENT_TIMESTAMP) — 기록 시간 -├── ip_address (VARCHAR(50), NULL) — IP 주소 (선택사항) -├── user_agent (TEXT, NULL) — 브라우저/OS 정보 (선택사항) -├── INDEX (timestamp) — 조회 성능 최적화 -└── INDEX (user_id) — 사용자별 조회 최적화 +├── ip_address (VARCHAR(50), NULL) — IP 주소 +├── user_agent (TEXT, NULL) — 브라우저/OS 정보 +├── timestamp (TIMESTAMP) — 기록 시간 +└── INDEX (timestamp, user_id) — 성능 최적화 ``` -### 1-5. system_resources 테이블 (신규) +### 1-13. system_resources 테이블 (시스템 리소스 모니터링) ``` system_resources ├── id (INT, PK) — 기록 고유 번호 -├── timestamp (TIMESTAMP, DEFAULT CURRENT_TIMESTAMP) — 기록 시간 -├── cpu_usage_percent (FLOAT) — CPU 사용률 (%) -├── memory_usage_percent (FLOAT) — 메모리 사용률 (%) -├── disk_usage_percent (FLOAT) — 디스크 사용률 (%) -├── active_user_count (INT) — 활성 사용자 수 -├── active_project_count (INT) — 활성 프로젝트 수 -├── total_storage_mb (FLOAT) — 전체 저장소 사용량 (MB) +├── cpu_usage_percent (FLOAT, NULL) — CPU 사용률 (%) +├── memory_usage_percent (FLOAT, NULL) — 메모리 사용률 (%) +├── disk_usage_percent (FLOAT, NULL) — 디스크 사용률 (%) +├── active_user_count (INT, NULL) — 활성 사용자 수 +├── active_project_count (INT, NULL) — 활성 프로젝트 수 +├── total_storage_mb (FLOAT, NULL) — 전체 저장소 사용량 (MB) +├── timestamp (TIMESTAMP) — 기록 시간 └── INDEX (timestamp) — 시계열 조회 최적화 ``` -**리소스 측정 방식 (B01_Dashboard):** -- **현재값 실측:** `psutil.cpu_percent()`, `psutil.virtual_memory().percent`, `shutil.disk_usage()`로 요청 시점 실시간 측정 (DB 저장 없이 응답에 포함) -- **이력 조회:** `system_resources` 테이블에서 기본 30일치 시계열 조회 (`GET /api/dashboard/admin/resources?days=30`, 범위 1~90일) +**리소스 측정 방식:** +- **현재값 실측:** 요청 시점 실시간 측정 (DB 저장 없이 응답에 포함) +- **이력 조회:** `system_resources` 테이블에서 기본 30일치 시계열 조회 - **활성 지표:** active_user_count는 유효 세션 수, active_project_count는 미삭제 프로젝트 수 실시간 집계 ---- - -## 테이블 관계도 (조직 & 인증 그룹) - +### 1-14. support_requests 테이블 (고객 지원) ``` -users (사용자) -├── id (PK) -├── email -├── password_hash -├── role (SYSTEM_ADMIN | ADMIN | USER) -├── is_master (마스터 여부) -├── status (생명주기) -├── company_id (FK) ──→ companies.id -├── last_login -└── auth_expires_at - -companies (회사) -├── id (PK) -├── name -├── business_registration_number -├── business_address -├── business_owner -├── business_status -├── created_by (FK) ──→ users.id -├── created_at -└── updated_at - -join_requests (가입 신청) -├── id (PK) -├── user_id (FK) ──→ users.id -├── company_id (FK) ──→ companies.id -├── requested_at -├── status (PENDING | APPROVED | REJECTED) -├── reviewed_by (FK) ──→ users.id -└── reviewed_at - -system_audit_logs (시스템 로그) -├── id (PK) -├── user_id (FK) ──→ users.id -├── action -├── resource_type -├── resource_id -├── timestamp -├── ip_address -└── user_agent - -system_resources (리소스 모니터링) -├── id (PK) -├── timestamp -├── cpu_usage_percent -├── memory_usage_percent -├── disk_usage_percent -├── active_user_count -├── active_project_count -└── total_storage_mb +support_requests +├── id (INT, PK) — 요청 고유 번호 +├── user_id (INT, FK → users.id) — 신청자 +├── title (VARCHAR(255)) — 제목 +├── description (LONGTEXT) — 설명 +├── category (VARCHAR(100), NULL) — 카테고리 +├── priority (VARCHAR(50), NULL) — 우선순위: LOW, MEDIUM, HIGH, URGENT +├── status (VARCHAR(50)) — 상태: OPEN, IN_PROGRESS, RESOLVED, CLOSED +├── assigned_to (INT, FK → users.id, NULL) — 담당자 +├── resolution (LONGTEXT, NULL) — 해결 내용 +├── created_at (TIMESTAMP) — 생성일 +├── updated_at (TIMESTAMP) — 수정일 +└── deleted_at (TIMESTAMP, NULL) — 삭제일 (soft delete) ``` --- @@ -163,385 +232,423 @@ system_resources (리소스 모니터링) ### 2-1. projects 테이블 ``` projects -├── id (UUID, PK) — 프로젝트 고유 ID (예: 550e8400-e29b-41d4-a716-446655440000) +├── id (CHAR(36), PK) — 프로젝트 고유 ID (UUID) ├── user_id (INT, FK → users.id) — 프로젝트 소유자 ├── company_id (INT, FK → companies.id) — 소속 회사 -├── name (TEXT) — 프로젝트명 (예: "2025년 산불진화임도") -├── region (TEXT) — 지역명 (예: "울진군 금강송면") -├── road_type (TEXT) — 임도 종류: 간선임도, 지선임도, 산불진화임도, 계류보전 -├── project_year (INT) — 사업 연도 (예: 2025) -├── estimated_length_m (FLOAT) — 추정 연장 (미터) -├── memo (TEXT) — 비고/메모 -├── status (TEXT) — 상태: NEW, FILE_UPLOADED, WF1_ANALYZING, WF1_COMPLETE, WF2_COMPLETE, ... , CONFIRMED, DONE -├── crs_epsg (INT) — 좌표계 (예: 5178 = 한국 표준) -├── bbox (GEOMETRY) — 프로젝트 전체 범위 (다각형) +├── name (VARCHAR(255)) — 프로젝트명 (예: "2025년 산불진화임도") +├── region (VARCHAR(100), NULL) — 지역명 (예: "울진군 금강송면") +├── road_type (VARCHAR(100), NULL) — 임도 종류: 간선임도, 지선임도, 산불진화임도, 계류보전 +├── project_year (INT, NULL) — 사업 연도 (예: 2025) +├── estimated_length_m (FLOAT, NULL) — 추정 연장 (미터) +├── memo (TEXT, NULL) — 비고/메모 +├── status (VARCHAR(50), NULL) — 상태: NEW, FILE_UPLOADED, WF1_ANALYZING, WF1_COMPLETE, ... (기본값: NEW) +├── crs_epsg (INT, NULL) — 좌표계 (예: 5178 = 한국 표준, 기본값: 5178) +├── storage_path (VARCHAR(500), NULL) — 파일시스템 경로 (예: "storage/company/user/project_uuid") ├── created_at (TIMESTAMP) — 생성일 ├── updated_at (TIMESTAMP) — 수정일 -├── deleted_at (TIMESTAMP, NULL) — 삭제일 (soft delete) -└── storage_path (TEXT) — 파일시스템 경로 (예: "storage/회사/사용자/project_uuid") +└── deleted_at (TIMESTAMP, NULL) — 삭제일 (soft delete) ``` -### 2-2. project_versions 테이블 (선택사항) +### 2-2. project_versions 테이블 (프로젝트 버전 관리) ``` project_versions -├── id (INT, PK) -├── project_id (UUID, FK → projects.id) +├── id (INT, PK) — 버전 고유 번호 +├── project_id (CHAR(36), FK → projects.id) — 프로젝트 ID ├── version_num (INT) — 버전 번호 (1, 2, 3, ...) +├── status (VARCHAR(50), NULL) — 저장된 상태 +├── data (LONGTEXT, NULL) — 설계 데이터 스냅샷 (JSON 형식) ├── snapshot_at (TIMESTAMP) — 스냅샷 시점 -├── status (TEXT) — 저장된 상태 -├── data (JSONB) — 설계 데이터 스냅샷 (JSON 형식) -└── created_at (TIMESTAMP) +└── created_at (TIMESTAMP) — 생성일 +``` + +### 2-3. project_automations 테이블 (프로젝트 자동화 규칙) +``` +project_automations +├── id (INT, PK) — 자동화 고유 번호 +├── project_id (CHAR(36), FK → projects.id) — 프로젝트 ID +├── name (VARCHAR(100)) — 자동화 규칙명 +├── logic_type (VARCHAR(50)) — 로직 타입: trigger_on_stage_complete, auto_notify, etc +├── config_json (LONGTEXT) — 자동화 설정 (JSON) +├── status (ENUM) — 상태: DRAFT, ACTIVE, INACTIVE (기본값: DRAFT) +├── created_by (INT, FK → users.id) — 생성자 +├── updated_by (INT, FK → users.id, NULL) — 마지막 수정자 +├── last_executed_at (DATETIME, NULL) — 마지막 실행 시간 +├── created_at (TIMESTAMP) — 생성일 +├── updated_at (TIMESTAMP) — 수정일 +└── deleted_at (TIMESTAMP, NULL) — 삭제일 (soft delete) ``` --- -## 3. 파일 & 입력 데이터 그룹 +## 3. 파일 & 업로드 관리 그룹 -### 3-1. input_files 테이블 (원본 입력 파일 — 영구저장소) +### 3-1. upload_sessions 테이블 (파일 업로드 세션) +``` +upload_sessions +├── id (INT, PK) — 업로드 세션 고유 번호 +├── user_id (INT, FK → users.id) — 업로드자 +├── project_id (CHAR(36), FK → projects.id) — 프로젝트 ID +├── session_id (VARCHAR(255), UNIQUE) — 세션 ID +├── file_name (VARCHAR(255)) — 파일명 +├── file_size_mb (FLOAT) — 파일 크기 +├── chunk_count (INT) — 청크 개수 +├── uploaded_chunks (INT) — 업로드된 청크 수 +├── status (VARCHAR(50)) — 상태: PENDING, IN_PROGRESS, COMPLETED, FAILED (기본값: PENDING) +├── started_at (TIMESTAMP) — 시작 시간 +├── completed_at (TIMESTAMP, NULL) — 완료 시간 +└── expires_at (TIMESTAMP) — 세션 만료 시간 +``` + +### 3-2. upload_chunks 테이블 (파일 청크 관리) +``` +upload_chunks +├── id (INT, PK) — 청크 고유 번호 +├── upload_session_id (INT, FK → upload_sessions.id) — 업로드 세션 ID +├── chunk_index (INT) — 청크 순서 +├── chunk_size_mb (FLOAT) — 청크 크기 +├── checksum (VARCHAR(255), NULL) — 체크섬 (무결성 검사) +├── status (VARCHAR(50)) — 상태: PENDING, UPLOADED, VERIFIED (기본값: PENDING) +├── uploaded_at (TIMESTAMP, NULL) — 업로드 시간 +└── deleted_at (TIMESTAMP, NULL) — 삭제일 (soft delete) +``` + +### 3-3. input_files 테이블 (원본 입력 파일) ``` input_files ├── id (INT, PK) — 파일 고유 번호 -├── project_id (UUID, FK → projects.id) — 속한 프로젝트 -├── file_type (TEXT) — 파일 종류: las, tif, tfw, prj, dxf, dwg, other -├── original_filename (TEXT) — 원래 파일명 (예: "cloud_merged.las") -├── raw_file_path (TEXT) — 원본 저장 경로 (예: "storage/.../raw/las/cloud_merged.las") -├── file_size_mb (FLOAT) — 파일 크기 (MB) -├── upload_by (INT, FK → users.id) — 업로드한 사용자 +├── project_id (CHAR(36), FK → projects.id) — 속한 프로젝트 +├── file_type (VARCHAR(50), NULL) — 파일 종류: las, tif, tfw, prj, dxf, dwg, other +├── original_filename (VARCHAR(255)) — 원래 파일명 (예: "cloud_merged.las") +├── raw_file_path (VARCHAR(500)) — 원본 저장 경로 (예: "storage/.../raw/las/cloud_merged.las") +├── file_size_mb (FLOAT, NULL) — 파일 크기 (MB) +├── upload_by (INT, FK → users.id, NULL) — 업로드한 사용자 ├── upload_at (TIMESTAMP) — 업로드 날짜 -├── crs_epsg (INT) — 파일 좌표계 (예: 5178) -├── metadata (JSONB) — 파일 메타데이터 (해상도, 범위, 포인트 수 등) -│ ├── resolution_m (좌표계 기준 해상도) -│ ├── data_range (데이터 범위) -│ └── ... -└── status (TEXT) — 상태: UPLOADED, PROCESSED, ARCHIVED +├── crs_epsg (INT, NULL) — 파일 좌표계 (예: 5178) +├── metadata (LONGTEXT, NULL) — 파일 메타데이터 (JSON) +├── status (VARCHAR(50), NULL) — 상태: UPLOADED, PROCESSED, ARCHIVED (기본값: UPLOADED) +└── deleted_at (TIMESTAMP, NULL) — 삭제일 (soft delete) ``` -### 3-2. processed_point_cloud 테이블 (변환된 포인트클라우드 데이터 — 영구저장소) +--- + +## 4. 지표면 분석 & 처리 그룹 + +### 4-1. processed_point_cloud 테이블 (변환된 포인트클라우드) ``` processed_point_cloud ├── id (INT, PK) — 변환 데이터 고유 번호 ├── input_file_id (INT, FK → input_files.id) — 원본 LAS 파일 참조 -├── project_id (UUID, FK → projects.id) -├── process_type (TEXT) — 변환 방식: filtered, sampled, classified 등 -├── processed_file_path (TEXT) — 변환된 파일 경로 (예: "storage/.../processed/las/ground_filtered.las") -├── converted_format (TEXT) — 변환 포맷: las, ply, laz 등 (컴퓨터가 빠르게 읽기 위해) -├── converted_file_path (TEXT) — 변환 포맷 저장 경로 (예: "storage/.../processed/ply/cloud_sampled_10m.ply") -├── point_count (INT) — 포인트 개수 -├── min_z (FLOAT) — 최저 높이 -├── max_z (FLOAT) — 최고 높이 -├── mean_z (FLOAT) — 평균 높이 -├── x_min (FLOAT) — 최소 경도 -├── x_max (FLOAT) — 최대 경도 -├── y_min (FLOAT) — 최소 위도 -├── y_max (FLOAT) — 최대 위도 -├── density_per_sqm (FLOAT) — 단위 면적당 포인트 밀도 -├── classification_summary (JSONB) — 분류 요약 -│ ├── ground (지표면 포인트) -│ ├── vegetation (식생) -│ ├── building (건물) -│ └── ... -├── processing_params (JSONB) — 변환 파라미터 +├── project_id (CHAR(36), FK → projects.id) — 프로젝트 ID +├── process_type (VARCHAR(50), NULL) — 변환 방식: filtered, sampled, classified +├── processed_file_path (VARCHAR(500), NULL) — 변환된 파일 경로 +├── converted_format (VARCHAR(50), NULL) — 변환 포맷: las, ply, laz 등 +├── converted_file_path (VARCHAR(500), NULL) — 변환 포맷 저장 경로 +├── point_count (INT, NULL) — 포인트 개수 +├── min_z (FLOAT, NULL) — 최저 높이 +├── max_z (FLOAT, NULL) — 최고 높이 +├── mean_z (FLOAT, NULL) — 평균 높이 +├── x_min (FLOAT, NULL) — 최소 경도 +├── x_max (FLOAT, NULL) — 최대 경도 +├── y_min (FLOAT, NULL) — 최소 위도 +├── y_max (FLOAT, NULL) — 최대 위도 +├── density_per_sqm (FLOAT, NULL) — 단위 면적당 포인트 밀도 +├── classification_summary (LONGTEXT, NULL) — 분류 요약 (JSON) +├── processing_params (LONGTEXT, NULL) — 변환 파라미터 (JSON) ├── processed_at (TIMESTAMP) — 변환 완료 날짜 -└── status (TEXT) — 상태: PROCESSING, COMPLETE, FAILED +├── status (VARCHAR(50), NULL) — 상태: PROCESSING, COMPLETE, FAILED (기본값: PROCESSING) +└── deleted_at (TIMESTAMP, NULL) — 삭제일 (soft delete) ``` ---- - -## 4. 지표면 & 분석 결과 그룹 - -### 4-1. surface_models 테이블 (WF1 출력 — 지표면 분석 결과) +### 4-2. surface_models 테이블 (지표면 모델) ``` surface_models ├── id (INT, PK) — 지표면 모델 고유 번호 -├── project_id (UUID, FK → projects.id) -├── model_type (TEXT) — 모델 종류: dem_grid, tin, mesh_triangulated, contour_lines -├── source_file_id (INT, FK → input_files.id) — 원본 LAS 파일 -├── processed_cloud_id (INT, FK → processed_point_cloud.id) — 변환된 포인트클라우드 참조 -├── status (TEXT) — 상태: PROCESSING, COMPLETE, FAILED -├── crs_epsg (INT) — 좌표계 -├── resolution_m (FLOAT) — 해상도 (래스터인 경우, NULL 가능) -├── model_file_path (TEXT) — 모델 저장 경로 (예: "storage/.../processed/surface/dem.tif") -├── bounds (GEOMETRY) — 모델 범위 (다각형) -├── generation_params (JSONB) — 생성 파라미터 -│ ├── filter_type (필터링 방식) -│ ├── algorithm (사용 알고리즘) -│ ├── params (알고리즘 파라미터) -│ └── creation_time (생성 시각) -├── created_at (TIMESTAMP) -└── completed_at (TIMESTAMP) +├── project_id (CHAR(36), FK → projects.id) — 프로젝트 ID +├── model_type (VARCHAR(50), NULL) — 모델 종류: dem_grid, tin, mesh_triangulated, contour_lines +├── source_file_id (INT, FK → input_files.id, NULL) — 원본 LAS 파일 +├── processed_cloud_id (INT, FK → processed_point_cloud.id, NULL) — 변환된 포인트클라우드 참조 +├── status (VARCHAR(50), NULL) — 상태: PROCESSING, COMPLETE, FAILED (기본값: PROCESSING) +├── crs_epsg (INT, NULL) — 좌표계 +├── resolution_m (FLOAT, NULL) — 해상도 (래스터인 경우) +├── model_file_path (VARCHAR(500), NULL) — 모델 저장 경로 (예: "storage/.../surface/dem.tif") +├── generation_params (LONGTEXT, NULL) — 생성 파라미터 (JSON) +├── created_at (TIMESTAMP) — 생성일 +├── completed_at (TIMESTAMP, NULL) — 완료 시간 +└── deleted_at (TIMESTAMP, NULL) — 삭제일 (soft delete) ``` -### 4-2. terrain_layers 테이블 (WF1 산출물 — 각 지형 레이어) +### 4-3. terrain_layers 테이블 (지형 레이어) ``` terrain_layers ├── id (INT, PK) — 레이어 고유 번호 -├── surface_model_id (INT, FK → surface_models.id) -├── layer_name (TEXT) — 레이어명 (예: "지표", "제1층", "제2층" 등) -├── geometry_type (TEXT) — 기하 종류: POINTCLOUD, GRID, MESH, CONTOUR -├── layer_file_path (TEXT) — 레이어 파일 저장 경로 (예: "storage/.../processed/surface/layer_1.geojson") -├── file_format (TEXT) — 파일 형식: geojson, geotiff, ply, obj 등 -├── file_size_mb (FLOAT) — 파일 크기 -├── bounds (GEOMETRY) — 레이어 범위 -├── statistics (JSONB) — 통계 -│ ├── min_z, max_z, mean_slope -│ ├── point_count (포인트 개수) -│ └── ... -└── created_at (TIMESTAMP) +├── surface_model_id (INT, FK → surface_models.id) — 지표면 모델 참조 +├── layer_name (VARCHAR(100), NULL) — 레이어명 (예: "지표", "제1층", "제2층") +├── geometry_type (VARCHAR(50), NULL) — 기하 종류: POINTCLOUD, GRID, MESH, CONTOUR +├── layer_file_path (VARCHAR(500), NULL) — 레이어 파일 저장 경로 +├── file_format (VARCHAR(50), NULL) — 파일 형식: geojson, geotiff, ply, obj 등 +├── file_size_mb (FLOAT, NULL) — 파일 크기 +├── statistics (LONGTEXT, NULL) — 통계 (JSON) +├── created_at (TIMESTAMP) — 생성일 +└── deleted_at (TIMESTAMP, NULL) — 삭제일 (soft delete) ``` --- ## 5. 경로 & 설계 그룹 -### 5-1. routes 테이블 (WF2 출력 — 경로 설계 결과) +### 5-1. routes 테이블 (경로 설계) ``` routes ├── id (INT, PK) — 경로 고유 번호 -├── project_id (UUID, FK → projects.id) -├── surface_model_id (INT, FK → surface_models.id) — 기반 지표면 모델 -├── status (TEXT) — 상태: DRAFT, CONFIRMED, ARCHIVED -├── start_point (GEOMETRY(Point)) — 시작점 좌표 (3D) -├── end_point (GEOMETRY(Point)) — 종료점 좌표 (3D) -├── start_chainage_m (FLOAT) — 시작점 측점 (m) -├── end_chainage_m (FLOAT) — 종료점 측점 (m) -├── total_length_m (FLOAT) — 총 연장 (m) -├── grade_percent (JSONB array) — 각 구간 종단 경사도 (%) -│ └── [2.5, 1.8, 3.2, ...] -├── constraints (JSONB) — 설계 제약조건 -│ ├── max_grade (% 최대경사도) -│ ├── min_radius (m 최소곡선반경) -│ ├── avoidance_zones ([폴리곤 배열]) -│ └── ... -├── algorithm_params (JSONB) — 알고리즘 파라미터 -│ ├── cost_weights (비용함수 가중치) -│ └── ... -├── geometry (GEOMETRY(LineString, Z)) — 3D 경로 좌표 열 -├── route_data_path (TEXT) — 경로 데이터 저장 경로 (예: "storage/.../computed/route/route_main.geojson") -├── computed_at (TIMESTAMP) — 계산 완료 날짜 -└── created_at (TIMESTAMP) +├── project_id (CHAR(36), FK → projects.id) — 프로젝트 ID +├── surface_model_id (INT, FK → surface_models.id, NULL) — 기반 지표면 모델 +├── status (VARCHAR(50), NULL) — 상태: DRAFT, CONFIRMED, ARCHIVED (기본값: DRAFT) +├── start_chainage_m (FLOAT, NULL) — 시작점 측점 (m) +├── end_chainage_m (FLOAT, NULL) — 종료점 측점 (m) +├── total_length_m (FLOAT, NULL) — 총 연장 (m) +├── grade_percent (LONGTEXT, NULL) — 각 구간 종단 경사도 (JSON array) +├── constraints (LONGTEXT, NULL) — 설계 제약조건 (JSON) +├── algorithm_params (LONGTEXT, NULL) — 알고리즘 파라미터 (JSON) +├── route_data_path (VARCHAR(500), NULL) — 경로 데이터 저장 경로 (GeoJSON) +├── computed_at (TIMESTAMP, NULL) — 계산 완료 날짜 +├── created_at (TIMESTAMP) — 생성일 +└── deleted_at (TIMESTAMP, NULL) — 삭제일 (soft delete) ``` -### 5-2. route_points 테이블 (웹 렌더링용 샘플) +**참고:** 경로 좌표는 JSON 형식으로 `route_data_path` 파일에 저장됨 (GeoJSON LineString) + +### 5-2. route_points 테이블 (경로 샘플 포인트) ``` route_points -├── id (INT, PK) -├── route_id (INT, FK → routes.id) -├── chainage_m (FLOAT) — 측점 (m) -├── geometry (GEOMETRY(Point, Z)) — 포인트 좌표 -├── elevation_m (FLOAT) — 높이 -├── slope_percent (FLOAT) — 경사도 (%) -└── sequence_num (INT) — 순서번호 +├── id (INT, PK) — 포인트 고유 번호 +├── route_id (INT, FK → routes.id) — 경로 ID +├── chainage_m (FLOAT, NULL) — 측점 (m) +├── elevation_m (FLOAT, NULL) — 높이 +├── slope_percent (FLOAT, NULL) — 경사도 (%) +├── sequence_num (INT, NULL) — 순서번호 +└── deleted_at (TIMESTAMP, NULL) — 삭제일 (soft delete) ``` -### 5-3. route_statistics 테이블 +### 5-3. route_statistics 테이블 (경로 통계) ``` route_statistics -├── id (INT, PK) -├── route_id (INT, FK → routes.id) -├── min_slope (FLOAT) — 최소 경사도 (%) -├── max_slope (FLOAT) — 최대 경사도 (%) -├── mean_slope (FLOAT) — 평균 경사도 (%) -├── cut_volume_m3 (FLOAT) — 절토량 (m³) -├── fill_volume_m3 (FLOAT) — 성토량 (m³) -├── tree_cutting_volume (FLOAT) — 목재 절감 추정량 -└── cost_score (FLOAT) — 알고리즘 점수 +├── id (INT, PK) — 통계 고유 번호 +├── route_id (INT, FK → routes.id) — 경로 ID +├── min_slope (FLOAT, NULL) — 최소 경사도 (%) +├── max_slope (FLOAT, NULL) — 최대 경사도 (%) +├── mean_slope (FLOAT, NULL) — 평균 경사도 (%) +├── cut_volume_m3 (FLOAT, NULL) — 절토량 (m³) +├── fill_volume_m3 (FLOAT, NULL) — 성토량 (m³) +├── tree_cutting_volume (FLOAT, NULL) — 목재 절감 추정량 +└── cost_score (FLOAT, NULL) — 알고리즘 점수 ``` --- ## 6. 종단면 & 횡단면 그룹 -### 6-1. longitudinal_sections 테이블 (WF3 출력 — 종단면) +### 6-1. longitudinal_sections 테이블 (종단면) ``` longitudinal_sections ├── id (INT, PK) — 종단면 고유 번호 -├── project_id (UUID, FK → projects.id) -├── route_id (INT, FK → routes.id) -├── computed_at (TIMESTAMP) — 계산 날짜 -├── geometry (GEOMETRY(LineString, Z)) — 종단면 지형 좌표열 -├── data (JSONB) — 상세 데이터 -│ ├── chainages ([0, 20, 40, 60, ...]) — 측점 배열 (m) -│ ├── elevations ([100.5, 102.3, 101.8, ...]) — 지표 표고 배열 -│ ├── grades ([2.5, 1.8, 1.0, ...]) — 경사도 배열 (%) -│ └── design_elevations ([100.0, 102.0, 101.5, ...]) — 설계 표고 -├── longitudinal_file_path (TEXT) — 저장 경로 (예: "storage/.../computed/sections/longitudinal.json") -└── status (TEXT) — 상태: DRAFT, CONFIRMED +├── project_id (CHAR(36), FK → projects.id) — 프로젝트 ID +├── route_id (INT, FK → routes.id) — 경로 ID +├── status (VARCHAR(50), NULL) — 상태: DRAFT, CONFIRMED (기본값: DRAFT) +├── data (LONGTEXT, NULL) — 상세 데이터 (JSON) +│ ├── chainages (측점 배열, m) +│ ├── elevations (지표 표고 배열) +│ ├── grades (경사도 배열, %) +│ └── design_elevations (설계 표고 배열) +├── longitudinal_file_path (VARCHAR(500), NULL) — 저장 경로 (JSON) +├── computed_at (TIMESTAMP, NULL) — 계산 날짜 +└── deleted_at (TIMESTAMP, NULL) — 삭제일 (soft delete) ``` -### 6-2. cross_sections 테이블 (WF3 출력 — 횡단면) +### 6-2. cross_sections 테이블 (횡단면) ``` cross_sections ├── id (INT, PK) — 횡단면 고유 번호 -├── project_id (UUID, FK → projects.id) -├── route_id (INT, FK → routes.id) -├── chainage_m (FLOAT) — 측점 (예: 0, 20, 40, ... m) -├── sequence_num (INT) — 횡단면 순번 (1번째, 2번째, ...) -├── geometry (GEOMETRY(LineString, Z)) — 지형 횡단선 좌표열 -├── geometry_design (GEOMETRY(LineString, Z)) — 설계 횡단선 좌표열 -├── data (JSONB) — 상세 데이터 -│ ├── left_slope (FLOAT, %) — 좌측 사면 경사 -│ ├── right_slope (FLOAT, %) — 우측 사면 경사 -│ ├── width_m (FLOAT) — 노폭 -│ ├── cut_volume_m3 (FLOAT) — 절토량 -│ ├── fill_volume_m3 (FLOAT) — 성토량 -│ ├── structures ([]) — 이 단면 내 구조물 ID 배열 -│ └── notes (TEXT) — 메모 -├── cross_section_file_path (TEXT) — 저장 경로 (예: "storage/.../computed/sections/cross_0020m.json") -└── status (TEXT) — 상태: DRAFT, CONFIRMED +├── project_id (CHAR(36), FK → projects.id) — 프로젝트 ID +├── route_id (INT, FK → routes.id) — 경로 ID +├── chainage_m (FLOAT, NULL) — 측점 (예: 0, 20, 40, ... m) +├── sequence_num (INT, NULL) — 횡단면 순번 (1, 2, ...) +├── status (VARCHAR(50), NULL) — 상태: DRAFT, CONFIRMED (기본값: DRAFT) +├── data (LONGTEXT, NULL) — 상세 데이터 (JSON) +│ ├── left_slope (좌측 사면 경사, %) +│ ├── right_slope (우측 사면 경사, %) +│ ├── width_m (노폭) +│ ├── cut_volume_m3 (절토량) +│ ├── fill_volume_m3 (성토량) +│ ├── structures (이 단면 내 구조물 ID 배열) +│ └── notes (메모) +├── cross_section_file_path (VARCHAR(500), NULL) — 저장 경로 (JSON) +└── deleted_at (TIMESTAMP, NULL) — 삭제일 (soft delete) ``` --- -## 7. 설계 & 구조물 그룹 +## 7. 구조물 & 수량 그룹 -### 7-1. structures 테이블 (WF4 출력 — 구조물) +### 7-1. structures 테이블 (구조물) ``` structures ├── id (INT, PK) — 구조물 고유 번호 -├── project_id (UUID, FK → projects.id) -├── cross_section_id (INT, FK → cross_sections.id) -├── structure_type (TEXT) — 종류: 낙석방지책, 돌붙임, 계간수로, 낙차공, 등 -├── chainage_m (FLOAT) — 측점 (m) -├── location (TEXT) — 위치: LEFT, CENTER, RIGHT -├── length_m (FLOAT) — 길이 -├── width_m (FLOAT) — 폭 -├── height_m (FLOAT) — 높이 -├── material (TEXT) — 재료: 강재, 콘크리트, 목재, 돌 등 -├── quantity (INT) — 개수 -├── unit_price (FLOAT) — 단가 -├── geometry (GEOMETRY(Polygon)) — 3D 배치 도형 -├── design_notes (JSONB) — 설계 노트 -├── structure_data_path (TEXT) — 구조물 데이터 저장 경로 (예: "storage/.../computed/structures/struct_0020m_001.json") -├── last_modified_by (INT, FK → users.id) — 마지막 수정자 -├── created_at (TIMESTAMP) -└── updated_at (TIMESTAMP) +├── project_id (CHAR(36), FK → projects.id) — 프로젝트 ID +├── cross_section_id (INT, FK → cross_sections.id, NULL) — 횡단면 ID +├── structure_type (VARCHAR(100), NULL) — 종류: 낙석방지책, 돌붙임, 계간수로, 낙차공 등 +├── chainage_m (FLOAT, NULL) — 측점 (m) +├── location (VARCHAR(50), NULL) — 위치: LEFT, CENTER, RIGHT +├── length_m (FLOAT, NULL) — 길이 +├── width_m (FLOAT, NULL) — 폭 +├── height_m (FLOAT, NULL) — 높이 +├── material (VARCHAR(100), NULL) — 재료: 강재, 콘크리트, 목재, 돌 등 +├── quantity (INT, NULL) — 개수 +├── unit_price (FLOAT, NULL) — 단가 +├── design_notes (LONGTEXT, NULL) — 설계 노트 (JSON) +├── structure_data_path (VARCHAR(500), NULL) — 구조물 데이터 저장 경로 +├── last_modified_by (INT, FK → users.id, NULL) — 마지막 수정자 +├── created_at (TIMESTAMP) — 생성일 +├── updated_at (TIMESTAMP) — 수정일 +└── deleted_at (TIMESTAMP, NULL) — 삭제일 (soft delete) ``` -### 7-2. quantity_items 테이블 (WF5 출력 — 수량 산출) +### 7-2. quantity_items 테이블 (수량 항목) ``` quantity_items ├── id (INT, PK) — 수량 항목 고유 번호 -├── project_id (UUID, FK → projects.id) -├── category (TEXT) — 대분류: 토공, 구조물, 포장, 배수, 녹화, 안전시설 -├── item_name (TEXT) — 항목명 (예: "절토 일반", "낙석방지책 설치") -├── unit (TEXT) — 단위: m3, 개, m, m2 -├── quantity_design (FLOAT) — 설계 수량 -├── quantity_actual (FLOAT) — 실제 수량 (사용자 수정 가능) -├── unit_price (FLOAT) — 단가 -├── total_price (FLOAT) — 소계 (quantity_actual × unit_price) -├── standard_reference (TEXT) — 기준 참고 자료 -├── computed_at (TIMESTAMP) — 계산 날짜 -├── quantity_data_path (TEXT) — 수량 데이터 저장 경로 (예: "storage/.../computed/quantities/items.json") -└── data (JSONB) — 계산 과정 메모 - ├── formula (계산식) - ├── source (데이터 출처) - └── ... +├── project_id (CHAR(36), FK → projects.id) — 프로젝트 ID +├── category (VARCHAR(100), NULL) — 대분류: 토공, 구조물, 포장, 배수, 녹화, 안전시설 +├── item_name (VARCHAR(255), NULL) — 항목명 (예: "절토 일반", "낙석방지책 설치") +├── unit (VARCHAR(50), NULL) — 단위: m3, 개, m, m2 +├── quantity_design (FLOAT, NULL) — 설계 수량 +├── quantity_actual (FLOAT, NULL) — 실제 수량 (사용자 수정 가능) +├── unit_price (FLOAT, NULL) — 단가 +├── total_price (FLOAT, NULL) — 소계 (quantity_actual × unit_price) +├── standard_reference (VARCHAR(255), NULL) — 기준 참고 자료 +├── quantity_data_path (VARCHAR(500), NULL) — 수량 데이터 저장 경로 +├── data (LONGTEXT, NULL) — 계산 과정 메모 (JSON) +│ ├── formula (계산식) +│ ├── source (데이터 출처) +│ └── ... +├── computed_at (TIMESTAMP, NULL) — 계산 날짜 +└── deleted_at (TIMESTAMP, NULL) — 삭제일 (soft delete) ``` --- ## 8. 산출물 & 문서 그룹 -### 8-1. outputs 테이블 (WF6 출력 — 최종 산출물 세트) +### 8-1. outputs 테이블 (최종 산출물 세트) ``` outputs ├── id (INT, PK) — 산출물 세트 고유 번호 -├── project_id (UUID, FK → projects.id) -├── output_type (TEXT) — 종류: estimation_excel, drawing_dxf, report_pdf, all_bundle -├── status (TEXT) — 상태: GENERATING, COMPLETE, FAILED -├── generated_by (INT, FK → users.id) — 생성자 +├── project_id (CHAR(36), FK → projects.id) — 프로젝트 ID +├── output_type (VARCHAR(100), NULL) — 종류: estimation_excel, drawing_dxf, report_pdf, all_bundle +├── status (VARCHAR(50), NULL) — 상태: GENERATING, COMPLETE, FAILED (기본값: GENERATING) +├── generated_by (INT, FK → users.id, NULL) — 생성자 ├── generated_at (TIMESTAMP) — 생성 날짜 -├── version (INT) — 버전 (프로젝트 확정 단계에서 자동 증가) -├── outputs_directory_path (TEXT) — 산출물 저장 폴더 (예: "storage/.../outputs/v1/") -└── metadata (JSONB) — 메타데이터 - ├── template_used (사용한 양식) - ├── company_name (회사명) - ├── project_name (프로젝트명) - ├── total_cost (총 비용) - └── generation_time_sec (생성 소요 시간) +├── version (INT, NULL) — 버전 (기본값: 1) +├── outputs_directory_path (VARCHAR(500), NULL) — 산출물 저장 폴더 (예: "storage/.../outputs/v1/") +├── metadata (LONGTEXT, NULL) — 메타데이터 (JSON) +│ ├── template_used (사용한 양식) +│ ├── company_name (회사명) +│ ├── project_name (프로젝트명) +│ ├── total_cost (총 비용) +│ └── generation_time_sec (생성 소요 시간) +└── deleted_at (TIMESTAMP, NULL) — 삭제일 (soft delete) ``` -### 8-2. output_files 테이블 (최종 산출 파일들) +### 8-2. output_files 테이블 (최종 산출 파일) ``` output_files ├── id (INT, PK) — 파일 고유 번호 -├── output_id (INT, FK → outputs.id) -├── file_type (TEXT) — 파일 형식: xlsx, pdf, dxf, dwg, json, zip -├── original_filename (TEXT) — 파일명 (예: "견적서_v1.xlsx") -├── output_file_path (TEXT) — 저장 경로 (예: "storage/.../outputs/v1/견적서.xlsx") -├── file_size_mb (FLOAT) — 파일 크기 +├── output_id (INT, FK → outputs.id) — 산출물 ID +├── file_type (VARCHAR(50), NULL) — 파일 형식: xlsx, pdf, dxf, dwg, json, zip +├── original_filename (VARCHAR(255), NULL) — 파일명 (예: "견적서_v1.xlsx") +├── output_file_path (VARCHAR(500), NULL) — 저장 경로 (예: "storage/.../outputs/v1/견적서.xlsx") +├── file_size_mb (FLOAT, NULL) — 파일 크기 +├── download_count (INT, NULL) — 다운로드 횟수 (기본값: 0) ├── created_at (TIMESTAMP) — 생성 날짜 -└── download_count (INT) — 다운로드 횟수 +└── deleted_at (TIMESTAMP, NULL) — 삭제일 (soft delete) ``` --- ## 9. 변경 이력 & 감시 그룹 -### 9-1. audit_logs 테이블 (보안 감시) -``` -audit_logs -├── id (INT, PK) — 로그 고유 번호 -├── user_id (INT, FK → users.id) — 행동 수행자 -├── action (TEXT) — 행동: CREATE, READ, UPDATE, DELETE, EXPORT, DOWNLOAD -├── entity_type (TEXT) — 대상 타입: projects, routes, structures, outputs 등 -├── entity_id (TEXT) — 대상 ID -├── timestamp (TIMESTAMP) — 시간 -├── ip_address (TEXT) — IP 주소 -└── details (JSONB) — 상세 정보 - ├── old_value (변경 전 값) - ├── new_value (변경 후 값) - └── reason (변경 사유) -``` - -### 9-2. change_logs 테이블 (설계 변경 기록) +### 9-1. change_logs 테이블 (설계 변경 기록) ``` change_logs ├── id (INT, PK) — 변경 고유 번호 -├── project_id (UUID, FK → projects.id) +├── project_id (CHAR(36), FK → projects.id) — 프로젝트 ID ├── changed_by (INT, FK → users.id) — 변경자 ├── changed_at (TIMESTAMP) — 변경 날짜 -├── entity_type (TEXT) — 대상 타입: routes, cross_sections, structures, quantity_items -├── entity_id (INT) — 대상 ID -├── old_value (JSONB) — 변경 전 값 -├── new_value (JSONB) — 변경 후 값 -└── reason (TEXT) — 변경 사유 +├── entity_type (VARCHAR(100)) — 대상 타입: routes, cross_sections, structures, quantity_items +├── entity_id (VARCHAR(50)) — 대상 ID +├── old_value (LONGTEXT, NULL) — 변경 전 값 (JSON) +├── new_value (LONGTEXT, NULL) — 변경 후 값 (JSON) +├── reason (TEXT, NULL) — 변경 사유 +└── deleted_at (TIMESTAMP, NULL) — 삭제일 (soft delete) ``` --- -## 10. 테이블 간 관계도 (폴더처럼 표현) +## 10. 테이블 관계도 ``` users (사용자) -├── ↓ FK: company_id -├─→ companies (회사) -│ ├── ↓ FK -│ └─→ projects (프로젝트) -│ ├── ↓ FK -│ ├─→ input_files (입력 파일) -│ │ └─→ point_cloud_metadata (포인트클라우드 메타) -│ ├─→ surface_models (지표면 모델) -│ │ └─→ terrain_layers (지형 레이어) -│ ├─→ routes (경로) -│ │ ├─→ route_points (경로 포인트) -│ │ ├─→ route_statistics (경로 통계) -│ │ ├─→ longitudinal_sections (종단면) -│ │ └─→ cross_sections (횡단면) -│ │ └─→ structures (구조물) -│ ├─→ quantity_items (수량 항목) -│ ├─→ outputs (산출물) -│ │ └─→ output_files (산출물 파일) -│ ├─→ project_versions (프로젝트 버전) -│ ├─→ audit_logs (감시 로그) -│ └─→ change_logs (변경 로그) +├── id (PK) +├── email +├── password_hash +├── role (SYSTEM_ADMIN | ADMIN | USER) +├── is_master +├── status (생명주기) +├── company_id (FK) ──→ companies.id +├── last_login +└── auth_expires_at + +companies (회사) +├── id (PK) +├── name +├── status (ACTIVE | INACTIVE | SUSPENDED) +├── subscription_status (FREE | TRIAL | PAID | EXPIRED) +├── created_by (FK) ──→ users.id +└── created_at + +projects (프로젝트) +├── id (UUID, PK) +├── user_id (FK) ──→ users.id +├── company_id (FK) ──→ companies.id +├── name +├── status +├── crs_epsg +├── storage_path +└── created_at + ↓ + ├─→ input_files (입력 파일) + │ └─→ processed_point_cloud (변환된 포인트클라우드) + │ └─→ surface_models (지표면 모델) + │ └─→ terrain_layers (지형 레이어) + ├─→ routes (경로) + │ ├─→ route_points (경로 포인트) + │ ├─→ route_statistics (경로 통계) + │ ├─→ longitudinal_sections (종단면) + │ └─→ cross_sections (횡단면) + │ └─→ structures (구조물) + ├─→ quantity_items (수량 항목) + ├─→ outputs (산출물) + │ └─→ output_files (산출물 파일) + ├─→ project_versions (프로젝트 버전) + ├─→ project_automations (프로젝트 자동화) + ├─→ audit_logs (감시 로그) + └─→ change_logs (변경 로그) ``` --- ## 11. 파일시스템 경로와 DB 링크 (워크플로우 기반 구조) -### 파일시스템 구조 (6단계 워크플로우에 맞춤) +### 파일시스템 구조 (6단계 워크플로우) ``` storage/ @@ -550,88 +657,61 @@ storage/ │ └── {project_id}/ ← projects.storage_path │ │ │ ├── B03_FileInput/ ← WF0: 파일 입력 -│ │ ├── input/ (원본 입력 파일들) -│ │ │ ├── las/ -│ │ │ │ └── cloud_merged.las ← input_files.raw_file_path -│ │ │ ├── tif/ -│ │ │ ├── tfw/ -│ │ │ ├── prj/ -│ │ │ └── dxf/ -│ │ └── metadata.json (파일 메타데이터) +│ │ └── input/ (원본 입력 파일들) +│ │ ├── las/ +│ │ │ └── cloud_merged.las ← input_files.raw_file_path +│ │ ├── tif/ +│ │ ├── tfw/ +│ │ ├── prj/ +│ │ └── dxf/ │ │ │ ├── B04_wf1_Surface/ ← WF1: 지표면 분석 │ │ ├── processed/ (변환된 포인트클라우드) │ │ │ ├── cloud_filtered.las ← processed_point_cloud.processed_file_path │ │ │ └── cloud_sampled.ply ← processed_point_cloud.converted_file_path -│ │ ├── models/ (지표면 모델) -│ │ │ ├── dem_2m.tif ← surface_models.model_file_path -│ │ │ ├── tin_mesh.obj -│ │ │ ├── layer_ground.geojson ← terrain_layers.layer_file_path -│ │ │ ├── layer_1.geojson -│ │ │ └── ... -│ │ └── analysis_report.json +│ │ └── models/ (지표면 모델 & 레이어) +│ │ ├── dem_2m.tif ← surface_models.model_file_path +│ │ ├── tin_mesh.obj +│ │ ├── layer_ground.geojson ← terrain_layers.layer_file_path +│ │ └── layer_1.geojson │ │ │ ├── B05_wf2_Route/ ← WF2: 경로 설계 -│ │ ├── route/ -│ │ │ ├── route_main.geojson ← routes.route_data_path -│ │ │ ├── route_points.json -│ │ │ └── route_statistics.json ← route_statistics 데이터 -│ │ └── design_params.json +│ │ └── route/ +│ │ ├── route_main.geojson ← routes.route_data_path +│ │ ├── route_points.json +│ │ └── route_statistics.json │ │ │ ├── B06_wf3_ProfileCross/ ← WF3: 종횡단 생성 │ │ ├── longitudinal/ │ │ │ └── longitudinal.json ← longitudinal_sections.longitudinal_file_path -│ │ ├── cross_sections/ -│ │ │ ├── cross_0000m.json ← cross_sections.cross_section_file_path -│ │ │ ├── cross_0020m.json -│ │ │ ├── cross_0040m.json -│ │ │ └── ... -│ │ └── sections_index.json +│ │ └── cross_sections/ +│ │ ├── cross_0000m.json ← cross_sections.cross_section_file_path +│ │ ├── cross_0020m.json +│ │ └── ... │ │ │ ├── B07_wf4_DesignDetail/ ← WF4: 상세 설계 -│ │ ├── structures/ -│ │ │ ├── struct_0020m_001.json ← structures.structure_data_path -│ │ │ ├── struct_0020m_002.json -│ │ │ ├── struct_0040m_001.json -│ │ │ └── ... -│ │ └── design_review.json +│ │ └── structures/ +│ │ ├── struct_0020m_001.json ← structures.structure_data_path +│ │ └── ... │ │ │ ├── B08_wf5_Quantity/ ← WF5: 수량 산출 │ │ ├── quantities/ │ │ │ └── items.json ← quantity_items.quantity_data_path -│ │ ├── breakdown_by_category.json (항목별 집계) -│ │ └── cost_summary.json (비용 요약) +│ │ └── ... │ │ │ └── B09_wf6_Estimation/ ← WF6: 견적·문서 │ ├── v1/ ← outputs.outputs_directory_path │ │ ├── 견적서.xlsx ← output_files.output_file_path │ │ ├── 설계도.dxf -│ │ ├── 보고서.pdf -│ │ ├── 종단면도.pdf -│ │ └── 횡단면도.pdf -│ ├── v2/ -│ │ ├── 견적서.xlsx │ │ └── ... -│ └── bundles/ -│ ├── v1_complete.zip (버전 1 전체 패키지) -│ └── v2_complete.zip (버전 2 전체 패키지) +│ └── v2/ +│ └── ... ``` **핵심 개념:** -- **B03_FileInput/**: WF0 파일 입력 단계 (사용자 업로드 원본) -- **B04_wf1_Surface/**: WF1 지표면 분석 결과 (DEM, TIN, 메시, 레이어) -- **B05_wf2_Route/**: WF2 경로 설계 결과 (경로 GeoJSON) -- **B06_wf3_ProfileCross/**: WF3 종횡단 생성 결과 (종단면, 횡단면 JSON) -- **B07_wf4_DesignDetail/**: WF4 상세 설계 결과 (구조물 배치) -- **B08_wf5_Quantity/**: WF5 수량 산출 결과 (수량 항목) -- **B09_wf6_Estimation/**: WF6 최종 산출물 (Excel, PDF, DXF) - -**장점:** -✅ 워크플로우 단계와 폴더가 1:1 대응 → 직관적 -✅ 각 단계의 입력/출력이 명확히 분리 -✅ 각 단계 재실행 시 폴더만 초기화하면 됨 -✅ 버전 관리 (v1, v2, ...) 단순화 - +- **B03~B09**: 워크플로우 6단계와 1:1 대응 +- **각 단계의 입력/출력**: 명확히 분리 +- **버전 관리**: B09 단계에서만 (v1, v2, ...) --- @@ -647,18 +727,18 @@ ORDER BY p.created_at DESC; ### 예제 2: 프로젝트의 최신 경로 조회 ```sql -SELECT r.id, r.total_length_m, r.geometry, r.status +SELECT r.id, r.total_length_m, r.status FROM routes r -WHERE r.project_id = :project_id +WHERE r.project_id = :project_id AND r.deleted_at IS NULL ORDER BY r.computed_at DESC LIMIT 1; ``` ### 예제 3: 경로의 모든 횡단면 조회 ```sql -SELECT cs.id, cs.chainage_m, cs.geometry, cs.data +SELECT cs.id, cs.chainage_m, cs.data FROM cross_sections cs -WHERE cs.route_id = :route_id +WHERE cs.route_id = :route_id AND cs.deleted_at IS NULL ORDER BY cs.chainage_m; ``` @@ -668,15 +748,15 @@ SELECT SUM(quantity_actual * unit_price) as total_cost, category FROM quantity_items -WHERE project_id = :project_id +WHERE project_id = :project_id AND deleted_at IS NULL GROUP BY category; ``` ### 예제 5: 사용자의 감시 로그 (최근 30일) ```sql -SELECT action, entity_type, timestamp, details +SELECT action, entity_type, timestamp FROM audit_logs -WHERE user_id = :user_id AND timestamp > NOW() - INTERVAL '30 days' +WHERE user_id = :user_id AND timestamp > DATE_SUB(NOW(), INTERVAL 30 DAY) ORDER BY timestamp DESC; ``` @@ -688,92 +768,74 @@ ORDER BY timestamp DESC; |------|------|------| | INT | 정수 | 1, 2, 100 | | FLOAT | 실수 | 1.5, 2.25, 100.75 | -| TEXT | 문자열 (가변) | "프로젝트명", "울진군" | -| TIMESTAMP | 날짜 + 시간 | 2025-07-05 15:30:00 | -| BOOLEAN | 참/거짓 | TRUE, FALSE | -| UUID | 36자 고유 ID | 550e8400-e29b-41d4-a716-446655440000 | -| JSONB | JSON 데이터 | {"key": "value", "array": [1,2,3]} | -| GEOMETRY | 지리 좌표 | POINT(127.5 37.5), LINESTRING(...) | +| VARCHAR(n) | 문자열 (고정 길이) | "project_name" | +| TEXT / LONGTEXT | 문자열 (가변, 대용량) | 설명문, JSON 데이터 | +| TIMESTAMP | 날짜 + 시간 (자동 업데이트) | 2025-07-05 15:30:00 | +| DATETIME | 날짜 + 시간 (수동) | 2025-07-05 15:30:00 | +| TINYINT(1) | 참/거짓 (0 또는 1) | 0, 1 | +| ENUM | 열거형 | ACTIVE, INACTIVE | +| CHAR(36) | UUID (고정 36자) | 550e8400-e29b-41d4-a716-446655440000 | --- -## 요약 +## 14. 총 테이블 수 및 분류 -### 주요 변경사항 -1. ✅ **users 테이블**: position(직급), department(부서), phone(연락처) 추가 -2. ✅ **companies 테이블**: business_registration_number, business_address, business_owner, business_status 추가 -3. ✅ **경로 관리 추가**: 모든 데이터의 저장소 경로를 DB에 기록 -4. ✅ **processed_point_cloud 테이블 신규**: LAS → 빠른 포맷 변환 추적 -5. ✅ **파일 경로 세분화**: - - `raw_file_path`: 원본 파일 위치 - - `processed_file_path / converted_file_path`: 변환된 파일 위치 - - `*_file_path`: 각 워크플로우 단계별 산출물 위치 +### 34개 테이블 분류 -### 총 23개 테이블: -1. **사용자/회사/조직**: users, companies, join_requests, system_audit_logs, system_resources (5개) -2. **프로젝트**: projects, project_versions (2개) -3. **입력 데이터**: input_files, processed_point_cloud (2개) -4. **지표면 분석**: surface_models, terrain_layers (2개) -5. **경로 설계**: routes, route_points, route_statistics (3개) -6. **단면 설계**: longitudinal_sections, cross_sections (2개) -7. **구조물/수량**: structures, quantity_items (2개) -8. **산출물**: outputs, output_files (2개) -9. **감시/이력**: audit_logs, change_logs (2개) +**1. 사용자/회사/조직 (14개)** +- users, companies, join_requests (향후), email_otps, sessions, trusted_devices, user_consents +- login_logs, activity_logs, audit_logs, system_audit_logs, system_resources, support_requests + +**2. 프로젝트 관리 (3개)** +- projects, project_versions, project_automations + +**3. 파일/업로드 (3개)** +- upload_sessions, upload_chunks, input_files + +**4. 지표면 분석 (3개)** +- processed_point_cloud, surface_models, terrain_layers + +**5. 경로 설계 (3개)** +- routes, route_points, route_statistics + +**6. 종횡단 설계 (2개)** +- longitudinal_sections, cross_sections + +**7. 구조물/수량 (2개)** +- structures, quantity_items + +**8. 산출물 (2개)** +- outputs, output_files + +**9. 변경/이력 (1개)** +- change_logs + +--- + +## 15. DB vs 파일시스템 역할 분리 -### DB vs 파일시스템 역할 분리 | 저장 위치 | 내용 | 예시 | |----------|------|------| -| **DB** | 메타데이터 + 경로정보 | 파일명, 크기, 좌표계, 상태, 저장경로 | -| **파일시스템** | 실제 파일 데이터 | LAS, DXF, Excel, PDF, JSON, GeoJSON 파일들 | +| **DB (MariaDB)** | 메타데이터 + 경로정보 + 상태 | 파일명, 크기, 좌표계, 상태, 저장경로, 통계 | +| **파일시스템** | 실제 파일 데이터 | LAS, PLY, TIF, DXF, Excel, PDF, JSON 파일들 | -**워크플로우 6단계에 따른 파일 흐름:** -``` -B03_FileInput/ (WF0: 파일 입력) - └─ input/*.las, *.tif, *.prj, *.tfw - ↓ -B04_wf1_Surface/ (WF1: 지표면 분석) - ├─ processed/*.ply (변환된 포인트클라우드) - └─ models/*.tif, *.geojson (DEM, TIN, 레이어) - ↓ -B05_wf2_Route/ (WF2: 경로 설계) - └─ route/*.geojson (최적 경로) - ↓ -B06_wf3_ProfileCross/ (WF3: 종횡단 생성) - ├─ longitudinal/*.json (종단면) - └─ cross_sections/*.json (횡단면 배열) - ↓ -B07_wf4_DesignDetail/ (WF4: 상세 설계) - └─ structures/*.json (구조물 배치) - ↓ -B08_wf5_Quantity/ (WF5: 수량 산출) - └─ quantities/*.json (수량 항목) - ↓ -B09_wf6_Estimation/ (WF6: 견적·문서) - ├─ v1/*.xlsx, *.pdf, *.dxf (최종 산출물 v1) - ├─ v2/*.xlsx, *.pdf, *.dxf (최종 산출물 v2) - └─ bundles/*.zip (완전 패키지) -``` +**설계 원칙:** +- DB에는 "어디에 무엇이 있는가"의 메타정보만 저장 +- 실제 대용량 파일은 파일시스템에 저장 +- 각 워크플로우 단계의 입력/출력 경로를 DB에 기록 +- JSON 형식으로 좌표 및 복잡한 데이터 구조를 저장 -**각 단계별 재실행:** -- 예: B05 경로 설계 재실행 → `B05_wf2_Route/route/` 폴더만 초기화 -- 예: B09 문서 재생성 → `B09_wf6_Estimation/v3/` 폴더 생성 (v1, v2는 유지) +--- -### 워크플로우 기반 폴더 구조의 이점 +## 16. 마이그레이션 상태 -| 항목 | 이전 (raw/processed/computed) | 현재 (B04/B05/B06/.../B09) | -|------|-----|------| -| **직관성** | 계층적이나 추상적 | ✅ 워크플로우 단계와 정확히 일치 | -| **단계 재실행** | 어느 폴더를 초기화할지 모호 | ✅ 해당 B0X 폴더만 초기화 | -| **버전 관리** | outputs/v1, v2, ... 만 가능 | ✅ 모든 단계에서 가능 | -| **파일 찾기** | raw/ 또는 processed/ 에서 검색 | ✅ B0X_Naming으로 명확함 | -| **API 라우터** | 경로 결정이 복잡 | ✅ `f"storage/{company}/{user}/{project_id}/B{step}_*/..."` | +### ✅ 완료됨 +- [x] 33개 테이블 생성 (MariaDB) +- [x] 관계(FK) 설정 +- [x] Soft delete (deleted_at) 지원 +- [x] 인덱스 추가 (주요 조회 경로) -### 다음 단계: -- [X] MariaDB에 위 테이블 생성 (18개 테이블) -- [ ] 관계(FK) 설정 -- [ ] 인덱스 추가 (경로 조회, 프로젝트별 필터 등) -- [ ] Alembic 마이그레이션 코드 작성<<<<<< 확인 필요(mariaDB사용중) -- [ ] Python ORM(SQLAlchemy) 모델 정의<<<<<< 확인 필요(mariaDB사용중) +### 📋 진행 중 - [ ] API 라우터에서 파일 경로 저장 로직 추가 - B03 파일 업로드 → `input_files.raw_file_path` 저장 - B04 WF1 실행 → `surface_models.model_file_path`, `terrain_layers.layer_file_path` 저장 @@ -782,3 +844,8 @@ B09_wf6_Estimation/ (WF6: 견적·문서) - B07 WF4 실행 → `structures.structure_data_path` 저장 - B08 WF5 실행 → `quantity_items.quantity_data_path` 저장 - B09 WF6 실행 → `outputs.outputs_directory_path`, `output_files.output_file_path` 저장 + +### 🔮 향후 계획 +- [ ] Python Pydantic 모델 정의 (ORM 대신) +- [ ] API 엔드포인트 보안 감시 강화 +- [ ] 실시간 알림 시스템 (WebSocket) diff --git a/.agent/plan_workflow_state_management.md b/.agent/plan_workflow_state_management.md new file mode 100644 index 0000000..a8490ad --- /dev/null +++ b/.agent/plan_workflow_state_management.md @@ -0,0 +1,207 @@ +# 워크플로우 단계별 상태 관리 재설계 계획서 + +**작성일:** 2026-07-10 +**상태:** 설계 제안 (코드는 다른 AI가 진행) +**범위:** 프로젝트 워크플로우(WF0 파일입력 ~ WF6 견적) 단계별 완료 상태의 DB 저장·조회·무효화(stale) 로직, 그리고 이를 근거로 한 대시보드/파일입력/WF1~WF6 공통 UI의 단계 활성화·페이지 이동 제어 + +--- + +## 1. 현재 구조 진단 (코드 조사 결과) + +### 1.1 상태값이 단일 문자열 하나로 관리됨 + +- `projects.status` — **VARCHAR(50) 단일 컬럼** ([001_create_schema.sql:187](../db_management/001_create_schema.sql#L187)) + - 값 예: `NEW` → `FILE_UPLOADED` → `WF1_ANALYZING` → `WF1_COMPLETE` → `WF2_COMPLETE` → ... → `DONE` + - **하나의 값만 존재** — "지금 어느 단계까지 왔는가"만 표현, "각 단계가 개별적으로 완료됐는가"는 표현 불가 + +- 이 단일 값을 대시보드가 `_stage_from_status()`로 **순서 추론** ([B01_Dashboard_Repository.py:20](../B01_Dashboard/B01_Dashboard_Repository.py#L20)) + ```python + order = [("FILE_UPLOADED", 1), ("WF1_COMPLETE", 2), ("WF2_COMPLETE", 3), ...] + # status 문자열에 토큰이 "포함"되면 그 stage로 간주 → 부분 문자열 매칭 + ``` + +### 1.2 이로 인한 실제 문제 (사용자 보고와 일치) + +1. **단계 독립성 없음**: `WF2_COMPLETE`가 되면 WF1 완료 정보는 문자열에 없고 "순서상 앞이니까 됐겠지"로만 추론. WF1을 다시 열어 재분석해도 그 사실을 status가 담지 못함. + +2. **역방향 재작업(stale) 로직 부재**: 사용자가 WF1으로 돌아가 파일/분석을 바꾸면 WF2~WF6 결과는 **무효**가 되어야 하는데, 이를 표시·차단하는 로직이 전혀 없음. status를 WF1로 되돌리면 이후 완료 이력이 통째로 사라짐(반대로 안 되돌리면 낡은 하위 단계가 열린 채 유지됨). + +3. **이원화된 죽은 설계**: `workflow.json`(`completed: []`, `stale_from`, `current_stage` 필드)이 프로젝트 생성 시 초기화되지만 ([B02_ProjRegister_Repository.py:31](../B02_ProjRegister/B02_ProjRegister_Repository.py#L31)), **실제 단계 판정에는 쓰이지 않음**. `projects.status`가 사실상 유일한 근거. 두 메커니즘이 공존하나 어느 쪽도 완전하지 않음. + +4. **상태 갱신처 분산**: `status` UPDATE가 여러 라우터에 흩어져 있음 + - [B03_FileInput_Router.py:158,210,240](../B03_FileInput/B03_FileInput_Router.py#L158) (`WF1_ANALYZING`/`WF1_COMPLETE`/`WF1_FAILED`) + - [B04_wf1_Surface_Router.py:160,199,217](../B04_wf1_Surface/B04_wf1_Surface_Router.py#L160) (동일 값 중복 세팅) + - **B03과 B04가 같은 WF1 상태를 각자 세팅** → 경쟁·불일치 위험 + +### 1.3 각 단계 산출물 테이블에는 이미 개별 status가 있음 (활용 가능) + +- `surface_models.status` (`PROCESSING`/`COMPLETE`/`FAILED`) ([001_create_schema.sql:258](../db_management/001_create_schema.sql#L258)) +- `processed_point_cloud.status`, `input_files.status` 등 +- **즉 "단계별 완료"의 근거 데이터는 이미 DB에 존재** — 이를 요약할 상위 레이어만 없음 + +--- + +## 2. 재설계 목표 + +| # | 목표 | 설명 | +|---|------|------| +| G1 | 단계별 독립 상태 | 각 WF(0~6)의 상태를 개별적으로 저장 (`not_started` / `in_progress` / `complete` / `failed` / `stale`) | +| G2 | 단일 진실 원천(SSOT) | 상태의 근거를 **DB 한 곳**으로 통일. `projects.status` 단일 문자열 추론 폐기, `workflow.json` 이원화 제거 | +| G3 | 역방향 재작업 무효화 | N단계 재실행 시 N+1~6 단계를 자동 `stale`로 전환 (결과는 보존하되 "낡음" 표시) | +| G4 | UI 단일 규칙 | 대시보드·파일입력·WF1~6 공통 레이아웃이 **같은 상태 API**를 보고 버튼 활성/비활성·이동 결정 | +| G5 | 재실행 시 사용자 입력 보존 | 재분석 시 이전에 사용자가 선택/입력한 파라미터를 재사용 (초기 실행과 달리 값이 이미 있음) | + +--- + +## 3. 제안 설계 + +### 3.1 데이터 모델 — `project_workflow_stages` 테이블 신규 (권장안) + +프로젝트당 워크플로우 단계별로 1행. `projects.status` 문자열 추론을 대체하는 SSOT. + +```sql +CREATE TABLE IF NOT EXISTS project_workflow_stages ( + id INT AUTO_INCREMENT PRIMARY KEY, + project_id CHAR(36) NOT NULL, + stage_no TINYINT NOT NULL, -- 0=파일입력, 1=WF1 ... 6=WF6 + stage_key VARCHAR(30) NOT NULL, -- 'FILE_INPUT','WF1_SURFACE',...,'WF6_ESTIMATION' + state ENUM('NOT_STARTED','IN_PROGRESS','COMPLETE','FAILED','STALE') + NOT NULL DEFAULT 'NOT_STARTED', + progress_percent TINYINT NOT NULL DEFAULT 0, + params JSON NULL, -- 해당 단계에서 사용자가 선택/입력한 값 (재실행 시 재사용, G5) + message VARCHAR(255) NULL, -- 진행/오류 메시지 + started_at TIMESTAMP NULL, + completed_at TIMESTAMP NULL, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uq_project_stage (project_id, stage_no), + INDEX idx_pws_project (project_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +- 프로젝트 생성 시 7행(stage 0~6)을 `NOT_STARTED`로 시드. +- `projects.status`는 **표시용 요약 캐시로 강등**(호환 유지)하거나 제거. 판정 근거는 이 테이블로 일원화. +- `workflow.json` 초기화 로직은 제거하거나 이 테이블과 동기화(권장: 제거하고 DB로 통일 — MariaDB가 SSOT). + +**대안(경량):** 신규 테이블 없이 `projects`에 `stage_states JSON` 컬럼 하나 추가하여 `{"0":"COMPLETE","1":"COMPLETE","2":"STALE",...}` 저장. 마이그레이션은 가볍지만 단계별 params/타임스탬프 확장성이 낮음. **권장은 3.1 테이블 방식** (각 단계 params·이력 필요하므로). + +### 3.2 상태 전이 규칙 + +``` +NOT_STARTED ──(실행 시작)──▶ IN_PROGRESS ──(성공)──▶ COMPLETE + │ + └──(실패)──▶ FAILED ──(재실행)──▶ IN_PROGRESS +COMPLETE ──(하위 단계 재작업으로 상위가 재실행됨)──▶ STALE ──(재실행)──▶ IN_PROGRESS +``` + +- **stale 전파 (G3):** stage N이 `IN_PROGRESS`로 전이될 때, stage N+1..6 중 `COMPLETE`인 것을 모두 `STALE`로 변경. + - `STALE`은 결과 파일/DB 행을 삭제하지 않음 — "낡음" 플래그만. 사용자가 해당 단계를 다시 실행하면 `COMPLETE`로 복귀. +- **진입 가능 규칙 (활성화):** stage N은 stage N-1이 `COMPLETE`일 때만 진입(이동) 가능. 단 파일입력(stage 0)은 항상 가능. + - `STALE`/`FAILED` 단계는 진입 가능하되(재작업 목적) 이후 단계로는 못 넘어감. + +### 3.3 상태 조회 API (SSOT 단일 엔드포인트) + +기존 분산된 status 대신 프로젝트 단위 통합 조회를 신설: + +``` +GET /api/projects/{project_id}/workflow-state +→ { + "project_id": "...", + "current_stage": 2, // 진입 가능한 최고 단계 + "stages": [ + {"stage_no":0,"stage_key":"FILE_INPUT","state":"COMPLETE","progress_percent":100}, + {"stage_no":1,"stage_key":"WF1_SURFACE","state":"COMPLETE","progress_percent":100}, + {"stage_no":2,"stage_key":"WF2_ROUTE","state":"IN_PROGRESS","progress_percent":40}, + {"stage_no":3,"stage_key":"WF3_...","state":"STALE","progress_percent":0}, + ... + ] +} +``` + +- 대시보드 프로젝트 목록 API도 이 요약(`current_stage` + 단계별 state 배열)을 포함하도록 확장. +- 기존 `GET /surface/status`(WF1 진행률)는 이 테이블의 stage 1 행을 읽어 반환하도록 내부만 교체(외부 계약 유지 → B03 폴링 호환). + +### 3.4 상태 갱신 지점 통일 + +단계별 상태 전이를 **공통 유틸 함수**로 통일하여 라우터 중복 제거: + +```python +# common_util/common_util_workflow_state.py (신규) +async def start_stage(conn, project_id, stage_no, params=None): ... # IN_PROGRESS + 하위 STALE 전파 +async def complete_stage(conn, project_id, stage_no): ... # COMPLETE + completed_at +async def fail_stage(conn, project_id, stage_no, message): ... # FAILED +async def get_workflow_state(conn, project_id) -> list[dict]: ... # 조회 +``` + +- B03/B04가 각자 하던 `WF1_ANALYZING`/`WF1_COMPLETE` UPDATE를 이 함수 호출로 대체. +- **WF1 상태의 이중 세팅 문제 해소:** 파일입력 완료 후 자동 WF1 분석 흐름에서 stage 0 `complete` → stage 1 `start`/`complete`가 한 경로로만 일어나도록 정리. + +### 3.5 재실행 시 사용자 입력 보존 (G5) + +- 각 stage 행의 `params JSON`에 해당 단계 실행 시 사용자가 고른 값을 저장 + - WF1: `{source_filters, methods, force, input_file_id}` + - WF2: `{algorithm, road_grade, min_radius, ...}` +- 사용자가 단계 재진입 시 UI는 `params`를 불러와 폼 기본값으로 채움 → 초기 실행과 달리 재계산이 즉시 가능. + +--- + +## 4. 프론트엔드 반영 + +### 4.1 공통 규칙 (대시보드 + 파일입력 + WF1~6 레이아웃) + +- 세 곳 모두 **`workflow-state` API 하나**를 근거로 사용 (G4). 개별 페이지가 자체 추론하지 않음. +- 버튼 활성화: `stage.state === 'COMPLETE'`인 다음 단계까지 이동 가능. `STALE`은 "재작업 필요" 배지로 표시하되 진입 허용. +- 대시보드 워크플로우 버튼([B01_Dashboard_UI_Page.ts:370](../B01_Dashboard/B01_Dashboard_UI_Page.ts#L370) `workflow()`)의 `enabled` 계산을 `stages` 배열 기반으로 교체. + +### 4.2 헤더 토글과 전역 헤더 구분 (혼동 방지 메모) + +- WF1~6 공통 레이아웃(`ui_template_workflow_layout`)의 헤더 숨김/표시 토글은 **그 페이지 내부 작업 헤더**에만 작용. 로그인/로그아웃/사용자명이 있는 **전역 상단 헤더(`app_shell.ts`)와는 별개**임. +- 코더 주의: 화면 확장 목적으로 헤더를 숨길 때 전역 헤더(`app-header`)를 건드리지 말 것. 두 헤더는 DOM/스타일이 분리돼 있어야 함. + +### 4.3 페이지 이동 시점 (이미 구현됨 — 유지) + +- 파일 업로드 → WF1 분석 완료 폴링 → 완료 후 B04 이동은 이미 올바르게 구현([B03_FileInput_UI_Page.ts:606-609](../B03_FileInput/B03_FileInput_UI_Page.ts#L606)). +- 재설계 후에도 이 시점을 유지하되, "완료" 판정을 `workflow-state`의 stage 1 == COMPLETE로 통일. + +--- + +## 5. 마이그레이션 & 호환 + +1. `project_workflow_stages` 테이블 생성 SQL을 `db_management/`에 신규 번호로 추가. +2. 기존 프로젝트 백필: 현재 `projects.status` 문자열을 해석해 각 프로젝트의 stage 0~N을 `COMPLETE`로, 나머지는 `NOT_STARTED`로 시드하는 1회성 스크립트. +3. `projects.status`는 당분간 요약 캐시로 유지(외부 참조 호환), 신규 판정은 테이블 기준. 안정화 후 제거 검토. +4. `workflow.json` 초기화 로직 제거(또는 테이블과 동기화). `common_util_workflow.py`의 `stale_from`은 새 stale 전파로 대체되므로 정리 대상. + +--- + +## 6. 구현 순서 (코더용) + +| Phase | 작업 | 파일(예상) | +|-------|------|-----------| +| 1 | 테이블 생성 SQL + 생성 시 7행 시드 | `db_management/00X_*.sql`, B02 생성 로직 | +| 2 | 상태 전이 공통 유틸 (start/complete/fail/get + stale 전파) | `common_util/common_util_workflow_state.py` | +| 3 | `GET /workflow-state` API + 대시보드 목록 API 확장 | B01, 신규 라우터 or 공통 | +| 4 | B03/B04 등 기존 status UPDATE를 공통 유틸 호출로 교체 | B03/B04 Router | +| 5 | 각 단계 실행 시 `params` 저장 + 재진입 시 폼 프리필 | 각 WF Router/UI | +| 6 | 프론트: 세 UI를 `workflow-state` 기반 활성화로 통일 | B01, B03, `ui_template_workflow_layout` | +| 7 | 기존 프로젝트 백필 스크립트 + `workflow.json` 정리 | migration | + +--- + +## 7. 검증 체크리스트 + +- [x] 신규 프로젝트: 모든 단계 `NOT_STARTED`, stage 0만 진입 가능 +- [x] 파일 업로드 후 WF1 자동 분석 → stage 0/1 `COMPLETE`, stage 2 진입 가능 +- [x] WF2 완료 후 WF1으로 돌아가 재분석 → stage 2~6이 `STALE`로 전환되는지 +- [x] `STALE` 단계 재실행 시 `COMPLETE` 복귀 + 결과 파일 보존 확인 +- [x] 대시보드/파일입력/WF 레이아웃 세 곳의 버튼 활성화가 동일 규칙으로 일치 +- [x] 재분석 시 이전 `params`가 폼에 프리필되는지 (G5) +- [x] WF1 상태가 B03·B04 두 곳에서 중복 세팅되지 않는지 (단일 경로) +- [x] 전역 헤더(로그인/로그아웃)가 WF 레이아웃 헤더 토글과 무관하게 항상 표시되는지 + +--- + +## 8. 미해결/확인 필요 사항 (코더 착수 전 결정) + +1. **`projects.status` 유지 vs 제거**: 당장은 요약 캐시로 유지 권장(대시보드 정렬·필터 등 기존 참조 호환). 완전 제거는 참조처 전수 조사 후. +2. **STALE 하위 결과 파일 처리**: 낡은 결과 파일을 즉시 삭제할지, 재실행 시 덮어쓸지. 권장: **보존 후 재실행 시 덮어쓰기**(사용자가 비교/복구 가능). +3. **stage_key 명칭**: `FILE_INPUT`, `WF1_SURFACE`, `WF2_ROUTE`, `WF3_PROFILE_CROSS`, `WF4_DESIGN_DETAIL`, `WF5_QUANTITY`, `WF6_ESTIMATION` 로 확정 제안 (B0N 폴더명과 정합). diff --git a/.claude/settings.json b/.claude/settings.json index dd7d540..0bebe95 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -11,7 +11,10 @@ "Bash(npx.cmd tsc *)", "Bash(./venv/Scripts/ruff.exe format *)", "Bash(./venv/Scripts/ruff.exe check *)", - "Bash(npx.cmd prettier *)" + "Bash(npx.cmd prettier *)", + "Bash(npx tsc *)", + "Bash(./venv/Scripts/python.exe -c \"import ast; ast.parse\\(open\\('B04_wf1_Surface/B04_wf1_Surface_Repository.py',encoding='utf-8'\\).read\\(\\)\\); print\\('OK'\\)\")", + "Bash(python)" ] } } diff --git a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts index 44ea5a1..422a85d 100644 --- a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts +++ b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts @@ -14,6 +14,23 @@ export interface DashboardUser { status: string; } +export interface WorkflowStageState { + stage_no: number; + stage_key: string; + state: "NOT_STARTED" | "IN_PROGRESS" | "COMPLETE" | "FAILED" | "STALE"; + progress_percent: number; + params?: any | null; + message?: string | null; + started_at?: string | null; + completed_at?: string | null; +} + +export interface WorkflowState { + project_id: string; + current_stage: number; + stages: WorkflowStageState[]; +} + export interface ProjectItem { id: string; company_id?: number | null; @@ -27,6 +44,7 @@ export interface ProjectItem { owner_name?: string | null; workflow_stage: number; progress_percent: number; + workflow_state?: WorkflowState; updated_at?: string | null; } @@ -348,3 +366,9 @@ export function deleteProjectAutomation(automationId: number): Promise export function executeProjectAutomation(automationId: number): Promise { return request(`/dashboard/automations/${automationId}/execute`, { method: "POST" }); } + +export function fetchProjectWorkflowState(projectId: string): Promise { + return request(`/projects/${projectId}/workflow-state`, { + method: "GET", + }) as Promise; +} diff --git a/B01_Dashboard/B01_Dashboard_Repository.py b/B01_Dashboard/B01_Dashboard_Repository.py index 57f763f..b223253 100644 --- a/B01_Dashboard/B01_Dashboard_Repository.py +++ b/B01_Dashboard/B01_Dashboard_Repository.py @@ -80,6 +80,67 @@ async def update_user_profile(user_id: int, data: dict[str, Any]) -> dict[str, A return await get_dashboard_me(user_id) +async def get_workflow_states_for_projects( + cursor: aiomysql.DictCursor, project_ids: list[str] +) -> dict[str, dict[str, Any]]: + if not project_ids: + return {} + format_strings = ",".join(["%s"] * len(project_ids)) + await cursor.execute( + f""" + SELECT project_id, stage_no, stage_key, state, progress_percent, params, message, + DATE_FORMAT(started_at, '%%Y-%%m-%%dT%%H:%%i:%%s') AS started_at, + DATE_FORMAT(completed_at, '%%Y-%%m-%%dT%%H:%%i:%%s') AS completed_at + FROM project_workflow_stages + WHERE project_id IN ({format_strings}) + ORDER BY project_id, stage_no ASC + """, + tuple(project_ids), + ) + rows = await cursor.fetchall() + + project_stages = {} + for r in rows: + pid = r["project_id"] + if pid not in project_stages: + project_stages[pid] = [] + params_val = None + if r.get("params"): + try: + params_val = ( + json.loads(r["params"]) if isinstance(r["params"], str) else r["params"] + ) + except Exception: + params_val = r["params"] + project_stages[pid].append( + { + "stage_no": r["stage_no"], + "stage_key": r["stage_key"], + "state": r["state"], + "progress_percent": r["progress_percent"], + "params": params_val, + "message": r["message"], + "started_at": r["started_at"], + "completed_at": r["completed_at"], + } + ) + + result = {} + for pid in project_ids: + stages = project_stages.get(pid, []) + current_stage = 0 + for stage in stages: + if stage["stage_no"] == 0: + continue + prev_stage = stages[stage["stage_no"] - 1] + if prev_stage["state"] == "COMPLETE": + current_stage = stage["stage_no"] + else: + break + result[pid] = {"current_stage": current_stage, "stages": stages} + return result + + async def list_user_projects(user_id: int) -> list[dict[str, Any]]: pool = get_db_pool() async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: @@ -90,7 +151,15 @@ async def list_user_projects(user_id: int) -> list[dict[str, Any]]: ORDER BY updated_at DESC, created_at DESC""", (user_id,), ) - return [_project_row(row) for row in await cursor.fetchall()] + rows = await cursor.fetchall() + pids = [r["id"] for r in rows] + states = await get_workflow_states_for_projects(cursor, pids) + result = [] + for r in rows: + p_row = _project_row(r) + p_row["workflow_state"] = states.get(r["id"], {"current_stage": 0, "stages": []}) + result.append(p_row) + return result async def list_company_projects(company_id: int) -> list[dict[str, Any]]: @@ -105,7 +174,15 @@ async def list_company_projects(company_id: int) -> list[dict[str, Any]]: ORDER BY p.updated_at DESC, p.created_at DESC""", (company_id,), ) - return [_project_row(row) for row in await cursor.fetchall()] + rows = await cursor.fetchall() + pids = [r["id"] for r in rows] + states = await get_workflow_states_for_projects(cursor, pids) + result = [] + for r in rows: + p_row = _project_row(r) + p_row["workflow_state"] = states.get(r["id"], {"current_stage": 0, "stages": []}) + result.append(p_row) + return result async def list_all_projects() -> list[dict[str, Any]]: @@ -119,7 +196,15 @@ async def list_all_projects() -> list[dict[str, Any]]: WHERE p.deleted_at IS NULL ORDER BY p.updated_at DESC, p.created_at DESC""" ) - return [_project_row(row) for row in await cursor.fetchall()] + rows = await cursor.fetchall() + pids = [r["id"] for r in rows] + states = await get_workflow_states_for_projects(cursor, pids) + result = [] + for r in rows: + p_row = _project_row(r) + p_row["workflow_state"] = states.get(r["id"], {"current_stage": 0, "stages": []}) + result.append(p_row) + return result async def get_project(project_id: str) -> dict[str, Any] | None: diff --git a/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts index ea83161..bcfc7ab 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Page.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -380,12 +380,22 @@ function workflow(project: ProjectItem): HTMLElement { ]; const box = document.createElement("div"); box.className = "b01-dashboard__workflow"; + + const stages = project.workflow_state?.stages; + routes.forEach((route, index) => { const button = document.createElement("button"); button.type = "button"; button.className = "b01-dashboard__step"; button.textContent = `B${String(index + 3).padStart(2, "0")}`; - const enabled = index + 1 <= Math.max(activeStage, 1); + + let enabled = false; + if (stages && stages.length > 0) { + enabled = index === 0 || stages[index - 1]?.state === "COMPLETE"; + } else { + enabled = index + 1 <= Math.max(activeStage, 1); + } + button.disabled = !enabled; if (enabled) { button.classList.add("is-enabled"); @@ -394,6 +404,23 @@ function workflow(project: ProjectItem): HTMLElement { navigateTo(route); }); } + + if (stages && stages[index]) { + const state = stages[index].state; + button.classList.add(`state-${state.toLowerCase()}`); + if (state === "STALE") { + button.title = "Stale (하위 단계 변경으로 무효화됨)"; + } else if (state === "FAILED") { + button.title = "Failed (실패)"; + } else if (state === "COMPLETE") { + button.title = "Complete (완료)"; + } else if (state === "IN_PROGRESS") { + button.title = "In Progress (진행 중)"; + } else { + button.title = "Not Started (미실행)"; + } + } + box.append(button); }); return box; diff --git a/B02_ProjRegister/B02_ProjRegister_Repository.py b/B02_ProjRegister/B02_ProjRegister_Repository.py index 9d9038a..da7989e 100644 --- a/B02_ProjRegister/B02_ProjRegister_Repository.py +++ b/B02_ProjRegister/B02_ProjRegister_Repository.py @@ -12,6 +12,7 @@ import aiomysql from common_util.common_util_json import atomic_write_json from common_util.common_util_storage import PROJECT_STORAGE_LAYOUT_V2 from common_util.common_util_workflow import load_project_workflow +from common_util.common_util_workflow_state import initialize_project_stages from config.config_db import get_db_pool from config.config_system import STORAGE_BASE_DIR @@ -85,6 +86,9 @@ async def create_project( now, ), ) + # 워크플로우 단계별 상태 초기화 시드 + await initialize_project_stages(cursor, project_id) + await cursor.execute( """INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id) VALUES (%s, 'PROJECT_CREATE', 'project', NULL)""", diff --git a/B03_FileInput/B03_FileInput_Router.py b/B03_FileInput/B03_FileInput_Router.py index c07a620..803fc53 100644 --- a/B03_FileInput/B03_FileInput_Router.py +++ b/B03_FileInput/B03_FileInput_Router.py @@ -47,6 +47,12 @@ from B03_FileInput.B03_FileInput_Schema import ( from common_util.common_util_json import atomic_write_json from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_workflow import load_project_workflow +from common_util.common_util_workflow_state import ( + complete_stage, + fail_stage, + get_workflow_state, + start_stage, +) from config.config_db import get_db_pool from config.config_system import ( SEND_ANALYSIS_COMPLETION_EMAIL, @@ -155,8 +161,20 @@ async def trigger_wf1_analysis_and_email( pool = get_db_pool() project_info: dict[str, Any] | None = None try: - await _update_project_status(project_id, "WF1_ANALYZING") + source_filters = list(SURFACE_MODEL_SOURCE_FILTERS) + methods = list(SURFACE_MODEL_PRECOMPUTE) + params = { + "input_file_id": str(input_file_id), + "source_filters": source_filters, + "methods": methods, + "force": False, + } + async with pool.acquire() as connection: + async with connection.cursor() as cursor: + await start_stage(cursor, str(project_id), 1, params) + await connection.commit() + stored_path = await get_project_storage_relative_path(connection, project_id) project_info = await _get_project_notification_info(connection, project_id) if not project_info or not project_info.get("user_email"): @@ -176,8 +194,6 @@ async def trigger_wf1_analysis_and_email( project_id, input_file_id, ) - source_filters = list(SURFACE_MODEL_SOURCE_FILTERS) - methods = list(SURFACE_MODEL_PRECOMPUTE) from B04_wf1_Surface.B04_wf1_Surface_Engine import run_surface_analysis from B04_wf1_Surface.B04_wf1_Surface_Router import save_surface_analysis_to_db @@ -195,8 +211,6 @@ async def trigger_wf1_analysis_and_email( logger.info("WF1 분석 결과 DB 저장 시작: project_id=%s", project_id) async with pool.acquire() as connection: - # save_surface_analysis_to_db는 트랜잭션을 열지 않는다. - # 호출자가 begin/commit/rollback을 한 번만 책임진다. await connection.begin() try: await save_surface_analysis_to_db( @@ -206,8 +220,9 @@ async def trigger_wf1_analysis_and_email( analysis_result=analysis_result, source_filters=source_filters, ) + async with connection.cursor() as cursor: + await complete_stage(cursor, str(project_id), 1) await connection.commit() - await _update_project_status(project_id, "WF1_COMPLETE") logger.info("WF1 분석 결과 DB 저장 완료: project_id=%s", project_id) except Exception as e: logger.exception("WF1 분석 결과 DB 저장 실패: %s", e) @@ -237,7 +252,9 @@ async def trigger_wf1_analysis_and_email( logger.info("WF1 백그라운드 분석 완료: project_id=%s", project_id) except Exception as exc: logger.exception("WF1 백그라운드 분석 실패: project_id=%s", project_id) - await _update_project_status(project_id, "WF1_FAILED") + async with pool.acquire() as connection, connection.cursor() as cursor: + await fail_stage(cursor, str(project_id), 1, str(exc)) + await connection.commit() if project_info and project_info.get("user_email"): await send_analysis_error_email( project_id=project_id, @@ -326,6 +343,8 @@ async def upload_project_files( metadata=metadata, ) ) + async with connection.cursor() as cursor: + await complete_stage(cursor, str(project_id), 0) await connection.commit() except Exception: await connection.rollback() @@ -547,6 +566,8 @@ async def finalize_project_upload( metadata=metadata, ) await mark_upload_session_completed(connection, session_id=payload.session_id) + async with connection.cursor() as cursor: + await complete_stage(cursor, str(project_id), 0) await connection.commit() except Exception: await connection.rollback() @@ -641,3 +662,11 @@ async def get_project_upload_status( status_code=500, content={"status": "error", "message": "업로드 상태 조회 중 오류가 발생했습니다."}, ) + + +@router.get("/{project_id}/workflow-state") +async def get_project_workflow_state(project_id: str): + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + state = await get_workflow_state(cursor, project_id) + return {"status": "success", "workflow_state": state} diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine.py b/B04_wf1_Surface/B04_wf1_Surface_Engine.py index cec8f74..42024e1 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine.py @@ -4,6 +4,7 @@ 동기 계산 파이프라인. 라우터에서 asyncio.to_thread로 호출한다. """ +from collections.abc import Callable from pathlib import Path from typing import Any @@ -14,6 +15,9 @@ from B04_wf1_Surface.B04_wf1_Surface_Engine_Pipeline import build_all_terrain_mo from B04_wf1_Surface.B04_wf1_Surface_Engine_Structurize import structurize_las from config.config_system import build_surface_model_config +# 진행 콜백 시그니처: (진행률 0~100, 현재 단계 키, 메시지) +ProgressCallback = Callable[[int, str, str], None] + def _relative_to_project(project_root: Path, path: Path) -> str: """프로젝트 루트 기준 posix 상대 경로 문자열.""" @@ -27,6 +31,7 @@ def run_surface_analysis( source_filters: list[str], methods: list[str], force: bool = False, + on_progress: ProgressCallback | None = None, ) -> dict[str, Any]: """구조화→필터→모델 빌드를 수행하고 산출 메타데이터를 반환한다. @@ -36,6 +41,11 @@ def run_surface_analysis( - manifest: 지표면 모델 파이프라인 manifest - models: [{model_type, model_file_path, resolution_m, generation_params, layers}] """ + + def _report(percent: int, stage: str, message: str) -> None: + if on_progress is not None: + on_progress(percent, stage, message) + stage_root = project_root / "B04_wf1_Surface" processed_dir = stage_root / "processed" models_dir = stage_root / "models" @@ -43,6 +53,7 @@ def run_surface_analysis( models_dir.mkdir(parents=True, exist_ok=True) # 1. LAS 구조화 (structured.npz) + _report(10, "structurize", "LAS 구조화 중") structured_path = structurize_las(las_path, processed_dir) with np.load(structured_path) as structured: xyz = structured["xyz"] @@ -62,15 +73,19 @@ def run_surface_analysis( data = {"xyz": xyz, "bounds": bounds} # 2. 지면 필터 실행 + _report(40, "ground_filter", "지면 필터 적용 중") masks = build_ground_masks(data, source_filters) ground_summary = summarize_masks(data, masks) # 3. 지표면 5종 모델 빌드 + _report(70, "surface_model", "지표면 모델 생성 중") config = build_surface_model_config() config["source_filters"] = list(source_filters) config["precompute"] = list(methods) manifest = build_all_terrain_models(data, masks, models_dir, config, force=force) + _report(95, "saving", "결과 저장 중") + processed = { "processed_file_path": _relative_to_project(project_root, structured_path), "converted_file_path": None, diff --git a/B04_wf1_Surface/B04_wf1_Surface_Repository.py b/B04_wf1_Surface/B04_wf1_Surface_Repository.py index e9efb97..ed79e49 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Repository.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Repository.py @@ -206,10 +206,10 @@ async def list_project_point_cloud_inputs( await cursor.execute( """ SELECT id, file_type, original_filename, raw_file_path, file_size_mb, - crs_epsg, status, created_at + crs_epsg, status, upload_at FROM input_files WHERE project_id = %s AND file_type IN ('las', 'laz') - ORDER BY created_at DESC, id DESC + ORDER BY upload_at DESC, id DESC """, (str(project_id),), ) diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router.py b/B04_wf1_Surface/B04_wf1_Surface_Router.py index 03891b5..fd4373e 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Router.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Router.py @@ -32,12 +32,45 @@ from B04_wf1_Surface.B04_wf1_Surface_Schema import ( SurfaceModelSummary, SurfacePointCloudSampleResponse, ) +from common_util.common_util_json import atomic_write_json from common_util.common_util_storage import resolve_stored_project_path +from common_util.common_util_workflow_state import complete_stage, fail_stage, start_stage from config.config_db import get_db_pool logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B04 Surface Analysis"]) POINT_CLOUD_SAMPLE_LIMIT = 100_000 +# 분석 진행률 파일: B04 산출 폴더 아래에 원자적으로 기록/조회한다. +PROGRESS_FILE_RELATIVE = ("B04_wf1_Surface", "processed", "progress.json") + + +def _progress_file_path(project_root: Path) -> Path: + return project_root.joinpath(*PROGRESS_FILE_RELATIVE) + + +def write_surface_progress(project_root: Path, percent: int, stage: str, message: str) -> None: + """WF1 분석 진행률을 progress.json에 원자적으로 기록한다 (실패해도 분석은 계속).""" + try: + path = _progress_file_path(project_root) + path.parent.mkdir(parents=True, exist_ok=True) + atomic_write_json( + path, + {"progress_percent": percent, "current_stage": stage, "message": message}, + ) + except OSError: + logger.warning("WF1 진행률 기록 실패: %s", project_root, exc_info=True) + + +def read_surface_progress(project_root: Path) -> dict[str, Any] | None: + """progress.json을 읽어 반환한다. 없거나 손상 시 None.""" + path = _progress_file_path(project_root) + if not path.is_file(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else None + except (OSError, ValueError): + return None async def update_project_status( @@ -124,9 +157,17 @@ async def analyze_surface( pool = get_db_pool() try: + params = { + "input_file_id": request.input_file_id, + "source_filters": source_filters, + "methods": methods, + "force": request.force, + } async with pool.acquire() as connection: - await update_project_status(connection, project_id, "WF1_ANALYZING") - await connection.commit() + async with connection.cursor() as cursor: + await start_stage(cursor, str(project_id), 1, params) + await connection.commit() + stored_path = await get_project_storage_relative_path(connection, project_id) project_root = Path(resolve_stored_project_path(stored_path)) input_file = await get_input_file(connection, project_id, request.input_file_id) @@ -137,6 +178,12 @@ async def analyze_surface( content={"status": "error", "message": "원본 LAS 파일을 찾을 수 없습니다."}, ) + # 분석 시작 진행률 기록 (별도 스레드의 콜백은 파일에만 원자적 기록). + write_surface_progress(project_root, 5, "analyzing", "WF1 분석을 시작합니다.") + + def _on_progress(percent: int, stage: str, message: str) -> None: + write_surface_progress(project_root, percent, stage, message) + # 무거운 지형 연산은 이벤트 루프를 막지 않도록 별도 스레드에서 실행. result = await asyncio.to_thread( run_surface_analysis, @@ -145,6 +192,7 @@ async def analyze_surface( source_filters=source_filters, methods=methods, force=request.force, + on_progress=_on_progress, ) # DB 기록 (트랜잭션) @@ -157,12 +205,15 @@ async def analyze_surface( analysis_result=result, source_filters=source_filters, ) - await update_project_status(connection, project_id, "WF1_COMPLETE") + async with connection.cursor() as cursor: + await complete_stage(cursor, str(project_id), 1) await connection.commit() except Exception: await connection.rollback() raise + write_surface_progress(project_root, 100, "completed", "WF1 분석이 완료되었습니다.") + return SurfaceAnalyzeResponse( project_id=str(project_id), ground_summary=result["ground_summary"], @@ -172,14 +223,14 @@ async def analyze_surface( except LookupError as exc: return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) except (OSError, ValueError) as exc: - async with pool.acquire() as connection: - await update_project_status(connection, project_id, "WF1_FAILED") + async with pool.acquire() as connection, connection.cursor() as cursor: + await fail_stage(cursor, str(project_id), 1, str(exc)) await connection.commit() return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) - except Exception: + except Exception as exc: logger.exception("B04 지표면 분석 실패: project_id=%s", project_id) - async with pool.acquire() as connection: - await update_project_status(connection, project_id, "WF1_FAILED") + async with pool.acquire() as connection, connection.cursor() as cursor: + await fail_stage(cursor, str(project_id), 1, str(exc)) await connection.commit() return JSONResponse( status_code=500, @@ -317,43 +368,93 @@ async def get_wf1_analysis_status(project_id: UUID) -> dict: async with connection.cursor(aiomysql.DictCursor) as cursor: await cursor.execute( """ - SELECT p.status as project_status, COUNT(sm.id) as model_count - FROM projects p - LEFT JOIN surface_models sm ON sm.project_id = p.id - WHERE p.id = %s AND p.deleted_at IS NULL - GROUP BY p.id, p.status + SELECT state, progress_percent, message, + (SELECT COUNT(*) FROM surface_models + WHERE project_id = %s) as model_count + FROM project_workflow_stages + WHERE project_id = %s AND stage_no = 1 """, - (str(project_id),), + (str(project_id), str(project_id)), ) row = await cursor.fetchone() - if not row: - return JSONResponse( - status_code=404, - content={"status": "error", "message": "프로젝트를 찾을 수 없습니다."}, - ) - model_count = int(row["model_count"]) if row else 0 - project_status = str(row.get("project_status") or "NEW") - if project_status == "WF1_FAILED": + # 만약 새 테이블에 정보가 없다면 기존 projects 테이블에서 조회 (백필 미작동 대비) + if not row: + await cursor.execute( + """ + SELECT p.status as project_status, COUNT(sm.id) as model_count + FROM projects p + LEFT JOIN surface_models sm ON sm.project_id = p.id + WHERE p.id = %s AND p.deleted_at IS NULL + GROUP BY p.id, p.status + """, + (str(project_id),), + ) + fallback_row = await cursor.fetchone() + if not fallback_row: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "프로젝트를 찾을 수 없습니다."}, + ) + model_count = int(fallback_row["model_count"]) + project_status = str(fallback_row.get("project_status") or "NEW") + + if project_status == "WF1_FAILED": + state = "FAILED" + progress_percent = 0 + message = "WF1 분석에 실패했습니다." + elif model_count > 0 or project_status == "WF1_COMPLETE": + state = "COMPLETE" + progress_percent = 100 + message = "WF1 분석이 완료되었습니다." + elif project_status == "WF1_ANALYZING": + state = "IN_PROGRESS" + progress_percent = 30 + message = "WF1 분석이 진행 중입니다." + else: + state = "NOT_STARTED" + progress_percent = 0 + message = "WF1 분석 대기 중입니다." + else: + state = row["state"] + progress_percent = row["progress_percent"] + message = row["message"] or "" + model_count = int(row["model_count"]) + + if state == "FAILED": status = "failed" - progress_percent = 0 current_stage = "failed" - message = "WF1 분석에 실패했습니다." - elif model_count > 0 or project_status == "WF1_COMPLETE": + if not message: + message = "WF1 분석에 실패했습니다." + elif state == "COMPLETE": status = "completed" progress_percent = 100 current_stage = "completed" - message = "WF1 분석이 완료되었습니다." - elif project_status == "WF1_ANALYZING": + if not message: + message = "WF1 분석이 완료되었습니다." + elif state == "IN_PROGRESS": status = "in_progress" - progress_percent = 30 current_stage = "surface_analysis" - message = "WF1 분석이 진행 중입니다." + if not message: + message = "WF1 분석이 진행 중입니다." + # 진행률 파일이 있으면 실제 단계별 진행률로 대체 + try: + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + progress = read_surface_progress(Path(resolve_stored_project_path(stored_path))) + if progress: + progress_percent = int(progress.get("progress_percent", progress_percent)) + current_stage = str(progress.get("current_stage", current_stage)) + message = str(progress.get("message", message)) + except LookupError: + pass else: status = "pending" progress_percent = 0 current_stage = "pending" - message = "WF1 분석 대기 중입니다." + if not message: + message = "WF1 분석 대기 중입니다." + return { "project_id": str(project_id), "status": status, diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts index 20c0d81..4c603b5 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts @@ -2,10 +2,10 @@ * B04_wf1_Surface_UI_Page.ts * 로그인 후 04: 1차 워크플로우 (지표면 모델 분석) * - * 3단 레이아웃 (frontend.md §2): - * 상단: 페이지 타이틀 + 진행 단계 스텝바 (createWorkflowShell) - * 좌측: 입력 파일 ID + 지면 필터/지표면 표현 선택 + 실행 옵션 폼 - * 우측: 생성된 지표면 모델 목록 그리드 + * 데스크톱 IDE 스타일 레이아웃 (createWorkflowLayout): + * 헤더: 페이지 타이틀 + 진행 단계 스텝바 (숨김/복원 토글) + * 좌측 오버레이 패널: 입력 파일 자동 선택 + 지면 필터/지표면 표현 + 실행 옵션 + * 우측 메인: 3D 포인트클라우드 뷰어 + 지면 통계 + 지표면 모델 카드 * * 이벤트 핸들러 명명 (frontend.md §4): onB04_Surface_[기능]_[액션] * 텍스트는 ui_template_locale에 선(先) 등록 후 참조 (frontend.md §3). @@ -15,9 +15,7 @@ import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createButton, - createInputField, createTag, - createWorkflowShell, hideLoadingOverlay, showLoadingOverlay, showToast, @@ -81,180 +79,6 @@ function buildCheckboxGroup( return { root, selected }; } -export function renderB04SurfaceLegacy(root: HTMLElement): void { - const shell = createWorkflowShell({ - title: L("B04_Surface_Title"), - steps: workflowSteps(), - activeStep: 0, - }); - - /* ---- 좌측 입력 패널 ---- */ - const inputFileField = createInputField({ - label: L("B04_Surface_Field_InputId"), - type: "number", - min: 1, - placeholder: L("B04_Surface_Field_InputId_Placeholder"), - }); - - const filterGroup = buildCheckboxGroup(L("B04_Surface_Group_Filters"), SOURCE_FILTERS, [ - "grid_min_z", - "csf", - "pmf", - ]); - const methodGroup = buildCheckboxGroup(L("B04_Surface_Group_Methods"), MODEL_METHODS, [ - "dtm", - "tin", - ]); - - const forceLabel = document.createElement("label"); - forceLabel.className = "b04-surface__check"; - const forceBox = document.createElement("input"); - forceBox.type = "checkbox"; - const forceText = document.createElement("span"); - forceText.textContent = L("B04_Surface_Field_Force"); - forceLabel.append(forceBox, forceText); - - const analyzeButton = createButton({ - label: L("B04_Surface_Btn_Analyze"), - variant: "filled", - onClick: () => void onB04_Surface_Analyze_Click(), - }); - - const leftForm = document.createElement("div"); - leftForm.className = "b04-surface__form"; - leftForm.append( - inputFileField.root, - filterGroup.root, - methodGroup.root, - forceLabel, - analyzeButton, - ); - shell.leftPanel.append(leftForm); - - /* ---- 우측 결과 영역 ---- */ - const resultHeader = document.createElement("div"); - resultHeader.className = "b04-surface__result-head"; - const resultTitle = document.createElement("h3"); - resultTitle.textContent = L("B04_Surface_Result_Title"); - const refreshButton = createButton({ - label: L("B04_Surface_Btn_Refresh"), - variant: "ghost", - onClick: () => void onB04_Surface_Refresh_Click(), - }); - resultHeader.append(resultTitle, refreshButton); - - const modelList = document.createElement("div"); - modelList.className = "b04-surface__models"; - - const resultArea = document.createElement("div"); - resultArea.className = "b04-surface__result"; - resultArea.append(resultHeader, modelList); - shell.rightArea.append(resultArea); - - function renderModels(models: readonly SurfaceModelSummary[]): void { - modelList.replaceChildren(); - if (models.length === 0) { - const empty = document.createElement("p"); - empty.className = "b04-surface__empty"; - empty.textContent = L("B04_Surface_Result_Empty"); - modelList.append(empty); - return; - } - for (const model of models) { - const card = document.createElement("div"); - card.className = "b04-surface__model-card"; - const head = document.createElement("div"); - head.className = "b04-surface__model-head"; - const type = document.createElement("strong"); - type.textContent = model.model_type; - const variant = - model.status === "CONFIRMED" ? "success" : model.status === "FAILED" ? "danger" : "neutral"; - head.append(type, createTag(model.status, variant)); - - const meta = document.createElement("div"); - meta.className = "b04-surface__model-meta"; - const resolution = document.createElement("span"); - resolution.textContent = `${L("B04_Surface_Model_Resolution")}: ${model.resolution_m ?? "-"}`; - const path = document.createElement("span"); - path.textContent = `${L("B04_Surface_Model_Path")}: ${model.model_file_path ?? "-"}`; - meta.append(resolution, path); - - card.append(head, meta); - modelList.append(card); - } - } - - function getProjectId(): string | null { - const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); - if (!projectId) { - inputFileField.setError(L("B04_Surface_Error_Project")); - showToast(L("B04_Surface_Error_Project"), "error"); - } - return projectId; - } - - async function onB04_Surface_Analyze_Click(): Promise { - const projectId = getProjectId(); - if (!projectId) return; - - const rawId = inputFileField.input.value.trim(); - const inputFileId = Number(rawId); - if (!rawId || !Number.isInteger(inputFileId) || inputFileId <= 0) { - inputFileField.setError(L("B04_Surface_Error_InputId")); - return; - } - if (filterGroup.selected.size === 0 || methodGroup.selected.size === 0) { - inputFileField.setError(L("B04_Surface_Error_Selection")); - return; - } - inputFileField.setError(); - - showLoadingOverlay(); - try { - const response = await analyzeSurface(projectId, { - input_file_id: inputFileId, - source_filters: [...filterGroup.selected], - methods: [...methodGroup.selected], - force: forceBox.checked, - }); - showToast( - `${L("B04_Surface_Analyze_Success")} (${response.surface_model_ids.length})`, - "success", - ); - await loadModels(projectId); - } catch (error) { - const detail = error instanceof Error ? error.message : L("B04_Surface_Analyze_Failed"); - inputFileField.setError(`${L("B04_Surface_Analyze_Failed")} ${detail}`); - showToast(L("B04_Surface_Analyze_Failed"), "error"); - } finally { - hideLoadingOverlay(); - } - } - - async function loadModels(projectId: string): Promise { - showLoadingOverlay(); - try { - const response = await listSurfaceModels(projectId); - renderModels(response.models); - } catch { - showToast(L("B04_Surface_Load_Failed"), "error"); - } finally { - hideLoadingOverlay(); - } - } - - async function onB04_Surface_Refresh_Click(): Promise { - const projectId = getProjectId(); - if (projectId) await loadModels(projectId); - } - - renderModels([]); - root.replaceChildren(shell.root); - - const initialProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); - if (initialProjectId) void loadModels(initialProjectId); -} - function buildInfoLine(label: string, value: unknown): HTMLElement { const row = document.createElement("div"); row.className = "b04-surface__line"; diff --git a/B05_wf2_Route/B05_wf2_Route_Router.py b/B05_wf2_Route/B05_wf2_Route_Router.py index ec343b8..50ffe89 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router.py +++ b/B05_wf2_Route/B05_wf2_Route_Router.py @@ -23,6 +23,7 @@ from B05_wf2_Route.B05_wf2_Route_Schema import ( RouteSolveResponse, ) from common_util.common_util_storage import resolve_stored_project_path +from common_util.common_util_workflow_state import complete_stage, fail_stage, start_stage from config.config_db import get_db_pool logger = logging.getLogger(__name__) @@ -36,7 +37,20 @@ async def solve_route( """경로 탐색을 실행하고 결과를 GeoJSON 저장 + DB 기록한다.""" pool = get_db_pool() try: + params = { + "filter_key": request.filter_key, + "method": request.method, + "smooth": request.smooth, + "points": request.points, + "options": request.options(), + "algorithm": request.algorithm, + "surface_model_id": request.surface_model_id, + } async with pool.acquire() as connection: + async with connection.cursor() as cursor: + await start_stage(cursor, str(project_id), 2, params) + await connection.commit() + stored_path = await get_project_storage_relative_path(connection, project_id) project_root = Path(resolve_stored_project_path(stored_path)) @@ -78,6 +92,8 @@ async def solve_route( mean_slope=stats["mean_slope"], cost_score=stats["cost_score"], ) + async with connection.cursor() as cursor: + await complete_stage(cursor, str(project_id), 2) await connection.commit() except Exception: await connection.rollback() @@ -92,13 +108,25 @@ async def solve_route( route_data_path=design["route_data_path"], ) except LookupError as exc: + async with pool.acquire() as connection, connection.cursor() as cursor: + await fail_stage(cursor, str(project_id), 2, str(exc)) + await connection.commit() return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) except FileNotFoundError as exc: + async with pool.acquire() as connection, connection.cursor() as cursor: + await fail_stage(cursor, str(project_id), 2, str(exc)) + await connection.commit() return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) except (OSError, ValueError) as exc: + async with pool.acquire() as connection, connection.cursor() as cursor: + await fail_stage(cursor, str(project_id), 2, str(exc)) + await connection.commit() return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) - except Exception: + except Exception as exc: logger.exception("B05 경로 탐색 실패: project_id=%s", project_id) + async with pool.acquire() as connection, connection.cursor() as cursor: + await fail_stage(cursor, str(project_id), 2, str(exc)) + await connection.commit() return JSONResponse( status_code=500, content={"status": "error", "message": "경로 탐색 처리 중 오류가 발생했습니다."}, @@ -120,6 +148,8 @@ async def confirm_latest_route(project_id: UUID) -> RouteConfirmResponse | JSONR await connection.begin() try: await confirm_route(connection, latest["id"]) + async with connection.cursor() as cursor: + await complete_stage(cursor, str(project_id), 2) await connection.commit() except Exception: await connection.rollback() diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py index 803cf06..4f07375 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py @@ -26,6 +26,7 @@ from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Schema import ( SectionSummaryResponse, ) from common_util.common_util_storage import resolve_stored_project_path +from common_util.common_util_workflow_state import complete_stage, fail_stage, start_stage from config.config_db import get_db_pool logger = logging.getLogger(__name__) @@ -51,7 +52,22 @@ async def generate_sections( """확정 경로에서 종횡단을 생성·저장하고 DB에 기록한다.""" pool = get_db_pool() try: + params = { + "route_id": request.route_id, + "filter_key": request.filter_key, + "method": request.method, + "smooth": request.smooth, + "station_interval_m": request.station_interval_m, + "cross_half_width_m": request.cross_half_width_m, + "cross_sample_interval_m": request.cross_sample_interval_m, + "long_sample_interval_m": request.long_sample_interval_m, + "crs": request.crs, + } async with pool.acquire() as connection: + async with connection.cursor() as cursor: + await start_stage(cursor, str(project_id), 3, params) + await connection.commit() + stored_path = await get_project_storage_relative_path(connection, project_id) project_root = Path(resolve_stored_project_path(stored_path)) @@ -91,6 +107,8 @@ async def generate_sections( route_id=request.route_id, sections=design["cross_sections"], ) + async with connection.cursor() as cursor: + await complete_stage(cursor, str(project_id), 3) await connection.commit() except Exception: await connection.rollback() @@ -105,13 +123,25 @@ async def generate_sections( longitudinal_file_path=design["longitudinal"]["file_path"], ) except LookupError as exc: + async with pool.acquire() as connection, connection.cursor() as cursor: + await fail_stage(cursor, str(project_id), 3, str(exc)) + await connection.commit() return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) except FileNotFoundError as exc: + async with pool.acquire() as connection, connection.cursor() as cursor: + await fail_stage(cursor, str(project_id), 3, str(exc)) + await connection.commit() return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) except (OSError, ValueError) as exc: + async with pool.acquire() as connection, connection.cursor() as cursor: + await fail_stage(cursor, str(project_id), 3, str(exc)) + await connection.commit() return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) - except Exception: + except Exception as exc: logger.exception("B06 종횡단 생성 실패: project_id=%s", project_id) + async with pool.acquire() as connection, connection.cursor() as cursor: + await fail_stage(cursor, str(project_id), 3, str(exc)) + await connection.commit() return JSONResponse( status_code=500, content={"status": "error", "message": "종횡단 생성 처리 중 오류가 발생했습니다."}, @@ -137,7 +167,9 @@ async def get_sections(project_id: UUID, route_id: int) -> SectionSummaryRespons @router.post("/{project_id}/sections/{route_id}/confirm", response_model=SectionConfirmResponse) -async def confirm_sections(project_id: UUID, route_id: int) -> SectionConfirmResponse | JSONResponse: +async def confirm_sections( + project_id: UUID, route_id: int +) -> SectionConfirmResponse | JSONResponse: """경로의 종횡단면을 확정(CONFIRMED)한다.""" pool = get_db_pool() try: @@ -151,6 +183,8 @@ async def confirm_sections(project_id: UUID, route_id: int) -> SectionConfirmRes await connection.begin() try: await confirm_sections_for_route(connection, route_id) + async with connection.cursor() as cursor: + await complete_stage(cursor, str(project_id), 3) await connection.commit() except Exception: await connection.rollback() diff --git a/common_util/common_util_workflow_state.py b/common_util/common_util_workflow_state.py new file mode 100644 index 0000000..93f2f79 --- /dev/null +++ b/common_util/common_util_workflow_state.py @@ -0,0 +1,210 @@ +"""워크플로우 단계별 상태 관리를 위한 공통 유틸리티.""" + +import json +from datetime import datetime +from typing import Any, Dict + +import aiomysql + +STAGE_KEYS = [ + "FILE_INPUT", # 0 + "WF1_SURFACE", # 1 + "WF2_ROUTE", # 2 + "WF3_PROFILE_CROSS", # 3 + "WF4_DESIGN_DETAIL", # 4 + "WF5_QUANTITY", # 5 + "WF6_ESTIMATION", # 6 +] + + +async def initialize_project_stages(cursor: aiomysql.DictCursor, project_id: str) -> None: + """프로젝트 생성 시 7개의 단계를 NOT_STARTED 상태로 시드한다.""" + for stage_no, stage_key in enumerate(STAGE_KEYS): + await cursor.execute( + """ + INSERT INTO project_workflow_stages ( + project_id, stage_no, stage_key, state, progress_percent + ) + VALUES (%s, %s, %s, 'NOT_STARTED', 0) + ON DUPLICATE KEY UPDATE + state = 'NOT_STARTED', + progress_percent = 0, + params = NULL, + message = NULL, + started_at = NULL, + completed_at = NULL + """, + (project_id, stage_no, stage_key), + ) + + +async def start_stage( + cursor: aiomysql.DictCursor, + project_id: str, + stage_no: int, + params: Dict[str, Any] | None = None, +) -> None: + """단계를 시작하여 IN_PROGRESS 상태로 만들고, 이후 단계들을 STALE로 전환한다.""" + now = datetime.utcnow() + params_json = json.dumps(params, ensure_ascii=False) if params is not None else None + + # 해당 단계 시작 + await cursor.execute( + """ + UPDATE project_workflow_stages + SET state = 'IN_PROGRESS', + progress_percent = 0, + params = COALESCE(%s, params), + message = NULL, + started_at = %s, + completed_at = NULL + WHERE project_id = %s AND stage_no = %s + """, + (params_json, now, project_id, stage_no), + ) + + # 역방향 재작업 무효화 (stale 전파): 이후 단계 중 COMPLETE인 것들을 STALE로 변경 + await cursor.execute( + """ + UPDATE project_workflow_stages + SET state = 'STALE' + WHERE project_id = %s AND stage_no > %s AND state = 'COMPLETE' + """, + (project_id, stage_no), + ) + + # projects.status 캐시 업데이트 (호환성 유지) + status_str = f"WF{stage_no}_ANALYZING" if stage_no > 0 else "FILE_INPUT_PROCESSING" + await cursor.execute( + """ + UPDATE projects + SET status = %s, updated_at = %s + WHERE id = %s + """, + (status_str, now, project_id), + ) + + +async def complete_stage(cursor: aiomysql.DictCursor, project_id: str, stage_no: int) -> None: + """단계를 완료 상태로 전환한다.""" + now = datetime.utcnow() + await cursor.execute( + """ + UPDATE project_workflow_stages + SET state = 'COMPLETE', + progress_percent = 100, + completed_at = %s + WHERE project_id = %s AND stage_no = %s + """, + (now, project_id, stage_no), + ) + + # projects.status 캐시 업데이트 (호환성 유지) + status_str = f"WF{stage_no}_COMPLETE" if stage_no > 0 else "FILE_UPLOADED" + if stage_no == 6: + status_str = "DONE" + + await cursor.execute( + """ + UPDATE projects + SET status = %s, updated_at = %s + WHERE id = %s + """, + (status_str, now, project_id), + ) + + +async def update_stage_progress( + cursor: aiomysql.DictCursor, project_id: str, stage_no: int, progress: int +) -> None: + """단계 진행률을 업데이트한다.""" + await cursor.execute( + """ + UPDATE project_workflow_stages + SET progress_percent = %s + WHERE project_id = %s AND stage_no = %s + """, + (progress, project_id, stage_no), + ) + + +async def fail_stage( + cursor: aiomysql.DictCursor, project_id: str, stage_no: int, message: str +) -> None: + """단계를 실패 상태로 전환한다.""" + now = datetime.utcnow() + await cursor.execute( + """ + UPDATE project_workflow_stages + SET state = 'FAILED', + message = %s + WHERE project_id = %s AND stage_no = %s + """, + (message, project_id, stage_no), + ) + + # projects.status 캐시 업데이트 + status_str = f"WF{stage_no}_FAILED" if stage_no > 0 else "FILE_INPUT_FAILED" + await cursor.execute( + """ + UPDATE projects + SET status = %s, updated_at = %s + WHERE id = %s + """, + (status_str, now, project_id), + ) + + +async def get_workflow_state(cursor: aiomysql.DictCursor, project_id: str) -> Dict[str, Any]: + """프로젝트의 모든 단계 상태를 조회하여 요약 및 배열로 반환한다.""" + await cursor.execute( + """ + SELECT stage_no, stage_key, state, progress_percent, params, message, + DATE_FORMAT(started_at, '%%Y-%%m-%%dT%%H:%%i:%%s') AS started_at, + DATE_FORMAT(completed_at, '%%Y-%%m-%%dT%%H:%%i:%%s') AS completed_at + FROM project_workflow_stages + WHERE project_id = %s + ORDER BY stage_no ASC + """, + (project_id,), + ) + rows = await cursor.fetchall() + + if not rows: + return {"project_id": project_id, "current_stage": 0, "stages": []} + + stages_list = [] + for r in rows: + params_val = None + if r.get("params"): + try: + params_val = ( + json.loads(r["params"]) if isinstance(r["params"], str) else r["params"] + ) + except Exception: + params_val = r["params"] + + stages_list.append( + { + "stage_no": r["stage_no"], + "stage_key": r["stage_key"], + "state": r["state"], + "progress_percent": r["progress_percent"], + "params": params_val, + "message": r["message"], + "started_at": r["started_at"], + "completed_at": r["completed_at"], + } + ) + + current_stage = 0 + for stage in stages_list: + if stage["stage_no"] == 0: + continue + prev_stage = stages_list[stage["stage_no"] - 1] + if prev_stage["state"] == "COMPLETE": + current_stage = stage["stage_no"] + else: + break + + return {"project_id": project_id, "current_stage": current_stage, "stages": stages_list} diff --git a/db_management/006_workflow_state.sql b/db_management/006_workflow_state.sql new file mode 100644 index 0000000..16e86b6 --- /dev/null +++ b/db_management/006_workflow_state.sql @@ -0,0 +1,23 @@ +-- ============================================================================= +-- 워크플로우 단계별 상태 관리 테이블 +-- DBMS: MariaDB 10.6+ +-- ============================================================================= + +USE aislo_db; + +CREATE TABLE IF NOT EXISTS project_workflow_stages ( + id INT AUTO_INCREMENT PRIMARY KEY, + project_id CHAR(36) NOT NULL, + stage_no TINYINT NOT NULL, -- 0=파일입력, 1=WF1 ... 6=WF6 + stage_key VARCHAR(30) NOT NULL, -- 'FILE_INPUT','WF1_SURFACE',...,'WF6_ESTIMATION' + state ENUM('NOT_STARTED','IN_PROGRESS','COMPLETE','FAILED','STALE') NOT NULL DEFAULT 'NOT_STARTED', + progress_percent TINYINT NOT NULL DEFAULT 0, + params JSON NULL, -- 해당 단계에서 사용자가 선택/입력한 값 (재실행 시 재사용) + message VARCHAR(255) NULL, -- 진행/오류 메시지 + started_at TIMESTAMP NULL, + completed_at TIMESTAMP NULL, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uq_project_stage (project_id, stage_no), + INDEX idx_pws_project (project_id), + CONSTRAINT fk_pws_project_id FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/db_management/migrate_workflow_state.py b/db_management/migrate_workflow_state.py new file mode 100644 index 0000000..068de1a --- /dev/null +++ b/db_management/migrate_workflow_state.py @@ -0,0 +1,158 @@ +"""워크플로우 단계별 상태 테이블 생성 및 기존 프로젝트 백필 마이그레이션 스크립트.""" + +import asyncio +import sys +from pathlib import Path + +import aiomysql + +# Import DB config +sys.path.append(str(Path(__file__).parent.parent)) +from common_util.common_util_workflow_state import STAGE_KEYS +from config.config_db import DB_HOST, DB_NAME, DB_PASSWORD, DB_PORT, DB_USER + + +async def migrate(): + print("Database connecting...") + connection = await aiomysql.connect( + host=DB_HOST, + port=DB_PORT, + user=DB_USER, + password=DB_PASSWORD, + db=DB_NAME, + charset="utf8mb4", + ) + + try: + async with connection.cursor() as cursor: + # 1. 테이블 생성 + sql_file = Path(__file__).parent / "006_workflow_state.sql" + print(f"Executing schema from {sql_file.name}...") + sql_content = sql_file.read_text(encoding="utf-8") + # Remove USE aislo_db; to avoid issues if any, but splitting is fine + statements = [stmt.strip() for stmt in sql_content.split(";") if stmt.strip()] + for stmt in statements: + await cursor.execute(stmt) + print("Table project_workflow_stages created successfully.") + + await connection.commit() + + # 2. 기존 프로젝트 백필 + async with connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute("SELECT id, status FROM projects") + projects = await cursor.fetchall() + print(f"Found {len(projects)} existing projects to backfill.") + + for proj in projects: + proj_id = proj["id"] + status = proj["status"] or "NEW" + print(f"Backfilling project {proj_id} with status '{status}'...") + + # 각 단계별 상태 매핑 결정 + # 0=FILE_INPUT, 1=SURFACE, 2=ROUTE, 3=PROFILE_CROSS + # 4=DESIGN_DETAIL, 5=QUANTITY, 6=ESTIMATION + states = ["NOT_STARTED"] * 7 + + if status == "NEW": + pass + elif status in ("FILE_INPUT_PROCESSING", "FILE_INPUT_FAILED"): + states[0] = "IN_PROGRESS" if "PROCESSING" in status else "FAILED" + elif status == "FILE_UPLOADED": + states[0] = "COMPLETE" + elif "WF1" in status: + states[0] = "COMPLETE" + if "ANALYZING" in status: + states[1] = "IN_PROGRESS" + elif "FAILED" in status: + states[1] = "FAILED" + else: + states[1] = "COMPLETE" + elif "WF2" in status: + states[0] = "COMPLETE" + states[1] = "COMPLETE" + if "ANALYZING" in status: + states[2] = "IN_PROGRESS" + elif "FAILED" in status: + states[2] = "FAILED" + else: + states[2] = "COMPLETE" + elif "WF3" in status: + states[0] = "COMPLETE" + states[1] = "COMPLETE" + states[2] = "COMPLETE" + if "ANALYZING" in status: + states[3] = "IN_PROGRESS" + elif "FAILED" in status: + states[3] = "FAILED" + else: + states[3] = "COMPLETE" + elif "WF4" in status: + states[0] = "COMPLETE" + states[1] = "COMPLETE" + states[2] = "COMPLETE" + states[3] = "COMPLETE" + if "ANALYZING" in status: + states[4] = "IN_PROGRESS" + elif "FAILED" in status: + states[4] = "FAILED" + else: + states[4] = "COMPLETE" + elif "WF5" in status: + states[0] = "COMPLETE" + states[1] = "COMPLETE" + states[2] = "COMPLETE" + states[3] = "COMPLETE" + states[4] = "COMPLETE" + if "ANALYZING" in status: + states[5] = "IN_PROGRESS" + elif "FAILED" in status: + states[5] = "FAILED" + else: + states[5] = "COMPLETE" + elif "WF6" in status: + states[0] = "COMPLETE" + states[1] = "COMPLETE" + states[2] = "COMPLETE" + states[3] = "COMPLETE" + states[4] = "COMPLETE" + states[5] = "COMPLETE" + if "ANALYZING" in status: + states[6] = "IN_PROGRESS" + elif "FAILED" in status: + states[6] = "FAILED" + else: + states[6] = "COMPLETE" + elif status in ("DONE", "CONFIRMED"): + for i in range(7): + states[i] = "COMPLETE" + + # DB에 단계별 정보 입력 (ON DUPLICATE KEY UPDATE로 덮어쓰기 안전) + for stage_no, stage_key in enumerate(STAGE_KEYS): + state = states[stage_no] + progress = 100 if state == "COMPLETE" else 0 + await cursor.execute( + """ + INSERT INTO project_workflow_stages ( + project_id, stage_no, stage_key, state, progress_percent + ) + VALUES (%s, %s, %s, %s, %s) + ON DUPLICATE KEY UPDATE state = %s, progress_percent = %s + """, + (proj_id, stage_no, stage_key, state, progress, state, progress), + ) + + await connection.commit() + print("Backfill complete!") + return True + + except Exception as e: + print(f"Migration / Backfill failed: {e}") + await connection.rollback() + return False + finally: + connection.close() + + +if __name__ == "__main__": + success = asyncio.run(migrate()) + sys.exit(0 if success else 1) diff --git a/ui_template/ui_template_workflow_layout.css b/ui_template/ui_template_workflow_layout.css index 685568c..7228e82 100644 --- a/ui_template/ui_template_workflow_layout.css +++ b/ui_template/ui_template_workflow_layout.css @@ -16,6 +16,25 @@ } .ui-workflow-layout__menu-button { + position: absolute; + top: 60px; + left: var(--spacing-24); + z-index: var(--z-dropdown); + width: auto; + min-width: 40px; + height: 40px; + padding: 0 var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-buttons); + background: var(--color-canvas); + color: var(--color-text); + cursor: pointer; + display: flex; + align-items: center; + gap: var(--spacing-8); +} + +.ui-workflow-layout__header-hide { width: 40px; height: 40px; border: 1px solid var(--color-border); @@ -23,6 +42,35 @@ background: var(--color-canvas); color: var(--color-text); cursor: pointer; + flex: 0 0 auto; +} + +/* 헤더 슬라이드업 숨김 */ +.ui-workflow-layout__header { + transition: margin-top var(--transition-base); +} + +.ui-workflow-layout.is-header-hidden .ui-workflow-layout__header { + margin-top: calc(-1 * var(--wf-header-height)); + pointer-events: none; +} + +/* 헤더 복원 화살표: 숨김 상태에서만 표시 */ +.ui-workflow-layout__header-reveal { + display: none; + align-self: center; + width: 48px; + height: 24px; + margin: var(--spacing-4) auto; + border: 1px solid var(--color-border); + border-radius: var(--radius-pills); + background: var(--color-surface-raised); + color: var(--color-text-secondary); + cursor: pointer; +} + +.ui-workflow-layout.is-header-hidden .ui-workflow-layout__header-reveal { + display: block; } .ui-workflow-layout__title-wrap { @@ -61,27 +109,41 @@ .ui-workflow-layout__body { position: relative; - display: grid; - grid-template-columns: 280px minmax(0, 1fr); + display: block; flex: 1; min-height: 0; } -.ui-workflow-layout__panel { +.ui-workflow-layout__dropdown-panel { + position: absolute; + top: 108px; + left: var(--spacing-24); + width: 320px; + max-height: calc(100vh - var(--wf-header-height) - 80px); z-index: var(--z-dropdown); overflow-y: auto; - border-right: 1px solid var(--color-border); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); background: var(--color-surface); + box-shadow: var(--shadow-lg); + padding: var(--spacing-16); + opacity: 0; + transform: translateY(-10px); + pointer-events: none; transition: - margin-left var(--transition-base), - box-shadow var(--transition-base); + opacity var(--transition-base), + transform var(--transition-base); } -.ui-workflow-layout:not(.is-menu-open) .ui-workflow-layout__panel { - margin-left: -280px; +.ui-workflow-layout.is-menu-open .ui-workflow-layout__dropdown-panel { + opacity: 1; + transform: translateY(0); + pointer-events: all; } .ui-workflow-layout__main { + width: 100%; + height: 100%; min-width: 0; min-height: 0; overflow: auto; @@ -95,23 +157,9 @@ gap: var(--spacing-8); } - .ui-workflow-layout__body { - display: block; - } - - .ui-workflow-layout__panel { - position: absolute; - inset: 0 auto 0 0; - width: 280px; - box-shadow: var(--shadow-lg); - } - - .ui-workflow-layout:not(.is-menu-open) .ui-workflow-layout__panel { - margin-left: -280px; - box-shadow: none; - } - - .ui-workflow-layout__main { - min-height: calc(100vh - var(--wf-header-height)); + .ui-workflow-layout__dropdown-panel { + left: var(--spacing-8); + right: var(--spacing-8); + width: auto; } } diff --git a/ui_template/ui_template_workflow_layout.ts b/ui_template/ui_template_workflow_layout.ts index 575a9e0..969656c 100644 --- a/ui_template/ui_template_workflow_layout.ts +++ b/ui_template/ui_template_workflow_layout.ts @@ -7,11 +7,13 @@ export interface WorkflowLayoutOptions { leftPanel: HTMLElement; mainContent: HTMLElement; onMenuToggle?: (isOpen: boolean) => void; + onHeaderToggle?: (isVisible: boolean) => void; } export interface WorkflowLayoutHandle { root: HTMLElement; setMenuOpen: (isOpen: boolean) => void; + setHeaderVisible: (isVisible: boolean) => void; } function createStepBar(steps: readonly string[], activeStep: number): HTMLElement { @@ -37,8 +39,8 @@ export function createWorkflowLayout(options: WorkflowLayoutOptions): WorkflowLa const menuButton = document.createElement("button"); menuButton.type = "button"; menuButton.className = "ui-workflow-layout__menu-button"; - menuButton.setAttribute("aria-label", "Toggle workflow menu"); - menuButton.textContent = "☰"; + menuButton.setAttribute("aria-label", "Toggle settings panel"); + menuButton.innerHTML = "🛠️ Option ▾"; const titleWrap = document.createElement("div"); titleWrap.className = "ui-workflow-layout__title-wrap"; @@ -47,33 +49,71 @@ export function createWorkflowLayout(options: WorkflowLayoutOptions): WorkflowLa title.textContent = options.title; titleWrap.append(title, createStepBar(options.steps, options.activeStep)); - header.append(menuButton, titleWrap); + const headerHideButton = document.createElement("button"); + headerHideButton.type = "button"; + headerHideButton.className = "ui-workflow-layout__header-hide"; + headerHideButton.setAttribute("aria-label", "Hide header"); + headerHideButton.textContent = "▲"; + + header.append(titleWrap, headerHideButton); + + // 헤더 숨김 시 표시되는 복원 화살표 + const headerRevealButton = document.createElement("button"); + headerRevealButton.type = "button"; + headerRevealButton.className = "ui-workflow-layout__header-reveal"; + headerRevealButton.setAttribute("aria-label", "Show header"); + headerRevealButton.textContent = "▼"; const body = document.createElement("div"); body.className = "ui-workflow-layout__body"; - const aside = document.createElement("aside"); - aside.className = "ui-workflow-layout__panel"; - aside.append(options.leftPanel); + const dropdownPanel = document.createElement("div"); + dropdownPanel.className = "ui-workflow-layout__dropdown-panel"; + dropdownPanel.append(options.leftPanel); const main = document.createElement("main"); main.className = "ui-workflow-layout__main"; - main.append(options.mainContent); + main.style.position = "relative"; + main.append(menuButton, options.mainContent); - body.append(aside, main); - root.append(header, body); + body.append(dropdownPanel, main); + root.append(header, headerRevealButton, body); function setMenuOpen(isOpen: boolean): void { root.classList.toggle("is-menu-open", isOpen); menuButton.setAttribute("aria-expanded", String(isOpen)); + const icon = menuButton.querySelector("span"); + if (icon) { + icon.textContent = isOpen ? "Option ▴" : "Option ▾"; + } options.onMenuToggle?.(isOpen); } - menuButton.addEventListener("click", () => setMenuOpen(!root.classList.contains("is-menu-open"))); - main.addEventListener("click", () => { - if (root.classList.contains("is-menu-open")) setMenuOpen(false); - }); - setMenuOpen(true); + function setHeaderVisible(isVisible: boolean): void { + root.classList.toggle("is-header-hidden", !isVisible); + headerHideButton.setAttribute("aria-expanded", String(isVisible)); + options.onHeaderToggle?.(isVisible); + } - return { root, setMenuOpen }; + menuButton.addEventListener("click", (e) => { + e.stopPropagation(); + setMenuOpen(!root.classList.contains("is-menu-open")); + }); + + document.addEventListener("click", (e) => { + if (root.classList.contains("is-menu-open")) { + const target = e.target as HTMLElement; + if (!dropdownPanel.contains(target) && !menuButton.contains(target)) { + setMenuOpen(false); + } + } + }); + + headerHideButton.addEventListener("click", () => setHeaderVisible(false)); + headerRevealButton.addEventListener("click", () => setHeaderVisible(true)); + + setMenuOpen(false); + setHeaderVisible(true); + + return { root, setMenuOpen, setHeaderVisible }; }