feat(b09): 치즐 소모량 — 자원 목록 AR-M 치즐(본) · 대형브레이커 줄 옆 본/hr ÷ Q × 배분으로 같은 D 에 붙음(수동 단가가 있을 때만) · 칸을 채우면 치즐 13공종 온전 · 9-4-1 평균 갈래는 원문에 치즐 평균이 없어 사유

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
This commit is contained in:
2026-09-14 12:53:32 +09:00
co-authored by Claude Opus 5
parent 2b464138ae
commit 6b083ce3c5
5 changed files with 240 additions and 5 deletions
+72
View File
@@ -0,0 +1,72 @@
"""B09 원가계산 — **치즐 소모량**(대형브레이커 깨기 · PLAN 1장 Ⓐ-3 · 2026-09-14 브레인 판정).
품셈 표가 브레이커 줄 옆에 「치즐소모량(본/hr)」을 줌 — 제8장 [주]⑤ 「브레이커 … 손료 및 치즐
소모율을 **추가**」. 실무 원본(영월·봉화·울진) 단가산출근거 「(2) 치즐 손료 · M=0.006 × 223,000 / Q
= 382.2 W/㎥」 — 재료비(재료비 집계표 M00118 치즐(대형 브레이카) 본).
수량 치즐(본/hr) ÷ Q × 장비 배분율 — 브레이커 줄과 같은 D(단가산출)에 나란히(Q 를 함께 실음)
단가 자원 목록 `AR-M` 치즐 — **수동 단가가 들어와야** 제목이 섬(임의 단가 없음) ·
안 들어오면 종전대로 「못 붙은 줄」
⚠ 기계 층(`X-0230` 대형브레이커)은 부착 장비라 손료만 — 치즐은 그 안에 없어 이중계상 아님(시험).
"""
from __future__ import annotations
from decimal import Decimal
from typing import Any
#: 자원 목록 치즐(대형브레이커용 · 본) — `resource_catalog_ext` 에 올린 코드.
CHISEL_CODE = "AR-M-a2926452"
#: 치즐을 붙이는 기계 — 대형브레이커(분류 0230).
BREAKER_PREFIX = "0230-"
CHISEL_MISSING = "치즐소모량(본/hr) — 치즐 단가 없음(「자재 단가」 탭에서 넣으면 붙음)"
CHISEL_AVERAGE_NOT_GIVEN = (
"치즐(평균 갈래) — 원문 [주]① 은 Q 평균만 정하고 치즐 평균은 안 줌(지어내지 않음)"
)
def chisel_per_hour(node: dict[str, Any]) -> Decimal | None:
"""표의 「치즐소모(량)(본/hr)」 줄 값 — 그 이름 칸 뒤 첫 수. 없으면 `None`."""
from B09_Estimation.B09_Estimation_MachineProductivity import parse_measure
for table in node.get("tables", []):
for row in table.get("raw_row") or []:
cells = ["".join(str(cell).split()) for cell in row]
for index, cell in enumerate(cells):
if not cell.startswith("치즐소모"):
continue
value = next(
(parse_measure(t) for t in cells[index + 1 :] if parse_measure(t)), None
)
if value:
return value
return None
def attach_chisel(
book: Any,
title_code: str,
per_hour: Decimal | None,
capacity: Decimal,
share: Decimal = Decimal(1),
) -> bool:
"""치즐 줄을 그 제목의 D 에 붙임 — 치즐 제목(수동 단가)이 없거나 소모량이 없으면 `False`."""
if not per_hour or CHISEL_CODE not in book.titles:
return False
book.add_output_detail(
title_code,
CHISEL_CODE,
per_hour / capacity * share,
note=(
f"치즐 {per_hour} 본/hr ÷ Q {capacity}"
+ (f" × 배분 {share}" if share != 1 else "")
+ " — 품셈 제8장 [주]⑤ 치즐 소모율 추가"
),
output=capacity,
)
return True
def without_chisel_labels(labels: list[str]) -> list[str]:
"""치즐이 붙은 뒤 「못 붙은 줄」 에서 치즐 글을 걷음."""
return [label for label in labels if "치즐" not in label]
+29 -5
View File
@@ -106,12 +106,22 @@ def _add_rock_leaf(build: Any, node: dict[str, Any]) -> None:
+ (f" (기계 단가 층 없음: {', '.join(missing)})" if missing else "") + (f" (기계 단가 층 없음: {', '.join(missing)})" if missing else "")
) )
return return
variants = [(rock, q, f"작업능력 Q = {q} ㎥/hr ({rock})") for rock, q, _ in rows] from B09_Estimation.B09_Estimation_Chisel import (
CHISEL_AVERAGE_NOT_GIVEN,
CHISEL_MISSING,
attach_chisel,
)
variants = [(rock, q, f"작업능력 Q = {q} ㎥/hr ({rock})", chisel) for rock, q, chisel in rows]
if code in AVERAGE_BASIS: if code in AVERAGE_BASIS:
# 평균 Q 도 **소수 2자리로 확정**한 뒤 나눔(명세 7장) — (5.0+3.4+2.6)/3 = 3.67. # 평균 Q 도 **소수 2자리로 확정**한 뒤 나눔(명세 7장) — (5.0+3.4+2.6)/3 = 3.67.
# ⚠ 치즐 평균은 원문에 없어 안 붙임(None) — 지어내지 않고 사유로 남김.
average = fix2(sum((q for _, q, _ in rows), Decimal(0)) / Decimal(len(rows))) average = fix2(sum((q for _, q, _ in rows), Decimal(0)) / Decimal(len(rows)))
variants.append((AVERAGE_VARIANT, average, f"Q = {average} ㎥/hr — {AVERAGE_BASIS[code]}")) variants.append(
for variant, capacity, note in variants: (AVERAGE_VARIANT, average, f"Q = {average} ㎥/hr — {AVERAGE_BASIS[code]}", None)
)
attached = False
for variant, capacity, note, chisel in variants:
title_code = f"B-{code}#{variant}" title_code = f"B-{code}#{variant}"
build.book.add_title( build.book.add_title(
PriceTitle( PriceTitle(
@@ -131,9 +141,23 @@ def _add_rock_leaf(build: Any, node: dict[str, Any]) -> None:
f"{note} · {source}", f"{note} · {source}",
output=capacity, output=capacity,
) )
# 치즐 — 암질마다 표의 본/hr ÷ Q(수동 단가가 들어와 치즐 제목이 섰을 때만).
attached = attach_chisel(build.book, title_code, chisel, capacity) or attached
build.variants.setdefault(code, []).append(variant) build.variants.setdefault(code, []).append(variant)
# 표 줄은 다 읽었다 — 남는 것은 치즐뿐(자재 카탈로그가 없어 금액에 안 붙는 알려진 미결). # 표 줄은 다 읽었다 — 남는 것은 치즐뿐. 단가가 없으면 못 붙은 줄, 붙었으면 평균 갈래 사유만.
build.unattached[code] = ["치즐소모량(본/hr) — 자재 단가 층 없음"] if rows[0][2] else [] if rows[0][2]:
# 암질 표는 치즐이 열이라 「치즐소모」 줄로 안 잡힘 — 「자재 단가」 탭 쓰이는 곳에 올림.
from B09_Estimation.B09_Estimation_Chisel import CHISEL_CODE
uses = build.material_uses.setdefault(CHISEL_CODE, [])
if code not in uses:
uses.append(code)
if not rows[0][2]:
build.unattached[code] = []
elif not attached:
build.unattached[code] = [CHISEL_MISSING]
else:
build.unattached[code] = [CHISEL_AVERAGE_NOT_GIVEN] if code in AVERAGE_BASIS else []
def _step_titles(build: Any, step: str) -> dict[str, str]: def _step_titles(build: Any, step: str) -> dict[str, str]:
@@ -797,6 +797,19 @@ def build_unit_prices(
from B09_Estimation.B09_Estimation_MaterialPrices import add_material_titles from B09_Estimation.B09_Estimation_MaterialPrices import add_material_titles
build.manual_materials = add_material_titles(build.book, material_prices) build.manual_materials = add_material_titles(build.book, material_prices)
from B09_Estimation.B09_Estimation_Chisel import (
BREAKER_PREFIX,
CHISEL_CODE,
attach_chisel,
chisel_per_hour,
without_chisel_labels,
)
nodes_by_code = {str(node.get("work_item_code")): node for node in master.get("work_items", [])}
# 치즐은 자원 축 줄이 아니라 표의 소모량 줄 — 「자재 단가」 탭에 칸이 서게 쓰는 공종을 실음.
chisel_items = sorted(code for code, node in nodes_by_code.items() if chisel_per_hour(node))
if chisel_items:
build.material_uses[CHISEL_CODE] = chisel_items
for row in axis.rows: for row in axis.rows:
if row.resource_kind == "material": if row.resource_kind == "material":
uses = build.material_uses.setdefault(row.resource_code, []) uses = build.material_uses.setdefault(row.resource_code, [])
@@ -1005,6 +1018,17 @@ def build_unit_prices(
output=fix2(capacity["capacity_per_hour"]), output=fix2(capacity["capacity_per_hour"]),
) )
attached_capacity = True attached_capacity = True
# 치즐 — 대형브레이커 줄 옆 「치즐소모량(본/hr)」 ÷ Q × 배분(Ⓐ-3 · 수동 단가가 있을 때만).
if capacity["machine_code"].startswith(BREAKER_PREFIX) and attach_chisel(
build.book,
title_code,
chisel_per_hour(nodes_by_code.get(work_item_code, {})),
fix2(capacity["capacity_per_hour"]),
group_share,
):
build.unattached[work_item_code] = without_chisel_labels(
build.unattached.get(work_item_code, [])
)
# ⚠ **배분율이 있는 표는 「몇 %가 실제로 붙었나」를 세어 둔다.** # ⚠ **배분율이 있는 표는 「몇 %가 실제로 붙었나」를 세어 둔다.**
# 「인력(10%)·장비(90%)」 표에서 인력만 붙으면 단가가 10 % 몫만인데, 그 값이 # 「인력(10%)·장비(90%)」 표에서 인력만 붙으면 단가가 10 % 몫만인데, 그 값이
@@ -311,6 +311,37 @@
"F0346" "F0346"
] ]
} }
},
{
"code": "AR-M-a2926452",
"kind": "material",
"name": "치즐",
"spec": "대형브레이커용",
"unit": "본",
"source": {
"pum_edition": "2026-01-01",
"pum_table_ids": [
"F0241",
"F0244",
"F0259",
"F0260",
"F0267",
"F0268",
"F0269",
"F0270",
"F0271",
"F0272",
"F0273",
"F0274",
"F0275",
"F0276",
"F0277",
"F0278"
],
"stmate_codes": [
"M00118"
]
}
} }
] ]
} }
+84
View File
@@ -0,0 +1,84 @@
"""치즐 소모량(Ⓐ-3 · 2026-09-14 브레인 판정) — **칸을 채우면 치즐 공종 상태가 움직이는가**로 잼.
칸이 섰다 아니라 칸을 채우면 붙은 줄이 풀린다 판정(81공종 다시 셈에서 칸을 채워도
상태가 움직였던 까닭이 치즐 15공종). 단가는 없음 시험의 223,000 원은 실무 원본(영월 M00118)
대조용 값이고 프로그램에 넣지 않음.
근거 실무 원본 단가산출근거 (2) 치즐 손료 · M=0.006 × 223,000 / Q = 382.2 W/(재료비) ·
품셈 제8장 [] 브레이커 손료 치즐 소모율을 **추가**(기계 손료에 이중계상 아님).
"""
from __future__ import annotations
from decimal import Decimal
from B09_Estimation.B09_Estimation_Chisel import CHISEL_CODE
from B09_Estimation.B09_Estimation_UnitPrice import cached_build
#: 기본 조립에서 「치즐소모량」 이 못 붙은 줄로 남던 15공종.
CHISEL_ITEMS = (
"FP-09-04-01",
"FP-09-05-02",
"FP-09-12-02",
"FP-09-12-03",
*(f"FP-09-13-{n:02d}" for n in range(7, 19)),
)
PRICE = (CHISEL_CODE, "223000", "시험")
def _chisel_labels(build, code: str) -> list[str]:
return [label for label in build.unattached.get(code, []) if "치즐" in label]
def test_칸을_안_채우면_종전대로_못_붙은_줄() -> None:
base = cached_build()
assert CHISEL_CODE not in base.book.titles
assert all(_chisel_labels(base, code) for code in CHISEL_ITEMS)
# 「자재 단가」 탭에 칸이 서는 자리 — 치즐을 쓰는 16공종이 다 실림(암질 표 둘 포함)
assert set(CHISEL_ITEMS) <= set(base.material_uses[CHISEL_CODE])
def test_칸을_채우면_치즐_못_붙은_줄이_풀린다() -> None:
filled = cached_build(material_prices=(PRICE,))
left = {code: _chisel_labels(filled, code) for code in CHISEL_ITEMS}
# 9-4-1 평균 갈래만 남음 — 원문 [주]① 은 Q 평균만 정하고 치즐 평균은 안 줌(지어내지 않음)
assert {code: labels for code, labels in left.items() if labels} == {
"FP-09-04-01": [left["FP-09-04-01"][0]]
}
assert "평균" in left["FP-09-04-01"][0]
whole = [code for code in CHISEL_ITEMS if not filled.unattached.get(code)]
assert len(whole) >= 12, (
whole
) # 못 붙은 줄이 아예 없어진 공종(9-13-14·15 는 들어내기 줄이 남음)
def test_치즐은_시간당_소모량_나누기_Q_로_재료비에_붙음() -> None:
base = cached_build()
filled = cached_build(material_prices=(PRICE,))
# 9-4-1 연암 — 0.006 본/hr ÷ Q 5.0 × 223,000 = 267.6 원/㎥ (배분율 없음)
gap = (
filled.book.resolve("B-FP-09-04-01#연암").material
- base.book.resolve("B-FP-09-04-01#연암").material
)
# 줄 0.1원·소계 원 미만 절사가 기존 재료비 끝전과 겹쳐 267 또는 268
assert Decimal("267") <= gap <= Decimal("268")
rock = next(d for d in filled.book.details["D-FP-09-04-01#연암"] if d.ref_code == CHISEL_CODE)
assert rock.quantity == Decimal("0.006") / Decimal("5.0") and rock.output == Decimal("5.0")
# 9-12-2 암절취 — 장비(90%) 몫 · 0.006 ÷ 3.5 × 0.9 × 223,000 = 344.05
detail = next(d for d in filled.book.details["D-FP-09-12-02"] if d.ref_code == CHISEL_CODE)
assert detail.quantity == Decimal("0.006") / Decimal("3.5") * Decimal("0.9")
assert detail.output == Decimal("3.5")
def test_치즐은_기계_손료와_겹치지_않음() -> None:
"""㉠ 브레이커 시간당 사용료는 손료만(부착 장비) — 치즐이 X 층 안에 없음."""
filled = cached_build(material_prices=(PRICE,))
breaker = next(code for code in filled.book.titles if code.startswith("X-0230-"))
stack, seen = [breaker], set()
while stack:
code = stack.pop()
assert code != CHISEL_CODE
if code in seen:
continue
seen.add(code)
stack.extend(d.ref_code for d in filled.book.details.get(code, []) if d.ref_code != code)