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:
@@ -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]
|
||||
@@ -106,12 +106,22 @@ def _add_rock_leaf(build: Any, node: dict[str, Any]) -> None:
|
||||
+ (f" (기계 단가 층 없음: {', '.join(missing)})" if missing else "")
|
||||
)
|
||||
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:
|
||||
# 평균 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)))
|
||||
variants.append((AVERAGE_VARIANT, average, f"Q = {average} ㎥/hr — {AVERAGE_BASIS[code]}"))
|
||||
for variant, capacity, note in variants:
|
||||
variants.append(
|
||||
(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}"
|
||||
build.book.add_title(
|
||||
PriceTitle(
|
||||
@@ -131,9 +141,23 @@ def _add_rock_leaf(build: Any, node: dict[str, Any]) -> None:
|
||||
f"{note} · {source}",
|
||||
output=capacity,
|
||||
)
|
||||
# 치즐 — 암질마다 표의 본/hr ÷ Q(수동 단가가 들어와 치즐 제목이 섰을 때만).
|
||||
attached = attach_chisel(build.book, title_code, chisel, capacity) or attached
|
||||
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]:
|
||||
|
||||
@@ -797,6 +797,19 @@ def build_unit_prices(
|
||||
from B09_Estimation.B09_Estimation_MaterialPrices import add_material_titles
|
||||
|
||||
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:
|
||||
if row.resource_kind == "material":
|
||||
uses = build.material_uses.setdefault(row.resource_code, [])
|
||||
@@ -1005,6 +1018,17 @@ def build_unit_prices(
|
||||
output=fix2(capacity["capacity_per_hour"]),
|
||||
)
|
||||
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 % 몫만인데, 그 값이
|
||||
|
||||
@@ -311,6 +311,37 @@
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user