Compare commits
5
Commits
sub_desktop_1
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
852ad6c7d8 | ||
|
|
6152437113 | ||
|
|
2f9d42ac35 | ||
|
|
80b2669638 | ||
|
|
7dd5dda98b |
@@ -11,6 +11,7 @@ import string
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from B08_Quantity.B08_Quantity_Engine_BasisUnit import normalize_unit, unit_for_code
|
from B08_Quantity.B08_Quantity_Engine_BasisUnit import normalize_unit, unit_for_code
|
||||||
|
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import HAUL_LABELS
|
||||||
from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import (
|
from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import (
|
||||||
BLOCKED_FORMULA_MISSING,
|
BLOCKED_FORMULA_MISSING,
|
||||||
BLOCKED_INPUT_MISSING,
|
BLOCKED_INPUT_MISSING,
|
||||||
@@ -243,9 +244,8 @@ def _haul_rows(
|
|||||||
in_bill = bool(row.get("in_bill", True)) and entry.get("in_bill", True)
|
in_bill = bool(row.get("in_bill", True)) and entry.get("in_bill", True)
|
||||||
if code is None and in_bill:
|
if code is None and in_bill:
|
||||||
unmatched.append(f"운반({equipment})")
|
unmatched.append(f"운반({equipment})")
|
||||||
# ⚠ 코드가 없으면 **줄에 막힘 표시를 단다**(2026-09-09) — 목록에만 실으면 줄 단위로
|
# ⚠ 코드가 없으면 **줄에 막힘 표시를 단다**(2026-09-09) — 목록에만 실으면 금액이 조용히
|
||||||
# 보는 쪽이 「멀쩡한 줄」로 읽어 금액이 조용히 빠진다(도자운반·덤프운반이 그랬다).
|
# 빠진다. ⚠ `in_bill` 이 False 인 무대 줄은 **막힌 것이 아니다** — 품에 포함이라 안 세움.
|
||||||
# ⚠ `in_bill` 이 False 인 무대 줄은 **막힌 것이 아니다** — 품에 포함이라 안 세우는 것.
|
|
||||||
no_code = code is None and in_bill # 암 줄 막힘은 운반표가 구성비로 가르며 단 것(㉱)
|
no_code = code is None and in_bill # 암 줄 막힘은 운반표가 구성비로 가르며 단 것(㉱)
|
||||||
haul_blocked = (
|
haul_blocked = (
|
||||||
BLOCKED_UNIT_DATA_MISSING if no_code else in_bill and row.get("blocked_kind") or None
|
BLOCKED_UNIT_DATA_MISSING if no_code else in_bill and row.get("blocked_kind") or None
|
||||||
@@ -276,7 +276,7 @@ def _haul_rows(
|
|||||||
rows.append(
|
rows.append(
|
||||||
{
|
{
|
||||||
"work_item_code": code,
|
"work_item_code": code,
|
||||||
"name": f"{equipment} 운반",
|
"name": entry.get("master_name") or HAUL_LABELS.get(equipment, f"{equipment} 운반"),
|
||||||
"spec": " · ".join(
|
"spec": " · ".join(
|
||||||
dict.fromkeys(filter(None, (row.get("rock_class"), row.get("ground"))))
|
dict.fromkeys(filter(None, (row.get("rock_class"), row.get("ground"))))
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from B09_Estimation.B09_Estimation_BillOfQuantities import (
|
from B09_Estimation.B09_Estimation_BillOfQuantities import (
|
||||||
@@ -22,6 +23,7 @@ from B09_Estimation.B09_Estimation_BillOfQuantities import (
|
|||||||
from B09_Estimation.B09_Estimation_MachineProductivity_Dump import (
|
from B09_Estimation.B09_Estimation_MachineProductivity_Dump import (
|
||||||
DUMP_PARENT,
|
DUMP_PARENT,
|
||||||
LOADING_EQUIPMENT,
|
LOADING_EQUIPMENT,
|
||||||
|
distance_label,
|
||||||
dump_child_for,
|
dump_child_for,
|
||||||
dump_title_code,
|
dump_title_code,
|
||||||
loading_title_code,
|
loading_title_code,
|
||||||
@@ -37,6 +39,13 @@ from B09_Estimation.B09_Estimation_UnitPrice import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def spec_with_variant(spec: str, variant: str) -> str:
|
||||||
|
"""규격에 갈래를 한 번만 — 적힌 갈래(Ø800·리핑암)는 안 붙임(㉵) · 규격 글로 시작하면 갈음."""
|
||||||
|
if re.search(rf"(?<![\d.]){re.escape(variant)}(?![\d.])", spec): # 숫자 경계: Ø1800 ≠ 800
|
||||||
|
return spec
|
||||||
|
return variant if variant.startswith(spec) else f"{spec} {variant}"
|
||||||
|
|
||||||
|
|
||||||
def bill_line(unit: Money3, quantity) -> Money3:
|
def bill_line(unit: Money3, quantity) -> Money3:
|
||||||
"""내역 줄 금액 — **성분마다** `절사(수량 × 성분 단가)`, 줄 합계는 셋의 합(명세 7장).
|
"""내역 줄 금액 — **성분마다** `절사(수량 × 성분 단가)`, 줄 합계는 셋의 합(명세 7장).
|
||||||
|
|
||||||
@@ -372,7 +381,7 @@ def _leaf_row(
|
|||||||
return row
|
return row
|
||||||
price_code = wanted
|
price_code = wanted
|
||||||
if not loading:
|
if not loading:
|
||||||
row.spec = f"{row.spec} L={item.haul_distance_m}m".strip()
|
row.spec = f"{row.spec} {distance_label(item.haul_distance_m)}".strip()
|
||||||
if price_code not in unit_prices.book.titles:
|
if price_code not in unit_prices.book.titles:
|
||||||
# B08 은 **의미**(어느 공종·어느 제원)만 보내고 갈래 키는 우리가 만든다.
|
# B08 은 **의미**(어느 공종·어느 제원)만 보내고 갈래 키는 우리가 만든다.
|
||||||
# 못 맞추면 후보를 보이는 길로 내려간다 — 가까운 갈래를 임의로 고르지 않는다.
|
# 못 맞추면 후보를 보이는 길로 내려간다 — 가까운 갈래를 임의로 고르지 않는다.
|
||||||
@@ -384,8 +393,7 @@ def _leaf_row(
|
|||||||
default = unit_prices.default_variants.get(node.code)
|
default = unit_prices.default_variants.get(node.code)
|
||||||
if picked is not None:
|
if picked is not None:
|
||||||
price_code = picked
|
price_code = picked
|
||||||
variant = str(item.variant_value) # 규격 글로 시작하는 갈래는 한 번만(면고르기)
|
row.spec = spec_with_variant(row.spec, str(item.variant_value))
|
||||||
row.spec = variant if variant.startswith(row.spec) else f"{row.spec} {variant}"
|
|
||||||
elif default is not None:
|
elif default is not None:
|
||||||
# 표에 없거나 안 준 암질(풍화암·암) — **원문이 정한 갈래**로만 선다(9-4-1 [주]① 평균).
|
# 표에 없거나 안 준 암질(풍화암·암) — **원문이 정한 갈래**로만 선다(9-4-1 [주]① 평균).
|
||||||
price_code = f"{price_code}#{default[0]}"
|
price_code = f"{price_code}#{default[0]}"
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
"""B09 원가계산 — **2장 소요재료·기계손료 표를 읽는 한 벌** (2026-09-15 브레인 ㉡ · 판정 ①②③).
|
||||||
|
|
||||||
|
산림품셈 2장은 소형 장비(체인톱·예취기·윈치·천공기 …)의 1대 1일 소모(연료·잡품·오일)와 손료계수를
|
||||||
|
표로 주고, 각 공종 표는 **인원**만 적음(「벌목부」·「특별인부 (체인톱 사용)」). 대수 규칙이 인원에 붙어
|
||||||
|
장비 몫을 세우는데 그 길이 없어 공종이 인력만으로 싸게 서 있었음(㉯ 셈 7 · 4-2-2 는 부모 표에만 장비 말).
|
||||||
|
|
||||||
|
장비 한 벌(`Equipment`) = 소요재료 표 줄 · 손료 표 · 고르는 줄(체인오일 일반/친환경) · 쓰는 공종과 인원 줄
|
||||||
|
값은 표에서 읽음(연료 ℓ/대/일 · 잡품 % · 오일 ℓ · 손료계수) — 여기엔 **어느 줄을 읽을지**만 적음
|
||||||
|
`X-<AR-X>` 1대 1일 호표 = 연료 × ℓ + 잡품(주연료비 %) + 손료(구입가 × 계수 · 구입가가 들면)
|
||||||
|
공종 제목에 장비 호표 × 인원 줄 수량(대수 = 인원 × 100%) · 고르는 오일은 넣은 쪽 하나 × 인원 × ℓ
|
||||||
|
|
||||||
|
⚠ 인원 줄은 **표가 이름으로 밝힌 줄만**(③) — 「비슷한 인원 줄」로 넓히지 않음.
|
||||||
|
⚠ 구입가·오일 단가는 카탈로그가 없어 「자재 단가」 칸 + 사유(②) · 휘발유는 유가 판으로 바로 섬.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Equipment:
|
||||||
|
key: str
|
||||||
|
machine: str # AR-X — 1대 1일 호표
|
||||||
|
price: str # AR-M — 구입가(손료 밑수) 칸
|
||||||
|
fuel: tuple[str, str, str] # (2-1 공종, 표, 주연료 줄 이름)
|
||||||
|
loss: tuple[str, str] # (2-2 공종, 표)
|
||||||
|
choices: tuple[tuple[str, str], ...] = () # 넣은 쪽 하나 — (AR-M, 2-1 표 줄 이름)
|
||||||
|
users: dict[str, str] = field(default_factory=dict) # 공종 → 표가 밝힌 인원 줄 이름
|
||||||
|
basis: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
EQUIPMENTS: tuple[Equipment, ...] = (
|
||||||
|
Equipment(
|
||||||
|
key="체인톱",
|
||||||
|
machine="AR-X-61d1681d",
|
||||||
|
price="AR-M-5649cf3f",
|
||||||
|
fuel=("FP-02-01-01", "F0042", "보통휘발유 (주연료)"),
|
||||||
|
loss=("FP-02-02-01", "F0064"),
|
||||||
|
choices=(
|
||||||
|
("AR-M-fa7fbf6d", "체인오일 (일반오일)"),
|
||||||
|
("AR-M-de2fe662", "체인오일 (친환경오일)"),
|
||||||
|
),
|
||||||
|
# 2-1-1 [주]① 「숲가꾸기(작업로설치, 어린나무가꾸기, 단목베기) 및 수확베기, 병해충방제」 중
|
||||||
|
# 지금 제목이 서는 둘 · 표가 이름으로 밝힌 인원 줄(2026-09-15 브레인 ③).
|
||||||
|
users={"FP-04-02-02": "벌목부", "FP-06-05": "특별인부 (체인톱 사용)"},
|
||||||
|
basis="산림품셈 2-1-1 「체인톱 대수는 산출된 벌목부 또는 특별인부의 100% 적용」 · 2-2-1 손료",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
_RE_NUMBER = re.compile(r"\d+(?:\.\d+)?")
|
||||||
|
|
||||||
|
|
||||||
|
def _tight(text: Any) -> str:
|
||||||
|
return re.sub(r"\s", "", str(text or ""))
|
||||||
|
|
||||||
|
|
||||||
|
def _table_rows(nodes: dict[str, dict[str, Any]], code: str, table_id: str) -> list[list[str]]:
|
||||||
|
node = nodes.get(code) or {}
|
||||||
|
table = next((t for t in node.get("tables") or [] if t.get("pum_table_id") == table_id), {})
|
||||||
|
return [[str(c) for c in row] for row in table.get("raw_row") or []]
|
||||||
|
|
||||||
|
|
||||||
|
def _row_numbers(rows: list[list[str]], name: str) -> list[Decimal]:
|
||||||
|
"""그 이름 줄의 수 칸들 — 이름 칸 뒤에서 차례로(빈 칸 건너뜀)."""
|
||||||
|
row = next((r for r in rows if r and _tight(r[0]) == _tight(name)), None)
|
||||||
|
if row is None:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
Decimal(m.group()) for c in row[1:] if (m := _RE_NUMBER.fullmatch(_tight(c).rstrip("%")))
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _fuel_title(book: Any, kind: str, fuel_region: str | None) -> str:
|
||||||
|
from B09_Estimation.B09_Estimation_MachineOperating import load_fuel_price
|
||||||
|
from B09_Estimation.B09_Estimation_PriceBook import PriceKind, PriceTitle
|
||||||
|
from B09_Estimation.B09_Estimation_UnitPrice import FUEL_CODE_PREFIX, _slots
|
||||||
|
|
||||||
|
code = f"{FUEL_CODE_PREFIX}{kind}"
|
||||||
|
if code not in book.titles:
|
||||||
|
price, meta = load_fuel_price(region=fuel_region, kind=kind)
|
||||||
|
spec = f"{meta.get('region_name')} 공시가" if meta.get("region_name") else "전국 공시가"
|
||||||
|
book.add_title(PriceTitle(code, PriceKind.MATERIAL, kind, spec, "L", slots=_slots(price)))
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
def _machine_title(build: Any, nodes: dict, eq: Equipment, fuel_region: str | None) -> list[str]:
|
||||||
|
"""`X-<AR-X>` 1대 1일 — 표에서 연료·잡품·손료계수를 읽음. 돌려주는 값 = 못 붙은 사유들."""
|
||||||
|
from B09_Estimation.B09_Estimation_PriceBook import PriceDetail, PriceKind, PriceTitle
|
||||||
|
from B09_Estimation.B09_Estimation_UnitPrice import _misc_row, _slots
|
||||||
|
|
||||||
|
book = build.book
|
||||||
|
code = f"X-{eq.machine}"
|
||||||
|
fuel_rows = _table_rows(nodes, eq.fuel[0], eq.fuel[1])
|
||||||
|
numbers = _row_numbers(fuel_rows, eq.fuel[2])
|
||||||
|
loss = _row_numbers(_table_rows(nodes, *eq.loss), _table_rows(nodes, *eq.loss)[0][0])
|
||||||
|
if len(numbers) < 2 or not loss:
|
||||||
|
return [f"{eq.key} — 2장 표 칸이 달라져 장비 몫을 못 읽음"]
|
||||||
|
if code in book.titles:
|
||||||
|
return []
|
||||||
|
kind = "휘발유" if "휘발유" in eq.fuel[2] else "경유"
|
||||||
|
liters, misc = numbers[0], numbers[1]
|
||||||
|
book.add_title(PriceTitle(code, PriceKind.MACHINE_HOURLY, eq.key, "1대 1일", "대·일"))
|
||||||
|
book.add_detail(
|
||||||
|
PriceDetail(code, _fuel_title(book, kind, fuel_region), liters, note=f"주연료 {kind}")
|
||||||
|
)
|
||||||
|
book.add_detail(_misc_row(code, misc))
|
||||||
|
reasons = []
|
||||||
|
if eq.price in book.titles:
|
||||||
|
base = f"S-{eq.machine}"
|
||||||
|
price = book.titles[eq.price].slots[-1]
|
||||||
|
book.add_title(
|
||||||
|
PriceTitle(
|
||||||
|
base, PriceKind.MACHINE_BASE, eq.key, "손료", "대·일", slots=_slots(price * loss[0])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
book.add_detail(PriceDetail(code, base, Decimal(1), note=f"손료 = 구입가 × {loss[0]}"))
|
||||||
|
else:
|
||||||
|
reasons.append(f"{eq.key}가격 — 손료({loss[0]} × 구입가) 칸 · 「자재 단가」 에 넣으면 붙음")
|
||||||
|
return reasons
|
||||||
|
|
||||||
|
|
||||||
|
def attach_consumables(
|
||||||
|
build: Any, nodes: dict[str, dict[str, Any]], fuel_region: str | None = None
|
||||||
|
) -> None:
|
||||||
|
"""장비마다 1대 1일 호표를 세우고, 쓰는 공종 제목에 인원 줄 수량만큼 붙임."""
|
||||||
|
from B09_Estimation.B09_Estimation_PriceBook import PriceDetail
|
||||||
|
|
||||||
|
book = build.book
|
||||||
|
for eq in EQUIPMENTS:
|
||||||
|
for material in (eq.price, *(c for c, _ in eq.choices)):
|
||||||
|
uses = build.material_uses.setdefault(material, [])
|
||||||
|
uses.extend(c for c in eq.users if c not in uses)
|
||||||
|
machine_reasons = _machine_title(build, nodes, eq, fuel_region)
|
||||||
|
fuel_rows = _table_rows(nodes, eq.fuel[0], eq.fuel[1])
|
||||||
|
picked = [(c, name) for c, name in eq.choices if c in book.titles]
|
||||||
|
for work_item, user_row in eq.users.items():
|
||||||
|
node_rows = [
|
||||||
|
r
|
||||||
|
for t in (nodes.get(work_item) or {}).get("tables") or []
|
||||||
|
for r in t.get("raw_row") or []
|
||||||
|
]
|
||||||
|
# 이름 칸이 뭉친 표(「벌목부 보통인부」)도 낱말로 봄 — 그 이름이 없으면 넓히지 않음(③).
|
||||||
|
if not any(
|
||||||
|
r and (_tight(r[0]) == _tight(user_row) or user_row in str(r[0]).split())
|
||||||
|
for r in node_rows
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
labor_name = _tight(user_row).split("(")[0]
|
||||||
|
reasons = list(machine_reasons)
|
||||||
|
for title in [
|
||||||
|
t for t in book.titles if t == f"B-{work_item}" or t.startswith(f"B-{work_item}#")
|
||||||
|
]:
|
||||||
|
if f"X-{eq.machine}" not in book.titles:
|
||||||
|
break
|
||||||
|
users = [
|
||||||
|
r
|
||||||
|
for r in book.details.get(title, [])
|
||||||
|
if book.titles.get(r.ref_code)
|
||||||
|
and _tight(book.titles[r.ref_code].name) == labor_name
|
||||||
|
]
|
||||||
|
if not users:
|
||||||
|
continue
|
||||||
|
count = users[0].quantity
|
||||||
|
note = f"{eq.key} {count}대(인원 「{user_row}」 × 100%) — {eq.basis}"
|
||||||
|
book.add_detail(PriceDetail(title, f"X-{eq.machine}", count, note=note))
|
||||||
|
if len(picked) == 1:
|
||||||
|
code, name = picked[0]
|
||||||
|
liters = _row_numbers(fuel_rows, name)[0]
|
||||||
|
book.add_detail(
|
||||||
|
PriceDetail(title, code, count * liters, note=f"{name} {liters}ℓ/대/일")
|
||||||
|
)
|
||||||
|
if len(picked) > 1:
|
||||||
|
reasons.append(
|
||||||
|
f"{eq.choices[0][1]}·{eq.choices[1][1]} 가 둘 다 들어옴 — 하나만 넣을 것"
|
||||||
|
)
|
||||||
|
elif not picked and eq.choices:
|
||||||
|
reasons.append(
|
||||||
|
f"{' 또는 '.join(name for _, name in eq.choices)} — 시중가격 칸(설계자 선택) · 넣은 쪽이 붙음"
|
||||||
|
)
|
||||||
|
labels = build.unattached.setdefault(work_item, [])
|
||||||
|
labels.extend(r for r in reasons if r not in labels)
|
||||||
@@ -170,6 +170,15 @@ def dump_title_code(work_item_code: str, distance_m: Decimal) -> str:
|
|||||||
return f"B-{work_item_code}#L{distance_m.normalize():f}m"
|
return f"B-{work_item_code}#L{distance_m.normalize():f}m"
|
||||||
|
|
||||||
|
|
||||||
|
def distance_label(distance_m: Decimal) -> str:
|
||||||
|
"""보이는 운반거리 「L=137.13m」 — 소수 둘째 자리까지(㉰ 2026-09-14 · 가중평균이 14자리로 떴음).
|
||||||
|
|
||||||
|
⚠ **보이기만** 줄임 — 호표 열쇠(`dump_title_code`)·운반 식은 받은 거리 그대로 씀.
|
||||||
|
"""
|
||||||
|
shown = Decimal(distance_m).quantize(Decimal("0.01")).normalize()
|
||||||
|
return f"L={shown:f}m"
|
||||||
|
|
||||||
|
|
||||||
def attach_dump_hauls(build: Any, master: dict[str, Any], distances_m: tuple[Decimal, ...]) -> None:
|
def attach_dump_hauls(build: Any, master: dict[str, Any], distances_m: tuple[Decimal, ...]) -> None:
|
||||||
"""거리마다 덤프 운반 일위대가를 세운다 — X(덤프트럭 15ton) → D → B.
|
"""거리마다 덤프 운반 일위대가를 세운다 — X(덤프트럭 15ton) → D → B.
|
||||||
|
|
||||||
@@ -226,7 +235,7 @@ def attach_dump_hauls(build: Any, master: dict[str, Any], distances_m: tuple[Dec
|
|||||||
kind=PriceKind.UNIT_PRICE,
|
kind=PriceKind.UNIT_PRICE,
|
||||||
name=f"{names.get(DUMP_PARENT) or '덤프운반'} "
|
name=f"{names.get(DUMP_PARENT) or '덤프운반'} "
|
||||||
f"{names.get(code) or material.label}",
|
f"{names.get(code) or material.label}",
|
||||||
spec=f"L={distance.normalize():f}m",
|
spec=distance_label(distance),
|
||||||
unit="㎥",
|
unit="㎥",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -226,6 +226,28 @@ def _tidy_resource_name(cell: str) -> str:
|
|||||||
_MACHINE_UNITS = ("시간", "hr", "h", "대", "시 간")
|
_MACHINE_UNITS = ("시간", "hr", "h", "대", "시 간")
|
||||||
|
|
||||||
|
|
||||||
|
#: 조용히 빠지던 기계·연료 줄의 사유(2026-09-15 ㉠ — 인력만으로 싸게 서던 7공종).
|
||||||
|
SILENT_MACHINE = "기계·연료 줄이 안 붙음 — 이 공종 단가는 일부만 섬(인력만으로 싸게 서지 않게 막음)"
|
||||||
|
#: 규격이 이름 앞에 온 장비 칸 — 「0.8㎥ 굴착기」.
|
||||||
|
_RE_SPEC_FIRST = re.compile(r"^\d+(?:\.\d+)?(?:㎥|m3|ton|톤|㎾|kW)\s*[가-힣]{2,}")
|
||||||
|
#: 연료·기관 줄 이름 — 값이 비었거나 식이라도 기계 몫(연료비 · 휘발유 · 경유 · 엔진 · 기관).
|
||||||
|
_RE_FUEL_OR_ENGINE = re.compile(r"연료|휘발유|경유|엔진|기관|양수기|발전기|원동기")
|
||||||
|
_POWER_UNITS = ("kW", "㎾", "HP", "PS", "마력")
|
||||||
|
|
||||||
|
|
||||||
|
def _silent_machine_row(name_cell: str, value_cells: list[str], has_digit: bool) -> bool:
|
||||||
|
"""값을 못 읽고 이름도 안 풀린 줄이 **기계·연료 몫**인가 — 머리 줄·설명 줄은 아님."""
|
||||||
|
name = _normalize(name_cell)
|
||||||
|
if not name or is_non_resource_label(name_cell) and not _RE_FUEL_OR_ENGINE.search(name):
|
||||||
|
return False
|
||||||
|
if _RE_FUEL_OR_ENGINE.search(name):
|
||||||
|
return True
|
||||||
|
joined = " ".join(value_cells)
|
||||||
|
return has_digit and (
|
||||||
|
_is_machine_like_row(value_cells) or any(unit in joined for unit in _POWER_UNITS)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _is_machine_like_row(cells: list[str]) -> bool:
|
def _is_machine_like_row(cells: list[str]) -> bool:
|
||||||
"""그 줄이 **장비 몫**인가 — 단위 칸이 시간·대수인지로 본다."""
|
"""그 줄이 **장비 몫**인가 — 단위 칸이 시간·대수인지로 본다."""
|
||||||
for cell in cells:
|
for cell in cells:
|
||||||
@@ -480,6 +502,17 @@ def match_table(
|
|||||||
reason="자원은 알아봤으나 값을 못 읽었습니다 — 단가가 일부만 섭니다.",
|
reason="자원은 알아봤으나 값을 못 읽었습니다 — 단가가 일부만 섭니다.",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
elif _silent_machine_row(name_cell, value_cells, has_digit):
|
||||||
|
# 이름도 값도 못 읽은 기계·연료 줄 — 조용히 넘기면 인력만으로 싸게 섬(2026-09-15 ㉠).
|
||||||
|
code = node.get("work_item_code", "")
|
||||||
|
result.partial_items[code] = (
|
||||||
|
f"{_normalize(name_cell)[:20]} (기계·연료 줄)이 안 붙음"
|
||||||
|
)
|
||||||
|
result.unmatched.append(
|
||||||
|
UnmatchedRow(
|
||||||
|
code, str(table.get("pum_table_id", "")), name_cell, SILENT_MACHINE
|
||||||
|
)
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# ⚠ **카탈로그 조회를 먼저 한다.** 이름 필터를 앞에 두면 필터가 넓을 때
|
# ⚠ **카탈로그 조회를 먼저 한다.** 이름 필터를 앞에 두면 필터가 넓을 때
|
||||||
@@ -491,6 +524,17 @@ def match_table(
|
|||||||
name_cell = apply_scoped_alias(catalog, name_cell, node["work_item_code"], value_cells)
|
name_cell = apply_scoped_alias(catalog, name_cell, node["work_item_code"], value_cells)
|
||||||
entry = entry or _resolve_cell(catalog, name_cell, [name_cell, *value_cells])
|
entry = entry or _resolve_cell(catalog, name_cell, [name_cell, *value_cells])
|
||||||
if entry is None:
|
if entry is None:
|
||||||
|
if _RE_SPEC_FIRST.match(_normalize(name_cell)) and _is_machine_like_row(value_cells):
|
||||||
|
# 「0.8㎥ 굴착기 | h | 0.25」 — 규격이 이름 앞이라 머리글로 걸러지던 장비 줄(14-2 · ㉠).
|
||||||
|
result.partial_items[node["work_item_code"]] = (
|
||||||
|
f"{_normalize(name_cell)[:20]} (기계·연료 줄)이 안 붙음"
|
||||||
|
)
|
||||||
|
result.unmatched.append(
|
||||||
|
UnmatchedRow(
|
||||||
|
node["work_item_code"], table["pum_table_id"], name_cell, SILENT_MACHINE
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
if is_non_resource_label(name_cell):
|
if is_non_resource_label(name_cell):
|
||||||
continue # 머리글·소계 — 못 맞춘 목록에도 안 올린다
|
continue # 머리글·소계 — 못 맞춘 목록에도 안 올린다
|
||||||
# 「규격 미정 — 후보 N」 · 「같은 이름 여럿」 · 「카탈로그에 없는 이름」을 가른다.
|
# 「규격 미정 — 후보 N」 · 「같은 이름 여럿」 · 「카탈로그에 없는 이름」을 가른다.
|
||||||
|
|||||||
@@ -1114,6 +1114,10 @@ def build_unit_prices(
|
|||||||
from B09_Estimation.B09_Estimation_WoodChipping import attach_wood_chipping
|
from B09_Estimation.B09_Estimation_WoodChipping import attach_wood_chipping
|
||||||
|
|
||||||
attach_wood_chipping(build, nodes_by_code)
|
attach_wood_chipping(build, nodes_by_code)
|
||||||
|
# 2장 소요재료·기계손료 표(체인톱 …) — 인원 줄에 장비 몫을 붙임(2026-09-15 ㉡).
|
||||||
|
from B09_Estimation.B09_Estimation_Consumables import attach_consumables
|
||||||
|
|
||||||
|
attach_consumables(build, nodes_by_code, fuel_region)
|
||||||
# ⚠ **마지막에 한 번** — 조합 사용 공종의 본체 기계를 잡재료 16% 층으로 바꿔 단다
|
# ⚠ **마지막에 한 번** — 조합 사용 공종의 본체 기계를 잡재료 16% 층으로 바꿔 단다
|
||||||
# (품셈 제8장 [주]⑤). 조립 도중에 바꾸면 어느 공종이 조합인지 아직 모른다.
|
# (품셈 제8장 [주]⑤). 조립 도중에 바꾸면 어느 공종이 조합인지 아직 모른다.
|
||||||
build.combined_swapped = _apply_combined_misc_rate(
|
build.combined_swapped = _apply_combined_misc_rate(
|
||||||
|
|||||||
@@ -99,7 +99,15 @@ def attach_wood_chipping(build: Any, nodes_by_code: dict[str, dict[str, Any]]) -
|
|||||||
note=f"[주]② 1일 보통인부 {LABORERS_PER_DAY}인 ÷ [주]① {HOURS_PER_DAY}시간 ÷ Q {q}",
|
note=f"[주]② 1일 보통인부 {LABORERS_PER_DAY}인 ÷ [주]① {HOURS_PER_DAY}시간 ÷ Q {q}",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
labels = [label for label in build.unattached.get(CODE, []) if "파쇄기날" not in label]
|
# 표의 파쇄기 줄은 이 모듈이 씀 — 자원 축이 「기계·연료 줄이 안 붙음」 으로 올린 것을 걷음.
|
||||||
|
labels = [
|
||||||
|
label
|
||||||
|
for label in build.unattached.get(CODE, [])
|
||||||
|
if "파쇄기날" not in label and "".join(label.split()) != "이동식임목파쇄기"
|
||||||
|
]
|
||||||
|
if "파쇄기" in (build.component_gaps.get(CODE) or ""):
|
||||||
|
build.component_gaps.pop(CODE)
|
||||||
|
build.partial_ratio.pop(CODE, None)
|
||||||
if BLADE in book.titles:
|
if BLADE in book.titles:
|
||||||
book.add_detail(
|
book.add_detail(
|
||||||
PriceDetail(
|
PriceDetail(
|
||||||
|
|||||||
@@ -394,6 +394,59 @@
|
|||||||
"F0237"
|
"F0237"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AR-X-61d1681d",
|
||||||
|
"kind": "machine",
|
||||||
|
"name": "체인톱",
|
||||||
|
"spec": "45cc (배기량기준)",
|
||||||
|
"unit": "대·일",
|
||||||
|
"source": {
|
||||||
|
"pum_edition": "2026-01-01",
|
||||||
|
"pum_table_ids": [
|
||||||
|
"F0042",
|
||||||
|
"F0064"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AR-M-5649cf3f",
|
||||||
|
"kind": "material",
|
||||||
|
"name": "체인톱 구입가",
|
||||||
|
"spec": "45cc (배기량기준) · 손료 밑수",
|
||||||
|
"unit": "대",
|
||||||
|
"source": {
|
||||||
|
"pum_edition": "2026-01-01",
|
||||||
|
"pum_table_ids": [
|
||||||
|
"F0064"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AR-M-fa7fbf6d",
|
||||||
|
"kind": "material",
|
||||||
|
"name": "체인오일",
|
||||||
|
"spec": "일반오일",
|
||||||
|
"unit": "ℓ",
|
||||||
|
"source": {
|
||||||
|
"pum_edition": "2026-01-01",
|
||||||
|
"pum_table_ids": [
|
||||||
|
"F0042"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AR-M-de2fe662",
|
||||||
|
"kind": "material",
|
||||||
|
"name": "체인오일",
|
||||||
|
"spec": "친환경오일",
|
||||||
|
"unit": "ℓ",
|
||||||
|
"source": {
|
||||||
|
"pum_edition": "2026-01-01",
|
||||||
|
"pum_table_ids": [
|
||||||
|
"F0042"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,8 +180,8 @@ def test_무대는_넘기되_내역에는_안_섬() -> None:
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
handoff = build_handoff(haul_table=haul)
|
handoff = build_handoff(haul_table=haul)
|
||||||
free = 줄(handoff, "free_haul 운반")
|
free = 줄(handoff, "무대(종방향유용토)")
|
||||||
dump = 줄(handoff, "dump_truck 운반")
|
dump = 줄(handoff, "덤프 운반")
|
||||||
assert free["in_bill"] is False
|
assert free["in_bill"] is False
|
||||||
assert free["in_bill_reason"] # 왜 빠지는지 함께 간다
|
assert free["in_bill_reason"] # 왜 빠지는지 함께 간다
|
||||||
assert free["quantity"] == pytest.approx(800.0) # 값은 그대로 넘어간다
|
assert free["quantity"] == pytest.approx(800.0) # 값은 그대로 넘어간다
|
||||||
@@ -616,7 +616,7 @@ def test_운반계획이_있으면_네_줄이_실림() -> None:
|
|||||||
def test_무대가_함께_와야_운반_검산이_걸림() -> None:
|
def test_무대가_함께_와야_운반_검산이_걸림() -> None:
|
||||||
"""무대를 빼고 넘기면 `무대+도자+덤프 = 총 운반토량` 검산이 죽는다."""
|
"""무대를 빼고 넘기면 `무대+도자+덤프 = 총 운반토량` 검산이 죽는다."""
|
||||||
handoff = build_handoff(haul_table=운반표())
|
handoff = build_handoff(haul_table=운반표())
|
||||||
free = 줄(handoff, "free_haul 운반")
|
free = 줄(handoff, "무대(종방향유용토)")
|
||||||
assert free["in_bill"] is False
|
assert free["in_bill"] is False
|
||||||
assert free["quantity"] == pytest.approx(871.0)
|
assert free["quantity"] == pytest.approx(871.0)
|
||||||
assert handoff["excluded_row_count"] == 1
|
assert handoff["excluded_row_count"] == 1
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ def test_사유가_적힌다() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_운반표_쪽은_그대로_선다() -> None:
|
def test_운반표_쪽은_그대로_선다() -> None:
|
||||||
row = 줄들()["dozer 운반"]
|
row = 줄들()["불도저 운반"]
|
||||||
assert row["in_bill"] is True and row["work_item_code"] == "FP-10-11"
|
assert row["in_bill"] is True and row["work_item_code"] == "FP-10-11"
|
||||||
assert row["quantity"] == 17.39
|
assert row["quantity"] == 17.39
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ def 표(**extra: object) -> dict:
|
|||||||
|
|
||||||
def 줄(**extra: object) -> dict:
|
def 줄(**extra: object) -> dict:
|
||||||
rows = build_handoff(haul_table=표(**extra))["work_items"]
|
rows = build_handoff(haul_table=표(**extra))["work_items"]
|
||||||
return next(row for row in rows if row["name"] == "dump_truck 운반")
|
return next(row for row in rows if row["name"] == "덤프 운반")
|
||||||
|
|
||||||
|
|
||||||
def test_되돌린_값이_오면_그것으로_선다() -> None:
|
def test_되돌린_값이_오면_그것으로_선다() -> None:
|
||||||
@@ -80,5 +80,5 @@ def test_갈래가_없는_줄은_칸이_비어_있다() -> None:
|
|||||||
rows = build_handoff(haul_table={"rows": [{"equipment": "dozer", "volume_m3": 10.0}]})[
|
rows = build_handoff(haul_table={"rows": [{"equipment": "dozer", "volume_m3": 10.0}]})[
|
||||||
"work_items"
|
"work_items"
|
||||||
]
|
]
|
||||||
row = next(r for r in rows if r["name"] == "dozer 운반")
|
row = next(r for r in rows if r["name"] == "불도저 운반")
|
||||||
assert row["variant_axis"] is None and row["variant_value"] is None
|
assert row["variant_axis"] is None and row["variant_value"] is None
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""2장 소요재료·기계손료 표를 읽는 한 벌 — 체인톱부터(2026-09-15 브레인 ㉡ · 판정 ①②③).
|
||||||
|
|
||||||
|
산림품셈 2-1-1 체인톱 표(F0042): 보통휘발유(주연료) 5.6ℓ/대/일 · 잡품 주연료비 40% · 체인오일(일반·친환경) 2.1ℓ/대/일
|
||||||
|
「체인톱 대수는 산출된 벌목부 또는 특별인부의 100% 적용」 · 2-2-1 체인톱 손료(F0064): 45cc 0.0084(1일 1대) × 체인톱가격
|
||||||
|
체인톱을 쓰는 공종(4-2-2 단목베기 · 6-5 어린나무가꾸기)이 인력만으로 서서 연료·잡품·손료가 통째로 빠져 있었음.
|
||||||
|
③ 표가 이름으로 밝힌 인원 줄만 — 4-2-2 「벌목부」 · 6-5 「특별인부 (체인톱 사용)」
|
||||||
|
① 체인오일 일반/친환경은 「자재 단가」 에 넣은 쪽 · 둘 다면 안 붙고 사유
|
||||||
|
② 체인톱가격·체인오일 시중가격은 칸 + 사유 · 휘발유는 유가 판으로 바로 섬
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import ROUND_FLOOR, Decimal
|
||||||
|
|
||||||
|
from B09_Estimation.B09_Estimation_UnitPrice import cached_build
|
||||||
|
|
||||||
|
SAW = "X-AR-X-61d1681d"
|
||||||
|
SAW_PRICE = "AR-M-5649cf3f"
|
||||||
|
OIL = "AR-M-fa7fbf6d"
|
||||||
|
ECO_OIL = "AR-M-de2fe662"
|
||||||
|
|
||||||
|
|
||||||
|
def _rows(book, code: str) -> list:
|
||||||
|
own = book.details.get(code) or []
|
||||||
|
return [
|
||||||
|
*own,
|
||||||
|
*(r for d in own if d.ref_code.startswith("D-") for r in book.details[d.ref_code]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_체인톱_1대_1일_호표는_표의_휘발유와_잡품_40퍼센트() -> None:
|
||||||
|
book = cached_build().book
|
||||||
|
rows = book.details[SAW]
|
||||||
|
fuel = next(r for r in rows if r.ref_code == "M-FUEL-휘발유")
|
||||||
|
assert fuel.quantity == Decimal("5.6"), fuel
|
||||||
|
misc = next(r for r in rows if r.percent_of_material is not None)
|
||||||
|
assert misc.percent_of_material == Decimal(40)
|
||||||
|
price = book.titles["M-FUEL-휘발유"].slots[-1]
|
||||||
|
expected = (Decimal("5.6") * price * Decimal("1.4")).quantize(Decimal(1), rounding=ROUND_FLOOR)
|
||||||
|
assert abs(book.resolve(SAW).material - expected) <= 1, (book.resolve(SAW), expected)
|
||||||
|
|
||||||
|
|
||||||
|
def test_단목베기는_벌목부_인원만큼_체인톱_어린나무가꾸기는_체인톱_사용_특별인부만큼() -> None:
|
||||||
|
book = cached_build().book
|
||||||
|
rows = _rows(book, "B-FP-04-02-02#5m미만")
|
||||||
|
faller = next(r for r in rows if book.titles[r.ref_code].name == "벌목부")
|
||||||
|
saw = next(r for r in rows if r.ref_code == SAW)
|
||||||
|
assert saw.quantity == faller.quantity, (saw, faller)
|
||||||
|
rows = _rows(book, "B-FP-06-05")
|
||||||
|
special = next(
|
||||||
|
r
|
||||||
|
for r in rows
|
||||||
|
if book.titles.get(r.ref_code) and book.titles[r.ref_code].name == "특별인부"
|
||||||
|
)
|
||||||
|
saw = next(r for r in rows if r.ref_code == SAW)
|
||||||
|
assert saw.quantity == special.quantity, (saw, special)
|
||||||
|
# 보통인부(작업 보조)는 체인톱을 안 씀 — 표가 이름으로 밝힌 줄만(③)
|
||||||
|
assert sum(1 for r in rows if r.ref_code == SAW) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_체인오일은_넣은_쪽만_둘_다면_사유() -> None:
|
||||||
|
none = cached_build()
|
||||||
|
assert any("체인오일" in label for label in none.unattached.get("FP-04-02-02", []))
|
||||||
|
one = cached_build(material_prices=((OIL, "2250", "시중"),))
|
||||||
|
rows = _rows(one.book, "B-FP-04-02-02#5m미만")
|
||||||
|
faller = next(r for r in rows if one.book.titles[r.ref_code].name == "벌목부")
|
||||||
|
oil = next(r for r in rows if r.ref_code == OIL)
|
||||||
|
assert oil.quantity == faller.quantity * Decimal("2.1"), oil
|
||||||
|
both = cached_build(material_prices=((OIL, "2250", "시중"), (ECO_OIL, "6000", "시중")))
|
||||||
|
assert not any(r.ref_code in (OIL, ECO_OIL) for r in _rows(both.book, "B-FP-04-02-02#5m미만"))
|
||||||
|
assert any("둘 다" in label for label in both.unattached.get("FP-04-02-02", []))
|
||||||
|
|
||||||
|
|
||||||
|
def test_체인톱가격이_들면_손료_0_0084_가_경비로() -> None:
|
||||||
|
none = cached_build()
|
||||||
|
assert any("체인톱가격" in label for label in none.unattached.get("FP-06-05", []))
|
||||||
|
priced = cached_build(material_prices=((SAW_PRICE, "900000", "견적"),))
|
||||||
|
assert priced.book.resolve(SAW).expense == Decimal(7560) # 900,000 × 0.0084
|
||||||
|
for code in (SAW_PRICE, OIL, ECO_OIL):
|
||||||
|
assert "FP-04-02-02" in none.material_uses.get(code, []), code
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""내역 표시 흠 묶음 — 장비 id · 거리 14자리 · 규격 갈래 두 번 (2026-09-14 브레인 ㉰㉵).
|
||||||
|
|
||||||
|
훑기(936be972 내역)에서 잡은 것:
|
||||||
|
㉰ 운반 줄 이름이 장비 키 그대로 「dozer 운반」「dump_truck 운반」「free_haul 운반」
|
||||||
|
㉰ 덤프 규격 칸 「토사 L=137.13474461798225m」 — 가중평균 거리를 그대로 찍음
|
||||||
|
㉵ 배수관 규격 칸 「Ø800 800」 — 이미 적힌 관경을 갈래로 한 번 더 붙임
|
||||||
|
⚠ 거리는 **보이기만** 줄인다 — 호표 열쇠·운반 식은 받은 거리 그대로.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import B09_Estimation.B09_Estimation_BillOfQuantities # noqa: F401 — 줄 모듈은 조판 모듈이 먼저 섬
|
||||||
|
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff
|
||||||
|
from B09_Estimation.B09_Estimation_BillOfQuantities_Rows import spec_with_variant
|
||||||
|
from B09_Estimation.B09_Estimation_MachineProductivity_Dump import distance_label, dump_title_code
|
||||||
|
|
||||||
|
|
||||||
|
def test_운반_줄_이름에_장비_키가_안_뜬다() -> None:
|
||||||
|
haul = {
|
||||||
|
"rows": [
|
||||||
|
{"equipment": "free_haul", "ground": "토사", "volume_m3": 10.0, "in_bill": False},
|
||||||
|
{"equipment": "dozer", "ground": "토사", "volume_m3": 20.0, "average_distance_m": 40.0},
|
||||||
|
{
|
||||||
|
"equipment": "dump_truck",
|
||||||
|
"ground": "토사",
|
||||||
|
"volume_m3": 30.0,
|
||||||
|
"average_distance_m": 137.13474461798225,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
names = [row["name"] for row in build_handoff(haul_table=haul)["work_items"]]
|
||||||
|
assert "불도저 운반" in names and "덤프 운반" in names and "무대(종방향유용토)" in names
|
||||||
|
assert not [
|
||||||
|
name for name in names if any(key in name for key in ("dozer", "dump_truck", "free_haul"))
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_거리는_둘째_자리까지_보이고_열쇠는_그대로() -> None:
|
||||||
|
raw = Decimal("137.13474461798225")
|
||||||
|
assert distance_label(raw) == "L=137.13m"
|
||||||
|
assert distance_label(Decimal("100")) == "L=100m"
|
||||||
|
assert distance_label(Decimal("60.5")) == "L=60.5m"
|
||||||
|
# 호표 열쇠는 받은 거리 그대로 — 거리마다 갈리는 호표가 뭉치면 안 된다.
|
||||||
|
assert dump_title_code("FP-10-12-01", raw).endswith("L137.13474461798225m")
|
||||||
|
|
||||||
|
|
||||||
|
def test_규격에_적힌_갈래는_한_번만() -> None:
|
||||||
|
assert spec_with_variant("Ø800", "800") == "Ø800"
|
||||||
|
assert spec_with_variant("연암 · 리핑암", "리핑암") == "연암 · 리핑암"
|
||||||
|
# 숫자 경계 — 「Ø1800」 에 800 은 적힌 것이 아님.
|
||||||
|
assert spec_with_variant("Ø1800", "800") == "Ø1800 800"
|
||||||
|
# 종전 동작 — 빈 규격 · 규격 글로 시작하는 갈래(면고르기) · 새 갈래.
|
||||||
|
assert spec_with_variant("", "토사") == "토사"
|
||||||
|
assert spec_with_variant("성토면", "성토면 인력") == "성토면 인력"
|
||||||
|
assert spec_with_variant("H=2", "돌쌓기") == "H=2 돌쌓기"
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""말없이 버려지던 기계·연료 줄 — 2026-09-15 브레인 ㉠(인력만으로 싸게 서던 공종 막기).
|
||||||
|
|
||||||
|
자원 축 줄 읽기가 세 자리에서 기계·연료 줄을 「못 붙은 줄」 목록에도 안 올리고 버렸음 →
|
||||||
|
그 공종 단가가 인력만으로 **막히지도 사유도 없이** 싸게 섬(사용자는 값이 맞는 줄 앎).
|
||||||
|
① 규격이 이름 앞 「0.8㎥ 굴착기 | h | 0.25」(14-2 · 14-1) — 숫자로 시작해 머리글로 걸러짐
|
||||||
|
② 값·이름 둘 다 못 읽음 「디젤엔진(15HP) | 11.19kW」(12-26) · 「연료비 | 소요량 적용 | ps × 0.253ℓ × 6h」(10-7-4)
|
||||||
|
③ 수량 빈 연료 줄 「연료비 | 경유 | L | 」(10-8-3) — 숫자 없는 줄은 머리 줄로 봄
|
||||||
|
⇒ 기계·연료로 보이는 줄은 「못 맞춤 + 일부만」 — 틀린 금액보다 안 선 금액(사유 또렷이).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from B09_Estimation.B09_Estimation_UnitPrice import cached_build
|
||||||
|
|
||||||
|
|
||||||
|
def test_기계_연료_줄이_안_붙은_다섯_공종은_일부만으로_막히고_사유() -> None:
|
||||||
|
build = cached_build()
|
||||||
|
for code in ("FP-12-26", "FP-14-02", "FP-14-01", "FP-10-08-03", "FP-10-07-04"):
|
||||||
|
assert code in build.partial_ratio, code
|
||||||
|
assert "기계·연료 줄" in (build.component_gaps.get(code) or ""), (
|
||||||
|
code,
|
||||||
|
build.component_gaps.get(code),
|
||||||
|
)
|
||||||
|
left = " ".join(build.unattached.get("FP-12-26", []))
|
||||||
|
assert "디젤엔진" in left and "양수기" in left, left
|
||||||
|
assert "0.8㎥ 굴착기" in " ".join(build.unattached.get("FP-14-02", []))
|
||||||
|
|
||||||
|
|
||||||
|
def test_머리_줄과_인력만인_정상_공종은_안_막힘() -> None:
|
||||||
|
build = cached_build()
|
||||||
|
for code in ("FP-09-15-02", "FP-12-12", "FP-04-02-02", "FP-09-03-02"):
|
||||||
|
assert code not in build.partial_ratio, code
|
||||||
|
|
||||||
|
|
||||||
|
def test_파쇄_모듈이_쓰는_파쇄기_줄은_막힘으로_안_남음() -> None:
|
||||||
|
build = cached_build()
|
||||||
|
assert "FP-08-11" not in build.partial_ratio
|
||||||
|
assert "기계·연료 줄" not in (build.component_gaps.get("FP-08-11") or "")
|
||||||
|
assert not any(
|
||||||
|
"파쇄기 " in label or label == "이동식 임목 파쇄기"
|
||||||
|
for label in build.unattached.get("FP-08-11", [])
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user