Files
Aislo/.agent/complete_and_old/plan_b02_project_creation.md
T
2026-07-10 16:41:22 +09:00

15 KiB

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() 함수

수정 내용:

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 (신규 생성)

목적: 프로젝트 생성 요청 검증

"""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 및 파일시스템 상호작용

"""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 엔드포인트

"""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 섹션

추가:

from B02_ProjRegister.B02_ProjRegister_Router import router as b02_proj_register_router

변경 대상: 라우터 등록 섹션 (app.include_router 호출)

추가:

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. 코드 포맷팅

구현 완료 후 프로젝트 루트에서:

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