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

This commit is contained in:
2026-09-08 05:54:29 +09:00
16 changed files with 1066 additions and 54 deletions
@@ -407,6 +407,29 @@ def formula_rows(table: dict[str, Any]) -> list[str]:
return found
# ⚠ **규격 표기에 쓰이는 특수문자** — 원문이 한 종류로 안 쓴다(2026-09-07 실측).
# 물결표만 셋이다: ``(U+223C) 662회 · ``(U+FF5E) 459회 · `~`(U+007E) 2회.
# 곱셈표도 `×`(U+00D7) 97회 · `x`(U+0078) 4회로 갈린다.
# **두 창이 각자 갈래 키를 조립하면 글자 하나로 영영 안 맞는다** — 그래서 우리는
# 키를 조립하지 않고 **저장 원본값만** 보낸다(인계 계약). 여기서는 **표시만** 한다.
# ⚠ 값을 고치지 않는다 — 원문 표기를 흡수하는 것은 **원문을 읽는 쪽**의 몫이다.
SPECIAL_GLYPHS = {
"": "(U+223C)",
"": "(U+FF5E)",
"~": "~(U+007E)",
"×": "×(U+00D7)",
"x": "x(U+0078)",
"": "(U+2013)",
"": "(U+2010)",
}
def special_glyphs(table: dict[str, Any]) -> list[str]:
"""규격 표기에 쓰인 특수문자 종류. 갈래 키를 맞출 때 대조할 자리."""
text = " ".join(norm(cell) for row in table.get("rows", []) for cell in row)
return sorted({SPECIAL_GLYPHS[ch] for ch in text if ch in SPECIAL_GLYPHS})
def crew_table(table: dict[str, Any]) -> bool:
"""작업조 + 시공량으로 적힌 표인가."""
hay = " ".join(norm(h) for h in table.get("headers", []))
@@ -532,6 +555,8 @@ def build() -> dict[str, Any]:
"spaced_names": spaced_names(table),
# ⚠ 공식 기호 줄 — 소요량이 아니다. 자원으로 세면 이중계상.
"formula_rows": formula_rows(table),
# ⚠ 규격 표기 특수문자 — 갈래 키를 맞출 때 대조할 자리(값은 안 고침).
"special_glyphs": special_glyphs(table),
# 공식 기호가 이 표에 직접 있는가 — 없으면 앞 표에서 물려받는 모양이다.
"capacity_formula_here": capacity_formula_pending(table),
"variant_key": variant_axis(table),
@@ -94,7 +94,7 @@ def _split_by_rock(total: float, source: SummaryInput) -> list[tuple[str, float,
given = sum(ratios.values())
if given <= 0:
return [("", total, "")]
note = "" if abs(given - 100.0) < 1e-9 else f"입력 합 {given:g} % → 100 % 로 안분"
note = "" if abs(given - 100.0) < 1e-9 else f"암 갈래 입력 합 {given:g} % → 100 % 로 안분"
return [(name, total * ratios[name] / given, note) for name in classes if ratios[name] > 0]
+36 -4
View File
@@ -499,6 +499,9 @@ def _earthwork_rows(
"spec_detail": "",
"composite_parts": None,
"structure_kind": None,
# 토공·운반 줄에는 갈래 축이 없다 — 그래도 **칸은 둔다**(계약이 한 모양).
"variant_axis": None,
"variant_value": None,
"spec_class": None,
"spec_class_basis": "",
# 토공 줄은 막힐 자리가 없다 — 그래도 **칸은 둔다**(계약이 한 모양이어야 한다).
@@ -551,6 +554,9 @@ def _haul_rows(
"spec_detail": "",
"composite_parts": None,
"structure_kind": None,
# 토공·운반 줄에는 갈래 축이 없다 — 그래도 **칸은 둔다**(계약이 한 모양).
"variant_axis": None,
"variant_value": None,
"spec_class": None,
"spec_class_basis": "",
"blocked_kind": None,
@@ -602,13 +608,26 @@ def _structure_rows(
type_id = str(structure.get("type_id") or "")
entry = mapping.for_structure(type_id) or {}
code = entry.get("work_item_code")
# 돌쌓기는 **뒷길이 갈래**로 단가가 갈린다 — 저장 제원에서 자동으로 고른다.
# 돌쌓기는 **뒷길이 갈래**로, 큰돌쌓기는 **메/찰**로 단가가 갈린다 —
# 둘 다 저장 제원에서 자동으로 고른다(사용자 칸을 따로 만들지 않는다).
class_key: str | None = None
class_basis = ""
if entry.get("class_from") == "bond":
bond = str((structure.get("options") or {}).get("bond") or "").strip()
bond_codes = entry.get("bond_codes") or {}
if bond in bond_codes:
# 메/찰은 **의미 판정**이라 우리 몫이다 — 공종 자체가 갈린다.
code = bond_codes[bond]
class_key = bond
class_basis = f"쌓기 방식 「{bond}」 → 품셈 13-6 {code.split('-')[-1]}"
else:
class_basis = (
"큰돌쌓기 쌓기 방식이 아직 입력되지 않았습니다 — 구조물 상세 입력에서 "
"메쌓기·찰쌓기 중 하나를 고르면 공종이 정해집니다"
)
if code and entry.get("class_from") == "back_length":
class_key, class_basis = masonry_class(structure.get("options") or {})
if class_key:
code = f"{code}#{normalize_kind_key(class_key)}"
composite = mapping.composite_for(type_id) if code is None else None
kind = structure_kind(structure) if composite else None
parts: list[dict[str, Any]] | None = None
@@ -616,9 +635,12 @@ def _structure_rows(
if composite:
parts, parts_missing = composite_quantities(structure, composite, mapping)
blocked_kind, blocked_reason = blocked_of(structure, class_basis)
# 갈래 축과 **저장 제원 원본값**. 가공하지 않는다.
variant_axis = str(entry.get("variant_axis") or "") or None
variant_value = (structure.get("options") or {}).get(variant_axis) if variant_axis else None
if code is None and composite is None:
unmatched.append(f"{wording_type_label(type_id)} — 품셈 공종을 아직 못 이었습니다")
elif entry.get("class_from") == "back_length" and class_key is None:
elif entry.get("class_from") in ("back_length", "bond") and class_key is None:
unmatched.append(f"{wording_type_label(type_id)}{class_basis}")
length = float(structure.get("length_m") or 0.0)
rows.append(
@@ -647,6 +669,12 @@ def _structure_rows(
"blocked_kind": blocked_kind,
"blocked_reason": blocked_reason,
# 규격 갈래(뒷길이 …㎝ 이하) — 못 고르면 사유가 남는다.
# ⚠ **갈래 키 문자열을 우리가 조립하지 않는다** (2026-09-07 계약 변경).
# 품셈 원문이 물결표를 섞어 쓴다(`` U+223C / `` U+FF5E). 두 창이 각자
# 키를 조립하면 **글자 하나로 영영 안 맞는다.** 우리는 **어느 축인지와
# 저장 원본값**만 보내고, 원문을 읽는 쪽이 그 표기를 흡수한다.
"variant_axis": variant_axis,
"variant_value": variant_value,
"spec_class": class_key,
"spec_class_basis": class_basis,
# ⚠ 물량을 못 채운 조각 — 0 으로 적지 않고 사유와 함께 드러낸다.
@@ -747,7 +775,11 @@ def build_handoff(
"excluded_row_count": sum(1 for row in work_items if not row["in_bill"]),
}
# ⚠ 검사는 **실제로 부른다** — 만들어 두고 안 부르면 없는 것과 같다.
# 2026-09-08 ㉘ 자기 감사: 아래 줄 하나만 이어져 있고 형제 둘은 **시험에서만** 불리고
# 있었다. B09 에서 같은 병(가드 둘이 놀고 있음)을 지적해 놓고 내 쪽도 같았다.
result["ratio_math_warnings"] = verify_ratio_math(result)
result["material_code_warnings"] = verify_no_code_on_materials(result)
result["bill_flag_warnings"] = verify_bill_flags(result)
return result
+51 -18
View File
@@ -22,6 +22,8 @@ from __future__ import annotations
from typing import Any, Iterable
from B08_Quantity.B08_Quantity_Wording import type_label
#: 사방 시설로 보는 구조물 종류 — **레지스트리의 실제 `type_id` 를 쓴다**(D 그룹 + 흙막이).
#: 목록에 없으면 그 노선에 사방공이 **없는** 것이다. 이름을 지어내면 영영 안 걸린다.
EROSION_CONTROL_TYPES = frozenset(
@@ -72,13 +74,23 @@ def batter_frame_count(slope_rows: Iterable[dict[str, Any]]) -> tuple[int, list[
return count, notes
def level_frame_count(slope_rows: Iterable[dict[str, Any]]) -> tuple[int, list[str]]:
"""수평 규준틀 — **중심점 성토 높이 5m 이상**에 설치(품셈 11-3 [주]①).
def level_frame_count(slope_rows: Iterable[dict[str, Any]]) -> tuple[int | None, list[str]]:
"""수평 규준틀 — **성토고 5m 이상 측점마다** 한 개소(품셈 11-3 [주]①).
성토고는 사면표에 없다 — 그 값을 받기 전에는 **0 으로 때우지 않고 미확보**로 둔다.
「비탈길이 10m 이상 20m마다」인 비탈규준틀과 **기준이 다르다** — 이쪽은 **지점 조건**이라
간격이 없다. 두 기준을 같은 식으로 쓰면 조용히 틀린다.
⚠ 성토고 칸이 아예 없으면 **0 으로 때우지 않고** 미확보로 둔다 — 「없음」과 다르다.
"""
return 0, [
"중심점 성토고 5m 이상 지점에 설치(품셈 11-3 [주]①)인데 성토고가 이 표에 없어 개소를 못 셈"
rows = list(slope_rows)
if not rows:
return None, ["사면표가 없어 성토고를 못 봄"]
if all("fill_height_m" not in row for row in rows):
return None, ["성토고가 사면표에 없어 개소를 못 셈 (품셈 11-3 [주]① 「성토고 5m 이상」)"]
tall = [
row for row in rows if float(row.get("fill_height_m") or 0.0) >= LEVEL_MIN_FILL_HEIGHT_M
]
return len(tall), [
f"성토고 {LEVEL_MIN_FILL_HEIGHT_M:g}m 이상 측점 {len(tall)}곳 (품셈 11-3 [주]①)"
]
@@ -119,15 +131,7 @@ def preparation_rows(
"work_item_code": "FP-09-21",
},
_batter_frame_row(list(slope_rows)),
{
"group": "준비공",
"item": "수평 규준틀",
"unit": "개소",
"amount": None,
"status": STATUS_PENDING,
"reason": level_frame_count(())[1][0],
"work_item_code": "FP-11-03",
},
_level_frame_row(list(slope_rows)),
]
@@ -179,8 +183,35 @@ def _batter_frame_row(slope_rows: list[dict[str, Any]]) -> dict[str, Any]:
}
def erosion_rows(structures: Iterable[dict[str, Any]] = ()) -> list[dict[str, Any]]:
"""사방공 줄 — 이 노선에 사방 시설이 **있을 때만** 값이 선다."""
def _level_frame_row(slope_rows: list[dict[str, Any]]) -> dict[str, Any]:
"""수평 규준틀 한 줄. 성토고를 못 보면 미확보, 보면 개소가 선다."""
count, notes = level_frame_count(slope_rows)
return {
"group": "준비공",
"item": "수평 규준틀",
"unit": "개소",
"amount": float(count) if count is not None else None,
"status": STATUS_READY if count is not None else STATUS_PENDING,
"reason": "; ".join(notes)
+ (
" · 재료량은 품셈 11-3 [주]④ 「설계수량에 따른다」라 미확보"
if count is not None
else ""
),
"work_item_code": "FP-11-03",
}
def erosion_rows(
structures: Iterable[dict[str, Any]] = (),
names: dict[str, str] | None = None,
) -> list[dict[str, Any]]:
"""사방공 줄 — 이 노선에 사방 시설이 **있을 때만** 값이 선다.
⚠ 줄 이름은 **레지스트리 이름**을 쓴다 — `type_id` 를 그대로 적으면 화면에
`soil_guard` 같은 개발자 키가 뜬다(B08 ㉑ 과 같은 병). `names` 가 없으면
문구표가 받아 주고, 그것도 없으면 키를 보이되 **지어내지는 않는다**.
"""
found = sorted(
{
str(item.get("type_id"))
@@ -203,7 +234,8 @@ def erosion_rows(structures: Iterable[dict[str, Any]] = ()) -> list[dict[str, An
return [
{
"group": "사방공",
"item": type_id,
"item": type_label(type_id, names),
"type_id": type_id,
"unit": "개소",
"amount": None,
"status": STATUS_PENDING,
@@ -219,10 +251,11 @@ def build_table(
structures: Iterable[dict[str, Any]] = (),
slope_rows: Iterable[dict[str, Any]] = (),
topsoil_thickness_m: float | None = None,
names: dict[str, str] | None = None,
) -> dict[str, Any]:
"""화면·인계가 그대로 쓰는 모양. **못 서는 줄도 목록에 남는다.**"""
rows = preparation_rows(slope_totals, slope_rows, topsoil_thickness_m) + erosion_rows(
structures
structures, names
)
return {
"columns": ["구분", "공종", "단위", "수량", "상태", "사유"],
@@ -69,6 +69,8 @@ class SlopeAreaRow:
chainage_m: float
distance_m: float = 0.0
berm_width_m: float = 0.0
# 성토고(m) — 수평 규준틀 개소 판정(품셈 11-3 [주]① 「성토고 5m 이상」)이 쓴다.
fill_height_m: float = 0.0
unclosed: bool = False
lengths: dict[str, float] = field(default_factory=dict)
areas: dict[str, float] = field(default_factory=dict)
@@ -102,6 +104,7 @@ def build_rows(
row = SlopeAreaRow(
chainage_m=slope.chainage_m,
berm_width_m=slope.berm_width_m,
fill_height_m=slope.fill_height_m,
unclosed=slope.unclosed,
)
for series, faces in SERIES:
@@ -153,6 +156,7 @@ def build_table(
"chainage_m": row.chainage_m,
"distance_m": row.distance_m,
"berm_width_m": row.berm_width_m,
"fill_height_m": row.fill_height_m,
"unclosed": row.unclosed,
"lengths": row.lengths,
"areas": row.areas,
@@ -59,6 +59,10 @@ class StationSlope:
cut_length_m: float = 0.0
fill_length_m: float = 0.0
berm_width_m: float = 0.0
# 성토고(m) — 성토 사면 조각들의 **수직 낙차 합**. 노면 끝에서 원지반까지 내려간 높이다.
# ⚠ 좌우가 다르면 **큰 쪽**을 쓴다. 「중심점 성토고 5m 이상」(품셈 11-3 [주]①) 판정은
# 가장 높은 쪽이 기준이고, 양쪽을 더하면 실제보다 두 배가 된다.
fill_height_m: float = 0.0
segments: tuple[SlopeSegment, ...] = ()
# 사면이 샘플 범위 끝까지 원지반을 못 만나 **면적이 잘린** 측점.
# 설계 엔진이 `slope_unclosed` 로 이미 경고하는 값을 그대로 물고 온다. 잘린 측점은
@@ -204,10 +208,15 @@ def station_slope(chainage_m: float, design: dict[str, Any]) -> StationSlope:
for side in ("left", "right"):
segments.extend(_side_segments(design, side, ratios))
berm = design.get("berm") or {}
fill_by_side = {
side: sum(abs(s.rise_m) for s in segments if s.role == "fill" and s.side == side)
for side in ("left", "right")
}
return StationSlope(
chainage_m=float(chainage_m),
cut_length_m=sum(s.length_m for s in segments if s.role == "cut"),
fill_length_m=sum(s.length_m for s in segments if s.role == "fill"),
fill_height_m=max(fill_by_side.values(), default=0.0),
berm_width_m=_num(berm.get("width_m")) or 0.0,
segments=tuple(segments),
unclosed=bool(design.get("slope_unclosed")),
@@ -58,7 +58,14 @@ DEFAULT_BACK_LENGTH_CM = 45
# 돌쌓기 전개식의 상수 — 실무 수식에 박혀 있던 값을 뺀 것.
STONE_MASONRY = {
"face_to_slope_factor": 1.04, # 돌쌓기 면적 = 정면적 × 1.04 (비탈 기울기 몫)
# ⚠ **곱하는 값이 아니라 검산 참고값이다** (2026-09-08 ㉘ 에서 고침).
# 실무 시트의 「돌쌓기 = 정면적 × 1.04」에서 그 1.04 가 **곧 기울기 몫**이다
# (1:0.3 → √(1+0.3²) = 1.0440 ≈ 1.04). 시트가 반올림해 적은 것을 우리가
# **별도 계수로 오해해 `hypot` 위에 또 곱하고 있었다** — 면적이 4 % 부풀었고
# 그 면적이 고임돌·야면석·채움콘크리트·모르터·물구멍 **전부의 밑수**였다.
# ⚠ 하드코딩하면 안 되는 값이다 — 큰돌쌓기는 「1:0.3 **이상**」이라 기울기가
# 바뀔 수 있고, 그때 1.04 는 틀린 값이 되지만 `hypot` 은 따라간다.
"sheet_check_factor_at_0_3": 1.04, # 1:0.3 에서 시트값과 맞는지 대조하는 자리
"thickness_base_m": 0.45, # 평균두께 식의 밑돌 두께
"thickness_top_coeff": 0.10, # 상부 두께 계수 (0.45 + 0.10·H)
"thickness_bottom_coeff": 0.40, # 하부 두께 계수 (0.45 + 0.40·H)
@@ -178,7 +185,9 @@ BOULDER_DIAMETERS = ("40~60", "60~80", "80~100")
#: 큰돌쌓기 전개 상수. **재료 원단위는 품셈에 없다** — 13-6 [주]⑦ 「재료량은 설계수량을 적용한다」.
#: 그래서 여기서 내는 것은 **면적과 터파기 계열까지**이고 큰돌 자체는 미확보로 둔다.
BOULDER_MASONRY = {
"face_to_slope_factor": 1.04, # 돌쌓기 면적 = 정면적 × 1.04 (13-4 와 같은 기울기 몫)
# ⚠ 검산 참고값 — 곱하지 않는다. 까닭은 `STONE_MASONRY` 의 같은 칸 주석을 볼 것.
# 큰돌쌓기는 전면 기울기가 「1:0.3 **이상**」이라 특히 하드코딩하면 안 된다.
"sheet_check_factor_at_0_3": 1.04,
"excavation_extra_m": 0.2, # 터파기 폭 여유
"backfill_thickness_m": 0.2, # 되메우기 두께
}
@@ -221,8 +230,8 @@ def boulder_masonry(
slope_ratio = _num(options.get("face_slope_ratio"), 0.3) # 레지스트리에 칸 없음 — 기본 0.3
constants = BOULDER_MASONRY
face_area = height_m * length_m
slope_area = face_area * math.hypot(1.0, slope_ratio)
masonry_area = slope_area * constants["face_to_slope_factor"]
# 기울기 몫은 **한 번만** — 13-4 와 같은 자리다(㉘).
masonry_area = face_area * math.hypot(1.0, slope_ratio)
components = [
Component(
@@ -230,7 +239,7 @@ def boulder_masonry(
"",
masonry_area,
DESTINATION["돌쌓기"],
f"비탈면적 × 1.04 · 직경 {diameter}㎝ (품셈 13-6)",
f"면적 × √(1+{slope_ratio}²) · 직경 {diameter}㎝ (품셈 13-6)",
)
]
@@ -293,13 +302,16 @@ def stone_masonry(
# ⚠ `face_slope_ratio` 는 **레지스트리에 없는 키**다 — 즉 지금은 늘 기본 0.3 으로 돈다.
# 상수로 두는 것이 아니라 「칸이 생기면 바로 받는다」는 뜻으로 남겨 둔다.
# (키 이름 어긋남으로 저장값이 안 닿던 `back_len_cm` 사고와 구별할 것 — 이쪽은 **칸 자체가 없다**.)
# **기본 0.3 의 근거** — 교본 7-3 돌흙막이 기준: 「돌 찰쌓기 3.0m 이하 **1:0.3** /
# 돌 메쌓기 2.0m 이하 **1:0.3** / 큰돌쌓기 **1:0.3 이상**(전도 방지)」
# (`지식DB 02_상세설계/구조물/돌쌓기.md §1`, 값은 `data_masonry` 의 `face_slope`).
slope_ratio = _num(options.get("face_slope_ratio"), 0.3) # 전면 기울기 1:0.3 (교본 7-3)
constants = STONE_MASONRY
face_area = height_m * length_m # 정면적
# 비탈면적 = 정면적 × √(1+n²) — 기울어진 만큼 길어진다.
slope_area = face_area * math.hypot(1.0, slope_ratio)
masonry_area = slope_area * constants["face_to_slope_factor"]
# 돌쌓기 면적 = 정면적 × √(1+n²) — 기울어진 만큼 길어진다. **기울기 몫은 한 번만.**
# 실무 시트의 「정면적 × 1.04」가 바로 이 값이다(1:0.3 에서 1.0440 ≈ 1.04).
masonry_area = face_area * math.hypot(1.0, slope_ratio)
thickness = (
(constants["thickness_base_m"] + constants["thickness_top_coeff"] * height_m)
+ (constants["thickness_base_m"] + constants["thickness_bottom_coeff"] * height_m)
@@ -307,7 +319,13 @@ def stone_masonry(
volume = face_area * thickness # 입적
components = [
Component("돌쌓기", "", masonry_area, DESTINATION["돌쌓기"], "비탈면적 × 1.04"),
Component(
"돌쌓기",
"",
masonry_area,
DESTINATION["돌쌓기"],
f"정면적 × √(1+{slope_ratio}²) — 비탈면적",
),
Component(
"고임돌",
"",
+28 -2
View File
@@ -22,6 +22,7 @@ from fastapi.responses import JSONResponse
from pydantic import BaseModel
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from B06_Section.B06_Section_Repository import (
get_cross_section_designs,
get_longitudinal_section,
@@ -31,6 +32,7 @@ from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SummaryInput
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table as build_summary_table
from B08_Quantity.B08_Quantity_Engine_EarthworkTable import StationArea, build_table
from B08_Quantity.B08_Quantity_Engine_HaulSummary import build_table as build_haul_table
from B08_Quantity.B08_Quantity_Engine_HaulSummary import check_against_plan
from B08_Quantity.B08_Quantity_Engine_Handoff import load_mapping
from B08_Quantity.B08_Quantity_Engine_Preparation import build_table as build_preparation_table
from B08_Quantity.B08_Quantity_Engine_HaulSummary import summary_input_rows
@@ -84,6 +86,17 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
table["haul"] = haul
# 운반계획은 [저장]·[확정]에서 정본에 남는 값이다 — 아직 없으면 빈 표가 정직하다.
table["haul_available"] = bool(plan)
# ⚠ 검산을 **실제로 부른다** — 무대·도자·덤프 합이 운반계획 총량과 맞는가(8-7 ㉡).
# 2026-09-08 ㉘ 자기 감사: 만들어 두고 시험에서만 부르고 있었다. 값을 막지는 않고
# 차이만 실어 화면이 띄우게 한다 — 막으면 계획이 없는 정상 상태에서도 멈춘다.
if plan:
check = check_against_plan(haul, plan)
table["haul_check"] = {
"hauled_total_m3": check.hauled_total_m3,
"plan_total_m3": check.plan_total_m3,
"difference_m3": check.difference_m3,
"by_equipment": check.details,
}
table["summary"] = build_summary_table(
SummaryInput(
@@ -99,11 +112,13 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
)
)
# 준비공·사방공 — 못 서는 줄도 사유와 함께 남긴다(빈 표는 「빠뜨림」과 구별이 안 됨).
structures = await _route_structures(project_id)
table["preparation"] = build_preparation_table(
slope.get("totals") or {},
await _route_structures(project_id),
structures,
slope.get("rows") or [],
settings.get("topsoil_thickness_m"),
{type_id: definition.name for type_id, definition in structure_type_map().items()},
)
method, method_is_default = concrete_placing_method(settings)
# ⚠ 금액에 바로 걸리는 값이라 「기본값으로 돌고 있음」을 응답에 실어 화면이 띄우게 한다.
@@ -186,6 +201,11 @@ class QuantitySettingsBody(BaseModel):
topsoil_thickness_m: float | None = None
#: `None` 이 「안 정함」을 뜻하는 칸 — 저장에서 **버리지 않고 그대로 덮어쓴다**.
#: 빈 문자열로 되돌리는 칸(시공법·타설 방식)과 달리 숫자 칸은 되돌릴 값이 `None` 뿐이다.
NULLABLE_SETTING_KEYS = ("topsoil_thickness_m",)
@router.put("/{project_id}/quantity/settings")
async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -> JSONResponse:
"""산출 조건을 정본에 남긴다 — [저장]이 부르는 자리.
@@ -203,6 +223,12 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."},
)
values = {key: value for key, value in body.model_dump().items() if value is not None}
# ⚠ `None` 을 통째로 버리면 **「안 정함」으로 되돌릴 길이 없다** — 한 번 넣은 값이
# 영영 남는다(2026-09-08 ㉘ 자기 감사). 시공법·타설 방식은 빈 문자열로 되돌리지만
# 숫자 칸은 되돌리는 값이 `None` 뿐이라, **화면이 보낸 것**만 골라 살린다.
for key in NULLABLE_SETTING_KEYS:
if key in body.model_fields_set:
values[key] = getattr(body, key)
if "concrete_placing_method" in values:
method = values["concrete_placing_method"]
# 「안 정함」으로 되돌릴 수 있어야 한다 — 빈 값이면 지운다(8-22 ② 와 같은 자리).
@@ -222,7 +248,7 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
_save_quantity,
root,
values,
("rock_methods", "material_supply", "concrete_placing_method"),
("rock_methods", "material_supply", "concrete_placing_method") + NULLABLE_SETTING_KEYS,
)
except Exception:
logger.exception("B08 설정 저장 실패(쓰기): project_id=%s", project_id)
@@ -65,6 +65,8 @@ export interface QuantitySettings {
material_supply?: Record<string, { supply: string; install_by: string | null }>;
/** 콘크리트 타설 방식. `null`·없음이면 **아직 안 정한 것**이고 화면이 기본값 안내를 띄운다. */
concrete_placing_method?: string | null;
/** 표토 두께(m). `null`·없음이면 **안 정한 것**이라 표토제거 줄이 「근거 없음」으로 선다. */
topsoil_thickness_m?: number | null;
}
export interface EarthworkTable {
+63 -1
View File
@@ -84,6 +84,8 @@ async function saveQuantitySettings(projectId: string, draft: DraftSettings): Pr
rock_methods: draft.rock_methods,
material_supply: draft.material_supply,
concrete_placing_method: draft.concrete_placing_method,
// ⚠ `null` 도 그대로 보낸다 — 「안 정함」으로 되돌릴 길이 있어야 한다(시공법과 같은 규칙).
topsoil_thickness_m: draft.topsoil_thickness_m,
}),
},
);
@@ -134,6 +136,37 @@ function numberField(label: string, value: number, onInput: (value: number) => v
return row;
}
/** ** ** 0 .
*
* `numberField` 0 0m .
* ** ** .
*/
function optionalNumberField(
label: string,
value: number | null,
step: string,
onInput: (value: number | null) => void,
): HTMLElement {
const row = document.createElement("label");
row.className = "b08-quantity__field";
const name = document.createElement("span");
name.textContent = label;
const input = document.createElement("input");
input.type = "number";
input.className = "b08-quantity__input";
input.min = "0";
input.step = step;
input.placeholder = L("B08_Quantity_Unset_Placeholder");
input.value = value === null || value === undefined ? "" : String(value);
// 자동저장은 만들지 않는다 — 입력은 캐시에만 남는다(CLAUDE.md 5장).
input.addEventListener("input", () => {
const text = input.value.trim();
onInput(text === "" ? null : Number(text));
});
row.append(name, input);
return row;
}
/** 타설 방식 표기 — 코드가 아니라 사람이 읽는 이름으로 보인다. */
const PLACING_LABELS: Record<string, string> = {
ready_mixed: "레디믹스트",
@@ -182,6 +215,8 @@ interface DraftSettings {
rock_methods: Record<string, string>;
// 콘크리트 타설 방식 — `""` 는 「안 정함」이고 저장에서 지워진다.
concrete_placing_method: string;
// 표토 두께(m) — `null` 은 「안 정함」. 정해야 표토제거 줄이 선다(품셈 9-15 [주]② 의 T).
topsoil_thickness_m: number | null;
// 자재별 관급/사급 — 표 안에서 줄마다 고른 값.
material_supply: Record<string, SupplyChoice>;
dirty: boolean;
@@ -218,7 +253,10 @@ function buildQuantitySidePanel(
panel.append(field(L("B08_Quantity_Side_RockRatios"), ""));
for (const name of classes) {
// 「암」은 비율을 넣으면 사라지는 되메움 줄이라 비율 칸을 두지 않는다.
if (name !== "암") {
// 「토사」도 비율 칸을 두지 않는다 — 토사 물량은 토적표의 흙깎기 값이 그대로 서고,
// 비율은 **암 총량을 갈래로 나누는 데만** 쓰인다(집계 엔진 `_rock_split`).
// 칸을 두면 넣은 값이 조용히 버려져 「입력 합 60 %」 같은 안내가 뜬다(2026-09-08 통과).
if (name !== "암" && name !== "토사") {
panel.append(
numberField(name, draft.rock_ratios_pct[name] ?? 0, (value) => {
draft.rock_ratios_pct[name] = value;
@@ -262,6 +300,22 @@ function buildQuantitySidePanel(
}
}
// ── 표토 두께 — 정해야 준비공 표토제거 줄이 선다(품셈 9-15 [주]② 의 「T : 표토두께(m)」) ──
// ⚠ 2026-09-08 ㉘ 자기 감사: 서버·엔진은 이 값을 받고 있었는데 **화면에 넣을 칸이 없었다.**
// 「죽은 칸」(넣어도 안 쓰임)의 반대 짝이다 — 쓰이는데 넣을 데가 없던 자리.
panel.append(field(L("B08_Quantity_Side_Topsoil"), ""));
panel.append(
optionalNumberField(
L("B08_Quantity_Side_Topsoil_Label"),
draft.topsoil_thickness_m,
"0.01",
(value) => {
draft.topsoil_thickness_m = value;
draft.dirty = true;
},
),
);
// ── 콘크리트 타설 방식 — ⚠ **금액에 바로 걸리는 값**이라 잠정임을 조용히 두지 않는다 ──
panel.append(field(L("B08_Quantity_Side_Placing"), ""));
panel.append(
@@ -298,6 +352,13 @@ function buildQuantitySidePanel(
placing.is_default ? `${label} (${L("B08_Quantity_Placing_Default_Tag")})` : label,
),
);
// ⚠ 고른 값이 **아직 금액에 안 닿는다**는 사실을 숨기지 않는다(2026-09-08 ㉘).
// 인계가 타설 공종 줄을 아직 안 세운다 — B09 일위대가가 타설 품을 이미 갖고 있는지
// 확인되기 전까지는 세우면 같은 콘크리트를 두 번 센다(이중계상 ㉢ 과 같은 자리).
const pending = document.createElement("p");
pending.className = "b08-quantity__note";
pending.textContent = L("B08_Quantity_Placing_NotApplied");
panel.append(pending);
}
// ⚠ 「정하면 얼마나 달라지는지」까지 보여야 사용자가 판단한다. 이 값은 **참고 표시 전용**이고
// B08 의 어떤 계산에도 안 들어간다(금액은 B09 몫).
@@ -500,6 +561,7 @@ export async function renderB08Quantity(root: HTMLElement): Promise<void> {
application_ratios_pct: { ...(stored.application_ratios_pct ?? {}) },
rock_methods: { ...((stored.rock_methods ?? {}) as Record<string, string>) },
concrete_placing_method: (stored.concrete_placing_method as string) ?? "",
topsoil_thickness_m: (stored.topsoil_thickness_m as number | null) ?? null,
material_supply: { ...((stored.material_supply ?? {}) as Record<string, SupplyChoice>) },
dirty: false,
};
+5
View File
@@ -30,6 +30,11 @@ TYPE_LABELS = {
"ford_pavement": "물넘이포장",
"ford_bridge": "세월교",
"box_culvert": "BOX암거",
# 사방 시설 — 레지스트리 이름 그대로 옮긴 것(2026-09-08 확인). 지어낸 이름 아님.
"erosion_check": "골막이",
"bed_sill": "바닥막이",
"check_dam_small": "소형사방댐(복합형)",
"revetment": "기슭막이",
}
#: 저장 제원 칸의 사람 이름 + **어디서 채우는지**. 키 이름을 화면에 내보내지 않기 위한 표.
@@ -31,5 +31,24 @@
"60~80",
"80~100"
]
},
"face_slope": {
"note": "전면 기울기(1:n) — **교본 7-3 돌흙막이 기준**이 형식별로 정해 둔다. 코드의 기본 0.3 은 지어낸 값이 아니라 이 표에서 온 것이다.",
"source": "resources/knowledge/technical_info/01_임도/02_상세설계/구조물/돌쌓기.md §1 (교본 7-3)",
"quote": "돌 찰쌓기 3.0m 이하 1:0.3 / 돌 메쌓기 2.0m 이하 1:0.3 / 큰돌쌓기 1:0.3 이상(전도 방지)",
"by_type": {
"masonry_wet": 0.3,
"masonry_dry": 0.3,
"boulder_masonry": 0.3
},
"pending": "⚠ 큰돌쌓기는 「1:0.3 **이상**」이라 더 완만하게 잡을 수 있음 — 칸을 만든다면 그 범위를 보여야 함. 지금은 하한 0.3 으로 감."
},
"bond": {
"note": "큰돌쌓기 쌓기 방식 — 저장 제원 `bond`. 품셈 13-6-1(메)·13-6-2(찰)로 그대로 갈린다.",
"option_key": "bond",
"codes": {
"메쌓기": "FP-13-06-01",
"찰쌓기": "FP-13-06-02"
}
}
}
@@ -119,16 +119,16 @@
"work_item_code": "FP-13-04-05",
"master_name": "돌쌓기 > 찰쌓기(장비)",
"note": "인력 시공이면 FP-13-04-04",
"class_from": "back_length",
"class_note": "저장 제원 `back_len_cm` 으로 「…㎝ 이하」 구간을 고름 — 자동 판정"
"class_note": "⚠ 갈래 키 문자열을 여기서 만들지 않는다 — `variant_axis`+`variant_value`(저장 원본값)만 보내고 「…㎝ 이하」 구간 나누기는 **품셈 원문을 읽는 쪽**이 한다(2026-09-07 계약).",
"variant_axis": "back_len_cm"
},
{
"type_id": "masonry_dry",
"work_item_code": "FP-13-04-02",
"master_name": "돌쌓기 > 메쌓기(장비)",
"note": "인력 시공이면 FP-13-04-01",
"class_from": "back_length",
"class_note": "저장 제원 `back_len_cm` 으로 「…㎝ 이하」 구간을 고름 — 자동 판정"
"class_note": "⚠ 갈래 키 문자열을 여기서 만들지 않는다 — `variant_axis`+`variant_value`(저장 원본값)만 보내고 「…㎝ 이하」 구간 나누기는 **품셈 원문을 읽는 쪽**이 한다(2026-09-07 계약).",
"variant_axis": "back_len_cm"
},
{
"type_id": "pipe_inlet_basin",
@@ -139,6 +139,17 @@
"type_id": "ford_pavement",
"work_item_code": "FP-12-06",
"master_name": "콘크리트 포장(인력시공)"
},
{
"type_id": "boulder_masonry",
"work_item_code": null,
"class_from": "bond",
"bond_codes": {
"메쌓기": "FP-13-06-01",
"찰쌓기": "FP-13-06-02"
},
"class_note": "메/찰(`bond`)은 **공종 자체가 갈리는 의미 판정**이라 여기서 고른다. 직경 갈래는 `variant_value` 로 원본값만 보낸다 — 원문이 물결표를 섞어 써서.",
"variant_axis": "stone_cm"
}
],
"pending_user": {
@@ -160,15 +171,6 @@
"FP-09-05 발파암"
],
"why": "설계자가 넣는 암 갈래 이름(풍화암·연암·보통암·경암)이 리핑이냐 발파냐를 말하지 않음. 갈래마다 시공법을 지정하는 칸이 필요함"
},
{
"type_id": "boulder_masonry",
"candidates": [
"FP-13-06-01 메쌓기",
"FP-13-06-02 찰쌓기"
],
"why": "저장 제원(`boulder_masonry`)에 **메/찰 구분 칸이 없어** 어느 쪽인지 못 고름. 레지스트리(`B05_Profile_Structure_Types.json`)는 다른 창 소관이라 여기서 못 고침 — 칸이 생기면 `structure` 에 옮긴다.",
"class_axis": "직경(`stone_cm`) 40~60·60~80·80~100 — 품셈 13-6 축과 글자까지 같음"
}
]
},
@@ -263,5 +265,17 @@
"hand_mixed": 408327
}
}
}
},
"variant_contract": {
"note": "갈래 계약(2026-09-07 3자) — **키 문자열을 두 창이 각자 조립하지 않는다.**",
"why": "품셈 원문이 물결표를 섞어 쓴다: 13-06-01·02 는 ``(U+223C), 13-06-03 은 ``(U+FF5E). 각자 조립하면 글자 하나로 영영 안 맞는다.",
"b08_sends": [
"work_item_code",
"variant_axis",
"variant_value",
"kind_basis"
],
"b09_does": "원문 표기(물결표·공백·괄호)를 흡수해 자기 키로 옮긴다. 「…㎝ 이하」 구간 나누기도 그쪽 몫 — 그 구간이 **품셈 표의 구조**이기 때문."
},
"masonry_class_reference": "resources/data_masonry/masonry_class_2026-01-01.json — ⚠ 이제 **참고용**이다. 서브 판정과 어긋나면 그것이 곧 신호다."
}
@@ -1,7 +1,7 @@
{
"schema_version": "1.0",
"dataset_id": "data_work_item_master_manifest",
"generated_at": "2026-09-08T01:52:34+09:00",
"generated_at": "2026-09-08T04:57:04+09:00",
"built_by": "B08_Quantity/B08_Quantity_Build_WorkItemMaster.py",
"source": {
"dataset_id": "pum_forest",
@@ -12,8 +12,8 @@
"files": [
{
"file": "work_item_master_2026-01-01.json",
"sha256": "08b0c7c26c4aa569ace3b13490b8cbd00606254abb1439894eac750d387bfb03",
"size_bytes": 839464
"sha256": "1c49012f9f50d721676ad8def15c87c96b77aad83123286dd6981b7d9cefcd81",
"size_bytes": 856450
},
{
"file": "form_undetermined_2026-01-01.json",
File diff suppressed because it is too large Load Diff
+11 -1
View File
@@ -628,6 +628,13 @@ export const ui_locales_b2 = {
B08_Quantity_Method_Unset: ["안 정함", "Not set"],
B08_Quantity_Method_Ripping: ["긁어내기(암절취)", "Ripping"],
B08_Quantity_Method_Blasting: ["터뜨리기(발파암)", "Blasting"],
B08_Quantity_Placing_NotApplied: [
"⚠ 이 선택은 아직 금액에 반영되지 않습니다 — 견적의 일위대가가 타설 품을 이미 갖고 있는지 확인 중입니다.",
"Not yet applied to cost — checking whether the unit-price already covers placing labour.",
],
B08_Quantity_Side_Topsoil: ["표토제거", "Topsoil Removal"],
B08_Quantity_Side_Topsoil_Label: ["표토 두께(m)", "Topsoil thickness (m)"],
B08_Quantity_Unset_Placeholder: ["안 정함", "Not set"],
B08_Quantity_Side_Placing: ["콘크리트 타설", "Concrete Placing"],
B08_Quantity_Side_Placing_Label: ["타설 방식", "Method"],
B08_Quantity_Placing_Unset: ["안 정함(기본값 사용)", "Not set (default)"],
@@ -657,7 +664,10 @@ export const ui_locales_b2 = {
B08_Quantity_Ratio_SeedCut: ["초류종자살포(절토면)", "Seed spray (cut)"],
B08_Quantity_Ratio_TreeRemoval: ["지장목제거", "Obstacle removal"],
B08_Quantity_Side_RockSet: ["암 갈래 세트", "Rock class set"],
B08_Quantity_Side_RockRatios: ["지반 구성비(%)", "Ground composition (%)"],
B08_Quantity_Side_RockRatios: [
"암 갈래 구성비(%) — 암 총량 기준",
"Rock class split (%) of rock volume",
],
B08_Quantity_Btn_Save: ["저장", "Save"],
B08_Quantity_Save_Success: ["산출 조건을 저장했습니다.", "Calculation settings saved."],
B08_Quantity_Save_Failed: ["산출 조건을 저장하지 못했습니다.", "Failed to save the settings."],