feat(B08): 돌 중량 근거 · 계수 갈래 칸 · 채움콘 강도 칸 (확정 2차 ⑤·⑨·⑩)

⑤ 돌 중량 — 지식DB 전수 확인 결과 품셈·교본에 돌중량표 없음. 값(0.88 계열)은
   그대로 두고 출처(울진 소광 원단위 라이브러리)를 산출근거·source 에 남김
⑨ `stone_coeff_basis` — 빈 값이면 품셈 열(야면석 0.11·0.15)이 기본, 「실무 관행」이면
   건설품셈 참고자료 열(0.15·0.20)로 덮이고 그 사실이 사유로 뜸
⑩ `fill_concrete_mpa` — 빈 값이면 210, 180 을 고르면 그 값이 이김. 아는 값이 아니면
   기본으로 서되 사유에 저장값을 적음
- Component 에 `spec` 칸 추가 — 같은 이름이라도 단가가 갈리는 값을 받는 쪽에 넘김
- 기본값은 등록부가 아니라 계산 쪽이 가짐(등록부 default 는 비어 있음 = 「안 정함」)
- tmp/tests/test_b08_coeff_and_strength.py 신설(6건)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-08 21:49:52 +09:00
co-authored by Claude Opus 5
parent fe032bccce
commit a69f8af82d
@@ -75,6 +75,17 @@ STONE_BACK_LENGTH_TABLE: dict[int, dict[str, float | None]] = {
}
DEFAULT_BACK_LENGTH_CM = 45
#: ⑤ 돌 중량이 어디서 온 값인가 (2026-09-08 사용자 확정 2차 ⑤ 「DB 확인 → 없으면 현재 유지,
#: 나중에 프로그램에서 확인할 수 있게 근거를 남길 것」).
#: ⚠ **품셈·교본에는 돌중량표가 없다** — 지식DB 전수 확인 결과 이 값이 나오는 곳은
#: 실무 원단위 라이브러리 한 곳뿐이다(기술문서에는 그 라이브러리를 인용한 줄만 있음).
#: 그래서 값을 바꾸지 않고 **어디서 온 값인지**를 줄마다 달아 화면에서 되짚게 한다.
STONE_WEIGHT_SOURCE = "uljin_library"
STONE_WEIGHT_BASIS = (
"⚠ 실무 관측값 — 울진 소광 원단위 라이브러리 「돌뒷길이별 원단위표」(35·45·55㎝만 있음)."
" 품셈·교본에는 돌중량표가 없음"
)
#: 돌 종류별 계수표 — 품셈 13-4-3·13-4-4 [주]① · 교본 7-3.
#: ⚠ 지금까지 **건설품셈 참고자료 한 벌**(돌 종류로 안 갈리는 표)로만 돌고 있었다.
@@ -100,6 +111,20 @@ def load_stone_kind_table() -> dict[str, Any]:
return {}
#: ⑨ 계수를 **어느 열로 볼 것인가** (2026-09-08 사용자 확정 2차 ⑨).
#: 기본은 **품셈 열**(야면석 고임돌 0.11 · 채움 0.15)이고, 「실무 관행」으로 두면
#: 건설품셈 참고자료 한 벌(깬돌 계열 0.15 · 0.20)로 덮어쓴다.
#: ⚠ **빈 칸은 「안 정함」**이고 그때 기본(품셈)으로 선다 — 등록부 default 는 비어 있다.
#: 기본값을 등록부가 아니라 여기가 갖는 것은 `face_slope_ratio` 와 같은 결이다.
COEFF_BASIS_KEY = "stone_coeff_basis"
COEFF_BASIS_PRACTICE = {"실무 관행", "실무관행", "practice", "관행"}
def wants_practice_coefficients(options: dict[str, Any]) -> bool:
"""계수를 실무 관행 열로 볼 것인가. 빈 칸이면 **아니오**(= 품셈 열)."""
return str(options.get(COEFF_BASIS_KEY) or "").strip() in COEFF_BASIS_PRACTICE
def stone_coefficients(options: dict[str, Any], back_cm: int) -> tuple[dict[str, Any], str]:
"""(계수 한 벌, 알림). 돌 종류를 안 고르면 **종전 값**으로 돌되 그 사실을 알린다.
@@ -113,14 +138,21 @@ def stone_coefficients(options: dict[str, Any], back_cm: int) -> tuple[dict[str,
return {}, ""
if key not in [str(x) for x in (table.get("back_lengths_cm") or [])]:
return {}, (f"뒷길이 {back_cm}㎝ 는 품셈 표(25·30·35·45·55·60·75㎝)에 없어 계수가 없습니다")
if not kind:
practice = wants_practice_coefficients(options)
if not kind or practice:
fallback = table.get("fallback") or {}
message = (
f"{kind}」이지만 계수를 **실무 관행 열**(건설품셈 참고자료)로 덮어썼습니다 —"
" 기본은 품셈 열입니다(확정 ⑨)"
if practice and kind
else str(fallback.get("message") or "")
)
return {
"wedge_stone_m3_per_m2": (fallback.get("wedge_stone_m3_per_m2") or {}).get(key),
"fill_concrete_m3_per_m2": (fallback.get("fill_concrete_m3_per_m2") or {}).get(key),
"backfill_ratio": fallback.get("backfill_ratio_of_back_length"),
"kind": "",
}, str(fallback.get("message") or "")
}, message
if kind not in (table.get("kinds") or []):
return {}, f"{kind}」은(는) 아는 돌 종류가 아니라 계수를 못 골랐습니다"
wedge = ((table.get("wedge_stone_m3_per_m2") or {}).get(kind) or {}).get(key)
@@ -207,6 +239,31 @@ STONE_SUPPLY_PURCHASED = {"구입", "구입품", "사서", "purchase", "purchase
#: 뒤집힌다. 우리는 **양수**로 주고, 빼는 것은 받는 쪽이 한다.
COLLECTED_STONE_KEY = "collected_stone_deduction_m3"
#: ⑩ 채움 콘크리트 강도 (2026-09-08 사용자 확정 2차 ⑩ 「기본 210, 고를 수 있게」).
#: ⚠ **빈 칸은 「안 정함」**이고 그때 210 으로 선다 — 등록부 default 는 비어 있다.
#: 180 은 국가기준 하한(돌쌓기 전용 값)이라 고르면 그 값이 이긴다.
FILL_CONCRETE_MPA_KEY = "fill_concrete_mpa"
FILL_CONCRETE_MPA_DEFAULT = "210"
FILL_CONCRETE_MPA_CHOICES = ("180", "210")
def fill_concrete_mpa(options: dict[str, Any]) -> tuple[str, str]:
"""(강도, 근거). 안 정했으면 기본 210 이고 그 사실이 근거에 적힌다."""
value = str(options.get(FILL_CONCRETE_MPA_KEY) or "").strip()
if value in FILL_CONCRETE_MPA_CHOICES:
return value, f"강도 {value} — 저장 제원에서 고른 값"
if value:
return (
FILL_CONCRETE_MPA_DEFAULT,
f"강도 {FILL_CONCRETE_MPA_DEFAULT} — ⚠ 저장값 「{value}」은 180·210 중에"
" 없어 기본으로 섬",
)
return (
FILL_CONCRETE_MPA_DEFAULT,
f"강도 {FILL_CONCRETE_MPA_DEFAULT} — 안 정해 기본값(확정 ⑩)",
)
BLINDING_THICKNESS_M = 0.10
#: 버림을 뺄 수 있는 칸 — 「기본은 넣고, 빼고 싶으면 뺀다」(사용자 확정 ⑭).
#: 저장 제원에 이 칸이 없으면 **넣는 쪽**이 기본이다.
@@ -259,6 +316,8 @@ class Component:
# 두 근거가 한 표에 섞이므로 줄마다 단다. 안 적으면 나중에 못 되짚는다.
basis_kind: str = BASIS_DERIVED
source: str = ""
#: 규격 — 같은 이름이라도 단가가 갈리는 값(채움 콘크리트 강도 등). 받는 쪽이 읽는다.
spec: str = ""
@dataclass(slots=True)
@@ -750,7 +809,8 @@ def stone_masonry(
"ton",
masonry_area * stone_ton,
DESTINATION["야면석"],
f"돌쌓기 × {stone_ton} ton/㎡ (뒷길이 {back_cm}㎝)",
f"돌쌓기 × {stone_ton} ton/㎡ (뒷길이 {back_cm}㎝) · {STONE_WEIGHT_BASIS}",
source=STONE_WEIGHT_SOURCE,
)
)
@@ -772,13 +832,16 @@ def stone_masonry(
)
if wet:
mpa, mpa_basis = fill_concrete_mpa(options)
components.append(
Component(
"채움콘크리트",
"",
masonry_area * _num(table["fill_concrete_m3_per_m2"]),
DESTINATION["채움콘크리트"],
f"돌쌓기 × {table['fill_concrete_m3_per_m2']} ㎥/㎡ (뒷길이 {back_cm}㎝)",
f"돌쌓기 × {table['fill_concrete_m3_per_m2']} ㎥/㎡ (뒷길이 {back_cm}㎝)"
f" · {mpa_basis}",
spec=f"{mpa}",
)
)
components.append(
@@ -1110,6 +1173,7 @@ def build_table(
"basis": component.basis,
"basis_kind": component.basis_kind,
"source": component.source,
"spec": component.spec,
}
for component in item.components
],