diff --git a/B09_Estimation/B09_Estimation_Chisel.py b/B09_Estimation/B09_Estimation_Chisel.py new file mode 100644 index 00000000..4463468a --- /dev/null +++ b/B09_Estimation/B09_Estimation_Chisel.py @@ -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] diff --git a/B09_Estimation/B09_Estimation_ParentSteps.py b/B09_Estimation/B09_Estimation_ParentSteps.py index 5c01321d..934f5dc0 100644 --- a/B09_Estimation/B09_Estimation_ParentSteps.py +++ b/B09_Estimation/B09_Estimation_ParentSteps.py @@ -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]: diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index 01ec0a06..b776bf41 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -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 % 몫만인데, 그 값이 diff --git a/resources/data_resource_catalog/resource_catalog_ext_2026-01-01.json b/resources/data_resource_catalog/resource_catalog_ext_2026-01-01.json index a780760c..0b71fbef 100644 --- a/resources/data_resource_catalog/resource_catalog_ext_2026-01-01.json +++ b/resources/data_resource_catalog/resource_catalog_ext_2026-01-01.json @@ -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" + ] + } } ] } diff --git a/resources/tester/test_b09_chisel.py b/resources/tester/test_b09_chisel.py new file mode 100644 index 00000000..75de80e3 --- /dev/null +++ b/resources/tester/test_b09_chisel.py @@ -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)