From de8f0f15f2982d47cef52b1d5b19f50ae41fe088 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 07:50:39 +0900 Subject: [PATCH 1/6] =?UTF-8?q?fix(B09):=20=E3=80=8C=EB=B6=99=EC=9D=80=20?= =?UTF-8?q?=EB=AA=AB=200%=E3=80=8D=EB=A7=8C=20=EC=A0=81=ED=9E=88=EB=8D=98?= =?UTF-8?q?=20=EC=82=AC=EC=9C=A0=EC=97=90=20**=EB=AC=B4=EC=97=87=EC=9D=B4?= =?UTF-8?q?=20=EC=95=88=20=EB=B6=99=EC=97=88=EB=8A=94=EC=A7=80**=EB=A5=BC?= =?UTF-8?q?=20=ED=95=A8=EA=BB=98=20=EC=A0=81=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「단가가 일부만 섰습니다 — 붙은 몫 0%」 만으로는 어디를 손볼지 알 수 없었음. 구조물터파기가 그 자리였는데, 참조는 이미 풀렸고 실제로 막는 것은 깨기(대형브레이커)· 치즐이었음. - 사유에 `component_gaps`·`unattached` 를 이어 붙임 「붙은 몫 0% · 깨기, 치즐소모량(본/hr) 줄이 아직 안 붙었습니다 (계수 참조는 풀렸습니다).」 검증: pytest 288 통과 Co-Authored-By: Claude Opus 5 (1M context) --- .../B09_Estimation_BillOfQuantities_Rows.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py index fb2d04fe..19ec51ac 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py @@ -250,14 +250,21 @@ def _leaf_row( if covered is not None: # ⚠ **일부 몫만 선 단가는 안 붙인다.** 「인력(10%)·장비(90%)」 표에서 인력만 # 붙은 값을 전량에 곱하면 내역서가 조용히 틀린다 — 0 으로 때우는 것과 같은 사고다. - row.note = f"단가가 일부만 섰습니다 — 붙은 몫 {covered}% (나머지는 시공능력 공식 몫)." + # 무엇이 없어서 못 붙었는지까지 적는다 — 「붙은 몫 0%」만으로는 어디를 손볼지 모른다. + why = unit_prices.component_gaps.get(node.code) or "" + missing_rows = unit_prices.unattached.get(node.code) or [] + if not why and missing_rows: + why = f"{', '.join(missing_rows[:3])} 줄이 아직 안 붙었습니다" + row.note = ( + f"단가가 일부만 섰습니다 — 붙은 몫 {covered}%" + (f" · {why}" if why else "") + "." + ) result.missing.append( { "name": row.name, "code": node.code, "unit": row.unit, "quantity": str(item.quantity), - "reason": f"단가 일부만 섬(붙은 몫 {covered}%)", + "reason": f"단가 일부만 섬(붙은 몫 {covered}%)" + (f" — {why}" if why else ""), } ) return row From 1dceeb5befaac6a5da454a518fd450328af1e0cb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 08:06:02 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat(B09):=20=EA=B9=A8=EA=B8=B0(=EB=8C=80?= =?UTF-8?q?=ED=98=95=EB=B8=8C=EB=A0=88=EC=9D=B4=EC=BB=A4)=EA=B0=80=20?= =?UTF-8?q?=EB=B6=99=EC=96=B4=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=ED=84=B0?= =?UTF-8?q?=ED=8C=8C=EA=B8=B0=EA=B0=80=20=EC=84=AC=20=E2=80=94=20=EB=B3=B8?= =?UTF-8?q?=EC=B2=B4=203,089=EB=A7=8C=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 품셈 제8장 [주]⑤ 「불도저 및 굴착기에 리퍼, 브레이커, 부착용집게를 조합하여 사용할 때는 …잡재료비율을 16%로 계상하고, 리퍼, 브레이커, 부착용 집게의 손료 및 치즐 소모율을 추가하는 것이다」 를 따라감. 세 자리를 이었음 1. **뭉개진 카탈로그 줄을 원문으로 되살림** — `mach_base_2026.json` 의 대형 브레이커 여섯 줄이 이름 칸에 표 전체가 뭉쳐 규격·손료계수가 없었음. 건설품셈 제8장 (0230) 표에서 읽어 채움. ⚠ **두 번 검증** — 계수 합 3,000+2,833+768=6,601 이 표의 「계」와 같고, 같은 방식으로 읽은 굴착기 「계 2,085」가 카탈로그 0.0002085 와 정확히 일치 2. **부착용 장비는 손료만으로 층을 세움** — 제 엔진이 없어 운전경비표에 줄이 없음. 없다고 버리면 깨기 몫이 영영 안 붙음. ⚠ 리퍼·브레이커·집게에만 여는 길 3. **작업량을 직접 준 기계 줄을 읽음** — 「대형브레이커(㎥/hr) 3.5」. 공식이 아니라 시간당 작업량을 표가 바로 주는 모양. 단위가 붙은 칸만 읽음(짐작 금지) 결과 - 구조물터파기(암절취·육상·2~3m) 155.625㎥ × 72,378.4 = **11,263,899원** — 처음 섬 - 돌쌓기 단가에 **부착용 집게 손료**가 더해짐(메 76,607.9 → 78,864.5) — [주]⑤ 그대로 - 본체 19,444,626 → **30,892,279원** - 남은 것은 **치즐(자재) 하나** — 기계가 빠지면 막고 자재 소모품이 빠지면 드러내기만 하는 규칙대로, 금액을 세우고 본표가 「치즐소모량 줄이 아직 안 붙었습니다」를 말함 ⚠ 아직 안 한 것 — 같은 [주]⑤ 의 **「잡재료비율 16%」**. 조합 사용 시 굴착기 잡재료가 22% 가 아니라 16% 임. 그 자리는 운전경비 층이라 따로 손봐야 함. 검증: pytest 290 통과(신규 3) Co-Authored-By: Claude Opus 5 (1M context) --- B09_Estimation/B09_Estimation_MachineCost.py | 31 +++++ ...stimation_MachineProductivity_Reference.py | 107 ++++++++++++++++++ B09_Estimation/B09_Estimation_UnitPrice.py | 99 +++++++++++++++- 3 files changed, 234 insertions(+), 3 deletions(-) diff --git a/B09_Estimation/B09_Estimation_MachineCost.py b/B09_Estimation/B09_Estimation_MachineCost.py index a571234b..3c65e5a2 100644 --- a/B09_Estimation/B09_Estimation_MachineCost.py +++ b/B09_Estimation/B09_Estimation_MachineCost.py @@ -82,6 +82,32 @@ OPERATOR_ALLOWANCE_NOTICE = ( ) +#: ⚠ **원천이 뭉개 놓은 줄을 원문으로 되살린다** (2026-09-09). +#: +#: `mach_base_2026.json` 의 대형 브레이커 여섯 줄은 **이름 칸에 표 전체가 뭉쳐** 들어가 +#: 규격이 비고 손료계수가 없다. 그래서 「대형브레이커」로 찾아지지도, 시간당 사용료가 +#: 서지도 않았다 — 구조물터파기(암절취)가 그 때문에 통째로 막혀 있었다. +#: +#: 값은 **건설공사 표준품셈 제8장 (0230) 대형 브레이커** 표에서 읽었고, **두 번 검증**했다. +#: ① 계수 합이 맞는다 — 상각 3,000 + 정비 2,833 + 관리 768 = **6,601** (표의 「계」와 같다) +#: ② 같은 방식으로 읽은 굴착기(0201) 표의 「계 2,085」가 카탈로그의 손료계수 +#: **0.0002085 와 정확히 일치**한다 — 열 배치를 잘못 읽지 않았다는 증거다. +#: +#: ⚠ **원천이 이 줄을 제대로 싣게 되면 이 표는 지운다.** 두 곳에 같은 값을 두면 +#: 나중에 한쪽만 고쳐진다. 취득가는 원천 값을 그대로 쓴다 — 여기서는 **이름·규격·손료계수**만 채운다. +MASHED_MACHINE_FIXES: dict[str, tuple[str, str]] = { + "0230-0002": ("대형 브레이커", "0.2"), + "0230-0004": ("대형 브레이커", "0.4"), + "0230-0006": ("대형 브레이커", "0.6"), + "0230-0007": ("대형 브레이커", "0.7"), + "0230-0008": ("대형 브레이커", "0.8"), + "0230-0010": ("대형 브레이커", "1.0"), +} + +#: (0230) 표의 「시간당 계」 — 규격이 달라도 같은 값이다(원문 여섯 줄 모두 6,601). +MASHED_LOSS_COEFFICIENT = Decimal("0.0006601") + + class MachineCostError(LookupError): """기계경비를 세울 수 없는 경우. 0 으로 때우지 않고 멈춘다.""" @@ -149,6 +175,11 @@ def load_machine_catalog(file_name: str = "mach_base_2026.json") -> MachineCatal for row in variables.get("mach_price", {}).get("records", []): code = row["machine_code"] coefficient = coefficients.get(code, {}) + fixed = MASHED_MACHINE_FIXES.get(code) + if fixed: + # 뭉개진 줄 — 원문으로 이름·규격을 되살리고 손료계수를 채운다. + row = {**row, "machine_name": fixed[0], "specification": fixed[1]} + coefficient = {**coefficient, "loss_coefficient_per_hour": MASHED_LOSS_COEFFICIENT} catalog.machines[code] = MachineSpec( machine_code=code, name=row["machine_name"], diff --git a/B09_Estimation/B09_Estimation_MachineProductivity_Reference.py b/B09_Estimation/B09_Estimation_MachineProductivity_Reference.py index 95250c35..6bfce469 100644 --- a/B09_Estimation/B09_Estimation_MachineProductivity_Reference.py +++ b/B09_Estimation/B09_Estimation_MachineProductivity_Reference.py @@ -193,3 +193,110 @@ def reference_factor_values( raw_texts[code] = own_ref[1] return values, provenance, failures, raw_texts + + +# --------------------------------------------------------------------------- +# 작업량을 **직접 준** 기계 줄 (2026-09-09) +# --------------------------------------------------------------------------- +# +# 품셈은 기계 몫을 늘 공식으로만 주지 않는다. **시간당 작업량을 바로 적는** 줄이 있다. +# +# ['장비 (90%)', '깨기', '대형브레이커(㎥/hr)', '3.5', 'Q=(3.2+3.8)/2 (연암평균치 적용)'] +# +# 이 줄을 못 읽으면 암·발파암 갈래의 **깨기 몫이 통째로 빠진다** — 들어내기(백호우)만 +# 붙어 「일부만 선 단가」로 남는다. +# +# ⚠ **단위가 붙어 있을 때만 읽는다.** 「(㎥/hr)」·「(m/hr)」처럼 시간당 작업량임을 +# 표가 스스로 밝힌 줄만 본다. 숫자만 있는 칸을 작업량으로 넘겨짚지 않는다. + +_CAPACITY_UNIT = re.compile(r"[((]\s*(㎥|m3|㎡|m2|m|ton|t)\s*/\s*(?:hr|시간)\s*[))]") + + +def _paired_machine_spec(node: dict[str, Any]) -> str: + """그 표에 함께 나오는 기종의 규격(「유압식백호우 (무한궤도,0.7㎥)」 → 0.7).""" + from B09_Estimation.B09_Estimation_MachineProductivity import resolve_machine + + from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog + + catalog = load_machine_catalog() + for table in node.get("tables", []): + for row in table.get("raw_row") or []: + for cell in row: + found = resolve_machine(_clean(cell)) + if found is not None: + machine = catalog.machines.get(found[0]) + if machine is not None and machine.specification: + return str(machine.specification) + return "" + + +def _machine_by_name(text: str, preferred_spec: str) -> tuple[str, str] | None: + """이름만으로 기종을 고른다 — 규격이 여럿이면 **짝의 규격**을 따른다. + + ⚠ 「대형브레이커(㎥/hr)」는 괄호가 **규격이 아니라 단위**라 보통 길로는 안 풀린다. + """ + from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog + + wanted = re.sub(r"\s", "", text) + if not wanted: + return None + catalog = load_machine_catalog() + hits = [ + (code, machine) + for code, machine in catalog.machines.items() + if wanted and wanted in re.sub(r"\s", "", machine.name) + ] + if not hits: + return None + if preferred_spec: + narrowed = [item for item in hits if str(item[1].specification) == str(preferred_spec)] + if len(narrowed) == 1: + return narrowed[0][0], narrowed[0][1].name + return (hits[0][0], hits[0][1].name) if len(hits) == 1 else None + + +def direct_capacity_rows(node: dict[str, Any]) -> list[dict[str, Any]]: + """그 공종에서 **시간당 작업량을 직접 준 기계 줄**들. + + 돌려주는 것 — 기계 이름 칸 · 기종 코드/이름 · 시간당 작업량 · 묶음 배분율(%) · 원문 문구. + """ + from B09_Estimation.B09_Estimation_MachineProductivity import parse_measure + + found: list[dict[str, Any]] = [] + ratio: Decimal | None = None + # 같은 표에 짝이 되는 기종이 있으면 **그 규격**을 따른다 — 「대형브레이커」는 규격을 + # 안 적고, 실무도 「대형브레이커 + B/H 0.7」처럼 붙는 굴착기 규격으로 잡는다. + paired_spec = _paired_machine_spec(node) + for table in node.get("tables", []): + for row in table.get("raw_row") or []: + cells = [_clean(cell) for cell in row] + if not cells: + continue + seen_ratio = re.search(r"[((]\s*(\d+(?:\.\d+)?)\s*%\s*[))]", cells[0]) + if seen_ratio: + ratio = Decimal(seen_ratio.group(1)) + for index, cell in enumerate(cells): + if not _CAPACITY_UNIT.search(cell): + continue + machine = _machine_by_name(_CAPACITY_UNIT.sub("", cell).strip(), paired_spec) + if machine is None: + continue + capacity = next( + (parse_measure(token) for token in cells[index + 1 :] if parse_measure(token)), + None, + ) + if capacity is None or capacity <= 0: + continue + found.append( + { + "cell": cell, + "machine_code": machine[0], + "machine_name": machine[1], + "capacity_per_hour": capacity, + "ratio_pct": ratio, + "table_id": str(table.get("pum_table_id", "")), + # 그 줄의 칸들 — 「못 붙은 줄」 목록에서 이 줄을 걷어내는 데 쓴다. + "row_cells": [c for c in cells if c], + } + ) + return found diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index f8c64e04..35b78bd5 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -132,6 +132,11 @@ def _add_labor_titles(book: PriceBook, wages: dict[str, Decimal]) -> None: ) +#: 부착용 장비 — 제 엔진이 없어 **손료만** 붙는다(품셈 제8장 [주]⑤). +#: ⚠ 이 목록을 넓히지 말 것 — 넓히면 연료가 빠진 기계가 조용히 싸게 선다. +_ATTACHMENT_WORDS = ("브레이커", "리퍼", "부착용집게", "집게") + + def _add_machine_layers(book: PriceBook, machine_codes: set[str]) -> list[str]: """`S`(취득가) · `L`(운전사) · `M`(연료) 을 세우고 그 위에 `X` 를 올린다. @@ -159,7 +164,21 @@ def _add_machine_layers(book: PriceBook, machine_codes: set[str]) -> list[str]: for code in sorted(machine_codes): machine = catalog.machines.get(code) record = operating.get(code) - if machine is None or machine.loss_coefficient_per_hour is None or record is None: + # ⚠ **부착용 장비는 운전경비표에 줄이 없다** — 제 엔진이 없어 연료·조종원이 + # 본체(굴착기·불도저)에 든다. 품셈 제8장 [주]⑤ 가 그 자리를 밝힌다: + # 「불도저 및 굴착기에 **리퍼, 브레이커, 부착용집게를 조합하여 사용**할 때는 + # …잡재료비율을 16%로 계상하고, **리퍼, 브레이커, 부착용 집게의 손료 및 + # 치즐 소모율을 추가**하는 것이다.」 + # ⇒ 그 셋은 **손료만으로** 층을 세운다. 운전경비 줄이 없다고 통째로 버리면 + # 깨기 몫이 영영 안 붙는다(구조물터파기 암 갈래가 그 자리였다). + # ⚠ **다른 기계에는 이 길을 열지 않는다** — 연료가 빠진 채 조용히 싼 값이 선다. + attachment = machine is not None and any( + word in re.sub(r"\s", "", machine.name) for word in _ATTACHMENT_WORDS + ) + if machine is None or machine.loss_coefficient_per_hour is None: + incomplete.append(code) + continue + if record is None and not attachment: incomplete.append(code) continue @@ -192,6 +211,21 @@ def _add_machine_layers(book: PriceBook, machine_codes: set[str]) -> list[str]: ) book.add_detail(PriceDetail(hourly_code, base_code, Decimal(1), note="시간당 손료")) + if record is None: + # 부착용 장비 — 여기서 끝난다. 연료·조종원은 본체 줄에 이미 들어 있다. + book.add_detail( + PriceDetail( + hourly_code, + base_code, + Decimal(0), + note=( + "부착용 장비 — 연료·조종원은 본체(굴착기·불도저)에 듭니다" + " (품셈 제8장 [주]⑤)." + ), + ) + ) + continue + liters = record.fuel_liters_per_hour if liters is not None: if record.misc_material_percent is not None: @@ -353,6 +387,7 @@ def build_unit_prices( ) from B09_Estimation.B09_Estimation_MachineProductivity_Reference import ( + direct_capacity_rows, reference_factor_values, ) @@ -374,9 +409,22 @@ def build_unit_prices( for failed_code, why in borrow_fail.items(): build.factor_sources.setdefault(failed_code, f"⚠ {why}") build.component_gaps = dict(axis.partial_items) + # 「작업량을 직접 준」 기계(깨기 대형브레이커)도 사용료 층을 세운다 — 안 세우면 + # 그 줄이 붙을 데가 없어 암·발파암 갈래의 깨기 몫이 통째로 빠진다. + capacity_rows = { + str(node.get("work_item_code", "")): direct_capacity_rows(node) + for node in master.get("work_items", []) + } + capacity_rows = {code: rows for code, rows in capacity_rows.items() if rows} + # ⚠ **참조로 이미 푼 줄은 「못 붙은 줄」이 아니다.** 안 걷어 내면 다 풀린 공종이 # 계속 반쪽으로 보이고, 그 표시를 믿고 막아 둔 금액이 영영 안 선다. resolved_rows = {code: text for code, text in borrow_text.items() if code in borrow_note} + # 작업량을 직접 준 줄도 이제 붙었다 — 그 줄의 칸들은 「못 붙은 줄」이 아니다. + capacity_cells: dict[str, set[str]] = {} + for capacity_code, capacity_list in capacity_rows.items(): + for entry in capacity_list: + capacity_cells.setdefault(capacity_code, set()).update(entry.get("row_cells") or []) for unmatched_row in axis.unmatched: # ⚠ 지역 이름을 조심할 것 — 바로 위 `names` 는 **공종 이름표**다. 같은 이름을 쓰면 # 그 표가 리스트로 덮여 조립이 통째로 터진다(2026-09-09 실측). @@ -385,16 +433,35 @@ def build_unit_prices( reference_text = resolved_rows.get(unmatched_row.work_item_code) if reference_text and reference_text in label: continue # 그 줄은 참조를 따라가 값을 얻었다 + if label in capacity_cells.get(unmatched_row.work_item_code, set()): + continue # 그 줄은 표가 작업량을 직접 줘 붙었다 if label and label not in labels: labels.append(label) # ⚠ **거두는 자리는 「못 붙은 줄」을 다 모은 뒤다.** 앞에서 거두면 목록이 비어 있어 # **전부 거둬지고**, 깨기(대형브레이커)가 빠진 암 계열까지 「다 찼다」로 선다 # (2026-09-09 실측). 남은 줄이 하나도 없을 때만 거둔다. + def _names_a_machine(label: str) -> bool: + """그 줄이 **기계를 가리키나** — 기계가 빠지면 막고, 자재가 빠지면 드러내기만 한다. + + ⚠ 두 가지는 무게가 다르다. 기계 몫은 단가의 대부분이라 빠지면 금액이 통째로 + 틀리고, 자재 소모품(치즐 0.006본/hr)은 카탈로그가 서면 채워지는 알려진 미결이다 + (자원 축이 이미 그 규칙으로 가른다). + """ + from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog + + flat = re.sub(r"\s", "", label) + return any( + re.sub(r"\s", "", machine.name) in flat + for machine in load_machine_catalog().machines.values() + if len(re.sub(r"\s", "", machine.name)) >= 3 + ) + solved_codes = { code for code in borrow_note - if code in axis.partial_items and not build.unattached.get(code) + if code in axis.partial_items + and not any(_names_a_machine(label) for label in build.unattached.get(code, [])) } for code in solved_codes: build.component_gaps.pop(code, None) @@ -414,6 +481,7 @@ def build_unit_prices( machine_codes = {r.resource_code for r in axis.rows if r.resource_kind == "machine"} # 공식표에만 나오는 기종도 사용료 층을 세운다 — 안 세우면 공식이 붙을 데가 없다. machine_codes |= formula_machine_codes(master) + machine_codes |= {row["machine_code"] for rows in capacity_rows.values() for row in rows} build.incomplete_machines = _add_machine_layers(build.book, machine_codes) # 규격 갈래(`variant`)가 있으면 **갈래마다 따로 세운다** — 무근·철근·소형구조물은 @@ -533,6 +601,31 @@ def build_unit_prices( build.factor_sources, ) + # 작업량을 직접 준 기계 줄(깨기) — 공식 몫과 **자리를 나눠 쓴다**. 같은 묶음(장비 + # 90%) 안에서 깨기와 들어내기가 차례로 붙는다. + attached_capacity = False + for capacity in capacity_rows.get(work_item_code, []): + hourly_code = f"X-{capacity['machine_code']}" + if hourly_code not in build.book.titles: + continue + group_share = ( + Decimal(1) + if capacity["ratio_pct"] is None + else Decimal(str(capacity["ratio_pct"])) / Decimal(100) + ) + build.book.add_detail( + PriceDetail( + title_code, + hourly_code, + (Decimal(1) / capacity["capacity_per_hour"]) * group_share, + note=( + f"작업량을 표가 직접 줌 — {capacity['cell']} {capacity['capacity_per_hour']}" + f" (품셈 원문 표기 그대로)" + ), + ) + ) + attached_capacity = True + # ⚠ **배분율이 있는 표는 「몇 %가 실제로 붙었나」를 세어 둔다.** # 「인력(10%)·장비(90%)」 표에서 인력만 붙으면 단가가 10 % 몫만인데, 그 값이 # 조용히 서면 내역서가 틀린 줄 모른다(2026-09-08 실측: 측구터파기 39,575.6원/㎥ @@ -543,7 +636,7 @@ def build_unit_prices( # ⚠ 공식은 있는데 **아무것도 안 붙은** 제목은 남기지 않는다 — 「상세 줄이 없어 # 조립 불가」로 화면에서 터진다. 기계 층이 못 선 경우가 그 자리다. - if not attachable and not build.book.details.get(title_code): + if not attachable and not attached_capacity and not build.book.details.get(title_code): build.book.titles.pop(title_code, None) if variant in build.variants.get(work_item_code, []): build.variants[work_item_code].remove(variant) From 1d5145eaa515fda94949c0745abeb8e97f2dbe82 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 08:11:49 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat(B09):=20=EB=AF=B8=ED=99=95=EC=A0=95=20?= =?UTF-8?q?=EC=82=B0=EC=8B=9D=20=EC=A4=84=EC=97=90=20=EC=82=AC=EC=9C=A0?= =?UTF-8?q?=EB=A5=BC=20=EC=8B=A4=EC=9D=8C=20+=20=EA=B0=88=EB=9E=98=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EB=8B=A4=EB=A6=AC(=EB=A6=AC=ED=95=91?= =?UTF-8?q?=EC=95=94=E2=86=94=ED=8C=8C=EC=87=84=EC=95=94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ① 수량 산식이 확정 대기인 줄에 그 사실을 함께 실음 (계획서 4-12 3단계) 구조물터파기가 내역서에서 가장 큰 줄(11,263,899원)이 됐는데 그 밑수가 사용자 확정을 기다리는 식 위에 서 있음. 값은 그대로 두고 **드러내기만** 함. 「⚠ 터파기 수량 산식이 확정 대기입니다 — 실무 시트는 기초만(기초폭 × 깊이 0.3) 세고 우리는 벽 전체를 셉니다(m당 실무 0.35 vs 우리 2.30, 약 6.6배). 실무 식으로 확정되면 이 줄 금액이 약 1/6.6 로 줄어듭니다.」 되메우기·잔토처리도 같은 뿌리라 함께 표시. ⚠ 측구터파기는 다른 뿌리라 대상 아님 ② `VARIANT_ALIASES` — 축이 달라 다르게 불리는 갈래를 **이름을 갈지 않고** 이음 우리 「리핑암」은 B05·B06 지반유형(캘 수 있는가), 품셈 「파쇄암」은 운반 상태 (부서졌는가)라 일반적으로 같은 말이 아님 — 발파암도 캐고 나면 파쇄암 상태임. ⭐ 다만 산림품셈 10-11 f 표가 토사·파쇄암·발파암을 따로 두어 그 표의 파쇄암은 발파를 뺀 나머지이고, 10-12 [주]③ 「토사 1.3, **암절취 1.35**, 발파암 1.625」가 10-11 의 파쇄암 1/1.35 와 **같은 값**이라 그 표 안에서는 암절취(리핑) = 파쇄암임. 그 문장을 주석에 그대로 남김 검증: pytest 290 통과 · `find_variant_code("FP-10-11","리핑암")` → `B-FP-10-11#파쇄암` Co-Authored-By: Claude Opus 5 (1M context) --- .../B09_Estimation_BillOfQuantities_Rows.py | 42 +++++++++++++++++++ B09_Estimation/B09_Estimation_UnitPrice.py | 20 +++++++++ 2 files changed, 62 insertions(+) diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py index 19ec51ac..bcf9bb38 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py @@ -307,6 +307,13 @@ def _leaf_row( ) return row + # ⚠ **수량이 미확정 산식 위에 서 있는 줄**은 금액과 함께 그 사실을 싣는다. + # 금액이 커질수록 더 그렇다 — 지금 구조물터파기가 내역서에서 가장 큰 줄인데 + # 그 밑수가 사용자 확정을 기다리고 있다(계획서 4-12 3단계). + pending = pending_formula_note(node.code) + if pending: + row.note = " / ".join(part for part in (row.note, pending) if part) + # 쓰인 차례를 기억한다 — 실무 참조번호(「단산 46」)가 그 차례다. if price_code not in result.used_unit_prices: result.used_unit_prices.append(price_code) @@ -323,6 +330,41 @@ def _leaf_row( return row +#: 수량 산식이 **사용자 확정을 기다리는** 공종 — 금액은 세우되 그 사실을 함께 싣는다. +#: +#: ⚠ **값을 우리가 바꾸지 않는다.** 실무 시트와 어긋나는 것이 확인됐을 뿐이고, 어느 쪽으로 +#: 갈지는 설계 판단이다(계획서 4-12 3단계, 확정 대기). +#: 돌쌓기(찰) H=2.0·ℓ3=45 · m당 기준으로 실무 대비 +#: 터파기 실무 0.35 우리 2.30 (약 6.6배) — 실무는 기초만(기초폭 × 깊이 0.3), +#: 우리는 벽 전체를 센다 +#: 되메우기 실무 0.185 우리 0.40 · 잔토정리 실무 0.165 우리 1.90 (위 둘의 차) +_PENDING_FORMULA: dict[str, str] = { + "FP-09-13": ( + "⚠ 터파기 수량 산식이 확정 대기입니다 — 실무 시트는 기초만(기초폭 × 깊이 0.3) 세고 " + "우리는 벽 전체를 셉니다(m당 실무 0.35 vs 우리 2.30, 약 6.6배). 실무 식으로 확정되면 " + "이 줄 금액이 약 1/6.6 로 줄어듭니다." + ), + "FP-09-14": ( + "⚠ 되메우기 수량 산식이 확정 대기입니다 — 터파기와 같은 뿌리입니다" + "(m당 실무 0.185 vs 우리 0.40)." + ), + "FP-09-15": ( + "⚠ 잔토처리 수량 산식이 확정 대기입니다 — 터파기·되메우기의 차라 같은 뿌리입니다" + "(m당 실무 0.165 vs 우리 1.90)." + ), +} + + +def pending_formula_note(code: str | None) -> str: + """그 공종의 수량 산식이 확정 대기인가 — 맞으면 실을 문구.""" + if not code: + return "" + for prefix, note in _PENDING_FORMULA.items(): + if str(code).startswith(prefix): + return note + return "" + + def _material_row(material: HandoffMaterial, result: BillResult) -> BillRow: """자재 한 줄. 공급 구분이 안 갈렸으면 **어느 쪽에도 안 넣는다**.""" row = BillRow( diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index 35b78bd5..79921d94 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -306,6 +306,25 @@ def normalize_variant_key(text: str) -> str: return "".join("~" if ch in _TILDE_CHARS else ch for ch in tight) +#: 갈래 이름이 **축이 달라 다르게 불리는** 자리 — 이름을 갈지 않고 여기서 잇는다. +#: +#: ⚠ **우리 축과 품셈 축이 다르다.** 우리 「리핑암」은 B05·B06 의 **지반유형**(캘 수 있는가)이 +#: 낳은 이름이고, 품셈 운반표의 「파쇄암」은 **운반할 때의 상태**(부서졌는가)를 가리킨다. +#: 그래서 일반적으로는 「리핑암 = 파쇄암」이 아니다 — **발파암도 캐고 나면 파쇄암 상태**다 +#: (건설품셈 8장 「발파 또는 리퍼작업 등에 의하여 얻어진 암과 파쇄암…」). +#: +#: ⭐ **다만 이 표 안에서는 성립한다.** 산림품셈 10-11 f 표가 **토사·파쇄암·발파암을 따로** +#: 두었으므로 그 표의 「파쇄암」은 **발파를 뺀 나머지 = 리퍼로 얻은 것**이다. 결정적 증거는 +#: 10-12 [주]③ 이다 — 「적재 재료의 토량환산계수(L)는 토사 1.3, **암절취 1.35**, 발파암 +#: 1.625 적용한다」. 10-11 f 표의 **파쇄암이 1/1.35** 라 **암절취(리핑)과 같은 값**이다. +#: +#: ⚠ **어느 쪽 이름도 갈지 않는다** — 우리 이름을 갈면 B05·B06 이 깨지고, 품셈 이름을 갈면 +#: 원문과 어긋난다. 잇는 자리는 여기 한 곳뿐이다. +VARIANT_ALIASES: dict[str, str] = { + "리핑암": "파쇄암", +} + + def find_variant_code( work_item_code: str, variant_value: str, @@ -320,6 +339,7 @@ def find_variant_code( wanted = normalize_variant_key(variant_value) if not wanted: return None + wanted = VARIANT_ALIASES.get(wanted, wanted) prefix = f"B-{work_item_code}#" candidates = [code for code in prices.book.titles if code.startswith(prefix)] From c892bdc93592b54115799379228a9d67248859c2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 08:30:30 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat(B09):=20=EC=A1=B0=ED=95=A9=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=20=EC=8B=9C=20=EB=B3=B8=EC=B2=B4=20=EC=9E=A1=EC=9E=AC?= =?UTF-8?q?=EB=A3=8C=2016%=20+=20=ED=84=B0=ED=8C=8C=EA=B8=B0=20=EB=85=B8?= =?UTF-8?q?=ED=8B=B0=EC=8A=A4=20=EB=AC=B8=EA=B5=AC=20=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ① 품셈 제8장 [주]⑤ 의 마지막 조각 — 「…조합하여 사용할 때는 …**잡재료비율을 16%로 계상**하고, 리퍼·브레이커·집게의 손료 및 치즐 소모율을 추가」. 손료·치즐만 넣고 이 줄을 빠뜨리면 본체 재료비가 22% 로 남아 조금씩 비싸짐. - 기계마다 「#조합」 층을 함께 세움(손료·조종원 같음, 잡재료만 16%) - 조립 **끝에 한 번** 조합 공종의 본체 줄을 그 층으로 바꿔 닮 — 도중에는 어느 공종이 조합인지 아직 모름. 바꾼 줄 24 - 부착 장비는 **이름이 아니라 분류번호**로 잡음(0103 리퍼 · 0230 브레이커 · 0240 진동콤팩터 · 7206 집게) — 이름은 원천이 뭉개 놓는 일이 있음(0230 이 그랬음) - 결과: 구조물터파기 72,378.3 → 72,333.2 · 돌쌓기(메) 78,864.5 → 78,423.8 · 돌쌓기(찰) 61,757.3 → 61,418.0 · 본체 30,892,279 → **30,849,376원** ② 터파기 노티스 문구 정정 — 「실무는 기초만 세고 우리는 벽 전체를 셈(6.6배)」이 **틀렸음**. 우리 식은 정본(기초 0.45 + 비탈) 그대로이고, 6.6배는 소광리 시트가 비탈 줄을 안 적어서 난 것임. ⇒ 「비탈 터파기를 셀지 확정 대기 — 안 세면 약 1/7」로 고침. 값·금액은 그대로 검증: pytest 290 통과(신규 1) Co-Authored-By: Claude Opus 5 (1M context) --- .../B09_Estimation_BillOfQuantities_Rows.py | 25 +++--- B09_Estimation/B09_Estimation_UnitPrice.py | 89 +++++++++++++++++++ 2 files changed, 99 insertions(+), 15 deletions(-) diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py index bcf9bb38..ed02f559 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py @@ -332,26 +332,21 @@ def _leaf_row( #: 수량 산식이 **사용자 확정을 기다리는** 공종 — 금액은 세우되 그 사실을 함께 싣는다. #: -#: ⚠ **값을 우리가 바꾸지 않는다.** 실무 시트와 어긋나는 것이 확인됐을 뿐이고, 어느 쪽으로 -#: 갈지는 설계 판단이다(계획서 4-12 3단계, 확정 대기). -#: 돌쌓기(찰) H=2.0·ℓ3=45 · m당 기준으로 실무 대비 -#: 터파기 실무 0.35 우리 2.30 (약 6.6배) — 실무는 기초만(기초폭 × 깊이 0.3), -#: 우리는 벽 전체를 센다 -#: 되메우기 실무 0.185 우리 0.40 · 잔토정리 실무 0.165 우리 1.90 (위 둘의 차) +#: ⚠ **값을 우리가 바꾸지 않는다.** 어느 쪽으로 갈지는 설계 판단이다(계획서 4-12 3단계). +#: +#: ⚠ **2026-09-09 문구를 고쳤다.** 처음에는 「실무는 기초만 세고 우리는 벽 전체를 센다」로 +#: 적었는데 **그것이 틀렸다** — 우리 식은 정본(기초 0.45 + 비탈) 그대로이고, 6.6배 차이는 +#: **소광리 시트가 비탈 터파기 줄을 안 적어서** 난 것이다. 다투는 자리는 「우리 식이 +#: 맞는가」가 아니라 **「비탈 터파기를 셀 것인가」**다. _PENDING_FORMULA: dict[str, str] = { "FP-09-13": ( - "⚠ 터파기 수량 산식이 확정 대기입니다 — 실무 시트는 기초만(기초폭 × 깊이 0.3) 세고 " - "우리는 벽 전체를 셉니다(m당 실무 0.35 vs 우리 2.30, 약 6.6배). 실무 식으로 확정되면 " - "이 줄 금액이 약 1/6.6 로 줄어듭니다." + "⚠ 비탈 터파기를 셀지 확정 대기입니다 — 정본은 세고(기초 0.45 + 비탈) 다른 실무 " + "시트는 안 셉니다. 안 세는 쪽으로 확정되면 이 줄 금액이 약 1/7 로 줄어듭니다." ), "FP-09-14": ( - "⚠ 되메우기 수량 산식이 확정 대기입니다 — 터파기와 같은 뿌리입니다" - "(m당 실무 0.185 vs 우리 0.40)." - ), - "FP-09-15": ( - "⚠ 잔토처리 수량 산식이 확정 대기입니다 — 터파기·되메우기의 차라 같은 뿌리입니다" - "(m당 실무 0.165 vs 우리 1.90)." + "⚠ 되메우기도 같은 확정에 걸립니다 — 비탈 터파기를 안 세면 되메울 부피도 함께 줍니다." ), + "FP-09-15": ("⚠ 잔토처리도 같은 확정에 걸립니다 — 터파기와 되메우기의 차라 같은 뿌리입니다."), } diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index 79921d94..c4c02382 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -19,6 +19,7 @@ from __future__ import annotations import re from dataclasses import dataclass, field +from dataclasses import replace as dataclass_replace from decimal import Decimal from functools import lru_cache @@ -102,6 +103,8 @@ class UnitPriceBuild: #: ⚠ 값이 남의 절에서 온 것이면 **화면이 그렇게 말해야** 한다 — 안 그러면 나중에 #: 「이 숫자 어디서 왔지」로 되짚을 길이 없다. factor_sources: dict[str, str] = field(default_factory=dict) + #: 조합 사용(품셈 [주]⑤)으로 **잡재료 16% 층으로 바꿔 단 줄** 수. + combined_swapped: int = 0 #: 배분율 표인데 일부 몫만 붙은 공종 — 「단가가 일부만 섬」. 값은 붙은 몫(%). partial_ratio: dict[str, Decimal] = field(default_factory=dict) #: 시공능력 공식으로 장비 몫을 세운 공종 — 산출근거를 화면에 그대로 보인다. @@ -137,6 +140,50 @@ def _add_labor_titles(book: PriceBook, wages: dict[str, Decimal]) -> None: _ATTACHMENT_WORDS = ("브레이커", "리퍼", "부착용집게", "집게") +#: 조합 사용 시 본체(굴착기·불도저)의 잡재료비율 — 품셈 제8장 [주]⑤. +#: 「…리퍼, 브레이커, 부착용집게를 **조합하여 사용**할 때는 …**잡재료비율을 16%로 계상**하고, +#: 리퍼, 브레이커, 부착용 집게의 손료 및 치즐 소모율을 추가하는 것이다.」 +#: ⚠ 손료·치즐만 더하고 이 줄을 빠뜨리기 쉽다 — 그러면 본체 재료비가 계속 22% 로 서서 +#: 조금씩 비싸진다(굴착기 0.7 기준 시간당 1,285원). +COMBINED_MISC_PERCENT = Decimal(16) + +#: 조합으로 쓰는 부착 장비 — 이 층이 붙은 공종의 본체는 위 비율을 쓴다. +#: 카탈로그 분류번호로 잡는다 — 0103 유압식 리퍼 · 0230 대형 브레이커 · +#: 0240 유압식 진동콤팩터(굴착기 부착용) · 7206 부착용 집게. +#: ⚠ 이름이 아니라 **번호**로 잡는다 — 이름은 원천이 뭉개 놓는 일이 있다(0230 이 그랬다). +_ATTACHMENT_PREFIXES = ("X-0103-", "X-0230-", "X-0240-", "X-7206-") + + +def _apply_combined_misc_rate(book: PriceBook, work_item_titles: list[str]) -> int: + """조합 사용 공종의 본체 기계를 **잡재료 16% 짜리 층**으로 바꿔 단다. + + 바꾼 줄 수를 돌려준다. ⚠ 원래 층은 그대로 둔다 — 조합이 아닌 공종은 22% 그대로다. + """ + swapped = 0 + for title_code in work_item_titles: + details = book.details.get(title_code) or [] + if not any(detail.ref_code.startswith(_ATTACHMENT_PREFIXES) for detail in details): + continue + for index, detail in enumerate(details): + if not detail.ref_code.startswith("X-") or detail.ref_code.startswith( + _ATTACHMENT_PREFIXES + ): + continue + combined = f"{detail.ref_code}#조합" + if combined not in book.titles: + continue + details[index] = dataclass_replace( + detail, + ref_code=combined, + note=( + (detail.note + " · " if detail.note else "") + + "조합 사용 — 잡재료 16% (품셈 제8장 [주]⑤)" + ), + ) + swapped += 1 + return swapped + + def _add_machine_layers(book: PriceBook, machine_codes: set[str]) -> list[str]: """`S`(취득가) · `L`(운전사) · `M`(연료) 을 세우고 그 위에 `X` 를 올린다. @@ -232,6 +279,32 @@ def _add_machine_layers(book: PriceBook, machine_codes: set[str]) -> list[str]: # 잡재료는 **주연료의 %** — 유가와 같이 움직인다. liters = liters * (Decimal(1) + record.misc_material_percent / Decimal(100)) book.add_detail(PriceDetail(hourly_code, fuel_code, liters, note="주연료 + 잡재료")) + + # 조합 사용(리퍼·브레이커·집게)일 때 쓸 **잡재료 16%** 짜리 층을 함께 세운다. + # 같은 기계라도 조합이면 본체 잡재료가 줄어든다(품셈 제8장 [주]⑤). + combined_code = f"{hourly_code}#조합" + if combined_code not in book.titles: + book.add_title( + PriceTitle( + code=combined_code, + kind=PriceKind.MACHINE_HOURLY, + name=machine.name, + spec=(f"{machine.specification} · 조합").strip(" ·"), + unit="hr", + ) + ) + book.add_detail( + PriceDetail(combined_code, base_code, Decimal(1), note="시간당 손료") + ) + book.add_detail( + PriceDetail( + combined_code, + fuel_code, + record.fuel_liters_per_hour + * (Decimal(1) + COMBINED_MISC_PERCENT / Decimal(100)), + note=f"주연료 + 잡재료 {COMBINED_MISC_PERCENT}% (조합 사용)", + ) + ) else: incomplete.append(f"{code} (연료소모량 없음)") @@ -251,6 +324,17 @@ def _add_machine_layers(book: PriceBook, machine_codes: set[str]) -> list[str]: slots=_slots(wages[wage_code]), ) ) + # 조합 층에도 조종원을 같이 단다 — 본체를 모는 사람은 하나뿐이다. + combined_code = f"{hourly_code}#조합" + if combined_code in book.titles: + book.add_detail( + PriceDetail( + combined_code, + wage_code, + per_hour_person, + note="조종원 (1일 8시간 × 제수당·상여·퇴직충당 16/12 × 25/20)", + ) + ) book.add_detail( PriceDetail( hourly_code, @@ -668,6 +752,11 @@ def build_unit_prices( covered += machine_share if covered < Decimal(100): build.partial_ratio[work_item_code] = covered + # ⚠ **마지막에 한 번** — 조합 사용 공종의 본체 기계를 잡재료 16% 층으로 바꿔 단다 + # (품셈 제8장 [주]⑤). 조립 도중에 바꾸면 어느 공종이 조합인지 아직 모른다. + build.combined_swapped = _apply_combined_misc_rate( + build.book, [code for code in build.book.titles if code.startswith("B-")] + ) return build From e0081cd953de52042273f769ac43535078616420 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 09:03:49 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix(B09):=20=EC=A0=9C=EA=B7=BC=20=EC=9D=B4?= =?UTF-8?q?=EC=A4=91=EA=B3=84=EC=83=81=20=E2=80=94=20=ED=91=9C=EA=B0=80=20?= =?UTF-8?q?=E3=80=8C=EA=B3=A0=EB=A5=B4=EB=9D=BC=E3=80=8D=EA=B3=A0=20?= =?UTF-8?q?=EB=91=94=20=EC=9E=A5=EB=B9=84=20=EB=B8=94=EB=A1=9D=EC=9D=84=20?= =?UTF-8?q?=EB=8B=A4=20=EB=8D=94=ED=95=98=EA=B3=A0=20=EC=9E=88=EC=97=88?= =?UTF-8?q?=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 품셈 9-21 제근은 장비 규격 두 갈래(0.2㎥·0.7㎥)를 **고르라고** 둔 표인데 둘 다 붙고 보통인부도 두 번 붙어 단가가 **128,039원**으로 서 있었음. 밑수가 원문에 없어 아직 금액이 안 서 있었을 뿐, **밑수가 정해지는 날 조용히 두 배로 설 자리**였음. - `B09_Estimation_ResourceAxis_ChooseOne` 신설 — 블록마다 갈래 하나, 열마다 갈래 하나. 장비 2 × 임목밀도 3 = **여섯 갈래**로 세움 (55,625.9 ~ 111,161.4원) - ⚠ **「고르는 표」를 좁게 가름** — 기계 줄이 둘이라고 다 그런 표가 아님. 9-13 암절취는 깨기(브레이커)와 들어내기(백호우)가 **함께 듦**. 자국은 **같은 이름에 규격만 다른 것**. 품셈도 그렇게 말함 — 9-20-1 [주]④ 「0.2㎥ 또는 0.4㎥ … 계수를 달리 적용」 - ⚠ 밑수는 만들지 않음 — 9-21 은 표 머리·제목·[주] 어디에도 밑수 표기가 없음 (앞 절 9-20-1 「(10주당)」·9-20-2 「(단위: 10주당)」과 대비되는 자리) ⚠ 짓다 잡은 것 — 기종 해석이 **넘겨받은 카탈로그를 무시**하고 기계 카탈로그를 직접 봐서, 노무만 든 카탈로그로 돌릴 때도 기계 줄이 나왔음. 카탈로그에 기계가 없으면 이 표를 안 읽게 막음(시험이 그것을 잡았음). ⚠ 시험 하나가 **버그를 맞는 것으로 적어 두고 있었음** — 「제근은 보통인부 줄이 둘이다」. 그것이 곧 이중계상이었음. 뒤집어 다시 적었음. 검증: pytest 295 통과(신규 4) · 자원 축 539 → 547 줄, 늘어난 8 줄은 전부 제근 Co-Authored-By: Claude Opus 5 (1M context) --- B09_Estimation/B09_Estimation_ResourceAxis.py | 9 + .../B09_Estimation_ResourceAxis_ChooseOne.py | 200 ++++++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 B09_Estimation/B09_Estimation_ResourceAxis_ChooseOne.py diff --git a/B09_Estimation/B09_Estimation_ResourceAxis.py b/B09_Estimation/B09_Estimation_ResourceAxis.py index 5a336691..ac9add90 100644 --- a/B09_Estimation/B09_Estimation_ResourceAxis.py +++ b/B09_Estimation/B09_Estimation_ResourceAxis.py @@ -378,6 +378,15 @@ def match_table( if match_three_axis_table(node, table, catalog, result): return + # ⚠ **「둘 중 하나를 고르는」 장비 블록 표도 먼저 가른다.** 그냥 읽으면 블록을 다 더해 + # 장비 두 대·인부 두 몫이 서서 대략 두 배가 된다(2026-09-09 제근 128,039원). + from B09_Estimation.B09_Estimation_ResourceAxis_ChooseOne import ( + match_choose_one_machine_table, + ) + + if match_choose_one_machine_table(node, table, catalog, result): + return + form = table.get("pum_form", "") if form in NON_WORK_ITEM_FORMS or form in UNUSABLE_FORMS or form not in USABLE_FORMS: result.skipped_forms[form] = result.skipped_forms.get(form, 0) + 1 diff --git a/B09_Estimation/B09_Estimation_ResourceAxis_ChooseOne.py b/B09_Estimation/B09_Estimation_ResourceAxis_ChooseOne.py new file mode 100644 index 00000000..11314660 --- /dev/null +++ b/B09_Estimation/B09_Estimation_ResourceAxis_ChooseOne.py @@ -0,0 +1,200 @@ +"""B09 원가계산 — **표가 「둘 중 하나를 고르라」고 둔 장비 블록** 읽기 (2026-09-09). + +품셈에는 같은 일을 **장비 규격에 따라 달리 세는** 표가 있다. 블록이 둘인데 **둘 다 더하면 +장비 두 대와 인부 두 몫이 서서 대략 두 배**가 된다. + + 9-21 제근 + | 종 류 | 명 칭 | 단위 | 소 | 중 | 밀 | + | 굴착기(무한궤도) | 굴착기(무한궤도,0.2㎥) | hr | 0.80 | 1.01 | 1.22 | ┐ 0.2㎥ 블록 + | 보통인부 | 인 | 0.03 | 0.04 | 0.05 | ┘ + | 굴착기(무한궤도,0.7㎥) | hr | 0.46 | 0.58 | 0.70 | ┐ 0.7㎥ 블록 + | 보통인부 | 인 | 0.03 | 0.04 | 0.05 | ┘ + +2026-09-09 실측: 제근 단가가 **128,039원**으로 서 있었다 — 굴착기 0.2·0.7 이 둘 다 붙고 +보통인부도 두 번 붙은 값이다. 밑수가 원문에 없어 아직 금액이 안 서 있었을 뿐, +**밑수가 정해지는 날 조용히 두 배로 설 자리**였다. + +읽는 법 — **블록마다 갈래 하나**, 열마다 갈래 하나. 둘을 곱해 갈래를 낸다. + + 갈래 = 「굴착기(무한궤도) 0.2㎥ · 소」 … 「굴착기(무한궤도) 0.7㎥ · 밀」 (2 × 3 = 6) + +⚠ **「고르는 표」인지 아닌지를 좁게 가른다.** 기계 줄이 둘이라고 다 고르는 표가 아니다 — +9-13 암절취는 「깨기(대형브레이커)」와 「들어내기(백호우)」가 **함께 드는** 표다. +가르는 자국은 **같은 기계 이름에 규격만 다른 것**이다(굴착기 0.2 vs 0.7). 품셈도 그렇게 +말한다 — 9-20-1 [주]④ 「0.2㎥ 또는 0.4㎥ 용량의 굴착기를 사용하는 경우에는 …적용계수를 +달리 적용하도록 한다」. + +⚠ **밑수는 여기서 만들지 않는다.** 9-21 은 표 머리·제목·[주] 어디에도 밑수가 없다 +(2026-09-09 원문 전수 확인). 갈래만 바로 세우고 밑수는 빈 채로 둔다. +""" + +from __future__ import annotations + +import re + +from decimal import Decimal +from typing import Any + +from B09_Estimation.B09_Estimation_ResourceAxis import ( + AxisResult, + CatalogEntry, + ResourceCatalog, + ResourceRow, + UnmatchedRow, + parse_amount, + split_name_and_spec, +) + +_NUMBER = re.compile(r"^\d+(?:\.\d+)?$") + +#: 열 머리로 인정하지 않는 말 — 값이 아니라 설명이다. +_NOT_A_COLUMN = ("비고", "적요", "참고", "단위", "명칭", "명 칭", "종류", "종 류", "규격", "규 격") + + +def _clean(cell: Any) -> str: + return " ".join(str(cell or "").split()) + + +def _column_labels(header: list[Any]) -> list[str]: + """표 머리에서 **갈래 열 이름**만 골라 낸다 — 「소·중·밀」.""" + labels = [_clean(cell) for cell in header] + return [ + label + for label in labels + if label and "".join(label.split()) not in {"".join(w.split()) for w in _NOT_A_COLUMN} + ] + + +def _machine_of(cells: list[str], catalog: ResourceCatalog): + """그 줄이 기계 줄이면 (칸 번호, 기종, 원문 칸). 아니면 `None`. + + ⚠ 자원 카탈로그의 이름·규격 짝으로는 안 풀린다 — 품셈이 「굴착기(무한궤도,0.2㎥)」처럼 + **규격을 괄호 안에 몰아** 적기 때문이다. 기종 해석은 그 모양을 아는 쪽에 맡긴다. + """ + from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog + from B09_Estimation.B09_Estimation_MachineProductivity import resolve_machine + + machines = load_machine_catalog().machines + for index, cell in enumerate(cells): + found = resolve_machine(cell) + if found is None: + continue + machine = machines.get(found[0]) + if machine is None: + continue + entry = CatalogEntry( + code=found[0], name=machine.name, kind="machine", spec=str(machine.specification) + ) + return index, entry, cell + return None + + +def _values_of(cells: list[str], count: int) -> list[Decimal] | None: + """그 줄 끝에서 값 `count` 개. 개수가 안 맞으면 `None` — 짐작해 채우지 않는다.""" + numbers = [cell for cell in cells if _NUMBER.match(cell)] + if len(numbers) < count: + return None + picked = [parse_amount(cell) for cell in numbers[-count:]] + return None if any(value is None for value in picked) else picked # type: ignore[return-value] + + +def match_choose_one_machine_table( + node: dict[str, Any], + table: dict[str, Any], + catalog: ResourceCatalog, + result: AxisResult, +) -> bool: + """「둘 중 하나를 고르는」 장비 블록 표를 읽는다. 그런 표가 아니면 `False`. + + ⚠ 같은 기계 이름에 **규격만 다른** 블록이 둘 이상일 때만 내 표로 본다. + """ + # ⚠ **기계 카탈로그가 없는 조립에서는 이 표를 읽지 않는다.** 기종 해석은 기계 쪽 + # 카탈로그를 직접 보므로, 노무만 든 카탈로그로 돌릴 때도 기계 줄이 나와 버린다 + # (2026-09-09 시험이 그것을 잡았다). **넘겨받은 카탈로그의 결을 따른다.** + if not any(entry.kind == "machine" for entry in catalog.entries): + return False + + rows = [row for row in (table.get("raw_row") or []) if isinstance(row, list)] + header = list(table.get("condition_note") or []) + labels = _column_labels(header) + if len(rows) < 2 or len(labels) < 2: + return False + + blocks: list[dict[str, Any]] = [] + for row in rows: + cells = [_clean(cell) for cell in row] + if not cells: + continue + found = _machine_of(cells, catalog) + if found is not None: + _index, entry, raw_cell = found + values = _values_of(cells, len(labels)) + if values is None: + return False + blocks.append({"entry": entry, "cell": raw_cell, "rows": [], "values": values}) + continue + if not blocks: + continue + name, spec = split_name_and_spec(cells[0]) + entry = catalog.resolve(name, spec) + values = _values_of(cells, len(labels)) + if entry is None or values is None: + continue + blocks[-1]["rows"].append({"entry": entry, "values": values, "cell": cells[0]}) + + if len(blocks) < 2: + return False + # ⚠ **같은 이름 · 다른 규격**일 때만 「고르는 표」다. 이름이 다르면 함께 드는 장비다. + names = {block["entry"].name for block in blocks} + specs = {block["entry"].spec for block in blocks} + if len(names) != 1 or len(specs) != len(blocks): + return False + + work_item_code = str(node.get("work_item_code", "")) + table_id = str(table.get("pum_table_id", "")) + form = str(table.get("pum_form", "")) + unit = table.get("basis_unit") or "" + + made = 0 + for block in blocks: + machine = block["entry"] + for column, label in enumerate(labels): + variant = f"{machine.name} {machine.spec} · {label}".strip() + entries = [(machine, block["values"][column], block["cell"])] + entries += [ + (item["entry"], item["values"][column], item["cell"]) for item in block["rows"] + ] + for entry, amount, cell in entries: + result.rows.append( + ResourceRow( + work_item_code=work_item_code, + pum_table_id=table_id, + pum_form=form, + resource_kind=entry.kind, + resource_code=entry.code, + resource_name=entry.name, + resource_spec=entry.spec, + amount=amount, + amount_unit=unit, + raw_row_index=0, + variant=variant, + ) + ) + made += 1 + del cell + + if made == 0: + return False + result.unmatched.append( + UnmatchedRow( + work_item_code=work_item_code, + pum_table_id=table_id, + cell=" | ".join(_clean(cell) for cell in header), + reason=( + f"장비 규격 {len(blocks)} 가지 × 갈래 {len(labels)} 가지로 세웠습니다 — " + "표가 「둘 중 하나」로 둔 자리라 **더하지 않고 고르게** 합니다. " + "⚠ 밑수(무엇당)는 원문에 없습니다." + ), + ) + ) + return True From 446426e99750fa3736b1d462b4f6927611ad1ead Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 11:19:57 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat(B09):=20=EC=9B=90=EB=AC=B8=EC=97=90?= =?UTF-8?q?=EB=8A=94=20=EC=9E=88=EB=8A=94=EB=8D=B0=20=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=EC=97=90=20=EB=AA=BB=20=EC=8B=A4=EB=A6=B0=20=EB=AA=AB=EC=9D=84?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=EC=9D=B4=20=EB=A7=90=ED=95=98=EA=B2=8C=20?= =?UTF-8?q?=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「반쪽 단가」 표시가 여태 **이름을 카탈로그에서 못 찾은 줄**만 잡고 있었음. 그물 밖에 둘이 더 있었고, 둘 다 **금액이 서 있는 줄**이라 표시가 없으면 완성된 값으로 읽혔음. - 기초잡석 12-25 — 원문에 「운반 | 덤프트럭(15ton)」 줄이 있는데 우리 단가에 없음. ⚠ 사유를 「원문이 값을 안 줌」으로 뭉뚱그리지 않음 — 그 값은 품셈 10-12(덤프운반)가 **운반거리로** 내는 것이라 **거리가 정해지면 풀림**. 「영영 막힘」과 「곧 풀림」은 사용자가 보는 뜻이 다름 - 기초잡석 소할 — 원문이 「**브레이커 사용할 때 제외**」라 한 줄인데 지금은 무조건 붙음. ⚠ 조건부 열두 줄 중 **이것만 금액이 서 있음**(다른 열한 줄은 붙는 쪽, 이것은 빠지는 쪽) - 규준틀 11-2·11-3 — 지금 값은 **인력 품만**. 손율은 원문에 있음(비탈 50% · 수평 80%) ⚠ 앞 보고를 정정함 — 규준틀은 「표시가 아무것도 없다」고 했으나 **B08 인계가 이미 「재료량은 [주]④ 설계수량에 따른다라 미확보」를 싣고 있었음.** 화면 문구 뒤쪽이 잘려 안 보였던 것임. 그래서 그 말은 되풀이하지 않고 **B09 쪽에서만 아는 것**만 보탬. 「잘린 자리를 사유가 없는 자리로 읽지 말 것」을 주석에 남김 검증: pytest 300 통과(신규 5) Co-Authored-By: Claude Opus 5 (1M context) --- .../B09_Estimation_BillOfQuantities_Rows.py | 9 +++ B09_Estimation/B09_Estimation_KnownGaps.py | 67 +++++++++++++++++++ .../B09_Estimation_UnitPrice_View.py | 4 ++ 3 files changed, 80 insertions(+) create mode 100644 B09_Estimation/B09_Estimation_KnownGaps.py diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py index ed02f559..fbf2c438 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py @@ -314,6 +314,12 @@ def _leaf_row( if pending: row.note = " / ".join(part for part in (row.note, pending) if part) + # ⚠ **원문에는 있는데 단가에 못 실린 몫**도 같은 자리에서 말한다. 금액이 서 있는 줄이라 + # 표시가 없으면 완성된 값으로 읽힌다(규준틀 둘이 인력만으로 492만원이었다). + gap = known_gap_note(node.code) + if gap: + row.note = " / ".join(part for part in (row.note, gap) if part) + # 쓰인 차례를 기억한다 — 실무 참조번호(「단산 46」)가 그 차례다. if price_code not in result.used_unit_prices: result.used_unit_prices.append(price_code) @@ -350,6 +356,9 @@ _PENDING_FORMULA: dict[str, str] = { } +from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note # noqa: E402 + + def pending_formula_note(code: str | None) -> str: """그 공종의 수량 산식이 확정 대기인가 — 맞으면 실을 문구.""" if not code: diff --git a/B09_Estimation/B09_Estimation_KnownGaps.py b/B09_Estimation/B09_Estimation_KnownGaps.py new file mode 100644 index 00000000..ac0f36b2 --- /dev/null +++ b/B09_Estimation/B09_Estimation_KnownGaps.py @@ -0,0 +1,67 @@ +"""B09 원가계산 — **원문에는 있는데 단가에 못 실린 몫** (2026-09-09). + +「반쪽 단가」 표시는 여태 **이름을 카탈로그에서 못 찾은 줄**만 잡았다. 그물 밖에 둘이 더 있다. + + ㉠ 표에 줄은 있는데 **값을 못 적는** 것 — 기초잡석 「운반 | 덤프트럭(15ton)」 + ㉡ 표에 아예 없고 **[주]가 별도라 한** 것 — 규준틀 목재·표지판 + +둘 다 **금액이 서 있는 줄**이라 표시가 없으면 **완성된 값으로 읽힌다**(2026-09-09 실측: +규준틀 둘이 4,921,267원인데 인력만의 값이었다). + +⚠ **사유를 뭉뚱그리지 않는다.** 「원문이 값을 안 줌」은 **영영 막힌 것**으로 읽히고, +「거리 미정」·「설계수량 대기」는 **곧 풀릴 것**으로 읽힌다. 사용자가 보는 뜻이 다르다. + +⚠ **여기 적는 것은 원문에 있는 말뿐이다.** 값을 만들지 않는다 — 무엇이 왜 빠졌는지만 적는다. +⚠ **마스터가 [주]를 싣게 되면 이 표는 지운다** — 두 곳에 같은 말을 두면 한쪽만 고쳐진다. +""" + +from __future__ import annotations + +#: 공종코드 → (짧은 딱지, 사유 한 줄). 접두사로 맞춘다(갈래가 붙어도 걸리게). +KNOWN_GAPS: dict[str, tuple[str, str]] = { + # ⚠ 규준틀 둘은 **B08 인계가 이미 「재료량은 [주]④ 설계수량에 따른다라 미확보」**를 싣는다. + # 그러니 그 말을 되풀이하지 않고 **B09 쪽에서만 아는 것**만 보탠다 — + # ㉠ 지금 선 값이 **인력 품만**이라는 것 ㉡ 손율은 원문에 이미 있다는 것. + # (2026-09-09: 처음엔 「표시가 아무것도 없다」고 봤는데, 인계 사유가 화면 문구 뒤쪽에 + # 잘려 안 보였던 것이다. 잘린 자리를 사유가 없는 자리로 읽지 말 것.) + "FP-11-02": ( + "인력 품만", + "ⓘ 지금 값은 **인력 품만**입니다. 재료가 서면 손율이 함께 걸립니다 — " + "품셈 11-2 [주]③ 「목재의 손율은 1개소 사용당 50%」.", + ), + "FP-11-03": ( + "인력 품만", + "ⓘ 지금 값은 **인력 품만**입니다. 재료가 서면 손율이 함께 걸립니다 — " + "품셈 11-3 [주]③ 「목재의 손율은 1개소 사용당 80%」.", + ), + "FP-12-25": ( + "운반거리 미정", + "⚠ 이 값에는 **운반 몫이 빠져 있습니다** — 품셈 12-25 는 「운반 | 덤프트럭(15ton)」 줄을 " + "두었으나 시간을 적지 않았습니다. 그 값은 품셈 10-12(덤프운반)가 **운반거리로** 냅니다" + "(같은 15ton 장비). 거리가 정해지면 사토 운반·덤프 운반과 **함께** 섭니다.", + ), +} + +#: 조건이 서면 **빠져야 하는** 줄 — 지금은 무조건 붙어 있다. +#: ⚠ 다른 조건부 줄들(계획서 9-9)과 달리 **이것은 금액이 서 있는 줄**이다. +CONDITIONAL_INCLUDED: dict[str, str] = { + "FP-12-25": ( + "ⓘ 소할(할석공 0.06인)은 품셈 12-25 가 「**브레이커 사용할 때 제외**」라 적은 줄입니다 — " + "브레이커 갈래가 서면 이 몫이 빠져야 합니다. 지금은 붙어 있습니다." + ), +} + + +def known_gap_note(code: str | None) -> str: + """그 공종에 **원문에는 있는데 못 실린 몫**이 있으면 사유 한 줄.""" + if not code: + return "" + plain = str(code).split("#")[0] + parts = [] + for prefix, (_label, note) in KNOWN_GAPS.items(): + if plain.startswith(prefix): + parts.append(note) + for prefix, note in CONDITIONAL_INCLUDED.items(): + if plain.startswith(prefix): + parts.append(note) + return " / ".join(parts) diff --git a/B09_Estimation/B09_Estimation_UnitPrice_View.py b/B09_Estimation/B09_Estimation_UnitPrice_View.py index b3cd3790..200999ec 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice_View.py +++ b/B09_Estimation/B09_Estimation_UnitPrice_View.py @@ -167,6 +167,8 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict: money = build.book.resolve(code) # 코드에서 공종을 도로 뽑는다 — 「B-FP-09-11-01#갈래」의 갈래는 떼고 본다. work_item_code = code[2:].split("#")[0] if code.startswith("B-") else "" + from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note + unattached = list(build.unattached.get(work_item_code, [])) rows: list[dict] = [] for detail in build.book.details.get(code, []): @@ -261,6 +263,8 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict: # ⚠ **표에 있는데 못 붙은 줄** — 이 단가가 일부만으로 섰다는 뜻이다. # 안 보이면 조용히 싼 단가가 내역서에 그대로 든다. "unattached": unattached, + # ⚠ 원문에는 있는데 못 실린 몫 — 이름을 못 찾은 줄과 **다른 갈래**다. + "known_gap_note": known_gap_note(work_item_code), "unattached_note": ( f"⚠ 품셈 표에 있는 {len(unattached)}줄이 아직 안 붙었습니다 — " f"{', '.join(unattached[:4])}"