From 58afe8f2d2fb94951d0f54d4ca88ef2394f38999 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 08:37:58 +0900 Subject: [PATCH] =?UTF-8?q?feat(b09):=20=EB=8D=A4=ED=94=84=20=EC=A0=81?= =?UTF-8?q?=EC=9E=AC=2010-12=20=E3=80=8C1.=20=EC=A0=81=EC=9E=AC=E3=80=8D?= =?UTF-8?q?=20=E2=80=94=20=EA=B5=B4=EC=B0=A9=EA=B8=B0=200.7=E3=8E=A5=20Q1?= =?UTF-8?q?=20=ED=98=B8=ED=91=9C(#=EC=A0=81=EC=9E=AC)=20=C2=B7=20E0=20?= =?UTF-8?q?=ED=86=A0=EC=82=AC=200.60(=EB=B3=B8=EB=AC=B8=200.75=20=EB=B2=84?= =?UTF-8?q?=EB=A6=BC=EC=9D=84=20=EC=82=AC=EC=9C=A0=EC=97=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Q1 = 3600×0.7×K×f×E0/22초 · 토사 47.63 · 암절취 16.32 · 발파암 13.67 ㎥/hr - E0 는 운반 식 Es 와 다른 기호 — 토사 0.60(표 「임도」·10-12-3 [주]③) 채택 · 본문 0.75 버림(브레인 판정) - 거리 무관이라 늘 세움 · 내역은 인계 수단 표지 dump_loading 줄을 잎 #적재 로(B08 짝 줄은 다음 커밋) - 시험 1건 보탬 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- .../B09_Estimation_BillOfQuantities_Rows.py | 20 ++++-- ...B09_Estimation_MachineProductivity_Dump.py | 69 ++++++++++++++++++- resources/tester/test_b09_dump_haul.py | 22 ++++++ 3 files changed, 103 insertions(+), 8 deletions(-) diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py index 2914ee70..e18a4905 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py @@ -24,8 +24,10 @@ from B09_Estimation.B09_Estimation_BillOfQuantities import ( ) from B09_Estimation.B09_Estimation_MachineProductivity_Dump import ( DUMP_PARENT, + LOADING_EQUIPMENT, dump_child_for, dump_title_code, + loading_title_code, ) from B09_Estimation.B09_Estimation_PriceBook import Money3 from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at @@ -312,19 +314,26 @@ def _leaf_row( return row price_code = f"B-{node.code}" - if node.code == DUMP_PARENT and item.haul_equipment == "dump_truck": + loading = item.haul_equipment == LOADING_EQUIPMENT + if node.code == DUMP_PARENT and (item.haul_equipment == "dump_truck" or loading): # 덤프 운반(10-12) — 잎(토사·암절취·발파암) × 운반거리마다 호표 한 장. 거리 없으면 0원 금지. + # 적재 짝 줄은 거리 무관 `#적재` 한 장. child = dump_child_for(item.variant_value, _master_edition()) why = "" + wanted = "" if child is None: why = ( f"덤프 운반 갈래 「{item.variant_value or '미지정'}」를 10-12 잎" "(토사·암절취·발파암)에 못 맞춤" ) + elif loading: + wanted = loading_title_code(child) elif item.haul_distance_m is None: why = "입력이 필요합니다 — 운반거리 미입력(유토곡선·사토장 거리가 서면 섬)" - elif dump_title_code(child, item.haul_distance_m) not in unit_prices.book.titles: - why = unit_prices.component_gaps.get(child) or "덤프 운반 일위대가를 못 세웠습니다" + else: + wanted = dump_title_code(child, item.haul_distance_m) + if wanted and wanted not in unit_prices.book.titles: + why = unit_prices.component_gaps.get(child) or "덤프 운반·적재 일위대가를 못 세웠습니다" if why: row.add_note("unit_price_krw", why) result.missing.append( @@ -337,8 +346,9 @@ def _leaf_row( } ) return row - price_code = dump_title_code(child, item.haul_distance_m) - row.spec = f"{row.spec} L={item.haul_distance_m}m".strip() + price_code = wanted + if not loading: + row.spec = f"{row.spec} L={item.haul_distance_m}m".strip() if price_code not in unit_prices.book.titles: # B08 은 **의미**(어느 공종·어느 제원)만 보내고 갈래 키는 우리가 만든다. # 못 맞추면 후보를 보이는 길로 내려간다 — 가까운 갈래를 임의로 고르지 않는다. diff --git a/B09_Estimation/B09_Estimation_MachineProductivity_Dump.py b/B09_Estimation/B09_Estimation_MachineProductivity_Dump.py index 07b2e629..f79ff9b5 100644 --- a/B09_Estimation/B09_Estimation_MachineProductivity_Dump.py +++ b/B09_Estimation/B09_Estimation_MachineProductivity_Dump.py @@ -82,6 +82,40 @@ DUMP_MATERIALS: dict[str, DumpMaterial] = { } # fmt: skip +#: 「1. 적재」 — Q1 = 3600 × q0 × K × f × E0 / ㎝ · 굴착기(무한궤도) 0.7㎥ · ㎝ 22초(180°). +#: ⚠ E0 는 운반 식의 Es 와 **다른 기호**다(판정 ④⑥). 토사 본문 「E0=0.75」 는 표(10-12-1 [주]⑤ +#: 「E0 토사 0.60(불량) — 임도」)·10-12-3 [주]③(「토사 0.6」)과 어긋나 **0.60 채택 · 0.75 버림**. +LOADER_CODE = "0201-0070" +_LOADING_CYCLE_SEC = Decimal(22) +_LOADING_E0 = { + "FP-10-12-01": Decimal("0.60"), + "FP-10-12-02": Decimal("0.35"), + "FP-10-12-03": Decimal("0.35"), +} +_LOADING_NOTES = { + "FP-10-12-01": "E0 = 0.60(표 「임도」·10-12-3 [주]③) — 본문 표기 0.75 버림", + "FP-10-12-02": "E0 = 0.35(본문·표 「파쇄암」 같음)", + "FP-10-12-03": "E0 = 0.35(10-12-3 [주]③ 「파쇄암 0.35」)", +} # fmt: skip + + +def loading_output(code: str) -> Decimal: + """적재 Q1 (㎥/hr, 자연상태) — f·Q1 소수 2자리 확정(명세 7장).""" + material = DUMP_MATERIALS[code] + return fix2( + Decimal(3600) + * _BUCKET_M3 + * material.bucket_factor + * fix2(Decimal(1) / material.loose_factor) + * _LOADING_E0[code] + / _LOADING_CYCLE_SEC + ) + + +def loading_title_code(code: str) -> str: + return f"B-{code}#적재" + + @dataclass(frozen=True) class DumpHaul: """덤프 운반 한 벌 — 재료 + 운반거리.""" @@ -143,13 +177,39 @@ def attach_dump_hauls(build: Any, master: dict[str, Any], distances_m: tuple[Dec """ from B09_Estimation.B09_Estimation_PriceBook import PriceKind, PriceTitle - if not distances_m: - return - hourly = f"X-{DUMP_TRUCK_CODE}" names = { str(node.get("work_item_code")): str(node.get("name") or "") for node in master.get("work_items", []) } + # 적재 — 거리와 무관해 늘 세움(굴착기 0.7㎥ 사용료 층이 있으면). 운반과 한 벌이라 여기 둠. + loader = f"X-{LOADER_CODE}" + for code in DUMP_MATERIALS: + title_code = loading_title_code(code) + if loader not in build.book.titles or title_code in build.book.titles: + continue + output = loading_output(code) + material = DUMP_MATERIALS[code] + label = names.get(code) or material.label + build.book.add_title( + PriceTitle( + code=title_code, + kind=PriceKind.UNIT_PRICE, + name=f"{names.get(DUMP_PARENT) or '덤프운반'} {label} 적재", + spec="굴착기(무한궤도) 0.7㎥", + unit="㎥", + ) + ) + build.book.add_output_detail( + title_code, + loader, + Decimal(1) / output, + f"Q1 = 3600×0.7×{material.bucket_factor}×{fix2(Decimal(1) / material.loose_factor)}" + f"×{_LOADING_E0[code]}/22 = {output}㎥/hr · {_LOADING_NOTES[code]}", + output=output, + ) + if not distances_m: + return + hourly = f"X-{DUMP_TRUCK_CODE}" for code, material in DUMP_MATERIALS.items(): if hourly not in build.book.titles: build.component_gaps[code] = "덤프트럭(15ton) 시간당 사용료가 아직 안 섰습니다" @@ -192,6 +252,9 @@ def dump_haul_distances(payload: dict[str, Any]) -> tuple[str, ...]: _RE_DUMP_CODE = re.compile(rf"^[BD]-{DUMP_PARENT}-0[1-3]#L(\d+(?:\.\d+)?)m$") +#: 인계 「덤프 적재」 짝 줄의 수단 표지 — 운반 줄(`dump_truck`)과 갈라 잎 `#적재` 로 감. +LOADING_EQUIPMENT = "dump_loading" + def dump_distance_from_code(code: str) -> tuple[str, ...]: """호표 한 장을 여는 쪽 — 코드 `#L164.23m` 에서 거리를 되읽음(인계를 다시 안 셈).""" diff --git a/resources/tester/test_b09_dump_haul.py b/resources/tester/test_b09_dump_haul.py index 02fd296d..da497c9d 100644 --- a/resources/tester/test_b09_dump_haul.py +++ b/resources/tester/test_b09_dump_haul.py @@ -74,6 +74,23 @@ def test_인계_거리와_갈래_잇기() -> None: assert dump_child_for("모르는암", "2026-01-01") is None +def test_적재는_거리_무관_한_장이고_E0_는_0_60() -> None: + from B09_Estimation.B09_Estimation_MachineProductivity_Dump import ( + loading_output, + loading_title_code, + ) + + # 토사 3600×0.7×0.9×0.77×0.60/22 = 47.628 → 47.63 + # 암절취 3600×0.7×0.55×0.74×0.35/22 = 16.317 → 16.32 + assert loading_output("FP-10-12-01") == Decimal("47.63") + assert loading_output("FP-10-12-02") == Decimal("16.32") + build = cached_build() # 거리 없어도 섬 + code = loading_title_code("FP-10-12-01") + assert build.book.resolve(code).total > 0 + basis = build.book.details[f"D-{code[2:]}"][0].note + assert "0.75 버림" in basis + + def test_내역_줄_거리_없으면_운반거리_미입력() -> None: from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill @@ -102,3 +119,8 @@ def test_내역_줄_거리_없으면_운반거리_미입력() -> None: priced = build_bill(payload(164.23), build=cached_build(dump_haul_m=("164.23",))) row = next(r for r in priced.rows if r.name == "dump_truck 운반") assert row.price_code == "B-FP-10-12-01#L164.23m" and row.amount_krw > 0 + loading = payload(None) + loading["work_items"][0].update(name="덤프 적재", haul_equipment="dump_loading") + loaded = build_bill(loading, build=cached_build()) + row = next(r for r in loaded.rows if r.name == "덤프 적재") + assert row.price_code == "B-FP-10-12-01#적재" and row.amount_krw > 0