Files
Aislo/B09_Estimation/B09_Estimation_UnitPrice.py
T
eomsangdonandClaude Opus 5 f6f51f33f5 feat(B09): 품의 할인·할증 26계열 칸 — 고른 것만 품에 붙음
산림품셈 1-4 가 26표로 품을 할인·할증하는데 한 계열도 안 붙고 있었음(자재 할증만 있었음).

- 붙는 자리 = 일위대가의 품(인력) 줄. 물량에 곱하면 자재·기계까지 부풀어 이중계상임
  (원문 제목이 「품의 할인·할증」이고 1-6 라 표가 직접노무비를 품셈으로 냄).
- 기본은 「안 고름」 — 한 계열도 안 고르면 금액이 한 원도 안 움직임.
- 각 계열에 [주] 원문을 그대로 띄움 — 26계열의 [주] 가 대개 조림·숲가꾸기·방제를
  지목하고 임도 토공에 붙이라는 지시가 원문에 없어, 켜는 것은 사용자 판단임.
- 여럿 고를 때 합산인지 곱인지 원문에 없음 — 합산으로 두되 한 곳(COMBINE_RULE)에서
  갈아 끼울 수 있게 하고 그 사실을 화면 근거에 적음.
- 품이 늘면 제잡비 밑수도 함께 늘도록 줄 차례를 지킴(할증 줄이 제잡비보다 먼저).
- ⚠ 율 표는 24개임 — 1-4-24(장비품의 -20%)·1-4-26(적용시공량 규칙)은 성격이 달라 뺌.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 22:43:14 +09:00

