260710_0
This commit is contained in:
@@ -0,0 +1,503 @@
|
||||
# B02_ProjRegister 프로젝트 생성 기능 구현 계획서
|
||||
|
||||
**작성일:** 2026-07-09
|
||||
**상태:** 설계 완료 — 백엔드/프론트엔드 구현 대기
|
||||
**범위:** `B02_ProjRegister` 프로젝트 등록 → DB 저장 → 저장소 폴더 생성
|
||||
**DB:** MariaDB v10.6+ (aiomysql, Raw SQL)
|
||||
|
||||
---
|
||||
|
||||
## 1. 문제 분석
|
||||
|
||||
### 1.1 현재 상태
|
||||
- **프론트엔드**: B02_ProjRegister_UI_Page.ts 라인 110에 TODO 주석 처리됨
|
||||
- 실제 API 호출 없이 하드코딩된 성공 처리만 수행
|
||||
- 입력값 유효성 검사만 하고 서버 전송 안 함
|
||||
- **백엔드**: 프로젝트 생성 엔드포인트 부재
|
||||
- B01_Dashboard_Router.py에는 프로젝트 CRUD가 있지만, 신규 프로젝트 생성 엔드포인트(`POST /api/b02/project`) 없음
|
||||
- **저장소**: 파일시스템 경로 미생성
|
||||
- `storage/{company_slug}/{user_slug}/{project_id}/` 디렉토리 자동 생성 로직 부재
|
||||
- **결과**: DB에 프로젝트 정보 저장 안 됨 → 업로드 페이지(B03)에서 프로젝트 ID 불일치
|
||||
|
||||
---
|
||||
|
||||
## 2. 구현 목표
|
||||
|
||||
### 2.1 기능 요구사항
|
||||
1. 사용자가 B02_ProjRegister 페이지에서 프로젝트 기본 정보 입력
|
||||
2. 프론트엔드에서 백엔드 `/api/b02/project` 엔드포인트로 POST 요청
|
||||
3. 백엔드에서:
|
||||
- 입력값 2차 검증 (Pydantic)
|
||||
- `projects` 테이블에 신규 프로젝트 행 삽입
|
||||
- 파일시스템에 저장소 폴더 구조 생성
|
||||
- 생성된 프로젝트 ID & 기본 정보 응답
|
||||
4. 프론트엔드에서 응답받은 프로젝트 ID로 로컬 스토어 업데이트
|
||||
5. B03_FileInput 페이지로 자동 네비게이션
|
||||
|
||||
### 2.2 데이터 흐름
|
||||
```
|
||||
사용자 입력 (B02_ProjRegister)
|
||||
↓
|
||||
검증 (클라이언트 1차, 서버 2차)
|
||||
↓
|
||||
DB 저장 (projects 테이블)
|
||||
↓
|
||||
저장소 폴더 생성 (storage/{company}/{user}/{project_id}/)
|
||||
↓
|
||||
프로젝트 ID 응답
|
||||
↓
|
||||
로컬 스토어 업데이트
|
||||
↓
|
||||
B03_FileInput 네비게이션
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 설계 명세
|
||||
|
||||
### 3.1 프론트엔드 변경사항
|
||||
|
||||
#### 파일: `B02_ProjRegister/B02_ProjRegister_UI_Page.ts`
|
||||
|
||||
**변경 대상:** 라인 94-114의 `onB02_Proj_Submit_Click()` 함수
|
||||
|
||||
**수정 내용:**
|
||||
```typescript
|
||||
async function onB02_Proj_Submit_Click(): Promise<void> {
|
||||
// 1. 클라이언트 1차 유효성 검사
|
||||
nameField.setError();
|
||||
regionField.setError();
|
||||
let hasError = false;
|
||||
if (isBlank(nameField.input.value)) {
|
||||
nameField.setError(L("B02_Proj_Error_Required"));
|
||||
hasError = true;
|
||||
}
|
||||
if (isBlank(regionField.input.value)) {
|
||||
regionField.setError(L("B02_Proj_Error_Required"));
|
||||
hasError = true;
|
||||
}
|
||||
if (hasError) return;
|
||||
|
||||
// 2. 로딩 오버레이 표시
|
||||
showLoadingOverlay();
|
||||
|
||||
try {
|
||||
// 3. POST /api/b02/project 호출
|
||||
const response = await fetch("/api/b02/project", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: nameField.input.value.trim(),
|
||||
region: regionField.input.value.trim(),
|
||||
road_type: roadTypeField.input.value,
|
||||
project_year: parseInt(yearField.input.value),
|
||||
estimated_length_m: lengthField.input.value ? parseFloat(lengthField.input.value) : null,
|
||||
memo: memoField.input.value.trim() || null,
|
||||
}),
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
// 4. 프로젝트 ID를 로컬 스토어에 저장
|
||||
localStorage.setItem("current_project_id", data.project_id);
|
||||
|
||||
// 5. 성공 메시지 표시
|
||||
showToast(L("B02_Proj_Success"), "success");
|
||||
|
||||
// 6. B03_FileInput으로 이동
|
||||
navigateTo(ROUTES.B03_FILE_INPUT);
|
||||
} catch (err) {
|
||||
showToast(
|
||||
L("B02_Proj_Error_Server") || `오류: ${err.message}`,
|
||||
"error"
|
||||
);
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**추가 구현:**
|
||||
- `showLoadingOverlay()` / `hideLoadingOverlay()` 함수 (기존 UI 패턴 활용)
|
||||
- 에러 핸들링 및 toast 메시지
|
||||
- `localStorage` 활용으로 프로젝트 ID 영속성 관리
|
||||
|
||||
---
|
||||
|
||||
### 3.2 백엔드 변경사항
|
||||
|
||||
#### 파일: `B02_ProjRegister/B02_ProjRegister_Schema.py` (신규 생성)
|
||||
|
||||
**목적:** 프로젝트 생성 요청 검증
|
||||
|
||||
```python
|
||||
"""B02_ProjRegister 요청/응답 스키마"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CreateProjectRequest(BaseModel):
|
||||
"""프로젝트 생성 요청"""
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
region: str = Field(..., min_length=1, max_length=255)
|
||||
road_type: str = Field(..., pattern="^(main|branch|fire|stream)$")
|
||||
project_year: int = Field(..., ge=2000, le=2100)
|
||||
estimated_length_m: float | None = Field(None, ge=0)
|
||||
memo: str | None = Field(None, max_length=1000)
|
||||
|
||||
|
||||
class CreateProjectResponse(BaseModel):
|
||||
"""프로젝트 생성 응답"""
|
||||
project_id: str
|
||||
name: str
|
||||
region: str
|
||||
road_type: str
|
||||
project_year: int
|
||||
estimated_length_m: float | None
|
||||
memo: str | None
|
||||
status: str
|
||||
storage_path: str
|
||||
created_at: str
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 파일: `B02_ProjRegister/B02_ProjRegister_Repository.py` (신규 생성)
|
||||
|
||||
**목적:** DB 및 파일시스템 상호작용
|
||||
|
||||
```python
|
||||
"""B02_ProjRegister DB 및 저장소 관리"""
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from config.config_system import STORAGE_ROOT
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
|
||||
async def create_project(
|
||||
user_id: int,
|
||||
company_id: int,
|
||||
name: str,
|
||||
region: str,
|
||||
road_type: str,
|
||||
project_year: int,
|
||||
estimated_length_m: float | None,
|
||||
memo: str | None,
|
||||
) -> dict[str, any]:
|
||||
"""
|
||||
신규 프로젝트 생성 및 저장소 폴더 구조 초기화
|
||||
|
||||
Args:
|
||||
user_id: 프로젝트 소유자 ID
|
||||
company_id: 소속 회사 ID
|
||||
name: 프로젝트명
|
||||
region: 지역명
|
||||
road_type: 임도 종류 (main|branch|fire|stream)
|
||||
project_year: 사업 연도
|
||||
estimated_length_m: 추정 연장 (미터)
|
||||
memo: 비고
|
||||
|
||||
Returns:
|
||||
생성된 프로젝트 정보 dict
|
||||
|
||||
Raises:
|
||||
ValueError: 사용자/회사 불일치, 폴더 생성 실패
|
||||
Exception: DB 오류
|
||||
"""
|
||||
# 1. 프로젝트 UUID 생성
|
||||
project_id = str(uuid4())
|
||||
|
||||
# 2. 저장소 경로 구성
|
||||
# storage/{company_slug}/{user_id}/{project_id}/
|
||||
storage_path = f"storage/{company_id}/{user_id}/{project_id}"
|
||||
|
||||
# 3. 파일시스템 폴더 생성
|
||||
base_path = Path(STORAGE_ROOT) / storage_path
|
||||
try:
|
||||
# B03~B09 모든 워크플로우 폴더 사전 생성
|
||||
for stage in ["B03_FileInput", "B04_wf1_Surface", "B05_wf2_Route",
|
||||
"B06_wf3_ProfileCross", "B07_wf4_DesignDetail",
|
||||
"B08_wf5_Quantity", "B09_wf6_Estimation"]:
|
||||
stage_path = base_path / stage
|
||||
stage_path.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as e:
|
||||
raise ValueError(f"저장소 폴더 생성 실패: {e}")
|
||||
|
||||
# 4. DB에 프로젝트 행 삽입
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor() as cursor:
|
||||
now = datetime.utcnow().isoformat()
|
||||
query = """
|
||||
INSERT INTO projects (
|
||||
id, user_id, company_id, name, region, road_type,
|
||||
project_year, estimated_length_m, memo, status,
|
||||
crs_epsg, storage_path, created_at, updated_at
|
||||
) VALUES (
|
||||
%s, %s, %s, %s, %s, %s,
|
||||
%s, %s, %s, %s,
|
||||
%s, %s, %s, %s
|
||||
)
|
||||
"""
|
||||
try:
|
||||
await cursor.execute(query, (
|
||||
project_id,
|
||||
user_id,
|
||||
company_id,
|
||||
name,
|
||||
region,
|
||||
road_type,
|
||||
project_year,
|
||||
estimated_length_m,
|
||||
memo,
|
||||
"NEW", # 초기 상태
|
||||
5178, # 기본 좌표계: 한국 표준
|
||||
storage_path,
|
||||
now,
|
||||
now,
|
||||
))
|
||||
await conn.commit()
|
||||
except Exception as e:
|
||||
await conn.rollback()
|
||||
raise Exception(f"프로젝트 DB 저장 실패: {e}")
|
||||
|
||||
# 5. 응답 데이터 반환
|
||||
return {
|
||||
"project_id": project_id,
|
||||
"name": name,
|
||||
"region": region,
|
||||
"road_type": road_type,
|
||||
"project_year": project_year,
|
||||
"estimated_length_m": estimated_length_m,
|
||||
"memo": memo,
|
||||
"status": "NEW",
|
||||
"storage_path": storage_path,
|
||||
"created_at": now,
|
||||
}
|
||||
|
||||
|
||||
async def get_project_by_id(project_id: str) -> dict[str, any] | None:
|
||||
"""프로젝트 조회 (검증용)"""
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"SELECT * FROM projects WHERE id = %s AND deleted_at IS NULL",
|
||||
(project_id,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
# dict 변환 (컬럼명 매핑)
|
||||
return dict(zip([desc[0] for desc in cursor.description], row))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 파일: `B02_ProjRegister/B02_ProjRegister_Router.py` (신규 생성)
|
||||
|
||||
**목적:** FastAPI 엔드포인트
|
||||
|
||||
```python
|
||||
"""B02_ProjRegister API 엔드포인트"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
|
||||
from .B02_ProjRegister_Repository import create_project
|
||||
from .B02_ProjRegister_Schema import CreateProjectRequest, CreateProjectResponse
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/b02", tags=["B02_ProjRegister"])
|
||||
|
||||
|
||||
@router.post("/project", response_model=CreateProjectResponse)
|
||||
async def post_project(
|
||||
payload: CreateProjectRequest,
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
):
|
||||
"""
|
||||
신규 프로젝트 생성
|
||||
|
||||
- 세션 인증 필수
|
||||
- 사용자의 회사에 소속되어야 함
|
||||
"""
|
||||
user_id = int(session["user_id"])
|
||||
company_id = session.get("company_id")
|
||||
|
||||
# 회사 연결 확인
|
||||
if not company_id:
|
||||
raise HTTPException(status_code=403, detail="회사 연결이 필요합니다.")
|
||||
|
||||
company_id = int(company_id)
|
||||
|
||||
try:
|
||||
result = await create_project(
|
||||
user_id=user_id,
|
||||
company_id=company_id,
|
||||
name=payload.name,
|
||||
region=payload.region,
|
||||
road_type=payload.road_type,
|
||||
project_year=payload.project_year,
|
||||
estimated_length_m=payload.estimated_length_m,
|
||||
memo=payload.memo,
|
||||
)
|
||||
return CreateProjectResponse(**result)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"프로젝트 생성 실패: {str(e)}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.3 라우터 등록
|
||||
|
||||
#### 파일: `main.py`
|
||||
|
||||
**변경 대상:** 라인 23-31의 라우터 import 섹션
|
||||
|
||||
**추가:**
|
||||
```python
|
||||
from B02_ProjRegister.B02_ProjRegister_Router import router as b02_proj_register_router
|
||||
```
|
||||
|
||||
**변경 대상:** 라우터 등록 섹션 (app.include_router 호출)
|
||||
|
||||
**추가:**
|
||||
```python
|
||||
app.include_router(b02_proj_register_router)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 저장소 구조 (생성 대상)
|
||||
|
||||
프로젝트 생성 후 파일시스템에 다음 구조 자동 생성:
|
||||
|
||||
```
|
||||
storage/{company_id}/{user_id}/{project_id}/
|
||||
├── B03_FileInput/
|
||||
│ └── input/ (원본 파일 저장소)
|
||||
├── B04_wf1_Surface/
|
||||
│ └── processed/ (변환 결과)
|
||||
├── B05_wf2_Route/
|
||||
│ └── route/ (경로 데이터)
|
||||
├── B06_wf3_ProfileCross/
|
||||
│ └── profiles/ (종횡단면)
|
||||
├── B07_wf4_DesignDetail/
|
||||
│ └── structures/ (구조물)
|
||||
├── B08_wf5_Quantity/
|
||||
│ └── quantities/ (수량 항목)
|
||||
└── B09_wf6_Estimation/
|
||||
└── v1/ (최종 산출물 - 버전별)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. DB 스키마 (기존 projects 테이블 활용)
|
||||
|
||||
### projects 테이블 관련 컬럼
|
||||
|
||||
| 컬럼명 | 타입 | 설명 |
|
||||
|--------|------|------|
|
||||
| `id` | UUID | 프로젝트 고유 ID |
|
||||
| `user_id` | INT (FK) | 프로젝트 소유자 |
|
||||
| `company_id` | INT (FK) | 소속 회사 |
|
||||
| `name` | TEXT | 프로젝트명 |
|
||||
| `region` | TEXT | 지역명 |
|
||||
| `road_type` | TEXT | 임도 종류 |
|
||||
| `project_year` | INT | 사업 연도 |
|
||||
| `estimated_length_m` | FLOAT | 추정 연장 (m) |
|
||||
| `memo` | TEXT | 비고 |
|
||||
| `status` | TEXT | 상태 (기본값: NEW) |
|
||||
| `crs_epsg` | INT | 좌표계 (기본값: 5178) |
|
||||
| `storage_path` | TEXT | 파일시스템 경로 |
|
||||
| `created_at` | TIMESTAMP | 생성일 |
|
||||
| `updated_at` | TIMESTAMP | 수정일 |
|
||||
| `deleted_at` | TIMESTAMP | 삭제일 (soft delete) |
|
||||
|
||||
---
|
||||
|
||||
## 6. 검증 체크리스트
|
||||
|
||||
### 프론트엔드 검증
|
||||
- [ ] TypeScript 타입체크 통과 (`tsc --noEmit`)
|
||||
- [ ] 로딩 오버레이 UI 구현 확인
|
||||
- [ ] 에러 토스트 메시지 표시 확인
|
||||
- [ ] localStorage 프로젝트 ID 저장 확인
|
||||
- [ ] B03_FileInput 네비게이션 정상 작동
|
||||
|
||||
### 백엔드 검증
|
||||
- [ ] Pydantic 스키마 검증 정상 작동
|
||||
- [ ] POST /api/b02/project 엔드포인트 응답 정상
|
||||
- [ ] DB에 프로젝트 행 삽입 확인
|
||||
- [ ] 저장소 폴더 생성 확인 (storage 디렉토리)
|
||||
- [ ] 세션 인증 체크 정상 작동
|
||||
- [ ] 회사 연결 검증 정상 작동
|
||||
|
||||
### 통합 검증
|
||||
- [ ] 브라우저 개발자 도구 → Network 탭에서 POST 요청 확인
|
||||
- [ ] Response 상태 코드 200 확인
|
||||
- [ ] DB에 저장된 프로젝트 레코드 수동 조회 확인
|
||||
- [ ] storage 폴더 구조 파일시스템 직접 확인
|
||||
- [ ] 프로젝트 생성 → B03_FileInput 전체 흐름 테스트
|
||||
|
||||
---
|
||||
|
||||
## 7. 코드 포맷팅
|
||||
|
||||
구현 완료 후 프로젝트 루트에서:
|
||||
|
||||
```bash
|
||||
# Python
|
||||
ruff format B02_ProjRegister/
|
||||
ruff check --fix B02_ProjRegister/
|
||||
|
||||
# TypeScript
|
||||
npx prettier --write B02_ProjRegister/B02_ProjRegister_UI_Page.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 주의사항
|
||||
|
||||
1. **트랜잭션 관리**: DB 삽입 실패 시 생성된 폴더 롤백 필요 (현재 계획에서는 폴더 생성 후 DB 삽입으로 순서 변경 가능)
|
||||
2. **권한 검증**: 세션에서 company_id 누락 시 명확한 에러 메시지 반환
|
||||
3. **경로 충돌**: 동일 project_id로 중복 생성 시 UUID 고유성 보장으로 자동 방지
|
||||
4. **저장소 경로 형식**: `storage/{company_id}/{user_id}/{project_id}/` 형식 엄격히 준수 (다른 페이지와 일관성)
|
||||
5. **초기 상태**: status = "NEW"로 설정하여 B03 이전 단계 표기
|
||||
|
||||
---
|
||||
|
||||
## 9. 참고 문서
|
||||
|
||||
- `.agent/structure.md` — 폴더/파일 명명 규칙
|
||||
- `.agent/db_schema.md` — projects 테이블 상세 스키마
|
||||
- `.agent/backend.md` — FastAPI 아키텍처 및 인증 가이드
|
||||
- `.agent/frontend.md` — UI 컴포넌트 및 라우팅 표준
|
||||
- `main.py` — 라우터 등록 위치
|
||||
|
||||
---
|
||||
|
||||
## 10. 다음 단계
|
||||
|
||||
이 계획서 승인 후:
|
||||
|
||||
1. 백엔드 구현 (B02_ProjRegister_Schema.py, _Repository.py, _Router.py)
|
||||
2. 프론트엔드 수정 (B02_ProjRegister_UI_Page.ts)
|
||||
3. main.py에 라우터 등록
|
||||
4. 통합 테스트 (브라우저 + DB 확인)
|
||||
5. 포맷팅 & 린팅
|
||||
Reference in New Issue
Block a user