Merge remote-tracking branch 'origin/dev' into sub_laptop_2
This commit is contained in:
@@ -321,6 +321,22 @@ function insertDelete(
|
||||
});
|
||||
if (s.조건부서식) s.조건부서식 = remapRanges<CondFmtRule>(s.조건부서식);
|
||||
if (s.유효성) s.유효성 = remapRanges<DataValidationRule>(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];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 `<img>` · 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 한 줄 — 재료산출 알약. */
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = (
|
||||
'<div style="width:100%;font-size:9px;text-align:center;font-family:sans-serif">'
|
||||
'<span class="pageNumber"></span> / <span class="totalPages"></span></div>'
|
||||
)
|
||||
|
||||
_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="<span></span>",
|
||||
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
|
||||
|
||||
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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"]:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = (
|
||||
'<div style="width:100%;font-size:9px;text-align:center;font-family:sans-serif">'
|
||||
'<span class="pageNumber"></span> / <span class="totalPages"></span></div>'
|
||||
)
|
||||
|
||||
_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="<span></span>", 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)
|
||||
@@ -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
|
||||
@@ -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 (
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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');
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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$) 이내 일치 여부를 자동 검증할 수 있음.
|
||||
@@ -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$<br>• 입도 양호하고 모난 모래 또는 일반 사질토/풍화토: $\phi = \sqrt{12N} + 20$<br>• 입도 양호하고 모난 자갈질 모래: $\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$<br>*(도로설계요령 상한 $\le 30$)* | $28 \sim 30$ | • 「도로설계요령 제2권」 표 〈토사의 개략적인 토질 정수〉: 밀실 사질토 $\gamma_t = 19, \phi = 30^\circ, c \le 30\text{ kPa}$<br>• 비탈면 실무에서는 성토층 점착력을 $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$<br>• 「도로교시방서」: $N < 10\sim 30$ 사질토 $\gamma_t = 17\sim 18, \gamma' = 7\sim 8$ |
|
||||
| **3. 풍화토 (Residual Soil)** | $18.0 \sim 19.0$<br>*(편람 범위 $17 \sim 20$)* | $19.0 \sim 20.0$ | $5 \sim 20$<br>*(편람 전범위 $0 \sim 100$)* | $27 \sim 32$<br>*(편람 범위 $25 \sim 30$)* | • 서울시 「지반조사편람(2006)」: $\gamma_t = 17\sim 20, c = 0\sim 100, \phi = 25\sim 30^\circ$<br>• 「도로교시방서」 조밀 사질토($N=30\sim 50$): $\gamma_t = 19, \gamma_{\text{sat}} \approx 19\sim 20$ |
|
||||
| **4. 풍화암 (Weathered Rock)** | $19.5 \sim 21.0$<br>*(편람 $20 \sim 22$)* | $20.5 \sim 22.0$ | **비탈면**: $10 \sim 30$<br>**파쇄기반암**: $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$<br>• 한국도로공사 「도로설계실무편람(1996)」 파쇄암($TCR \le 20\%$): $c = 100\text{ kPa}, \phi = 30^\circ$ |
|
||||
| **5. 연암 (Soft Rock — 암반체 연속체)** | $22.0 \sim 24.0$<br>*(범위 $20 \sim 25$)* | $23.0 \sim 25.0$ | **도로편람**: $130 \sim 150$<br>**RMR Ⅲ등급**: $200 \sim 300$<br>**실무관행**: $50 \sim 200$ | $33 \sim 40$<br>*(RMR Ⅲ등급 $25\sim 35$)* | • 「도로설계실무편람(1996)」($TCR=20\sim 50\%$): $c = 130\sim 150\text{ kPa}, \phi = 33\sim 35^\circ$<br>• 「건설공사 비탈면 설계기준 해설」 Bieniawski RMR Ⅲ등급: $c = 200\sim 300\text{ kPa}, \phi = 25\sim 35^\circ$ |
|
||||
| **6. 경암 (Hard Rock — 암반체 연속체)** | $25.0 \sim 27.0$<br>*(범위 $23 \sim 28$)* | $25.5 \sim 27.5$ | **도로편람**: $200$ 이상<br>**RMR Ⅰ~Ⅱ**: $300 \sim 400+$<br>**실무관행**: $200 \sim 1,000$ | $40 \sim 45$ | • 「도로설계실무편람(1996)」($TCR \ge 70\%$): $c = 200\text{ kPa}, \phi = 40^\circ$<br>• Bieniawski RMR Ⅰ~Ⅱ등급: $c = 300\sim 400+\text{ kPa}, \phi = 35\sim 45^\circ+$<br>• Hoek & Bray 화성암/변성암 단위중량: $\gamma = 25\sim 28\text{ kN/m}^3$ |
|
||||
| **7. 암반 불연속면 (절리면, 평면·쐐기·전도 해석용)** | *(연암·경암 본체 단위중량 사용)* | *(상동)* | $c_j = 0 \sim 30$<br>*(충전물·거칠기에 따름)* | $\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$ |
|
||||
BIN
Binary file not shown.
@@ -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 } }]);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user