260710_1
This commit is contained in:
+551
-484
File diff suppressed because it is too large
Load Diff
@@ -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 폴더명과 정합).
|
||||||
@@ -11,7 +11,10 @@
|
|||||||
"Bash(npx.cmd tsc *)",
|
"Bash(npx.cmd tsc *)",
|
||||||
"Bash(./venv/Scripts/ruff.exe format *)",
|
"Bash(./venv/Scripts/ruff.exe format *)",
|
||||||
"Bash(./venv/Scripts/ruff.exe check *)",
|
"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)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,23 @@ export interface DashboardUser {
|
|||||||
status: string;
|
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 {
|
export interface ProjectItem {
|
||||||
id: string;
|
id: string;
|
||||||
company_id?: number | null;
|
company_id?: number | null;
|
||||||
@@ -27,6 +44,7 @@ export interface ProjectItem {
|
|||||||
owner_name?: string | null;
|
owner_name?: string | null;
|
||||||
workflow_stage: number;
|
workflow_stage: number;
|
||||||
progress_percent: number;
|
progress_percent: number;
|
||||||
|
workflow_state?: WorkflowState;
|
||||||
updated_at?: string | null;
|
updated_at?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,3 +366,9 @@ export function deleteProjectAutomation(automationId: number): Promise<unknown>
|
|||||||
export function executeProjectAutomation(automationId: number): Promise<unknown> {
|
export function executeProjectAutomation(automationId: number): Promise<unknown> {
|
||||||
return request(`/dashboard/automations/${automationId}/execute`, { method: "POST" });
|
return request(`/dashboard/automations/${automationId}/execute`, { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchProjectWorkflowState(projectId: string): Promise<WorkflowState> {
|
||||||
|
return request(`/projects/${projectId}/workflow-state`, {
|
||||||
|
method: "GET",
|
||||||
|
}) as Promise<WorkflowState>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
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]]:
|
async def list_user_projects(user_id: int) -> list[dict[str, Any]]:
|
||||||
pool = get_db_pool()
|
pool = get_db_pool()
|
||||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
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""",
|
ORDER BY updated_at DESC, created_at DESC""",
|
||||||
(user_id,),
|
(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]]:
|
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""",
|
ORDER BY p.updated_at DESC, p.created_at DESC""",
|
||||||
(company_id,),
|
(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]]:
|
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
|
WHERE p.deleted_at IS NULL
|
||||||
ORDER BY p.updated_at DESC, p.created_at DESC"""
|
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:
|
async def get_project(project_id: str) -> dict[str, Any] | None:
|
||||||
|
|||||||
@@ -380,12 +380,22 @@ function workflow(project: ProjectItem): HTMLElement {
|
|||||||
];
|
];
|
||||||
const box = document.createElement("div");
|
const box = document.createElement("div");
|
||||||
box.className = "b01-dashboard__workflow";
|
box.className = "b01-dashboard__workflow";
|
||||||
|
|
||||||
|
const stages = project.workflow_state?.stages;
|
||||||
|
|
||||||
routes.forEach((route, index) => {
|
routes.forEach((route, index) => {
|
||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
button.type = "button";
|
button.type = "button";
|
||||||
button.className = "b01-dashboard__step";
|
button.className = "b01-dashboard__step";
|
||||||
button.textContent = `B${String(index + 3).padStart(2, "0")}`;
|
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;
|
button.disabled = !enabled;
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
button.classList.add("is-enabled");
|
button.classList.add("is-enabled");
|
||||||
@@ -394,6 +404,23 @@ function workflow(project: ProjectItem): HTMLElement {
|
|||||||
navigateTo(route);
|
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);
|
box.append(button);
|
||||||
});
|
});
|
||||||
return box;
|
return box;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import aiomysql
|
|||||||
from common_util.common_util_json import atomic_write_json
|
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_storage import PROJECT_STORAGE_LAYOUT_V2
|
||||||
from common_util.common_util_workflow import load_project_workflow
|
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_db import get_db_pool
|
||||||
from config.config_system import STORAGE_BASE_DIR
|
from config.config_system import STORAGE_BASE_DIR
|
||||||
|
|
||||||
@@ -85,6 +86,9 @@ async def create_project(
|
|||||||
now,
|
now,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
# 워크플로우 단계별 상태 초기화 시드
|
||||||
|
await initialize_project_stages(cursor, project_id)
|
||||||
|
|
||||||
await cursor.execute(
|
await cursor.execute(
|
||||||
"""INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id)
|
"""INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id)
|
||||||
VALUES (%s, 'PROJECT_CREATE', 'project', NULL)""",
|
VALUES (%s, 'PROJECT_CREATE', 'project', NULL)""",
|
||||||
|
|||||||
@@ -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_json import atomic_write_json
|
||||||
from common_util.common_util_storage import resolve_stored_project_path
|
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 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_db import get_db_pool
|
||||||
from config.config_system import (
|
from config.config_system import (
|
||||||
SEND_ANALYSIS_COMPLETION_EMAIL,
|
SEND_ANALYSIS_COMPLETION_EMAIL,
|
||||||
@@ -155,8 +161,20 @@ async def trigger_wf1_analysis_and_email(
|
|||||||
pool = get_db_pool()
|
pool = get_db_pool()
|
||||||
project_info: dict[str, Any] | None = None
|
project_info: dict[str, Any] | None = None
|
||||||
try:
|
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 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)
|
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||||
project_info = await _get_project_notification_info(connection, project_id)
|
project_info = await _get_project_notification_info(connection, project_id)
|
||||||
if not project_info or not project_info.get("user_email"):
|
if not project_info or not project_info.get("user_email"):
|
||||||
@@ -176,8 +194,6 @@ async def trigger_wf1_analysis_and_email(
|
|||||||
project_id,
|
project_id,
|
||||||
input_file_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_Engine import run_surface_analysis
|
||||||
from B04_wf1_Surface.B04_wf1_Surface_Router import save_surface_analysis_to_db
|
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)
|
logger.info("WF1 분석 결과 DB 저장 시작: project_id=%s", project_id)
|
||||||
async with pool.acquire() as connection:
|
async with pool.acquire() as connection:
|
||||||
# save_surface_analysis_to_db는 트랜잭션을 열지 않는다.
|
|
||||||
# 호출자가 begin/commit/rollback을 한 번만 책임진다.
|
|
||||||
await connection.begin()
|
await connection.begin()
|
||||||
try:
|
try:
|
||||||
await save_surface_analysis_to_db(
|
await save_surface_analysis_to_db(
|
||||||
@@ -206,8 +220,9 @@ async def trigger_wf1_analysis_and_email(
|
|||||||
analysis_result=analysis_result,
|
analysis_result=analysis_result,
|
||||||
source_filters=source_filters,
|
source_filters=source_filters,
|
||||||
)
|
)
|
||||||
|
async with connection.cursor() as cursor:
|
||||||
|
await complete_stage(cursor, str(project_id), 1)
|
||||||
await connection.commit()
|
await connection.commit()
|
||||||
await _update_project_status(project_id, "WF1_COMPLETE")
|
|
||||||
logger.info("WF1 분석 결과 DB 저장 완료: project_id=%s", project_id)
|
logger.info("WF1 분석 결과 DB 저장 완료: project_id=%s", project_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("WF1 분석 결과 DB 저장 실패: %s", 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)
|
logger.info("WF1 백그라운드 분석 완료: project_id=%s", project_id)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("WF1 백그라운드 분석 실패: project_id=%s", project_id)
|
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"):
|
if project_info and project_info.get("user_email"):
|
||||||
await send_analysis_error_email(
|
await send_analysis_error_email(
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
@@ -326,6 +343,8 @@ async def upload_project_files(
|
|||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
async with connection.cursor() as cursor:
|
||||||
|
await complete_stage(cursor, str(project_id), 0)
|
||||||
await connection.commit()
|
await connection.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
await connection.rollback()
|
await connection.rollback()
|
||||||
@@ -547,6 +566,8 @@ async def finalize_project_upload(
|
|||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
)
|
)
|
||||||
await mark_upload_session_completed(connection, session_id=payload.session_id)
|
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()
|
await connection.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
await connection.rollback()
|
await connection.rollback()
|
||||||
@@ -641,3 +662,11 @@ async def get_project_upload_status(
|
|||||||
status_code=500,
|
status_code=500,
|
||||||
content={"status": "error", "message": "업로드 상태 조회 중 오류가 발생했습니다."},
|
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}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
동기 계산 파이프라인. 라우터에서 asyncio.to_thread로 호출한다.
|
동기 계산 파이프라인. 라우터에서 asyncio.to_thread로 호출한다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
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 B04_wf1_Surface.B04_wf1_Surface_Engine_Structurize import structurize_las
|
||||||
from config.config_system import build_surface_model_config
|
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:
|
def _relative_to_project(project_root: Path, path: Path) -> str:
|
||||||
"""프로젝트 루트 기준 posix 상대 경로 문자열."""
|
"""프로젝트 루트 기준 posix 상대 경로 문자열."""
|
||||||
@@ -27,6 +31,7 @@ def run_surface_analysis(
|
|||||||
source_filters: list[str],
|
source_filters: list[str],
|
||||||
methods: list[str],
|
methods: list[str],
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
|
on_progress: ProgressCallback | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""구조화→필터→모델 빌드를 수행하고 산출 메타데이터를 반환한다.
|
"""구조화→필터→모델 빌드를 수행하고 산출 메타데이터를 반환한다.
|
||||||
|
|
||||||
@@ -36,6 +41,11 @@ def run_surface_analysis(
|
|||||||
- manifest: 지표면 모델 파이프라인 manifest
|
- manifest: 지표면 모델 파이프라인 manifest
|
||||||
- models: [{model_type, model_file_path, resolution_m, generation_params, layers}]
|
- 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"
|
stage_root = project_root / "B04_wf1_Surface"
|
||||||
processed_dir = stage_root / "processed"
|
processed_dir = stage_root / "processed"
|
||||||
models_dir = stage_root / "models"
|
models_dir = stage_root / "models"
|
||||||
@@ -43,6 +53,7 @@ def run_surface_analysis(
|
|||||||
models_dir.mkdir(parents=True, exist_ok=True)
|
models_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# 1. LAS 구조화 (structured.npz)
|
# 1. LAS 구조화 (structured.npz)
|
||||||
|
_report(10, "structurize", "LAS 구조화 중")
|
||||||
structured_path = structurize_las(las_path, processed_dir)
|
structured_path = structurize_las(las_path, processed_dir)
|
||||||
with np.load(structured_path) as structured:
|
with np.load(structured_path) as structured:
|
||||||
xyz = structured["xyz"]
|
xyz = structured["xyz"]
|
||||||
@@ -62,15 +73,19 @@ def run_surface_analysis(
|
|||||||
data = {"xyz": xyz, "bounds": bounds}
|
data = {"xyz": xyz, "bounds": bounds}
|
||||||
|
|
||||||
# 2. 지면 필터 실행
|
# 2. 지면 필터 실행
|
||||||
|
_report(40, "ground_filter", "지면 필터 적용 중")
|
||||||
masks = build_ground_masks(data, source_filters)
|
masks = build_ground_masks(data, source_filters)
|
||||||
ground_summary = summarize_masks(data, masks)
|
ground_summary = summarize_masks(data, masks)
|
||||||
|
|
||||||
# 3. 지표면 5종 모델 빌드
|
# 3. 지표면 5종 모델 빌드
|
||||||
|
_report(70, "surface_model", "지표면 모델 생성 중")
|
||||||
config = build_surface_model_config()
|
config = build_surface_model_config()
|
||||||
config["source_filters"] = list(source_filters)
|
config["source_filters"] = list(source_filters)
|
||||||
config["precompute"] = list(methods)
|
config["precompute"] = list(methods)
|
||||||
manifest = build_all_terrain_models(data, masks, models_dir, config, force=force)
|
manifest = build_all_terrain_models(data, masks, models_dir, config, force=force)
|
||||||
|
|
||||||
|
_report(95, "saving", "결과 저장 중")
|
||||||
|
|
||||||
processed = {
|
processed = {
|
||||||
"processed_file_path": _relative_to_project(project_root, structured_path),
|
"processed_file_path": _relative_to_project(project_root, structured_path),
|
||||||
"converted_file_path": None,
|
"converted_file_path": None,
|
||||||
|
|||||||
@@ -206,10 +206,10 @@ async def list_project_point_cloud_inputs(
|
|||||||
await cursor.execute(
|
await cursor.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, file_type, original_filename, raw_file_path, file_size_mb,
|
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
|
FROM input_files
|
||||||
WHERE project_id = %s AND file_type IN ('las', 'laz')
|
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),),
|
(str(project_id),),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -32,12 +32,45 @@ from B04_wf1_Surface.B04_wf1_Surface_Schema import (
|
|||||||
SurfaceModelSummary,
|
SurfaceModelSummary,
|
||||||
SurfacePointCloudSampleResponse,
|
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_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
|
from config.config_db import get_db_pool
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api/projects", tags=["B04 Surface Analysis"])
|
router = APIRouter(prefix="/api/projects", tags=["B04 Surface Analysis"])
|
||||||
POINT_CLOUD_SAMPLE_LIMIT = 100_000
|
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(
|
async def update_project_status(
|
||||||
@@ -124,9 +157,17 @@ async def analyze_surface(
|
|||||||
|
|
||||||
pool = get_db_pool()
|
pool = get_db_pool()
|
||||||
try:
|
try:
|
||||||
|
params = {
|
||||||
|
"input_file_id": request.input_file_id,
|
||||||
|
"source_filters": source_filters,
|
||||||
|
"methods": methods,
|
||||||
|
"force": request.force,
|
||||||
|
}
|
||||||
async with pool.acquire() as connection:
|
async with pool.acquire() as connection:
|
||||||
await update_project_status(connection, project_id, "WF1_ANALYZING")
|
async with connection.cursor() as cursor:
|
||||||
|
await start_stage(cursor, str(project_id), 1, params)
|
||||||
await connection.commit()
|
await connection.commit()
|
||||||
|
|
||||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||||
project_root = Path(resolve_stored_project_path(stored_path))
|
project_root = Path(resolve_stored_project_path(stored_path))
|
||||||
input_file = await get_input_file(connection, project_id, request.input_file_id)
|
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 파일을 찾을 수 없습니다."},
|
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(
|
result = await asyncio.to_thread(
|
||||||
run_surface_analysis,
|
run_surface_analysis,
|
||||||
@@ -145,6 +192,7 @@ async def analyze_surface(
|
|||||||
source_filters=source_filters,
|
source_filters=source_filters,
|
||||||
methods=methods,
|
methods=methods,
|
||||||
force=request.force,
|
force=request.force,
|
||||||
|
on_progress=_on_progress,
|
||||||
)
|
)
|
||||||
|
|
||||||
# DB 기록 (트랜잭션)
|
# DB 기록 (트랜잭션)
|
||||||
@@ -157,12 +205,15 @@ async def analyze_surface(
|
|||||||
analysis_result=result,
|
analysis_result=result,
|
||||||
source_filters=source_filters,
|
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()
|
await connection.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
await connection.rollback()
|
await connection.rollback()
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
write_surface_progress(project_root, 100, "completed", "WF1 분석이 완료되었습니다.")
|
||||||
|
|
||||||
return SurfaceAnalyzeResponse(
|
return SurfaceAnalyzeResponse(
|
||||||
project_id=str(project_id),
|
project_id=str(project_id),
|
||||||
ground_summary=result["ground_summary"],
|
ground_summary=result["ground_summary"],
|
||||||
@@ -172,14 +223,14 @@ async def analyze_surface(
|
|||||||
except LookupError as exc:
|
except LookupError as exc:
|
||||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||||
except (OSError, ValueError) as exc:
|
except (OSError, ValueError) as exc:
|
||||||
async with pool.acquire() as connection:
|
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||||
await update_project_status(connection, project_id, "WF1_FAILED")
|
await fail_stage(cursor, str(project_id), 1, str(exc))
|
||||||
await connection.commit()
|
await connection.commit()
|
||||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
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)
|
logger.exception("B04 지표면 분석 실패: project_id=%s", project_id)
|
||||||
async with pool.acquire() as connection:
|
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||||
await update_project_status(connection, project_id, "WF1_FAILED")
|
await fail_stage(cursor, str(project_id), 1, str(exc))
|
||||||
await connection.commit()
|
await connection.commit()
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
@@ -315,6 +366,20 @@ async def get_wf1_analysis_status(project_id: UUID) -> dict:
|
|||||||
try:
|
try:
|
||||||
async with pool.acquire() as connection:
|
async with pool.acquire() as connection:
|
||||||
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
||||||
|
await cursor.execute(
|
||||||
|
"""
|
||||||
|
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)),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
|
||||||
|
# 만약 새 테이블에 정보가 없다면 기존 projects 테이블에서 조회 (백필 미작동 대비)
|
||||||
|
if not row:
|
||||||
await cursor.execute(
|
await cursor.execute(
|
||||||
"""
|
"""
|
||||||
SELECT p.status as project_status, COUNT(sm.id) as model_count
|
SELECT p.status as project_status, COUNT(sm.id) as model_count
|
||||||
@@ -325,35 +390,71 @@ async def get_wf1_analysis_status(project_id: UUID) -> dict:
|
|||||||
""",
|
""",
|
||||||
(str(project_id),),
|
(str(project_id),),
|
||||||
)
|
)
|
||||||
row = await cursor.fetchone()
|
fallback_row = await cursor.fetchone()
|
||||||
if not row:
|
if not fallback_row:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=404,
|
status_code=404,
|
||||||
content={"status": "error", "message": "프로젝트를 찾을 수 없습니다."},
|
content={"status": "error", "message": "프로젝트를 찾을 수 없습니다."},
|
||||||
)
|
)
|
||||||
model_count = int(row["model_count"]) if row else 0
|
model_count = int(fallback_row["model_count"])
|
||||||
|
project_status = str(fallback_row.get("project_status") or "NEW")
|
||||||
|
|
||||||
project_status = str(row.get("project_status") or "NEW")
|
|
||||||
if project_status == "WF1_FAILED":
|
if project_status == "WF1_FAILED":
|
||||||
status = "failed"
|
state = "FAILED"
|
||||||
progress_percent = 0
|
progress_percent = 0
|
||||||
current_stage = "failed"
|
|
||||||
message = "WF1 분석에 실패했습니다."
|
message = "WF1 분석에 실패했습니다."
|
||||||
elif model_count > 0 or project_status == "WF1_COMPLETE":
|
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"
|
||||||
|
current_stage = "failed"
|
||||||
|
if not message:
|
||||||
|
message = "WF1 분석에 실패했습니다."
|
||||||
|
elif state == "COMPLETE":
|
||||||
status = "completed"
|
status = "completed"
|
||||||
progress_percent = 100
|
progress_percent = 100
|
||||||
current_stage = "completed"
|
current_stage = "completed"
|
||||||
|
if not message:
|
||||||
message = "WF1 분석이 완료되었습니다."
|
message = "WF1 분석이 완료되었습니다."
|
||||||
elif project_status == "WF1_ANALYZING":
|
elif state == "IN_PROGRESS":
|
||||||
status = "in_progress"
|
status = "in_progress"
|
||||||
progress_percent = 30
|
|
||||||
current_stage = "surface_analysis"
|
current_stage = "surface_analysis"
|
||||||
|
if not message:
|
||||||
message = "WF1 분석이 진행 중입니다."
|
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:
|
else:
|
||||||
status = "pending"
|
status = "pending"
|
||||||
progress_percent = 0
|
progress_percent = 0
|
||||||
current_stage = "pending"
|
current_stage = "pending"
|
||||||
|
if not message:
|
||||||
message = "WF1 분석 대기 중입니다."
|
message = "WF1 분석 대기 중입니다."
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"project_id": str(project_id),
|
"project_id": str(project_id),
|
||||||
"status": status,
|
"status": status,
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
* B04_wf1_Surface_UI_Page.ts
|
* B04_wf1_Surface_UI_Page.ts
|
||||||
* 로그인 후 04: 1차 워크플로우 (지표면 모델 분석)
|
* 로그인 후 04: 1차 워크플로우 (지표면 모델 분석)
|
||||||
*
|
*
|
||||||
* 3단 레이아웃 (frontend.md §2):
|
* 데스크톱 IDE 스타일 레이아웃 (createWorkflowLayout):
|
||||||
* 상단: 페이지 타이틀 + 진행 단계 스텝바 (createWorkflowShell)
|
* 헤더: 페이지 타이틀 + 진행 단계 스텝바 (숨김/복원 토글)
|
||||||
* 좌측: 입력 파일 ID + 지면 필터/지표면 표현 선택 + 실행 옵션 폼
|
* 좌측 오버레이 패널: 입력 파일 자동 선택 + 지면 필터/지표면 표현 + 실행 옵션
|
||||||
* 우측: 생성된 지표면 모델 목록 그리드
|
* 우측 메인: 3D 포인트클라우드 뷰어 + 지면 통계 + 지표면 모델 카드
|
||||||
*
|
*
|
||||||
* 이벤트 핸들러 명명 (frontend.md §4): onB04_Surface_[기능]_[액션]
|
* 이벤트 핸들러 명명 (frontend.md §4): onB04_Surface_[기능]_[액션]
|
||||||
* 텍스트는 ui_template_locale에 선(先) 등록 후 참조 (frontend.md §3).
|
* 텍스트는 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 { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||||
import {
|
import {
|
||||||
createButton,
|
createButton,
|
||||||
createInputField,
|
|
||||||
createTag,
|
createTag,
|
||||||
createWorkflowShell,
|
|
||||||
hideLoadingOverlay,
|
hideLoadingOverlay,
|
||||||
showLoadingOverlay,
|
showLoadingOverlay,
|
||||||
showToast,
|
showToast,
|
||||||
@@ -81,180 +79,6 @@ function buildCheckboxGroup(
|
|||||||
return { root, selected };
|
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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 {
|
function buildInfoLine(label: string, value: unknown): HTMLElement {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "b04-surface__line";
|
row.className = "b04-surface__line";
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from B05_wf2_Route.B05_wf2_Route_Schema import (
|
|||||||
RouteSolveResponse,
|
RouteSolveResponse,
|
||||||
)
|
)
|
||||||
from common_util.common_util_storage import resolve_stored_project_path
|
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
|
from config.config_db import get_db_pool
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -36,7 +37,20 @@ async def solve_route(
|
|||||||
"""경로 탐색을 실행하고 결과를 GeoJSON 저장 + DB 기록한다."""
|
"""경로 탐색을 실행하고 결과를 GeoJSON 저장 + DB 기록한다."""
|
||||||
pool = get_db_pool()
|
pool = get_db_pool()
|
||||||
try:
|
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 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)
|
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||||
project_root = Path(resolve_stored_project_path(stored_path))
|
project_root = Path(resolve_stored_project_path(stored_path))
|
||||||
|
|
||||||
@@ -78,6 +92,8 @@ async def solve_route(
|
|||||||
mean_slope=stats["mean_slope"],
|
mean_slope=stats["mean_slope"],
|
||||||
cost_score=stats["cost_score"],
|
cost_score=stats["cost_score"],
|
||||||
)
|
)
|
||||||
|
async with connection.cursor() as cursor:
|
||||||
|
await complete_stage(cursor, str(project_id), 2)
|
||||||
await connection.commit()
|
await connection.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
await connection.rollback()
|
await connection.rollback()
|
||||||
@@ -92,13 +108,25 @@ async def solve_route(
|
|||||||
route_data_path=design["route_data_path"],
|
route_data_path=design["route_data_path"],
|
||||||
)
|
)
|
||||||
except LookupError as exc:
|
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)})
|
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||||
except FileNotFoundError as 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)})
|
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||||
except (OSError, ValueError) as 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)})
|
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)
|
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(
|
return JSONResponse(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
content={"status": "error", "message": "경로 탐색 처리 중 오류가 발생했습니다."},
|
content={"status": "error", "message": "경로 탐색 처리 중 오류가 발생했습니다."},
|
||||||
@@ -120,6 +148,8 @@ async def confirm_latest_route(project_id: UUID) -> RouteConfirmResponse | JSONR
|
|||||||
await connection.begin()
|
await connection.begin()
|
||||||
try:
|
try:
|
||||||
await confirm_route(connection, latest["id"])
|
await confirm_route(connection, latest["id"])
|
||||||
|
async with connection.cursor() as cursor:
|
||||||
|
await complete_stage(cursor, str(project_id), 2)
|
||||||
await connection.commit()
|
await connection.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
await connection.rollback()
|
await connection.rollback()
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Schema import (
|
|||||||
SectionSummaryResponse,
|
SectionSummaryResponse,
|
||||||
)
|
)
|
||||||
from common_util.common_util_storage import resolve_stored_project_path
|
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
|
from config.config_db import get_db_pool
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -51,7 +52,22 @@ async def generate_sections(
|
|||||||
"""확정 경로에서 종횡단을 생성·저장하고 DB에 기록한다."""
|
"""확정 경로에서 종횡단을 생성·저장하고 DB에 기록한다."""
|
||||||
pool = get_db_pool()
|
pool = get_db_pool()
|
||||||
try:
|
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 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)
|
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||||
project_root = Path(resolve_stored_project_path(stored_path))
|
project_root = Path(resolve_stored_project_path(stored_path))
|
||||||
|
|
||||||
@@ -91,6 +107,8 @@ async def generate_sections(
|
|||||||
route_id=request.route_id,
|
route_id=request.route_id,
|
||||||
sections=design["cross_sections"],
|
sections=design["cross_sections"],
|
||||||
)
|
)
|
||||||
|
async with connection.cursor() as cursor:
|
||||||
|
await complete_stage(cursor, str(project_id), 3)
|
||||||
await connection.commit()
|
await connection.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
await connection.rollback()
|
await connection.rollback()
|
||||||
@@ -105,13 +123,25 @@ async def generate_sections(
|
|||||||
longitudinal_file_path=design["longitudinal"]["file_path"],
|
longitudinal_file_path=design["longitudinal"]["file_path"],
|
||||||
)
|
)
|
||||||
except LookupError as exc:
|
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)})
|
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||||
except FileNotFoundError as 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)})
|
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||||
except (OSError, ValueError) as 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)})
|
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)
|
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(
|
return JSONResponse(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
content={"status": "error", "message": "종횡단 생성 처리 중 오류가 발생했습니다."},
|
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)
|
@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)한다."""
|
"""경로의 종횡단면을 확정(CONFIRMED)한다."""
|
||||||
pool = get_db_pool()
|
pool = get_db_pool()
|
||||||
try:
|
try:
|
||||||
@@ -151,6 +183,8 @@ async def confirm_sections(project_id: UUID, route_id: int) -> SectionConfirmRes
|
|||||||
await connection.begin()
|
await connection.begin()
|
||||||
try:
|
try:
|
||||||
await confirm_sections_for_route(connection, route_id)
|
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()
|
await connection.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
await connection.rollback()
|
await connection.rollback()
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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;
|
||||||
@@ -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)
|
||||||
@@ -16,6 +16,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ui-workflow-layout__menu-button {
|
.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;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
@@ -23,6 +42,35 @@
|
|||||||
background: var(--color-canvas);
|
background: var(--color-canvas);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
cursor: pointer;
|
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 {
|
.ui-workflow-layout__title-wrap {
|
||||||
@@ -61,27 +109,41 @@
|
|||||||
|
|
||||||
.ui-workflow-layout__body {
|
.ui-workflow-layout__body {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: block;
|
||||||
grid-template-columns: 280px minmax(0, 1fr);
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
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);
|
z-index: var(--z-dropdown);
|
||||||
overflow-y: auto;
|
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);
|
background: var(--color-surface);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
padding: var(--spacing-16);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
pointer-events: none;
|
||||||
transition:
|
transition:
|
||||||
margin-left var(--transition-base),
|
opacity var(--transition-base),
|
||||||
box-shadow var(--transition-base);
|
transform var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-workflow-layout:not(.is-menu-open) .ui-workflow-layout__panel {
|
.ui-workflow-layout.is-menu-open .ui-workflow-layout__dropdown-panel {
|
||||||
margin-left: -280px;
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
pointer-events: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-workflow-layout__main {
|
.ui-workflow-layout__main {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
@@ -95,23 +157,9 @@
|
|||||||
gap: var(--spacing-8);
|
gap: var(--spacing-8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-workflow-layout__body {
|
.ui-workflow-layout__dropdown-panel {
|
||||||
display: block;
|
left: var(--spacing-8);
|
||||||
}
|
right: var(--spacing-8);
|
||||||
|
width: auto;
|
||||||
.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));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ export interface WorkflowLayoutOptions {
|
|||||||
leftPanel: HTMLElement;
|
leftPanel: HTMLElement;
|
||||||
mainContent: HTMLElement;
|
mainContent: HTMLElement;
|
||||||
onMenuToggle?: (isOpen: boolean) => void;
|
onMenuToggle?: (isOpen: boolean) => void;
|
||||||
|
onHeaderToggle?: (isVisible: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkflowLayoutHandle {
|
export interface WorkflowLayoutHandle {
|
||||||
root: HTMLElement;
|
root: HTMLElement;
|
||||||
setMenuOpen: (isOpen: boolean) => void;
|
setMenuOpen: (isOpen: boolean) => void;
|
||||||
|
setHeaderVisible: (isVisible: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createStepBar(steps: readonly string[], activeStep: number): HTMLElement {
|
function createStepBar(steps: readonly string[], activeStep: number): HTMLElement {
|
||||||
@@ -37,8 +39,8 @@ export function createWorkflowLayout(options: WorkflowLayoutOptions): WorkflowLa
|
|||||||
const menuButton = document.createElement("button");
|
const menuButton = document.createElement("button");
|
||||||
menuButton.type = "button";
|
menuButton.type = "button";
|
||||||
menuButton.className = "ui-workflow-layout__menu-button";
|
menuButton.className = "ui-workflow-layout__menu-button";
|
||||||
menuButton.setAttribute("aria-label", "Toggle workflow menu");
|
menuButton.setAttribute("aria-label", "Toggle settings panel");
|
||||||
menuButton.textContent = "☰";
|
menuButton.innerHTML = "🛠️ <span>Option ▾</span>";
|
||||||
|
|
||||||
const titleWrap = document.createElement("div");
|
const titleWrap = document.createElement("div");
|
||||||
titleWrap.className = "ui-workflow-layout__title-wrap";
|
titleWrap.className = "ui-workflow-layout__title-wrap";
|
||||||
@@ -47,33 +49,71 @@ export function createWorkflowLayout(options: WorkflowLayoutOptions): WorkflowLa
|
|||||||
title.textContent = options.title;
|
title.textContent = options.title;
|
||||||
titleWrap.append(title, createStepBar(options.steps, options.activeStep));
|
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");
|
const body = document.createElement("div");
|
||||||
body.className = "ui-workflow-layout__body";
|
body.className = "ui-workflow-layout__body";
|
||||||
|
|
||||||
const aside = document.createElement("aside");
|
const dropdownPanel = document.createElement("div");
|
||||||
aside.className = "ui-workflow-layout__panel";
|
dropdownPanel.className = "ui-workflow-layout__dropdown-panel";
|
||||||
aside.append(options.leftPanel);
|
dropdownPanel.append(options.leftPanel);
|
||||||
|
|
||||||
const main = document.createElement("main");
|
const main = document.createElement("main");
|
||||||
main.className = "ui-workflow-layout__main";
|
main.className = "ui-workflow-layout__main";
|
||||||
main.append(options.mainContent);
|
main.style.position = "relative";
|
||||||
|
main.append(menuButton, options.mainContent);
|
||||||
|
|
||||||
body.append(aside, main);
|
body.append(dropdownPanel, main);
|
||||||
root.append(header, body);
|
root.append(header, headerRevealButton, body);
|
||||||
|
|
||||||
function setMenuOpen(isOpen: boolean): void {
|
function setMenuOpen(isOpen: boolean): void {
|
||||||
root.classList.toggle("is-menu-open", isOpen);
|
root.classList.toggle("is-menu-open", isOpen);
|
||||||
menuButton.setAttribute("aria-expanded", String(isOpen));
|
menuButton.setAttribute("aria-expanded", String(isOpen));
|
||||||
|
const icon = menuButton.querySelector("span");
|
||||||
|
if (icon) {
|
||||||
|
icon.textContent = isOpen ? "Option ▴" : "Option ▾";
|
||||||
|
}
|
||||||
options.onMenuToggle?.(isOpen);
|
options.onMenuToggle?.(isOpen);
|
||||||
}
|
}
|
||||||
|
|
||||||
menuButton.addEventListener("click", () => setMenuOpen(!root.classList.contains("is-menu-open")));
|
function setHeaderVisible(isVisible: boolean): void {
|
||||||
main.addEventListener("click", () => {
|
root.classList.toggle("is-header-hidden", !isVisible);
|
||||||
if (root.classList.contains("is-menu-open")) setMenuOpen(false);
|
headerHideButton.setAttribute("aria-expanded", String(isVisible));
|
||||||
|
options.onHeaderToggle?.(isVisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
setMenuOpen(true);
|
|
||||||
|
headerHideButton.addEventListener("click", () => setHeaderVisible(false));
|
||||||
|
headerRevealButton.addEventListener("click", () => setHeaderVisible(true));
|
||||||
|
|
||||||
|
setMenuOpen(false);
|
||||||
|
setHeaderVisible(true);
|
||||||
|
|
||||||
return { root, setMenuOpen };
|
return { root, setMenuOpen, setHeaderVisible };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user