품셈 8-1-7 5호 「유류가격은 해당지역의 가격으로 한다」. 칸은 있었으나 시도별 값이 없어 잠겨 있었음. 오피넷 avgSidoPrice.do 스냅샷을 받아 데이터셋으로 세우고 물림. - 안 고르면 전국평균 그대로 — 현장 소재지를 임의로 찍지 않음. - 판에 없는 지역은 조용히 전국평균으로 눕지 않고 사유를 남김. - 「지역 공시가」를 고를 수 있는지는 코드가 아니라 판이 정함. - ⚠ 원천의 시도 가름이 행정구역과 다름(20=전남광주 한 줄, 07·16 없음) — 원문 그대로 둠. - 수집 스크립트에 시도별 호출을 더함(전국평균과 두 벌로 보존). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1087 lines
51 KiB
Python
1087 lines
51 KiB
Python
"""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
|
||
|
||
_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)
|
||
#: 내역에 실린 노임 중 **신뢰도 기호가 붙은 것** — 직종코드 → (이름, 기호, 뜻).
|
||
#: ⚠ 금액을 막지 않는다. 「표본이 얇다」는 사실을 화면이 말하게 하는 통로다
|
||
#: (지식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) # 「2~5%**까지**」 — 상한
|
||
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%로 계상하고, **리퍼, 브레이커, 부착용 집게의 손료 및
|
||
# 치즐 소모율을 추가**하는 것이다.」
|
||
# ⇒ 그 셋은 **손료만으로** 층을 세운다. 운전경비 줄이 없다고 통째로 버리면
|
||
# 깨기 몫이 영영 안 붙는다(구조물터파기 암 갈래가 그 자리였다).
|
||
# ⚠ **다른 기계에는 이 길을 열지 않는다** — 연료가 빠진 채 조용히 싼 값이 선다.
|
||
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]),
|
||
# ⚠ 조종원도 노임이다 — 같은 플래그가 붙어야 한다.
|
||
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,
|
||
) -> UnitPriceBuild:
|
||
"""자원 축을 일위대가(`B`)로 조립한다.
|
||
|
||
공종 하나에 붙은 자원 줄들을 그 공종의 상세로 삼는다. 자원이 하나도 안 붙은
|
||
공종은 **빈 줄로 세우지 않고 건너뛴다** — 0 원 일위대가가 내역에 서면 안 된다.
|
||
|
||
⚠ `factor_choices` — 품셈이 **범위로 준 계수**에 사용자가 고른 값(확정 ①). 안 주면
|
||
**평균**이 기본이다(`B09_Estimation_FactorChoices`). 범위가 아닌 칸은 안 덮는다.
|
||
|
||
⚠ `misc_material_percent` — 공구손료·잡재료(산림품셈 1-2-6). **안 주면 줄이 안 선다** —
|
||
사용자 확정 5차 작은 것 1 「지금은 안 넣되 숫자 넣으면 되게 열어 둘 것」 그대로다.
|
||
|
||
⚠ `fuel_region` — 유가 시도코드(품셈 8-1-7 5호). **안 주면 전국평균**이다.
|
||
"""
|
||
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, 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
|
||
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,
|
||
)
|
||
)
|
||
|
||
# 공구손료·잡재료 — **주재료비의 %가 재료비로** 붙는다(산림품셈 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" (품셈 원문 표기 그대로)"
|
||
),
|
||
)
|
||
)
|
||
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-")]
|
||
)
|
||
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 = "",
|
||
) -> 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,
|
||
)
|
||
|
||
|
||
@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",
|
||
]
|