Merge remote-tracking branch 'origin/dev' into sub_laptop_2

This commit is contained in:
2026-10-25 19:34:48 +09:00
24 changed files with 1566 additions and 155 deletions
@@ -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];
+10 -1
View File
@@ -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",
+35 -2
View File
@@ -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 한 줄 — 재료산출 알약. */
+2
View File
@@ -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),
},
}
+7 -3
View File
@@ -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)
+10
View File
@@ -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,
+5 -3
View File
@@ -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,
+24 -137
View File
@@ -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}
+124
View File
@@ -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
+23
View File
@@ -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"]:
+23 -6
View File
@@ -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:
+212
View File
@@ -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)
+102
View File
@@ -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
+13 -3
View File
@@ -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 (
+9
View File
@@ -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",
}
)
+59
View File
@@ -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');
+7
View File
@@ -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$ |
@@ -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 } }]);
});
+258
View File
@@ -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