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

This commit is contained in:
2026-09-09 21:28:30 +09:00
7 changed files with 254 additions and 8 deletions
@@ -802,6 +802,26 @@
"required": false,
"phase": "b05"
},
{
"key": "thickness_top_m",
"label": "상부 두께",
"input": "number",
"unit": "m",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김"
},
{
"key": "thickness_bottom_m",
"label": "하부 두께",
"input": "number",
"unit": "m",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김"
},
{
"key": "back_len_cm",
"label": "뒷길이",
@@ -944,6 +964,26 @@
"required": false,
"phase": "b05"
},
{
"key": "thickness_top_m",
"label": "상부 두께",
"input": "number",
"unit": "m",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김"
},
{
"key": "thickness_bottom_m",
"label": "하부 두께",
"input": "number",
"unit": "m",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김"
},
{
"key": "back_len_cm",
"label": "뒷길이",
@@ -1142,6 +1182,26 @@
"phase": "b05",
"default_basis": "0 = 자동 자리 그대로 — 옮기지 않음을 뜻함(기슭막이와 같은 벌)"
},
{
"key": "thickness_top_m",
"label": "상부 두께",
"input": "number",
"unit": "m",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김"
},
{
"key": "thickness_bottom_m",
"label": "하부 두께",
"input": "number",
"unit": "m",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김"
},
{
"key": "back_len_cm",
"label": "뒷길이",
@@ -1479,6 +1539,26 @@
"required": true,
"phase": "detail"
},
{
"key": "thickness_top_m",
"label": "상부 두께",
"input": "number",
"unit": "m",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김"
},
{
"key": "thickness_bottom_m",
"label": "하부 두께",
"input": "number",
"unit": "m",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김"
},
{
"key": "back_len_cm",
"label": "뒷길이 3",
@@ -1759,6 +1839,26 @@
"required": false,
"phase": "b05"
},
{
"key": "thickness_top_m",
"label": "상부 두께",
"input": "number",
"unit": "m",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김"
},
{
"key": "thickness_bottom_m",
"label": "하부 두께",
"input": "number",
"unit": "m",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김"
},
{
"key": "back_len_cm",
"label": "뒷길이",
@@ -277,6 +277,8 @@ export function putStandardSheetSpec(
foundation: string | null;
stone_coeff_basis: string | null;
fill_concrete_mpa: string | null;
thickness_top_m: string | null;
thickness_bottom_m: string | null;
},
): Promise<{ status: string; revision: number; changed: number; notes: string[] }> {
return requestJson(`/projects/${projectId}/standard-sheets/spec`, {
@@ -33,6 +33,8 @@ EDITABLE_KEYS: tuple[str, ...] = (
"foundation",
"stone_coeff_basis",
"fill_concrete_mpa",
"thickness_top_m",
"thickness_bottom_m",
)
#: 기초 갈래 — 정본 xls 탭 제목 그대로(`04.구조도(기슭막이).xls`).
@@ -55,6 +57,8 @@ FIELD_LABELS: dict[str, str] = {
"foundation": "기초",
"stone_coeff_basis": "야면석 계수",
"fill_concrete_mpa": "채움 강도",
"thickness_top_m": "상부 두께",
"thickness_bottom_m": "하부 두께",
}
#: 야면석 계수를 어느 열에서 읽나 — 확정 ⑨ 「품셈 열이 기본, 사용자가 고를 수 있게」.
@@ -147,6 +151,26 @@ def clean_spec(spec: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
if note:
notes.append(note)
# 벽 두께 — 비우면 실무 구조물도 식(확정 2차 ② 후반). 값을 넣으면 그 값이 이긴다.
for key in ("thickness_top_m", "thickness_bottom_m"):
if key not in spec:
continue
raw = spec.get(key)
if raw in (None, ""):
cleaned[key] = None
continue
try:
value = float(raw)
except (TypeError, ValueError):
notes.append(
f"{FIELD_LABELS[key]}{raw}」를 숫자로 읽지 못했습니다 — 비우면 식으로 돕니다."
)
continue
if value <= 0:
notes.append(f"{FIELD_LABELS[key]}는 0보다 커야 합니다 — 비우면 식으로 돕니다.")
continue
cleaned[key] = value
return cleaned, notes
@@ -73,6 +73,8 @@ class StandardSheetSpecRequest(BaseModel):
foundation: str | None = None
stone_coeff_basis: str | None = None
fill_concrete_mpa: str | None = None
thickness_top_m: float | str | None = None
thickness_bottom_m: float | str | None = None
@router.put("/{project_id}/standard-sheets/spec")
@@ -40,11 +40,16 @@ export interface StandardSpecResult {
foundation: string | null;
stone_coeff_basis: string | null;
fill_concrete_mpa: string | null;
thickness_top_m: string | null;
thickness_bottom_m: string | null;
}
/** 이 종류가 돌쌓기 계열인가 — 옹벽·집수정에는 이 칸들이 뜻이 없다. */
const STONE_TYPES = new Set(["masonry_wet", "masonry_dry", "boulder_masonry"]);
/** 벽 두께 칸을 받는 종류 — **큰돌쌓기는 두께 식이 달라** 등록부에 칸이 없다(2026-09-09). */
const THICKNESS_TYPES = new Set(["masonry_wet", "masonry_dry"]);
function field(label: string, control: HTMLElement, hint?: string): HTMLLabelElement {
const wrap = document.createElement("label");
wrap.className = "b07-spec__field";
@@ -126,6 +131,19 @@ export function buildStandardSpecPanel(
slope.placeholder = "비우면 자동";
slope.value = options.face_slope_ratio == null ? "" : String(options.face_slope_ratio);
// 벽 두께 — 비우면 실무 구조물도 식(상부 = 뒷길이 + 0.30 · 하부 = 상부 + 0.30×(H1)).
const thickness = (key: "thickness_top_m" | "thickness_bottom_m"): HTMLInputElement => {
const input = document.createElement("input");
input.className = "b07-spec__input";
input.type = "text";
input.inputMode = "decimal";
input.placeholder = "비우면 식";
input.value = options[key] == null ? "" : String(options[key]);
return input;
};
const topThickness = thickness("thickness_top_m");
const bottomThickness = thickness("thickness_bottom_m");
panel.append(
field("돌 종류", kind),
field("조달", supply, "비우면 「캔다」로 봅니다."),
@@ -139,6 +157,12 @@ export function buildStandardSpecPanel(
field("야면석 계수", coeff, "비우면 품셈 열을 씁니다."),
field("채움 강도 (MPa)", mpa, "비우면 210. 180 은 국가기준 하한입니다."),
);
if (THICKNESS_TYPES.has(sheet.type_id)) {
panel.append(
field("상부 두께 (m)", topThickness, "비우면 뒷길이 + 0.30 으로 섭니다."),
field("하부 두께 (m)", bottomThickness, "비우면 상부 + 0.30×(H1) 로 섭니다."),
);
}
const notes = document.createElement("ul");
notes.className = "b07-spec__notes";
@@ -175,6 +199,12 @@ export function buildStandardSpecPanel(
foundation: foundation.value || null,
stone_coeff_basis: coeff.value || null,
fill_concrete_mpa: mpa.value || null,
thickness_top_m: THICKNESS_TYPES.has(sheet.type_id)
? topThickness.value.trim() || null
: null,
thickness_bottom_m: THICKNESS_TYPES.has(sheet.type_id)
? bottomThickness.value.trim() || null
: null,
}),
);
} catch (error) {
@@ -639,6 +639,56 @@ def _num(value: Any, fallback: float = 0.0) -> float:
return float(value) if isinstance(value, (int, float)) else fallback
#: 벽 두께를 **사용자가 덮어쓰는 칸** — 비우면 실무 구조물도 식으로 돈다(확정 2차 ② 후반
#: 「사용자가 값을 바꿀 수 있게」). 야면석 계수·채움 강도와 같은 자리다.
WALL_THICKNESS_KEYS: tuple[str, str] = ("thickness_top_m", "thickness_bottom_m")
def wall_thickness(
options: dict[str, Any], *, back_cm: float, height_m: float
) -> tuple[float, float, str]:
"""(상부 두께 m, 하부 두께 m, 근거 문구) — **사용자가 넣은 값이 식을 이긴다.**
⚠ 빈 칸은 「안 정함」이다 — 자동으로 나온 값을 저장에 박지 않는다(`face_slope_ratio`
와 같은 태도). 상부만 넣으면 하부는 **그 상부 위에서** 식을 이어 간다 — 벽 모양
(아래로 벌어지는 기울기)을 사용자 값 위에 그대로 얹는 것이 자연스럽다.
"""
def given(key: str) -> float | None:
raw = options.get(key)
if raw in (None, ""):
return None
try:
value = float(raw)
except (TypeError, ValueError):
return None
return value if value > 0 else None
constants = STONE_MASONRY
back_m = back_cm / 100.0
top_given = given(WALL_THICKNESS_KEYS[0])
top = top_given if top_given is not None else back_m + constants["thickness_top_add_m"]
rise = constants["thickness_slope_per_m"] * max(
height_m - constants["thickness_height_base_m"], 0.0
)
bottom_given = given(WALL_THICKNESS_KEYS[1])
bottom = bottom_given if bottom_given is not None else top + rise
top_basis = (
f"상부 {top:.2f}(사용자 입력)"
if top_given is not None
else f"상부 {top:.2f}(뒷길이 {back_cm:g}㎝ + {constants['thickness_top_add_m']:g})"
)
bottom_basis = (
f"하부 {bottom:.2f}(사용자 입력)"
if bottom_given is not None
else (
f"하부 {bottom:.2f}(상부 + {constants['thickness_slope_per_m']:g}"
f"×(H{constants['thickness_height_base_m']:g}))"
)
)
return top, bottom, f"실무 구조물도 식 — {top_basis} · {bottom_basis}"
#: 큰돌쌓기(품셈 13-6) 직경 갈래 — **저장 제원 `stone_cm` 과 글자까지 같다**(레지스트리 확인).
#: ⚠ 돌쌓기(13-4)의 **뒷길이** 축과 섞지 않는다. 앞서 섞여 있어 직경 60~80 짜리가
#: 「뒷길이 45㎝」 계수로 돌던 자리다.
@@ -871,16 +921,12 @@ def stone_masonry(
# 실무 시트의 「정면적 × 1.04」가 바로 이 값이다(1:0.3 에서 1.0440 ≈ 1.04).
masonry_area = face_area * math.hypot(1.0, slope_ratio)
# 벽 두께 — 실무 구조물도 식(확정 2차 ②). **뒷길이가 들어간다.**
back_m = back_cm / 100.0
top_thickness = back_m + constants["thickness_top_add_m"]
bottom_thickness = top_thickness + constants["thickness_slope_per_m"] * max(
height_m - constants["thickness_height_base_m"], 0.0
# ⚠ 표준도 제원에 상부·하부 두께 칸이 있으면 **그 값이 식을 이긴다**(확정 ② 후반).
back_m = back_cm / 100.0 # 석적(아래)이 뒷길이를 m 로 쓴다
top_thickness, bottom_thickness, thickness_basis = wall_thickness(
options, back_cm=back_cm, height_m=height_m
)
thickness = (top_thickness + bottom_thickness) / 2.0
thickness_basis = (
f"실무 구조물도 식 — 상부 {top_thickness:.2f}(뒷길이 {back_cm}㎝ + 0.30) ·"
f" 하부 {bottom_thickness:.2f}(상부 + 0.30×(H1))"
)
volume = face_area * thickness # 입적
components = [
@@ -90,3 +90,45 @@ def test_안_고르면_계산식으로_서고_그_사실이_적힌다() -> None:
assert .source == "" # 관측 출처가 아니다
assert "0.77" in .basis and "2.65" in .basis
assert any("계산식" in n for n in notes)
def test_두께_칸이_비면_식으로_돈다() -> None:
"""확정 2차 ② 후반 「사용자가 값을 바꿀 수 있게」 — 빈 칸은 「안 정함」이다."""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import wall_thickness
top, bottom, basis = wall_thickness({}, back_cm=45, height_m=2.5)
assert abs(top - 0.75) < 1e-9 # 뒷길이 0.45 + 0.30
assert abs(bottom - 1.20) < 1e-9 # 상부 + 0.30 × (2.5 1.0)
assert "뒷길이" in basis and "사용자 입력" not in basis
def test_넣은_두께가_식을_이긴다() -> None:
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import wall_thickness
top, bottom, basis = wall_thickness(
{"thickness_top_m": 0.6, "thickness_bottom_m": "1.0"}, back_cm=45, height_m=2.5
)
assert (top, bottom) == (0.6, 1.0)
assert basis.count("사용자 입력") == 2
def test_상부만_넣으면_하부는_그_위에서_식을_잇는다() -> None:
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import wall_thickness
top, bottom, _ = wall_thickness({"thickness_top_m": 0.6}, back_cm=45, height_m=2.5)
assert (top, abs(bottom - 1.05) < 1e-9) == (0.6, True) # 0.6 + 0.30 × 1.5
def test_두께가_입적까지_닿는다() -> None:
"""칸만 만들고 계산이 안 읽으면 「값이 안 닿는」 옛 사고와 같아진다."""
기본, _ = 성분()
두꺼움, _ = 성분(thickness_top_m=1.2, thickness_bottom_m=1.6)
assert 두꺼움["입적"].amount > 기본["입적"].amount
def test_이상한_값은_식으로_돌린다() -> None:
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import wall_thickness
for bad in ("", None, "두꺼움", -1, 0):
top, _, basis = wall_thickness({"thickness_top_m": bad}, back_cm=45, height_m=2.5)
assert abs(top - 0.75) < 1e-9 and "사용자 입력" not in basis, bad