1172 lines
56 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 typing import Any
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,
LABOR_RELIABILITY_LABEL,
load_labor_reliability,
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
from B09_Estimation.B09_Estimation_Transport import parse_distance_km
_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)
#: 수송비를 왜 못 세웠나 — 거리·도로 구분이 없으면 여기에 사유가 남는다(빈칸으로 안 둔다).
transport_notes: list[str] = field(default_factory=list)
#: 품 할인·할증(1-4) — 고른 것이 없으면 0 이고 줄도 안 선다.
labor_surcharge_percent: Decimal = _ZERO
labor_surcharge_reasons: 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)
#: 내역에 실린 노임 중 **신뢰도 기호가 붙은 것** — 직종코드 → (이름, 기호, 뜻).
#: ⚠ 금액을 막지 않는다. 「표본이 얇다」는 사실을 화면이 말하게 하는 통로다
#: (지식DB `노임단가_적용 §2-3` 「단가 채택 시 플래그 유지 필요」, 2026-09-09에 이음).
labor_reliability: dict[str, tuple[str, str, str]] = field(default_factory=dict)
def _add_labor_titles(book: PriceBook, wages: dict[str, Decimal]) -> None:
catalog = load_labor_catalog()
# ⚠ 신뢰도 기호(`*`·`**`)를 제목에 함께 싣는다 — 지식DB 가 「단가 채택 시 플래그 유지」로
# 두었는데 노임 층에 없었다(2026-09-09). 금액은 안 바꾸고 **사실만** 나른다.
reliability = load_labor_reliability()
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),
reliability=reliability.get(entry.code, ""),
)
)
#: 부착용 장비 — 제 엔진이 없어 **손료만** 붙는다(품셈 제8장 [주]⑤).
#: ⚠ 이 목록을 넓히지 말 것 — 넓히면 연료가 빠진 기계가 조용히 싸게 선다.
_ATTACHMENT_WORDS = ("브레이커", "리퍼", "부착용집게", "집게")
#: 조합 사용 시 본체(굴착기·불도저)의 잡재료비율 — 품셈 제8장 [주]⑤.
#: 「…리퍼, 브레이커, 부착용집게를 **조합하여 사용**할 때는 …**잡재료비율을 16%로 계상**하고,
#: 리퍼, 브레이커, 부착용 집게의 손료 및 치즐 소모율을 추가하는 것이다.」
#: ⚠ 손료·치즐만 더하고 이 줄을 빠뜨리기 쉽다 — 그러면 본체 재료비가 계속 22% 로 서서
#: 조금씩 비싸진다(굴착기 0.7 기준 시간당 1,285원).
COMBINED_MISC_PERCENT = Decimal(16)
#: ⭐ 사용자 확정 5차 작은 것 1 — **공구손료·잡재료 칸**.
#: 「지금은 안 넣음. 다만 숫자를 넣으면 되게 열어 둘 것 — 칸을 만들되 기본은 빔, 비면 안 붙음」
#: 근거는 산림품셈 1-2-6 — 「각 항목에 명시되어 있는 잡재료 및 소모재료에 대해서는 이를
#: 계상하고, 명시되어 있지 않는 … 주재료비(재료비의 할증수량 제외)의 **2~5%까지** 별도
#: 계상하되 **산정 근거를 명시**하여야 한다」.
#: ⚠ **몇 %인지는 사용자 몫이다** — 기본값을 만들어 두지 않는다(범위값을 임의로 굳히면
#: 금액이 조용히 그 값으로 선다). 비어 있으면 줄 자체가 안 서고 지금 상태 그대로다.
MISC_MATERIAL_MAX_PERCENT = Decimal(5) # 「25%**까지**」 — 상한
MISC_MATERIAL_MIN_PERCENT = Decimal(2) # 원문이 적은 아랫값(아래로 내려가면 사유로 알린다)
MISC_MATERIAL_BASIS = "공구손료·잡재료 — 주재료비의 {percent}% (산림품셈 1-2-6, 산정 근거 명시)"
def parse_misc_material_percent(raw: Any) -> Decimal | None:
"""설정 칸의 값을 비율로 읽는다. **비면 `None`**(= 안 붙음).
⚠ 상한(5%)을 넘는 값은 **받지 않는다** — 품셈이 「2~5%까지」로 못 박은 자리다.
"""
text = str(raw or "").strip().rstrip("%").strip()
if not text:
return None
try:
percent = Decimal(text)
except (ArithmeticError, ValueError):
raise ValueError(f"공구손료·잡재료 비율을 숫자로 못 읽었습니다: {raw!r}") from None
if percent <= 0:
return None
if percent > MISC_MATERIAL_MAX_PERCENT:
raise ValueError(
f"공구손료·잡재료는 주재료비의 {MISC_MATERIAL_MAX_PERCENT}% 까지입니다"
f" (산림품셈 1-2-6) — 받은 값 {percent}%"
)
return percent
#: 조합으로 쓰는 부착 장비 — 이 층이 붙은 공종의 본체는 위 비율을 쓴다.
#: 카탈로그 분류번호로 잡는다 — 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], fuel_region: str | None = None
) -> list[str]:
"""`S`(취득가) · `L`(운전사) · `M`(연료) 을 세우고 그 위에 `X` 를 올린다.
시간당 사용료를 **미리 계산해 넣지 않는다** — 층을 실제로 쌓아야 화면이
「무엇으로 이루어졌나」를 보일 수 있다(PLAN 8-13 계산 과정을 감추지 않음).
⚠ `fuel_region`(시도코드) — 품셈 8-1-7 5호 「유류가격은 **해당지역의 가격**」.
안 주면 전국평균이다(현장 소재지를 임의로 찍지 않음).
"""
catalog = load_machine_catalog()
operating = {r.machine_code: r for r in load_operating_records().records}
fuel_price, fuel_meta = load_fuel_price(region=fuel_region)
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="경유",
# ⚠ 어느 판으로 섰는지 **줄에 남긴다** — 지역 값과 전국평균은 리터당
# 수십 원이 갈려 단가가 조용히 달라지는 자리다.
spec=(
f"{fuel_meta.get('region_name')} 공시가"
if fuel_meta.get("region_name")
else "전국 공시가"
),
unit="L",
slots=_slots(fuel_price),
)
)
for code in sorted(machine_codes):
machine = catalog.machines.get(code)
record = operating.get(code)
# ⚠ **부착용 장비는 운전경비표에 줄이 없다** — 제 엔진이 없어 연료·조종원이
# 본체(굴착기·불도저)에 든다. 품셈 제8장 [주]⑤ 가 그 자리를 밝힌다:
# 「불도저 및 굴착기에 **리퍼, 브레이커, 부착용집게를 조합하여 사용**할 때는
# …잡재료비율을 16%로 계상하고, **리퍼, 브레이커, 부착용 집게의 손료 및
# 치즐 소모율을 추가**하는 것이다.」
# ⇒ 그 셋은 **손료만으로** 층을 세운다. 운전경비 줄이 없다고 통째로 버리면
# 깨기 몫이 영영 안 붙는다(구조물터파기 암 갈래가 그 자리였다).
# ⚠ **다른 기계에는 이 길을 열지 않는다** — 연료가 빠진 채 조용히 싼 값이 선다.
# ⚠ 이름으로만 가리면 **이름이 빈 줄을 놓친다** — 카탈로그의 `0240-0007`
# (유압식 진동콤팩터, 굴착기 부착용)이 원천에서 **이름을 잃은 채** 들어와 있어
# 비탈면 다짐의 콤팩터 손료가 통째로 안 붙고 있었다(2026-09-09 밤 실측).
# 그래서 **분류번호**로도 가린다 — 아래 번호는 이미 조합 사용으로 못 박아 둔
# 그 넷(`_ATTACHMENT_PREFIXES`)이고, **목록을 넓히는 것이 아니다.**
attachment = machine is not None and (
any(word in re.sub(r"\s", "", machine.name) for word in _ATTACHMENT_WORDS)
or f"X-{code}".startswith(_ATTACHMENT_PREFIXES)
)
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]),
# ⚠ 조종원도 노임이다 — 같은 플래그가 붙어야 한다.
reliability=load_labor_reliability().get(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,
misc_material_percent: Decimal | None = None,
fuel_region: str | None = None,
transport_distance_km: Decimal | None = None,
transport_road: str | None = None,
labor_surcharge_choices: dict[str, str] | None = None,
) -> UnitPriceBuild:
"""자원 축을 일위대가(`B`)로 조립한다.
공종 하나에 붙은 자원 줄들을 그 공종의 상세로 삼는다. 자원이 하나도 안 붙은
공종은 **빈 줄로 세우지 않고 건너뛴다** — 0 원 일위대가가 내역에 서면 안 된다.
⚠ `factor_choices` — 품셈이 **범위로 준 계수**에 사용자가 고른 값(확정 ①). 안 주면
**평균**이 기본이다(`B09_Estimation_FactorChoices`). 범위가 아닌 칸은 안 덮는다.
⚠ `misc_material_percent` — 공구손료·잡재료(산림품셈 1-2-6). **안 주면 줄이 안 선다** —
사용자 확정 5차 작은 것 1 「지금은 안 넣되 숫자 넣으면 되게 열어 둘 것」 그대로다.
⚠ `fuel_region` — 유가 시도코드(품셈 8-1-7 5호). **안 주면 전국평균**이다.
⚠ `transport_*` — 기계 수송비(산림품셈 10-4 · 건설품셈 8-1-6의 2). **거리·도로 구분이
없으면 그 줄이 안 선다** — 임의 거리로 금액을 세우지 않는다.
⚠ `labor_surcharge_choices` — 품의 할인·할증 26계열(산림품셈 1-4). **안 고르면 안 붙는다.**
어느 공종에 붙일지는 원문 [주]가 작업을 지목하므로 **켜는 것은 사용자 몫**이다.
"""
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", [])}
from B09_Estimation.B09_Estimation_LaborSurcharge import COMBINE_NOTE as LABOR_SURCHARGE_COMBINE
from B09_Estimation.B09_Estimation_LaborSurcharge import SEAT_NOTE as LABOR_SURCHARGE_SEAT
from B09_Estimation.B09_Estimation_LaborSurcharge import total_percent as _labor_surcharge_total
labor_surcharge_percent, labor_surcharge_reasons = _labor_surcharge_total(
labor_surcharge_choices or {}
)
build = UnitPriceBuild()
build.labor_surcharge_percent = labor_surcharge_percent
build.labor_surcharge_reasons = list(labor_surcharge_reasons)
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}
# 수송 차량(트레일러·트럭)은 어느 공종의 자원 줄도 아니다 — **거리가 들어왔을 때만** 세운다.
if transport_distance_km is not None:
from B09_Estimation.B09_Estimation_Transport import TRANSPORT_VARIANTS
machine_codes |= {variant["machine_code"] for variant in TRANSPORT_VARIANTS}
build.incomplete_machines = _add_machine_layers(build.book, machine_codes, fuel_region)
# 규격 갈래(`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
# ⚠ **표가 한 장도 없어도 작업량을 원문이 준 공종은 세운다** — 9-17-1 비탈면 다짐이
# 그 자리다(제목 줄이 「A = 77.7 ㎡/시간」을 든다). 표만 보면 영영 안 서고,
# 실제로 성토면다짐 12,350.3㎡ 가 금액 없이 내역에 서 있었다(2026-09-09 밤).
if capacity_rows.get(code):
by_item[(code, "")] = []
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 capacity_rows.get(work_item_code)
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))
# 품의 할인·할증(산림품셈 1-4) — **품이 늘어난 것**이라 노무비로 붙고, 아래 제잡비의
# 밑수에도 든다. 그래서 **제잡비보다 먼저** 넣는다(줄 차례가 곧 셈 차례다).
# ⚠ 한 계열도 안 고르면 이 줄이 안 선다 — 금액이 한 원도 안 움직인다.
if labor_surcharge_percent and not variant_key.startswith("__"):
build.book.add_detail(
PriceDetail(
title_code,
title_code,
_ZERO,
note=(
f"품 할인·할증 {labor_surcharge_percent:g}% — "
+ " · ".join(labor_surcharge_reasons)
+ f" · {LABOR_SURCHARGE_SEAT} {LABOR_SURCHARGE_COMBINE}"
),
percent_of_labor=labor_surcharge_percent,
percent_of_labor_target="labor",
)
)
# 제잡비 — **노무비 합계의 %가 경비로** 붙는다(품셈 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,
)
)
# 공구손료·잡재료 — **주재료비의 %가 재료비로** 붙는다(산림품셈 1-2-6).
# ⚠ **비어 있으면 이 줄이 아예 안 선다** = 지금까지와 같은 금액이다(확정 5차 작은 것 1).
# ⚠ 갈래 제목(`__` 로 시작하는 내부 갈래)에는 안 붙인다 — 제잡비와 같은 자리를 쓴다.
if misc_material_percent is not None and not variant_key.startswith("__"):
build.book.add_detail(
PriceDetail(
title_code,
title_code,
_ZERO,
note=MISC_MATERIAL_BASIS.format(percent=misc_material_percent),
percent_of_material=misc_material_percent,
)
)
# 장비 몫은 자원 수량이 아니라 **시공능력 공식**으로 온다 (품셈 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" (품셈 원문 표기 그대로)"
if not capacity.get("source_text")
# 표가 아니라 절 제목·[주]에서 온 값은 **어디서 왔는지 그대로** 싣는다.
else (
f"작업량을 원문이 직접 줌 — {capacity['cell']}"
f" {capacity['capacity_per_hour']} · {capacity['source_text']}"
)
),
)
)
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
# 기계 수송비 — 기계경비의 셋째 몫(손료·운전경비·**수송비**). 거리가 있어야 선다.
from B09_Estimation.B09_Estimation_Transport import attach_transport
build.transport_notes = attach_transport(
build.book,
names,
distance_km=transport_distance_km,
road_key=transport_road,
)
# ⚠ **마지막에 한 번** — 조합 사용 공종의 본체 기계를 잡재료 16% 층으로 바꿔 단다
# (품셈 제8장 [주]⑤). 조립 도중에 바꾸면 어느 공종이 조합인지 아직 모른다.
build.combined_swapped = _apply_combined_misc_rate(
build.book, [code for code in build.book.titles if code.startswith("B-")]
)
build.labor_reliability = _labor_reliability_in_use(build.book)
return build
def _labor_reliability_in_use(book: PriceBook) -> dict[str, tuple[str, str, str]]:
"""**실제로 일위대가에 붙은** 노임 중 신뢰도 기호가 있는 것만 모은다.
⚠ 카탈로그 전체(40직종)를 경고하면 **쓰지도 않는 직종까지** 화면을 채운다 —
「이 내역서에 실린 단가 중 표본이 얇은 것」만 말해야 사용자가 볼 값어치가 있다.
⚠ 값을 바꾸지 않는다. 지식DB `노임단가_적용 §2-3`·`원가_입력변수_사전` 이
「단가 채택 시 플래그 유지 · 경고 표시」로 둔 자리를 잇는 것뿐이다(2026-09-09).
"""
used = {row.ref_code for rows in book.details.values() for row in rows}
found: dict[str, tuple[str, str, str]] = {}
for code in sorted(used):
title = book.titles.get(code)
if title is None or title.kind is not PriceKind.LABOR or not title.reliability:
continue
found[code] = (
title.name,
title.reliability,
LABOR_RELIABILITY_LABEL.get(title.reliability, "신뢰도 기호가 붙은 직종"),
)
return found
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], ...] = (),
misc_material_percent: str = "",
fuel_region: str = "",
transport_distance_km: str = "",
transport_road: str = "",
labor_surcharge: 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),
misc_material_percent=parse_misc_material_percent(misc_material_percent),
fuel_region=fuel_region or None,
transport_distance_km=parse_distance_km(transport_distance_km),
transport_road=transport_road or None,
labor_surcharge_choices=dict(labor_surcharge),
)
@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",
]