15 KiB
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) 없음
- B01_Dashboard_Router.py에는 프로젝트 CRUD가 있지만, 신규 프로젝트 생성 엔드포인트(
- 저장소: 파일시스템 경로 미생성
storage/{company_slug}/{user_slug}/{project_id}/디렉토리 자동 생성 로직 부재
- 결과: DB에 프로젝트 정보 저장 안 됨 → 업로드 페이지(B03)에서 프로젝트 ID 불일치
2. 구현 목표
2.1 기능 요구사항
- 사용자가 B02_ProjRegister 페이지에서 프로젝트 기본 정보 입력
- 프론트엔드에서 백엔드
/api/b02/project엔드포인트로 POST 요청 - 백엔드에서:
- 입력값 2차 검증 (Pydantic)
projects테이블에 신규 프로젝트 행 삽입- 파일시스템에 저장소 폴더 구조 생성
- 생성된 프로젝트 ID & 기본 정보 응답
- 프론트엔드에서 응답받은 프로젝트 ID로 로컬 스토어 업데이트
- 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. 주의사항
- 트랜잭션 관리: DB 삽입 실패 시 생성된 폴더 롤백 필요 (현재 계획에서는 폴더 생성 후 DB 삽입으로 순서 변경 가능)
- 권한 검증: 세션에서 company_id 누락 시 명확한 에러 메시지 반환
- 경로 충돌: 동일 project_id로 중복 생성 시 UUID 고유성 보장으로 자동 방지
- 저장소 경로 형식:
storage/{company_id}/{user_id}/{project_id}/형식 엄격히 준수 (다른 페이지와 일관성) - 초기 상태: status = "NEW"로 설정하여 B03 이전 단계 표기
9. 참고 문서
.agent/structure.md— 폴더/파일 명명 규칙.agent/db_schema.md— projects 테이블 상세 스키마.agent/backend.md— FastAPI 아키텍처 및 인증 가이드.agent/frontend.md— UI 컴포넌트 및 라우팅 표준main.py— 라우터 등록 위치
10. 다음 단계
이 계획서 승인 후:
- 백엔드 구현 (B02_ProjRegister_Schema.py, _Repository.py, _Router.py)
- 프론트엔드 수정 (B02_ProjRegister_UI_Page.ts)
- main.py에 라우터 등록
- 통합 테스트 (브라우저 + DB 확인)
- 포맷팅 & 린팅