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

This commit is contained in:
2026-09-14 13:17:22 +09:00
20 changed files with 401 additions and 23 deletions
@@ -35,6 +35,9 @@ export interface StructureOptionField {
* ⚠ 이 칸의 select 는 **빈 보기를 둔다** — 첫 보기를 슬쩍 고르면 기준값과 다른 값이
* 조용히 저장된다(2026-09-13: `fill_concrete_mpa` 가 180 으로 박혀 엔진 기준 210 과 갈렸다). */
empty_means?: string | null;
/** 이 값을 넘으면 칸이 경고색 + 툴팁(막지 않음) — 기준값과 까닭 한 줄. */
warn_above?: number | null;
warn_message?: string | null;
}
/** 구조물 배치 폼을 어느 화면이 쓰는가 — B05 는 유무·종류·위치만, **B06/B07 은 상세
+36 -3
View File
@@ -921,6 +921,16 @@
"phase": "detail",
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
},
{
"key": "face_role",
"label": "성토/절토",
"input": "select",
"choices": ["성토", "절토"],
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김"
},
{
"key": "foundation",
"label": "기초",
@@ -990,7 +1000,9 @@
"default": 2.5,
"required": false,
"phase": "b05",
"empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)"
"empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)",
"warn_above": 2.0,
"warn_message": "메쌓기 높이 기준을 넘음 — 임도기술교본 7-3:27 「메쌓기는 2.0m 이하」 · 품셈 13-4-4 [주]⑪(원문 L7192) 「높이 3m 이상이면 전부 또는 하부를 찰쌓기」 · 막지 않음(설계자 확인)"
},
{
"key": "length_m",
@@ -1095,6 +1107,16 @@
"phase": "detail",
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
},
{
"key": "face_role",
"label": "성토/절토",
"input": "select",
"choices": ["성토", "절토"],
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김"
},
{
"key": "foundation",
"label": "기초",
@@ -1995,6 +2017,16 @@
"phase": "detail",
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
},
{
"key": "face_role",
"label": "성토/절토",
"input": "select",
"choices": ["성토", "절토"],
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김"
},
{
"key": "foundation",
"label": "기초",
@@ -2009,9 +2041,10 @@
"label": "높이",
"input": "number",
"unit": "m",
"default": 2.5,
"default": null,
"required": false,
"phase": "b05"
"phase": "b05",
"empty_means": "설계자 입력 — 계획홍수위 + 0.5~0.7m(사방기술교본 2-나:141 · 3-가:181 · 사방(계류) 교본 기준). 비우면 줄만 서고 미확정이라 금액에 안 들어감 · 옛 기본 2.5 는 근거 없어 걷음(2026-09-14 브레인 판정)"
},
{
"key": "length_m",
@@ -60,6 +60,10 @@ class StructureOptionField(BaseModel):
# 기본값이 **도메인 확정값이 아닐 때** 그 뜻을 적는다(예: 「다단 없음」·「안 더함」).
# 법정·확정 수치면 비워 둔다 — 비어 있는 것이 「확정값」이라는 뜻이다.
default_basis: str | None = None
# 이 값을 **넘으면** 칸이 경고색 + 툴팁 — 막지 않음(2026-09-14 브레인 판정 ①·㉰ 「놓기를 막지 말 것」).
# 넘는지 보는 기준값과 그 까닭 한 줄(원문 쪽·줄 번호).
warn_above: float | None = None
warn_message: str | None = None
class StructureType(BaseModel):
@@ -9,6 +9,7 @@
* ========================================================================== */
import { FORD_MANNING_N, FORD_SLOPE } from "@config/config_frontend";
import revetmentSabang from "../resources/data_masonry/revetment_sabang_2026-01-01.json";
/* ── 유입구 구조 ──────────────────────────────────────────────────────────── */
@@ -252,16 +253,43 @@ export interface RevetSideGroup {
slot: HTMLElement;
}
/** 독립 기슭막이 치수 근거 줄 — `revetment_sabang_*.json` 한 벌(B08 사유와 같은 파일 · 쪽·줄 번호).
* ⚠ 파일 판이 바뀌면 이 import 도 옮길 것 — 시험(`test_b05_revetment_sabang_basis`)이 최신 판을 대조. */
function sabangBasisLines(): string[] {
const { items, scope } = revetmentSabang;
const span = (values: number[], unit = ""): string => `${values[0]}~${values[1]}${unit}`;
return [
`높이 — ${items.height.rule}(${items.height.source}) · ${items.height.input}`,
`계획비탈 1:${span(items.face_slope.standard_ratio)}(${items.face_slope.source}) — 제안값은 품셈 표준경사`,
`둑마루 두께 ${span(items.crown_thickness.standard_m, "m")}(${items.crown_thickness.source}) — 콘크리트 기준 · 돌쌓기는 뒷길이 + 0.30 식`,
`뒷채움 조약돌 두께 — ${items.backfill_pebble.source}`,
scope,
];
}
export function createRevetSideGroup(keys: RevetmentKeys, formDefault: string): RevetSideGroup {
const box = group("기슭막이");
const kind = document.createElement("select");
kind.replaceChildren(new Option("기슭막이", "기슭막이"));
const fields = createRevetmentFields(keys, { ...REVET_COMMON_DEFAULTS, form: formDefault });
// 높이는 제안 없음 — 설계자 입력(계획홍수위 + 여유고 · 2026-09-14 브레인 판정). 옛 2.5 는 근거 없어 걷음.
const fields = createRevetmentFields(keys, {
...REVET_COMMON_DEFAULTS,
form: formDefault,
height: "",
});
const basis = document.createElement("p");
basis.className = "b05-route__note";
basis.replaceChildren(
...sabangBasisLines().flatMap((line, index) =>
index ? [document.createElement("br"), line] : [line],
),
);
const slot = document.createElement("div");
slot.className = "b05-structure__adjust-slot";
box.body.append(
grid(labeled("구조", kind), fields.formField),
...fields.rows,
basis,
// 단 수는 여기 두지 않는다 — 조정창에서 옮겨 오는 [추가 기슭막이(단)] 행과
// 같은 값이라 두 벌이면 헷갈린다(2026-08-30 사용자).
slot,
@@ -301,9 +329,11 @@ export function createRevetmentFields(
...REVET_FORMS.map((value) => new Option(value, value)),
);
const length = keys.length ? numberInput("0.1", "0", `제안 ${defaults.length}`) : null;
const height = numberInput("0.1", "0", `제안 ${suggestHeight()}`);
// 제안이 없는 칸(독립 기슭막이 높이)은 「설계자 입력」 — 값을 지어내지 않음.
const heightHint = (): string => (suggestHeight() ? `제안 ${suggestHeight()}` : "설계자 입력");
const height = numberInput("0.1", "0", heightHint());
// 관 벽 높이 제안은 관경을 따라감 — 칸을 볼 때마다 다시 셈.
height.addEventListener("focus", () => (height.placeholder = `제안 ${suggestHeight()}`));
height.addEventListener("focus", () => (height.placeholder = heightHint()));
// 기준측점 전/후(2026-08-23 사용자) — 구조물 패널(D군)과 같은 연동:
// 전 + 후 = 길이, 나중에 고친 쪽이 살아남고 반대쪽이 재배분된다.
const before = keys.before && length ? numberInput("0.1", "0", `제안 ${defaults.before}`) : null;
@@ -363,7 +393,7 @@ export function createRevetmentFields(
form.value = text(keys.form);
if (length && keys.length) length.value = text(keys.length);
height.value = text(keys.height);
height.placeholder = `제안 ${suggestHeight()}`;
height.placeholder = heightHint();
if (before && keys.before) before.value = text(keys.before);
if (after && keys.after) after.value = text(keys.after);
syncSplit("length");
@@ -40,6 +40,9 @@ interface OptionShape {
required?: boolean;
/** 비워 두면 어떻게 되나 — 칸에 마우스를 올리면 보임. */
empty_means?: string | null;
/** 이 값을 넘으면 칸이 경고색 + 툴팁(막지 않음). */
warn_above?: number | null;
warn_message?: string | null;
}
/**
@@ -71,6 +74,17 @@ export function optionControl(
element.value = value;
element.placeholder = suggested ? `제안 ${suggested}` : option.required ? "필수 입력" : "";
if (option.empty_means) element.title = option.empty_means;
const limit = option.warn_above;
if (typeof limit === "number") {
// 기준을 넘으면 알리기만 — 막지 않음(2026-09-14 브레인 판정 ①·㉰).
const warn = (): void => {
const over = element.value.trim() !== "" && Number(element.value) > limit;
element.classList.toggle("is-outside-standard", over);
element.title = over ? (option.warn_message ?? "") : (option.empty_means ?? "");
};
element.addEventListener("input", warn);
warn();
}
return element;
}
@@ -67,6 +67,13 @@ function facilityNotes(section: CrossSection): Array<[string, string]> {
`B05 시설 칸에 안 적어 기본값으로 그림 — ${items.join(" · ")} · 적으면 그 값으로 그림`,
]);
}
const own = section.culvert?.hidden_pipe ? section.culvert : null;
if (own && [own.inlet, own.outlet].some((side) => !side.revet_height_m)) {
notes.push([
"⚠ 기슭막이 높이 없음 — 근입만 그림",
"독립 기슭막이 높이는 설계자 입력 — 계획홍수위 + 0.5~0.7m(사방기술교본 2-나:141 · 3-가:181) · 비우면 수량도 미확정 · B05 시설 칸에 적으면 그림",
]);
}
if (section.ford_pavement && !section.ford_pavement.depth_m) {
notes.push([
"⚠ 월류 높이 없음 — 파임 안 그림",
+59 -4
View File
@@ -26,6 +26,9 @@
from __future__ import annotations
import json
from functools import lru_cache
from pathlib import Path
from typing import Any
#: 배관으로 보는 `facility` 값. 그 밖(BOX암거·물넘이·세월교·독립 기슭막이)은 관이 아니다.
@@ -214,9 +217,52 @@ REVET_ROLES = (
NOTE_DEFAULT_WALL = (
"⚠ 시설 지점에 {filled} 을 안 적어 기본값으로 섰음 — 미확정이라 금액에 안 들어감"
)
#: 꼬리 — 관 벽은 횡단도와 같은 높이 · 독립 기슭막이는 아직 갈림(관경이 없음).
#: 꼬리 — 관 벽은 횡단도와 같은 높이.
NOTE_WALL_HEIGHT_PIPE = " · 높이는 횡단도와 같은 관경 기준 최소 높이(실제 설계값은 더 클 수 있음)"
NOTE_WALL_HEIGHT_OWN = " · ⚠ 횡단도는 저장 높이가 없으면 근입 0.5m 만 그림 — 표 높이와 다름"
#: 독립 기슭막이 치수 **한 벌**(`resources/data_masonry/revetment_sabang_*.json`) — B05 칸 밑 근거와
#: 이 사유가 같은 파일을 읽음(2026-09-14 브레인 판정). 높이는 **설계자 입력** — 등록부 기본 2.5 걷음.
REVET_SABANG_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_masonry"
REVET_SABANG_PREFIX = "revetment_sabang_"
UNCONFIRMED_OWN_HEIGHT = "높이를 안 적음 — {rule}({source}) 설계자 입력 · 적으면 금액이 섬"
@lru_cache(maxsize=1)
def load_revetment_sabang() -> dict[str, Any]:
"""독립 기슭막이 치수 한 벌. 없으면 빈 표 — 사유가 근거 없이 짧아질 뿐 값은 안 바뀜."""
files = sorted(REVET_SABANG_DIR.glob(REVET_SABANG_PREFIX + "*.json"))
return json.loads(files[-1].read_text(encoding="utf-8")) if files else {}
def _span(values: list[float], unit: str = "") -> str:
return f"{values[0]:g}~{values[1]:g}{unit}"
def own_revetment_basis() -> str:
"""독립 기슭막이 근거 한 줄 — 높이·계획비탈·둑마루·뒷채움, 쪽·줄 번호 그대로."""
data = load_revetment_sabang()
items = data.get("items") or {}
if not items:
return ""
height, slope = items["height"], items["face_slope"]
crown, pebble = items["crown_thickness"], items["backfill_pebble"]
return (
f"ⓘ 독립 기슭막이 치수 근거 — 높이 {height['rule']}({height['source']})"
f" · 계획비탈 1:{_span(slope['standard_ratio'])}({slope['source']})"
f" · 둑마루 두께 {_span(crown['standard_m'], 'm')}({crown['source']} · 콘크리트 기준)"
f" · 뒷채움 조약돌 두께 {pebble['source']} · {data.get('scope', '')}"
)
def own_height_unconfirmed() -> str:
"""높이를 안 적은 독립 기슭막이의 미확정 까닭."""
height = (load_revetment_sabang().get("items") or {}).get("height") or {}
return UNCONFIRMED_OWN_HEIGHT.format(
rule=height.get("rule", "계획홍수위 + 0.5~0.7m"),
source=height.get("source", "사방기술교본"),
)
#: 금액에서 뺄 까닭(2026-09-14 브레인 판정) — 줄·표·그림은 서되 내역 합에는 안 듦.
UNCONFIRMED_WALL = "벽 치수({filled})가 시설 지점에 없어 기본값으로 섰음 — 적으면 금액이 섬"
NOTE_SIDE_UNKNOWN = (
@@ -330,8 +376,13 @@ def facility_structures(points: list[dict[str, Any]]) -> list[dict[str, Any]]:
values, filled = _revet_values(options, role, wall_defaults, legacy, min_height)
notes = list(inlet_notes) if role == "inlet" else []
if filled:
tail = NOTE_WALL_HEIGHT_OWN if legacy else NOTE_WALL_HEIGHT_PIPE
tail = "" if legacy else NOTE_WALL_HEIGHT_PIPE
notes.append(NOTE_DEFAULT_WALL.format(filled=" · ".join(filled)) + tail)
if legacy:
# 독립 기슭막이 — 치수 한 벌 근거(사방 교본 기준 · 쪽·줄 번호)를 늘 붙임.
basis = own_revetment_basis()
if basis:
notes.append(basis)
walls.append([role, own_label if legacy else pipe_label, values, notes, False, filled])
side = str(options.get("side") or "") if legacy else ""
if side in ("", ""):
@@ -345,13 +396,17 @@ def facility_structures(points: list[dict[str, Any]]) -> list[dict[str, Any]]:
for role, label, values, notes, withheld, filled in walls:
before, after = _num_or_zero(values["before_m"]), _num_or_zero(values["after_m"])
row = child_row(base, "revetment", label, f"{role}_revet")
reasons = [UNCONFIRMED_WALL.format(filled=" · ".join(filled))] if filled else []
if legacy and _blank(values["height_m"]):
# 높이는 설계자 입력(계획홍수위 + 여유고) — 없으면 줄만 서고 금액 밖.
reasons.append(own_height_unconfirmed())
row.update(
start_m=chainage - before,
end_m=chainage + after,
options={**(kept if legacy else {}), **values, "foundation": foundation},
notes=notes,
withheld=withheld,
unconfirmed=UNCONFIRMED_WALL.format(filled=" · ".join(filled)) if filled else "",
unconfirmed=" · ".join(reasons),
)
rows.append(row)
return rows
@@ -212,7 +212,7 @@ def build_standard_sheets(
계산을 다시 하지 않는다 들어온 전개를 제원 조합으로 묶고 단위당으로 나눌 뿐이다.
"""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import _section_mode_at
from common_util.common_util_structure_face_role import structure_face_role
from common_util.common_util_structure_face_role import structure_face_role_of
groups: dict[str, dict[str, Any]] = {}
for structure in unit_table.get("structures") or []:
@@ -220,7 +220,7 @@ def build_standard_sheets(
# 근거를 못 받아 종전값으로 떨어져 **표와 갈린다**(2026-09-09 실측: 표 1:0.35 ·
# 제목 1:0.3). 자리를 고르는 것도 B08 이 쓰는 그 함수를 쓴다.
mode = _section_mode_at(structure, section_modes)
face, face_reason = structure_face_role(mode, (structure.get("options") or {}).get("side"))
face, face_reason = structure_face_role_of(mode, structure.get("options"))
structure = {**structure, "face": face, "face_reason": face_reason}
key = sheet_key(structure)
sheet = groups.get(key)
@@ -227,7 +227,7 @@ def replace_with_templates(
from B08_Quantity.B08_Quantity_Engine_Formula import evaluate_sheets
from B08_Quantity.B08_Quantity_Engine_StructureSheet import slope_of
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import Component, _section_mode_at
from common_util.common_util_structure_face_role import structure_face_role
from common_util.common_util_structure_face_role import structure_face_role_of
settings = {"rubble_base_thickness_m": rubble_base_thickness_m}
targets: list[tuple[Any, dict[str, Any]]] = []
@@ -239,9 +239,7 @@ def replace_with_templates(
if (quantity.billing_unit or "m") not in _PER_LENGTH_UNITS:
continue
options = item.get("options") or {}
face, face_reason = structure_face_role(
_section_mode_at(item, section_modes), options.get("side")
)
face, face_reason = structure_face_role_of(_section_mode_at(item, section_modes), options)
sheet = {
"type_id": quantity.type_id,
"height_m": quantity.height_m,
@@ -80,7 +80,7 @@ from B08_Quantity.B08_Quantity_Engine_UnitQuantity_StoneSpec import ( # noqa: F
weep_hole_spec,
)
from common_util.common_util_quantity_spread import spread_by_unit
from common_util.common_util_structure_face_role import structure_face_role
from common_util.common_util_structure_face_role import structure_face_role_of
# 구조물 종류 → 전개식. 없는 종류는 전개하지 않고 이름만 남긴다(지어내지 않는다).
# ⚠ **관측 원단위표로 가는 종류** — 치수가 저장돼 있지 않아 전개식을 못 세우는 것들이다.
@@ -319,7 +319,7 @@ def expand(
)
return result
# 성토/절토 — **판정 한 벌**을 부른다(우리가 따로 짜지 않는다).
face, face_reason = structure_face_role(section_mode, options.get("side"))
face, face_reason = structure_face_role_of(section_mode, options)
result.components, notes = expander(height, length, options, face, face_reason)
result.notes.extend(notes)
billing = EXPANDER_BILLING.get(type_id)
@@ -21,6 +21,7 @@ from B08_Quantity.B08_Quantity_Engine_UnitQuantity_StoneSpec import (
STONE_PILE_BASIS,
_back_length,
back_length_default_note,
dry_masonry_height_note,
face_slope_ratio,
fill_concrete_mpa,
STONE_SUPPLY_DEFAULT_NOTE,
@@ -270,6 +271,9 @@ def stone_masonry(
default_note = back_length_default_note(options, back_cm, wet=wet, height_m=height_m)
if default_note:
notes.append(default_note)
height_note = None if wet else dry_masonry_height_note(height_m)
if height_note:
notes.append(height_note)
# ⚠ **돌 종류로 계수가 갈린다** (품셈 13-4-3 · 13-4-4 [주]① · 교본 7-3).
# 안 고르면 종전 값(건설품셈 참고자료)으로 돌되 그 사실을 알린다 — 값을 못 낸다고
# 멈추면 이미 저장된 프로젝트가 통째로 빈다.
@@ -444,6 +444,29 @@ def wants_blinding(options: dict[str, Any]) -> bool:
BACK_LENGTH_KEYS = ("back_len_cm", "stone_back_length_cm")
#: 메쌓기 높이 — **두 기준이 공존**(2026-09-14 브레인 판정 ㉰ · 지식DB 대조 정합):
#: 임도기술교본 7-3:27 「돌흙막이 높이는 원칙적으로 찰쌓기는 3.0m 이하, 메쌓기는 2.0m 이하」 ↔
#: 품셈 13-4-4 [주]⑪ 원문 L7192 「돌쌓기는 높이 3m 이상이면 전부 또는 하부를 찰쌓기로 한다」.
#: 막지 않고 사유만(판정 ① 「놓기를 막지 말 것」과 한 결).
DRY_MASONRY_GUIDE_M = 2.0
DRY_MASONRY_LIMIT_M = 3.0
def dry_masonry_height_note(height_m: float) -> str | None:
"""메쌓기 높이가 기준을 넘으면 사유 한 줄 — 두 기준을 다 적음."""
if height_m >= DRY_MASONRY_LIMIT_M:
return (
f"⚠ 메쌓기 높이 {height_m:g}m — 품셈 13-4-4 [주]⑪(원문 L7192) 「돌쌓기는 높이 3m 이상이면"
" 전부 또는 하부를 찰쌓기로 한다」 · 임도기술교본 7-3:27 은 메쌓기 2.0m 이하 — 막지 않음(설계자 확인)"
)
if height_m > DRY_MASONRY_GUIDE_M:
return (
f"⚠ 메쌓기 높이 {height_m:g}m — 임도기술교본 7-3:27 「메쌓기는 2.0m 이하」를 넘음"
" · 품셈 13-4-4 [주]⑪(원문 L7192)은 3m 이상부터 찰쌓기 — 두 기준이 다름 · 막지 않음"
)
return None
#: 뒷길이 표준표 — 품셈 13-4-4 [주]⑩. 안 고른 돌쌓기는 붙박이 45 가 아니라 이 표의 하한
#: (2026-09-14 브레인 판정 ㉮ 「범위 안에 드는 규격 중 가장 작은 것」). 파일이 없으면 종전 45.
BACK_LENGTH_PREFIX = "masonry_back_length_"
@@ -59,3 +59,18 @@ def structure_face_role(section_mode: str | None, side: str | None) -> tuple[str
return None, f"{mode} · 자동(성토 쪽)인데 성토면이 없음 — 가를 근거 없음"
return FILL, f"{mode} · 자동(성토 쪽) → 성토면"
return None, f"설치 측을 알 수 없음({side!r}) — 성절토를 가를 근거 없음"
#: 성토/절토를 **사용자가 고르는 칸** — 비우면 위 판정(계산이지 기본값이 아님 · 2026-09-14 브레인 판정 ㉲).
FACE_ROLE_KEY = "face_role"
def structure_face_role_of(
section_mode: str | None, options: dict | None
) -> tuple[str | None, str]:
"""칸 `face_role`(성토·절토)을 골랐으면 그 값, 비었으면 단면유형 + 설치 측 판정."""
values = options or {}
picked = str(values.get(FACE_ROLE_KEY) or "").strip()
if picked in (FILL, CUT):
return picked, f"사용자 지정 {picked}면(성토/절토 칸)"
return structure_face_role(section_mode, values.get("side"))
@@ -0,0 +1,44 @@
{
"schema_version": "1.0",
"dataset_id": "revetment_sabang",
"effective_date": "2026-01-01",
"note": "독립 기슭막이 치수 **한 벌** — 높이 · 계획비탈 · 둑마루 두께 · 뒷채움 조약돌 두께를 한 자리에 둠(2026-09-14 브레인 판정). B05 칸 밑 근거와 B08 사유가 이 파일 하나를 같이 읽음.",
"scope": "⚠ 사방(계류) 교본 기준 — 임도 배수관 유입·유출부 적용은 확인 남음",
"rank": "품셈(산림청고시 제2025-82호)은 행정규칙 · 사방기술교본은 참고자료 — 제안값은 품셈 표, 교본은 근거로 병기(브레인 판정 ㉯ · 운영지침 「프로그램 기본값 = 현행 법령·행정규칙」)",
"items": {
"height": {
"label": "높이",
"rule": "계획홍수위 + 0.5~0.7m",
"freeboard_m": [0.5, 0.7],
"input": "설계자 입력 · 비우면 미확정(금액에 안 들어감)",
"source": "사방기술교본 2-나:141 · 3-가:181",
"quote": "기슭막이 높이는 계획홍수위 이상으로 하여 홍수시에도 넘치지 않도록 시공한다, 일반적으로 계획홍수위 보다 0.5~0.7m 높게 하며, 계류의 만곡부에서는 凹안이 凸안에 비해 수위가 높아지므로 더 높게 시공한다"
},
"face_slope": {
"label": "계획비탈",
"standard_ratio": [0.3, 0.5],
"extended_ratio": [1.0, 1.5],
"source": "사방기술교본 2-나:129 · 3-가:151",
"quote": "기슭막이의 계획비탈은 1:0.3∼0.5를 표준으로 하고, 사용 재료와 현장 연건에 따라 1:1.0∼1.5 정도로 계획할 수 있다",
"applied": "제안값은 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191) — 이 범위는 근거로만 보임 · 표 값이 범위 밖이면 사유만(교본을 이기려 들지 않음)"
},
"crown_thickness": {
"label": "둑마루 두께",
"standard_m": [0.3, 0.5],
"source": "사방기술교본 2-나:143 · 3-가:201 · 3-가:219(콘크리트기슭막이) · 2-나:135(콘크리트 0.3m)",
"quote": "기슭막이 둑마루 두께는 0.3∼0.5m를 표준으로 하고, 둑마루 기울기는 원칙적으로 계획계상기울기와 일치하도록 시공한다",
"applied": "⚠ 콘크리트 기슭막이 기준 — 돌쌓기 상부 두께는 식(뒷길이 + 0.30)으로 따로 셈 · 식을 안 바꾸고 근거로만(브레인 판정 ㉱)"
},
"backfill_pebble": {
"label": "뒷채움 조약돌 두께",
"source": "사방기술교본 2-나:133(「사방품셈 규정에 의하여」) → 품셈 13-4-4 [주]⑨ 원문 L7164~7171",
"steps_m": [1.5, 3.0, 5.0, 7.0],
"fill_cm": {
"top": [20, 40],
"bottom": [[30, 60], [45, 75], [60, 100], [80, 140]]
},
"cut_cm": [30, 40],
"applied": "막자갈은 정본 뒷채움 폭(상 0.30 · 하 0.45)으로 셈 — 이 표는 근거로만 보임(`STONE_BACKFILL_WIDTH_M` 주석의 갈림)"
}
}
}
@@ -0,0 +1,57 @@
# -*- coding: utf-8 -*-
"""독립 기슭막이 치수 한 벌 — B05 칸 밑 근거와 B08 사유가 **같은 파일**을 읽음 (2026-09-14 브레인 판정).
자리 = `resources/data_masonry/revetment_sabang_*.json`. TS 이름을 import 박으므로 판이
바뀌면 import 옮겨야 여기서 최신 판과 대조. · 번호가 사유에 그대로 실리는지도 .
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_Pipe import ( # noqa: E402
own_height_unconfirmed,
own_revetment_basis,
)
DATA_DIR = ROOT / "resources" / "data_masonry"
FIELDS = (ROOT / "B05_Profile" / "B05_Profile_UI_Drainage_Facility_Fields.ts").read_text(
encoding="utf-8"
)
CHROME = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Card_Chrome.ts").read_text(encoding="utf-8")
def test_TS_가_최신_판을_읽는다() -> None:
latest = sorted(DATA_DIR.glob("revetment_sabang_*.json"))[-1].name
imported = re.search(r'from "\.\./resources/data_masonry/(revetment_sabang_[^"]+)"', FIELDS)
assert imported and imported.group(1) == latest
def test_TS_가_쓰는_칸이_파일에_있다() -> None:
latest = sorted(DATA_DIR.glob("revetment_sabang_*.json"))[-1]
data = json.loads(latest.read_text(encoding="utf-8"))
items = data["items"]
assert data["scope"] and "사방(계류) 교본 기준" in data["scope"]
assert items["height"]["rule"] == "계획홍수위 + 0.5~0.7m"
assert items["face_slope"]["standard_ratio"] == [0.3, 0.5]
assert items["crown_thickness"]["standard_m"] == [0.3, 0.5]
assert "L7164" in items["backfill_pebble"]["source"]
def test_B08_사유가_같은_근거를_쪽_줄_번호로() -> None:
basis = own_revetment_basis()
for ref in ("2-나:141", "3-가:181", "2-나:129", "3-가:151", "2-나:143", "L7164~7171"):
assert ref in basis, ref
assert "2-나:141" in own_height_unconfirmed()
def test_B05_높이_칸은_제안_없이_설계자_입력() -> None:
assert 'height: "",' in FIELDS and '"설계자 입력"' in FIELDS
assert "기슭막이 높이 없음 — 근입만 그림" in CHROME
@@ -68,7 +68,8 @@ def test_client_format_gisungmagi_maps_to_revetment():
# 기준측점 전/후 5m — 레지스트리 기본값.
assert (item.start_m, item.end_m) == (115.0, 125.0)
assert item.options["form"] == "돌쌓기(메)"
assert item.options["height_m"] == 2.5 and item.options["length_m"] == 10
# 높이는 설계자 입력 — 등록부 기본 2.5 걷음(2026-09-14 브레인 판정 · 사방기술교본 2-나:141)
assert "height_m" not in item.options and item.options["length_m"] == 10
def test_client_format_escape_route_width_field_wins():
@@ -485,7 +485,9 @@ def test_revetment_range_options_like_slope_group():
pipe_options = {option.key: option for option in by_id["pipe"].options}
assert item.placement == "point" and item.managed_by == "pipe_points"
expected = {"height_m": 2.5, "length_m": 10, "before_m": 5, "after_m": 5}
# ⚠ 높이는 2026-09-14 부터 **설계자 입력**(기본 없음) — 계획홍수위 + 0.5~0.7m(사방기술교본
# 2-나:141 · 3-가:181) · 옛 2.5 는 근거 없어 걷음(브레인 판정).
expected = {"height_m": None, "length_m": 10, "before_m": 5, "after_m": 5}
for key, default in expected.items():
assert key in options, f"revetment.{key} 옵션 없음"
assert options[key].phase == "b05" and not options[key].required, key
@@ -247,7 +247,8 @@ def test_migrate_converts_legacy_stations(client, tmp_path):
assert pipe["facility"] == "revetment" and pipe["chainage_m"] == 120.0
# 구간·제원은 레지스트리 기본값 승계 — 기준측점 전/후 5m.
assert (pipe["start_m"], pipe["end_m"]) == (115.0, 125.0)
assert pipe["options"]["form"] == "돌쌓기(메)" and pipe["options"]["height_m"] == 2.5
# 높이는 설계자 입력 — 등록부 기본 2.5 걷음(2026-09-14 브레인 판정)
assert pipe["options"]["form"] == "돌쌓기(메)" and "height_m" not in pipe["options"]
def test_migrate_without_pipe_points_defers_revetment(client):
@@ -0,0 +1,76 @@
# -*- coding: utf-8 -*-
"""메쌓기 높이 사유(B2) · 성토/절토 칸(B3) — 2026-09-14 브레인 판정 ㉰·㉲.
기준 공존 임도기술교본 7-3:27 메쌓기 2.0m 이하 품셈 13-4-4 [] 원문 L7192
3m 이상이면 전부 또는 하부를 찰쌓기. 막지 않고 사유(B08 ) · 경고(B05, `warn_above`).
`face_role` 비우면 단면유형 + 설치 자동 판정(계산이지 기본값이 아님) · 고르면 값이 이김.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import expand, stone_masonry # noqa: E402
from common_util.common_util_structure_face_role import structure_face_role_of # noqa: E402
REGISTRY = json.loads(
(ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8")
)
FIELDS = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Fields.ts").read_text(encoding="utf-8")
def _options(type_id: str) -> dict[str, dict]:
item = next(t for t in REGISTRY["types"] if t["type_id"] == type_id)
return {o["key"]: o for o in item["options"]}
def _height_notes(height: float, wet: bool) -> list[str]:
_rows, notes = stone_masonry(height, 10.0, {"back_len_cm": 45}, wet)
return [n for n in notes if "메쌓기 높이" in n]
def test_메쌓기_높이_두_기준_사유() -> None:
assert _height_notes(2.0, False) == []
over_guide = _height_notes(2.5, False)
assert len(over_guide) == 1 and "7-3:27" in over_guide[0] and "L7192" in over_guide[0]
over_limit = _height_notes(3.0, False)
assert len(over_limit) == 1 and "3m 이상이면" in over_limit[0]
assert _height_notes(3.5, True) == [] # 찰쌓기는 이 사유 대상 아님
def test_등록부_메쌓기_높이_칸은_경고만() -> None:
height = _options("masonry_dry")["height_m"]
assert height["warn_above"] == 2.0 and "L7192" in height["warn_message"]
assert "is-outside-standard" in FIELDS and "warn_above" in FIELDS
def test_성토절토_칸이_이긴다() -> None:
assert structure_face_role_of("both_fill", {"face_role": "절토"}) == (
"절토",
"사용자 지정 절토면(성토/절토 칸)",
)
# 비우면 지금 판정 그대로
assert structure_face_role_of("both_fill", {})[0] == "성토"
for type_id in ("masonry_wet", "masonry_dry", "revetment"):
role = _options(type_id)["face_role"]
assert role["choices"] == ["성토", "절토"] and role["default"] is None, type_id
def test_전개가_칸_값으로_기울기를_고른다() -> None:
structure = {
"structure_id": "w",
"type_id": "masonry_wet",
"start_m": 0.0,
"end_m": 10.0,
"options": {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45, "face_role": "절토"},
}
result = expand(structure, None, None, "both_fill")
wall = next(c for c in result.components if c.name == "돌쌓기")
# 찰쌓기 · 절토 · 직고 ≤3m → 1:0.25 (성토였으면 1:0.30)
assert "절토" in wall.basis and "1:0.25" in wall.basis
@@ -95,7 +95,7 @@ def test_유입구가_기슭막이인데_집수정_형식이_남아_있으면_
def test_독립_기슭막이_한쪽이면_두_칸이_같을_때만_세고_다르면_사유() -> None:
same = {"facility": "revetment", "chainage_m": 50.0, "options": {"side": ""}}
same = {"facility": "revetment", "chainage_m": 50.0, "options": {"side": "", "height_m": 2.0}}
rows = _table([same])["structures"]
assert [r["name"] for r in rows] == ["기슭막이 · 좌 벽"] and rows[0]["components"]
differ = {**same, "options": {"side": "", "inlet_revet_height_m": 3.0}}
@@ -103,6 +103,17 @@ def test_독립_기슭막이_한쪽이면_두_칸이_같을_때만_세고_다르
assert not row["components"] and "서버가 못 가림" in row["notes"][-1]
def test_독립_기슭막이_높이는_설계자_입력_없으면_미확정과_사방_근거() -> None:
"""2026-09-14 브레인 판정 — 높이 = 계획홍수위 + 0.5~0.7m(사방기술교본 2-나:141 · 3-가:181)."""
point = {"facility": "revetment", "chainage_m": 50.0, "options": {"side": ""}}
(row,) = _table([point])["structures"]
assert "높이를 안 적음" in row["unconfirmed"] and "2-나:141" in row["unconfirmed"]
assert any("사방(계류) 교본 기준" in note and "3-가:151" in note for note in row["notes"])
with_height = {**point, "options": {"side": "", "height_m": 2.0, "form": "돌쌓기(메)"}}
(row,) = _table([with_height])["structures"]
assert "높이를 안 적음" not in row["unconfirmed"]
def test_산출식_없는_세월교는_사유로_서고_터파기_빠짐_줄을_안_만든다() -> None:
table = _table([{"facility": "ford_bridge", "chainage_m": 30.0, "options": {}}])
assert "산출식이 아직 없습니다" in table["structures"][0]["notes"][0]