diff --git a/A00_Common/spreadsheet/spreadsheet_commands.ts b/A00_Common/spreadsheet/spreadsheet_commands.ts index 0527c5d91..80b7a9d28 100644 --- a/A00_Common/spreadsheet/spreadsheet_commands.ts +++ b/A00_Common/spreadsheet/spreadsheet_commands.ts @@ -321,6 +321,22 @@ function insertDelete( }); if (s.조건부서식) s.조건부서식 = remapRanges(s.조건부서식); if (s.유효성) s.유효성 = remapRanges(s.유효성); + // 그림 닻 · 쪽 나눔 · 머리 행도 따라감(요구 347-5) — 다 지워진 그림 · 나눔 행은 뺌 + if (s.그림) { + s.그림 = s.그림.flatMap((f) => { + const next = parseRange(f.닻) && shiftRange(parseRange(f.닻)!); + return next ? [{ ...f, 닻: rangeToA1(next) }] : []; + }); + if (!s.그림.length) delete s.그림; + } + const print = rows ? s.인쇄 : undefined; + if (print?.쪽나눔) + print.쪽나눔 = print.쪽나눔.flatMap((k) => span(k - 1) ?? []).map((i) => i + 1); + if (print?.머리행) { + const sp = shiftSpan(print.머리행[0] - 1, print.머리행[1] - 1, insert, at, n, max); + if (sp) print.머리행 = [sp[0] + 1, sp[1] + 1]; + else delete print.머리행; + } const axis = rows ? "행" : "열"; const table = s[axis]; diff --git a/A00_Common/spreadsheet/spreadsheet_sample.json b/A00_Common/spreadsheet/spreadsheet_sample.json index 9d078d35f..a501fc0c1 100644 --- a/A00_Common/spreadsheet/spreadsheet_sample.json +++ b/A00_Common/spreadsheet/spreadsheet_sample.json @@ -70,7 +70,16 @@ }, "행": { "1": { "높이": 20 } }, "틀고정": { "행": 3 }, - "보기": { "눈금선": false } + "보기": { "눈금선": false }, + "인쇄": { + "용지": "A4", + "방향": "portrait", + "맞춤": { "폭": 1, "높이": 0 }, + "여백": { "위": 15, "아래": 15, "왼": 10, "오른": 10 }, + "머리행": [3, 3], + "쪽나눔": [4] + }, + "그림": [{ "열쇠": "fig/sample.png", "닻": "B8:F12", "이름": "단면 그림" }] }, { "id": "s2", diff --git a/A00_Common/spreadsheet/spreadsheet_types.ts b/A00_Common/spreadsheet/spreadsheet_types.ts index 502794912..75d193d3b 100644 --- a/A00_Common/spreadsheet/spreadsheet_types.ts +++ b/A00_Common/spreadsheet/spreadsheet_types.ts @@ -10,7 +10,8 @@ * · M02 만 `import()` 로 늦게 불러옴 — 다른 페이지 · `A00_Common/main.ts` 가 정적 import 금지. * · 엔진 파일(A · B)은 DOM 을 모름 — 화면 즉시 풀이와 [저장] 때 서버 Node 풀이가 같은 TS(CLAUDE.md 5장 ②). * · 보이는 칸만 그림 · 파일마다 700 줄 안쪽(예산 넘기 전에 쪼갬) · 화면 글자는 그 파일 안에 둠. - * · xlsx 가져오기 · 내보내기 · 인쇄 · PDF 는 만들지 않음(단추도 없음). xlsx 는 시험(F)만 읽음. + * · xlsx 가져오기 · 인쇄 단추는 없음. 내보내기(요구 347-5) = 서버가 시트 `인쇄` · `그림` 으로 — + * 인쇄 HTML(`spreadsheet_print_html.ts` · Node 진입 print) → PDF · xlsx = `common_util_workbook_xlsx.py`. * * ── 크기 예산 (vite build · 압축(gzip) 뒤 · M02 면적산출 · 재료산출을 열 때만 받는 조각) ── * 엔진 A+B 48 KB · 화면 C+D+E 72 KB · css 8 KB · 합 128 KB 안쪽. 재는 곳 = F(빌드 조각 크기 시험). @@ -67,7 +68,10 @@ * 3 spreadsheet_autosum.ts 자동 합계(Alt+= · Σ, 주인 sub_laptop_4 · 안 이어짐) ~90 * 3 spreadsheet_sheet_extra.ts 시트 숨김 · 보이기 · 탭 색(주인 sub_laptop_4 · 계약 더함 · 안 이어짐) ~150 * 3 spreadsheet_stage3.css 위 여섯 화면 스타일 한 벌 ~140 - * F common_util/common_util_spreadsheet_node.ts Node 진입(재계산 · 폴더 밖) ~40 + * P spreadsheet_figures.ts 그림 겹판 · 쪽 나눔 점선(늦게 불러옴 · 534 sub6) ~150 + * P spreadsheet_print_html.ts 통합문서 → 인쇄 HTML(DOM 없음 · 서버 Node print · 534 sub6) ~250 + * P common_util/common_util_workbook_xlsx.py 통합문서 → xlsx(openpyxl · 534 sub6) ~200 + * F common_util/common_util_spreadsheet_node.ts Node 진입(재계산 · 인쇄 HTML · 폴더 밖) ~40 * F common_util/common_util_spreadsheet.py 파이썬 껍데기(common_util_node_bundle) ~60 * F package.json `build:spreadsheet` · M02 `basis` 종류(Store · Layers) · 옛 산출근거 옮김 · * M02_MasterTemplete_Structure.ts 끼우기 · resources/tester/test_spreadsheet_*.py(실무 2,699 칸 대조 · 크기) @@ -198,6 +202,35 @@ export interface Sheet { /** 알약 연결(`spreadsheet_links.ts` · sub7 계약 더함) — 아래 알약(입력값 · 자재)이 가리키는 이 시트 칸. * 행열 넣기 · 지우기에 따라감(지운 칸이면 빠짐) · 바꿈은 `시트통째` 로(되돌리기 짝). */ 연결?: CellLink[]; + /** 인쇄 설정(요구 347-5) — 인쇄 HTML · PDF · xlsx 가 씀 · 화면은 쪽 나눔 점선만. */ + 인쇄?: SheetPrint; + /** 그림(요구 347-5) — 칸 범위에 얹음 · 화면 겹판 · 인쇄 HTML `` · xlsx 그림. */ + 그림?: SheetFigure[]; +} + +/** 시트 인쇄 설정 — 없는 값은 A4 · 세로 · 100% · 여백 10 mm. 시트 경계 = 늘 새 쪽. */ +export interface SheetPrint { + 용지?: "A4" | "A3"; + 방향?: "portrait" | "landscape"; + /** 배율 %(10~400) — `맞춤` 이 있으면 안 씀. */ + 배율?: number; + /** 쪽 수에 맞춤(엑셀 fitToWidth · fitToHeight · 0 = 제한 없음) — 인쇄 HTML 은 폭만 맞춤. */ + 맞춤?: { 폭?: number; 높이?: number }; + /** mm */ + 여백?: { 위?: number; 아래?: number; 왼?: number; 오른?: number }; + /** 쪽마다 되풀이할 머리 행 [첫 행, 끝 행](1 부터 · 엑셀 print_title_rows). 행열 넣기에 따라감. */ + 머리행?: [number, number]; + /** 이 행 **아래** 에서 쪽을 끊음(1 부터 · 엑셀 row_breaks 와 같음) · 오름차순. 행 넣기 · 지우기에 따라감(지운 행은 빠짐). */ + 쪽나눔?: number[]; +} + +/** 그림 한 장 — 범위를 채움(엑셀 두 칸 닻). 그림 자체는 문서에 안 넣음 — 부른 쪽이 `열쇠` 로 풂. */ +export interface SheetFigure { + /** 그림 열쇠(E05 = `fig/<측점 키>.png` 등) — 화면 `figureSrc` · 인쇄 · xlsx 가 주소 · 바이트로 바꿈. */ + 열쇠: string; + /** 칸 범위 A1(`B5:H20`) — 행열 넣기 · 지우기에 따라감(범위가 다 지워지면 그림도 빠짐). */ + 닻: string; + 이름?: string; } /** 자재DB 한 줄 — 재료산출 알약. */ diff --git a/A06_Login/A06_Login_Router.py b/A06_Login/A06_Login_Router.py index 162fc091c..e4e1c4714 100644 --- a/A06_Login/A06_Login_Router.py +++ b/A06_Login/A06_Login_Router.py @@ -15,6 +15,7 @@ from common_util.common_util_auth import ( hash_device_token, hash_password, hash_user_agent, + is_forest_service, set_device_token_cookie, set_session_cookie, verify_password, @@ -173,6 +174,7 @@ async def current_session(session: dict = Depends(verify_session)): "role": session["role"], "company_id": session["company_id"], "is_master": session["is_master"], + "is_forest_service": is_forest_service(session), }, } diff --git a/A07_Register/A07_Register_Router.py b/A07_Register/A07_Register_Router.py index 51e49b619..a96b4f647 100644 --- a/A07_Register/A07_Register_Router.py +++ b/A07_Register/A07_Register_Router.py @@ -12,6 +12,7 @@ from common_util.common_util_auth import ( hash_device_token, hash_password, hash_user_agent, + is_forest_email, set_device_token_cookie, verify_password, ) @@ -84,17 +85,20 @@ async def verify_registration(payload: RegisterVerifyRequest, request: Request): ): raise HTTPException(status_code=400, detail="인증 코드가 유효하지 않습니다.") await consume_otp(otp["id"]) - await complete_registration(user["id"], bool(user["is_master"])) + # 메일 *.go.kr = 회사 「산림청」 고정 — 화면이 보낸 회사 값은 받지 않음(요구 347-8). + account_status = await complete_registration( + user["id"], bool(user["is_master"]), forest=is_forest_email(user["email"]) + ) # 가입 인증한 브라우저를 신뢰 등록하여 첫 로그인 시 OTP를 생략한다. agent = request.headers.get("user-agent", "unknown")[:1000] device_token = generate_device_token() await trust_device(user["id"], hash_device_token(device_token), hash_user_agent(agent)) # 회원가입 시점에는 회사가 없으므로(NO_COMPANY) 마스터 알림은 발송하지 않는다. - # 회사 생성/연결은 로그인 후 B01_Dashboard에서 진행한다. + # 회사 생성/연결은 로그인 후 B01_Dashboard에서 진행한다(산림청 메일은 위에서 붙음). response = JSONResponse( { "status": "success", - "account_status": "NO_COMPANY", + "account_status": account_status, } ) set_device_token_cookie(response, device_token) diff --git a/A07_Register/A07_Register_UI_Auth_Page.ts b/A07_Register/A07_Register_UI_Auth_Page.ts index 7e8670f51..4dd8a8097 100644 --- a/A07_Register/A07_Register_UI_Auth_Page.ts +++ b/A07_Register/A07_Register_UI_Auth_Page.ts @@ -13,6 +13,7 @@ import { requestRegistration, verifyRegistration } from "./A07_Register_Api_Fetc import "./A07_Register_UI_Style.css"; const L = (key: keyof typeof ui_locales): string => ui_locales[key][currentLanguageIndex]; +const FOREST_COMPANY_NAME = "산림청"; interface AgreementField { root: HTMLElement; @@ -80,6 +81,14 @@ export function renderA07Register(root: HTMLElement): void { type: "text", }); const email = createInputField({ label: L("A07_Register_Field_Email"), type: "email" }); + // 메일 *.go.kr = 회사 「산림청」 고정(요구 347-8) — 서버도 같은 규칙으로 붙임. + const company = createInputField({ label: L("A07_Register_Field_Company"), type: "text" }); + company.input.value = FOREST_COMPANY_NAME; + company.input.readOnly = true; + company.root.hidden = true; + email.input.addEventListener("input", () => { + company.root.hidden = !email.input.value.trim().toLowerCase().endsWith(".go.kr"); + }); const phone = createInputField({ label: L("A07_Register_Field_Phone"), type: "tel" }); const password = createInputField({ label: L("A07_Register_Field_Password"), type: "password" }); const passwordConfirm = createInputField({ @@ -132,6 +141,7 @@ export function renderA07Register(root: HTMLElement): void { name.root, position.root, email.root, + company.root, phone.root, password.root, passwordConfirm.root, diff --git a/B01_Dashboard/B01_Dashboard_Repository.py b/B01_Dashboard/B01_Dashboard_Repository.py index 8907556f1..6fec3759c 100644 --- a/B01_Dashboard/B01_Dashboard_Repository.py +++ b/B01_Dashboard/B01_Dashboard_Repository.py @@ -193,7 +193,7 @@ async def list_user_projects(user_id: int) -> list[dict[str, Any]]: pool = get_db_pool() async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: await cursor.execute( - """SELECT id, company_id, name, region, road_type, project_year, + """SELECT id, company_id, name, region, road_type, project_kind, project_year, estimated_length_m, route_start_m, route_end_m, memo, status, updated_at, created_at, client_org, project_number, work_amount, design_date, @@ -210,7 +210,8 @@ async def list_company_projects(company_id: int) -> list[dict[str, Any]]: pool = get_db_pool() async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: await cursor.execute( - """SELECT p.id, p.company_id, p.name, p.region, p.road_type, p.project_year, + """SELECT p.id, p.company_id, p.name, p.region, p.road_type, p.project_kind, + p.project_year, p.estimated_length_m, p.route_start_m, p.route_end_m, p.memo, p.status, p.updated_at, p.created_at, p.client_org, p.project_number, p.work_amount, p.design_date, @@ -229,7 +230,8 @@ async def list_all_projects() -> list[dict[str, Any]]: pool = get_db_pool() async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: await cursor.execute( - """SELECT p.id, p.company_id, p.name, p.region, p.road_type, p.project_year, + """SELECT p.id, p.company_id, p.name, p.region, p.road_type, p.project_kind, + p.project_year, p.estimated_length_m, p.route_start_m, p.route_end_m, p.memo, p.status, p.updated_at, p.created_at, p.client_org, p.project_number, p.work_amount, p.design_date, diff --git a/B09_Estimation/draft/B09_Draft_Print_Router.py b/B09_Estimation/draft/B09_Draft_Print_Router.py index dd7841c95..8b9b73487 100644 --- a/B09_Estimation/draft/B09_Draft_Print_Router.py +++ b/B09_Estimation/draft/B09_Draft_Print_Router.py @@ -12,27 +12,30 @@ `B09_Estimation_Router` 가 draft/*_Router.py 의 router 를 끼워 넣음 (같은 머리 · 같은 권한 = 회사 · 프로젝트). -PDF 는 자식 파이썬 프로세스(이 파일을 스크립트로 · 입력 JSON stdin · 결과 JSON 한 줄) — -서버 안 스레드에선 Playwright 가 드라이버를 못 띄움(`B08_Quantity_Milestone_Capture` 와 같은 까닭). -헤드리스 쪽은 스크립트 끔 · 바깥 요청 모두 막음(그림 = data: 주소만). +PDF · 엑셀 인쇄 설정 = 공용 `common_util_print_pdf`(이 파일은 인쇄 CSS · 줄임만). """ from __future__ import annotations +import asyncio import html as htmlmod import io -import json -import re -import subprocess -import sys -import tempfile -import threading -from pathlib import Path from typing import Any, Literal +from urllib.parse import quote +from uuid import UUID + +from fastapi import APIRouter, HTTPException +from fastapi.responses import Response +from pydantic import BaseModel, Field + +from common_util.common_util_print_pdf import ( # noqa: F401 — PrintError · sheet_title = 시험 이음 + PrintError, + number_format, + print_setup, + render_html_to_pdf, + sheet_title, +) -TIMEOUT_S = 90 -CHANNELS = ("msedge", "chrome") # 시스템 브라우저 — 번들 chromium 은 안 받아 둠 -NO_BROWSER = "서버에서 브라우저(Edge · Chrome)를 못 띄움" #: A4 인쇄 칸 폭(px · 96dpi) — 210 · 297mm − 좌우 여백 10mm 둘 PRINT_W = {"portrait": 190 / 25.4 * 96, "landscape": 277 / 25.4 * 96} MIN_ZOOM = 0.3 @@ -74,16 +77,6 @@ body { font-family: "Malgun Gothic", "맑은 고딕", sans-serif; font-size: 11p .sheet-figure img { max-width: 100%; max-height: 240mm; } .sheet-figure figcaption { font-size: 11px; margin-top: 2px; } """ -FOOTER = ( - '
' - ' /
' -) - -_lock = threading.Lock() # ponytail: 서버 전체 한 줄 — 브라우저 한 번에 하나 - - -class PrintError(Exception): - """까닭 한 줄(예외 원문 없음).""" def _titled(table: str, name: str) -> str: @@ -115,105 +108,19 @@ FIT_JS = ( ) -def render_pdf(doc: str) -> bytes: - """헤드리스 브라우저 → PDF 바이트. 실패 = PrintError.""" - try: - from playwright.sync_api import Error as PwError - from playwright.sync_api import sync_playwright - except ImportError as error: - raise PrintError("서버에 Playwright 가 없습니다(pip install playwright)") from error - try: - with sync_playwright() as p: - browser = None - for channel in CHANNELS: - try: - browser = p.chromium.launch(channel=channel, headless=True) - break - except PwError: - continue - if browser is None: - raise PrintError(NO_BROWSER) - try: - context = browser.new_context(java_script_enabled=False) - context.route("**/*", lambda route: route.abort()) # 바깥 요청 없음(data: 만) - page = context.new_page() - page.set_content(doc, wait_until="load") - page.evaluate(FIT_JS, [PRINT_W["portrait"], PRINT_W["landscape"]]) - return page.pdf( - prefer_css_page_size=True, - print_background=True, - display_header_footer=True, - header_template="", - footer_template=FOOTER, - ) - finally: - browser.close() - except PrintError: - raise - except Exception as error: # 드라이버 못 뜸 등 — 예외 글은 내지 않음 - raise PrintError(NO_BROWSER) from error - - def spawn_pdf(doc: str) -> bytes: - """자식 파이썬 프로세스로 render_pdf — 결과 PDF 는 임시 파일로 받음.""" - with _lock, tempfile.TemporaryDirectory() as tmp: - out = Path(tmp) / "out.pdf" - try: - done = subprocess.run( - [sys.executable, __file__], - input=json.dumps({"doc": doc, "out": str(out)}).encode(), - capture_output=True, - timeout=TIMEOUT_S, - ) - except subprocess.TimeoutExpired as error: - raise PrintError(f"PDF 만들기가 {TIMEOUT_S}초 안에 안 끝남") from error - except OSError as error: - raise PrintError(NO_BROWSER) from error - try: - result = json.loads(done.stdout.decode().strip().splitlines()[-1]) - except (ValueError, IndexError): # 자식이 죽음 — stderr(예외 글)는 내지 않음 - result = {"reason": NO_BROWSER} - if result.get("reason"): - raise PrintError(result["reason"]) - return out.read_bytes() + """document_html → PDF(쪽 크기 = 인쇄 CSS · 넓은 표 줄임 · 쪽 번호). 실패 = PrintError.""" + return render_html_to_pdf( + doc, + page_size=None, + page_numbers=True, + prepare_js=FIT_JS, + prepare_arg=[PRINT_W["portrait"], PRINT_W["landscape"]], + ) # ── 엑셀 ────────────────────────────────────────────────────────────────── -BAD_TITLE = re.compile(r"[\[\]:*?/\\]") - - -def sheet_title(name: str, used: set[str]) -> str: - """엑셀 시트 이름 — 금지 글자 = 「_」 · 31자 · 같은 이름 = 뒤에 (2)…""" - base = BAD_TITLE.sub("_", name).strip().strip("'") or "시트" - title, n = base[:31], 1 - while title.lower() in used: - n += 1 - tail = f" ({n})" - title = base[: 31 - len(tail)] + tail - used.add(title.lower()) - return title - - -def number_format(decimals: int | None) -> str: - return "#,##0" + ("." + "0" * decimals if decimals else "") - - -def print_setup(ws: Any, orientation: str, head_rows: int) -> None: - """인쇄 설정(302-5) — PDF 와 같은 방향 · A4 · 폭 1쪽 맞춤 · 머리 줄 쪽마다 · 여백 ≈ PDF.""" - from openpyxl.worksheet.page import PageMargins - - ws.page_setup.orientation = orientation - ws.page_setup.paperSize = ws.PAPERSIZE_A4 - ws.page_setup.fitToWidth = 1 - ws.page_setup.fitToHeight = 0 - ws.sheet_properties.pageSetUpPr.fitToPage = True - ws.page_margins = PageMargins(left=0.4, right=0.4, top=0.4, bottom=0.55, header=0.2, footer=0.2) - if head_rows: - ws.print_title_rows = f"1:{head_rows}" - ws.oddHeader.left.text = "&A" # 시트 이름 - ws.oddFooter.center.text = "&P / &N" # 쪽 번호 - def build_xlsx(sheets: list[dict[str, Any]]) -> bytes: """grid 시트들 → 엑셀 바이트(차례 그대로).""" @@ -277,26 +184,6 @@ def build_xlsx(sheets: list[dict[str, Any]]) -> bytes: return out.getvalue() -if __name__ == "__main__": # 자식 프로세스 몸통 — fastapi 를 읽지 않음 - job = json.loads(sys.stdin.buffer.read()) - try: - Path(job["out"]).write_bytes(render_pdf(job["doc"])) - print(json.dumps({"reason": None})) - except PrintError as error: - print(json.dumps({"reason": str(error)}, ensure_ascii=False)) - sys.exit(0) - - -# ── 길 ──────────────────────────────────────────────────────────────────── - -import asyncio # noqa: E402 -from urllib.parse import quote # noqa: E402 -from uuid import UUID # noqa: E402 - -from fastapi import APIRouter, HTTPException # noqa: E402 -from fastapi.responses import Response # noqa: E402 -from pydantic import BaseModel, Field # noqa: E402 - router = APIRouter() MAX_SHEETS = 200 diff --git a/D01_PlanRegister/D01_PlanRegister_Router.py b/D01_PlanRegister/D01_PlanRegister_Router.py new file mode 100644 index 000000000..ce81164b8 --- /dev/null +++ b/D01_PlanRegister/D01_PlanRegister_Router.py @@ -0,0 +1,87 @@ +"""D01 예정노선 등록 API · 대시보드 「받은 예정노선」 (요구 345 · 요구서 3-1 · 2-2).""" + +from typing import Any + +import aiomysql +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel, Field + +from B05_Profile.B05_Profile_Engine_Grade import resolve_design_speed +from B05_Profile.B05_Profile_PageSettings import grade_class_of +from common_util.common_util_auth import is_forest_service, require_company +from common_util.common_util_project_kind import create_kind_project +from config.config_db import get_db_pool + +router = APIRouter(prefix="/api/d01", tags=["D01_PlanRegister"]) +dashboard_router = APIRouter(prefix="/api/dashboard", tags=["D01_PlanRegister"]) + + +class CreatePlanRequest(BaseModel): + project_name: str = Field(..., min_length=1, max_length=255) + project_year: int = Field(..., ge=2000, le=2100) + project_year_end: int | None = Field(default=None, ge=2000, le=2100) + region: str = Field(..., min_length=1, max_length=100) + road_type: str = Field(..., pattern="^(main|fire|work)$") + design_speed_kph: int | None = None + start_chainage_m: float | None = Field(default=None, ge=0) + end_chainage_m: float | None = Field(default=None, ge=0) + + +@router.post("/project") +async def post_plan_project( + request: Request, + payload: CreatePlanRequest, + session: dict[str, Any] = Depends(require_company), +) -> dict[str, str]: + """예정노선 프로젝트 생성 — 산림청(시스템관리자 · *.go.kr)만.""" + if not is_forest_service(session): + raise HTTPException(status_code=403, detail="예정노선은 산림청만 만들 수 있습니다.") + if session.get("company_id") is None: + raise HTTPException(status_code=403, detail="회사 연결이 필요합니다.") + year_end = payload.project_year_end or payload.project_year + if year_end < payload.project_year: + raise HTTPException(status_code=400, detail="사업연도 끝이 시작보다 앞입니다.") + start_m, end_m = payload.start_chainage_m, payload.end_chainage_m + if start_m is not None and end_m is not None and start_m >= end_m: + raise HTTPException( + status_code=400, detail="노선 시작 누가거리는 종료 누가거리보다 작아야 합니다." + ) + speed = resolve_design_speed(grade_class_of(payload.road_type, {}), payload.design_speed_kph) + project_id = await create_kind_project( + request=request, + user_id=int(session["user_id"]), + company_id=int(session["company_id"]), + kind="PLAN", + name=payload.project_name.strip(), + region=payload.region.strip(), + road_type=payload.road_type, + project_year=payload.project_year, + project_year_end=year_end, + design_speed_kph=speed, + route_start_m=start_m, + route_end_m=end_m, + ) + return {"project_id": project_id} + + +@dashboard_router.get("/plan-routes/assigned") +async def assigned_plan_routes( + session: dict[str, Any] = Depends(require_company), +) -> dict[str, Any]: + """우리 회사에 지정된 예정노선 연도 — 보기 · 연결 상태.""" + async with ( + get_db_pool().acquire() as connection, + connection.cursor(aiomysql.DictCursor) as cursor, + ): + await cursor.execute( + """SELECT a.project_id, p.name AS project_name, a.year, a.linked_project_id + FROM plan_route_assignees a + JOIN projects p ON p.id = a.project_id AND p.deleted_at IS NULL + WHERE a.company_id = %s + ORDER BY p.name, a.year""", + (session.get("company_id"),), + ) + rows = await cursor.fetchall() + # ponytail: update_available 자리만 — 지문 비교는 531-3. + routes = [{**r, "update_available": False} for r in rows] + return {"status": "success", "routes": routes} diff --git a/E01_StabRegister/E01_StabRegister_Router.py b/E01_StabRegister/E01_StabRegister_Router.py new file mode 100644 index 000000000..0d89c5822 --- /dev/null +++ b/E01_StabRegister/E01_StabRegister_Router.py @@ -0,0 +1,87 @@ +"""E01 안정성평가 생성 API (요구 345 · 347 · 요구서 4-1). + +대상 = 같은 회사 임도(ROAD) 중 B06 횡단 단계(stage 3) 완료. 자료 복사는 528-4 — 지금은 행만. +""" + +from typing import Any + +import aiomysql +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel, Field + +from common_util.common_util_auth import require_company +from common_util.common_util_project_kind import create_kind_project +from config.config_db import get_db_pool + +router = APIRouter(prefix="/api/e01", tags=["E01_StabRegister"]) + +_SOURCE_SQL = """ + SELECT p.id AS project_id, p.name, p.region, p.road_type, p.project_year, + DATE_FORMAT(p.updated_at, '%%Y-%%m-%%dT%%H:%%i:%%s') AS updated_at, + s.state = 'COMPLETE' AS section_complete + FROM projects p + LEFT JOIN project_workflow_stages s ON s.project_id = p.id AND s.stage_no = 3 + WHERE p.company_id = %s AND p.project_kind = 'ROAD' AND p.deleted_at IS NULL +""" + + +class CreateStabRequest(BaseModel): + project_name: str | None = Field(default=None, max_length=255) + source_project_id: str = Field(..., min_length=36, max_length=36) + + +async def _sources(company_id: Any, project_id: str | None = None) -> list[dict[str, Any]]: + sql, args = _SOURCE_SQL, [company_id] + if project_id: + sql, args = sql + " AND p.id = %s", [company_id, project_id] + async with ( + get_db_pool().acquire() as connection, + connection.cursor(aiomysql.DictCursor) as cursor, + ): + await cursor.execute(sql + " ORDER BY p.updated_at DESC", tuple(args)) + return list(await cursor.fetchall()) + + +@router.get("/source-candidates") +async def source_candidates( + session: dict[str, Any] = Depends(require_company), +) -> dict[str, Any]: + """B06 까지 끝난 같은 회사 임도 프로젝트.""" + rows = await _sources(session.get("company_id")) + projects = [ + {k: v for k, v in r.items() if k != "section_complete"} + for r in rows + if r["section_complete"] + ] + return {"status": "success", "projects": projects} + + +@router.post("/project") +async def post_stab_project( + request: Request, + payload: CreateStabRequest, + session: dict[str, Any] = Depends(require_company), +) -> dict[str, str]: + """안정성평가 프로젝트 생성 — 원본 B06 미완료면 400.""" + company_id = session.get("company_id") + rows = await _sources(company_id, payload.source_project_id) if company_id else [] + if not rows: + raise HTTPException(status_code=404, detail="같은 회사 임도 프로젝트가 아닙니다.") + source = rows[0] + if not source["section_complete"]: + raise HTTPException( + status_code=400, detail="원본 임도의 횡단 설계(B06)가 끝나지 않았습니다." + ) + name = (payload.project_name or "").strip() or f"{source['name']} 안정성평가" + project_id = await create_kind_project( + request=request, + user_id=int(session["user_id"]), + company_id=int(company_id), + kind="STABILITY", + name=name, + region=source["region"], + road_type=source["road_type"], + project_year=source["project_year"], + source_project_id=source["project_id"], + ) + return {"project_id": project_id} diff --git a/E03_StabSection/E03_StabSection_Types.py b/E03_StabSection/E03_StabSection_Types.py new file mode 100644 index 000000000..adfa50720 --- /dev/null +++ b/E03_StabSection/E03_StabSection_Types.py @@ -0,0 +1,124 @@ +"""E 안정성평가 공용 꼴 — 2D(E03) · 3D(E04) 해석이 같은 입력 꼴을 씀. + +단위: 단위중량 kN/m³ · 점착력 · 하중 kPa · 내부마찰각 ° · 좌표 m. +E04 가 이 파일을 그대로 import — 이름 · 열쇠를 바꾸지 않음. +""" + +from typing import Literal, TypedDict + +GAMMA_W = 9.81 # 물 단위중량 kN/m³ + + +class Material(TypedDict): + id: str + name: str + gamma_t: float # 습윤 단위중량 (지하수위 위) + gamma_sat: float # 포화 단위중량 (지하수위 아래) + c: float # 유효 점착력 + phi: float # 유효 내부마찰각 + + +class Groundwater(TypedDict, total=False): + mode: Literal["none", "ru", "line"] + ru: float # mode="ru" — 간극수압비 u = ru · γ·h + line: list[list[float]] # mode="line" — 지하수위선 [[x, z], …] (2D) · x 오름차순 + + +class Surcharge(TypedDict): + x0: float + x1: float + q: float # 연직 등분포 kPa + + +# 요구서 345 6장 기본값(사용자가 E02 에서 바꿈) — 사전조사 후보의 보수 쪽. +DEFAULT_MATERIALS: list[Material] = [ + { + "id": "weathered_soil", + "name": "풍화토", + "gamma_t": 18.0, + "gamma_sat": 19.0, + "c": 15.0, + "phi": 30.0, + }, + { + "id": "weathered_rock", + "name": "풍화암", + "gamma_t": 20.0, + "gamma_sat": 21.0, + "c": 30.0, + "phi": 33.0, + }, + { + "id": "soft_rock", + "name": "연암", + "gamma_t": 24.0, + "gamma_sat": 24.5, + "c": 150.0, + "phi": 35.0, + }, + { + "id": "hard_rock", + "name": "경암", + "gamma_t": 26.0, + "gamma_sat": 26.0, + "c": 300.0, + "phi": 40.0, + }, + {"id": "fill", "name": "성토", "gamma_t": 18.0, "gamma_sat": 19.0, "c": 10.0, "phi": 25.0}, + { + "id": "colluvium", + "name": "붕적토", + "gamma_t": 17.5, + "gamma_sat": 18.5, + "c": 5.0, + "phi": 27.0, + }, +] + +DEFAULT_ROAD_SURCHARGE_KPA = 10.0 # 노면 하중 기본 + + +def material(raw: dict) -> Material: + """밖에서 온 물성 한 줄을 꼴에 맞춤 — 빠진 γsat 는 γt · 음수 · 범위 밖은 오류.""" + gamma_t = float(raw["gamma_t"]) + gamma_sat = float(raw.get("gamma_sat", gamma_t)) + c = float(raw["c"]) + phi = float(raw["phi"]) + if gamma_t <= 0 or gamma_sat <= 0 or c < 0 or not 0 <= phi < 90: + raise ValueError(f"물성 범위 밖: {raw}") + return { + "id": str(raw["id"]), + "name": str(raw.get("name", raw["id"])), + "gamma_t": gamma_t, + "gamma_sat": gamma_sat, + "c": c, + "phi": phi, + } + + +def groundwater(raw: dict | None) -> Groundwater: + """지하수 꼴 맞춤 — 없으면 none · line 은 x 오름차순으로 정렬.""" + if not raw or raw.get("mode", "none") == "none": + return {"mode": "none"} + mode = raw["mode"] + if mode == "ru": + ru = float(raw.get("ru", 0.0)) + if not 0 <= ru < 1: + raise ValueError(f"r_u 범위 밖: {ru}") + return {"mode": "ru", "ru": ru} + if mode == "line": + line = sorted([float(x), float(z)] for x, z in raw["line"]) + if len(line) < 2: + raise ValueError("지하수위선 점이 둘 미만") + return {"mode": "line", "line": line} + raise ValueError(f"지하수 mode 모름: {mode}") + + +def surcharges(raw: list[dict] | None) -> list[Surcharge]: + """상재하중 꼴 맞춤 — x0 < x1 로 정렬 · 길이 0 은 버림.""" + out: list[Surcharge] = [] + for item in raw or []: + x0, x1 = sorted((float(item["x0"]), float(item["x1"]))) + if x1 > x0: + out.append({"x0": x0, "x1": x1, "q": float(item["q"])}) + return out diff --git a/common_util/common_util_auth.py b/common_util/common_util_auth.py index 89d314aaa..c8c994c10 100644 --- a/common_util/common_util_auth.py +++ b/common_util/common_util_auth.py @@ -148,6 +148,16 @@ async def get_optional_session(request: Request) -> dict[str, Any] | None: return None +def is_forest_email(email: str | None) -> bool: + """메일이 *.go.kr(대소문자 무시) — 가입 때 회사 「산림청」 고정.""" + return bool(email) and email.strip().lower().endswith(".go.kr") + + +def is_forest_service(session: dict[str, Any]) -> bool: + """산림청 = 시스템관리자 또는 메일 *.go.kr — 예정노선 생성 · 고침 권한.""" + return session.get("role") == "SYSTEM_ADMIN" or is_forest_email(session.get("email")) + + async def require_master(session: dict[str, Any] = Depends(verify_session)) -> dict[str, Any]: if not session["is_master"] and session["role"] != "SYSTEM_ADMIN": raise HTTPException(status_code=403, detail="마스터 권한이 필요합니다.") @@ -196,6 +206,19 @@ async def require_project_access( ) row = await cursor.fetchone() + if row and row[0] != session["company_id"]: + # 예정노선을 지정받은 회사 = 보기만(요구 345 · GET 만 통과). + await cursor.execute( + "SELECT 1 FROM plan_route_assignees" + " WHERE project_id = %s AND company_id = %s LIMIT 1", + (str(project_id), session["company_id"]), + ) + shared = await cursor.fetchone() + if shared: + if request.method in ("GET", "HEAD"): + return session + raise HTTPException(status_code=403, detail="보기만 허용된 예정노선입니다.") + if not row: raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") if row[0] != session["company_id"]: diff --git a/common_util/common_util_auth_repository.py b/common_util/common_util_auth_repository.py index 919cd8856..1b0c3ee97 100644 --- a/common_util/common_util_auth_repository.py +++ b/common_util/common_util_auth_repository.py @@ -148,15 +148,32 @@ async def consume_otp(otp_id: int) -> None: await connection.commit() -async def complete_registration(user_id: int, is_master: bool) -> None: +async def complete_registration(user_id: int, is_master: bool, forest: bool = False) -> str: + """메일 인증 끝 — 계정 상태를 돌려줌. forest(*.go.kr) = 회사 「산림청」 고정 · ACTIVE.""" pool = get_db_pool() async with pool.acquire() as connection, connection.cursor() as cursor: - await cursor.execute( - """UPDATE users SET status = 'NO_COMPANY', last_email_verified_at = CURRENT_TIMESTAMP - WHERE id = %s""", - (user_id,), - ) + company = None + if forest: + await cursor.execute( + "SELECT id FROM companies WHERE name = '산림청' AND deleted_at IS NULL" + ) + company = await cursor.fetchone() + if company: + await cursor.execute( + """UPDATE users SET status = 'ACTIVE', company_id = %s, role = 'USER', + last_email_verified_at = CURRENT_TIMESTAMP + WHERE id = %s""", + (company[0], user_id), + ) + else: + await cursor.execute( + """UPDATE users SET status = 'NO_COMPANY', + last_email_verified_at = CURRENT_TIMESTAMP + WHERE id = %s""", + (user_id,), + ) await connection.commit() + return "ACTIVE" if company else "NO_COMPANY" async def company_master_email(company_id: int) -> str | None: diff --git a/common_util/common_util_print_pdf.py b/common_util/common_util_print_pdf.py new file mode 100644 index 000000000..071fbf76b --- /dev/null +++ b/common_util/common_util_print_pdf.py @@ -0,0 +1,212 @@ +"""공용 PDF 엔진 — HTML → PDF(헤드리스 Edge · 없으면 Chrome · Playwright) · PDF 합치기(pypdf) · +엑셀 인쇄 설정. + + render_html_to_pdf(html, *, page_size, landscape, margins, page_numbers) → bytes + render_htmls_to_pdfs([{html, page_size, …}]) → [bytes] 브라우저 한 번으로 여러 벌 + merge_pdfs([bytes]) → bytes + sheet_title · number_format · print_setup 엑셀(openpyxl) 시트 이름 · 숫자 서식 · 인쇄 + +쪽 크기 = page_size — "A4" 등 이름 · (가로 mm, 세로 mm) · None(HTML 의 CSS @page 가 정함). +PDF 는 자식 파이썬 프로세스(이 파일을 스크립트로 · 입력 JSON stdin · 결과 JSON 한 줄) — +서버 안 스레드에선 Playwright 가 드라이버를 못 띄움(`B08_Quantity_Milestone_Capture` 와 같은 까닭). +헤드리스 쪽은 스크립트 끔 · 바깥 요청 모두 막음(그림 = data: 주소만). +""" + +from __future__ import annotations + +import io +import json +import re +import subprocess +import sys +import tempfile +import threading +from pathlib import Path +from typing import Any + +TIMEOUT_S = 90 +CHANNELS = ("msedge", "chrome") # 시스템 브라우저 — 번들 chromium 은 안 받아 둠 +NO_BROWSER = "서버에서 브라우저(Edge · Chrome)를 못 띄움" +FOOTER = ( + '
' + ' /
' +) + +_lock = threading.Lock() # ponytail: 서버 전체 한 줄 — 브라우저 한 번에 하나 + + +class PrintError(Exception): + """까닭 한 줄(예외 원문 없음).""" + + +def _pdf_options(job: dict[str, Any]) -> dict[str, Any]: + """job → Playwright page.pdf 인자.""" + size = job.get("page_size") + options: dict[str, Any] = {"print_background": True} + if size is None: + options["prefer_css_page_size"] = True + elif isinstance(size, str): + options.update(format=size, landscape=bool(job.get("landscape"))) + else: + width, height = size + if job.get("landscape"): + width, height = max(width, height), min(width, height) + options.update(width=f"{width}mm", height=f"{height}mm") + if job.get("margins"): + options["margin"] = job["margins"] + if job.get("page_numbers"): + options.update( + display_header_footer=True, header_template="", footer_template=FOOTER + ) + return options + + +def _render(jobs: list[dict[str, Any]]) -> list[bytes]: + """자식 프로세스 안 — 브라우저 한 번 띄워 job 마다 PDF. 실패 = PrintError.""" + try: + from playwright.sync_api import Error as PwError + from playwright.sync_api import sync_playwright + except ImportError as error: + raise PrintError("서버에 Playwright 가 없습니다(pip install playwright)") from error + try: + with sync_playwright() as p: + browser = None + for channel in CHANNELS: + try: + browser = p.chromium.launch(channel=channel, headless=True) + break + except PwError: + continue + if browser is None: + raise PrintError(NO_BROWSER) + try: + context = browser.new_context(java_script_enabled=False) + context.route("**/*", lambda route: route.abort()) # 바깥 요청 없음(data: 만) + page = context.new_page() + out = [] + for job in jobs: + page.set_content(job["html"], wait_until="load") + if job.get("prepare_js"): + page.evaluate(job["prepare_js"], job.get("prepare_arg")) + out.append(page.pdf(**_pdf_options(job))) + return out + finally: + browser.close() + except PrintError: + raise + except Exception as error: # 드라이버 못 뜸 등 — 예외 글은 내지 않음 + raise PrintError(NO_BROWSER) from error + + +def render_htmls_to_pdfs(jobs: list[dict[str, Any]], timeout_s: float = TIMEOUT_S) -> list[bytes]: + """HTML 여러 벌 → PDF 여러 벌(차례 그대로 · 자식 프로세스 한 번). + + job = {html, page_size="A4", landscape=False, margins=None, page_numbers=False, + prepare_js=None, prepare_arg=None} — prepare_js = PDF 직전 page.evaluate(줄임 등). + """ + jobs = [{"page_size": "A4", **job} for job in jobs] + with _lock, tempfile.TemporaryDirectory() as tmp: + outs = [str(Path(tmp) / f"{i}.pdf") for i in range(len(jobs))] + try: + done = subprocess.run( + [sys.executable, __file__], + input=json.dumps({"jobs": jobs, "outs": outs}).encode(), + capture_output=True, + timeout=timeout_s, + ) + except subprocess.TimeoutExpired as error: + raise PrintError(f"PDF 만들기가 {timeout_s:.0f}초 안에 안 끝남") from error + except OSError as error: + raise PrintError(NO_BROWSER) from error + try: + result = json.loads(done.stdout.decode().strip().splitlines()[-1]) + except (ValueError, IndexError): # 자식이 죽음 — stderr(예외 글)는 내지 않음 + result = {"reason": NO_BROWSER} + if result.get("reason"): + raise PrintError(result["reason"]) + return [Path(out).read_bytes() for out in outs] + + +def render_html_to_pdf( + html: str, + *, + page_size: str | tuple[float, float] | None = "A4", + landscape: bool = False, + margins: dict[str, str] | None = None, + page_numbers: bool = False, + prepare_js: str | None = None, + prepare_arg: Any = None, + timeout_s: float = TIMEOUT_S, +) -> bytes: + """HTML 한 벌 → PDF 바이트. margins = {"top": "10mm", …}(None = CSS · 0).""" + job = { + "html": html, + "page_size": page_size, + "landscape": landscape, + "margins": margins, + "page_numbers": page_numbers, + "prepare_js": prepare_js, + "prepare_arg": prepare_arg, + } + return render_htmls_to_pdfs([job], timeout_s)[0] + + +def merge_pdfs(parts: list[bytes]) -> bytes: + """PDF 여러 벌 → 한 벌(쪽 차례 그대로 · 같은 글꼴은 한 번만).""" + from pypdf import PdfWriter + + writer = PdfWriter() + for part in parts: + writer.append(io.BytesIO(part)) + writer.compress_identical_objects(remove_identicals=True, remove_orphans=True) + out = io.BytesIO() + writer.write(out) + return out.getvalue() + + +# ── 엑셀 ────────────────────────────────────────────────────────────────── + +BAD_TITLE = re.compile(r"[\[\]:*?/\\]") + + +def sheet_title(name: str, used: set[str]) -> str: + """엑셀 시트 이름 — 금지 글자 = 「_」 · 31자 · 같은 이름 = 뒤에 (2)…""" + base = BAD_TITLE.sub("_", name).strip().strip("'") or "시트" + title, n = base[:31], 1 + while title.lower() in used: + n += 1 + tail = f" ({n})" + title = base[: 31 - len(tail)] + tail + used.add(title.lower()) + return title + + +def number_format(decimals: int | None) -> str: + return "#,##0" + ("." + "0" * decimals if decimals else "") + + +def print_setup(ws: Any, orientation: str, head_rows: int) -> None: + """인쇄 설정(302-5) — PDF 와 같은 방향 · A4 · 폭 1쪽 맞춤 · 머리 줄 쪽마다 · 여백 ≈ PDF.""" + from openpyxl.worksheet.page import PageMargins + + ws.page_setup.orientation = orientation + ws.page_setup.paperSize = ws.PAPERSIZE_A4 + ws.page_setup.fitToWidth = 1 + ws.page_setup.fitToHeight = 0 + ws.sheet_properties.pageSetUpPr.fitToPage = True + ws.page_margins = PageMargins(left=0.4, right=0.4, top=0.4, bottom=0.55, header=0.2, footer=0.2) + if head_rows: + ws.print_title_rows = f"1:{head_rows}" + ws.oddHeader.left.text = "&A" # 시트 이름 + ws.oddFooter.center.text = "&P / &N" # 쪽 번호 + + +if __name__ == "__main__": # 자식 프로세스 몸통 + job = json.loads(sys.stdin.buffer.read()) + try: + for out, data in zip(job["outs"], _render(job["jobs"]), strict=True): + Path(out).write_bytes(data) + print(json.dumps({"reason": None})) + except PrintError as error: + print(json.dumps({"reason": str(error)}, ensure_ascii=False)) + sys.exit(0) diff --git a/common_util/common_util_project_kind.py b/common_util/common_util_project_kind.py new file mode 100644 index 000000000..d4d4e997d --- /dev/null +++ b/common_util/common_util_project_kind.py @@ -0,0 +1,102 @@ +"""종류별 프로젝트 행 만들기 — D 예정노선 · E 안정성평가(요구 345). + +임도설계(ROAD)는 B02 그대로. 저장소 경로 · 초기 폴더는 B02 함수를 그대로 씀. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any +from uuid import uuid4 + +from B02_ProjRegister.B02_ProjRegister_Repository import ( + _build_project_storage, + _initialize_project_storage, +) +from common_util.common_util_audit import record_audit +from common_util.common_util_storage import get_project_stage_path +from common_util.common_util_workflow_state import initialize_project_stages +from config.config_db import get_db_pool + +KIND_STAGE_DIRS = { + "PLAN": ("D02_PlanBase", "D03_PlanAnalysis", "D04_PlanConfirm"), + "STABILITY": ("E02_StabGround", "E03_StabSection", "E04_Stab3D", "E05_StabReport"), +} + + +async def create_kind_project( + *, + request: Any | None, + user_id: int, + company_id: int, + kind: str, + name: str, + region: str | None, + road_type: str | None, + project_year: int | None, + project_year_end: int | None = None, + design_speed_kph: int | None = None, + route_start_m: float | None = None, + route_end_m: float | None = None, + source_project_id: str | None = None, +) -> str: + """projects 행 + 참여자 + 종류별 단계 + 저장소 폴더 → 새 project_id.""" + project_id = str(uuid4()) + storage_path, project_root = _build_project_storage(company_id, user_id, project_id) + now = datetime.utcnow() + + async with get_db_pool().acquire() as connection, connection.cursor() as cursor: + try: + await connection.begin() + await cursor.execute( + """ + INSERT INTO projects ( + id, user_id, company_id, name, region, road_type, project_kind, + project_year, project_year_end, design_speed_kph, + route_start_m, route_end_m, status, crs_epsg, storage_path, + source_project_id, source_copied_at, created_at, updated_at + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'NEW', 5178, %s, + %s, %s, %s, %s) + """, + ( + project_id, + user_id, + company_id, + name, + region, + road_type, + kind, + project_year, + project_year_end, + design_speed_kph, + route_start_m, + route_end_m, + storage_path, + source_project_id, + now if source_project_id else None, + now, + now, + ), + ) + await cursor.execute( + "INSERT IGNORE INTO project_members (project_id, user_id) VALUES (%s, %s)", + (project_id, user_id), + ) + await initialize_project_stages(cursor, project_id, kind) + await record_audit( + cursor, + actor_id=user_id, + action="PROJECT_CREATE", + resource_type="project", + resource_ref=project_id, + request=request, + ) + _initialize_project_storage(project_root, project_id) + for stage_dir in KIND_STAGE_DIRS[kind]: + get_project_stage_path(str(project_root), stage_dir) + await connection.commit() + except Exception: + await connection.rollback() + raise + return project_id diff --git a/common_util/common_util_workflow_state.py b/common_util/common_util_workflow_state.py index f3eea9bdd..3c0c4c7d5 100644 --- a/common_util/common_util_workflow_state.py +++ b/common_util/common_util_workflow_state.py @@ -20,9 +20,19 @@ STAGE_KEYS = [ ] -async def initialize_project_stages(cursor: aiomysql.DictCursor, project_id: str) -> None: - """프로젝트 생성 시 7개의 단계를 NOT_STARTED 상태로 시드한다.""" - for stage_no, stage_key in enumerate(STAGE_KEYS): +# 종류별 단계(요구 345) — ROAD = B03~B09 · PLAN = D02~D04 · STABILITY = E02~E05. +STAGE_KEYS_BY_KIND = { + "ROAD": STAGE_KEYS, + "PLAN": ["PLAN_BASE", "PLAN_ANALYSIS", "PLAN_CONFIRM"], + "STABILITY": ["STAB_GROUND", "STAB_SECTION", "STAB_3D", "STAB_REPORT"], +} + + +async def initialize_project_stages( + cursor: aiomysql.DictCursor, project_id: str, kind: str = "ROAD" +) -> None: + """프로젝트 생성 시 종류별 단계를 NOT_STARTED 상태로 시드한다.""" + for stage_no, stage_key in enumerate(STAGE_KEYS_BY_KIND[kind]): await cursor.execute( """ INSERT INTO project_workflow_stages ( diff --git a/config/config_system.py b/config/config_system.py index 364da6e9d..2fa29bb33 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -211,6 +211,15 @@ PROJECT_STORAGE_STAGE_DIRS = frozenset( "B07_DesignDetail", "B08_Quantity", "B09_Estimation", + "D01_PlanRegister", + "D02_PlanBase", + "D03_PlanAnalysis", + "D04_PlanConfirm", + "E01_StabRegister", + "E02_StabGround", + "E03_StabSection", + "E04_Stab3D", + "E05_StabReport", } ) diff --git a/db_management/020_project_groups.sql b/db_management/020_project_groups.sql new file mode 100644 index 000000000..e32d7bc4c --- /dev/null +++ b/db_management/020_project_groups.sql @@ -0,0 +1,59 @@ +-- 020_project_groups.sql +-- 신규 프로젝트 그룹 — 종류 · 예정노선 연도 구간 · 지정 업체 · 산림청 회사 (요구 345 · 347-8) +-- +-- 추가만 함 — 기존 행 = ROAD(임도설계) · 기존 칸 · 동작 그대로. +-- 예정노선 시작 · 종료 누가거리는 기존 route_start_m · route_end_m 을 씀. + +USE aislo_db; + +ALTER TABLE projects + ADD COLUMN IF NOT EXISTS project_kind VARCHAR(20) NOT NULL DEFAULT 'ROAD' + COMMENT 'ROAD 임도설계 · PLAN 예정노선 · EROSION 사방설계 · STABILITY 안정성평가' AFTER road_type, + ADD COLUMN IF NOT EXISTS project_year_end INT NULL + COMMENT '사업연도 범위 끝(예정노선) — 시작은 project_year' AFTER project_year, + ADD COLUMN IF NOT EXISTS design_speed_kph INT NULL + COMMENT '설계속도(km/h · 예정노선 등록값)' AFTER project_year_end, + ADD COLUMN IF NOT EXISTS source_project_id CHAR(36) NULL + COMMENT '원본 프로젝트(E = 임도 · 임도 = 예정노선)', + ADD COLUMN IF NOT EXISTS source_copied_at DATETIME NULL COMMENT '원본 복사 시각', + ADD COLUMN IF NOT EXISTS source_fingerprint CHAR(40) NULL COMMENT '복사 때 원본 지문(sha1)'; + +CREATE TABLE IF NOT EXISTS plan_route_years ( + project_id CHAR(36) NOT NULL COMMENT 'projects.id (PLAN)', + year INT NOT NULL, + start_x DOUBLE NULL, + start_y DOUBLE NULL, + end_x DOUBLE NULL, + end_y DOUBLE NULL, + epsg INT NULL, + route_json JSON NULL COMMENT '분석 결과 노선', + fingerprint CHAR(40) NULL COMMENT '노선 내용 지문(sha1)', + confirmed_at DATETIME NULL, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (project_id, year), + CONSTRAINT fk_plan_route_years_project FOREIGN KEY (project_id) + REFERENCES projects (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='예정노선 연도 구간'; + +CREATE TABLE IF NOT EXISTS plan_route_assignees ( + id INT AUTO_INCREMENT PRIMARY KEY, + project_id CHAR(36) NOT NULL COMMENT 'projects.id (PLAN)', + year INT NOT NULL, + company_id INT NULL COMMENT '등록 회사 — NULL 이면 수기 업체', + company_name VARCHAR(255) NULL, + email VARCHAR(255) NULL, + notified_at DATETIME NULL, + linked_project_id CHAR(36) NULL COMMENT '업체가 연결한 임도 프로젝트', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_plan_route_assignees_project (project_id, year), + KEY idx_plan_route_assignees_company (company_id), + CONSTRAINT fk_plan_route_assignees_project FOREIGN KEY (project_id) + REFERENCES projects (id) ON DELETE CASCADE, + CONSTRAINT fk_plan_route_assignees_company FOREIGN KEY (company_id) + REFERENCES companies (id) ON DELETE SET NULL, + CONSTRAINT fk_plan_route_assignees_linked FOREIGN KEY (linked_project_id) + REFERENCES projects (id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='예정노선 연도별 지정 업체'; + +-- 산림청 회사 — 메일 *.go.kr 가입자가 자동으로 붙음. +INSERT IGNORE INTO companies (name, company_code, status) VALUES ('산림청', 'FOREST', 'ACTIVE'); diff --git a/main.py b/main.py index 9e294d58a..1eb86c7d3 100644 --- a/main.py +++ b/main.py @@ -72,6 +72,9 @@ from B08_Quantity.B08_Quantity_Router_Summary import router as b08_summary_route from B08_Quantity.B08_Quantity_Router_Material import router as b08_material_router from B08_Quantity.B08_Quantity_Router_Milestone import router as b08_milestone_router from B09_Estimation.B09_Estimation_Router import router as b09_estimation_router +from D01_PlanRegister.D01_PlanRegister_Router import dashboard_router as d01_dashboard_router +from D01_PlanRegister.D01_PlanRegister_Router import router as d01_plan_register_router +from E01_StabRegister.E01_StabRegister_Router import router as e01_stab_register_router from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router from M02_MasterTemplete.M02_Labor_Router import router as m02_labor_router from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_master_template_router @@ -450,6 +453,10 @@ app.include_router(a09_security_router) app.include_router(b01_dashboard_router) app.include_router(b02_proj_register_router) protected = [Depends(verify_session)] +# 신규 그룹 등록(요구 345) — 권한은 길 안에서(산림청 · 회사). +app.include_router(d01_plan_register_router, dependencies=protected) +app.include_router(d01_dashboard_router, dependencies=protected) +app.include_router(e01_stab_register_router, dependencies=protected) # 프로젝트 경로가 붙는 라우터는 그 프로젝트가 내 회사 것인지도 함께 본다 # (2026-09-01 — 지금까지 id만 알면 남의 회사 자료에 닿았다). protected_with_company = [ diff --git a/resources/knowledge/original/안정성평가/06_상용SW_기능_결과표시_보고서_검증예제.md b/resources/knowledge/original/안정성평가/06_상용SW_기능_결과표시_보고서_검증예제.md new file mode 100644 index 000000000..849a7dc89 --- /dev/null +++ b/resources/knowledge/original/안정성평가/06_상용SW_기능_결과표시_보고서_검증예제.md @@ -0,0 +1,247 @@ +# 상용 사면안정 해석 프로그램 기능·결과 표시·보고서 구성 및 공개 검증 예제 + +## 1. 개요 및 조사 목적 + +- **조사 목적**: Aislo 안정성평가(해석) 라이선스 모듈의 기능 구성, 탐색 알고리즘, 결과 시각화(임계면·등안전율·절편력), 설계보고서 자동 출력 포맷, 그리고 자체 엔진(2D/3D LEM) 교차 검증용 공개 벤치마크 예제(Verification Manual) 스펙을 확보하기 위함임. +- **조사 대상**: + 1. **Rocscience Slide2** (2D 한계평형해석 글로벌 표준) + 2. **Rocscience Slide3** (3D 한계평형해석 글로벌 표준) + 3. **GeoStudio SLOPE/W** (Seequent, 침투 SEEP/W·응력 SIGMA/W 연동 표준) + 4. **MIDAS SoilWorks / GTS NX** (국내 도로·철도·단지 토목 실무 다수 채택) + 5. **Fine Software GEO5 Slope Stability** (유럽·실무 설계보고서 일체형 표준) + 6. **Bentley PLAXIS LE (구 SoilVision SVSlope)** (2D/3D 다중 탐색·불포화 침투 특화) + 7. **국내 임도·도로 비탈면 안정성 검토 실무 보고서 표준 목차 및 출력 항목** +- **작성 원칙**: 각 소프트웨어별 공식 매뉴얼(User Guide, Theory Manual, Verification Manual) 및 국내 실무 기준에 근거하여 **해석 방법 · 탐색 방식 · 지하수/하중/보강 입력 · 결과 화면 · 보고서 구성 · 공개 검증 예제(입력 제원 및 기대 안전율)**를 원문 수치 그대로 정리함. + +--- + +## 2. 상용 사면안정 프로그램 6종 상세 기능 비교 + +### 2.1 종합 비교 매트릭스 + +| 구분 | Rocscience Slide2 | Rocscience Slide3 | GeoStudio SLOPE/W | MIDAS SoilWorks | Fine GEO5 Slope Stability | Bentley PLAXIS LE (SVSlope) | +| :--- | :--- | :--- | :--- | :--- | :--- | :--- | +| **차원** | 2D 단면 | 3D 지형·지층 (Column) | 2D (3D 별도 모듈) | 2D 단면 (3D는 GTS NX) | 2D 단면 | 2D / 3D 통합 | +| **주요 한계평형법 (LEM)** | Ordinary(Fellenius), Bishop, Janbu(간편/보정), Spencer, GLE/M-P, Corps of Engineers #1/#2, Lowe-Karafiath, Sarma(연직/비연직) | 3D Bishop, 3D Janbu, 3D Spencer, 3D GLE/M-P | Ordinary, Bishop, Janbu, Spencer, Morgenstern-Price, GLE, Corps #1/#2, Lowe-Karafiath, Sarma, 유한요소 응력기반(SIGMA/W 연동) | Fellenius, Bishop, Janbu, Spencer, Morgenstern-Price, + 유한요소 강도감소법(SRM) 내장 | Bishop, Fellenius/Petterson, Spencer, Janbu, Morgenstern-Price, Shahunyants, ITFM | Bishop, Janbu, Spencer, GLE/M-P, Corps #1/#2, Lowe-Karafiath, Sarma, Kulhawy(FEM 응력) | +| **원호 탐색 (Circular)** | Grid Search(격자+반경 증분), Slope Search(비탈면 출입점), Auto Refine Search(영역 분할 적응형) | Ellipsoid(타원체) Cuckoo Search, Particle Swarm(PSO) | Entry and Exit(진입·이탈 구간 지정), Grid and Radius(격자 중심·접선 반경), Auto Locate | Grid Search(원호 중심 격자 + 반경 분할/접선), Auto Search(자동 격자 설정), 지정 원호/다각형 | Grid Search(격자 탐색), Optimization(자동 최적화 원호 탐색) | Grid & Tangent, Entry & Exit, Slope Search | +| **비원호 탐색 (Non-Circular) 및 전역 최적화** | Block Search(약층/다각형), Path Search, Simulated Annealing, Cuckoo Search, Particle Swarm(PSO), **Surface Altering Optimization (SAO, 국부 곡면 변형 최적화)** | Ellipsoid + **Surface Altering Optimization (3D 자유 형상 활동면 변형)**, Multi-planar(다중 평면/쐐기) | Block Specified(블록 지정), Fully Specified, **Optimization (Monte Carlo 국부 절점 이동 최적화)** | 다각형(Polygonal) 활동면 탐색, 약층/파쇄대 통과 지정 탐색, SRM 소성전단변형률 집중대 자동 도출 | Polygonal Surface Optimization(다각형 꼭짓점 좌표 반복 이동 최적화) | Greco(Monte Carlo), Dynamic Programming, Cuckoo Search, Simulated Annealing | +| **지하수 입력** | Water Table(지하수위선), Piezometric Line, $r_u$ 계수, Pore Pressure Grid(수압 격자), 내장 Steady/Transient FE Seepage | Water Surfaces(3D 수위면), $r_u$, Pore Pressure Grid(3D), RS3/정상류 침투 연동 | Piezometric Line, $r_u$, B-bar($\bar{B}$), Spatial PWP, **SEEP/W 정밀 부정류 강우침투 완전 연동** | 지하수위선(Water Level), 간극수압계수($r_u$), **강우침투 부정류 해석(Seepage) 수두 분포 직접 연동** | 지하수위선(Water Table), 피압수위선, $r_u$ 계수, 등수두선 격자 입력 | Water Table, Piezometric Surface, $r_u$, SVFlux(2D/3D 불포화 침투) 연동 | +| **하중 및 지진** | 등분포/삼각형 분포하중, 선하중(Line Load), 유사정적 지진계수($k_h, k_v$), Newmark 변위법 | 면하중(Area Load), 선하중, 점하중, 유사정적 지진계수($k_h, k_v$) | Point Load, Surcharge(상재하중), Anchor Load, 유사정적($k_h, k_v$), QUAKE/W 동적 응력 연동 | 집중하중, 등분포/경사분포 상재하중, 유사정적 수평·수직 지진계수($k_h, k_v$) | 상재 띠하중(Surcharge Strip), 사다리꼴 하중, 선하중, 수평·수직 지진계수($k_h, k_v$) | Distributed Load, Point/Line Load, 유사정적 지진계수($k_h, k_v$) | +| **보강재 (Support)** | Soil Nail, Ground Anchor(Tieback), Geosynthetic(토목섬유), Micro-pile, RSP(억지말뚝), Grouted Tieback, Launch Soil Nail | 3D 패턴 배열(Soil Nail, Tieback, Geosynthetic, Pile/Micro-pile), EVW(등가부피) | Nail, Tieback Anchor, Geosynthetic(Reinforcement), Pile/Dowel | 네일(Nail), 어스앵커(Anchor - 정착장/자유장 구분), 보강토 띠/지오그리드, 억지말뚝(Pile) | Nails, Prestressed Anchors, Geosynthetics(Reinforcement), Anti-slide Piles | Nail, Tieback, Geosynthetic, Pile/Micro-pile | + +--- + +### 2.2 소프트웨어별 세부 특징 및 알고리즘 명세 + +#### (1) Rocscience Slide2 (2D) +- **탐색 방식 상세**: + - **Grid Search**: 회전 중심 격자($N_x \times N_y$, 기본 $20 \times 20$)의 각 점에서 반경 증분(Radius Increment) 또는 슬립 한계 접선(Slope Limits)으로 원호를 생성함. + - **Slope Search**: 비탈면 어깨(Crest)와 기슭(Toe)에 진입(Initiation)·이탈(Termination) 구간을 지정하고 무작위 원호/비원호를 생성함. + - **Auto Refine Search**: 비탈면 표면을 따라 구간을 분할하여 반복 계산 후 안전율이 낮은 구간을 좁혀가며 탐색함. + - **Cuckoo Search / Particle Swarm Optimization (PSO)**: 메타휴리스틱 전역 최적화로 비원호 다각형 꼭짓점을 탐색함. + - **Surface Altering Optimization (SAO)**: 원호 또는 Cuckoo/PSO로 찾은 초기 임계 활동면의 제어점(Spline/Vertex)을 국부적으로 미세 이동시켜 안전율을 추가 최소화하는 후처리 최적화 기법임(일반 탐색 대비 $FS$가 $3\sim 10\%$ 더 낮고 현실적인 복합 곡면을 도출함). +- **결과 화면 (Interpret)**: + - **Global Minimum & All Surfaces**: 최소 안전율 활동면(굵은 실선 + 회전 중심·반경·안전율 텍스트 박스) 및 안전율 필터 범위(예: $FS < 1.5$) 내 모든 활동면 색상 그라데이션 표시. + - **Safety Map (등안전율 컨투어 맵)**: 지반 내부 각 지점을 통과하는 최소 안전율을 색상 등고선 밴드(Red = 위험, Blue = 안전)로 비탈면 단면 위에 직접 중첩 표시함.Grid Search 시 격자 상단에도 최소 안전율 등고선(Grid Contour)을 표시함. + - **Slice Data & Free Body Diagram**: 임의 절편 클릭 시 해당 절편의 자중($W$), 저면 수직력($N$), 저면 전단저항력($S$), 간극수압($U$), 좌우 절편간 수평력($E_L, E_R$)·전단력($X_L, X_R$) 수치를 화살표 자유물체도로 표시하고, 수평 거리($x$)에 따른 절편 저면 응력·간극수압·절편간력 그래프를 출력함. + - **Probabilistic / Sensitivity Plot**: 지반물성 확률분포(정규·대수정규·삼각분포) 입력 시 Monte Carlo / Latin Hypercube 시뮬레이션으로 **파괴확률($P_f, \%$)**, **신뢰도지수(Reliability Index, $\beta$)**, 안전율 히스토그램 및 민감도 거미줄 그래프(Spider Plot)를 출력함. +- **보고서 구성 (Report Generator / Info Viewer)**: + - 표지(프로젝트명·작성자·날짜) $\rightarrow$ 해석 요약(적용 기법·수렴 공차·절편 수·탐색 설정) $\rightarrow$ 방법별 최소 안전율 요약표 $\rightarrow$ 지층 물성치 표(색상 범례 포함) $\rightarrow$ 하중·보강재 제원표 $\rightarrow$ 단면 뷰(임계면·Safety Map 캡처) $\rightarrow$ 절편별 상세 계산표(Slice #1~$n$의 폭, 높이, 저면각 $\alpha$, 자중 $W$, 수직응력 $\sigma_n$, 간극수압 $u$, 전단강도 $\tau$) 순으로 구성됨. +- **출처**: + - Slide2 User Guide Overview: `https://www.rocscience.com/help/slide2/documentation/slide2-model/slide2-overview` + - Slide2 Verification Manual PDF: `https://static.rocscience.cloud/assets/verification-and-theory/Slide2/Slide_SlopeStabilityVerification.pdf` + +#### (2) Rocscience Slide3 (3D) +- **해석 및 탐색 방식 상세**: + - 2D 절편(Slice)을 3D 연직 기둥(Column, 정사각형 격자)으로 확장하여 **3D Bishop, 3D Janbu, 3D Spencer, 3D GLE/M-P**를 동시 계산함. + - 초기 탐색은 3차원 타원체(Ellipsoid)를 대상으로 **Cuckoo Search** 또는 **Particle Swarm (PSO)**을 수행하고, 이후 **Surface Altering Optimization (SAO)**으로 타원체 표면을 비대칭 3D 스플라인 곡면으로 변형하여 실제 지층 경계·약층을 따르는 최소 안전율 곡면을 도출함. +- **결과 화면**: + - 3D 지형/지층 메쉬 위에 3D 임계 활동체(Slip Volume, $\text{m}^3$), 미끄러짐 방향 화살표(Direction of Sliding Azimuth $\theta$), 표면 Safety Map(지표면 위 위험 영역 히트맵), 기둥(Column)별 저면 안전율·전단응력·수압 3D 컨투어, 임의 2D 단면 절단선(Section Cut)에서의 2D Slide2 연동 비교 화면을 제공함. +- **출처**: + - Slide3 Verification Manual: `https://www.rocscience.com/help/slide3/verification-and-theory/verification-manuals` + +#### (3) GeoStudio SLOPE/W (Seequent) +- **해석 및 탐색 방식 상세**: + - **Entry and Exit (진입·이탈 구간 지정)**: 비탈면 상단 진입 구간(Entry Range, 적색 선)과 하단 이탈 구간(Exit Range, 녹색 선)을 마우스로 드래그 지정하고, 두 구간을 연결하는 분할점 수와 반경 접선 분할 수를 입력하면 직관적으로 수천 개의 원호를 자동 생성함(실무 엔지니어가 가장 선호하는 UI 방식임). + - **Auto Locate & Optimization**: 최소 안전율 원호를 찾은 뒤 활동면(Slip Surface)을 선분(Line Segments)으로 쪼개어 각 절점을 무작위 이동(Monte Carlo Walk)시켜 안전율이 낮아지는 방향으로 꺾인 비원호 곡선으로 최적화함. + - **SEEP/W · SIGMA/W 연동**: 동일 파일(`.gsz`) 내에서 SEEP/W 부정류 강우침투 해석의 시간대별($t = 0, 6, 12, 24, 48\text{ hr}$) 간극수압 수두와 SIGMA/W 유한요소 응력해석 결과를 그대로 받아 시간대별 안전율 변화 곡선($FS$ vs. Time)을 계산함. +- **결과 화면**: + - **Slip Surface Color Map & Safety Map**: 지정 개수(예: 하위 100개) 위험 활동면 다발 또는 Safety Map 컬러 밴드를 표시함. + - **View Slice Information**: 개별 절편 선택 시 상세 Free Body Diagram과 힘 다각형(Force Polygon Closure — 절편에 작용하는 모든 힘 벡터가 닫히는지 시각적으로 검증하는 다이어그램)을 함께 보여줌. + - **Draw Graph**: 활동면 거리($x$)별 Base Normal Stress, Pore-Water Pressure, Mobilized Shear, Slice Shear/Normal Force 그래프를 제도함. +- **출처**: + - SLOPE/W Verification Manual: `https://www.seequent.com/products-solutions/geostudio/slope-w/` + +#### (4) MIDAS SoilWorks (비탈면 모듈) / GTS NX +- **해석 및 탐색 방식 상세**: + - 국내 설계 엔지니어링사에서 도로·택지·임도 실시설계 시 가장 널리 사용하는 패키지 중 하나임. + - **한계평형법(LEM) + 유한요소 강도감소법(SRM) 하이브리드**: 동일 모델에서 LEM(Bishop, Janbu, Spencer, M-P)과 2D 유한요소 SRM(Strength Reduction Method)을 동시에 수행하여 LEM 임계원호와 SRM 최대 전단변형률 집중대(Shear Strain Contour)를 겹쳐 비교할 수 있음. + - **강우침투 연성 해석**: 산림청·국토부 우기 시 설계기준(예: 100년 빈도 강우강도 $I$, 지속시간 $24\sim 48\text{ hr}$ 적용 부정류 침투해석 후 시간대별 최소 안전율 산정) 워크플로우가 한국 설계기준에 맞춰 템플릿화되어 있음. +- **결과 화면 및 보고서**: + - 건기·우기·지진 시 조건별 최소 안전율 및 허용안전율(KDS 기준값自动 대조: 건기 $1.5$, 우기 $1.2\sim 1.3$, 지진 $1.1$) 판정 결과(`O.K.` / `N.G.`)를 한눈에 표시함. + - **Excel / Word 국문 계산서 자동 출력**: 국내 감리·심의 제출 양식에 맞춘 국문 구조계산서(단면도, 지반정수표, 탐색 격자 최소안전율표, 임계활동면 절편 계산표, 쏘일네일/앵커 소요 저항력 및 간격 검토표)를 원클릭으로 생성함. + +#### (5) Fine Software GEO5 Slope Stability +- **해석 및 탐색 방식 상세**: + - 유럽(Eurocode 7)·미국(LRFD)·표준 안전율(ASD) 설계법을 모두 지원하며, 원호(Bishop, Fellenius, Spencer, Janbu, M-P)와 다각형 비원호(Spencer, Janbu, M-P, Sarma) 최적화를 탑재함. + - 다단계 시공(Stage Construction: 원지반 $\rightarrow$ 1단 절토 $\rightarrow$ 네일 시공 $\rightarrow$ 2단 절토 $\rightarrow$ 성토 재하)을 하나의 파일 내 Stage 탭으로 연속 관리함. +- **보고서 구성**: + - 별도 후처리 없이 프로그램 하단에 실시간으로 구조계산서 미리보기가 갱신되며, 회사 로고·머리글·단면 스케일 도면·수식 대입 과정이 포함된 PDF/RTF 보고서를 즉시 출력함. +- **출처**: + - GEO5 Slope Stability Verification Manual: `https://www.finesoftware.eu/verification/slope-stability/` + +#### (6) Bentley PLAXIS LE (구 SoilVision SVSlope) +- **해석 및 탐색 방식 상세**: + - 2D/3D 한계평형해석에서 가장 다양한 비원호 탐색 알고리즘(Greco Monte Carlo, Dynamic Programming, Cuckoo Search, Entry & Exit, Slope Search, Auto-Refine)을 보유함. + - 불포화 지반역학(Fredlund & Rahardjo 모관흡수력 $\psi = u_a - u_w$, $\phi^b$ 모델)에 특화되어 있어 강우 시 표면 포화대 하강에 따른 천층 파괴(Shallow Failure)와 심층 원호 파괴를 단일 모델에서 정밀 모사함. + +--- + +## 3. 국내 임도·도로 비탈면 실무 안정성 검토 보고서 표준 목차 및 출력 항목 + +국내 국도·지방도·임도 실시설계 및 산사태취약지역/사방사업 실시설계 시 제출하는 **「비탈면 안정성 검토 보고서(구조 및 수리계산서)」**의 표준 목차와 필수 출력 테이블 구성은 다음과 같음. + +### 3.1 표준 보고서 목차 (7단계 구성) + +1. **검토 개요 및 목적** + - 대상 구간 현황(노선명, 측점 STA. NO., 절토/성토 구분, 비탈면 최대 높이 $H$, 평균 경사 $1:n$, 소단 설치 단수 및 폭). + - 적용 설계기준 명시: 국토교통부 「건설공사 비탈면 설계기준(KDS 11 70 05)」, 산림청 「임도설계·시공 지침」 등. +2. **해석 방법 및 기준 안전율 (Allowable Factor of Safety)** + - 적용 해석 기법 명시(예: 토사·풍화암 = Bishop 간편법 및 Janbu/Spencer법 병기, 암반 = 평사투영법 + 쐐기/평면/전도 한계평형해석). + - 상태별 기준 안전율 표 제시: + +| 검토 조건 | 기준 안전율 ($FS_{\text{allow}}$) | 지하수위 및 하중 조건 적용 기준 (KDS 11 70 05 / 실무 기준) | +| :--- | :---: | :--- | +| **건기 시 (장기)** | $1.50$ 이상 | 시추조사 측정 지하수위 적용 | +| **우기 시 (단기)** | $1.20 \sim 1.30$ 이상 | 지표면 포화(만수위) 또는 설계강우 부정류 침투해석 수두 분포 적용 | +| **지진 시** | $1.10$ 이상 | 건기 시 지하수위 + 지진구역계수·재현주기에 따른 수평지진계수 $k_h = 0.5 \cdot S$ 적용 | +| **단기 (시공 중)** | $1.10 \sim 1.20$ 이상 | 가설 상태 단기 조건 | + +3. **설계 지반정수(물성치) 산정 및 입력 제원** + - 지층 구성(성토층, 붕적층, 풍화토, 풍화암, 연암, 경암) 및 각 층의 설계 지반정수 요약표: + - 단위중량($\gamma_t, \gamma_{\text{sat}}, \text{kN/m}^3$), 유효점착력($c', \text{kPa}$), 유효내부마찰각($\phi', ^\circ$), 투수계수($k, \text{cm/s}$, 침투해석 시). + - 산정 근거(현장 시추 N치 경험식, 실내 직접전단/삼축압축시험, 문헌 기준값 비교표) 병기. +4. **지하수위 · 상재하중 · 보강재 입력 조건** + - 지하수위 조건(건기 시 측정수위, 우기 시 지표면 수위 또는 침투해석 결과도). + - 상재하중(임도/도로 노면 차량 활하중: 예 $q = 10.0 \sim 12.7\text{ kN/m}^2$). + - 보강재 적용 시 제원(쏘일네일/어스앵커 직경, 천공경, 설계 인장강도 $T_a$, 주면 마찰저항력 $\tau_{\text{bond}}$, 설치 각도 $\theta$, 수평·수직 간격 $S_h \times S_v$, 길이 $L$). +5. **단면별 안정성 해석 결과 요약표 (무보강 vs. 보강)** + - 대표 단면(최대 절토고 단면, 불량 지질 단면, 구조물 인접 단면)별 해석 결과를 일람표로 제시함: + +| 대표 단면 (측점) | 구분 (절/성토) | 비탈면 높이 $H$ (m) | 계획 경사 | 보강 여부 | 건기 시 $FS$ (기준 $1.5$) | 우기 시 $FS$ (기준 $1.2\sim 1.3$) | 지진 시 $FS$ (기준 $1.1$) | 종합 판정 | +| :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | +| STA. 0+240 | 절토 | $14.5$ | $1:1.0$ | 무보강 | $1.32$ (N.G.) | $0.98$ (N.G.) | $1.04$ (N.G.) | **불안정 (보강 또는 경사완화 필요)** | +| STA. 0+240 | 절토 | $14.5$ | $1:1.2$ | 경사완화 | $1.58$ (O.K.) | $1.26$ (O.K.) | $1.19$ (O.K.) | **안정 (O.K.)** | + +6. **해석 결과 단면도 (도면 캡처)** + - 각 조건(건기/우기/지진)별 **지층 단면 + 지하수위선 + 임계 활동면(원호 중심 $(X_c, Y_c)$, 반경 $R$, 최소 안전율 $FS_{\min}$ 표기) + 등안전율 분포(Safety Map)** 이미지. +7. **부록 (절편별 상세 계산 내역서 — Slice Data Table)** + - 임계 활동면의 절편 번호($i = 1 \dots n$)별 폭($b_i$), 평균 높이($h_i$), 저면 경사각($\alpha_i$), 절편 자중($W_i$), 간극수압($u_i$), 저면 수직력($N_i$), 미끄러짐 구동력($W_i \sin\alpha_i$), 전단저항력($S_i = [c'_i l_i + (N_i - u_i l_i)\tan\phi'_i]$) 수치 테이블. + +--- + +## 4. 공개 검증 예제 (Verification Manual Benchmark Problems) + +자체 개발 사면안정 해석 엔진(2D/3D LEM)의 수치 정확도를 자동 회귀 테스트(Unit Test)로 검증하기 위해 **호주 ACADS(Association for Computer Aided Design) 국제 표준 벤치마크(Giam & Donald, 1989)** 및 **Rocscience Slide2 / Slide3 / GeoStudio SLOPE/W / GEO5 Verification Manual**에 공통 수록된 핵심 공개 검증 예제 5건을 정확한 입력 좌표·물성·기대 안전율과 함께 수록함. + +### 4.1 검증 예제 #1: ACADS Problem 1(a) — 균질 단순 토사 사면 (Homogeneous Slope, No Pore Pressure) + +- **출처**: Giam & Donald (1989), *Example Problems for Testing Soil Slope Stability Programs*, ACADS Report No. U255; Rocscience Slide2 Verification Manual Problem #1 (`Slide_SlopeStabilityVerification.pdf`); GeoStudio SLOPE/W Verification Manual; OpenGeoRock Benchmark. +- **문제 설명**: 간극수압이 없는 단일 균질 토사 비탈면(높이 $10\text{ m}$, 경사 $2\text{H}:1\text{V}$, 경사각 $\beta = 26.565^\circ$)의 최소 안전율 탐색 문제임. +- **외곽 경계 꼭짓점 좌표 (External Boundary Polygon, 단위: $\text{m}$)**: + - $(20, 20) \rightarrow (70, 20) \rightarrow (70, 35) \rightarrow (50, 35) \rightarrow (30, 25) \rightarrow (20, 25) \rightarrow (20, 20)$ + - 비탈면 기슭(Toe): $(30, 25)$ / 비탈면 어깨(Crest): $(50, 35)$ +- **지반 물성치 (Mohr-Coulomb)**: + - 단위중량 $\gamma = 20.0\text{ kN/m}^3$ + - 유효점착력 $c' = 3.0\text{ kPa}$ + - 유효내부마찰각 $\phi' = 19.6^\circ$ + - 지하수위: 없음 (Dry, $u = 0$) +- **기대 안전율 (Expected Factor of Safety)**: + +| 해석 방법 | ACADS Referee (Giam & Donald, 1989) | Rocscience Slide2 | GeoStudio SLOPE/W | 허용 오차 범위 (회귀 테스트 기준) | +| :--- | :---: | :---: | :---: | :---: | +| **Bishop Simplified** | $1.000$ | $0.987$ | $0.988$ | $0.985 \sim 1.005$ | +| **Janbu Corrected** | $1.000$ | $0.990$ | — | $0.980 \sim 1.005$ | +| **Spencer** | $1.000$ | $0.986$ | $0.986$ | $0.985 \sim 1.005$ | +| **GLE / Morgenstern-Price** | $1.000$ | $0.986$ | $0.986$ | $0.985 \sim 1.005$ | + +--- + +### 4.2 검증 예제 #2: ACADS Problem 1(b) — 인장균열 및 수압이 존재하는 균질 사면 (Tension Crack with Water) + +- **출처**: Giam & Donald (1989) Problem 1(b); Rocscience Slide2 Verification Manual Problem #2. +- **문제 설명**: 예제 #1과 동일한 기하 형상에서 점착력이 크고 마찰각이 작은 점성토 조건에 인장균열(Tension Crack)이 발생하고 균열 내부에 물이 가득 찬 조건임. +- **외곽 경계 좌표 (단위: $\text{m}$)**: + - 예제 #1과 동일: $(20, 20) \rightarrow (70, 20) \rightarrow (70, 35) \rightarrow (50, 35) \rightarrow (30, 25) \rightarrow (20, 25)$ +- **지반 물성치 및 인장균열 조건**: + - 단위중량 $\gamma = 20.0\text{ kN/m}^3$, 점착력 $c' = 32.0\text{ kPa}$, 내부마찰각 $\phi' = 10.0^\circ$ + - 인장균열 깊이 $z_c \approx 3.8\text{ m}$ (균열 내부 만수위 포화 조건, 지반 본체 내부 간극수압은 $0$) +- **기대 안전율**: + - **ACADS Referee**: $1.650$ + - **Slide2**: Bishop Simplified = $1.596$, Spencer = $1.592$, GLE/M-P = $1.592$, Janbu Corrected = $1.489$ + +--- + +### 4.3 검증 예제 #3: ACADS Problem 1(c) — 3개 지층 다층 사면 (Non-Homogeneous 3-Layer Slope) + +- **출처**: Giam & Donald (1989) Problem 1(c); Rocscience Slide2 Verification Manual Problem #3; GeoStudio SLOPE/W Verification Manual. +- **문제 설명**: 전단강도가 서로 다른 3개 지층으로 구성된 다층 사면에서 다층 경계를 가로지르는 임계 활동면 탐색 능력을 검증하는 문제임. +- **기하 형상 및 지층 경계 좌표 (단위: $\text{m}$)**: + - 외곽 경계: $(20, 20) \rightarrow (70, 20) \rightarrow (70, 35) \rightarrow (50, 35) \rightarrow (30, 25) \rightarrow (20, 25)$ + - 상부/중간 지층 경계선 (Layer 1 / Layer 2): $(36, 28) \rightarrow (70, 28)$ (사면 경사면 $(36, 28)$에서 수평으로 우측 경계까지 연장) + - 중간/하부 지층 경계선 (Layer 2 / Layer 3): $(20, 23) \rightarrow (30, 25) \rightarrow (70, 23)$ (기슭 $(30, 25)$를 꼭짓점으로 완만히 경사진 경계) +- **지층별 물성치 (단위중량은 전 지층 $\gamma = 19.5\text{ kN/m}^3$, 지하수위 없음)**: + - **Layer 1 (상부 사질토층)**: $\gamma = 19.5\text{ kN/m}^3$, $c' = 0.0\text{ kPa}$, $\phi' = 38.0^\circ$ + - **Layer 2 (중간층)**: $\gamma = 19.5\text{ kN/m}^3$, $c' = 5.3\text{ kPa}$, $\phi' = 23.0^\circ$ + - **Layer 3 (하부 점성토층)**: $\gamma = 19.5\text{ kN/m}^3$, $c' = 7.2\text{ kPa}$, $\phi' = 20.0^\circ$ +- **기대 안전율**: + +| 해석 방법 | ACADS Referee | Rocscience Slide2 | GeoStudio SLOPE/W | +| :--- | :---: | :---: | :---: | +| **Bishop Simplified** | $1.39 \sim 1.41$ | $1.405$ | $1.406$ | +| **Spencer** | $1.39$ | $1.375$ | $1.380$ | +| **GLE / Morgenstern-Price** | $1.39$ | $1.374$ | $1.384$ | +| **Janbu Corrected** | — | $1.357$ | — | + +--- + +### 4.4 검증 예제 #4: ACADS Problem 2(a) / Fredlund & Krahn (1977) — 지하수위 및 박층 약층을 포함한 복합 사면 + +- **출처**: Fredlund & Krahn (1977), *Comparison of Slope Stability Methods of Analysis*, Canadian Geotechnical Journal, 14(3), 429–439; Slide2 Verification Manual Problem #4~#9; SLOPE/W Verification Manual. +- **문제 설명**: 높이 $12.2\text{ m}$ ($40\text{ ft}$), 경사 $2\text{H}:1\text{V}$ 사면에 대해서 (Case 1) 단순 균질 건기, (Case 2) 하부 기반암 상단 박층 약층(Weak Layer) 포함, (Case 3) 간극수압계수 $r_u = 0.25$ 적용, (Case 4) 피압수위선(Piezometric Line) 적용 등 6가지 조건별로 6개 한계평형법의 안전율을 정밀 비교한 고전 표준 문제임. +- **주요 제원 (SI 환산)**: + - 본체 토사(Soil 1): $\gamma = 18.84\text{ kN/m}^3$, $c' = 28.73\text{ kPa}$, $\phi' = 20.0^\circ$ + - 하부 약층(Weak Layer, Soil 2): $\gamma = 18.84\text{ kN/m}^3$, $c' = 0.0\text{ kPa}$, $\phi' = 10.0^\circ$ +- **고전 기준 안전율 (Fredlund & Krahn, 1977 원문 vs. Slide2 / SLOPE/W)**: + +| Case 조건 | Ordinary (Fellenius) | Bishop Simplified | Spencer | Janbu Simplified | Morgenstern-Price | +| :--- | :---: | :---: | :---: | :---: | :---: | +| **Case 1 (단순 원호, 건기)** | $1.928$ | $2.080$ | $2.073$ | $2.041$ | $2.076$ | +| **Case 2 (약층 통과 비원호, 건기)** | — | $1.377$ | $1.373$ | $1.432$ | $1.378$ | +| **Case 3 (원호, $r_u = 0.25$)** | $1.607$ | $1.766$ | $1.761$ | $1.735$ | $1.765$ | +| **Case 4 (약층 통과 비원호, $r_u = 0.25$)** | — | $1.124$ | $1.118$ | $1.162$ | $1.124$ | +| **Case 5 (원호, 피압수위선)** | $1.693$ | $1.834$ | $1.830$ | $1.827$ | $1.833$ | +| **Case 6 (약층 통과 비원호, 피압수위선)** | — | $1.248$ | $1.245$ | $1.298$ | $1.250$ | + +--- + +### 4.5 검증 예제 #5: Baligh & Azzouz (1975) / Hungr (1989) — 3D 구형·타원체 활동면 벤치마크 (Slide3 / Scoops3D 검증 예제) + +- **출처**: Baligh & Azzouz (1975), *End Effects on Stability of Cohesive Slopes*, J. Geotech. Eng. Div., ASCE; Hungr, Salgado & Byrne (1989); USGS Scoops3D Manual (Reid et al., 2015, Chapter 7 Verification); Rocscience Slide3 Verification Manual Problem #1~#2. +- **문제 설명**: + 1. **순수 점성토($\phi = 0, c > 0$) 3D 구형(Spherical) 활동면 엄밀해**: 닫힌 해석해(Analytical Solution) $F_{3\text{D}} = 1.402$ ($2\text{D}$ 원호 안전율 $F_{2\text{D}} = 1.323$ 대비 $F_{3\text{D}}/F_{2\text{D}} = 1.060$). Scoops3D 및 Slide3 모두 격자 해상도 세분화 시 $1.402 \pm 0.003$으로 수렴함. + 2. **$c$-$\phi$ 복합 사면 (Zhang, 1988 / Slide3 Verification #2)**: 경사 $2\text{H}:1\text{V}$, 높이 $10\text{ m}$, $\gamma = 18.8\text{ kN/m}^3, c' = 29.0\text{ kPa}, \phi' = 20^\circ$ 사면에 장축 대 단축 비가 대칭인 3D 타원체 활동면을 적용할 때: + - **균질 건기 조건**: Zhang (1988) = $2.122$, Hungr et al. (1989) 3D Bishop = $2.13$, Slide3 3D Bishop = $2.128$, Scoops3D 3D Bishop = $2.135$. + - **하부 약층($c'=0, \phi'=10^\circ$) 포함 복합 타원체**: Zhang (1988) = $1.553$, Hungr (1989) = $1.62$, Slide3 3D Janbu = $1.54$, 3D Bishop = $1.60$. + +--- + +## 5. Aislo 안정성평가 엔진 설계 시 핵심 시사점 요약 + +1. **해석 및 탐색 기능 우선순위**: + - **해석법**: 토사/복합 비탈면은 **Bishop Simplified**(원호 표준)와 **Janbu Simplified/Corrected + Morgenstern-Price(또는 Spencer)**(비원호·복합파괴 표준)의 3종 조합이 국내외 실무 표준임. + - **탐색 UI 및 알고리즘**: 실무 엔지니어가 가장 직관적으로 쓰는 **Entry & Exit (진입·이탈 구간 지정 원호 탐색)**과 **Grid Search (중심 격자 + 반경 탐색)**를 기본 제공하고, 약층·다층 지반을 위해 **최소 원호의 국부 절점 이동 최적화(Monte Carlo / Surface Altering Optimization)**를 후처리로 결합하는 것이 계산 속도와 정확도 면에서 가장 효율적임. +2. **결과 화면 필수 3요소**: + - 단면 위 **최소 안전율 임계면 + 회전중심/반경 박스**, 지반 내 위험 구역을 한눈에 보여주는 **등안전율 컬러 밴드(Safety Map)**, 그리고 개별 절편의 수렴성을 확인하는 **절편 자유물체도(Slice Free Body Diagram) 및 절편력 그래프**가 상용 SW의 공통 핵심 시각화 요소임. +3. **자체 검증 자동화**: + - 4장에 수록한 **ACADS 1(a), 1(c), Fredlund & Krahn (1977) Case 1~6, 3D 구형/타원체 예제**의 좌표와 물성을 `resources/tester/` 단위 테스트 픽스처로 등록하면, 엔진 구현 즉시 상용 SW(Slide2, SLOPE/W, Slide3) 대비 소수점 둘째 자리($\pm 0.01$) 이내 일치 여부를 자동 검증할 수 있음. diff --git a/resources/knowledge/original/안정성평가/07_지반물성치_원문근거.md b/resources/knowledge/original/안정성평가/07_지반물성치_원문근거.md new file mode 100644 index 000000000..d20a5b30d --- /dev/null +++ b/resources/knowledge/original/안정성평가/07_지반물성치_원문근거.md @@ -0,0 +1,190 @@ +# 지반물성치(단위중량·점착력·내부마찰각) 설계기준 및 실무편람 원문 근거 + +## 1. 조사 목적 및 작성 원칙 + +- **조사 목적**: Aislo 안정성평가(해석) 모듈에서 시추·실내시험 결과가 없는 예비·개략 검토 단계 또는 사용자 초기값(Default) 후보 설정을 위해, **풍화토 · 풍화암 · 연암 · 경암 · 성토 · 붕적토(퇴적토) · 절리면**의 **단위중량($\gamma_t, \gamma_{\text{sat}}$) · 점착력($c$) · 내부마찰각($\phi$)**에 관한 국내 공식 설계기준·편람·실무요령의 원문 표와 출처(문서명 · 판/연도 · 장/절/표 제목 · URL)를 수집·정리함. +- **작성 원칙 (기술정보 DB 원칙 준수)**: + - 현행 법령·행정규칙(KDS 설계기준)과 공공기관 발간 편람(한국도로공사 「도로설계요령」·「도로설계실무편람」, 대한토목학회 「도로교시방서」, 서울특별시 「지반조사편람」, 국토교통부·한국시설안전공단 「건설공사 비탈면 설계기준 해설」)의 **인쇄된 원문 표**와 **N치·RMR 기반 경험식**을 명확히 구분하여 수록함. + - 본 문서는 원문 근거와 후보 범위를 제공하는 자료이며, **프로그램 기본값을 임의로 확정하지 않음**(개발 단계에서 사용자와 협의하여 선정함). + +--- + +## 2. 현행 KDS 설계기준의 지반정수 산정 규정 확인 결과 + +국내 현행 국가건설기준(KDS) 원문을 전수 대조한 결과, KDS 본문은 실내·현장시험을 원칙으로 규정하고 특정 지층의 고정 수치표를 본문에 직접 못박기보다 **관련 설계기준 해설서·도로설계요령·지반공학 문헌값을 참조**하도록 위임하고 있음. + +| 기준 코드 및 문서명 | 조항 / 위치 | 원문 규정 요지 | +| :--- | :--- | :--- | +| **KDS 11 70 05** 「비탈면 안정해석 및 설계기준」 (국토교통부, 2016/2020) | `3.2` 지반조사 및 시험 / `3.3` 비탈면 안정해석 | 흙 비탈면과 암반 비탈면의 강도정수($c', \phi'$)는 지반조사 및 실내·현장시험으로 결정함을 원칙으로 함. (구체적인 지층별 문헌 참고표는 모태 기준인 「건설공사 비탈면 설계기준 해설(2011/2016)」 및 「도로설계요령」에 수록됨) | +| **KDS 11 44 00** 「공동구 설계기준」 (국토교통부, 2018 KS 부합화) | `4.1` 하중 `④ 토압` 나.(다) (로컬 파일 573행) | *"흙의 단위중량과 내부마찰각은 보링자료, 토질시험 값 등과 같이 지반조사 자료의 분석과 경험을 종합적으로 고려하며 실내 및 현장시험에 의하지 않았을 경우에는 책임기술자의 판단아래 지반공학적 근거에 입각한 관계식이나 구조물 기초 설계기준 등 관련 자료의 값을 참고로 사용할 수 있다."* | +| **KDS 24 14 51** 「교량 하부구조 설계기준(한계상태설계법)」 (국토교통부, 2018) | `3.9.5.1` 일반사항 / `3.9.6.2` 하중 (로컬 파일 1922, 1946행) | 배면 뒤채움 토사 주동토압 계산 시 *"측정된 값이 없다면 사질토에 대하여 내부마찰각의 최댓값으로 $30^\circ$를 사용해도 된다"*, 보강토체 횡방향 힘 평가 시 *"삼축시험이나 직접전단시험으로 구한 값이 없다면, $34^\circ$로 가정된다(간편법 상한 $40^\circ$)"*고 규정함. | + +--- + +## 3. 공공기관 설계편람·실무요령 수록 지반물성치 원문 표 + +### 3.1 「도로설계요령 (제2권 토공 및 배수)」 — 토사(성토·자연지반·붕적토)의 개략적인 토질 정수표 + +- **문서명**: 국토교통부 · 한국도로공사, **「도로설계요령 (제2권 토공 및 배수)」** (2009년 개정판 / 2020년 판), 제5장 비탈면 안정 및 제6장 토공, **표 〈토사의 개략적인 토질 정수〉** (동일 표가 국토교통부 「국도건설공사 설계실무요령」 토공편에 공통 수록됨). +- **적용 대상**: **성토 재료(다짐 토사)** 및 **자연지반 토사 · 붕적토(퇴적토)**의 예비 설계 및 개략 안정성 검토. +- **표 원문 (단위: SI 기준 $\text{kN/m}^3$, $\text{MPa}$ 및 $\text{kPa}$ 병기)**: + +| 종류 | 재료의 상태 (밀실도 / 연경도) | 단위중량 $\gamma_t$ ($\text{kN/m}^3$) | 내부마찰각 $\phi$ ($^\circ$) | 점착력 $c$ ($\text{MPa}$) | 점착력 $c$ 환산 ($\text{kPa}$) | 통일분류법 기호 (USCS) | +| :--- | :--- | :---: | :---: | :---: | :---: | :---: | +| **자갈** | 밀실한 것, 입도 양호 | $20$ | $40$ | $0$ | $0$ | GW, GP | +| **자갈** | 밀실치 않은 것, 입도 불량 | $18$ | $35$ | $0$ | $0$ | GW, GP | +| **자갈섞인 모래** | 밀실한 것 | $21$ | $40$ | $0$ | $0$ | GW, GP | +| **자갈섞인 모래** | 밀실치 않은 것 | $19$ | $35$ | $0$ | $0$ | GW, GP | +| **모래** | 밀실한 것, 입도 양호 | $20$ | $35$ | $0$ | $0$ | SW, SP | +| **모래** | 밀실치 않은 것, 입도 불량 | $18$ | $30$ | $0$ | $0$ | SW, SP | +| **사질토** | 밀실한 것 (성토 다짐 / 조밀 자연지반) | $19$ | $30$ | $0.03$ 이하 | $30$ 이하 (비탈면 실무 $0\sim 15$) | SM, SC | +| **사질토** | 밀실치 않은 것 (느슨한 자연지반 / **붕적토**) | $17$ | $25$ | $0$ | $0$ | SM, SC | +| **점성토** | 굳은 것 (손가락으로 강하게 눌러 조금 들어감) | $18$ | $25$ | $0.05$ 이하 | $50$ 이하 | ML, CL | +| **점성토** | 약간 무른 것 (중간 정도의 힘으로 들어감) | $17$ | $20$ | $0.03$ 이하 | $30$ 이하 | ML, CL | +| **점성토** | 무른 것 (손가락이 쉽게 들어감) | $17$ | $20$ | $0.015$ 이하 | $15$ 이하 | ML, CL | +| **점토 및 실트** | 굳은 것 (손가락으로 강하게 눌러 조금 들어감) | $17$ | $20$ | $0.05$ 이하 | $50$ 이하 | CH, MH, ML | +| **점토 및 실트** | 약간 무른 것 (중간 정도의 힘으로 들어감) | $16$ | $15$ | $0.03$ 이하 | $30$ 이하 | CH, MH, ML | +| **점토 및 실트** | 무른 것 (손가락이 쉽게 들어감) | $14$ | $10$ | $0.015$ 이하 | $15$ 이하 | CH, MH, ML | + +- **출처 URL**: + - 국토교통 디지털도서관(CODIL) 도로설계요령 원문: `https://www.codil.or.kr/filebank/original/MA/OTKHMA500309/OTKHMA500309.pdf` + - 실무 발췌 아카이브: `https://bacherd81.tistory.com/242` + +--- + +### 3.2 「도로교시방서」 — 토질 및 표준관입시험($N$값)에 따른 단위체적중량($\gamma_t, \gamma'$, $\gamma_{\text{sat}}$) 기준표 + +- **문서명**: 대한토목학회 · 국토교통부, **「도로교시방서」** (2000년 / 2008년 판, 현행 「구조물기초설계기준 해설」에 승계), **표 〈흙의 단위중량〉 및 표 〈$N$값과 단위중량〉**. +- **적용 대상**: 자연지반 토사(붕적토·풍화토 포함)의 습윤단위중량($\gamma_t$), 수중단위중량($\gamma' = \gamma_{\text{sat}} - \gamma_w$), 포화단위중량($\gamma_{\text{sat}} = \gamma' + 9.81 \approx \gamma' + 10\text{ kN/m}^3$) 산정. + +#### (1) 표 〈자연지반 흙의 단위중량 ($\text{kN/m}^3$)〉 + +| 지반 구분 | 토질 분류 | 느슨한 것 (붕적토·매립토 등) | 조밀한 것 (풍화토·조밀 퇴적토 등) | +| :--- | :--- | :---: | :---: | +| **자연지반** | **모래 및 자갈** | $18$ | $20$ | +| **자연지반** | **사질토** | $17$ | $19$ | +| **자연지반** | **점성토** | $14$ | $18$ | + +#### (2) 표 〈표준관입시험 $N$값과 단위체적중량 ($\text{kN/m}^3$)〉 + +| 토질 구분 | $N$값 (회 / $30\text{ cm}$) | 일반 (습윤단위중량 $\gamma_t$) | 수중 (수중단위중량 $\gamma'$) | 포화단위중량 환산 ($\gamma_{\text{sat}} \approx \gamma' + 10$) | 해당 지층 대응 (실무 기준) | +| :--- | :---: | :---: | :---: | :---: | :--- | +| **사질토** | $50$ 이상 | $20$ | $10$ | $20 \sim 21$ | 매우 조밀한 풍화토 / 풍화암 상부 | +| **사질토** | $30 \sim 50$ | $19$ | $9$ | $19 \sim 20$ | 조밀한 풍화토 | +| **사질토** | $10 \sim 30$ | $18$ | $8$ | $18 \sim 19$ | 보통 사질토 / 성토층 / 상부 풍화토 | +| **사질토** | $10$ 미만 | $17$ | $7$ | $17 \sim 18$ | 느슨한 퇴적토 / **붕적토(콜루비움)** | +| **점성토** | $30$ 이상 | $19$ | $9$ | $19 \sim 20$ | 고결 점성토 / 풍화토 | +| **점성토** | $20 \sim 30$ | $17$ | $7$ | $17 \sim 18$ | 견고한 점성토 | +| **점성토** | $10 \sim 20$ | $15 \sim 17$ | $5 \sim 7$ | $15 \sim 17$ | 보통 점성토 | +| **점성토** | $10$ 미만 | $14 \sim 16$ | $4 \sim 6$ | $14 \sim 16$ | 연약 점성토 | + +--- + +### 3.3 「지반조사편람」 (서울특별시, 2006) 및 「한국지반공학회 사면안정 학술자료」 — 풍화토 · 풍화암 기준표 + +- **문서명**: + 1. 서울특별시, **「지반조사편람」** (2006), 지층 분류 기준 및 지층별 개략 지반정수 범위표. + 2. 한국지반공학회(KGS), **「사면안정 학술발표회 논문집」** (1996, 2001, 2003 종합 — 국내 국도·고속도로 지반조사보고서에서 풍화암 비탈면 설계 시 가장 빈번하게 인용하는 문헌 표). + +#### (1) 서울특별시 「지반조사편람 (2006)」 표준 지반분류 및 물성 범위표 + +| 지층명 (기호) | 시추조사 분류 기준 (원문) | 탄성파 속도 $V_p$ ($\text{km/s}$) | 단위체적중량 $\gamma_t$ ($\text{kN/m}^3$) | 점착력 $c$ ($\text{kPa}$) | 내부마찰각 $\phi$ ($^\circ$) | +| :--- | :--- | :---: | :---: | :---: | :---: | +| **퇴적토 / 붕적토 (DS)** | 원지반에서 분리·이동되어 퇴적된 층, 통일분류법 세분 | — | $17 \sim 19$ (사질토 기준) | $0 \sim 15$ | $25 \sim 30$ | +| **풍화토 (RS)** | 조암광물이 완전풍화되어 암석 결합력 상실, 함수포화 시 전단강도 급감 ($N < 50\text{회}/10\text{cm}$) | $< 1.2$ | **$17 \sim 20$** | **$0 \sim 100$** *(비탈면 실무 $5 \sim 20$)* | **$25 \sim 30$** *(조밀 시 $32$)* | +| **풍화암 (WR)** | 심한 풍화로 변색, 가벼운 망치 타격에 부서짐 ($TCR \ge 10\%, N \ge 50\text{회}/10\text{cm}, q_u < 10\text{ MPa}$) | $1.0 \sim 2.5$ | **$20.0 \sim 22.0$** | **$100.0 \sim 300.0$** *(기초·터널 원암 기준)* | **$30.0 \sim 35.0$** | +| **연암 (SR)** | 절리면 주변 중간풍화, 강한 망치 타격에 깨어짐 ($TCR \ge 30\%, RQD \ge 10\%, q_u < 10\text{ MPa}, J_s \ge 20\text{ cm}$) | $2.0 \sim 3.2$ | *(아래 3.4절 표 참조)* | *(아래 3.4절 표 참조)* | *(아래 3.4절 표 참조)* | +| **보통암 (MR)** | 절리면 약한 풍화 ($TCR \ge 60\%, RQD \ge 25\%, q_u < 25\text{ MPa}, J_s \ge 60\text{ cm}$) | $3.0 \sim 4.2$ | *(아래 3.4절 표 참조)* | *(아래 3.4절 표 참조)* | *(아래 3.4절 표 참조)* | +| **경암 (HR)** | 조암광물 대부분 신선, 강한 망치 타격에 맑은 소리 ($TCR \ge 80\%, RQD \ge 50\%, q_u < 50\text{ MPa}, J_s \ge 200\text{ cm}$) | $4.0 \sim 5.0$ | *(아래 3.4절 표 참조)* | *(아래 3.4절 표 참조)* | *(아래 3.4절 표 참조)* | + +#### (2) 한국지반공학회 「사면안정 학술발표회 (1996~2003)」 — 비탈면 해석용 풍화암층 지반정수 문헌표 +> **실무 유의사항**: 기초·터널 설계에서는 풍화암 점착력을 $100 \sim 300\text{ kPa}$로 높게 보기도 하나, **비탈면 안정성 해석(우기 시 표면 포화 및 풍화 이완 고려)**에서는 점착력 과대평가가 안전율을 비현실적으로 부풀리므로 국내 도로·임도 비탈면 실무보고서는 아래 지반공학회 사면안정 문헌 범위($c = 10 \sim 30\text{ kPa}, \phi = 30 \sim 36^\circ$)를 표준 인용함. + +| 인용 문헌 (한국지반공학회 사면안정 학술발표회) | 대상 암종 / 비고 | 단위중량 $\gamma_t$ ($\text{kN/m}^3$) | 점착력 $c$ ($\text{kPa}$) | 내부마찰각 $\phi$ ($^\circ$) | +| :--- | :--- | :---: | :---: | :---: | +| 사면안정 학술발표회 (1996) | 일반 풍화암 비탈면 | $21.0$ | $30.0$ | $35.0$ | +| 사면안정 학술발표회 (2001) | 일반 풍화암 비탈면 | $20.0$ | $30.0$ | $35.0$ | +| 사면안정 학술발표회 (2003) | 사암, 셰일 등 퇴적암계 풍화암 | $19.5$ | $20.0$ | $36.0$ | +| 사면안정 학술발표회 (2003) | 편암 풍화암 | $20.0$ | $10.0$ | $30.0$ | +| 사면안정 학술발표회 (2003) | 편마암 풍화암 | $21.0$ | $30.0$ | $35.0$ | +| **문헌 종합 범위** | **비탈면 안정해석용 풍화암** | **$19.5 \sim 21.0$** | **$10.0 \sim 30.0$** | **$30.0 \sim 36.0$** | + +- **출처 URL**: `https://bacherd81.tistory.com/243`, `https://bacherd81.tistory.com/78` + +--- + +### 3.4 「도로설계실무편람」 (한국도로공사, 1996) 및 「건설공사 비탈면 설계기준 해설」 (2011/2016) — 기반암(풍화암·연암·경암) 및 절리면 전단강도 원문 표 + +#### (1) 한국도로공사 「도로설계실무편람 (1996)」 — 암반 파쇄상태(TCR, RQD)에 따른 풍화암·연암·경암 전단강도 제안표 +- **적용 대상**: 시추코어 회수율($TCR$) 및 암질지수($RQD$)에 기반하여 **파쇄된 풍화암·연암·경암 암반체(Rock Mass)**를 연속체(원호/비원호 한계평형해석)로 모델링할 때 적용하는 기준표임. + +| 암석 종류 및 파쇄 상태 구분 | 시추코어 $TCR$ ($\%$) | 암질지수 $RQD$ ($\%$) | 내부마찰각 $\phi$ ($^\circ$) | 점착력 $c$ ($\text{kPa}$) | 실무 대응 지층 | +| :--- | :---: | :---: | :---: | :---: | :--- | +| **풍화암 또는 연·경암으로 파쇄가 극심한 경우** | $20$ 이하 | $10$ 이하 | **$30$** | **$100$** | 풍화암 / 파쇄대 | +| **강한 풍화암(파쇄 거의 없음) 및 대부분의 연암·경암 (하위)** | $20 \sim 30$ | $10 \sim 25$ | **$33$** | **$130$** | 풍화암 하부 ~ 연암 | +| **연암 · 보통암 (중간 파쇄)** | $40 \sim 50$ | $25 \sim 35$ | **$35$** | **$150$** | 연암 | +| **양호한 연암 · 경암** | $70$ 이상 | $40 \sim 50$ | **$40$** | **$200$** *(양호 경암 $500+$)* | 연암 상부 ~ 경암 | + +- **출처 URL**: `https://bacherd81.tistory.com/244` + +#### (2) 「건설공사 비탈면 설계기준 해설 (2011/2016)」 및 「구조물기초설계기준 해설 (2009/2015)」 — Bieniawski (1989) RMR 암반등급별 전단강도정수 표 +- **문서명**: 국토해양부·한국시설안전공단, **「건설공사 비탈면 설계기준(해설)」** (2011, 제5장 암반비탈면 설계); 한국지반공학회, **「구조물기초설계기준 해설」** (2009/2015). +- **원문 표 (Bieniawski, 1989 *Engineering Rock Mass Classifications* 원전 수록 표)**: + +| 암반 등급 (Class) |Ⅰ등급 (매우 양호, Very Good Rock) | Ⅱ등급 (양호, Good Rock) | Ⅲ등급 (보통, Fair Rock) | Ⅳ등급 (불량, Poor Rock) | Ⅴ등급 (매우 불량, Very Poor Rock) | +| :--- | :---: | :---: | :---: | :---: | :---: | +| **RMR 총점** | $81 \sim 100$ | $61 \sim 80$ | $41 \sim 60$ | $21 \sim 40$ | $< 20$ | +| **국내 실무 지층 대응** | 신선한 **경암 (극경암)** | **경암 ~ 보통암** | **연암** | **풍화암 ~ 불량 연암** | **풍화암 상부 / 단층파쇄대** | +| **암반 점착력 $c$ ($\text{kPa}$)** | $> 400$ | $300 \sim 400$ | $200 \sim 300$ | $100 \sim 200$ | $< 100$ | +| **암반 내부마찰각 $\phi$ ($^\circ$)** | $> 45^\circ$ | $35^\circ \sim 45^\circ$ | $25^\circ \sim 35^\circ$ | $15^\circ \sim 25^\circ$ | $< 15^\circ$ | + +- **출처 URL**: 국토교통 디지털도서관(CODIL) 「건설공사 비탈면 설계기준」 원문 PDF: `https://www.codil.or.kr/filebank/construction/DC/CIGCDC190008/CIGCDC190008.pdf` + +#### (3) Hoek & Bray (1981) *Rock Slope Engineering* / Barton (1973) — 암종별 단위중량 및 절리면 기본마찰각($\phi_b$) 원문 표 +- **문서명**: 「건설공사 비탈면 설계기준 해설」 제8장 암반비탈면 안정해석 및 「도로설계실무편람」 인용 **Hoek & Bray (1981)** 암석 강도정수 표 및 **Barton (1973)** 암석 기본마찰각 표. +- **표 〈암종별 단위중량 및 원암 강도정수 (Hoek & Bray)〉**: + +| 암석 구분 (Rock Type) | 포화 / 건조 단위중량 ($\text{kN/m}^3$) | 내부마찰각 $\phi$ ($^\circ$) | 원암 점착력 $c$ ($\text{kPa}$) | 비탈면 불연속면(절리면) 해석 시 적용 | +| :--- | :---: | :---: | :---: | :--- | +| **경암 — 화성암 (화강암, 현무암 등)** | $25 \sim 30$ (실무 표준 $25 \sim 27$) | $35 \sim 45$ | $35,000 \sim 55,000$ (무결암) | 연암·경암 비탈면은 무결암 점착력 대신 **절리면 점착력 $c_j = 0 \sim 50\text{ kPa}$, 절리면 마찰각 $\phi_j = 25^\circ \sim 35^\circ$** 적용 | +| **경암 — 변성암 (규암, 편마암, 점판암)** | $25 \sim 28$ (실무 표준 $25 \sim 27$) | $30 \sim 40$ | $20,000 \sim 40,000$ (무결암) | 상동 | +| **경암 — 경질 퇴적암 (석회암, 사암)** | $23 \sim 28$ (실무 표준 $24 \sim 26$) | $35 \sim 45$ | $10,000 \sim 30,000$ (무결암) | 상동 | +| **연암 — 연질 퇴적암 (이암, 셰일, 응회암)** | $17 \sim 23$ (실무 표준 $22 \sim 24$) | $23 \sim 35$ | $10,000 \sim 20,000$ (무결암) | 상동 | + +- **표 〈암석 절리면의 기본마찰각 $\phi_b$ (Barton, 1973 / 도로설계실무편람)〉**: + - 화강암(조립질): $31^\circ \sim 35^\circ$ / 화강암(세립질): $29^\circ \sim 35^\circ$ + - 편마암(편암질): $23^\circ \sim 29^\circ$ / 점판암(Slate): $25^\circ \sim 30^\circ$ + - 사암(Sandstone): $25^\circ \sim 35^\circ$ / 미사암(Siltstone): $27^\circ \sim 31^\circ$ + - 석회암(Limestone): $33^\circ \sim 40^\circ$ / 현무암(Basalt): $31^\circ \sim 38^\circ$ / 역암: $35^\circ$ + +--- + +### 3.5 표준관입시험($N$값) 기반 내부마찰각($\phi$) 및 점착력($c$) 산정 경험식 원문 + +국내 지반조사보고서에서 시추조사 표준관입시험 $N$치(회/$30\text{ cm}$)로부터 사질토·풍화토·풍화암의 내부마찰각($\phi$)과 점착력($c$)을 도출할 때 사용하는 공식 경험식은 다음과 같음(「구조물기초설계기준 해설」, 「도로교설계기준 해설」 수록). + +| 구분 | 제안자 (출처) | 경험식 수식 ($N$: 표준관입시험 타격횟수) | 적용 대상 및 조건 | +| :--- | :--- | :--- | :--- | +| **내부마찰각 $\phi$ ($^\circ$)** | **Dunham (1954)** | $\phi = \sqrt{12N} + C$ ($C = 15, 20, 25$) | • 입도 불량하고 둥근 모래: $\phi = \sqrt{12N} + 15$
• 입도 양호하고 모난 모래 또는 일반 사질토/풍화토: $\phi = \sqrt{12N} + 20$
• 입도 양호하고 모난 자갈질 모래: $\phi = \sqrt{12N} + 25$ | +| **내부마찰각 $\phi$ ($^\circ$)** | **Peck, Hanson & Thornburn (1974)** | $\phi = 0.3N + 27^\circ$ | 일반 사질토 및 풍화토 ($N \le 50$) | +| **내부마찰각 $\phi$ ($^\circ$)** | **Ohsaki (오사키, 1959)** | $\phi = \sqrt{20N} + 15^\circ$ | 사질토 지반 | +| **내부마찰각 $\phi$ ($^\circ$)** | **도로교표준시방서** | $\phi = 15 + \sqrt{15N} \le 45^\circ$ (단, $N > 5$일 때) | 교량 기초 및 토류구조물 사질토 지반 | +| **내부마찰각 $\phi$ ($^\circ$)** | **Hatanaka & Uchida (1996)** | $\phi = \sqrt{20 N_1} + 20^\circ$ | 에너지·유효상재압 보정 $N_1$값 사용 시 | +| **비배수 점착력 $c_u$ ($\text{kPa}$)** | **Terzaghi & Peck (1967)** | $q_u = \dfrac{N}{0.082}\text{ (kPa)}, \quad c_u = \dfrac{q_u}{2} \approx 6.1 N \sim 6.25 N\text{ (kPa)}$ | 점성토 비배수 전단강도 ($1\text{ tf/m}^2 \approx 10\text{ kPa}$ 기준 $c_u \approx 0.625 N\text{ tf/m}^2$) | +| **비배수 점착력 $c_u$ ($\text{kPa}$)** | **Dunham (1954)** | $q_u = \dfrac{N}{0.077}\text{ (kPa)}, \quad c_u = \dfrac{q_u}{2} \approx 6.5 N\text{ (kPa)}$ | 점성토 및 세립분 함유 풍화토 | + +--- + +## 4. 지층별(6대 지층 + 절리면) 원문 근거 종합 대조표 + +위 3장의 공공 편람·설계기준 해설 원문 표들을 **성토 · 붕적토 · 풍화토 · 풍화암 · 연암 · 경암 · 암반 절리면**의 7개 카테고리로 교차 대조하여 정리한 종합표임(최종 기본값은 개발 단계에서 사용자 협의로 결정함). + +| 지층 구분 | 습윤단위중량 $\gamma_t$ ($\text{kN/m}^3$) | 포화단위중량 $\gamma_{\text{sat}}$ ($\text{kN/m}^3$) | 유효점착력 $c'$ ($\text{kPa}$) | 유효내부마찰각 $\phi'$ ($^\circ$) | 원문 출처 및 비고 | +| :--- | :---: | :---: | :---: | :---: | :--- | +| **1. 성토층 (다짐 사질토 SM/SC)** | $18.0 \sim 19.0$ | $19.0 \sim 20.0$ | $0 \sim 15$
*(도로설계요령 상한 $\le 30$)* | $28 \sim 30$ | • 「도로설계요령 제2권」 표 〈토사의 개략적인 토질 정수〉: 밀실 사질토 $\gamma_t = 19, \phi = 30^\circ, c \le 30\text{ kPa}$
• 비탈면 실무에서는 성토층 점착력을 $5 \sim 15\text{ kPa}$로 보수적 적용 | +| **2. 붕적토 / 퇴적토 (느슨한 사질토)** | $17.0 \sim 18.0$ | $18.0 \sim 19.0$ | $0 \sim 10$ | $25 \sim 28$ | • 「도로설계요령 제2권」: 밀실치 않은 사질토 $\gamma_t = 17, \phi = 25^\circ, c = 0$
• 「도로교시방서」: $N < 10\sim 30$ 사질토 $\gamma_t = 17\sim 18, \gamma' = 7\sim 8$ | +| **3. 풍화토 (Residual Soil)** | $18.0 \sim 19.0$
*(편람 범위 $17 \sim 20$)* | $19.0 \sim 20.0$ | $5 \sim 20$
*(편람 전범위 $0 \sim 100$)* | $27 \sim 32$
*(편람 범위 $25 \sim 30$)* | • 서울시 「지반조사편람(2006)」: $\gamma_t = 17\sim 20, c = 0\sim 100, \phi = 25\sim 30^\circ$
• 「도로교시방서」 조밀 사질토($N=30\sim 50$): $\gamma_t = 19, \gamma_{\text{sat}} \approx 19\sim 20$ | +| **4. 풍화암 (Weathered Rock)** | $19.5 \sim 21.0$
*(편람 $20 \sim 22$)* | $20.5 \sim 22.0$ | **비탈면**: $10 \sim 30$
**파쇄기반암**: $30 \sim 100$ | $30 \sim 36$ | • 한국지반공학회 사면안정 학술발표회(1996~2003): $\gamma_t = 19.5\sim 21.0, c = 10\sim 30\text{ kPa}, \phi = 30\sim 36^\circ$
• 한국도로공사 「도로설계실무편람(1996)」 파쇄암($TCR \le 20\%$): $c = 100\text{ kPa}, \phi = 30^\circ$ | +| **5. 연암 (Soft Rock — 암반체 연속체)** | $22.0 \sim 24.0$
*(범위 $20 \sim 25$)* | $23.0 \sim 25.0$ | **도로편람**: $130 \sim 150$
**RMR Ⅲ등급**: $200 \sim 300$
**실무관행**: $50 \sim 200$ | $33 \sim 40$
*(RMR Ⅲ등급 $25\sim 35$)* | • 「도로설계실무편람(1996)」($TCR=20\sim 50\%$): $c = 130\sim 150\text{ kPa}, \phi = 33\sim 35^\circ$
• 「건설공사 비탈면 설계기준 해설」 Bieniawski RMR Ⅲ등급: $c = 200\sim 300\text{ kPa}, \phi = 25\sim 35^\circ$ | +| **6. 경암 (Hard Rock — 암반체 연속체)** | $25.0 \sim 27.0$
*(범위 $23 \sim 28$)* | $25.5 \sim 27.5$ | **도로편람**: $200$ 이상
**RMR Ⅰ~Ⅱ**: $300 \sim 400+$
**실무관행**: $200 \sim 1,000$ | $40 \sim 45$ | • 「도로설계실무편람(1996)」($TCR \ge 70\%$): $c = 200\text{ kPa}, \phi = 40^\circ$
• Bieniawski RMR Ⅰ~Ⅱ등급: $c = 300\sim 400+\text{ kPa}, \phi = 35\sim 45^\circ+$
• Hoek & Bray 화성암/변성암 단위중량: $\gamma = 25\sim 28\text{ kN/m}^3$ | +| **7. 암반 불연속면 (절리면, 평면·쐐기·전도 해석용)** | *(연암·경암 본체 단위중량 사용)* | *(상동)* | $c_j = 0 \sim 30$
*(충전물·거칠기에 따름)* | $\phi_j = 25 \sim 35$ | • Barton (1973) 암석 기본마찰각 $\phi_b$: 화강암 $29\sim 35^\circ$, 편마암 $23\sim 29^\circ$, 사암 $25\sim 35^\circ$, 점판암 $25\sim 30^\circ$ | diff --git a/resources/knowledge/original/안정성평가/참고문헌/Rocscience_Slide2_SlopeStabilityVerification.pdf b/resources/knowledge/original/안정성평가/참고문헌/Rocscience_Slide2_SlopeStabilityVerification.pdf new file mode 100644 index 000000000..c66090f6c Binary files /dev/null and b/resources/knowledge/original/안정성평가/참고문헌/Rocscience_Slide2_SlopeStabilityVerification.pdf differ diff --git a/resources/tester/spreadsheet/helper_spreadsheet_engine.cjs b/resources/tester/spreadsheet/helper_spreadsheet_engine.cjs index a2a05c41c..47e1a750a 100644 --- a/resources/tester/spreadsheet/helper_spreadsheet_engine.cjs +++ b/resources/tester/spreadsheet/helper_spreadsheet_engine.cjs @@ -529,6 +529,22 @@ section("commands", () => { ["A1:H1", "C3:F3", "C5:F5", "C6:F6", "A7:G7"], ]); }); + // 그림 닻 · 쪽 나눔 · 머리 행(요구 347-5)이 행열 넣기 · 지우기에 따라감 + roundTrip("행넣기 인쇄 · 그림", { 종류: "행넣기", 시트: "s1", at: 3, 수: 2 }, (b) => { + eq("쪽나눔 · 머리행 · 그림 밀림", [s1(b).인쇄.쪽나눔, s1(b).인쇄.머리행, s1(b).그림[0].닻], [[6], [3, 3], "B10:F14"]); + }); + roundTrip("행넣기 머리행 위", { 종류: "행넣기", 시트: "s1", at: 0, 수: 1 }, (b) => { + eq("머리행 밀림", s1(b).인쇄.머리행, [4, 4]); + }); + roundTrip("행지우기 인쇄 · 그림", { 종류: "행지우기", 시트: "s1", at: 3, 수: 1 }, (b) => { + eq("나눔 행 지움 · 그림 올라감", [s1(b).인쇄.쪽나눔, s1(b).그림[0].닻], [[], "B7:F11"]); + }); + roundTrip("행지우기 그림 통째", { 종류: "행지우기", 시트: "s1", at: 7, 수: 5 }, (b) => { + eq("그림 빠짐", s1(b).그림, undefined); + }); + roundTrip("열넣기 그림", { 종류: "열넣기", 시트: "s1", at: 0, 수: 1 }, (b) => { + eq("그림 열 밀림 · 쪽나눔 그대로", [s1(b).그림[0].닻, s1(b).인쇄.쪽나눔], ["C8:G12", [4]]); + }); roundTrip("행넣기 틀고정 위", { 종류: "행넣기", 시트: "s1", at: 0, 수: 2 }, (b) => { eq("틀고정 · 행 높이 밀림", [s1(b).틀고정, s1(b).행], [{ 행: 5 }, { 3: { 높이: 20 } }]); }); diff --git a/resources/tester/test_528_project_groups.py b/resources/tester/test_528_project_groups.py new file mode 100644 index 000000000..b88997378 --- /dev/null +++ b/resources/tester/test_528_project_groups.py @@ -0,0 +1,258 @@ +"""528-1 · 3 · 5 신규 프로젝트 그룹 공통(요구 345 · 347-8). + +- 산림청 판정 — *.go.kr(대소문자 · 하위 도메인) · 시스템관리자 · 아닌 메일 +- D01 생성 — 일반 사용자 403 · 산림청 = PLAN · 설계속도 기본 · 연도 끝 기본 +- E01 생성 — 원본 B06 미완료 400 · 남의 것 404 · 완료 = STABILITY + 원본 칸 +- 공유 판정 — 지정 회사 GET 200 · PUT 403 · 미지정 403 · 같은 회사 그대로 +- 가입 — go.kr = 회사 「산림청」 · ACTIVE · 화면이 보낸 회사 값 무시 · 다른 메일 = NO_COMPANY +- 종류별 단계 시드 · storage 허용 목록 +""" + +import asyncio + +import pytest +from fastapi import HTTPException + +from A07_Register import A07_Register_Router as reg_router +from A07_Register.A07_Register_Schema import RegisterRequest +from common_util import common_util_auth as auth +from common_util import common_util_auth_repository as auth_repo +from common_util.common_util_workflow_state import initialize_project_stages +from config.config_system import PROJECT_STORAGE_STAGE_DIRS +from D01_PlanRegister import D01_PlanRegister_Router as d01 +from E01_StabRegister import E01_StabRegister_Router as e01 + + +class FakeCursor: + """SQL 조각 → 돌려줄 한 줄. 실행한 SQL · 인자를 쌓음.""" + + def __init__(self, answers, log): + self.answers, self.log, self.last = answers, log, None + + async def execute(self, sql, args=None): + self.log.append((" ".join(sql.split()), args)) + self.last = next((v for k, v in self.answers.items() if k in sql), None) + + async def fetchone(self): + return self.last + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + +class FakePool: + def __init__(self, answers=None): + self.answers, self.log = answers or {}, [] + + def acquire(self): + return self + + def cursor(self, *a): + return FakeCursor(self.answers, self.log) + + async def commit(self): + self.log.append(("commit", None)) + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + +class Req: + def __init__(self, method="GET", project_id="p1"): + self.method, self.path_params = method, {"project_id": project_id} + + +USER = {"user_id": 9, "role": "USER", "company_id": 1, "email": "a@gmail.com"} +FOREST = {"user_id": 8, "role": "USER", "company_id": 4, "email": "Kim@Forest.GO.KR"} +ADMIN = {"user_id": 1, "role": "SYSTEM_ADMIN", "company_id": 1, "email": "x@aislo.kr"} + + +@pytest.mark.parametrize( + "email,expected", + [ + ("a@forest.go.kr", True), + ("A@KFS.GO.KR", True), + ("a@nifos.sub.forest.go.kr", True), + ("a@gmail.com", False), + ("a@notgo.kr", False), + ("a@go.kr.evil.com", False), + (None, False), + ], +) +def test_forest_email(email, expected): + assert auth.is_forest_email(email) is expected + + +def test_forest_service(): + assert auth.is_forest_service(FOREST) + assert auth.is_forest_service(ADMIN) + assert not auth.is_forest_service(USER) + + +def _plan(**kw): + body = {"project_name": "예정", "project_year": 2026, "region": "홍천", "road_type": "fire"} + return d01.CreatePlanRequest(**{**body, **kw}) + + +def test_d01_plan_forbidden_for_user(): + with pytest.raises(HTTPException) as e: + asyncio.run(d01.post_plan_project(None, _plan(), USER)) + assert e.value.status_code == 403 + + +def test_d01_plan_created(monkeypatch): + seen = {} + + async def fake_create(**kw): + seen.update(kw) + return "new-id" + + monkeypatch.setattr(d01, "create_kind_project", fake_create) + out = asyncio.run(d01.post_plan_project(None, _plan(start_chainage_m=0), FOREST)) + assert out == {"project_id": "new-id"} + assert seen["kind"] == "PLAN" and seen["company_id"] == 4 + assert seen["design_speed_kph"] == 20 and seen["project_year_end"] == 2026 + asyncio.run(d01.post_plan_project(None, _plan(design_speed_kph=40), ADMIN)) + assert seen["design_speed_kph"] == 40 + asyncio.run(d01.post_plan_project(None, _plan(road_type="work", design_speed_kph=40), ADMIN)) + assert seen["design_speed_kph"] == 20 # 작업임도 = 20 만 + with pytest.raises(HTTPException) as e: + asyncio.run(d01.post_plan_project(None, _plan(project_year_end=2025), FOREST)) + assert e.value.status_code == 400 + + +def _e01(monkeypatch, rows): + seen = {} + + async def fake_sources(company_id, project_id=None): + return rows + + async def fake_create(**kw): + seen.update(kw) + return "stab-id" + + monkeypatch.setattr(e01, "_sources", fake_sources) + monkeypatch.setattr(e01, "create_kind_project", fake_create) + payload = e01.CreateStabRequest(source_project_id="s" * 36) + return seen, lambda: asyncio.run(e01.post_stab_project(None, payload, USER)) + + +def test_e01_requires_b06(monkeypatch): + src = {"project_id": "s" * 36, "name": "임도", "region": "r", "road_type": "fire"} + _, call = _e01(monkeypatch, [{**src, "project_year": 2026, "section_complete": 0}]) + with pytest.raises(HTTPException) as e: + call() + assert e.value.status_code == 400 + _, call = _e01(monkeypatch, []) + with pytest.raises(HTTPException) as e: + call() + assert e.value.status_code == 404 + seen, call = _e01(monkeypatch, [{**src, "project_year": 2026, "section_complete": 1}]) + assert call() == {"project_id": "stab-id"} + assert seen["kind"] == "STABILITY" and seen["source_project_id"] == "s" * 36 + assert seen["name"] == "임도 안정성평가" + + +def _access(monkeypatch, method, assigned, owner_company=2): + pool = FakePool( + { + "FROM projects": (owner_company,), + "plan_route_assignees": (1,) if assigned else None, + } + ) + monkeypatch.setattr(auth, "get_db_pool", lambda: pool) + return asyncio.run(auth.require_project_access(Req(method), USER)) + + +def test_shared_get_only(monkeypatch): + assert _access(monkeypatch, "GET", True) is USER + for method in ("PUT", "POST", "DELETE"): + with pytest.raises(HTTPException) as e: + _access(monkeypatch, method, True) + assert e.value.status_code == 403 + with pytest.raises(HTTPException) as e: + _access(monkeypatch, "GET", False) + assert e.value.status_code == 403 + assert _access(monkeypatch, "PUT", False, owner_company=1) is USER + + +def test_register_forest_company(monkeypatch): + pool = FakePool({"FROM companies": (4,)}) + monkeypatch.setattr(auth_repo, "get_db_pool", lambda: pool) + assert asyncio.run(auth_repo.complete_registration(5, False, forest=True)) == "ACTIVE" + update = [a for s, a in pool.log if s.startswith("UPDATE users")] + assert update == [(4, 5)] + pool = FakePool({"FROM companies": (4,)}) + monkeypatch.setattr(auth_repo, "get_db_pool", lambda: pool) + assert asyncio.run(auth_repo.complete_registration(5, False)) == "NO_COMPANY" + assert not any("companies" in s for s, _ in pool.log) + + +def test_register_verify_forces_forest(monkeypatch): + seen = {} + + async def fake_user(email): + return {"id": 5, "email": email, "status": "PENDING_EMAIL", "is_master": 0} + + async def fake_otp(user_id, kind): + from datetime import datetime, timedelta + + return {"id": 1, "expires_at": datetime.utcnow() + timedelta(minutes=5), "otp_hash": "h"} + + async def fake_complete(user_id, is_master, forest=False): + seen["forest"] = forest + return "ACTIVE" if forest else "NO_COMPANY" + + async def noop(*a, **k): + return None + + monkeypatch.setattr(reg_router, "get_user_by_email", fake_user) + monkeypatch.setattr(reg_router, "get_active_otp", fake_otp) + monkeypatch.setattr(reg_router, "verify_password", lambda a, b: True) + monkeypatch.setattr(reg_router, "consume_otp", noop) + monkeypatch.setattr(reg_router, "trust_device", noop) + monkeypatch.setattr(reg_router, "complete_registration", fake_complete) + + class R: + headers = {} + + body = reg_router.RegisterVerifyRequest(email="Lee@KFS.go.kr", otp_code="123456") + resp = asyncio.run(reg_router.verify_registration(body, R())) + assert seen["forest"] is True and b'"ACTIVE"' in resp.body + body = reg_router.RegisterVerifyRequest(email="lee@gmail.com", otp_code="123456") + resp = asyncio.run(reg_router.verify_registration(body, R())) + assert seen["forest"] is False and b'"NO_COMPANY"' in resp.body + + +def test_register_ignores_sent_company(): + data = RegisterRequest( + email="a@kfs.go.kr", + password="12345678", + password_confirm="12345678", + name="이", + terms_version="1.0", + terms_agreed=True, + privacy_agreed=True, + company_name="다른회사", + ).model_dump() + assert "company_name" not in data + + +@pytest.mark.parametrize("kind,count", [("ROAD", 7), ("PLAN", 3), ("STABILITY", 4)]) +def test_stage_seed_by_kind(kind, count): + log = [] + asyncio.run(initialize_project_stages(FakeCursor({}, log), "p1", kind)) + assert len(log) == count + if kind == "PLAN": + assert [a[2] for _, a in log] == ["PLAN_BASE", "PLAN_ANALYSIS", "PLAN_CONFIRM"] + + +def test_storage_dirs_allowed(): + for name in ("D02_PlanBase", "D04_PlanConfirm", "E02_StabGround", "E05_StabReport"): + assert name in PROJECT_STORAGE_STAGE_DIRS