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

This commit is contained in:
2026-09-09 12:39:15 +09:00
27 changed files with 2303 additions and 126 deletions
@@ -301,6 +301,22 @@ SOURCE_BASIS_NOTE_RE = re.compile(r"([\d,]*\.?\d*)\s*개소\s*사용\s*당")
SOURCE_NOTE_LOOKAHEAD = 8
#: ⚠⚠ **「인」은 품의 단위이지 공종 밑수가 아니다**(2026-09-09 원문 대조로 넷이 오독으로 드러남).
#: 8-6-2 드론방제·8-6-3 지상방제 「(단위 : 인)」 — 그 「인」은 **소요인력**이고 공종 밑수는
#: ha 다. 13-2-4 야면석 채집 「(단위: 인 당)」 — 표 안이 **㎡당·㎥당** 두 줄이다.
#: ⇒ **원문에 「<숫자>인당」이 그대로 있을 때만** 밑수로 인정한다.
#: ⚠ **넓게 「인」을 통째로 버리면 2-2-5 천공기가 사라진다** — 그 표는 셀 안에 「천공인부
#: **1인당** 1대」로 숫자가 붙어 있다. 그래서 「숫자가 붙었나」로 좁게 가른다.
PERSON_UNITS = {"", ""}
def person_basis_ok(raw_quantity: str | None, unit: str | None) -> bool:
"""「인」 계열 밑수를 인정할 것인가 — **숫자가 붙어 있을 때만** 참."""
if unit not in PERSON_UNITS:
return True
return bool((raw_quantity or "").strip())
def basis_from_source(lines: list[str], line_no: int) -> tuple[float | None, str | None]:
"""표 바로 위 본문에서 밑수를 읽는다. 못 찾으면 `(None, None)` — 1 로 단정하지 않는다.
@@ -315,6 +331,8 @@ def basis_from_source(lines: list[str], line_no: int) -> tuple[float | None, str
break # 앞 표에 닿았다 — 그 위는 남의 밑수다
if m := SOURCE_BASIS_RATIO_RE.search(text):
raw = (m.group(1) or "").replace(",", "")
if not person_basis_ok(raw, m.group(2)):
continue # 「인」에 숫자가 안 붙었다 — 품의 단위이지 밑수가 아니다
try:
quantity = float(raw) if raw else 1.0
except ValueError:
@@ -324,6 +342,8 @@ def basis_from_source(lines: list[str], line_no: int) -> tuple[float | None, str
unit = m.group("u1") or m.group("u2")
raw = (m.group(1) if m.group("u1") else m.group(3)) or ""
raw = raw.replace(",", "")
if not person_basis_ok(raw, unit):
continue # 위와 같음 — 「(단위 : 인)」·「(단위: 인 당)」은 밑수가 아니다
try:
quantity = float(raw) if raw else 1.0
except ValueError:
@@ -347,14 +367,29 @@ def basis_from_source(lines: list[str], line_no: int) -> tuple[float | None, str
def detect_basis(table: dict[str, Any]) -> tuple[float | None, str | None]:
"""「100㎥당」 같은 밑수. 없으면 `(None, None)` — 단위당 1 로 단정하지 않는다."""
hay = " ".join(norm(h) for h in table.get("headers", []))
hay += " " + " ".join(norm(c) for r in table.get("rows", [])[:2] for c in r)
if m := BASIS_RE.search(hay):
try:
return float(m.group(1).replace(",", "")), m.group(2)
except ValueError:
return None, m.group(2)
"""「100㎥당」 같은 밑수. 없으면 `(None, None)` — 단위당 1 로 단정하지 않는다.
⚠⚠ **칸 하나 안에서만 본다**(2026-09-09). 여러 칸을 이어 붙여 보면 **앞 칸의 값**과
**뒤 칸의 단위**가 붙어 없는 밑수가 생긴다 — 13-2-4 야면석 채집이 그랬다:
행 ① 인 부 | ㎡당 | 0.11 | 0.17 | 0.22 | 0.28 | **0.36**
행 ② **㎥당** | 0.60 | …
이어 붙이면 「0.36 ㎥당」이 되어 **밑수 0.36㎥** 라는 값이 선다(원문에 없는 값).
⚠ 그 표는 **㎡당·㎥당 두 줄**이라 애초에 하나로 못 정한다 — 「미확보」가 정직하다.
⚠ **「인」은 여기서도 숫자가 붙어야 인정한다** — `BASIS_RE` 가 숫자를 요구하므로
2-2-5 「천공인부 **1인당** 1대」는 그대로 서고 「(단위 : 인)」은 안 선다.
"""
cells = [norm(h) for h in table.get("headers", []) or []]
cells += [norm(c) for r in (table.get("rows", []) or [])[:2] for c in r]
for cell in cells:
if not cell:
continue
if m := BASIS_RE.search(cell):
if not person_basis_ok(m.group(1), m.group(2)):
continue
try:
return float(m.group(1).replace(",", "")), m.group(2)
except ValueError:
return None, m.group(2)
return None, None
@@ -170,24 +170,49 @@ def build_rows(source: SummaryInput) -> list[SummaryRow]:
)
)
removal = slope.get("tree_removal_fill", 0.0) + slope.get("tree_removal_cut", 0.0)
rows.append(
SummaryRow(
group="지장목제거",
unit="",
amount=removal * _ratio(source, "obstacle_removal"),
amount_gross=removal,
application_ratio_pct=_ratio(source, "obstacle_removal") * 100.0,
application_ratio_breakdown={
"fill": _ratio(source, "obstacle_removal") * 100.0,
"cut": _ratio(source, "obstacle_removal") * 100.0,
},
quantity_breakdown={
"fill": slope.get("tree_removal_fill", 0.0) * _ratio(source, "obstacle_removal"),
"cut": slope.get("tree_removal_cut", 0.0) * _ratio(source, "obstacle_removal"),
},
note=_ratio_note(source, "obstacle_removal", "성토면+절토면"),
# ⭐ 2026-09-09 **사용자 확정 5차 2번** — 지장목제거를 **두 줄로 가른다**(실무 서식).
# 영월 설계내역서 1.9 지장목제거가 두 줄이고 **같은 면적을 나눠 쓴다**:
# 1.9.1 뿌리뽑기(장비+인력) 11,035㎡ @475
# 1.9.2 잡관목제거 벌목(5m미만) 11,035㎡ @882 ← 같은 11,035㎡
# ⚠⚠ **이중계상이 아니다** — 한 면적에 **다른 두 작업**이 얹히는 것이라 실무가 그렇게 적는다.
# (같은 작업을 두 축에서 두 번 세는 것과는 다른 자리다.)
# ⚠ 잡관목제거는 **품셈에 그 이름이 없다** — 실무는 별도 단가(영월 D00033)를 씀.
# 공종 없는 줄 보류(확정 5차 3번)에 걸리므로 **코드 없이 서고 사유가 붙는다.**
for item, why in (
(
"뿌리뽑기",
"확정 5차 2번 — 실무가 뿌리뽑기·잡관목제거 두 줄로 가름(같은 면적을 나눠 씀 · 이중계상 아님)",
),
(
"잡관목제거",
"확정 5차 2번 — 같은 면적에 얹히는 다른 작업(이중계상 아님)."
" ⚠ 품셈에 그 이름이 없어 실무는 별도 단가를 씀(영월 D00033) — 공종 보류 대상",
),
):
rows.append(
SummaryRow(
group="지장목제거",
item=item,
unit="",
amount=removal * _ratio(source, "obstacle_removal"),
amount_gross=removal,
application_ratio_pct=_ratio(source, "obstacle_removal") * 100.0,
application_ratio_breakdown={
"fill": _ratio(source, "obstacle_removal") * 100.0,
"cut": _ratio(source, "obstacle_removal") * 100.0,
},
quantity_breakdown={
"fill": slope.get("tree_removal_fill", 0.0)
* _ratio(source, "obstacle_removal"),
"cut": slope.get("tree_removal_cut", 0.0) * _ratio(source, "obstacle_removal"),
},
note=" · ".join(
part
for part in (_ratio_note(source, "obstacle_removal", "성토면+절토면"), why)
if part
),
)
)
)
rows.append(
SummaryRow(
group="층따기", spec="백호우", unit="", amount=slope.get("bench_cut_fill", 0.0)
@@ -134,11 +134,30 @@ BLOCKED_UNIT_DATA_MISSING = "unit_data_missing" # 원단위·표준 물량 자
BLOCKED_FORMULA_MISSING = "formula_missing" # 수량 산출식 자체가 없음
#: 사면 계열 — 토공집계에 함께 실리지만 출처가 사면적이다.
#: ⚠ `item` 칸이 **지반 갈래**인 공종 — 그 밖의 공종에서 `item` 은 **작업 갈래**다
#: (지장목제거의 「뿌리뽑기·잡관목제거」). 갈래로 읽으면 「시공법 미지정」이라는 **틀린 사유**가
#: 붙는다(2026-09-09 실측). 정의처는 `EarthworkSummary` 이고 여기서 그대로 가져다 쓴다.
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import ( # noqa: E402
GROUND_SPLIT_GROUPS as GROUND_SPLIT_GROUPS,
)
SLOPE_GROUPS = frozenset({"성토면다짐", "초류종자살포", "지장목제거", "층따기", "면고르기"})
#: 집계 합계 줄 — 내역 줄이 아니라 검산용이다.
SUBTOTAL_GROUPS = frozenset({"보정량계"})
#: ⚠⚠ **토공집계표에도 운반 줄이 있다 — 그런데 내역 줄은 운반표 쪽이다**(2026-09-09 실측).
#: 집계표는 실무 토적집계 모양이라 「무대·도자운반·덤프운반」을 함께 싣는데, 인계본에는
#: 운반표(`_haul_rows`, FP-10-11·FP-10-12)가 **같은 물량으로 또 실렸다** — 같은 운반이
#: 두 줄이었다(실측: 도자 17.389·61.130, 덤프 37.511·122.417 이 두 축에 각각).
#: ⇒ 집계 쪽은 **값은 내되 내역 줄이 아니다**. 빼지 않는 까닭은 검산(무대+도자+덤프 = 총
#: 운반토량)이 그 값을 쓰기 때문이다 — 무대를 그렇게 둔 것과 같은 규칙이다.
HAUL_SUMMARY_GROUPS = frozenset({"무대(종방향유용토)", "도자운반", "덤프운반"})
NOTE_HAUL_IN_SUMMARY = (
"집계 값 — 내역 줄은 운반표 쪽(FP-10-11·FP-10-12)이 세움. 여기서 또 세우면 같은 운반이"
" 두 줄이 됨(검산용으로만 실림)"
)
def _latest_dataset_path(directory: Path | None = None) -> Path | None:
folder = directory or DATASET_DIR
@@ -14,7 +14,10 @@ from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import (
BLOCKED_FORMULA_MISSING,
BLOCKED_INPUT_MISSING,
BLOCKED_UNIT_DATA_MISSING,
GROUND_SPLIT_GROUPS,
HAUL_SUMMARY_GROUPS,
METHOD_TO_GROUND,
NOTE_HAUL_IN_SUMMARY,
NOTE_METHOD_MISSING,
ORIGIN_EARTHWORK,
ORIGIN_HAUL,
@@ -112,9 +115,15 @@ def _earthwork_rows(
group = str(row.get("group") or "")
if not group:
continue
ground = row.get("item") or None
# ⚠ `item` 이 **지반 갈래인 공종**은 정해져 있다(흙깎기·측구터파기·구조물터파기).
# 그 밖(지장목제거의 「뿌리뽑기·잡관목제거」 같은 **작업 갈래**)을 갈래로 읽으면
# 「시공법 미지정으로 공종을 못 고름」이라는 **틀린 사유**가 붙는다(2026-09-09 실측).
is_ground_split = group in GROUND_SPLIT_GROUPS
ground = (row.get("item") or None) if is_ground_split else None
work_kind = None if is_ground_split else (row.get("item") or None)
origin = ORIGIN_SLOPE if group in SLOPE_GROUPS else ORIGIN_EARTHWORK
is_subtotal = group in SUBTOTAL_GROUPS
# ⚠ 운반은 **집계에도 오르고 운반표에도 오른다** — 내역 줄은 운반표 쪽 하나뿐이다.
is_subtotal = group in SUBTOTAL_GROUPS or group in HAUL_SUMMARY_GROUPS
lookup_ground, method_note = _mapping_ground(ground, methods)
entry = mapping.for_earthwork(group, lookup_ground) if method_note == "" else None
code = (entry or {}).get("work_item_code")
@@ -139,7 +148,8 @@ def _earthwork_rows(
{
"work_item_code": code,
"name": group,
"spec": str(row.get("spec") or ""),
# 작업 갈래가 있으면 **규격 칸**에 적는다 — 갈래 축(`ground_class`)이 아니다.
"spec": str(work_kind or row.get("spec") or ""),
"unit": unit,
"quantity": amount,
# 반영률 — 곱하기는 여기가 끝이다. 받는 쪽은 적기만 한다.
@@ -169,9 +179,9 @@ def _earthwork_rows(
# 합계 줄과 무대 줄은 값은 내되 내역에 안 선다.
"in_bill": bool(row.get("in_bill", True)) and not is_subtotal and mismatch is None,
"excavation_method": methods.get(ground) if ground else None,
"in_bill_reason": "집계 합계 줄 — 검산용"
if is_subtotal
else str(row.get("note") or ""),
"in_bill_reason": NOTE_HAUL_IN_SUMMARY
if group in HAUL_SUMMARY_GROUPS
else ("집계 합계 줄 — 검산용" if is_subtotal else str(row.get("note") or "")),
"origin": origin,
}
)
@@ -232,9 +242,13 @@ def _haul_rows(
"spec_detail": state_note,
"composite_parts": None,
"structure_kind": None,
# 토공·운반 줄에는 갈래 축이 없다 — 그래도 **칸은 둔다**(계약이 한 모양).
"variant_axis": None,
"variant_value": None,
# ⚠⚠ **갈래를 통로로 보낸다**(2026-09-09). 운반 단가가 지반 갈래로 갈리는데
# `spec` 문자열만 보내고 있어 받는 쪽이 갈래를 못 골랐다 — 「버림」에서
# 275,584원이 사라졌던 그 자리와 같다. 갈래는 `variant_value` 한 통로로만.
# ⚠ 이름은 **우리 갈래 그대로**(리핑암) 보낸다 — 일위대가의 「파쇄암」과는
# 인계본의 `ground_class_aliases` 가 이어 준다(이름을 갈면 흙깎기가 어긋난다).
"variant_axis": "ground_class" if row.get("ground") else None,
"variant_value": str(row.get("ground")) if row.get("ground") else None,
"secondary_axes": None,
"spec_class": None,
"spec_class_basis": "",
@@ -575,7 +589,15 @@ def _preparation_rows(preparation_table: dict[str, Any]) -> list[dict[str, Any]]
"station_from": None,
"station_to": None,
"excavation_method": None,
"spec_detail": str(row.get("group") or ""),
# ⚠ **값이 서는 줄에도 근거를 싣는다**(2026-09-09) — 종전에는 대분류 이름만
# 실어, 값이 서는 순간 **왜 그 값인지가 사라졌다**(제근이 면적 축으로 확정돼
# 값이 서자 「교차 참조: 건설품셈 3-9-2」가 화면에서 사라진 자리).
# 못 서는 줄은 종전대로 `blocked_reason` 이 따로 든다.
"spec_detail": " · ".join(
part
for part in (str(row.get("group") or ""), str(row.get("reason") or ""))
if part
),
"composite_parts": None,
"structure_kind": None,
# ⚠ 못 서는 까닭을 그대로 넘긴다 — 받는 쪽이 「만들어야 할 것」 목록에 얹는다.
+11 -1
View File
@@ -53,6 +53,16 @@ COLLECTED_STONE_COMPONENT = "채집석"
#: ⚠ 갈래를 모르는 구조물 몫은 **환산하지 않는다**(계수가 없다) — 따로 낸다.
COLLECTED_STONE_BY_GROUND_KEY = "collected_stone_by_ground_m3"
COLLECTED_STONE_UNKNOWN_KEY = "collected_stone_ground_unknown_m3"
#: ⭐ **축 맞춤의 검산** — 더하는 쪽(구조물 잔토)과 빼는 쪽(채집석 공제)에 **같은 계수**가
#: 걸리므로 사토 총량은 안 흔들린다. 실측: 잔토 126.625 ×1.15 = 145.62 담기고 공제
#: 64.75 ×1.15 = 74.46 빠져 사토(다짐) 71.16 → ÷1.15 = **61.887**, 축 맞추기 전 61.88 과 같다.
#: ⇒ **값을 안 흔들면서 축만 바로잡은 것**이다. 갈래가 갈리거나 모르는 몫이 섞이는
#: 자리에서만 값이 달라진다.
COLLECTED_STONE_AXIS_CHECK = (
"축 맞춤 검산 — 더하는 쪽과 빼는 쪽에 같은 계수가 걸려 사토 총량은 그대로"
"(잔토 ×C 담김 · 공제 ×C 빠짐 ⇒ ÷C 하면 같은 값)"
)
COLLECTED_STONE_BASIS_NOTE = (
"벽 입적(제자리 부피) — 자연 축으로 보고 받는 쪽이 ×C 로 다짐 축에 맞춰 뺌."
" ⚠ 벽 입적과 원바닥 암 부피의 관계를 정한 원문이 없음(캐면 부풀고 쌓으면 공극이 생기나"
@@ -128,7 +138,7 @@ def haul_inputs(unit_quantity_table: dict[str, Any] | None) -> dict[str, Any]:
key: round(value, 3) for key, value in sorted(stone_by_ground.items())
},
COLLECTED_STONE_UNKNOWN_KEY: round(stone_unknown, 3) if stone_unknown else 0.0,
"collected_stone_basis": COLLECTED_STONE_BASIS_NOTE,
"collected_stone_basis": f"{COLLECTED_STONE_BASIS_NOTE} · {COLLECTED_STONE_AXIS_CHECK}",
STRUCTURE_SPOIL_KEY: round(total, 3) if points or total else None,
STRUCTURE_SPOIL_POINTS_KEY: sorted(points, key=lambda row: row["chainage_m"]),
# 총량에도 상태를 적는다 — 측점값을 안 쓰는 쪽도 상태를 알아야 한다.
+25 -8
View File
@@ -45,13 +45,24 @@ NOTE_SECTION_MISSING = (
"그 측점의 횡단 자체가 없습니다 — 관은 놓였는데 횡단이 안 만들어진 자리라 "
"[저장]으로는 안 풀립니다. 횡단설계에서 그 측점이 서야 합니다"
)
#: 관 자리에 횡단이 있는지 볼 때의 허용 오차(m).
#: ⚠⚠ **0.05 → 0.5 로 넓혔다**(2026-09-09 실측으로 앞의 판단이 뒤집힌 자리).
#: 측점을 만들 때 **정수 미터가 같은 격자 측점이 있으면 그리로 스냅**한다(횡단 파일명이
#: 정수 미터라 두 측점이 한 파일을 덮어쓰는 것을 막는 가드). 그래서 관 440.241 의 횡단은
#: **측점 440.0** 이고, 그 측점은 구조물 이름표까지 달고 있다 — **없는 것이 아니다.**
#: 좁게 보면 「그 측점의 횡단 자체가 없습니다」라는 **거짓 사유**가 뜬다(반대 방향의 거짓).
#: 스냅 폭이 「정수 미터 반올림」이라 최대 어긋남이 0.5m 이고, 길이 찾기와 같은 값이 된다.
# ── 허용 오차 둘 — **묻는 것이 다르다.** 한 자리에 모아 둔다 ────────────────
# · `LENGTH_MATCH_TOLERANCE_M` — 「이 관의 **길이를 어디서 가져오나**」.
# 넉넉해도 된다. 값을 못 찾는 것보다 옆 측점 길이를 쓰는 편이 낫다.
# · `SECTION_MATCH_TOLERANCE_M` — 「이 측점에 **횡단 설계가 있나**」.
#
# ⚠⚠ **둘 다 0.5 다. 좁히지 말 것** — 앞서 「좁아야 한다」던 판단이 실측으로 뒤집혔다
# (2026-09-09 랩탑 메인). 측점을 만들 때 **정수 미터가 같은 격자 측점이 있으면 그리로
# 스냅**한다(횡단 파일명이 정수 미터라 두 측점이 한 파일을 덮어쓰는 것을 막는 가드).
# 그래서 관 440.241 의 횡단은 **측점 440.0** 이고 구조물 이름표까지 달고 있다 —
# **없는 것이 아니다.** 좁게 보면 「그 측점의 횡단 자체가 없습니다」라는 **거짓 사유**가
# 뜬다(반대 방향의 거짓). 스냅 폭이 「정수 미터 반올림」이라 최대 어긋남이 0.5m 이고,
# 그래서 길이 찾기와 **같은 값**이 된다 — 우연이 아니라 같은 까닭이다.
# ⚠ 값이 같아졌다고 **하나로 합치지 말 것.** 묻는 것이 둘이라 근거도 둘이고, 한쪽 근거가
# 바뀌면 한쪽만 움직여야 한다.
# ⚠ 두 값은 **증상**이지 병이 아니다. 병은 **관 자리에 측점이 없는 것**이고
# (측점을 만드는 자리는 B05 노선 [확정] 한 곳뿐 — 관을 나중에 놓거나 옮기면 안 생긴다,
# 계획서 3-14), 그것이 고쳐지면 이 값들이 **아무 관도 안 건드린다.**
LENGTH_MATCH_TOLERANCE_M = 0.5
SECTION_MATCH_TOLERANCE_M = 0.5
NOTE_KIND_DEFAULT = "관종을 안 정해 기본값({kind})으로 섰습니다 — 정하면 공종이 갈립니다"
@@ -77,10 +88,16 @@ def _length_by_chainage(designs: list[dict[str, Any]], key: str) -> dict[float,
return found
def _nearest(lengths: dict[float, float], chainage: float, tolerance: float = 0.5) -> float | None:
def _nearest(
lengths: dict[float, float],
chainage: float,
tolerance: float = LENGTH_MATCH_TOLERANCE_M,
) -> float | None:
"""관 측점과 단면 측점이 소수점에서 어긋날 수 있어 **가까운 것**을 본다.
⚠ 좁게 본다(기본 0.5m) — 넓히면 옆 측점의 길이를 물어 와 조용히 틀린다.
⚠ 「횡단이 있나」를 재는 `SECTION_MATCH_TOLERANCE_M`(0.5m)와 **다른 물음**이다 —
위 주석을 볼 것. 맞추면 둘 다 나빠진다.
"""
if not lengths:
return None
+301 -13
View File
@@ -98,12 +98,15 @@ def preparation_rows(
slope_totals: dict[str, float] | None = None,
slope_rows: Iterable[dict[str, Any]] = (),
topsoil_thickness_m: float | None = None,
topsoil_haul_distance_m: float | None = None,
stand_volume_class: str | None = None,
) -> list[dict[str, Any]]:
"""준비공 줄 — 값이 서는 것과 안 서는 것을 **한 목록에** 낸다."""
slope = slope_totals or {}
tree_area = float(slope.get("tree_removal_fill", 0.0)) + float(
slope.get("tree_removal_cut", 0.0)
)
topsoil = _topsoil_row(slope, topsoil_thickness_m)
return [
{
"group": "준비공",
@@ -114,22 +117,23 @@ def preparation_rows(
# ⚠ 값을 여기서 또 내면 같은 나무를 두 번 벤다. 참고로 면적만 보인다.
"reference_amount": tree_area,
"reason": (
# ⚠ **소단면이 이 면적에 들어 있다** — 갈라 내지 않는다(2026-09-09 세 창 확인).
# 별표2 타.(1) 이 「노출되는 절·성토면은 **전체면적**을 녹화」라 소단도 녹화
# 대상이고, 갈라 내라는 원문이 없으며 실무도 한 덩이로 센다. 가르면 「그 몫으로
# 무엇을 세나」를 우리가 정하게 되어 **근거 없는 줄**이 선다. 대신 여기 적어 둔다.
"대상 면적에 **소단면이 포함**됨(별표2 타.(1) 「노출되는 면은 전체면적 녹화」 — "
"원문이 가르지 않고 실무도 한 덩이로 셈). "
"토공집계의 「지장목제거」로 이미 섬 — 여기서 또 세우면 이중계상. "
"⚠ 다만 **공종 미확정** — 품셈 4장이 벌목을 목적별로 갈라(수확베기·단목베기·"
"위험목 베기) 임도 지장목이 어디에 붙는지 원본이 말하지 않음. 지금은 공종코드 없이 감."
),
"work_item_code": None,
},
_topsoil_row(slope, topsoil_thickness_m),
{
"group": "준비공",
"item": "제근·뿌리다듬기",
"unit": "",
"amount": None,
"status": STATUS_PENDING,
"reason": "단위가 「개」(그루 수)인데 입목 본수를 들고 있지 않음 (품셈 9-20~21)",
"work_item_code": "FP-09-21",
},
topsoil,
# ⚠ 법이 요구하는 **운반·적치** — 제거 물량이 곧 밑수다(별표2).
_topsoil_haul_row(topsoil, topsoil_haul_distance_m),
_root_removal_row(slope, stand_volume_class),
*_root_steps_rows(slope, stand_volume_class),
_batter_frame_row(list(slope_rows)),
_level_frame_row(list(slope_rows)),
]
@@ -169,6 +173,274 @@ def _topsoil_row(slope: dict[str, float], thickness_m: float | None) -> dict[str
}
#: 표토 운반·적치 — ⚠ **법이 요구하는데 우리가 제거만 세고 있던 자리**(2026-09-09).
#: 시행규칙 별표2 .2.차.(6)·Ⅰ.3.카.(6):
#: 「노면·절토대상지에 있는 입목…과 그 뿌리, **표토는 전량 제거한 후** 강우 시 유실되거나
#: 경관에 저해되지 않도록 **최고 홍수위보다 높은 장소로 운반하고 쌓아두어야 한다**」
#: ⇒ 제거(9-15)만 세면 **운반이 빠진다.** 물량은 제거 물량 그대로이고 **거리가 설계 입력**이다.
#: ⚠ 거리를 지어내지 않는다 — 비면 막고 사유를 낸다.
TOPSOIL_HAUL_LAW = (
"⚠ 법정 의무 — 시행규칙 별표2 「표토는 전량 제거한 후 … 최고 홍수위보다 높은 장소로"
" 운반하고 쌓아두어야 한다」. 제거만 세면 운반이 빠짐"
)
#: 제근 — ⚠ **본수가 아니라 임목축적으로 갈린다**(2026-09-09 원문 확인).
#: 품셈 9-21 [주]① 「소림 : 임목축적이 30㎥/㏊ 이상 60 미만 · 중림 : 60 이상 90 미만 ·
#: 밀림 : 90 이상」. ⇒ 물어야 할 값은 **본/ha 밀도가 아니라 축적 등급**이다.
#: ⚠⚠ **그런데 원문 표에 밑수 단위 열이 없다** — 9-20(뿌리다듬기·적재)은 「10주당」이라
#: 적혀 있는데 9-21 표에는 그 표기가 없다. 등급을 넣어도 **무엇당 값인지 모르면 못 센다.**
#: 지어내지 않고 그 사실을 사유로 낸다(원문 확인이 필요한 자리).
STAND_VOLUME_CLASSES = ("소림", "중림", "밀림")
#: ⭐ 2026-09-09 **사용자 확정 5차 6번** — 제근 밑수를 **면적 축**으로 확정.
#: 산림품셈 9-21 표에 밑수 표기가 없어, **건설공사 표준품셈 3-9-2 「1,000㎡당」**을 빌려 쓴다.
#: ⚠ **교차 참조 표시 필수**(CLAUDE.md 3장 — 다른 품셈을 빌려 쓸 때의 규칙).
#: ⇒ 이 확정으로 **제근·뿌리 적재·뿌리 운반·지장목제거 넷이 같은 면적 밑수**로 선다.
ROOT_REMOVAL_BASIS = (
"밑수는 **면적 축**(사용자 확정 5차 6번) — 산림품셈 9-21 에 밑수 표기가 없어"
" ⚠ **교차 참조**: 건설공사 표준품셈 **3-9-2 뿌리뽑기 「1,000㎡당」**을 빌려 씀"
)
ROOT_REMOVAL_CLASS_MISSING = (
"임목축적 등급이 아직 입력되지 않았습니다 — 품셈 9-21 [주]① 이 소림(30~60㎥/㏊)·"
"중림(60~90)·밀림(90 이상)으로 가름. ⚠ 본수가 아니라 **축적**이고, 산림조사부·영림계획에서"
" 옮겨 적는 값이라 프로그램이 만들 수 없음"
)
def _root_removal_row(slope: dict[str, float], stand_volume_class: str | None) -> dict[str, Any]:
"""제근·뿌리다듬기 — 등급과 밑수 단위가 다 서야 값이 난다.
⚠ 대상 면적은 지장목제거와 같은 자리(벌개제근 연동)라 이미 서 있다 — 참고로 보인다.
"""
area = float(slope.get("tree_removal_fill", 0.0)) + float(slope.get("tree_removal_cut", 0.0))
picked = str(stand_volume_class or "").strip()
reasons = [ROOT_REMOVAL_BASIS, "대상 면적은 지장목제거와 같은 자리(벌개제근 연동)"]
if picked in STAND_VOLUME_CLASSES:
reasons.append(f"임목축적 등급 「{picked}」 — 품셈 9-21 [주]① 이 품을 그 축으로 가름")
else:
reasons.append(ROOT_REMOVAL_CLASS_MISSING)
# ⚠ 등급은 **품(단가) 갈래**이지 물량 밑수가 아니다 — 면적이 서면 물량은 선다.
# 등급이 비면 값은 서되 **단가를 못 고른다**는 사실만 사유로 남는다.
return {
"group": "준비공",
"item": "제근·뿌리다듬기",
"unit": "",
"amount": area if area > 0 else None,
"status": STATUS_READY if area > 0 else STATUS_PENDING,
"reason": " · ".join(reasons),
"reference_amount": area,
"work_item_code": "FP-09-21",
}
#: ⚠⚠ **품셈이 네 단계로 두었는데 우리가 한 줄만 세고 있었다**(2026-09-09).
#: 9-20 가. 「벌개·제근 → **뿌리다듬기** → **적재** → **운반**」이 시공 과정 표준이고
#: [주] 「본 품에서는 제근 후 **뿌리다듬기와 적재항목**에 적용한다」로 두 항목을 준다.
#: 법도 같은 말을 한다 — 별표2 「입목…과 그 뿌리, 표토는 전량 제거한 후 … **운반하고
#: 쌓아두어야** 한다」. ⇒ 제거만 세면 **적재·운반이 빠진다**(표토에서 이미 겪은 자리).
#: ⚠ 적재는 9-20-2 로 코드가 있고, **운반은 그 장에 공종이 없다** — 10장 계열 어디에 붙는지
#: 원문이 말하지 않는다. 지어내지 않고 사유로 낸다.
ROOT_STEPS_NOTE = (
"품셈 9-20 가. 「벌개·제근 → 뿌리다듬기 → 적재 → 운반」 · 별표2 「그 뿌리…를 전량 제거한 후"
" 운반하고 쌓아두어야 한다」 — 제거만 세면 뒤 단계가 빠짐"
)
#: ⭐ 2026-09-09 **사용자 확정 5차 5번** — 근주이식·임목파쇄는 **기본 안 셈**.
#: ⚠ 다만 「**현장에 따라 파쇄가 적용될 필요 있음**」이라 **임목파쇄만 켤 수 있는 칸**을 둔다.
#: **기본은 꺼짐**이고, **근주이식은 칸도 안 만든다**(켤 자리가 없으면 물을 일도 없다).
#: ⚠ 켜도 **부피는 지어내지 않는다** — 실무는 부피(영월 78㎥ @56,124)로 세고, 그 부피를
#: 우리가 든 곳이 없다. 켜면 줄이 서고 **수량 칸이 비어 사유로 드러난다.**
CHIPPING_ITEM = "임목파쇄"
CHIPPING_CODE = "FP-08-11" # 이동식 임목 파쇄
CHIPPING_OFF_NOTE = (
"기본 안 셈(확정 5차 5번) — 현장에 따라 필요하면 산출 조건에서 켤 것."
" 근주이식(FP-14-02)은 칸도 두지 않음"
)
CHIPPING_ON_NOTE = (
"켜져 있음(확정 5차 5번) — ⚠ **파쇄할 부피(㎥)를 든 곳이 없어** 물량이 안 섬."
" 실무는 부피로 셈(영월 78㎥). 산출 조건에 부피를 넣으면 값이 섬"
)
def chipping_rows(enabled: Any, volume_m3: Any) -> list[dict[str, Any]]:
"""임목파쇄 — **켰을 때만** 줄이 선다. 끄면 줄 자체를 안 낸다.
⚠ 끈 상태에서 줄을 세우면 **빈 칸이 「세야 함」으로 읽힌다**(부대시설과 다른 자리 —
그쪽은 법정 의무라 줄이 늘 서고, 이쪽은 **셀지 말지가 설계 판단**이다).
"""
if not bool(enabled):
return []
try:
amount = float(volume_m3) if volume_m3 not in (None, "") else None
except (TypeError, ValueError):
amount = None
return [
{
"group": "준비공",
"item": CHIPPING_ITEM,
"unit": "",
"amount": amount,
"status": STATUS_READY if amount and amount > 0 else STATUS_PENDING,
"reason": CHIPPING_ON_NOTE if not amount else "산출 조건에서 넣은 부피 (확정 5차 5번)",
"work_item_code": CHIPPING_CODE,
}
]
def _root_steps_rows(
slope: dict[str, float], stand_volume_class: str | None
) -> list[dict[str, Any]]:
"""뿌리 적재·운반 — **제근과 한 벌로 가는 뒤 단계**. 제근이 서면 함께 선다."""
area = float(slope.get("tree_removal_fill", 0.0)) + float(slope.get("tree_removal_cut", 0.0))
return [
{
"group": "준비공",
"item": "뿌리 적재",
"unit": "",
"amount": area if area > 0 else None,
"status": STATUS_READY if area > 0 else STATUS_PENDING,
"reason": (
f"{ROOT_STEPS_NOTE} · {ROOT_REMOVAL_BASIS}"
" · ⚠ **품셈 9-20-2 는 「10주당」이라 밑수 축이 다름** — 면적 축으로 내고"
" 그 사실을 적음(본수가 서면 그 축으로 옮길 것)"
),
"reference_amount": area,
"work_item_code": "FP-09-20-02",
},
{
# ⭐ 2026-09-09 **확정 5차 4번** — 뿌리 운반은 **덤프**로 잡는다.
# ⚠ 사용자 관찰 「실제 운반품이 적용 안 되는 듯함. 나중엔 적용될 가능성도 있음」
# ⇒ 코드는 붙이되 **품이 안 붙으면 그대로 두고 사유**를 낸다. 억지로 안 붙인다.
# ⚠ 물량 축이 다르다 — 운반은 **부피(㎥)** 인데 우리가 든 것은 **면적**뿐이라
# ㎥ 를 지어내지 않고 면적을 참고로만 싣는다.
"group": "준비공",
"item": "뿌리 운반",
"unit": "",
"amount": None,
"status": STATUS_PENDING,
"reason": (
f"{ROOT_STEPS_NOTE} · 사용자 확정 5차 4번 「**덤프**로 잡음」 —"
" ⚠ 운반 밑수는 **부피(㎥)** 인데 뿌리 부피를 든 곳이 없어 물량이 안 섬"
" (대상 면적만 있음). ⚠ 실무에서 **운반품이 안 붙는 경우가 있음**(사용자 관찰) —"
" 안 붙으면 그대로 두고 이 사유가 남음"
),
"reference_amount": area,
"work_item_code": "FP-10-12",
},
]
def _topsoil_haul_row(topsoil: dict[str, Any], distance_m: float | None) -> dict[str, Any]:
"""표토 운반 — 물량은 제거 물량 그대로, 거리는 설계 입력.
⚠ 제거 줄이 안 서면(두께 미입력) 운반도 안 선다 — 밑수가 그 줄이기 때문이다.
"""
amount = topsoil.get("amount")
distance = None
if distance_m is not None:
try:
distance = float(distance_m)
except (TypeError, ValueError):
distance = None
if amount is None:
return {
"group": "준비공",
"item": "표토 운반·적치",
"unit": "",
"amount": None,
"status": STATUS_PENDING,
"reason": f"{TOPSOIL_HAUL_LAW} · 제거 물량이 아직 안 서서 운반도 못 셈(두께 먼저)",
"work_item_code": None,
}
if distance is None or distance <= 0:
return {
"group": "준비공",
"item": "표토 운반·적치",
"unit": "",
"amount": None,
"status": STATUS_PENDING,
"reason": (
f"{TOPSOIL_HAUL_LAW} · 운반거리가 아직 입력되지 않았습니다 — 「최고 홍수위보다"
f" 높은 장소」는 현장에서 정하는 자리라 품셈이 거리를 주지 않습니다"
f" (운반할 물량 {float(amount):,.2f}㎥)"
),
"reference_amount": float(amount),
"work_item_code": None,
}
return {
"group": "준비공",
"item": "표토 운반·적치",
"unit": "",
"amount": float(amount),
"status": STATUS_READY,
"reason": f"{TOPSOIL_HAUL_LAW} · 제거 물량 그대로 · 운반거리 {distance:g}m(설계 입력)",
"work_item_code": "FP-10-12",
"haul_distance_m": distance,
}
#: 규준틀 재료 — **세는 것은 확정이고 수량만 몰랐던 자리**(2026-09-09).
#: 품셈 11-2·11-3 [주]④ 「재료량은 **설계수량에 따른다**」 ⇒ 품셈이 값을 안 주는 것이지
#: 「안 센다」가 아니다. 그래서 **제안값을 보이고 사용자가 고치는** 모양으로 둔다
#: (확정 ⑨·⑩ 과 같은 틀 — 「가는 기본값이고 나 선택처럼 동작할 수 있어야 함」).
#: ⚠ **제안값은 실무 관측값이지 법정 기준이 아니다** — 울진 소광 원단위 라이브러리 §8
#: 「규준틀 수평 | 개소 | 각재 50×50 0.0044㎥ · 판재 T12 0.0029㎥ · 못 0.03㎏」.
#: ⚠ **비탈 규준틀 값은 그 시트에 없다** — 수평 값을 준용하고 그 사실을 사유에 적는다.
#: ⚠ **손율은 원문에 있다** — 품셈 11-2 [주]③ 비탈 **50%** · 11-3 [주]③ 수평 **80%**.
FRAME_MATERIAL_SUGGESTED = {
"각재 50×50": (0.0044, ""),
"판재 T12": (0.0029, ""),
"": (0.03, ""),
}
FRAME_MATERIAL_SOURCE = (
"⚠ 실무 관측값(울진 소광 원단위 라이브러리 §8 규준틀 수평) — **법정 기준 아님**."
" 품셈 11-2·11-3 [주]④ 는 「재료량은 설계수량에 따른다」로만 둠. 산출 조건에서 고칠 수 있음"
)
FRAME_LOSS_RATE = {"비탈 규준틀": 50, "수평 규준틀": 80}
def frame_material_rows(
frame_rows: list[dict[str, Any]], overrides: dict[str, Any] | None = None
) -> list[dict[str, Any]]:
"""규준틀 재료 — 개소 × 개소당 수량. **자재 축으로 보낸다.**
⚠ 개소가 안 서면 재료도 안 선다(밑수가 그 줄이다).
⚠ 값은 **제안값**이고 산출 조건에서 덮어쓸 수 있다 — 그 사실이 사유에 적힌다.
"""
given = overrides or {}
rows: list[dict[str, Any]] = []
for frame in frame_rows:
count = frame.get("amount")
if not count:
continue
loss = FRAME_LOSS_RATE.get(str(frame.get("item")), None)
for name, (default, unit) in FRAME_MATERIAL_SUGGESTED.items():
raw = given.get(name)
try:
per_ea = (
float(raw) if raw is not None and str(raw).strip() != "" else float(default)
)
except (TypeError, ValueError):
per_ea = float(default)
picked = "산출 조건에서 고른 값" if raw not in (None, "") else "제안값(기본)"
rows.append(
{
"name": name,
"unit": unit,
"amount": float(count) * per_ea,
"destination": "material",
"source": str(frame.get("item") or "규준틀"),
"basis": (
f"{frame.get('item')} {float(count):g}개소 × {per_ea:g}{unit}/개소"
f" ({picked}) · {FRAME_MATERIAL_SOURCE}"
+ (f" · 손율 {loss}%(품셈 [주]③)" if loss else "")
),
}
)
return rows
def _batter_frame_row(slope_rows: list[dict[str, Any]]) -> dict[str, Any]:
"""비탈 규준틀 한 줄. **개소는 원문 기준으로 서고 재료는 미확보**다."""
count, notes = batter_frame_count(slope_rows)
@@ -178,7 +450,11 @@ def _batter_frame_row(slope_rows: list[dict[str, Any]]) -> dict[str, Any]:
"unit": "개소",
"amount": float(count) if count else None,
"status": STATUS_READY if count else STATUS_PENDING,
"reason": ("; ".join(notes) + " · 재료량은 품셈 11-2 [주]④ 「설계수량에 따른다」라 미확보"),
"reason": (
"; ".join(notes)
+ " · 재료량은 품셈 11-2 [주]④ 「설계수량에 따른다」 —"
+ " **제안값(실무 관측)으로 서고 산출 조건에서 고칠 수 있음**"
),
"work_item_code": "FP-11-02",
}
@@ -194,7 +470,8 @@ def _level_frame_row(slope_rows: list[dict[str, Any]]) -> dict[str, Any]:
"status": STATUS_READY if count is not None else STATUS_PENDING,
"reason": "; ".join(notes)
+ (
" · 재료량은 품셈 11-3 [주]④ 「설계수량에 따른다」라 미확보"
" · 재료량은 품셈 11-3 [주]④ 「설계수량에 따른다」"
" **제안값(실무 관측)으로 서고 산출 조건에서 고칠 수 있음**"
if count is not None
else ""
),
@@ -351,11 +628,22 @@ def build_table(
topsoil_thickness_m: float | None = None,
names: dict[str, str] | None = None,
ancillary_counts: dict[str, Any] | None = None,
topsoil_haul_distance_m: float | None = None,
stand_volume_class: str | None = None,
chipping_enabled: Any = False,
chipping_volume_m3: Any = None,
) -> dict[str, Any]:
"""화면·인계가 그대로 쓰는 모양. **못 서는 줄도 목록에 남는다.**"""
rows = (
preparation_rows(slope_totals, slope_rows, topsoil_thickness_m)
preparation_rows(
slope_totals,
slope_rows,
topsoil_thickness_m,
topsoil_haul_distance_m,
stand_volume_class,
)
+ erosion_rows(structures, names)
+ chipping_rows(chipping_enabled, chipping_volume_m3)
+ ancillary_rows(ancillary_counts)
)
return {
+238 -39
View File
@@ -86,6 +86,91 @@ STONE_WEIGHT_BASIS = (
" 품셈·교본에는 돌중량표가 없음"
)
#: ⭐ 사용자 확정 5차 큰 것 7 — **돌 무게는 계산식**: `뒷길이 × 0.77 × 2.65`.
#: 정본 여섯 탭이 모두 그 식을 그대로 적어 둔다(「2.09 × 0.45 × 0.77 × 2.65 ton/m3」).
#: ⚠ **야면석은 그 식이 안 맞는다** — 공극이 커 ㎥당 무게가 달라 원문이 값을 직접 준다
#: (35→0.575 · 45→0.880 · 55→1.100 ton/㎡). 그래서 **종류로 갈래를 둔다.**
#: ⚠ **잠정이다** — 확정 문구가 「나중에 실무자 협의 후 명확히」이므로 화면 근거에 적는다.
STONE_PACKING_RATIO = 0.77 # 채움률 — 돌 사이 공극을 뺀 몫
STONE_DENSITY_TON_M3 = 2.65 # 석재 비중
#: 계산식이 안 맞아 **관측표**를 쓰는 종류. 안 고른 경우도 이쪽(지금까지 쓰던 값이 그것).
STONE_WEIGHT_OBSERVED_KINDS = frozenset({"야면석·호박돌", "야면석"})
STONE_WEIGHT_FORMULA_BASIS = (
"⭐ 확정 5차 — 뒷길이 × 0.77(채움률) × 2.65(비중). ⚠ 잠정 — 실무자 협의 후 명확히 할 값"
)
#: 물구멍 칸 — 2026-09-09 에 등록부에 생겼다(랩탑 메인 `0b0763ed`). **빈 칸이 기본**이다.
#: ⚠ 기본 Ø50 은 **국가기준**이고 2㎡당 1개소는 실무 관측값이다(법은 「2~3㎡당 1개소 이상」).
WEEP_HOLE_DIAMETER_KEY = "weep_hole_diameter_mm"
WEEP_HOLE_AREA_KEY = "weep_hole_area_m2"
WEEP_HOLE_DIAMETER_DEFAULT_MM = 50
def weep_hole_spec(options: dict[str, Any]) -> tuple[float, int, str]:
"""(개소당 벽면적 ㎡, 관 지름 ㎜, 근거). 안 정하면 기본으로 서고 그 사실이 근거에 남는다."""
area = _num(options.get(WEEP_HOLE_AREA_KEY), 0.0)
diameter = _num(options.get(WEEP_HOLE_DIAMETER_KEY), 0.0)
area_given = area > 0
dia_given = diameter > 0
if not area_given:
area = STONE_MASONRY["weep_hole_area_m2"]
if not dia_given:
diameter = WEEP_HOLE_DIAMETER_DEFAULT_MM
basis = (
f"{area:g}㎡당 1개소 · Ø{int(diameter)}"
+ ("" if area_given else " · ⚠ 면적 안 정함 — 실무 관측 2㎡(법은 2~3㎡당 1개소 이상)")
+ ("" if dia_given else " · ⚠ 지름 안 정함 — 국가기준 Ø50")
)
return area, int(diameter), basis
def stone_weight_per_m2(
back_cm: int, kind: str, observed: float | None
) -> tuple[str, float | None, str, str]:
"""(줄 이름, ton/㎡, 근거, source) — ⭐ 확정 5차 큰 것 7.
**계산식이 기본**이고 야면석만 관측표다. 돌쌓기·골막이가 같은 규칙을 쓰도록 여기 한 벌만 둔다.
"""
if kind in STONE_WEIGHT_OBSERVED_KINDS:
basis = (
f"× {observed} ton/㎡ (뒷길이 {back_cm}㎝) · {STONE_WEIGHT_BASIS}"
if observed is not None
else ""
)
return kind, observed, basis, STONE_WEIGHT_SOURCE
ton = back_cm / 100.0 * STONE_PACKING_RATIO * STONE_DENSITY_TON_M3
basis = (
f"× {ton:.3f} ton/㎡ = 뒷길이 {back_cm / 100.0:g} × {STONE_PACKING_RATIO}"
f" × {STONE_DENSITY_TON_M3}"
+ (f" · {kind}" if kind else " · ⚠ 돌 종류를 안 골랐음")
+ f" · {STONE_WEIGHT_FORMULA_BASIS}"
)
return kind or "", ton, basis, ""
#: 뒷채움(막자갈) 폭 — ⭐ 사용자 확정 5차 작은 것 3 「막자갈 = (상 + 하) ÷ 2 × H」.
#: ⚠⚠ **여기 상·하는 벽 두께가 아니라 뒷채움 폭이다.** 벽 두께(0.75/0.9)를 끌어다 쓰면
#: H=2.0 에서 실무 0.75 가 1.66(입적)으로 튄다 — 옛 식이 곧 그 자리였다.
#: 정본 `04.구조도(기슭막이).xls` **여섯 탭 전부** 상 0.30 · 하 0.45 로 같다
#: (H=1.0~3.0 · 메/찰 · 기초유/버림 전수 확인, 2026-09-09).
#: ⓘ 소광리는 같은 식에 **0.30 / 0.60** 을 쓴다(그래서 H=2.0 에서 0.900). 문서마다 폭이
#: 다르므로 **값이 적힌 구조물은 그 값**을 쓰고, 없으면 이 정본 값으로 서되 그 사실을 알린다.
STONE_BACKFILL_WIDTH_M = {"top": 0.30, "bottom": 0.45}
#: ⚠⚠ 정본 전수 확인이 **H=1.0~3.0** 까지다 — 그보다 높은 벽은 **근거가 없다.**
#: 그리고 그 자리에서 품셈 13-4-4 [주]⑨ <뒤채움 조약돌 두께> 범위와 **갈린다**:
#: H 3.0 두께 0.359 (품셈 0.33~0.58 안) · H 3.5 0.354 (품셈 0.40~0.70 **아래**)
#: 까닭은 이 식의 두께(= 막자갈 ÷ 비탈면적)가 `0.375 ÷ √(1+경사²)` 로 거의 붙박이인데,
#: 품셈 표는 직고와 함께 두꺼워지기 때문이다 — **방향이 반대다.**
#: ⚠ 교본 7-3 은 「찰 3.0m 이하 · 메 2.0m 이하」로 두지만 **코드가 높이를 막지 않고**
#: 표준경사표(13-4-4 [주]⑪)는 직고 7m 까지 칸을 준다 — 즉 **높은 벽이 실제로 설 수 있다.**
#: 값을 눅이거나 막지 않고 **그 사실을 사유로 드러낸다**(임의 확정 금지).
STONE_BACKFILL_VERIFIED_HEIGHT_M = 3.0
#: 석적 — ⭐ 확정 5차 작은 것 4. **정본에는 없고 소광리에만 있는 줄**이라 사유에 적는다.
#: 소광리 식 `H × ℓ3 × 0.77`(면적 × 뒷길이 × 채움률) 그대로.
STONE_PILE_BASIS = "⚠ 정본에 없는 줄 — 소광리 시트에만 있음(정면적 × 뒷길이 × 0.77)"
#: 돌 종류별 계수표 — 품셈 13-4-3·13-4-4 [주]① · 교본 7-3.
#: ⚠ 지금까지 **건설품셈 참고자료 한 벌**(돌 종류로 안 갈리는 표)로만 돌고 있었다.
@@ -170,6 +255,17 @@ def stone_coefficients(options: dict[str, Any], back_cm: int) -> tuple[dict[str,
# 돌쌓기 전개식의 상수 — 실무 수식에 박혀 있던 값을 뺀 것.
#: ⚠⚠ **기슭막이 계열(돌쌓기 찰·메) 전용이다. 다른 구조물로 넓히지 말 것**(2026-09-09).
#: 골막이는 **더하는 몫이 다르다** — 소광리 숨김탭 「골막이(찰)(치수조서연결)」 C26:
#: 골막이 평균두께 = {(3 + 0.1×H) + (3 + 0.4×H)} ÷ 2
#: 기슭막이 상부 = ℓ3 + 0.30 · 하부 = 상부 + 0.30(H 1)
#: ⚠⚠ **둘 다 뒷길이(ℓ3) 기반이다** — C26 의 「0.45」는 상수가 아니라 `P6/100`(뒷길이 45㎝)을
#: 읽은 값이었다(2026-09-09 랩탑 보조가 셀 참조로 확인). 앞서 여기 「0.45 + 0.1H」로 적어
#: **상수처럼 보이게** 두었는데, 그렇게 굳으면 **뒷길이를 바꿔도 골막이 값이 안 움직이는**
#: 결함이 조용히 남는다.
#: ⚠ 상수만 보고 골막이에 가져다 쓰면 **값이 나오므로 아무 시험도 안 잡는다.**
#: ⚠ 골막이는 그 밖에도 셋이 더 다르다 — 밑수가 **돌쌓기 + 돌붙임**(기슭막이는 돌쌓기만) ·
#: 단위가 **개소당**(기슭막이는 m당) · 정면적이 **사다리꼴**.
STONE_MASONRY = {
# ⚠ **곱하는 값이 아니라 검산 참고값이다** (2026-09-08 ㉘ 에서 고침).
# 실무 시트의 「돌쌓기 = 정면적 × 1.04」에서 그 1.04 가 **곧 기울기 몫**이다
@@ -304,8 +400,16 @@ DESTINATION = {
# ⚠ **보여 주기만 하는 줄** — 자재도 토공도 일위대가도 아니다. 정본 계산표 좌측 열에
# 「입적」으로 실려 있어 그 이름 그대로 낸다(2026-09-08 확정 ⑦). 값은 이미 식 안에
# 있던 것을 줄로 꺼낸 것뿐이고, `material`·`earthwork` 어디에도 안 섞인다.
# ⚠ 「석적」은 정본에 없다(소광리 시트에만) — **안 낸다.**
# ⚠ 「석적」은 정본에 없소광리 시트에만 있으나 **확정 5차로 세운다** — 같은
# 보여 주기 줄이고, 「소광리에만 있는 줄」임을 사유에 적는다.
"입적": "reference",
"석적": "reference",
# 돌 종류를 고르면 그 이름으로 줄이 선다 — 자재총괄이 이름으로 찾으므로 넷 다 둔다.
"야면석·호박돌": "material",
"깬잡석": "material",
"견치돌": "material",
# 종류를 안 고른 경우의 이름 — 정본 계산표 줄 이름 그대로다(「돌 ℓ3=45cm」).
"": "material",
# 기초잡석(품셈 12-25) — 운반·부설·다짐 품이 붙는 **공종**이라 일위대가로 간다.
# 자재총괄로 보내면 같은 잡석이 재료로 한 번 더 선다.
RUBBLE_BASE_NAME: "unit_price",
@@ -746,12 +850,9 @@ def stone_masonry(
for key in ("wedge_stone_m3_per_m2", "fill_concrete_m3_per_m2"):
if picked.get(key) is not None:
table[key] = picked[key]
#: ⚠ **표는 「뒤채움 몫」, 우리 식은 「빼는 몫」** — 뜻이 반대라 1 에서 뺀다.
#: 교본은 「뒤채움 = 뒷길이 × (깬돌·잡석 1/2, 야면석 1/3)」이고, 우리 식이 입적에서
#: 빼는 것은 **돌 몸통**이라 `1 − 뒤채움몫` 이다. 종전 2/3 이 곧 야면석(1 − 1/3)이었다.
#: ⚠ 그대로 넣었더니 미지정 값이 15.130 → 19.045 로 바뀌었다(만들다 잡음).
backfill_share = picked.get("backfill_ratio")
body_ratio = 1.0 - float(backfill_share) if backfill_share is not None else 2.0 / 3.0
# ⚠ **교본의 「뒤채움 몫」(깬돌·잡석 1/2 · 야면석 1/3)은 이제 막자갈 밑수가 아니다** —
# ⭐ 확정 5차 작은 것 3 이 막자갈을 **뒷채움 폭 사다리꼴**로 못 박았다. 표는 그대로
# 두되(다른 자리에서 쓸 수 있다) 여기서는 안 쓴다.
kind_label = str(picked.get("kind") or "")
# ⚠ `face_slope_ratio` 는 **2026-09-09 에 칸이 생겼다**(돌쌓기 계열 여섯 종류 · 표준도
# 제원 폼). 빈 값이 「자동」의 뜻이라 비어 있으면 아래 표준경사표가 돌고, 채우면 그 값이
@@ -770,7 +871,8 @@ def stone_masonry(
# 실무 시트의 「정면적 × 1.04」가 바로 이 값이다(1:0.3 에서 1.0440 ≈ 1.04).
masonry_area = face_area * math.hypot(1.0, slope_ratio)
# 벽 두께 — 실무 구조물도 식(확정 2차 ②). **뒷길이가 들어간다.**
top_thickness = back_cm / 100.0 + constants["thickness_top_add_m"]
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
)
@@ -820,37 +922,74 @@ def stone_masonry(
)
)
stone_ton = table["stone_ton_per_m2"]
# 석적 — ⭐ 확정 5차 작은 것 4. 「체적」은 위 **입적** 줄이 이미 그것이고, 이쪽만 새 줄이다.
stone_pile = face_area * back_m * STONE_PACKING_RATIO
components.append(
Component(
"석적",
"",
stone_pile,
DESTINATION["석적"],
f"정면적 × 뒷길이 {back_m:g}m × {STONE_PACKING_RATIO}(채움률) · {STONE_PILE_BASIS}",
)
)
# 돌 무게 — ⭐ 확정 5차 큰 것 7. **계산식이 기본**이고 야면석만 관측표다(헬퍼 한 벌).
# ⚠ 안 고른 경우도 **계산식**으로 선다 — 정본 여섯 탭이 종류를 안 적고 그 식을 쓰고,
# 그래야 정본 H=2.0 의 「1.92 톤」과 맞는다(관측표 0.88 로 서면 1.84 로 4 % 낮다).
stone_name, stone_ton, weight_tail, weight_source = stone_weight_per_m2(
back_cm, kind_label, table["stone_ton_per_m2"]
)
weight_basis = f"돌쌓기 {weight_tail}" if weight_tail else ""
if not kind_label:
notes.append(
"돌 종류를 안 골라 **계산식**(뒷길이 × 0.77 × 2.65)으로 섰습니다 — "
"야면석이면 계산식이 안 맞아 관측표로 갈립니다"
)
if stone_ton is None:
# 원본 표가 비어 있는 칸이다 — 지어내지 않고 알린다(PLAN 8-8 ㉮).
notes.append(f"뒷길이 {back_cm}㎝ 의 돌중량이 원본 표에 없어 야면석을 내지 못함")
notes.append(f"뒷길이 {back_cm}㎝ 의 돌중량이 원본 표에 없어 {stone_name}을 내지 못함")
else:
components.append(
Component(
"야면석",
stone_name,
"ton",
masonry_area * stone_ton,
DESTINATION["야면석"],
f"돌쌓기 × {stone_ton} ton/㎡ (뒷길이 {back_cm}㎝) · {STONE_WEIGHT_BASIS}",
source=STONE_WEIGHT_SOURCE,
DESTINATION.get(stone_name, "material"),
weight_basis,
source=weight_source,
)
)
# 막자갈 = 입적 − (면적 × 뒷길이 × 뒤채움몫 + 고임돌).
# 뒤채움 몫은 **돌 종류로 갈린다** — 깬돌·잡석 1/2 · 야면석 1/3 (교본 7-3).
wedge = masonry_area * _num(table["wedge_stone_m3_per_m2"]) # None 이면 0 — 막자갈에서 안 뺌
stone_body = masonry_area * (back_cm / 100.0) * body_ratio # 면석 몸통 체적
rubble = volume - (stone_body + wedge)
if rubble > 0:
components.append(
Component(
"막자갈",
"",
rubble,
DESTINATION["막자갈"],
f"입적 (면적×뒷길이×{body_ratio:.4g} + 고임돌) · {thickness_basis}"
+ (f" · {kind_label}" if kind_label else ""),
)
# 막자갈(뒷채움) — ⭐ 확정 5차 작은 것 3 「(상 + 하) ÷ 2 × H」. **뒷채움 폭**이지 벽 두께가
# 아니다. 옛 식(입적 − 몸통 − 고임돌)에서 갈아탄 자리다.
backfill_top = STONE_BACKFILL_WIDTH_M["top"]
backfill_bottom = STONE_BACKFILL_WIDTH_M["bottom"]
rubble = (backfill_top + backfill_bottom) / 2.0 * height_m * length_m
components.append(
Component(
"막자갈",
"",
rubble,
DESTINATION["막자갈"],
f"(뒷채움 상 {backfill_top:g} + 하 {backfill_bottom:g}) ÷ 2 × H {height_m:g}m"
f" × 연장 {length_m:g}m · 정본 여섯 탭 공통값",
)
)
notes.append(
f"막자갈 뒷채움 폭이 정본(`04.구조도(기슭막이).xls`) 값 상 {backfill_top:g} · "
f"{backfill_bottom:g}m 붙박이입니다 — 구조물 제원에 뒷채움 폭 칸이 없습니다"
"(소광리는 같은 식에 0.30/0.60 을 씁니다)"
)
# ⚠ 근거 구간 밖은 **값이 서되 그 사실이 보여야 한다** — 값은 계속 나오므로 사유가
# 없으면 아무도 못 본다(2026-09-09 그물 침).
if height_m > STONE_BACKFILL_VERIFIED_HEIGHT_M:
notes.append(
f"직고 {height_m:g}m 는 뒷채움 폭 정본 확인 구간"
f"(H {STONE_BACKFILL_VERIFIED_HEIGHT_M:g}m 이하) **밖**입니다 — 붙박이 폭이라 "
"직고가 커져도 두께가 안 늘어 품셈 13-4-4 [주]⑨ 뒤채움 조약돌 두께 범위보다 "
"얇아집니다(그 표는 직고와 함께 두꺼워짐). 값은 정본 식대로 세우되 "
"**높은 벽의 뒷채움 폭은 실무자 확인이 필요합니다**"
)
if wet:
@@ -861,8 +1000,12 @@ def stone_masonry(
"",
masonry_area * _num(table["fill_concrete_m3_per_m2"]),
DESTINATION["채움콘크리트"],
# ⚠ 돌 종류로 계수가 갈리는데(깬돌 0.2 · 야면석 0.15) 근거에 종류가 안
# 적히던 자리다 — 값은 바뀌는데 **왜 바뀌었는지가 안 보였다**(고임돌은
# 적고 있었다). 2026-09-09 맞춤.
f"돌쌓기 × {table['fill_concrete_m3_per_m2']} ㎥/㎡ (뒷길이 {back_cm}㎝)"
f" · {mpa_basis}",
+ (f" · {kind_label}" if kind_label else "")
+ f" · {mpa_basis}",
spec=f"{mpa}",
)
)
@@ -887,22 +1030,29 @@ def stone_masonry(
# 지금 쓰는 13-4 계열에는 제잡비 행 자체가 없어 겹치지 않는다(전수 확인).
# ⚠ 관종·지름은 미확정 — 법은 「지름 3~6㎝ 파이프」, 실무 관측은 Ø50. 규격이 정해지면
# 이름에 붙인다(`물구멍 Ø50`). 지어내지 않고 규격 없는 이름으로 둔다.
# ⚠ 「미확정」만 적으면 사용자가 무엇을 정해야 하는지 모른다 —
# **지금 무슨 값으로 돌고 있는지**를 함께 적는다(원단위 미확보와 같은 방식).
# ⓘ 정본은 개소당 관 길이를 **평균두께**로 잡는다(H=2.0 에서 0.83m). 우리는 상수 0.5m 라
# 그만큼 짧게 선다 — 관은 벽을 가로지르므로 정본 쪽이 이치에 맞는다(미결).
hole_area, hole_dia, hole_basis = weep_hole_spec(options)
components.append(
Component(
"물구멍관",
"m",
masonry_area / constants["weep_hole_area_m2"] * constants["weep_hole_length_m"],
masonry_area / hole_area * constants["weep_hole_length_m"],
DESTINATION["물구멍관"],
# ⚠ 「미확정」만 적으면 사용자가 무엇을 정해야 하는지 모른다 —
# **지금 무슨 값으로 돌고 있는지**를 함께 적는다(원단위 미확보와 같은 방식).
"돌쌓기 ÷ 2㎡/개소 × 0.5 m/개소 · ⚠ 잠정: 관 Ø 미정(법 3~6㎝ / 실무 Ø50) ·"
" 간격 2.0㎡당 1개소(법 2~3㎡당 1개소 이상)",
f"돌쌓기 ÷ {hole_area:g}㎡/개소 × {constants['weep_hole_length_m']:g} m/개소"
f" · {hole_basis}",
spec=f"Ø{hole_dia}",
)
)
# 채집석 — **캐서 쓰는 구조물**의 돌 체적. 사토에서 뺄 밑수이고 **여기서 빼지 않는다.**
# ⚠ 밑수가 확정 5차로 바뀌었다 — 면석 몸통은 이제 **석적**(정면적×뒷길이×0.77)이고
# 막자갈은 **뒷채움 사다리꼴**이다. 옛 「입적 − 몸통 − 고임돌」 몫이 아니다.
if is_collected_stone(options):
collected = max(stone_body + wedge + max(rubble, 0.0), 0.0)
wedge = masonry_area * _num(table["wedge_stone_m3_per_m2"]) # None 이면 0
collected = max(stone_pile + wedge + rubble, 0.0)
if collected > 0:
components.append(
Component(
@@ -910,9 +1060,8 @@ def stone_masonry(
"",
collected,
DESTINATION["채집석"],
f"면석 몸통 {stone_body:.3f} + 고임돌 {wedge:.3f} + 막자갈"
f" {max(rubble, 0.0):.3f}· 현장 채집분 ·"
" ⚠ 여기서 빼지 않음 — 사토에서 한 번만 뺌",
f"석적 {stone_pile:.3f} + 고임돌 {wedge:.3f} + 막자갈 {rubble:.3f}"
" · 현장 채집분 · ⚠ 여기서 빼지 않음 — 사토에서 한 번만 뺌",
)
)
@@ -954,6 +1103,20 @@ EXPANDERS = {
"masonry_dry": lambda h, l, o, f=None, r="": stone_masonry(h, l, o, False, f, r),
# 큰돌쌓기는 표준경사 표 대상이 아니라 성절토를 안 쓴다(교본 「1:0.3 이상」).
"boulder_masonry": lambda h, l, o, f=None, r="": boulder_masonry(h, l, o),
# 기슭막이는 **형태가 돌쌓기면 돌쌓기 식**이다 — 실무 정본 탭 제목이 「돌기슭막이(…찰쌓기…)」
# 이고 그 안의 계산이 같다. 갈래·사유는 `_UnitQuantity_Revetment` 가 든다(늦게 부른다 —
# 그쪽이 이 모듈의 `stone_masonry` 를 쓰므로 위에서 부르면 맞물린다).
"revetment": lambda h, l, o, f=None, r="": _revetment()(h, l, o, f, r),
# 골막이는 **개소당**이고 정면적이 사다리꼴이라 상장·하장·높이를 본다
# (정본 「골막이(찰)(치수조서연결)」). 돌쌓기 식을 빌려 쓰지 않는다 — 까닭은 그 모듈에 적었다.
"erosion_check": lambda h, l, o, f=None, r="": _erosion_check()(h, o),
# 개거는 **m당** 원단위라 연장이 밑수다(정본 「개거(150-200)」·「L형수로-(201)」).
"open_ditch": lambda h, l, o, f=None, r="": _open_ditch()(l, o),
# 흙막이는 **「떼」만 섬**(정본 「떼흙막이」 개소당). 나머지 일곱 형식은 원단위 미확보.
# ⚠ 확정 4차 — 그림·옵션은 기슭막이와 한 벌이되 **수량 데이터는 분리**.
"soil_guard": lambda h, l, o, f=None, r="": _soil_guard()(o),
# 바닥막이는 **돌붙임 ㎡당**이라 높이·연장이 아니라 **면적**이 밑수다(정본 「돌붙임L3=…」).
"bed_sill": lambda h, l, o, f=None, r="": _bed_sill()(_num(o.get("area_m2"), 0.0), o),
# ⚠⚠ **큰돌쌓기(`boulder_masonry`)를 여기에 두지 않는다** (2026-09-07 발견).
# 큰돌쌓기는 품셈 **13-6** 이고 돌쌓기는 **13-4** 다 — **규격 축이 다르다.**
# 돌쌓기는 **뒷길이**(35·45·55·60㎝), 큰돌쌓기는 **직경**(40~60·60~80·80~100㎝).
@@ -963,6 +1126,42 @@ EXPANDERS = {
# 전개식·관측 원단위가 설 때까지 **미확보로 드러낸다.**
}
def _soil_guard():
"""흙막이 사유 함수를 늦게 가져온다."""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import soil_guard
return soil_guard
def _open_ditch():
"""개거 전개 함수를 늦게 가져온다."""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import open_ditch
return open_ditch
def _bed_sill():
"""바닥막이 전개 함수를 늦게 가져온다."""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import bed_sill
return bed_sill
def _erosion_check():
"""골막이·바닥막이 사유 함수 — 늦게 가져온다(서로 부르는 것을 푸는 자리)."""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import erosion_check_dam
return erosion_check_dam
def _revetment():
"""기슭막이 전개 함수를 늦게 가져온다 — 서로 부르는 것을 풀기 위한 자리."""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import revetment
return revetment
#: 전개식을 일부러 안 두는 종류 — 왜 안 두는지 사람이 읽게 적는다.
EXPANDER_WITHHELD: dict[str, str] = {}
@@ -0,0 +1,593 @@
"""기슭막이 전개 — **형태로 갈라 돌쌓기 식을 그대로 쓴다**(3단계, 2026-09-09).
실무 정본이 근거다 `구조물도/기슭막이/04.구조도(기슭막이).xls` 제목이
**돌기슭막이(H=2.0m, 찰쌓기, 기초유)** 이고, 안의 계산이 돌쌓기와 **같은 **이다.
정면적 비탈면적(×(1+)) 평균두께 입적 · 막자갈 · 고임돌
터파기(기초 + 비탈) 되메우기 잔토
**기슭막이는 식이 아니라 형태가 돌쌓기면 돌쌓기 **이다. 여기서 식을 다시 짜면
같은 계산이 벌이 되어 돌쌓기와 갈린다(CLAUDE.md 5).
**형태가 돌쌓기가 아닌 갈래는 식이 없다** 콘크리트·돌망태·통나무/목재틀·바자.
지어내지 않고 ** 없는지** 줄로 남긴다.
**뒷길이· 종류 칸이 기슭막이 등록부에 아직 없다**(2026-09-09 실측). 돌쌓기 식은 둘로
계수가 갈리는데, 없으면 `_back_length` **조용히 45 돈다**. 값이 나오므로 아무도
알아채는 자리라 ** 사실을 줄로 드러낸다** 값이 있기는 하니 보이는 그것.
"""
from __future__ import annotations
import math
from decimal import ROUND_HALF_UP, Decimal
from typing import Any
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import Component, _num, stone_masonry
#: 형태 → 돌쌓기 갈래(찰이면 True). 여기 없는 형태는 전개식이 없다.
STONE_FORMS: dict[str, bool] = {
"돌쌓기(찰)": True,
"돌쌓기(메)": False,
}
#: 식이 없는 형태와 **왜 없는지**. 품셈 장이 다르거나 원단위가 원문에 없다.
WITHHELD_FORMS: dict[str, str] = {
"콘크리트": (
"콘크리트 기슭막이는 돌쌓기(품셈 13-4)가 아니라 콘크리트 구조물이라 전개식이 다름 "
"— 벽 두께·저판이 정본에 없어 물량이 서지 않음"
),
"돌망태": ("돌망태는 품셈 13-8 이고 규격 축이 망태 치수라 돌쌓기 표를 못 씀 — 원단위 미확보"),
"통나무·목재틀": (
"목재틀은 품셈 13-13 이고 밑수가 ㎥당 목공 품이라 돌쌓기 표를 못 씀 "
"— 각재·판재 자재가 카탈로그에 없어 값이 모자람"
),
"바자": "바자얽기(품셈 5-15)는 별도 일위대가를 만들어 잇기로 확정(2026-09-09 ⑧-4)",
}
#: 돌붙임 원단위 — 소광리 정본 「돌붙임L3=45(사방)」(찰) · 「돌붙임L3=30(야면석메붙임)」(메).
#: ⚠⚠ **돌쌓기 표(13-4)와 합치지 말 것.** 값이 겹쳐 보여 합치고 싶어지는 자리다:
#:
#: 돌쌓기(13-4) 밑수 **비탈면적** · 뒷길이 **일곱 규격** · **기울기 몫 √(1+n²)**
#: 돌붙임(13-5) 밑수 **평면적** · 뒷길이 **넷** · **기울기 몫 없음**
#: 메는 모르타르·채움콘크리트가 통째로 빠진다
#:
#: ⚠ 45㎝ 에서 고임돌 0.15 · 채움 0.20 이 **우연히 같다** — 그것이 합치고 싶어지는 까닭이고,
#: 합치면 뒷길이 25·30·60·75 에서 조용히 틀린다(돌붙임 표에 없는 규격이다).
#:
#: ⚠ **뒷길이는 지금 붙박이다** — 찰 0.45m · 메 0.30m. 정본 탭이 그 둘로만 있고 바닥막이
#: 등록부에 뒷길이 칸이 없다. 칸이 생기면 이 표를 열면 된다.
#: ⓘ 돌중량은 **관측값이 아니라 유도값**이다 — `뒷길이 × 0.77(채움률) × 2.65(비중)`.
#: 찰 0.45 × 0.77 × 2.65 = 0.918 ton/㎡. 메(야면석)는 0.42 로 **원문에 값이 직접 적혀 있어**
#: 같은 유도식이 안 맞는다(야면석은 공극이 커 ㎥당 무게가 다르다) — 그대로 옮긴다.
BED_SILL_FORMS: dict[str, dict[str, Any]] = {
"돌붙임(찰)": {
"back_len_m": 0.45,
"stone_name": "",
"stone_spec": "30×30×45㎝",
"stone_ton_per_m2": 0.45 * 0.77 * 2.65,
"wedge_stone_m3_per_m2": 0.15,
"mortar_m3_per_m2": 0.009,
"fill_concrete_m3_per_m2": 0.2,
"blinding_m3_per_m2": 0.1,
},
"돌붙임(메)": {
"back_len_m": 0.30,
"stone_name": "야면석",
"stone_spec": "20×20×30㎝",
"stone_ton_per_m2": 0.42,
"wedge_stone_m3_per_m2": 0.07,
# 메붙임은 모르타르·채움콘크리트·버림이 **없다**(정본 탭에 줄 자체가 없음).
"mortar_m3_per_m2": None,
"fill_concrete_m3_per_m2": None,
"blinding_m3_per_m2": None,
},
}
#: 돌쌓기 식이 계수를 가르는 데 쓰는 칸 — 기슭막이 등록부에 아직 없는 것을 알린다.
STONE_SPEC_KEYS: tuple[tuple[str, str], ...] = (
("back_len_cm", "뒷길이"),
("stone_kind", "돌 종류"),
)
def revetment(
height_m: float,
length_m: float,
options: dict[str, Any],
face: str | None = None,
face_reason: str = "",
) -> tuple[list[Component], list[str]]:
"""기슭막이 1구간 전개 — 형태가 돌쌓기면 그 식, 아니면 사유만."""
form = str(options.get("form") or "").strip()
if form not in STONE_FORMS:
why = WITHHELD_FORMS.get(form)
if why:
return [], [f"기슭막이 형태 「{form}」 — {why}"]
return [], [
f"기슭막이 형태가 정해지지 않았습니다(지금 「{form or '빈 값'}」) "
"— 형태를 골라야 물량이 섭니다"
]
components, notes = stone_masonry(
height_m, length_m, options, STONE_FORMS[form], face, face_reason
)
# ⚠ 계수를 가르는 칸이 없으면 **기본값으로 조용히 돈다** — 그 사실을 드러낸다.
missing = [label for key, label in STONE_SPEC_KEYS if not options.get(key)]
if missing and components:
notes.append(
f"⚠ 기슭막이 제원에 {' · '.join(missing)} 칸이 없어 **돌쌓기 기본값으로 섰음** "
"— 그 값이 고임돌·야면석·채움콘크리트 계수를 가름"
)
return components, notes
#: 돌골막이 원단위 — 소광리 정본 「골막이(찰)(치수조서연결)」, **개소당**.
#: 「돌-골막이치수」 탭이 개소별 상장·하장·높이를 받아 **평균치수**를 내고, 이 표가 그 평균
#: 하나로 개소당 수량을 낸다. 우리는 개소마다 제 치수로 돌린다 — 밑수가 같으므로 식은 그대로다.
#:
#: ⚠⚠ **돌쌓기(기슭막이) 식과 합치지 말 것.** 넷이 다르다:
#:
#: 정면적 **사다리꼴** (상장+하장)÷2×H − 파형강관 ↔ 기슭막이 직사각 H×1
#: 두께식 (3+0.1H) / (3+0.4H) 의 평균 ↔ 기슭막이 3+0.30 / +0.30(H1)
#: 밑수 **돌쌓기 + 돌붙임** ↔ 기슭막이 돌쌓기만
#: 단위 **개소당** ↔ 기슭막이 m당 (확정 ⑦ 「통일 안 함」)
#:
#: ⓘ 앞서 이 두께식을 「0.45 + 0.1H」로 적어 두었으나 **0.45 는 상수가 아니라 뒷길이 ℓ3**
#: 이다(정본 C26 이 `P6/100` 을 읽는다 — P6 = 45㎝). 두 식 모두 뒷길이 기반이고
#: **더하는 몫만 다르다.**
#: ⓘ 같은 것 — 막자갈 **2/3** · 야면석 **0.88 ton/㎡** · 물구멍 **2㎡/개소 × 0.5m** ·
#: 고임돌 **0.15** · 채움콘크리트 **0.2** 는 두 시트가 같은 값이라 표를 함께 쓴다.
#:
#: ⚠ **엑셀의 자름·반올림을 그대로 옮긴다** — 정본이 줄마다 `INT(x*100)/100` 으로 자르고
#: **다음 줄이 그 잘린 값을 받는다**(막자갈·잔토가 그렇다). 안 자르면 정본과 갈린다.
#:
#: ⚠ **방수로 파형강관을 늘 뺀다** — 정본이 상수 `(0.4 × 0.4) × 3.14` 로 박아 두었고
#: (Ø0.8 파형강관, 울진 관급 목록에 있는 규격) 방수로 치수가 0 인 개소에서도 뺀다.
#: **「방수로 없음」을 고를 칸이 등록부에 없어** 그대로 따른다 — 그 사실을 줄로 낸다.
EROSION_CHECK_DAM = {
"spillway_pipe_r_m": 0.4, # 정본 상수 — 0.4 × 0.4 × 3.14
"spillway_pipe_pi": 3.14,
"thickness_top_per_m": 0.1, # 상부 두께 = 3 + 0.1H
"thickness_bottom_per_m": 0.4, # 하부 두께 = 3 + 0.4H
"rubble_body_ratio": 2 / 3, # 막자갈에서 뺄 몸통 몫
"trench_extra_m": 0.3, # 바닥파기 폭 = 평균두께 + 0.3
"trench_depth_m": 0.5, # 바닥파기 깊이
"default_slope_ratio": 0.3, # 반수면 비탈 1:0.3 — 정본 붙박이
}
#: 골막이가 세우는 형식. 정본 탭이 **찰쌓기 하나**뿐이다.
EROSION_CHECK_FORMS: frozenset[str] = frozenset({""})
#: 나머지 형식과 **왜 없는지**. 지어내지 않는다.
EROSION_CHECK_WITHHELD: dict[str, str] = {
"돌망태": "돌망태는 품셈 13-8 이고 규격 축이 망태 치수라 돌쌓기 표를 못 씀 — 원단위 미확보",
"콘크리트": "콘크리트 골막이는 몸체 두께·저판이 정본에 없어 물량이 서지 않음",
"통나무": (
"통나무 골막이는 품셈 13-13(목재틀)이고 밑수가 ㎥당 목공 품이라 축이 다름 — 원단위 미확보"
),
"바자": "바자얽기(품셈 5-15)는 별도 일위대가를 만들어 잇기로 확정(2026-09-09 ⑧-4)",
"": "흙골막이는 정본에 탭이 없고 품셈 장도 다름 — 원단위 미확보",
}
# ── 엑셀 자름·반올림 ────────────────────────────────────────────────────────
# ⚠⚠ **걷어 내지 말 것.** 「자릿수 맞추기」로 보여 지우고 싶어지는 자리인데, 정본이
# **줄마다 자르고 다음 줄이 그 잘린 값을 받는다** — 표시용이 아니라 **계산의 일부**다.
# 입적 = INT(정면적 × 평균두께) ← 평균두께가 이미 잘린 값(1.09)
# 막자갈 = INT(입적 (…)) ← 입적이 이미 잘린 값(20.56)
# 잔토 = ROUND(바닥파기 − (…)) ← 바닥파기가 이미 잘린 값(8.61)
# 안 자르면 막자갈이 10.55, 잔토가 2.75 로 나와 **정본과 갈린다.**
# 「정본과 같은 값」의 뜻을 **소수점까지**로 잡은 것이고, 거울 시험이 그것을 잡는다.
def _floor2(value: float) -> float:
"""엑셀 `INT(x*100)/100` · `ROUNDDOWN(x,2)`."""
return math.floor(value * 100) / 100
def _round2(value: float) -> float:
"""엑셀 `ROUND(x,2)`.
파이썬 `round` **짝수 반올림**이라 4.905 4.90 으로 내린다(엑셀은 4.91).
물구멍관이 정확히 값이라 바꾸면 정본과 갈린다.
"""
return float(Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
def _ceil2(value: float) -> float:
"""엑셀 `ROUNDUP(x,2)` — 야면석만 이쪽이다(정본이 그 줄만 올림)."""
return math.ceil(value * 100) / 100
def erosion_check_dam(
height_m: float, options: dict[str, Any]
) -> tuple[list[Component], list[str]]:
"""돌골막이 1개소 전개 — 정본 「골막이(찰)(치수조서연결)」의 식을 그대로 돌린다."""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import (
DESTINATION,
STONE_BACK_LENGTH_TABLE,
STONE_MASONRY,
_back_length,
fill_concrete_mpa,
stone_weight_per_m2,
weep_hole_spec,
)
form = str(options.get("form") or "").strip()
if form not in EROSION_CHECK_FORMS:
why = EROSION_CHECK_WITHHELD.get(form)
if why:
return [], [f"골막이 형식 「{form}」 — {why}"]
return [], [
f"골막이 형식이 정해지지 않았습니다(지금 「{form or '빈 값'}」) "
"— 형식을 골라야 물량이 섭니다"
]
top_m = _num(options.get("top_length_m"), 0.0)
bottom_m = _num(options.get("bottom_length_m"), 0.0)
if top_m <= 0 or bottom_m <= 0 or height_m <= 0:
return [], [
"골막이 정면적이 **사다리꼴**이라 상장ⓐ·하장ⓑ·높이가 다 있어야 셈이 섭니다 "
f"(지금 상장 {top_m:g}m · 하장 {bottom_m:g}m · 높이 {height_m:g}m)"
]
const = EROSION_CHECK_DAM
notes: list[str] = []
back_cm = _back_length(options)
coeff = STONE_BACK_LENGTH_TABLE.get(back_cm)
if coeff is None:
return [], [f"뒷길이 {back_cm}㎝ 는 품셈 표(25·30·35·45·55·60·75㎝)에 없어 계수가 없습니다"]
if not any(options.get(key) is not None for key in ("back_len_cm", "stone_back_length_cm")):
notes.append(
f"⚠ 뒷길이를 안 골라 기본 {back_cm}㎝ 로 섰습니다 "
"— 그 값이 두께식·고임돌·돌 무게·채움콘크리트 계수를 모두 가릅니다"
)
back_m = back_cm / 100.0
given = _num(options.get("face_slope_ratio"), 0.0)
if given > 0:
slope, slope_note = given, f"사용자 지정 1:{given:g}"
else:
slope = const["default_slope_ratio"]
slope_note = f"1:{slope:g} — 정본 「반수면비탈」 붙박이(안 정함)"
# ① 정면적(사다리꼴) − ② 방수로 파형강관 단면
# ⚠ 「없음」이면 안 뺀다 — 정면적이 밑수라 **열한 줄이 통째로 움직인다.**
# ⓘ 정본은 방수로 치수가 0 인 개소에서도 뺐다. 그러므로 **정본과 같은 값은 「있음」**이고,
# 「없음」은 정본보다 크게 나온다. 안 고르면 정본 쪽(「있음」)으로 서고 그 사실을 알린다.
trapezoid = _floor2((top_m + bottom_m) / 2 * height_m)
spillway = str(options.get("spillway") or "").strip()
pipe = const["spillway_pipe_r_m"] ** 2 * const["spillway_pipe_pi"]
if spillway == "없음":
front_area = trapezoid
notes.append("방수로 「없음」이라 파형강관 단면을 안 뺐습니다 — 정본보다 정면적이 큽니다")
else:
front_area = trapezoid - pipe
notes.append(
f"방수로 파형강관 {pipe:.4f}㎡ 를 정면적에서 뺐습니다 — 정본 붙박이(Ø0.8)"
+ ("" if spillway else " · ⚠ 방수로를 안 골라 정본대로 뺐습니다")
)
masonry = _floor2(front_area * _round2(math.hypot(slope, 1.0)))
top_t = _floor2(back_m + const["thickness_top_per_m"] * height_m)
thickness = _floor2(
(
(back_m + const["thickness_top_per_m"] * height_m)
+ (back_m + const["thickness_bottom_per_m"] * height_m)
)
/ 2
)
volume = _floor2(front_area * thickness)
facing = top_m * (top_t - back_m)
base_area = masonry + facing
rows: list[tuple[str, str, float, str]] = [
("돌쌓기", "", masonry, f"정면적 {front_area:.4f}× √(1+n²) · 기울기 {slope_note}"),
("돌붙임", "", facing, f"상장 {top_m:g}m × (상부두께 {top_t:g} 뒷길이 {back_m:g})"),
(
"입적",
"",
volume,
f"정면적 × 평균두께 {thickness:g}m "
f"[{{(3+0.1H)+(3+0.4H)}}÷2 · 3={back_m:g} · H={height_m:g}]",
),
]
# 돌 무게 — ⭐ 확정 5차 큰 것 7. 돌쌓기와 **같은 헬퍼**를 쓴다(규칙이 하나여야 한다).
kind = str(options.get("stone_kind") or "").strip()
stone_name, stone_ton, weight_tail, _src = stone_weight_per_m2(
back_cm, kind, coeff["stone_ton_per_m2"]
)
if stone_ton is None:
notes.append(f"뒷길이 {back_cm}㎝ 에 {stone_name} 중량 칸이 비어 있어 돌을 못 세웠습니다")
else:
rows.append(
(
stone_name,
"ton",
_ceil2(base_area * stone_ton),
f"(돌쌓기+돌붙임) {base_area:.4f}{weight_tail}",
)
)
wedge_per = coeff["wedge_stone_m3_per_m2"]
wedge = _round2(base_area * _num(wedge_per)) if wedge_per is not None else 0.0
if wedge_per is None:
notes.append(f"뒷길이 {back_cm}㎝ 에 고임돌 칸이 비어 있습니다 — 막자갈에서 안 뺐습니다")
else:
rows.append(("고임돌", "", wedge, f"(돌쌓기+돌붙임) × {wedge_per} ㎥/㎡"))
rows.append(
(
"막자갈",
"",
_floor2(volume - (base_area * back_m * const["rubble_body_ratio"] + wedge)),
"입적 − ((돌쌓기+돌붙임) × 뒷길이 × 2/3 + 고임돌)",
)
)
hole_area, hole_dia, hole_basis = weep_hole_spec(options)
rows.append(
(
"물구멍관",
"m",
_round2(masonry / hole_area * STONE_MASONRY["weep_hole_length_m"]),
f"돌쌓기 ÷ {hole_area:g}㎡/개소 × {STONE_MASONRY['weep_hole_length_m']:g}m/개소"
f" · {hole_basis}{hole_dia})",
)
)
# 바닥파기 — 비탈 사면장 두 쪽 + 하장 한 쪽, 폭은 평균두께 + 0.3.
slant = _floor2(math.hypot((top_m - bottom_m) / 2, height_m))
width = thickness + const["trench_extra_m"]
depth = const["trench_depth_m"]
trench = _floor2(slant * depth * width * 2 + bottom_m * depth * width)
spoil = _round2(trench - (slant * depth * width + bottom_m * depth * width))
rows.extend(
[
(
"터파기",
"",
trench,
f"(사면장 {slant:g} × {depth:g} × {width:g}) × 2 + (하장 {bottom_m:g} × "
f"{depth:g} × {width:g}) · 정본 이름 「바닥파기」",
),
("잔토처리", "", spoil, f"바닥파기 − (사면장 + 하장) × {depth:g} × {width:g}"),
("되메우기", "", trench - spoil, "바닥파기 잔토"),
]
)
components = [
Component(name, unit, amount, DESTINATION.get(name, "quantity"), basis)
for name, unit, amount, basis in rows
]
mpa, mpa_basis = fill_concrete_mpa(options)
fill_per = coeff["fill_concrete_m3_per_m2"]
components.append(
Component(
"채움콘크리트",
"",
_round2(base_area * _num(fill_per)),
DESTINATION["채움콘크리트"],
f"(돌쌓기+돌붙임) × {fill_per} ㎥/㎡ · {mpa_basis}",
spec=f"{mpa}",
)
)
if not kind:
notes.append(
"돌 종류를 안 골라 **계산식**(뒷길이 × 0.77 × 2.65)으로 섰습니다 "
"— 정본 탭은 야면석이고, 야면석은 계산식이 안 맞아 관측표로 갈립니다"
)
return components, notes
#: 개거(겉도랑) 원단위 — 소광리 정본 「개거(150-200) (2)」·「L형수로-(201)」, **둘 다 m당**.
#: ⚠⚠ **두 표가 담는 것이 다르다.** 값이 비슷해 보여 합치고 싶어지는 자리다:
#:
#: 콘크리트 개거 150×200 터파기 · 유로폼 · 면목 **세 줄뿐** — **콘크리트 본체 줄이 없다**
#: 콘크리트 L형수로 H=0.2 터파기 · 되메우기 · 잔토 · **콘크리트** · PVC Φ50 · 이형철근 D13 ·
#: 거푸집 · 면목 **여덟 줄**
#:
#: ⓘ 같은 것 — **둘 다 m당**이고 거푸집 계열(유로폼·거푸집)과 면목이 있다.
#: ⚠ 개거 표에 콘크리트가 없는 것은 **빠뜨린 것이 아니라 원문 그대로**다. 0 으로 때우거나
#: L형수로 값을 옮겨 채우지 않는다(2026-09-09 판단 — 돌붙임(메)에서 세 줄을 안 만든 그 자리).
#:
#: ⓘ **「다른 탭에 본체가 있나」를 찾아봤고 없었다**(2026-09-09 53탭 전수):
#: · 「개거」 글자가 나오는 탭은 **둘뿐** — 이 탭과 「콘크리트개거」.
#: 뒤엣것은 **제목만 「콘크리트개거 (물넘이형)」이고 내용이 국가지점번호판 표**다.
#: · 탭 이름의 「(2)」는 **짝 번호가 아니라 엑셀 사본 표식**이다 — `Φ1000` ↔ `Φ1000(2)`,
#: `떼수로(윤주1.08)` ↔ `…(2)` 가 **내용까지 똑같다**(대조 확인). 「(1)」은 없다.
#: · 같은 시트의 「측구수로400/500」도 제목이 **「터파기 계산서」**이고 본체가 없다 —
#: **한 구조물을 부분 계산서로 쪼개는 버릇**이 있는 시트다.
#: ⇒ 그러므로 이 표는 **부분 계산서일 가능성이 남아 있다.** 다만 이 원본 안에서는
#: 본체 표를 못 찾았으므로 **「원문에 없음」으로 두고 그 사실을 화면에 적는다** —
#: 「콘크리트 개거인데 콘크리트가 0」이 우리 결함으로 읽히지 않게.
#:
#: ✅ **규격 칸이 생겼다**(2026-09-09 랩탑 메인 `0b0763ed`) — 두 규격을 고를 수 있고,
#: 비우면 「콘크리트 개거 150×200」으로 선다.
OPEN_DITCH_FORMS: dict[str, dict[str, Any]] = {
"콘크리트 개거 150×200": {
"rows": (
("터파기", "", 0.3 * 0.1 * 1.0, "0.3 × 0.1 × 1"),
("유로폼", "", (0.2 + 0.2 + 0.15) * 1.0, "(0.2 + 0.2 + 0.15) × 1"),
("면목", "m", 2.0 * 1.0, "2 × 1"),
),
},
"콘크리트 L형수로 H=0.2": {
"rows": (
("터파기", "", 0.31645, "[{(1.11+1.51)÷2×0.2} + {(0.33×0.33)÷2}] × 1.0"),
("되메우기", "", 0.129975, "(0.2+0.30)÷2×0.25×1 + (0.2+0.19)÷2×0.33×1.0"),
("잔토처리", "", 0.186475, "터파기 되메우기"),
(
"콘크리트",
"",
0.1508,
"{(0.2+0.15)÷2×0.7} + (0.21×0.13)×1 + {(0.15+0.21)÷2×0.20}×1",
),
("이형철근 D13", "kg", 0.398, "0.2 × 2 × 0.995"),
("거푸집", "", 0.738, "0.2 + 0.33 + √(0.2² + 0.06²)"),
("면목", "m", 1.0, "1"),
),
# ⚠ 정본에 줄은 있으나 **수량 칸이 비어 있다**(PVC Φ50). 0 으로 만들지 않고 알린다.
"blank_rows": (("PVC 파이프 Φ50㎜", "m"),),
},
}
#: 떼흙막이 원단위 — 소광리 정본 「떼흙막이」, **개소당**. 치수는 전부 **평균 붙박이**다
#: (상단 1.5 · 하단 1.1 · 높이 0.5 · 두께 0.2 · 기슭 0.54 — 원문에 「(평균)」이라 적혀 있다).
#: ✅ **형식 칸에 「떼」가 들어와 이 표가 선다**(2026-09-09 랩탑 메인 `513aff3e`).
#: ⚠ 나머지 일곱 형식은 **정본에 탭이 없다.** 돌(찰)/(메)에 기슭막이 식을 빌려 쓰지 않는다 —
#: 흙막이는 교본상 앞면 1:0.3·뒷면 수직·천단 30㎝ 라 축이 다르고, 무엇보다 **근거가 없다.**
#: ⚠⚠ **사용자 확정 4차**(2026-09-09) — 「흙막이는 횡단도에서 표현방식·옵션이 기슭막이와
#: 동일, 형상도 동일. 다만 **데이터는 분리하여 계산**」. ⇒ **그림·옵션은 한 벌로 쓰되
#: 수량은 여기서 제 것으로 낸다.** 그림이 같다고 기슭막이 원단위를 끌어오면 그 확정을 어긴다.
SOIL_GUARD_SOD: dict[str, Any] = {
"unit_label": "개소당",
"rows": (
(
"",
"",
1.39,
"머리떼 1.5×0.2 + 바닥떼 1.1×0.2 + 정면떼 (1.5+1.1)÷2×0.5 "
"+ 양기슭바닥떼 0.54×0.2×2 · 규격 20×20㎝",
),
# ⚠ 이름을 **터파기**로 낸다 — 정본 이름은 「바닥파기」이나 그대로 두면 토공 축에
# 안 실린다(`DESTINATION` 이 이름으로 가른다). 골막이도 같은 자리를 그렇게 냈다.
("터파기", "", 0.17, "(0.54×0.4×0.2×2) + (1.1×0.4×0.2) · 정본 이름 「바닥파기」"),
),
}
def soil_guard(options: dict[str, Any]) -> tuple[list[Component], list[str]]:
"""흙막이 전개 — **「떼」만 선다**(정본 「떼흙막이」, 개소당). 나머지는 사유만.
치수가 **전부 평균 붙박이** 정본 표에 (평균)이라 적혀 있고 개소별 치수조서가
따로 없다. 그래서 높이·길이를 받지 않는다. 개소별로 갈리게 하려면 치수 칸이 먼저다.
"""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import DESTINATION
form = str(options.get("form") or "").strip()
if not form:
return [], ["흙막이 형식이 정해지지 않았습니다 — 형식을 골라야 물량이 섭니다"]
if form != "":
return [], [
f"흙막이 형식 「{form}」 — 정본(소광리 53탭)에 그 형식의 산출식이 없습니다"
"(있는 것은 **떼흙막이** 하나). ⚠ 돌(찰)·돌(메)에 기슭막이 식을 빌려 쓰지 않습니다 "
"— 흙막이는 앞면 1:0.3·뒷면 수직·천단 30㎝(교본)라 축이 다르고 근거가 없습니다"
]
components = [
Component(name, unit, amount, DESTINATION.get(name, "quantity"), basis)
for name, unit, amount, basis in SOIL_GUARD_SOD["rows"]
]
return components, [
f"떼흙막이는 **{SOIL_GUARD_SOD['unit_label']}**이고 치수가 정본 평균 붙박이입니다"
"(상단 1.5 · 하단 1.1 · 높이 0.5 · 두께 0.2 · 기슭 0.54m) — 개소 제원을 안 봅니다"
]
def open_ditch(length_m: float, options: dict[str, Any]) -> tuple[list[Component], list[str]]:
"""개거(겉도랑) 전개 — **m당** 원단위를 연장에 곱한다(소광리 정본 두 탭).
규격을 고를 칸이 없어 지금은 **콘크리트 개거 150×200 붙박이**. 사실을 줄로 낸다.
"""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import DESTINATION
if length_m <= 0:
return [], ["연장이 없어 전개하지 않음"]
spec = str(options.get("ditch_spec") or "콘크리트 개거 150×200").strip()
table = OPEN_DITCH_FORMS.get(spec)
if table is None:
return [], [
f"개거 규격 「{spec}」 원단위가 없습니다 — 있는 것: {' · '.join(OPEN_DITCH_FORMS)}"
]
components = [
Component(
name,
unit,
per_m * length_m,
DESTINATION.get(name, "quantity"),
f"{basis} × 연장 {length_m:g}m (m당 {per_m:g})",
)
for name, unit, per_m, basis in table["rows"]
]
notes: list[str] = []
if not options.get("ditch_spec"):
notes.append(f"규격을 안 골라 「{spec}」으로 섰습니다 — 제원에서 고를 수 있습니다")
if spec == "콘크리트 개거 150×200":
notes.append(
"⚠ 이 표에는 **콘크리트 본체 줄이 없습니다** — 정본 원문 그대로입니다"
"(터파기·유로폼·면목 세 줄뿐). 같은 원본 53탭에서 본체 표를 찾았으나 없었고"
"(「콘크리트개거」 탭은 제목만 그 이름이고 내용이 다른 표), 이 시트가"
"「측구수로 터파기 계산서」처럼 **부분 계산서를 쓰는 버릇**이 있어"
"**다른 문서에 본체가 있을 수 있습니다.** 지어내지 않았습니다"
)
for name, unit in table.get("blank_rows", ()):
notes.append(f"{name}({unit}) 은 정본에 줄은 있으나 **수량이 비어 있어** 안 세웠습니다")
return components, notes
def bed_sill(area_m2: float, options: dict[str, Any]) -> tuple[list[Component], list[str]]:
"""바닥막이 전개 — **돌붙임 ㎡당** 원단위를 면적에 곱한다(소광리 정본 두 탭).
밑수가 **면적**이다. 돌쌓기처럼 높이·연장으로 셈하지 않는다 정본 제목이 이고
등록부 밑수도 `area_m2` 축이 그대로 맞는다.
기울기 몫이 **없다** 평면에 붙이는 것이라 (1+) 곱하지 않는다.
"""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import DESTINATION
form = str(options.get("form") or "").strip()
table = BED_SILL_FORMS.get(form)
if table is None:
return [], [
f"바닥막이 형태 「{form or '빈 값'}」 — 돌붙임(찰)·돌붙임(메)만 원단위가 있습니다"
]
if area_m2 <= 0:
return [], ["면적이 없어 전개하지 않음"]
back = table["back_len_m"]
rows: list[tuple[str, str, float, str]] = [
("돌붙임", "", area_m2, f"면적 {area_m2:g}㎡ (평면적 — 기울기 몫 없음)"),
("입적", "", area_m2 * back, f"면적 × 두께 {back:g}m"),
(
table["stone_name"],
"ton",
area_m2 * table["stone_ton_per_m2"],
f"면적 × {table['stone_ton_per_m2']:.3f} ton/㎡ · {table['stone_spec']}"
+ (
f" (= 뒷길이 {back:g} × 0.77 × 2.65)"
if form == "돌붙임(찰)"
else " (원문 직접값 — 야면석은 유도식이 안 맞음)"
),
),
(
"고임돌",
"",
area_m2 * table["wedge_stone_m3_per_m2"],
f"면적 × {table['wedge_stone_m3_per_m2']} ㎥/㎡",
),
]
for name, key, unit in (
("모르터", "mortar_m3_per_m2", ""),
("채움콘크리트", "fill_concrete_m3_per_m2", ""),
("버림콘크리트", "blinding_m3_per_m2", ""),
):
value = table[key]
if value is not None:
rows.append((name, unit, area_m2 * value, f"면적 × {value} ㎥/㎡"))
# 터파기 — 정본은 「두께 × 1 × 1」이라 **면적 × 두께**와 같다.
rows.append(("터파기", "", area_m2 * back, f"면적 × 두께 {back:g}m"))
components = [
Component(name, unit, amount, DESTINATION.get(name, "quantity"), basis)
for name, unit, amount, basis in rows
]
notes = [f"뒷길이 {back:g}m 는 정본 탭 붙박이 값입니다 — 바닥막이 제원에 뒷길이 칸이 없습니다"]
if form == "돌붙임(찰)":
notes.append(
"버림 콘크리트 0.1 ㎥/㎡ 포함 — 정본 주기 「바닥 10㎝ 이상 콘크리트(버림) 포설 후 "
"돌붙임」(KDS 44 90 00 의 100㎜ 와 같음)"
)
return components, notes
@@ -138,6 +138,13 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
{type_id: definition.name for type_id, definition in structure_type_map().items()},
# 부대시설 개소 — 산식으로 만들지 않고 **설계자가 넣은 값**만 쓴다(확정 ⑬).
settings.get("ancillary_counts") or {},
# 표토 운반거리 — 별표2 가 요구하는 운반·적치의 밑수(거리는 현장값).
settings.get("topsoil_haul_distance_m"),
# 임목축적 등급 — 품셈 9-21 제근이 소·중·밀로 갈리는 축(본수가 아니다).
settings.get("stand_volume_class"),
# 임목파쇄 — 기본 꺼짐. 켠 프로젝트에서만 줄이 선다(확정 5차 5번).
settings.get("wood_chipping_enabled"),
settings.get("wood_chipping_volume_m3"),
)
method, method_is_default = concrete_placing_method(settings)
# ⚠ 금액에 바로 걸리는 값이라 「기본값으로 돌고 있음」을 응답에 실어 화면이 띄우게 한다.
@@ -399,6 +406,15 @@ class QuantitySettingsBody(BaseModel):
rubble_base_thickness_m: float | None = None
# 구조물터파기 용수 유무 — "육상"·"용수". ⚠ 기본 육상은 **통상값**이지 사용자 확정이 아니다.
structure_trench_water: str | None = None
# 표토 운반거리(m) — 별표2 가 요구하는 운반·적치의 밑수. 비면 그 줄이 막힌다.
topsoil_haul_distance_m: float | None = None
# 임목축적 등급 — "소림"·"중림"·"밀림"(품셈 9-21 [주]①). `""` 는 「안 정함」이다.
stand_volume_class: str | None = None
# 규준틀 개소당 재료 — `{자재명: 수량}`. 비우면 제안값(실무 관측)이 선다.
frame_material: dict[str, Any] | None = None
# 임목파쇄 — 기본 꺼짐(확정 5차 5번). 켜면 줄이 서고, 부피를 넣으면 값이 선다.
wood_chipping_enabled: bool | None = None
wood_chipping_volume_m3: float | None = None
#: `None` 이 「안 정함」을 뜻하는 칸 — 저장에서 **버리지 않고 그대로 덮어쓴다**.
@@ -408,6 +424,8 @@ NULLABLE_SETTING_KEYS = (
"bench_cut_depth_m",
"spoil_site_distance_m",
"rubble_base_thickness_m",
"topsoil_haul_distance_m",
"wood_chipping_volume_m3",
)
@@ -43,6 +43,7 @@ from common_util.common_util_project_settings import (
rock_method,
)
from B08_Quantity.B08_Quantity_Engine_HaulInputs import haul_inputs
from B08_Quantity.B08_Quantity_Engine_Preparation import frame_material_rows
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_structure_lengths import structure_lengths
from config.config_db import run_with_connection
@@ -251,6 +252,20 @@ async def get_handoff(project_id: UUID) -> JSONResponse:
# 토공·운반 표는 토적표 라우터의 것을 그대로 쓴다 — 여기서 다시 만들지 않는다.
earthwork = await _earthwork_tables(project_id)
# 규준틀 재료 — **개소가 선 뒤에야 설 수 있어** 준비공 표를 받은 다음 자재 축에 얹는다.
# ⚠ 값은 **제안값(실무 관측)**이고 산출 조건에서 고칠 수 있다 — 그 사실이 줄 사유에 적힌다.
frame_rows = [
row
for row in ((earthwork.get("preparation") or {}).get("rows") or [])
if str(row.get("item") or "").endswith("규준틀")
]
material_table = build_material_table(
unit_table,
supply_map=settings.get("material_supply") or {},
concrete_placing_method=settings.get("concrete_placing_method"),
extra_materials=frame_material_rows(frame_rows, settings.get("frame_material") or {}),
)
handoff = build_handoff(
summary_table=earthwork.get("summary"),
haul_table=earthwork.get("haul"),
@@ -75,6 +75,18 @@ export interface QuantitySettings {
spoil_site_distance_m?: number | null;
/** 구조물터파기 용수 — `"육상"`·`"용수"`. ⚠ 「육상」은 통상값이지 사용자 확정이 아니다. */
structure_trench_water?: string | null;
/** 표토 운반거리(m) — 별표2 가 요구하는 운반·적치의 밑수. 비면 그 줄이 막힌다. */
topsoil_haul_distance_m?: number | null;
/** 부대시설 개소 — `{항목키: 개소}`. ⚠ 산식으로 만들지 않는다(확정 13). */
ancillary_counts?: Record<string, number | null>;
/** 임목축적 등급 — `"소림"`·`"중림"`·`"밀림"`(품셈 9-21 [주]①). 본수가 아니라 축적이다. */
stand_volume_class?: string | null;
/** 규준틀 개소당 재료 — `{자재명: 수량}`. 비우면 제안값(실무 관측)이 선다. */
frame_material?: Record<string, number | null>;
/** 임목파쇄 — 기본 꺼짐(확정 5차 5번). 켜야 줄이 선다. */
wood_chipping_enabled?: boolean | null;
/** 파쇄 부피(㎥) — 켜도 이 값이 없으면 줄만 서고 사유가 남는다. */
wood_chipping_volume_m3?: number | null;
}
export interface EarthworkTable {
+144
View File
@@ -91,6 +91,13 @@ async function saveQuantitySettings(projectId: string, draft: DraftSettings): Pr
rubble_base_thickness_m: draft.rubble_base_thickness_m,
spoil_site_distance_m: draft.spoil_site_distance_m,
structure_trench_water: draft.structure_trench_water,
topsoil_haul_distance_m: draft.topsoil_haul_distance_m,
stand_volume_class: draft.stand_volume_class,
frame_material: draft.frame_material,
wood_chipping_enabled: draft.wood_chipping_enabled,
wood_chipping_volume_m3: draft.wood_chipping_volume_m3,
// 개소는 **통째로** 보낸다 — 지운 항목까지 그대로 가야 되돌릴 길이 있다.
ancillary_counts: draft.ancillary_counts,
}),
},
);
@@ -110,6 +117,28 @@ function field(label: string, value: string): HTMLElement {
return row;
}
/** 규준틀 재료 칸 — 이름은 **서버 `FRAME_MATERIAL_SUGGESTED` 와 같은 낱말**이라야 한다. */
const FRAME_MATERIAL_FIELDS: ReadonlyArray<readonly [string, string, string]> = [
["각재 50×50", "B08_Quantity_Frame_Square", "0.0001"],
["판재 T12", "B08_Quantity_Frame_Board", "0.0001"],
["못", "B08_Quantity_Frame_Nail", "0.01"],
];
/** 채워 보이는 **제안값**(실무 관측). ⚠ 법정 기준이 아니라 「고치라고 보이는 값」이다. */
const FRAME_MATERIAL_SUGGESTED: Record<string, number> = {
"각재 50×50": 0.0044,
"판재 T12": 0.0029,
: 0.03,
};
/** 부대시설 항목 키 — 서버 `ANCILLARY_ITEMS` 와 **같은 차례·같은 낱말**이라야 한다. */
const ANCILLARY_KEYS = [
"national_point_sign",
"guide_sign",
"gate",
"site_container",
"flood_supplies",
] as const;
/** 칸 밑에 붙는 **근거 한 줄** — 왜 그 값인지 화면에서 보이게 한다(2026-09-09 사용자 지시). */
function hintRow(text: string): HTMLElement {
const row = document.createElement("p");
@@ -249,6 +278,17 @@ interface DraftSettings {
spoil_site_distance_m: number | null;
// 구조물터파기 용수 — "육상"·"용수". ⚠ 「육상」은 **통상값**이지 사용자 확정이 아니다.
structure_trench_water: string;
// 표토 운반거리(m) — 별표2 가 요구하는 운반·적치의 밑수. `null` 은 「안 정함」.
topsoil_haul_distance_m: number | null;
// 부대시설 개소 — `{항목키: 개소}`. ⚠ 산식으로 만들지 않는다(확정 13).
ancillary_counts: Record<string, number | null>;
// 임목축적 등급 — "소림"·"중림"·"밀림". ⚠ 본수가 아니라 축적이다(품셈 9-21 [주]①).
stand_volume_class: string;
// 규준틀 개소당 재료 — 비우면 **제안값(실무 관측)**이 선다. 값이 아니라 「고칠 수 있음」이 요점.
frame_material: Record<string, number | null>;
// 임목파쇄 — **기본 꺼짐**(확정 5차 5번). 켜야 줄이 선다. 근주이식은 칸 자체가 없다.
wood_chipping_enabled: boolean;
wood_chipping_volume_m3: number | null;
// 자재별 관급/사급 — 표 안에서 줄마다 고른 값.
material_supply: Record<string, SupplyChoice>;
dirty: boolean;
@@ -486,6 +526,102 @@ function buildQuantitySidePanel(
),
);
panel.append(hintRow(L("B08_Quantity_Side_Water_Hint")));
panel.append(
optionalNumberField(
L("B08_Quantity_Side_TopsoilHaul_Label"),
draft.topsoil_haul_distance_m,
"10",
(value) => {
draft.topsoil_haul_distance_m = value;
draft.dirty = true;
},
),
);
panel.append(hintRow(L("B08_Quantity_Side_TopsoilHaul_Hint")));
panel.append(
selectField(
L("B08_Quantity_Side_StandVolume_Label"),
draft.stand_volume_class,
[
{ value: "", label: L("B08_Quantity_StandVolume_Unset") },
{ value: "소림", label: L("B08_Quantity_StandVolume_Low") },
{ value: "중림", label: L("B08_Quantity_StandVolume_Mid") },
{ value: "밀림", label: L("B08_Quantity_StandVolume_High") },
],
(value) => {
draft.stand_volume_class = value;
draft.dirty = true;
},
),
);
panel.append(hintRow(L("B08_Quantity_Side_StandVolume_Hint")));
// ── 부대시설 개소 — ⚠ **산식으로 만들지 않는다**(확정 13). 넣어야 줄이 선다 ──
panel.append(field(L("B08_Quantity_Side_Ancillary"), ""));
for (const key of ANCILLARY_KEYS) {
panel.append(
optionalNumberField(
L(`B08_Quantity_Ancillary_${key}` as keyof typeof ui_locales),
draft.ancillary_counts[key] ?? null,
"1",
(value) => {
draft.ancillary_counts[key] = value;
draft.dirty = true;
},
),
);
}
panel.append(hintRow(L("B08_Quantity_Side_Ancillary_Hint")));
// ── 규준틀 재료 — ⚠ **세는 것은 확정이고 수량만 모르던 자리**(품셈 [주]④ 「설계수량에 따른다」).
// 그래서 **제안값을 채워 보이고 고칠 수 있게** 둔다(확정 ⑨·⑩ 과 같은 틀).
// ⚠ 값만 박고 근거를 안 보이면 사용자 지시(「대신 페이지에 남길 것」)를 어기는 것이라
// 칸 밑에 **관측값임**과 **손율 원문값**을 함께 적는다.
panel.append(field(L("B08_Quantity_Side_Frame"), ""));
for (const [key, label, step] of FRAME_MATERIAL_FIELDS) {
panel.append(
optionalNumberField(
L(label as keyof typeof ui_locales),
draft.frame_material[key] ?? FRAME_MATERIAL_SUGGESTED[key],
step,
(value) => {
draft.frame_material[key] = value;
draft.dirty = true;
},
),
);
}
panel.append(hintRow(L("B08_Quantity_Side_Frame_Hint")));
// ── 임목파쇄 — ⚠ **기본 꺼짐**(확정 5차 5번). 「셀지 말지가 설계 판단」이라 켜야 줄이 선다.
// ⚠ 근주이식은 **칸 자체를 안 만든다** — 켤 자리가 없으면 물을 일도 없다.
panel.append(field(L("B08_Quantity_Side_Chipping"), ""));
panel.append(
selectField(
L("B08_Quantity_Chipping_Label"),
draft.wood_chipping_enabled ? "on" : "",
[
{ value: "", label: L("B08_Quantity_Chipping_Off") },
{ value: "on", label: L("B08_Quantity_Chipping_On") },
],
(value) => {
draft.wood_chipping_enabled = value === "on";
draft.dirty = true;
},
),
);
panel.append(
optionalNumberField(
L("B08_Quantity_Chipping_Volume"),
draft.wood_chipping_volume_m3,
"1",
(value) => {
draft.wood_chipping_volume_m3 = value;
draft.dirty = true;
},
),
);
panel.append(hintRow(L("B08_Quantity_Side_Chipping_Hint")));
// ── 콘크리트 타설 방식 — ⚠ **금액에 바로 걸리는 값**이라 잠정임을 조용히 두지 않는다 ──
panel.append(field(L("B08_Quantity_Side_Placing"), ""));
@@ -742,6 +878,14 @@ export async function renderB08Quantity(root: HTMLElement): Promise<void> {
rubble_base_thickness_m: (stored.rubble_base_thickness_m as number | null) ?? null,
spoil_site_distance_m: (stored.spoil_site_distance_m as number | null) ?? null,
structure_trench_water: (stored.structure_trench_water as string) ?? "",
topsoil_haul_distance_m: (stored.topsoil_haul_distance_m as number | null) ?? null,
stand_volume_class: (stored.stand_volume_class as string) ?? "",
frame_material: { ...((stored.frame_material ?? {}) as Record<string, number | null>) },
wood_chipping_enabled: Boolean(stored.wood_chipping_enabled),
wood_chipping_volume_m3: (stored.wood_chipping_volume_m3 as number | null) ?? null,
ancillary_counts: {
...((stored.ancillary_counts ?? {}) as Record<string, number | null>),
},
material_supply: { ...((stored.material_supply ?? {}) as Record<string, SupplyChoice>) },
dirty: false,
};
@@ -155,6 +155,14 @@ def _leaf_row(
if item.application_ratio_pct is not None:
# ⚠ 곱하지 않는다 — B08 이 이미 곱한 값이다. 산출근거로만 적는다.
row.note = f"반영률 {item.application_ratio_pct}% 적용 후 수량"
elif item.application_ratio_breakdown:
# ⚠ **「율 없음」이 아니라 「갈래마다 다름」이다.** 율이 갈리는 줄은 B08 이 `pct` 를
# 비우고 갈래로만 보낸다. 그 사실을 안 적으면 **값은 맞는데 왜 그 수량인지**를
# 사람이 못 본다 — 값이 맞아도 그것은 반쪽이다(2026-09-09 두 창 확인).
parts = ", ".join(
f"{name} {value}%" for name, value in item.application_ratio_breakdown.items()
)
row.note = f"반영률이 갈래마다 다릅니다 — {parts} (적용 후 수량)"
if not item.in_bill:
# 검산용 줄 — 수량은 보이되 **단가를 안 붙인다**(PLAN 8-7 ㉡ 와 같은 성격).
@@ -250,14 +258,21 @@ def _leaf_row(
if covered is not None:
# ⚠ **일부 몫만 선 단가는 안 붙인다.** 「인력(10%)·장비(90%)」 표에서 인력만
# 붙은 값을 전량에 곱하면 내역서가 조용히 틀린다 — 0 으로 때우는 것과 같은 사고다.
row.note = f"단가가 일부만 섰습니다 — 붙은 몫 {covered}% (나머지는 시공능력 공식 몫)."
# 무엇이 없어서 못 붙었는지까지 적는다 — 붙은 몫 0%」만으로는 어디를 손볼지 모른다.
why = unit_prices.component_gaps.get(node.code) or ""
missing_rows = unit_prices.unattached.get(node.code) or []
if not why and missing_rows:
why = f"{', '.join(missing_rows[:3])} 줄이 아직 안 붙었습니다"
row.note = (
f"단가가 일부만 섰습니다 — 붙은 몫 {covered}%" + (f" · {why}" if why else "") + "."
)
result.missing.append(
{
"name": row.name,
"code": node.code,
"unit": row.unit,
"quantity": str(item.quantity),
"reason": f"단가 일부만 섬(붙은 몫 {covered}%)",
"reason": f"단가 일부만 섬(붙은 몫 {covered}%)" + (f"{why}" if why else ""),
}
)
return row
@@ -300,6 +315,19 @@ def _leaf_row(
)
return row
# ⚠ **수량이 미확정 산식 위에 서 있는 줄**은 금액과 함께 그 사실을 싣는다.
# 금액이 커질수록 더 그렇다 — 지금 구조물터파기가 내역서에서 가장 큰 줄인데
# 그 밑수가 사용자 확정을 기다리고 있다(계획서 4-12 3단계).
pending = pending_formula_note(node.code)
if pending:
row.note = " / ".join(part for part in (row.note, pending) if part)
# ⚠ **원문에는 있는데 단가에 못 실린 몫**도 같은 자리에서 말한다. 금액이 서 있는 줄이라
# 표시가 없으면 완성된 값으로 읽힌다(규준틀 둘이 인력만으로 492만원이었다).
gap = known_gap_note(node.code)
if gap:
row.note = " / ".join(part for part in (row.note, gap) if part)
# 쓰인 차례를 기억한다 — 실무 참조번호(「단산 46」)가 그 차례다.
if price_code not in result.used_unit_prices:
result.used_unit_prices.append(price_code)
@@ -316,6 +344,29 @@ def _leaf_row(
return row
#: 수량 산식이 **사용자 확정을 기다리는** 공종 — 금액은 세우되 그 사실을 함께 싣는다.
#:
#: ⚠ **2026-09-09 저녁 비었다.** 걸려 있던 셋(구조물터파기·되메우기·잔토처리)이
#: **사용자 확정 5차로 닫혔다** — 「비탈 터파기, 지금 이대로」. 값은 안 바뀌었고
#: 11,263,899원이 **확정된 값**이 됐다.
#: ⚠ **표를 지우지 않고 비워 둔다** — 같은 성격의 자리가 또 생기면 여기 적으면 된다.
#: 적을 때는 **왜 대기인지**와 **정해지면 얼마나 움직이는지**를 함께 적을 것.
_PENDING_FORMULA: dict[str, str] = {}
from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note # noqa: E402
def pending_formula_note(code: str | None) -> str:
"""그 공종의 수량 산식이 확정 대기인가 — 맞으면 실을 문구."""
if not code:
return ""
for prefix, note in _PENDING_FORMULA.items():
if str(code).startswith(prefix):
return note
return ""
def _material_row(material: HandoffMaterial, result: BillResult) -> BillRow:
"""자재 한 줄. 공급 구분이 안 갈렸으면 **어느 쪽에도 안 넣는다**."""
row = BillRow(
@@ -0,0 +1,69 @@
"""B09 원가계산 — **원문에는 있는데 단가에 못 실린 몫** (2026-09-09).
반쪽 단가 표시는 여태 **이름을 카탈로그에서 찾은 ** 잡았다. 그물 밖에 둘이 있다.
표에 줄은 있는데 **값을 적는** 기초잡석 운반 | 덤프트럭(15ton)
표에 아예 없고 **[] 별도라 ** 규준틀 목재·표지판
**금액이 있는 **이라 표시가 없으면 **완성된 값으로 읽힌다**(2026-09-09 실측:
규준틀 둘이 4,921,267원인데 인력만의 값이었다).
**사유를 뭉뚱그리지 않는다.** 원문이 값을 **영영 막힌 **으로 읽히고,
거리 미정·설계수량 대기 ** 풀릴 **으로 읽힌다. 사용자가 보는 뜻이 다르다.
**여기 적는 것은 원문에 있는 말뿐이다.** 값을 만들지 않는다 무엇이 빠졌는지만 적는다.
**마스터가 [] 싣게 되면 표는 지운다** 곳에 같은 말을 두면 한쪽만 고쳐진다.
"""
from __future__ import annotations
#: 공종코드 → (짧은 딱지, 사유 한 줄). 접두사로 맞춘다(갈래가 붙어도 걸리게).
KNOWN_GAPS: dict[str, tuple[str, str]] = {
# ⚠ 규준틀 둘은 **B08 인계가 이미 「재료량은 [주]④ 설계수량에 따른다라 미확보」**를 싣는다.
# 그러니 그 말을 되풀이하지 않고 **B09 쪽에서만 아는 것**만 보탠다 —
# ㉠ 지금 선 값이 **인력 품만**이라는 것 ㉡ 손율은 원문에 이미 있다는 것.
# (2026-09-09: 처음엔 「표시가 아무것도 없다」고 봤는데, 인계 사유가 화면 문구 뒤쪽에
# 잘려 안 보였던 것이다. 잘린 자리를 사유가 없는 자리로 읽지 말 것.)
"FP-11-02": (
"인력 품만",
"ⓘ 이 단가는 **인력 품만**입니다 — 목재·못은 **자재 축**으로 따로 갑니다"
"(2026-09-09 각재·판재·못이 자재 축에 섰음. 다만 관급·사급이 안 갈려 아직 금액 0). "
"재료가 금액으로 서면 손율이 함께 걸립니다 — 품셈 [주]③ 「목재의 손율은 1개소 사용당 50%」.",
),
"FP-11-03": (
"인력 품만",
"ⓘ 이 단가는 **인력 품만**입니다 — 목재·못은 **자재 축**으로 따로 갑니다"
"(2026-09-09 각재·판재·못이 자재 축에 섰음. 다만 관급·사급이 안 갈려 아직 금액 0). "
"재료가 금액으로 서면 손율이 함께 걸립니다 — 품셈 [주]③ 「목재의 손율은 1개소 사용당 80%」.",
),
"FP-12-25": (
"운반거리 미정",
"⚠ 이 값에는 **운반 몫이 빠져 있습니다** — 품셈 12-25 는 「운반 | 덤프트럭(15ton)」 줄을 "
"두었으나 시간을 적지 않았습니다. 그 값은 품셈 10-12(덤프운반)가 **운반거리로** 냅니다"
"(같은 15ton 장비). 거리가 정해지면 사토 운반·덤프 운반과 **함께** 섭니다.",
),
}
#: 조건이 서면 **빠져야 하는** 줄 — 지금은 무조건 붙어 있다.
#: ⚠ 다른 조건부 줄들(계획서 9-9)과 달리 **이것은 금액이 서 있는 줄**이다.
CONDITIONAL_INCLUDED: dict[str, str] = {
"FP-12-25": (
"ⓘ 소할(할석공 0.06인)은 품셈 12-25 가 「**브레이커 사용할 때 제외**」라 적은 줄입니다 — "
"브레이커 갈래가 서면 이 몫이 빠져야 합니다. 지금은 붙어 있습니다."
),
}
def known_gap_note(code: str | None) -> str:
"""그 공종에 **원문에는 있는데 못 실린 몫**이 있으면 사유 한 줄."""
if not code:
return ""
plain = str(code).split("#")[0]
parts = []
for prefix, (_label, note) in KNOWN_GAPS.items():
if plain.startswith(prefix):
parts.append(note)
for prefix, note in CONDITIONAL_INCLUDED.items():
if plain.startswith(prefix):
parts.append(note)
return " / ".join(parts)
@@ -82,6 +82,32 @@ OPERATOR_ALLOWANCE_NOTICE = (
)
#: ⚠ **원천이 뭉개 놓은 줄을 원문으로 되살린다** (2026-09-09).
#:
#: `mach_base_2026.json` 의 대형 브레이커 여섯 줄은 **이름 칸에 표 전체가 뭉쳐** 들어가
#: 규격이 비고 손료계수가 없다. 그래서 「대형브레이커」로 찾아지지도, 시간당 사용료가
#: 서지도 않았다 — 구조물터파기(암절취)가 그 때문에 통째로 막혀 있었다.
#:
#: 값은 **건설공사 표준품셈 제8장 (0230) 대형 브레이커** 표에서 읽었고, **두 번 검증**했다.
#: ① 계수 합이 맞는다 — 상각 3,000 + 정비 2,833 + 관리 768 = **6,601** (표의 「계」와 같다)
#: ② 같은 방식으로 읽은 굴착기(0201) 표의 「계 2,085」가 카탈로그의 손료계수
#: **0.0002085 와 정확히 일치**한다 — 열 배치를 잘못 읽지 않았다는 증거다.
#:
#: ⚠ **원천이 이 줄을 제대로 싣게 되면 이 표는 지운다.** 두 곳에 같은 값을 두면
#: 나중에 한쪽만 고쳐진다. 취득가는 원천 값을 그대로 쓴다 — 여기서는 **이름·규격·손료계수**만 채운다.
MASHED_MACHINE_FIXES: dict[str, tuple[str, str]] = {
"0230-0002": ("대형 브레이커", "0.2"),
"0230-0004": ("대형 브레이커", "0.4"),
"0230-0006": ("대형 브레이커", "0.6"),
"0230-0007": ("대형 브레이커", "0.7"),
"0230-0008": ("대형 브레이커", "0.8"),
"0230-0010": ("대형 브레이커", "1.0"),
}
#: (0230) 표의 「시간당 계」 — 규격이 달라도 같은 값이다(원문 여섯 줄 모두 6,601).
MASHED_LOSS_COEFFICIENT = Decimal("0.0006601")
class MachineCostError(LookupError):
"""기계경비를 세울 수 없는 경우. 0 으로 때우지 않고 멈춘다."""
@@ -149,6 +175,11 @@ def load_machine_catalog(file_name: str = "mach_base_2026.json") -> MachineCatal
for row in variables.get("mach_price", {}).get("records", []):
code = row["machine_code"]
coefficient = coefficients.get(code, {})
fixed = MASHED_MACHINE_FIXES.get(code)
if fixed:
# 뭉개진 줄 — 원문으로 이름·규격을 되살리고 손료계수를 채운다.
row = {**row, "machine_name": fixed[0], "specification": fixed[1]}
coefficient = {**coefficient, "loss_coefficient_per_hour": MASHED_LOSS_COEFFICIENT}
catalog.machines[code] = MachineSpec(
machine_code=code,
name=row["machine_name"],
@@ -193,3 +193,110 @@ def reference_factor_values(
raw_texts[code] = own_ref[1]
return values, provenance, failures, raw_texts
# ---------------------------------------------------------------------------
# 작업량을 **직접 준** 기계 줄 (2026-09-09)
# ---------------------------------------------------------------------------
#
# 품셈은 기계 몫을 늘 공식으로만 주지 않는다. **시간당 작업량을 바로 적는** 줄이 있다.
#
# ['장비 (90%)', '깨기', '대형브레이커(㎥/hr)', '3.5', 'Q=(3.2+3.8)/2 (연암평균치 적용)']
#
# 이 줄을 못 읽으면 암·발파암 갈래의 **깨기 몫이 통째로 빠진다** — 들어내기(백호우)만
# 붙어 「일부만 선 단가」로 남는다.
#
# ⚠ **단위가 붙어 있을 때만 읽는다.** 「(㎥/hr)」·「(m/hr)」처럼 시간당 작업량임을
# 표가 스스로 밝힌 줄만 본다. 숫자만 있는 칸을 작업량으로 넘겨짚지 않는다.
_CAPACITY_UNIT = re.compile(r"[(]\s*(㎥|m3|㎡|m2|m|ton|t)\s*/\s*(?:hr|시간)\s*[)]")
def _paired_machine_spec(node: dict[str, Any]) -> str:
"""그 표에 함께 나오는 기종의 규격(「유압식백호우 (무한궤도,0.7㎥)」 → 0.7)."""
from B09_Estimation.B09_Estimation_MachineProductivity import resolve_machine
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
catalog = load_machine_catalog()
for table in node.get("tables", []):
for row in table.get("raw_row") or []:
for cell in row:
found = resolve_machine(_clean(cell))
if found is not None:
machine = catalog.machines.get(found[0])
if machine is not None and machine.specification:
return str(machine.specification)
return ""
def _machine_by_name(text: str, preferred_spec: str) -> tuple[str, str] | None:
"""이름만으로 기종을 고른다 — 규격이 여럿이면 **짝의 규격**을 따른다.
대형브레이커(/hr) 괄호가 **규격이 아니라 단위** 보통 길로는 풀린다.
"""
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
wanted = re.sub(r"\s", "", text)
if not wanted:
return None
catalog = load_machine_catalog()
hits = [
(code, machine)
for code, machine in catalog.machines.items()
if wanted and wanted in re.sub(r"\s", "", machine.name)
]
if not hits:
return None
if preferred_spec:
narrowed = [item for item in hits if str(item[1].specification) == str(preferred_spec)]
if len(narrowed) == 1:
return narrowed[0][0], narrowed[0][1].name
return (hits[0][0], hits[0][1].name) if len(hits) == 1 else None
def direct_capacity_rows(node: dict[str, Any]) -> list[dict[str, Any]]:
"""그 공종에서 **시간당 작업량을 직접 준 기계 줄**들.
돌려주는 기계 이름 · 기종 코드/이름 · 시간당 작업량 · 묶음 배분율(%) · 원문 문구.
"""
from B09_Estimation.B09_Estimation_MachineProductivity import parse_measure
found: list[dict[str, Any]] = []
ratio: Decimal | None = None
# 같은 표에 짝이 되는 기종이 있으면 **그 규격**을 따른다 — 「대형브레이커」는 규격을
# 안 적고, 실무도 「대형브레이커 + B/H 0.7」처럼 붙는 굴착기 규격으로 잡는다.
paired_spec = _paired_machine_spec(node)
for table in node.get("tables", []):
for row in table.get("raw_row") or []:
cells = [_clean(cell) for cell in row]
if not cells:
continue
seen_ratio = re.search(r"[(]\s*(\d+(?:\.\d+)?)\s*%\s*[)]", cells[0])
if seen_ratio:
ratio = Decimal(seen_ratio.group(1))
for index, cell in enumerate(cells):
if not _CAPACITY_UNIT.search(cell):
continue
machine = _machine_by_name(_CAPACITY_UNIT.sub("", cell).strip(), paired_spec)
if machine is None:
continue
capacity = next(
(parse_measure(token) for token in cells[index + 1 :] if parse_measure(token)),
None,
)
if capacity is None or capacity <= 0:
continue
found.append(
{
"cell": cell,
"machine_code": machine[0],
"machine_name": machine[1],
"capacity_per_hour": capacity,
"ratio_pct": ratio,
"table_id": str(table.get("pum_table_id", "")),
# 그 줄의 칸들 — 「못 붙은 줄」 목록에서 이 줄을 걷어내는 데 쓴다.
"row_cells": [c for c in cells if c],
}
)
return found
@@ -350,6 +350,10 @@ def _resolve_cell(catalog: ResourceCatalog, name_cell: str, cells: list[str]):
return None
#: 공종 단위로 인정하지 않는 말 — **품의 단위**(사람·날)이지 물리 수량이 아니다.
_NOT_A_WORK_ITEM_UNIT = frozenset({"", "인당", "", "일당", "인/일"})
def match_table(
node: dict[str, Any],
table: dict[str, Any],
@@ -378,6 +382,15 @@ def match_table(
if match_three_axis_table(node, table, catalog, result):
return
# ⚠ **「둘 중 하나를 고르는」 장비 블록 표도 먼저 가른다.** 그냥 읽으면 블록을 다 더해
# 장비 두 대·인부 두 몫이 서서 대략 두 배가 된다(2026-09-09 제근 128,039원).
from B09_Estimation.B09_Estimation_ResourceAxis_ChooseOne import (
match_choose_one_machine_table,
)
if match_choose_one_machine_table(node, table, catalog, result):
return
form = table.get("pum_form", "")
if form in NON_WORK_ITEM_FORMS or form in UNUSABLE_FORMS or form not in USABLE_FORMS:
result.skipped_forms[form] = result.skipped_forms.get(form, 0) + 1
@@ -386,6 +399,13 @@ def match_table(
basis = table.get("basis_quantity")
basis_quantity = None if basis is None else Decimal(str(basis))
unit = table.get("basis_unit") or ""
# ⚠ **「인」은 공종 단위가 아니다** — 사람 수다. 마스터가 본문·표에서 「인」을 밑수로
# 읽어 온 자리가 있고(드론방제·지상방제·야면석 채집·휘발유), 그대로 두면 「몇 인짜리
# 공종」이라는 뜻이 되어 **단위 불일치 검사가 엉뚱하게 통과**한다.
# ⚠ 원문 고치기는 마스터 쪽 몫(2026-09-09 데스크탑 메인) — 여기서는 **받는 쪽에서 막는다.**
# 짝 규칙은 `B09_Estimation_WorkItemUnit` 머리말에 이미 적어 둔 것과 같다.
if unit.strip() in _NOT_A_WORK_ITEM_UNIT:
unit = ""
# ⚠ **한 표에 밑수가 둘인 표가 있다** — 「㎡당 0.17 / ㎥당 0.64」(채집 13-2 계열).
# 행-자원으로 읽으면 **앞줄만 잡고 뒷줄을 버린다** — 막돌 채집이 ㎡당 값을 ㎥ 단위로
@@ -0,0 +1,200 @@
"""B09 원가계산 — **표가 「둘 중 하나를 고르라」고 둔 장비 블록** 읽기 (2026-09-09).
품셈에는 같은 일을 **장비 규격에 따라 달리 세는** 표가 있다. 블록이 둘인데 ** 더하면
장비 대와 인부 몫이 서서 대략 ** 된다.
9-21 제근
| | | 단위 | | | |
| 굴착기(무한궤도) | 굴착기(무한궤도,0.2) | hr | 0.80 | 1.01 | 1.22 | 0.2 블록
| 보통인부 | | 0.03 | 0.04 | 0.05 |
| 굴착기(무한궤도,0.7) | hr | 0.46 | 0.58 | 0.70 | 0.7 블록
| 보통인부 | | 0.03 | 0.04 | 0.05 |
2026-09-09 실측: 제근 단가가 **128,039**으로 있었다 굴착기 0.2·0.7 붙고
보통인부도 붙은 값이다. 밑수가 원문에 없어 아직 금액이 있었을 ,
**밑수가 정해지는 조용히 배로 자리**였다.
읽는 **블록마다 갈래 하나**, 열마다 갈래 하나. 둘을 곱해 갈래를 낸다.
갈래 = 굴착기(무한궤도) 0.2 · 굴착기(무한궤도) 0.7 · (2 × 3 = 6)
**고르는 인지 아닌지를 좁게 가른다.** 기계 줄이 둘이라고 고르는 표가 아니다
9-13 암절취는 깨기(대형브레이커) 들어내기(백호우) **함께 드는** 표다.
가르는 자국은 **같은 기계 이름에 규격만 다른 **이다(굴착기 0.2 vs 0.7). 품셈도 그렇게
말한다 9-20-1 [] 0.2 또는 0.4 용량의 굴착기를 사용하는 경우에는 적용계수를
달리 적용하도록 한다.
**밑수는 여기서 만들지 않는다.** 9-21 머리·제목·[] 어디에도 밑수가 없다
(2026-09-09 원문 전수 확인). 갈래만 바로 세우고 밑수는 채로 둔다.
"""
from __future__ import annotations
import re
from decimal import Decimal
from typing import Any
from B09_Estimation.B09_Estimation_ResourceAxis import (
AxisResult,
CatalogEntry,
ResourceCatalog,
ResourceRow,
UnmatchedRow,
parse_amount,
split_name_and_spec,
)
_NUMBER = re.compile(r"^\d+(?:\.\d+)?$")
#: 열 머리로 인정하지 않는 말 — 값이 아니라 설명이다.
_NOT_A_COLUMN = ("비고", "적요", "참고", "단위", "명칭", "명 칭", "종류", "종 류", "규격", "규 격")
def _clean(cell: Any) -> str:
return " ".join(str(cell or "").split())
def _column_labels(header: list[Any]) -> list[str]:
"""표 머리에서 **갈래 열 이름**만 골라 낸다 — 「소·중·밀」."""
labels = [_clean(cell) for cell in header]
return [
label
for label in labels
if label and "".join(label.split()) not in {"".join(w.split()) for w in _NOT_A_COLUMN}
]
def _machine_of(cells: list[str], catalog: ResourceCatalog):
"""그 줄이 기계 줄이면 (칸 번호, 기종, 원문 칸). 아니면 `None`.
자원 카탈로그의 이름·규격 짝으로는 풀린다 품셈이 굴착기(무한궤도,0.2)처럼
**규격을 괄호 안에 몰아** 적기 때문이다. 기종 해석은 모양을 아는 쪽에 맡긴다.
"""
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
from B09_Estimation.B09_Estimation_MachineProductivity import resolve_machine
machines = load_machine_catalog().machines
for index, cell in enumerate(cells):
found = resolve_machine(cell)
if found is None:
continue
machine = machines.get(found[0])
if machine is None:
continue
entry = CatalogEntry(
code=found[0], name=machine.name, kind="machine", spec=str(machine.specification)
)
return index, entry, cell
return None
def _values_of(cells: list[str], count: int) -> list[Decimal] | None:
"""그 줄 끝에서 값 `count` 개. 개수가 안 맞으면 `None` — 짐작해 채우지 않는다."""
numbers = [cell for cell in cells if _NUMBER.match(cell)]
if len(numbers) < count:
return None
picked = [parse_amount(cell) for cell in numbers[-count:]]
return None if any(value is None for value in picked) else picked # type: ignore[return-value]
def match_choose_one_machine_table(
node: dict[str, Any],
table: dict[str, Any],
catalog: ResourceCatalog,
result: AxisResult,
) -> bool:
"""「둘 중 하나를 고르는」 장비 블록 표를 읽는다. 그런 표가 아니면 `False`.
같은 기계 이름에 **규격만 다른** 블록이 이상일 때만 표로 본다.
"""
# ⚠ **기계 카탈로그가 없는 조립에서는 이 표를 읽지 않는다.** 기종 해석은 기계 쪽
# 카탈로그를 직접 보므로, 노무만 든 카탈로그로 돌릴 때도 기계 줄이 나와 버린다
# (2026-09-09 시험이 그것을 잡았다). **넘겨받은 카탈로그의 결을 따른다.**
if not any(entry.kind == "machine" for entry in catalog.entries):
return False
rows = [row for row in (table.get("raw_row") or []) if isinstance(row, list)]
header = list(table.get("condition_note") or [])
labels = _column_labels(header)
if len(rows) < 2 or len(labels) < 2:
return False
blocks: list[dict[str, Any]] = []
for row in rows:
cells = [_clean(cell) for cell in row]
if not cells:
continue
found = _machine_of(cells, catalog)
if found is not None:
_index, entry, raw_cell = found
values = _values_of(cells, len(labels))
if values is None:
return False
blocks.append({"entry": entry, "cell": raw_cell, "rows": [], "values": values})
continue
if not blocks:
continue
name, spec = split_name_and_spec(cells[0])
entry = catalog.resolve(name, spec)
values = _values_of(cells, len(labels))
if entry is None or values is None:
continue
blocks[-1]["rows"].append({"entry": entry, "values": values, "cell": cells[0]})
if len(blocks) < 2:
return False
# ⚠ **같은 이름 · 다른 규격**일 때만 「고르는 표」다. 이름이 다르면 함께 드는 장비다.
names = {block["entry"].name for block in blocks}
specs = {block["entry"].spec for block in blocks}
if len(names) != 1 or len(specs) != len(blocks):
return False
work_item_code = str(node.get("work_item_code", ""))
table_id = str(table.get("pum_table_id", ""))
form = str(table.get("pum_form", ""))
unit = table.get("basis_unit") or ""
made = 0
for block in blocks:
machine = block["entry"]
for column, label in enumerate(labels):
variant = f"{machine.name} {machine.spec} · {label}".strip()
entries = [(machine, block["values"][column], block["cell"])]
entries += [
(item["entry"], item["values"][column], item["cell"]) for item in block["rows"]
]
for entry, amount, cell in entries:
result.rows.append(
ResourceRow(
work_item_code=work_item_code,
pum_table_id=table_id,
pum_form=form,
resource_kind=entry.kind,
resource_code=entry.code,
resource_name=entry.name,
resource_spec=entry.spec,
amount=amount,
amount_unit=unit,
raw_row_index=0,
variant=variant,
)
)
made += 1
del cell
if made == 0:
return False
result.unmatched.append(
UnmatchedRow(
work_item_code=work_item_code,
pum_table_id=table_id,
cell=" | ".join(_clean(cell) for cell in header),
reason=(
f"장비 규격 {len(blocks)} 가지 × 갈래 {len(labels)} 가지로 세웠습니다 — "
"표가 「둘 중 하나」로 둔 자리라 **더하지 않고 고르게** 합니다. "
"⚠ 밑수(무엇당)는 원문에 없습니다."
),
)
)
return True
+205 -3
View File
@@ -19,6 +19,7 @@ from __future__ import annotations
import re
from dataclasses import dataclass, field
from dataclasses import replace as dataclass_replace
from decimal import Decimal
from functools import lru_cache
@@ -102,6 +103,8 @@ class UnitPriceBuild:
#: ⚠ 값이 남의 절에서 온 것이면 **화면이 그렇게 말해야** 한다 — 안 그러면 나중에
#: 「이 숫자 어디서 왔지」로 되짚을 길이 없다.
factor_sources: dict[str, str] = field(default_factory=dict)
#: 조합 사용(품셈 [주]⑤)으로 **잡재료 16% 층으로 바꿔 단 줄** 수.
combined_swapped: int = 0
#: 배분율 표인데 일부 몫만 붙은 공종 — 「단가가 일부만 섬」. 값은 붙은 몫(%).
partial_ratio: dict[str, Decimal] = field(default_factory=dict)
#: 시공능력 공식으로 장비 몫을 세운 공종 — 산출근거를 화면에 그대로 보인다.
@@ -132,6 +135,55 @@ def _add_labor_titles(book: PriceBook, wages: dict[str, Decimal]) -> None:
)
#: 부착용 장비 — 제 엔진이 없어 **손료만** 붙는다(품셈 제8장 [주]⑤).
#: ⚠ 이 목록을 넓히지 말 것 — 넓히면 연료가 빠진 기계가 조용히 싸게 선다.
_ATTACHMENT_WORDS = ("브레이커", "리퍼", "부착용집게", "집게")
#: 조합 사용 시 본체(굴착기·불도저)의 잡재료비율 — 품셈 제8장 [주]⑤.
#: 「…리퍼, 브레이커, 부착용집게를 **조합하여 사용**할 때는 …**잡재료비율을 16%로 계상**하고,
#: 리퍼, 브레이커, 부착용 집게의 손료 및 치즐 소모율을 추가하는 것이다.」
#: ⚠ 손료·치즐만 더하고 이 줄을 빠뜨리기 쉽다 — 그러면 본체 재료비가 계속 22% 로 서서
#: 조금씩 비싸진다(굴착기 0.7 기준 시간당 1,285원).
COMBINED_MISC_PERCENT = Decimal(16)
#: 조합으로 쓰는 부착 장비 — 이 층이 붙은 공종의 본체는 위 비율을 쓴다.
#: 카탈로그 분류번호로 잡는다 — 0103 유압식 리퍼 · 0230 대형 브레이커 ·
#: 0240 유압식 진동콤팩터(굴착기 부착용) · 7206 부착용 집게.
#: ⚠ 이름이 아니라 **번호**로 잡는다 — 이름은 원천이 뭉개 놓는 일이 있다(0230 이 그랬다).
_ATTACHMENT_PREFIXES = ("X-0103-", "X-0230-", "X-0240-", "X-7206-")
def _apply_combined_misc_rate(book: PriceBook, work_item_titles: list[str]) -> int:
"""조합 사용 공종의 본체 기계를 **잡재료 16% 짜리 층**으로 바꿔 단다.
바꾼 수를 돌려준다. 원래 층은 그대로 둔다 조합이 아닌 공종은 22% 그대로다.
"""
swapped = 0
for title_code in work_item_titles:
details = book.details.get(title_code) or []
if not any(detail.ref_code.startswith(_ATTACHMENT_PREFIXES) for detail in details):
continue
for index, detail in enumerate(details):
if not detail.ref_code.startswith("X-") or detail.ref_code.startswith(
_ATTACHMENT_PREFIXES
):
continue
combined = f"{detail.ref_code}#조합"
if combined not in book.titles:
continue
details[index] = dataclass_replace(
detail,
ref_code=combined,
note=(
(detail.note + " · " if detail.note else "")
+ "조합 사용 — 잡재료 16% (품셈 제8장 [주]⑤)"
),
)
swapped += 1
return swapped
def _add_machine_layers(book: PriceBook, machine_codes: set[str]) -> list[str]:
"""`S`(취득가) · `L`(운전사) · `M`(연료) 을 세우고 그 위에 `X` 를 올린다.
@@ -159,7 +211,21 @@ def _add_machine_layers(book: PriceBook, machine_codes: set[str]) -> list[str]:
for code in sorted(machine_codes):
machine = catalog.machines.get(code)
record = operating.get(code)
if machine is None or machine.loss_coefficient_per_hour is None or record is None:
# ⚠ **부착용 장비는 운전경비표에 줄이 없다** — 제 엔진이 없어 연료·조종원이
# 본체(굴착기·불도저)에 든다. 품셈 제8장 [주]⑤ 가 그 자리를 밝힌다:
# 「불도저 및 굴착기에 **리퍼, 브레이커, 부착용집게를 조합하여 사용**할 때는
# …잡재료비율을 16%로 계상하고, **리퍼, 브레이커, 부착용 집게의 손료 및
# 치즐 소모율을 추가**하는 것이다.」
# ⇒ 그 셋은 **손료만으로** 층을 세운다. 운전경비 줄이 없다고 통째로 버리면
# 깨기 몫이 영영 안 붙는다(구조물터파기 암 갈래가 그 자리였다).
# ⚠ **다른 기계에는 이 길을 열지 않는다** — 연료가 빠진 채 조용히 싼 값이 선다.
attachment = machine is not None and any(
word in re.sub(r"\s", "", machine.name) for word in _ATTACHMENT_WORDS
)
if machine is None or machine.loss_coefficient_per_hour is None:
incomplete.append(code)
continue
if record is None and not attachment:
incomplete.append(code)
continue
@@ -192,12 +258,53 @@ def _add_machine_layers(book: PriceBook, machine_codes: set[str]) -> list[str]:
)
book.add_detail(PriceDetail(hourly_code, base_code, Decimal(1), note="시간당 손료"))
if record is None:
# 부착용 장비 — 여기서 끝난다. 연료·조종원은 본체 줄에 이미 들어 있다.
book.add_detail(
PriceDetail(
hourly_code,
base_code,
Decimal(0),
note=(
"부착용 장비 — 연료·조종원은 본체(굴착기·불도저)에 듭니다"
" (품셈 제8장 [주]⑤)."
),
)
)
continue
liters = record.fuel_liters_per_hour
if liters is not None:
if record.misc_material_percent is not None:
# 잡재료는 **주연료의 %** — 유가와 같이 움직인다.
liters = liters * (Decimal(1) + record.misc_material_percent / Decimal(100))
book.add_detail(PriceDetail(hourly_code, fuel_code, liters, note="주연료 + 잡재료"))
# 조합 사용(리퍼·브레이커·집게)일 때 쓸 **잡재료 16%** 짜리 층을 함께 세운다.
# 같은 기계라도 조합이면 본체 잡재료가 줄어든다(품셈 제8장 [주]⑤).
combined_code = f"{hourly_code}#조합"
if combined_code not in book.titles:
book.add_title(
PriceTitle(
code=combined_code,
kind=PriceKind.MACHINE_HOURLY,
name=machine.name,
spec=(f"{machine.specification} · 조합").strip(" ·"),
unit="hr",
)
)
book.add_detail(
PriceDetail(combined_code, base_code, Decimal(1), note="시간당 손료")
)
book.add_detail(
PriceDetail(
combined_code,
fuel_code,
record.fuel_liters_per_hour
* (Decimal(1) + COMBINED_MISC_PERCENT / Decimal(100)),
note=f"주연료 + 잡재료 {COMBINED_MISC_PERCENT}% (조합 사용)",
)
)
else:
incomplete.append(f"{code} (연료소모량 없음)")
@@ -217,6 +324,17 @@ def _add_machine_layers(book: PriceBook, machine_codes: set[str]) -> list[str]:
slots=_slots(wages[wage_code]),
)
)
# 조합 층에도 조종원을 같이 단다 — 본체를 모는 사람은 하나뿐이다.
combined_code = f"{hourly_code}#조합"
if combined_code in book.titles:
book.add_detail(
PriceDetail(
combined_code,
wage_code,
per_hour_person,
note="조종원 (1일 8시간 × 제수당·상여·퇴직충당 16/12 × 25/20)",
)
)
book.add_detail(
PriceDetail(
hourly_code,
@@ -272,6 +390,25 @@ def normalize_variant_key(text: str) -> str:
return "".join("~" if ch in _TILDE_CHARS else ch for ch in tight)
#: 갈래 이름이 **축이 달라 다르게 불리는** 자리 — 이름을 갈지 않고 여기서 잇는다.
#:
#: ⚠ **우리 축과 품셈 축이 다르다.** 우리 「리핑암」은 B05·B06 의 **지반유형**(캘 수 있는가)이
#: 낳은 이름이고, 품셈 운반표의 「파쇄암」은 **운반할 때의 상태**(부서졌는가)를 가리킨다.
#: 그래서 일반적으로는 「리핑암 = 파쇄암」이 아니다 — **발파암도 캐고 나면 파쇄암 상태**다
#: (건설품셈 8장 「발파 또는 리퍼작업 등에 의하여 얻어진 암과 파쇄암…」).
#:
#: ⭐ **다만 이 표 안에서는 성립한다.** 산림품셈 10-11 f 표가 **토사·파쇄암·발파암을 따로**
#: 두었으므로 그 표의 「파쇄암」은 **발파를 뺀 나머지 = 리퍼로 얻은 것**이다. 결정적 증거는
#: 10-12 [주]③ 이다 — 「적재 재료의 토량환산계수(L)는 토사 1.3, **암절취 1.35**, 발파암
#: 1.625 적용한다」. 10-11 f 표의 **파쇄암이 1/1.35** 라 **암절취(리핑)과 같은 값**이다.
#:
#: ⚠ **어느 쪽 이름도 갈지 않는다** — 우리 이름을 갈면 B05·B06 이 깨지고, 품셈 이름을 갈면
#: 원문과 어긋난다. 잇는 자리는 여기 한 곳뿐이다.
VARIANT_ALIASES: dict[str, str] = {
"리핑암": "파쇄암",
}
def find_variant_code(
work_item_code: str,
variant_value: str,
@@ -286,6 +423,7 @@ def find_variant_code(
wanted = normalize_variant_key(variant_value)
if not wanted:
return None
wanted = VARIANT_ALIASES.get(wanted, wanted)
prefix = f"B-{work_item_code}#"
candidates = [code for code in prices.book.titles if code.startswith(prefix)]
@@ -353,6 +491,7 @@ def build_unit_prices(
)
from B09_Estimation.B09_Estimation_MachineProductivity_Reference import (
direct_capacity_rows,
reference_factor_values,
)
@@ -374,9 +513,22 @@ def build_unit_prices(
for failed_code, why in borrow_fail.items():
build.factor_sources.setdefault(failed_code, f"{why}")
build.component_gaps = dict(axis.partial_items)
# 「작업량을 직접 준」 기계(깨기 대형브레이커)도 사용료 층을 세운다 — 안 세우면
# 그 줄이 붙을 데가 없어 암·발파암 갈래의 깨기 몫이 통째로 빠진다.
capacity_rows = {
str(node.get("work_item_code", "")): direct_capacity_rows(node)
for node in master.get("work_items", [])
}
capacity_rows = {code: rows for code, rows in capacity_rows.items() if rows}
# ⚠ **참조로 이미 푼 줄은 「못 붙은 줄」이 아니다.** 안 걷어 내면 다 풀린 공종이
# 계속 반쪽으로 보이고, 그 표시를 믿고 막아 둔 금액이 영영 안 선다.
resolved_rows = {code: text for code, text in borrow_text.items() if code in borrow_note}
# 작업량을 직접 준 줄도 이제 붙었다 — 그 줄의 칸들은 「못 붙은 줄」이 아니다.
capacity_cells: dict[str, set[str]] = {}
for capacity_code, capacity_list in capacity_rows.items():
for entry in capacity_list:
capacity_cells.setdefault(capacity_code, set()).update(entry.get("row_cells") or [])
for unmatched_row in axis.unmatched:
# ⚠ 지역 이름을 조심할 것 — 바로 위 `names` 는 **공종 이름표**다. 같은 이름을 쓰면
# 그 표가 리스트로 덮여 조립이 통째로 터진다(2026-09-09 실측).
@@ -385,16 +537,35 @@ def build_unit_prices(
reference_text = resolved_rows.get(unmatched_row.work_item_code)
if reference_text and reference_text in label:
continue # 그 줄은 참조를 따라가 값을 얻었다
if label in capacity_cells.get(unmatched_row.work_item_code, set()):
continue # 그 줄은 표가 작업량을 직접 줘 붙었다
if label and label not in labels:
labels.append(label)
# ⚠ **거두는 자리는 「못 붙은 줄」을 다 모은 뒤다.** 앞에서 거두면 목록이 비어 있어
# **전부 거둬지고**, 깨기(대형브레이커)가 빠진 암 계열까지 「다 찼다」로 선다
# (2026-09-09 실측). 남은 줄이 하나도 없을 때만 거둔다.
def _names_a_machine(label: str) -> bool:
"""그 줄이 **기계를 가리키나** — 기계가 빠지면 막고, 자재가 빠지면 드러내기만 한다.
가지는 무게가 다르다. 기계 몫은 단가의 대부분이라 빠지면 금액이 통째로
틀리고, 자재 소모품(치즐 0.006/hr) 카탈로그가 서면 채워지는 알려진 미결이다
(자원 축이 이미 규칙으로 가른다).
"""
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
flat = re.sub(r"\s", "", label)
return any(
re.sub(r"\s", "", machine.name) in flat
for machine in load_machine_catalog().machines.values()
if len(re.sub(r"\s", "", machine.name)) >= 3
)
solved_codes = {
code
for code in borrow_note
if code in axis.partial_items and not build.unattached.get(code)
if code in axis.partial_items
and not any(_names_a_machine(label) for label in build.unattached.get(code, []))
}
for code in solved_codes:
build.component_gaps.pop(code, None)
@@ -414,6 +585,7 @@ def build_unit_prices(
machine_codes = {r.resource_code for r in axis.rows if r.resource_kind == "machine"}
# 공식표에만 나오는 기종도 사용료 층을 세운다 — 안 세우면 공식이 붙을 데가 없다.
machine_codes |= formula_machine_codes(master)
machine_codes |= {row["machine_code"] for rows in capacity_rows.values() for row in rows}
build.incomplete_machines = _add_machine_layers(build.book, machine_codes)
# 규격 갈래(`variant`)가 있으면 **갈래마다 따로 세운다** — 무근·철근·소형구조물은
@@ -533,6 +705,31 @@ def build_unit_prices(
build.factor_sources,
)
# 작업량을 직접 준 기계 줄(깨기) — 공식 몫과 **자리를 나눠 쓴다**. 같은 묶음(장비
# 90%) 안에서 깨기와 들어내기가 차례로 붙는다.
attached_capacity = False
for capacity in capacity_rows.get(work_item_code, []):
hourly_code = f"X-{capacity['machine_code']}"
if hourly_code not in build.book.titles:
continue
group_share = (
Decimal(1)
if capacity["ratio_pct"] is None
else Decimal(str(capacity["ratio_pct"])) / Decimal(100)
)
build.book.add_detail(
PriceDetail(
title_code,
hourly_code,
(Decimal(1) / capacity["capacity_per_hour"]) * group_share,
note=(
f"작업량을 표가 직접 줌 — {capacity['cell']} {capacity['capacity_per_hour']}"
f" (품셈 원문 표기 그대로)"
),
)
)
attached_capacity = True
# ⚠ **배분율이 있는 표는 「몇 %가 실제로 붙었나」를 세어 둔다.**
# 「인력(10%)·장비(90%)」 표에서 인력만 붙으면 단가가 10 % 몫만인데, 그 값이
# 조용히 서면 내역서가 틀린 줄 모른다(2026-09-08 실측: 측구터파기 39,575.6원/㎥
@@ -543,7 +740,7 @@ def build_unit_prices(
# ⚠ 공식은 있는데 **아무것도 안 붙은** 제목은 남기지 않는다 — 「상세 줄이 없어
# 조립 불가」로 화면에서 터진다. 기계 층이 못 선 경우가 그 자리다.
if not attachable and not build.book.details.get(title_code):
if not attachable and not attached_capacity and not build.book.details.get(title_code):
build.book.titles.pop(title_code, None)
if variant in build.variants.get(work_item_code, []):
build.variants[work_item_code].remove(variant)
@@ -555,6 +752,11 @@ def build_unit_prices(
covered += machine_share
if covered < Decimal(100):
build.partial_ratio[work_item_code] = covered
# ⚠ **마지막에 한 번** — 조합 사용 공종의 본체 기계를 잡재료 16% 층으로 바꿔 단다
# (품셈 제8장 [주]⑤). 조립 도중에 바꾸면 어느 공종이 조합인지 아직 모른다.
build.combined_swapped = _apply_combined_misc_rate(
build.book, [code for code in build.book.titles if code.startswith("B-")]
)
return build
@@ -167,6 +167,8 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict:
money = build.book.resolve(code)
# 코드에서 공종을 도로 뽑는다 — 「B-FP-09-11-01#갈래」의 갈래는 떼고 본다.
work_item_code = code[2:].split("#")[0] if code.startswith("B-") else ""
from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note
unattached = list(build.unattached.get(work_item_code, []))
rows: list[dict] = []
for detail in build.book.details.get(code, []):
@@ -261,6 +263,8 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict:
# ⚠ **표에 있는데 못 붙은 줄** — 이 단가가 일부만으로 섰다는 뜻이다.
# 안 보이면 조용히 싼 단가가 내역서에 그대로 든다.
"unattached": unattached,
# ⚠ 원문에는 있는데 못 실린 몫 — 이름을 못 찾은 줄과 **다른 갈래**다.
"known_gap_note": known_gap_note(work_item_code),
"unattached_note": (
f"⚠ 품셈 표에 있는 {len(unattached)}줄이 아직 안 붙었습니다 — "
f"{', '.join(unattached[:4])}"
+9 -1
View File
@@ -64,7 +64,15 @@ WALL_BLINDING_WIDTH_M = 0.7 # 그때의 폭 (여유폭 0.0 — 버림 탭에는
#: 근거의 급 — 화면 노티스가 이 값을 그대로 쓴다.
BASIS_PIPE = "법정 표(KCS 44 40 10 그림 3.2-1) — 설계도서에 따로 없으면 이 값"
BASIS_WALL = "법정 근거 없음 · 실무 정본 식(구조도 기슭막이 xls) 기본값 — 확인 후 쓸 것"
#: ⭐ 2026-09-09 **사용자 확정 5차 1번** — 「비탈 터파기를 셀 것인가」에 **「지금 이대로」**로 답이
#: 왔다(기초 + 비탈 두 줄). 그래서 문구에서 **「확인 후 쓸 것」을 걷는다** — 확인이 끝났다.
#: ⚠ 「법정 근거 없음」은 그대로 둔다(사실이 안 바뀜) — 근거의 급은 여전히 실무 정본이다.
#: ⚠ 소광리 07-구조도는 **비탈 몫을 안 적는다**(설계사무소 간 차이) — 그래도 우리는 정본
#: 04.구조도(기슭막이)의 두 줄을 따른다는 것이 이 확정의 뜻이다.
BASIS_WALL = (
"법정 근거 없음 · 실무 정본 식(구조도 기슭막이 xls) — 기초 + 비탈 두 줄."
" **2026-09-09 사용자 확정 5차 1번으로 「지금 이대로」 확정**"
)
def pipe_trench_width_m(diameter_mm: float | int | None) -> float | None:
@@ -127,6 +127,23 @@ def default_settings() -> dict[str, Any]:
# ⚠ **사용자 확정이 아니라 통상값**이다(확정 3차 ④). 화면·사유에 그 사실을
# 적어 두고 사용자가 뒤집을 수 있게 둔다.
"structure_trench_water": "육상",
# 표토 운반거리(m) — ⚠ 별표2 Ⅰ.2.차.(6) 「표토는 전량 제거한 후 … 최고 홍수위보다
# 높은 장소로 **운반하고 쌓아두어야** 한다」. 제거만 세면 법이 요구하는 운반이 빠진다.
# 거리는 현장값이라 품셈이 정하지 않는다 — 비면 운반 줄이 막힌 채로 선다.
"topsoil_haul_distance_m": None,
# 임목축적 등급 — 품셈 9-21 제근이 **소·중·밀** 셋으로 갈리는 축.
# [주]① 「소림 30~60㎥/㏊ · 중림 60~90 · 밀림 90 이상」 — 본수가 아니라 **축적**이다.
# ⚠ 산림조사부·영림계획에서 옮겨 적는 값이라 프로그램이 만들 수 없다. 비면 막는다.
"stand_volume_class": None,
# 규준틀 개소당 재료 — `{자재명: 개소당 수량}`. **비우면 제안값(실무 관측)이 선다.**
# 품셈 11-2·11-3 [주]④ 가 「재료량은 설계수량에 따른다」라 값을 안 주는 자리이고,
# 세는 것 자체는 확정이라 **제안값 + 고칠 수 있게**로 둔다(확정 ⑨·⑩ 과 같은 틀).
"frame_material": {},
# 임목파쇄 — **기본 꺼짐**(확정 5차 5번). 현장에 따라 켤 수 있는 칸.
# ⚠ 근주이식은 칸도 두지 않는다 — 「셀지 말지가 설계 판단」이라 켤 자리가 없으면
# 물을 일도 없다. 켜도 **부피는 지어내지 않는다**(아래 칸이 비면 줄만 서고 사유).
"wood_chipping_enabled": False,
"wood_chipping_volume_m3": None,
"dataset_versions": {},
},
"estimation": {
@@ -1,7 +1,7 @@
{
"schema_version": "1.0",
"dataset_id": "data_work_item_master_manifest",
"generated_at": "2026-09-08T08:33:10+09:00",
"generated_at": "2026-09-09T11:44:28+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": "3752af1e0328a2faf4947268e449e0bc602a0738cc6a29a78ed71d343cf2e583",
"size_bytes": 856832
"sha256": "3462899767156784158f724513c793052711806260e60db59d3e2a5fbf1fab21",
"size_bytes": 856810
},
{
"file": "form_undetermined_2026-01-01.json",
@@ -22,8 +22,8 @@
},
{
"file": "basis_missing_2026-01-01.json",
"sha256": "244851609c65ba8adbfc318668224cbf3a81032e9271cfda916de435d460dd92",
"size_bytes": 18360
"sha256": "644b179f7d4b57641fc4ea6df27ef3e3904a03a0bf49c6a4a8bcf44f1195bdb1",
"size_bytes": 19018
}
]
}
@@ -280,6 +280,12 @@
"pum_form": "requirement",
"line": 2653
},
{
"pum_table_id": "F0127",
"section": "5-19-2. 표토펴기 및 고르기",
"pum_form": "requirement",
"line": 2664
},
{
"pum_table_id": "F0129",
"section": "5-21. 표토이식",
@@ -586,6 +592,24 @@
"pum_form": "requirement",
"line": 4434
},
{
"pum_table_id": "F0228",
"section": "8-6-2. 드론방제",
"pum_form": "requirement",
"line": 4516
},
{
"pum_table_id": "F0229",
"section": "8-6-2. 드론방제",
"pum_form": "requirement",
"line": 4536
},
{
"pum_table_id": "F0230",
"section": "8-6-3. 지상방제",
"pum_form": "requirement",
"line": 4557
},
{
"pum_table_id": "F0232",
"section": "8-7. 방제 실행 등록",
@@ -754,6 +778,12 @@
"pum_form": "requirement",
"line": 6943
},
{
"pum_table_id": "F0401",
"section": "13-2-4. 야면석 채집(인력)",
"pum_form": "requirement",
"line": 7025
},
{
"pum_table_id": "F0405",
"section": "13-3. 기초다짐 및 뒤채움’ 항을 적용한다.",
@@ -2,7 +2,7 @@
"schema_version": "1.0",
"dataset_id": "work_item_master_forest",
"effective_date": "2026-01-01",
"generated_at": "2026-09-08T08:33:10+09:00",
"generated_at": "2026-09-09T11:44:28+09:00",
"dataset_version": {
"dataset_id": "pum_forest",
"effective_date": "2026-01-01",
@@ -21,9 +21,9 @@
"tables_attached": 456,
"tables_orphan": 19,
"form_undetermined": 77,
"basis_found": 185,
"basis_missing": 135,
"basis_grouped": 36
"basis_found": 180,
"basis_missing": 140,
"basis_grouped": 35
},
"orphan_tables": [
{
@@ -15789,9 +15789,9 @@
"source_line": 2664,
"pum_form": "requirement",
"form_basis": "직종 표기((인)·인부·공)",
"basis_quantity": 30.0,
"basis_unit": "㎥",
"basis_source": "표 안",
"basis_quantity": null,
"basis_unit": null,
"basis_source": null,
"resource_shares": {},
"partial_ratio": false,
"expression_cells": [],
@@ -26997,9 +26997,9 @@
"source_line": 4516,
"pum_form": "requirement",
"form_basis": "직종 표기((인)·인부·공)",
"basis_quantity": 1.0,
"basis_unit": "인",
"basis_source": "본문",
"basis_quantity": null,
"basis_unit": null,
"basis_source": null,
"resource_shares": {},
"partial_ratio": false,
"expression_cells": [],
@@ -27087,9 +27087,9 @@
"source_line": 4536,
"pum_form": "requirement",
"form_basis": "직종 표기((인)·인부·공)",
"basis_quantity": 1.0,
"basis_unit": "인",
"basis_source": "본문",
"basis_quantity": null,
"basis_unit": null,
"basis_source": null,
"resource_shares": {},
"partial_ratio": false,
"expression_cells": [],
@@ -27187,9 +27187,9 @@
"source_line": 4557,
"pum_form": "requirement",
"form_basis": "직종 표기((인)·인부·공)",
"basis_quantity": 1.0,
"basis_unit": "인",
"basis_source": "본문",
"basis_quantity": null,
"basis_unit": null,
"basis_source": null,
"resource_shares": {},
"partial_ratio": false,
"expression_cells": [],
@@ -39551,9 +39551,9 @@
"source_line": 7025,
"pum_form": "requirement",
"form_basis": "직종 표기((인)·인부·공)",
"basis_quantity": 1.0,
"basis_unit": "인",
"basis_source": "본문",
"basis_quantity": null,
"basis_unit": null,
"basis_source": null,
"resource_shares": {},
"partial_ratio": false,
"expression_cells": [],
+41
View File
@@ -677,6 +677,47 @@ export const ui_locales_b2 = {
"⚠ 「육상」은 통상값이고 사용자 확정이 아닙니다(확정 3차 ④) — 품셈 9-13 의 18구분이 이 값으로 갈립니다",
"⚠ “Dry” is a customary default, not a user decision — it selects one of the 18 sub-items",
],
B08_Quantity_Side_StandVolume_Label: ["임목축적 등급", "Stand volume class"],
B08_Quantity_StandVolume_Unset: ["안 정함(줄이 막힘)", "Not set (row blocked)"],
B08_Quantity_StandVolume_Low: ["소림(30~60㎥/㏊)", "Low (3060 ㎥/ha)"],
B08_Quantity_StandVolume_Mid: ["중림(60~90㎥/㏊)", "Mid (6090 ㎥/ha)"],
B08_Quantity_StandVolume_High: ["밀림(90㎥/㏊ 이상)", "High (90+ ㎥/ha)"],
B08_Quantity_Side_StandVolume_Hint: [
"제근(품셈 9-21)이 이 등급으로 갈립니다 — ⚠ 본수가 아니라 축적이고, 산림조사부·영림계획에서 옮겨 적는 값입니다",
"Root removal (9-21) splits by this class — stand volume, not tree count; copied from the forest survey",
],
B08_Quantity_Side_Chipping: ["임목파쇄", "Wood Chipping"],
B08_Quantity_Chipping_Label: ["임목파쇄를 셀 것인가", "Count wood chipping"],
B08_Quantity_Chipping_Off: ["안 셈(기본)", "No (default)"],
B08_Quantity_Chipping_On: ["셈", "Yes"],
B08_Quantity_Chipping_Volume: ["파쇄 부피(㎥)", "Chipping volume (㎥)"],
B08_Quantity_Side_Chipping_Hint: [
"기본은 안 셉니다(확정 5차 5번) — 현장에 따라 필요하면 켜세요. ⚠ 켜도 부피를 넣어야 값이 섭니다(실무는 부피로 셈)",
"Off by default — turn on per site. Volume must be entered for the row to carry a quantity",
],
B08_Quantity_Side_Frame: ["규준틀 개소당 재료", "Batter Board Materials (per unit)"],
B08_Quantity_Side_Frame_Hint: [
"⚠ 채워진 값은 실무 관측값(제안값)이고 법정 기준이 아닙니다 — 품셈 11-2·11-3 [주]④ 는 「재료량은 설계수량에 따른다」로만 둡니다. 손율은 원문값(비탈 50% · 수평 80%)",
"Pre-filled values are field observations, not a legal standard — the standard only says “per design quantity”",
],
B08_Quantity_Frame_Square: ["각재 50×50 (㎥/개소)", "Square timber (㎥/ea)"],
B08_Quantity_Frame_Board: ["판재 T12 (㎥/개소)", "Board T12 (㎥/ea)"],
B08_Quantity_Frame_Nail: ["못 (㎏/개소)", "Nails (㎏/ea)"],
B08_Quantity_Side_Ancillary: ["부대시설 개소", "Ancillary Facilities"],
B08_Quantity_Side_Ancillary_Hint: [
"개소를 넣어야 줄이 섭니다 — 원문이 배치 간격을 정하지 않아 연장÷500 같은 산식을 쓰지 않습니다(확정 13)",
"Counts are design input — the source sets no spacing rule, so no formula is used",
],
B08_Quantity_Ancillary_national_point_sign: ["국가지점번호판(개소)", "National point signs"],
B08_Quantity_Ancillary_guide_sign: ["임도 안내판(개소)", "Guide signs"],
B08_Quantity_Ancillary_gate: ["차단기(개소)", "Gates"],
B08_Quantity_Ancillary_site_container: ["가설창고(개소)", "Site containers"],
B08_Quantity_Ancillary_flood_supplies: ["수방대책 자재(식)", "Flood supplies (set)"],
B08_Quantity_Side_TopsoilHaul_Label: ["표토 운반거리(m)", "Topsoil haul distance (m)"],
B08_Quantity_Side_TopsoilHaul_Hint: [
"⚠ 별표2 는 표토를 「전량 제거한 후 최고 홍수위보다 높은 장소로 운반하고 쌓아두어야」로 정합니다 — 거리를 넣으면 운반 줄이 섭니다",
"The law requires hauling and stockpiling removed topsoil — enter the distance to raise that row",
],
B08_Quantity_Side_Placing: ["콘크리트 타설", "Concrete Placing"],
B08_Quantity_Side_Placing_Label: ["타설 방식", "Method"],
B08_Quantity_Placing_Unset: ["안 정함(기본값 사용)", "Not set (default)"],