# 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 { // 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. 포맷팅 & 린팅