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 "")
|
+ (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"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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