"""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 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 load_machine_catalog from B09_Estimation.B09_Estimation_MachineProductivity import ( CycleFactors, FactorGap, extract_cycle_factors, machine_hours_per_unit, ) 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_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) #: 배분율 표인데 일부 몫만 붙은 공종 — 「단가가 일부만 섬」. 값은 붙은 몫(%). 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), ) ) 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) if machine is None or machine.loss_coefficient_per_hour is None or record is None: 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="시간당 손료")) 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="주연료 + 잡재료")) 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시간분 인」이 된다. per_hour_person = record.operator_person_days / Decimal(8) if wage_code not in book.titles: book.add_title( PriceTitle( code=wage_code, kind=PriceKind.LABOR, name="조종원", unit="인", slots=_slots(wages[wage_code]), ) ) book.add_detail( PriceDetail(hourly_code, wage_code, per_hour_person, note="조종원 (1일 8시간)") ) 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) 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 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) -> UnitPriceBuild: """자원 축을 일위대가(`B`)로 조립한다. 공종 하나에 붙은 자원 줄들을 그 공종의 상세로 삼는다. 자원이 하나도 안 붙은 공종은 **빈 줄로 세우지 않고 건너뛴다** — 0 원 일위대가가 내역에 서면 안 된다. """ master = load_work_item_master() 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() 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"} 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) 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: 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), "") 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). machine_share = ( _ZERO if variant else _attach_machine_share(build, master, work_item_code, title_code) ) # ⚠ **배분율이 있는 표는 「몇 %가 실제로 붙었나」를 세어 둔다.** # 「인력(10%)·장비(90%)」 표에서 인력만 붙으면 단가가 10 % 몫만인데, 그 값이 # 조용히 서면 내역서가 틀린 줄 모른다(2026-09-08 실측: 측구터파기 39,575.6원/㎥ # 이 인력 10 % 몫만이었다). 0 으로 때우는 것과 같은 종류의 사고다. # 값을 못 읽은 자원 줄이 있으면 **일부만 선 단가**다 — 금액을 만들지 않는다. if work_item_code in axis.partial_items: build.partial_ratio.setdefault(work_item_code, _ZERO) 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 return build 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 _attach_machine_share( build: UnitPriceBuild, master: dict, work_item_code: str, title_code: str, ) -> Decimal: """시공능력 공식으로 **장비 몫**을 붙인다. 붙인 비율(%)을 돌려준다. 계수가 다 안 서면 **아무것도 안 붙이고 0 을 돌려준다** — 그러면 그 공종은 `partial_ratio` 에 남아 내역서에서 금액이 안 붙는다(지어낸 값이 서는 것보다 낫다). """ 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 _ZERO for table in node.get("tables", []): factors = extract_cycle_factors(work_item_code, table) if not isinstance(factors, CycleFactors): if isinstance(factors, FactorGap): build.factor_gaps[work_item_code] = factors continue hourly_code = f"X-{factors.machine_code}" if hourly_code not in build.book.titles: # 기계 층이 안 섰다 — 지어내지 않고 못 붙인 채로 둔다. build.factor_gaps[work_item_code] = FactorGap( work_item_code=work_item_code, pum_table_id=factors.pum_table_id, missing=("시간당 사용료",), note=f"{factors.machine_name} 의 시간당 사용료가 아직 안 섰습니다.", ) continue share = ( Decimal(1) if factors.machine_ratio_pct is None else Decimal(str(factors.machine_ratio_pct)) / Decimal(100) ) build.book.add_detail( PriceDetail( title_code, hourly_code, machine_hours_per_unit(factors) * share, note=factors.formula_text, ) ) build.cycle_factors[work_item_code] = factors return share * Decimal(100) return _ZERO 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=1) def cached_build() -> UnitPriceBuild: """조립 결과를 한 번만 만든다 — 품셈 3 MB 를 요청마다 다시 읽지 않는다.""" return build_unit_prices() @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", ]