"""B09 원가계산 — ③ 단가산출·일위대가 조립 (PLAN 9-3 · 9-5). 자원 축(`resource_axis`)이 「이 공종 1단위에 무엇이 얼마나」를 갖고 있고, 카탈로그가 「그 자원 하나가 얼마」를 갖고 있다. 이 모듈이 둘을 곱해 **일위대가 한 줄**을 만든다. 층은 그대로 쌓는다 (PLAN 9-3): S 취득가 · L 노임 · M 자재 → X 시간당 중기사용료 → B 일위대가 `PriceBook` 에 제목·상세로 앉히므로 **표를 따로 만들지 않는다.** **부르는 가드** (함수만 있고 안 부르면 없는 것과 같다) - ㉠ 자재는 **할증 전** 값 — 할증은 자재총괄 한 곳뿐 (`check_surcharge_once`). - ㉣ 작업효율은 사용료 쪽에 안 넣음 (`reject_efficiency_in_hourly_rate`, `B09_Estimation_MachineCost` 안에서 호출됨). """ 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 from B09_Estimation.B09_Estimation_Guards import check_column_sums, check_surcharge_once from B09_Estimation.B09_Estimation_MachineCost import ( OPERATOR_ALLOWANCE_FACTOR, load_machine_catalog, ) from B09_Estimation.B09_Estimation_MachineProductivity import ( CycleFactors, FactorGap, attach_machine_share, extract_cycle_factors, ) from B09_Estimation.B09_Estimation_MachineProductivity_Dozer import ( attach_dozer_share, dozer_variants, extract_dozer_factors, formula_machine_codes, ) from B09_Estimation.B09_Estimation_MachineOperating import ( load_fuel_price, load_operating_records, load_operator_wages, ) from B09_Estimation.B09_Estimation_PriceBook import ( PriceBook, PriceDetail, PriceKind, PriceTitle, ) from B09_Estimation.B09_Estimation_ResourceAxis import ( RANGE_DASHES, AxisResult, build_resource_axis, load_combined_catalog, load_labor_catalog, load_work_item_master, ) from B09_Estimation.B09_Estimation_WorkItemUnit import unit_of as work_item_unit from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at _RE_EMPHASIS = re.compile(r"\*\*(.+?)\*\*") _ZERO = Decimal(0) #: 연료는 자재 카탈로그에 없어 합성 코드로 세운다 — 코드가 있어야 조인이 성립한다. FUEL_CODE_PREFIX = "M-FUEL-" #: 일위대가 총액이 이보다 작으면 **성분이 빠졌을 가능성**이 크다 — 값이 있어도 경고한다. SUSPICIOUSLY_LOW_KRW = Decimal(100) #: 기준 단위를 모르는 채 이 금액을 넘으면 **사람이 한 번 봐야 한다**. #: 품셈 표가 「10㎡당」처럼 묶음 기준일 수 있어 값 자체는 맞고 기준만 모르는 경우가 많다 #: (2026-09-08: 목재틀흙막이 상등구조 = 건축목공 16.975인 → 503만원. 값은 품셈대로다). #: **막지 않고 드러내기만 한다** — 막으면 120 중 117 이 멈춘다. SUSPICIOUSLY_HIGH_KRW = Decimal(1_000_000) def _slots(value: Decimal) -> list[Decimal | None]: """6번(적용 단가) 슬롯에만 값을 넣는다 — 유료 물가지 미구독 상태의 기본 모양.""" slots: list[Decimal | None] = [None] * 6 slots[5] = value return slots @dataclass class UnitPriceBuild: book: PriceBook = field(default_factory=PriceBook) #: 세우지 못한 공종 — 값이 안 서는 것을 빈 줄로 두지 않는다. skipped: list[str] = field(default_factory=list) #: 기계 성분이 비어 있는 채로 쓰인 기종 (연료·조종원 미확보). incomplete_machines: list[str] = field(default_factory=list) #: 자원은 알아봤는데 **값을 못 읽은 줄**이 있어 단가를 못 세운 공종 — 사유 문구. #: ⚠ 일위대가가 **아예 안 선** 경우에도 남는다 — 「일위대가 없음」과 「성분이 빠져 #: 못 세움」은 할 일이 다르므로 화면에서 갈라 보여야 한다(2026-09-08 산마루측구). component_gaps: dict[str, str] = field(default_factory=dict) #: 공종코드 → **표에 있는데 못 붙은 줄 이름들**. #: ⚠ 값이 있다는 것은 그 단가가 **표의 일부만으로 서 있다**는 뜻이다 — 대개 자재·기계 #: 카탈로그가 없어서다. 막지는 않지만(막으면 정상 공종이 무더기로 멈춘다) **화면이 #: 반드시 말해야 한다.** 안 말하면 조용히 싼 단가가 내역서에 그대로 든다 #: (2026-09-09 실측: 일위대가가 선 141 공종 중 **70 공종**이 이 자리였다). unattached: dict[str, list[str]] = field(default_factory=dict) #: 공종코드 → **계수를 어디서 가져왔는지** 한 줄. 「…와 동일」 참조를 따라간 자리다. #: ⚠ 값이 남의 절에서 온 것이면 **화면이 그렇게 말해야** 한다 — 안 그러면 나중에 #: 「이 숫자 어디서 왔지」로 되짚을 길이 없다. factor_sources: dict[str, str] = field(default_factory=dict) #: 조합 사용(품셈 [주]⑤)으로 **잡재료 16% 층으로 바꿔 단 줄** 수. combined_swapped: int = 0 #: 배분율 표인데 일부 몫만 붙은 공종 — 「단가가 일부만 섬」. 값은 붙은 몫(%). partial_ratio: dict[str, Decimal] = field(default_factory=dict) #: 시공능력 공식으로 장비 몫을 세운 공종 — 산출근거를 화면에 그대로 보인다. cycle_factors: dict[str, CycleFactors] = field(default_factory=dict) #: 공종 하나가 낳은 규격 갈래들 — 「무근구조물」·「철근구조물」·「소형구조물」. variants: dict[str, list[str]] = field(default_factory=dict) #: 밑수(「10㎡당」)를 못 찾은 표를 쓰는 공종 — **곱하면 안 되는 줄**이다. #: 1 단위당으로 단정하면 곱셈이 10배·100배 틀린다(B08 `basis_missing` 목록). basis_missing: dict[str, str] = field(default_factory=dict) #: 계수를 못 세운 표 — **무엇이 없는지**를 들고 있는다. factor_gaps: dict[str, FactorGap] = field(default_factory=dict) def _add_labor_titles(book: PriceBook, wages: dict[str, Decimal]) -> None: catalog = load_labor_catalog() for entry in catalog.entries: wage = wages.get(entry.code) if wage is None or entry.code in book.titles: continue book.add_title( PriceTitle( code=entry.code, kind=PriceKind.LABOR, name=entry.name, unit="인", slots=_slots(wage), ) ) #: 부착용 장비 — 제 엔진이 없어 **손료만** 붙는다(품셈 제8장 [주]⑤). #: ⚠ 이 목록을 넓히지 말 것 — 넓히면 연료가 빠진 기계가 조용히 싸게 선다. _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` 를 올린다. 시간당 사용료를 **미리 계산해 넣지 않는다** — 층을 실제로 쌓아야 화면이 「무엇으로 이루어졌나」를 보일 수 있다(PLAN 8-13 계산 과정을 감추지 않음). """ catalog = load_machine_catalog() operating = {r.machine_code: r for r in load_operating_records().records} fuel_price, _ = load_fuel_price() wages = load_operator_wages() incomplete: list[str] = [] fuel_code = f"{FUEL_CODE_PREFIX}경유" if fuel_code not in book.titles: book.add_title( PriceTitle( code=fuel_code, kind=PriceKind.MATERIAL, name="경유", unit="L", slots=_slots(fuel_price), ) ) for code in sorted(machine_codes): machine = catalog.machines.get(code) record = operating.get(code) # ⚠ **부착용 장비는 운전경비표에 줄이 없다** — 제 엔진이 없어 연료·조종원이 # 본체(굴착기·불도저)에 든다. 품셈 제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 base_code = f"S-{code}" hourly_code = f"X-{code}" if hourly_code in book.titles: continue # S — 취득가에서 나온 시간당 손료. 경비 성분만 갖는다. book.add_title( PriceTitle( code=base_code, kind=PriceKind.MACHINE_BASE, name=machine.name, spec=machine.specification, unit="hr", slots=_slots( machine.price_thousand_krw * Decimal(1000) * machine.loss_coefficient_per_hour ), ) ) book.add_title( PriceTitle( code=hourly_code, kind=PriceKind.MACHINE_HOURLY, name=machine.name, spec=machine.specification, unit="hr", ) ) 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: # 잡재료는 **주연료의 %** — 유가와 같이 움직인다. 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} (연료소모량 없음)") wage_code = record.operator_occupation_code if wage_code and wage_code in wages and record.operator_person_days is not None: # ㉣ 나눗수는 8시간 — `PriceDetail` 수량이 「1시간분 인」이 된다. # 여기에 **제수당·상여금·퇴직급여충당금 계수**(1.667배)를 곱한다. 공표 노임이 # 기본급여액뿐이라 별도 계상해야 하는 몫이다(`MachineCost` 상수 주석에 근거). per_hour_person = (record.operator_person_days / Decimal(8)) * OPERATOR_ALLOWANCE_FACTOR if wage_code not in book.titles: book.add_title( PriceTitle( code=wage_code, kind=PriceKind.LABOR, name="조종원", unit="인", 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, wage_code, per_hour_person, note="조종원 (1일 8시간 × 제수당·상여·퇴직충당 16/12 × 25/20)", ) ) else: incomplete.append(f"{code} (조종원 없음)") return incomplete @lru_cache(maxsize=1) def load_basis_missing( file_name: str = "basis_missing_2026-01-01.json", ) -> dict[str, str]: """B08 이 낸 **밑수 못 찾은 표** 목록 — `{표 번호: 절 이름}`. 「10㎡당」 같은 기준을 원문에서 못 찾은 표다. 1 단위당으로 단정하면 곱셈이 10배·100배 틀리므로(떼채취가 실제로 100배였다) 그 표를 쓰는 공종은 **금액을 안 만든다**. """ import json import os root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) path = os.path.join(root, "resources", "data_work_item_master", file_name) if not os.path.exists(path): return {} with open(path, encoding="utf-8") as handle: payload = json.load(handle) return { str(item.get("pum_table_id")): str(item.get("section", "")) for item in payload.get("items", []) } #: 품셈 원문이 섞어 쓰는 물결표 — 「직경40㎝이상∼60㎝미만」(U+223C)과 #: 「직경40㎝이상~60㎝미만」(U+FF5E)이 **같은 뜻인데 키가 두 벌**이었다(2026-09-08 실측: #: 13-6-1·2 는 ∼, 13-6-3 은 ~). 키에서만 한 종류로 모으고 **원문 문구는 이름에 보존**한다. #: ⚠ 규칙은 둘뿐이다 — **내부 공백 제거 + 물결표 통일.** 다른 글자는 손대지 않는다 #: (키에 쓰인 글자를 세어 보니 그 밖에는 소수점·괄호뿐이었다). #: 물결표 목록은 `B09_Estimation_ResourceAxis.RANGE_DASHES` 한 곳에서 온다 — #: 붙임표(`-`·`–`)는 갈래 이름에 안 쓰이므로 물결표만 골라 쓴다. _TILDE_CHARS = "".join(ch for ch in RANGE_DASHES if ch not in "-–‐") def normalize_variant_key(text: str) -> str: """갈래 키 정규화 — 공백을 지우고 물결표를 한 종류(`~`)로 모은다.""" tight = "".join(str(text).split()) 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, build: UnitPriceBuild | None = None, ) -> str | None: """B08 이 보낸 **저장 제원 원본값**(「60~80」)을 내 갈래 코드로 옮긴다. 갈래 키는 품셈 원문에서 나오고 **그 원문을 읽는 쪽이 여기**다(2026-09-08 두 창 합의). 못 맞추면 `None` — **가까운 갈래를 임의로 고르지 않는다.** """ prices = build or cached_build() 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)] for code in candidates: if normalize_variant_key(code[len(prefix) :]) == wanted: return code # ⚠ 글자 포함으로는 안 맞는다 — 「60~80」은 「직경60㎝이상~80㎝미만」 **안에 없다** # (사이에 「㎝이상」이 낀다). **수의 짝**으로 견준다: [60, 80] == [60, 80]. numbers = _numbers_of(wanted) if not numbers: return None hits = [ code for code in candidates if _numbers_of(normalize_variant_key(code[len(prefix) :])) == numbers ] if len(hits) == 1: return hits[0] if len(numbers) == 1: # 저장 제원이 **한 값**으로 온다(뒷길이 45㎝). 갈래는 구간이므로 그 값을 담는 # 구간을 고른다 — 「45」 → 「55cm이하」. **가장 좁은 구간**을 고른다. return _bracket_for(numbers[0], candidates, prefix) return None def _bracket_for(value: Decimal, candidates: list[str], prefix: str) -> str | None: """그 값을 담는 갈래 — 「N 이하」는 상한, 「A 이상~B 미만」은 범위로 본다.""" best: tuple[Decimal, str] | None = None for code in candidates: label = normalize_variant_key(code[len(prefix) :]) bounds = _numbers_of(label) if len(bounds) == 1: if "이하" in label and value <= bounds[0]: if best is None or bounds[0] < best[0]: best = (bounds[0], code) elif len(bounds) == 2 and bounds[0] <= value <= bounds[1]: width = bounds[1] - bounds[0] if best is None or width < best[0]: best = (width, code) return best[1] if best else None def _numbers_of(text: str) -> list[Decimal]: """그 문자열에 나오는 수들 — 「직경60㎝이상~80㎝미만」 → [60, 80].""" return [Decimal(token) for token in re.findall(r"\d+(?:\.\d+)?", text)] def build_unit_prices( axis: AxisResult | None = None, factor_choices: dict[tuple[str, str], Decimal] | None = None, machine_picks: dict[str, str] | None = None, ) -> UnitPriceBuild: """자원 축을 일위대가(`B`)로 조립한다. 공종 하나에 붙은 자원 줄들을 그 공종의 상세로 삼는다. 자원이 하나도 안 붙은 공종은 **빈 줄로 세우지 않고 건너뛴다** — 0 원 일위대가가 내역에 서면 안 된다. ⚠ `factor_choices` — 품셈이 **범위로 준 계수**에 사용자가 고른 값(확정 ①). 안 주면 **평균**이 기본이다(`B09_Estimation_FactorChoices`). 범위가 아닌 칸은 안 덮는다. """ from B09_Estimation.B09_Estimation_FactorChoices import ( chosen_values, machine_choices, scan_range_factors, ) from B09_Estimation.B09_Estimation_MachineProductivity_Reference import ( direct_capacity_rows, reference_factor_values, ) master = load_work_item_master() if factor_choices is None: factor_choices = chosen_values(scan_range_factors(master)) # 「…와 동일」 참조로 이어 온 계수 — **사용자가 고른 값이 있으면 그것이 이긴다.** borrowed, borrow_note, borrow_fail, borrow_text = reference_factor_values(master) factor_choices = {**borrowed, **factor_choices} if machine_picks is None: machine_picks = machine_choices() if axis is None: axis = build_resource_axis(master, load_combined_catalog()) # 일위대가 이름은 **공종명**이어야 한다 — 코드만 보이면 사람이 못 읽는다. names = {w["work_item_code"]: w.get("name", "") for w in master.get("work_items", [])} build = UnitPriceBuild() build.factor_sources = dict(borrow_note) 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 실측). labels = build.unattached.setdefault(unmatched_row.work_item_code, []) label = " ".join(str(unmatched_row.cell).split()) 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 any(_names_a_machine(label) for label in build.unattached.get(code, [])) } for code in solved_codes: build.component_gaps.pop(code, None) # 참조는 풀렸는데 **다른 줄이 남은** 공종은 막힌 채로 두되 **사유를 고쳐 적는다** — # 「백호우 줄을 못 읽었다」는 이미 푼 이야기라 그대로 두면 사람을 엉뚱한 데로 보낸다. for code in borrow_note: remaining = build.unattached.get(code) if remaining and code in build.component_gaps: build.component_gaps[code] = ( f"{', '.join(remaining[:3])} 줄이 아직 안 붙었습니다 (계수 참조는 풀렸습니다)" ) missing_basis = load_basis_missing() wages = load_operator_wages() _add_labor_titles(build.book, wages) 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`)가 있으면 **갈래마다 따로 세운다** — 무근·철근·소형구조물은 # 품이 달라 한 일위대가로 뭉치면 어느 것도 안 맞는다. by_item: dict[tuple[str, str], list] = {} for row in axis.rows: by_item.setdefault((row.work_item_code, getattr(row, "variant", "")), []).append(row) # ⚠ **자원 줄이 하나도 없어도 공식이 온전하면 세운다.** 기계만 쓰는 공종(층따기 9-18 · # 쇄석 부설 11-4)은 표에 인력 줄이 없어 자원 축이 비고, 그러면 아래 반복문이 그 공종을 # 아예 안 본다 — 「미판정」으로 남아 있던 것이 실은 **시공능력 공식표**였다 # (2026-09-08 미판정 77건을 훑다 발견). for node in master.get("work_items", []): code = node.get("work_item_code") if not code or (code, "") in by_item: continue # 불도저 운반(8-2-1)은 **갈래(토사·파쇄암·발파암)마다 값이 다르다** — 한 벌로 # 뭉치면 어느 것도 안 맞는다. 갈래가 있으면 갈래를 세우고 여기서 끝낸다. labels = dozer_variants(node) if labels: for label in labels: by_item.setdefault((code, label), []) continue for table in node.get("tables", []): if isinstance( extract_cycle_factors(code, table, factor_choices, machine_picks), CycleFactors ): by_item[(code, "")] = [] break for (work_item_code, variant), rows in sorted(by_item.items()): # 갈래 키는 **내부 공백을 지운 것**, 화면 문구는 **원문 그대로** # (2026-09-08 두 창 합의). 원문이 「보 통」·「보 통」으로 들쭉날쭉해 # 키에 공백을 남기면 한 칸 차이로 영영 안 맞는다. 공백 말고는 손대지 않는다. variant_key = normalize_variant_key(variant) title_code = f"B-{work_item_code}" + (f"#{variant_key}" if variant_key else "") if title_code in build.book.titles: continue # ⚠ **붙을 상세를 먼저 모으고, 하나도 없으면 제목도 안 세운다.** # 제목만 세워 두면 「상세 줄이 없어 단가를 못 조립」하는 빈 일위대가가 남는다 # (기계 층이 안 선 기종만 참조하는 공종에서 실제로 생겼음). attachable = [ (row, row.resource_code if row.resource_kind == "labor" else f"X-{row.resource_code}") for row in rows ] attachable = [(row, ref) for row, ref in attachable if ref in build.book.titles] if not attachable and not _has_full_formula( master, work_item_code, factor_choices, machine_picks ): # 붙을 상세도 없고 공식도 없으면 **제목도 안 세운다**(0 원 일위대가 금지). build.skipped.append(work_item_code) continue # ⚠ 밑수를 못 찾은 표를 쓰면 **곱하면 안 되는 줄**로 표시한다. for row in rows: section = missing_basis.get(str(row.pum_table_id)) if section: build.basis_missing[work_item_code] = section break unit = next((r.amount_unit for r in rows if r.amount_unit), "") if not unit: # ⚠ 단위가 없으면 **내역서의 단위 불일치 검사가 못 걸린다** — 층따기가 # 「㎡ 수량 × ㎥당 단가」로 4,102,708원을 내고 있었다. 품셈 원문에 적힌 것만 # 채우고(「(단위: ㎥당)」·「Q= ㎥/시간」), 없으면 비워 둔다. unit = work_item_unit(work_item_code) or "" base_name = names.get(work_item_code) or work_item_code build.book.add_title( PriceTitle( code=title_code, kind=PriceKind.UNIT_PRICE, name=f"{base_name} ({variant})" if variant else base_name, spec=variant or work_item_code, unit=unit, ) ) if variant_key: build.variants.setdefault(work_item_code, []).append(variant) # 배분율 표는 각 몫을 **그 비율만큼만** 센다 — 인력 원단위를 전량에 곱하면 틀린다. for row, ref in attachable: share = _share_of(row) build.book.add_detail(PriceDetail(title_code, ref, row.amount * share)) # 제잡비 — **노무비 합계의 %가 경비로** 붙는다(품셈 13-6-1 [주]③). # ⚠ 기본은 **아랫단**(물빼기 파이프 미설치)이다. 윗단을 쓰면 파이프를 따로 세면 # 안 되므로(㉥ 가드), 그 선택은 설계 조건이 들어올 때 한다. # ⚠ **「상한」이다** — 곱한 값 이하로 계상하는 값이라 산출근거에 그 사실을 적는다. ratio = axis.overhead_ratio.get(work_item_code) if ratio is not None and not variant_key.startswith("__"): lower = ratio[1] build.book.add_detail( PriceDetail( title_code, title_code, _ZERO, note=f"제잡비 노무비의 {lower}% (상한, 물빼기 파이프 미설치 기준)", percent_of_labor=lower, ) ) # 장비 몫은 자원 수량이 아니라 **시공능력 공식**으로 온다 (품셈 8-1-4). # ⚠ 불도저와 굴착기는 **식이 다르다**(8-2-1 vs 8-1-4). 둘 다 붙이면 장비를 두 번 # 세므로, 불도저가 붙은 자리는 굴착기 쪽을 아예 안 본다. machine_share = attach_dozer_share( build.book, build.factor_gaps, master, work_item_code, title_code, variant ) if not machine_share and not variant: machine_share = attach_machine_share( build.book, build.factor_gaps, build.cycle_factors, master, work_item_code, title_code, factor_choices, machine_picks, 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원/㎥ # 이 인력 10 % 몫만이었다). 0 으로 때우는 것과 같은 종류의 사고다. # 값을 못 읽은 자원 줄이 있으면 **일부만 선 단가**다 — 금액을 만들지 않는다. if work_item_code in axis.partial_items and work_item_code not in solved_codes: build.partial_ratio.setdefault(work_item_code, _ZERO) # ⚠ 공식은 있는데 **아무것도 안 붙은** 제목은 남기지 않는다 — 「상세 줄이 없어 # 조립 불가」로 화면에서 터진다. 기계 층이 못 선 경우가 그 자리다. 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) build.skipped.append(work_item_code) continue covered = _covered_ratio_pct(rows, {ref for _, ref in attachable}, build) if covered is not None: 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 def _has_full_formula( master: dict, work_item_code: str, choices: dict[tuple[str, str], Decimal] | None = None, machines: dict[str, str] | None = None, ) -> bool: """그 공종에 **온전한 시공능력 공식**이 있는가 (기계만 쓰는 공종용).""" node = next( (w for w in master.get("work_items", []) if w.get("work_item_code") == work_item_code), None, ) if node is None: return False return any( isinstance(extract_cycle_factors(work_item_code, table, choices, machines), CycleFactors) or isinstance(extract_dozer_factors(work_item_code, table), dict) for table in node.get("tables", []) ) def _share_of(row) -> Decimal: """그 줄이 차지하는 몫(0~1). 배분율이 없으면 1 — 종전과 같다.""" ratio = getattr(row, "group_ratio_pct", None) return Decimal(1) if ratio is None else Decimal(str(ratio)) / Decimal(100) def _covered_ratio_pct( rows: list, attached_refs: set[str], build: UnitPriceBuild ) -> Decimal | None: """배분율 표에서 **실제로 붙은 몫**의 합계(%). 배분율이 없는 표면 `None`.""" ratios = { row.group_ratio_pct for row in rows if getattr(row, "group_ratio_pct", None) is not None } if not ratios: return None covered = Decimal(0) seen: set[Decimal] = set() for row in rows: ratio = getattr(row, "group_ratio_pct", None) if ratio is None or ratio in seen: continue ref = row.resource_code if row.resource_kind == "labor" else f"X-{row.resource_code}" if ref in attached_refs: seen.add(ratio) covered += ratio return covered def material_total_before_surcharge(build: UnitPriceBuild, code: str) -> Decimal: """일위대가 한 줄의 **할증 전** 재료비 합계 — ㉠ 가드에 넘길 값.""" return build.book.resolve(code).material def verify_surcharge_once( build: UnitPriceBuild, code: str, *, material_summary_total: Decimal, surcharge_rate_percent: Decimal, ) -> None: """㉠ 자재총괄 합과 대조한다 — 할증이 두 번 붙었으면 여기서 멈춘다.""" check_surcharge_once( material_summary_total=material_summary_total, unit_price_material_total=material_total_before_surcharge(build, code), surcharge_rate_percent=surcharge_rate_percent, label=code, ) #: 상세 줄이 **어느 층에서 왔는지** 보이는 표시 (PLAN 9-3, ESTX `LinkIndex` 와 같은 축). SOURCE_INDEX: dict[PriceKind, int] = { PriceKind.MATERIAL: 5, PriceKind.LABOR: 6, PriceKind.MACHINE_BASE: 105, PriceKind.MACHINE_HOURLY: 105, PriceKind.UNIT_PRICE: 103, PriceKind.PRICE_BASIS: 104, PriceKind.LUMPSUM: 0, } SOURCE_LABEL: dict[PriceKind, str] = { PriceKind.MATERIAL: "자재", PriceKind.LABOR: "노임", PriceKind.MACHINE_BASE: "기계경비", PriceKind.MACHINE_HOURLY: "기계경비", PriceKind.UNIT_PRICE: "일위대가", PriceKind.PRICE_BASIS: "단가산출", PriceKind.LUMPSUM: "일식·견적", } #: 상세를 파고들 수 있는 층 — 이 종류의 줄을 누르면 그 본표가 열린다. DRILLABLE_KINDS = frozenset({PriceKind.MACHINE_HOURLY, PriceKind.UNIT_PRICE, PriceKind.PRICE_BASIS}) @lru_cache(maxsize=8) def cached_build( range_choices: tuple[tuple[str, str], ...] = (), machine_picks: tuple[tuple[str, str], ...] = (), ) -> UnitPriceBuild: """조립 결과를 한 번만 만든다 — 품셈 3 MB 를 요청마다 다시 읽지 않는다. ⚠ 인자는 **프로젝트가 고른 값**이다(확정 ①). 아무것도 안 주면 확정 기본값 — 범위 계수는 평균, 장비는 원문 값이다. 고른 값이 다르면 **다른 벌로 캐시된다** — 한 벌만 들면 프로젝트마다 다른 값이 서로 덮어쓴다. """ from B09_Estimation.B09_Estimation_FactorChoices import ( chosen_values, machine_choices, scan_range_factors, ) settings = { "range_factor_choices": dict(range_choices), "machine_choices": dict(machine_picks), } master = load_work_item_master() return build_unit_prices( factor_choices=chosen_values(scan_range_factors(master), settings), machine_picks=machine_choices(settings), ) @dataclass class DirectCostBreakdown: """⑤ 공사원가계산서가 받는 **직접비 3분할**. ⚠ **일위대가 합계를 순공사비로 뭉쳐 넣으면 안 된다.** ⑤ 의 밑수는 항목마다 갈리고 (산재·고용 = 노무비 / 건강·연금 = 직접노무비 / 기타경비 = 재료비+노무비 …), 뭉쳐 넣으면 그 밑수가 전부 틀린다(PLAN 8-9 규칙 2). 일위대가는 3분할을 이미 들고 있으니 **성분별로 접어 넣는다.** """ material: Decimal = _ZERO labor: Decimal = _ZERO expense: Decimal = _ZERO #: 값을 못 세운 공종 — 수량이 있는데 단가가 없으면 여기 남는다(0 으로 안 때운다). missing: list[str] = field(default_factory=list) @property def total(self) -> Decimal: return self.material + self.labor + self.expense def direct_cost_from_quantities( quantities: dict[str, Decimal], build: UnitPriceBuild | None = None, ) -> DirectCostBreakdown: """공종별 수량을 일위대가에 곱해 **직접비 3분할**을 만든다. `quantities` = `{공종코드: 수량}`. 공종코드는 `FP-09-21` 처럼 마스터 코드를 쓰거나 `B-FP-09-21` 처럼 일위대가 코드를 그대로 써도 된다. 단가가 없는 공종은 **0 으로 안 때우고** `missing` 에 남긴다 — 수량이 있는데 단가가 없으면 그 공종이 총액에서 조용히 빠진다. """ book = (build or cached_build()).book result = DirectCostBreakdown() for raw_code, quantity in quantities.items(): code = raw_code if raw_code.startswith("B-") else f"B-{raw_code}" if code not in book.titles: result.missing.append(raw_code) continue unit_money = book.resolve(code) line = unit_money.scaled(Decimal(str(quantity))) result.material += line.material result.labor += line.labor result.expense += line.expense return result def cost_input_from_quantities( quantities: dict[str, Decimal], build: UnitPriceBuild | None = None, **cost_input_kwargs, ): """직접비 3분할을 ⑤ 엔진 입력으로 접어 넣는다. 성분이 그대로 `direct_material_krw`·`direct_labor_krw`·`direct_expense_krw` 로 간다 — **뭉치지 않는다.** """ from B09_Estimation.B09_Estimation_Engine_Cost import CostInput breakdown = direct_cost_from_quantities(quantities, build) # ⑤ 표에 들어가는 자리이므로 여기서 자른다 — 자원 집계표는 **반올림**이다 # (`B09_Estimation_Rounding` 참조). `breakdown` 자체는 전정밀 값으로 남긴다. summary = OutputPlace.RESOURCE_SUMMARY return ( CostInput( direct_material_krw=round_at(breakdown.material, summary), direct_labor_krw=round_at(breakdown.labor, summary), direct_expense_krw=round_at(breakdown.expense, summary), **cost_input_kwargs, ), breakdown, ) # 화면용 조회(요약·목록·본표)는 700줄 제한으로 `_View` 파일로 옮겼다. # **부르는 쪽이 어디서 오는지 신경 쓰지 않게** 여기서 다시 내보낸다. from B09_Estimation.B09_Estimation_UnitPrice_View import ( # noqa: E402 build_summary, detail_of, list_unit_prices, ) __all__ = [ "UnitPriceBuild", "build_unit_prices", "cached_build", "build_summary", "detail_of", "list_unit_prices", "direct_cost_from_quantities", "cost_input_from_quantities", "find_variant_code", "normalize_variant_key", ]