feat(M01): PLAN 7-5 — 안 쓰는 마스터 old/ 로 마저 옮기고 죽은 코드 셋 끊음
- 아무도 안 읽는 최상위·ref 파일 7개를 old/ 로(지우지 않음) — _원문목록·_열목록_화면표· 수량_돌쌓기찰라이브러리_소광리·공종_수량연결_실무·수량_구조물원단위_울진· 품셈_산림_재료할증_울진·품셈_산림_돌종류계수_울진(뒤 넷은 old_code 만 읽어 이제 0) - 공종_자원별칭_실무도 old/ 로 — common_util_aliases.py 를 읽던 시험 둘이 이미 깨져 있어( B08·B09 모듈 없음) 실제로 부르는 살아 있는 코드가 없었음 - 살아 있는 common_util/ 에서 dead 모듈 셋(work_item_key·work_item_link·aliases)을 old_code/common_util/ 로 옮김 — old_code 만 부르던 것이라 옮겨도 부르는 자리 그대로( old_code 는 old_code 를 스스로 루트로 읽는 관례) · 상대경로 계산만 한 단 보정 - 깨져 있던 시험 둘(test_b09_fuel_kind_vibrator·test_b08_root_removal_excavator)도 resources/tester/ 에서 old_code/resources/tester/ 로 — 7-3 이 놓친 것 마저 정리 - UnitPrice.py 의 load_basis_missing() — old/ 스냅샷 대신 새 최상위 마스터 `기준수량없음_산림품셈_2026-01-01.json` 으로(브레인 판정: 마스터 쪽) · old_code 로 옮겨진 뒤 root 계산이 한 단 부족해 조용히 빈 딕셔너리를 돌려주던 것도 같이 바로잡음 (79건 견본 대조 통과) · check_master.py 틀 검사에 특수 파일 면제 한 줄 추가 - 검증 — typecheck · check_master 틀 0건 · M01·자재품목 시험 215 + masterdata·m01_api 98 통과 · 전체 모으기 1475 그대로 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj
This commit is contained in:
@@ -500,19 +500,23 @@ def _add_machine_layers(
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def load_basis_missing(
|
||||
file_name: str = "2_공종_산림_밑수없음_2026-01-01.json",
|
||||
file_name: str = "기준수량없음_산림품셈_2026-01-01.json",
|
||||
) -> dict[str, str]:
|
||||
"""B08 이 낸 **밑수 못 찾은 표** 목록 — `{표 번호: 절 이름}`.
|
||||
|
||||
「10㎡당」 같은 기준을 원문에서 못 찾은 표다. 1 단위당으로 단정하면 곱셈이
|
||||
10배·100배 틀리므로(떼채취가 실제로 100배였다) 그 표를 쓰는 공종은
|
||||
**금액을 안 만든다**.
|
||||
|
||||
2026-09-22 — old/ 스냅샷 대신 최상위 마스터로 옮김(PLAN 7-5 ⑤ · 브레인 판정).
|
||||
⚠ `root` 계산이 파일이 `old_code/` 로 옮겨진 뒤(PLAN 7-3) 한 단 부족해 있었음 —
|
||||
조용히 빈 딕셔너리를 돌려주던 것을 여기서 같이 바로잡음(3단 위가 프로젝트 뿌리).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
|
||||
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
path = os.path.join(root, "resources", "master_data", "old", file_name)
|
||||
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
path = os.path.join(root, "resources", "master_data", file_name)
|
||||
if not os.path.exists(path):
|
||||
return {}
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""별칭표 한 벌 — 우리 이름 ↔ 품셈·실무 이름 (2026-09-13 축 C 명세 5장·17장).
|
||||
|
||||
정본 파일 `resources/data_aliases/aliases_<판>.json`. **B08·B09 는 이 파일만 읽는다** — 매핑표·
|
||||
단가 코드·인계본에 따로 두었더니 세 벌이 서로 달라졌다(리핑암 → 파쇄암·암절취 / 파쇄암만 / 안 읽음).
|
||||
|
||||
한 줄 = `{axis, from, to, scope, pum_edition, basis}`
|
||||
axis `resource`(자원 이름 → 카탈로그 코드) · `variant`(우리 갈래 이름 → 품셈 갈래 이름)
|
||||
scope 이 별칭이 **참인 `FP-*` 범위** — 없으면 오류. 별칭은 표 범위에서만 참이다
|
||||
(「리핑암 = 파쇄암」은 10-11 f 표 안에서만 — 발파암도 캐고 나면 파쇄암 상태다)
|
||||
pum_edition scope 가 묶인 품셈 판 — 판이 다르면 쓰지 않는다(`FP-*` 는 판 안에서만 유일)
|
||||
|
||||
⚠ **별칭 ≠ 대체** — 대응이 아예 없어 갈음하는 것은 여기 넣지 않는다(설계가 고른다).
|
||||
⚠ 같은 이름이 **겹친 범위**에서 두 곳으로 가면 **읽을 때 오류** — 조용히 첫째를 고르지 않는다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ALIAS_FILE = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "resources"
|
||||
/ "master_data"
|
||||
/ "old"
|
||||
/ "공종_자원별칭_실무_2026-01-01.json"
|
||||
)
|
||||
AXES = frozenset({"resource", "variant"})
|
||||
_FIELDS = ("axis", "from", "to", "scope", "pum_edition")
|
||||
|
||||
|
||||
class AliasError(ValueError):
|
||||
"""별칭표가 규약을 어겼다 — 조용히 넘기지 않는다."""
|
||||
|
||||
|
||||
def _key(text: str) -> str:
|
||||
"""이름 비교 키 — 내부 공백만 지운다(「화 약 공」 = 「화약공」). 다른 글자는 안 건드린다."""
|
||||
return re.sub(r"\s+", "", str(text or ""))
|
||||
|
||||
|
||||
def in_scope(work_item_code: str, scope: str) -> bool:
|
||||
"""`FP-*` 범위 — 그 코드 자신이거나 그 아래. ⚠ `AX`·`AR` 코드에는 계층이 없어 쓰지 않는다."""
|
||||
return work_item_code == scope or work_item_code.startswith(scope + "-")
|
||||
|
||||
|
||||
def parse_aliases(rows: list[dict[str, Any]]) -> list[dict[str, str]]:
|
||||
"""줄을 검사해 돌려준다. 칸 빠짐 · 모르는 축 · 겹친 범위의 두 대상은 오류."""
|
||||
aliases: list[dict[str, str]] = []
|
||||
for row in rows:
|
||||
picked = {key: str(row.get(key) or "").strip() for key in _FIELDS}
|
||||
if not all(picked.values()):
|
||||
raise AliasError(f"별칭 줄에 {'·'.join(_FIELDS)} 가 다 있어야 합니다: {row}")
|
||||
if picked["axis"] not in AXES:
|
||||
raise AliasError(f"모르는 별칭 축입니다: {picked['axis']}")
|
||||
picked["basis"] = str(row.get("basis") or "")
|
||||
aliases.append(picked)
|
||||
for index, left in enumerate(aliases):
|
||||
for right in aliases[index + 1 :]:
|
||||
if (
|
||||
left["axis"] == right["axis"]
|
||||
and _key(left["from"]) == _key(right["from"])
|
||||
and left["to"] != right["to"]
|
||||
and left["pum_edition"] == right["pum_edition"]
|
||||
and (
|
||||
in_scope(left["scope"], right["scope"])
|
||||
or in_scope(right["scope"], left["scope"])
|
||||
)
|
||||
):
|
||||
raise AliasError(
|
||||
f"같은 이름 「{left['from']}」이 겹친 범위에서 두 곳으로 갑니다: "
|
||||
f"{left['to']}({left['scope']}) · {right['to']}({right['scope']})"
|
||||
)
|
||||
return aliases
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
def _load(path: str) -> tuple[dict[str, str], ...]:
|
||||
target = Path(path)
|
||||
if not target.is_file():
|
||||
return () # 파일이 없으면 별칭 없이 돈다 — 못 맞춘 줄로 드러난다
|
||||
payload = json.loads(target.read_text(encoding="utf-8"))
|
||||
return tuple(parse_aliases(list(payload.get("aliases") or [])))
|
||||
|
||||
|
||||
def load_aliases(axis: str, path: Path | None = None) -> list[dict[str, str]]:
|
||||
"""그 축의 별칭 줄. 파일은 한 번만 읽는다."""
|
||||
return [dict(row) for row in _load(str(path or ALIAS_FILE)) if row["axis"] == axis]
|
||||
|
||||
|
||||
def alias_target(
|
||||
rows: list[dict[str, str]],
|
||||
name: str,
|
||||
work_item_code: str,
|
||||
edition: str,
|
||||
*,
|
||||
reverse: bool = False,
|
||||
) -> str | None:
|
||||
"""범위·판이 맞는 줄에서 이름을 옮긴다. `reverse` 면 남의 이름 → 우리 이름(양방향 조회)."""
|
||||
source, target = ("to", "from") if reverse else ("from", "to")
|
||||
wanted = _key(name)
|
||||
for row in rows:
|
||||
if (
|
||||
row["pum_edition"] == edition
|
||||
and _key(row[source]) == wanted
|
||||
and in_scope(work_item_code, row["scope"])
|
||||
):
|
||||
return row[target]
|
||||
return None
|
||||
|
||||
|
||||
def names_view(rows: list[dict[str, str]]) -> dict[str, dict[str, Any]]:
|
||||
"""인계본 모양 `{우리 이름: {names, scopes, basis}}` — B08 `ground_class_aliases` 가 싣는다."""
|
||||
view: dict[str, dict[str, Any]] = {}
|
||||
for row in rows:
|
||||
entry = view.setdefault(row["from"], {"names": [], "scopes": {}, "basis": ""})
|
||||
entry["names"].append(row["to"])
|
||||
entry["scopes"][row["to"]] = row["scope"]
|
||||
entry["basis"] = " · ".join(part for part in (entry["basis"], row["basis"]) if part)
|
||||
return view
|
||||
@@ -0,0 +1,82 @@
|
||||
"""공종 불변 열쇠 **길목** — 목차 코드(`FP-`·`CP-`) → 불변 열쇠(`FW-`·`CW-`) (2026-09-17 PLAN_공종축 8-6 이행).
|
||||
|
||||
코덱스 조사(FP- 참조 630곳 · 77파일)의 결론대로 **경계 두 곳에서만** 부름 — 630곳은 안 고침.
|
||||
B08 인계 출구 `B08_Quantity_Engine_Handoff.build_handoff` 줄마다 `work_item_key` 를 더함
|
||||
B09 인계 입구 `B09_Estimation_BillOfQuantities_Input.parse_handoff` 옛 인계(열쇠 없음)에도 채움
|
||||
`work_item_code` 는 **그대로 남김** — 사람이 보는 목차 번호(화면·로그에 보이는 것이 값짐).
|
||||
|
||||
정본은 공종 마스터 파일의 줄(`work_item_code` ↔ `work_item_key` · 판 `pum_edition`) — 장부를 따로 안 읽음
|
||||
(마스터가 장부로 지은 결과라 두 벌이 안 됨).
|
||||
⚠ `AX-WK-`·`AX-ST-` 는 난수 8자리(명세 2장 ④ · 동등 비교만)라 **이미 불변** — 그대로 열쇠.
|
||||
⚠ 못 찾은 코드는 `None` — 지어내지 않음. 받는 쪽이 목록으로 드러냄(`unkeyed_work_item_codes`).
|
||||
⚠ 한 판 안에서 같은 목차 코드가 두 줄이면(목차 오기) 어느 열쇠인지 못 가름 → `None`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
MASTER_DIR = Path(__file__).resolve().parent.parent.parent / "resources" / "master_data" / "old"
|
||||
#: 우리가 세운 공종·구조물 — 난수 코드가 곧 불변 열쇠
|
||||
_STABLE_CODE = re.compile(r"^AX-(?:WK|ST)-[0-9a-f]{8}$")
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _read(path: str, _mtime_ns: int) -> dict[tuple[str, str], str | None]:
|
||||
doc = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
edition = str(doc.get("pum_edition") or doc.get("effective_date") or "")
|
||||
keys: dict[tuple[str, str], str | None] = {}
|
||||
for item in doc.get("work_items") or []:
|
||||
slot = (edition, str(item.get("work_item_code") or ""))
|
||||
keys[slot] = None if slot in keys else item.get("work_item_key")
|
||||
return keys
|
||||
|
||||
|
||||
def _keys() -> dict[tuple[str, str], str | None]:
|
||||
out: dict[tuple[str, str], str | None] = {}
|
||||
for path in sorted(MASTER_DIR.glob("2_공종_*_마스터_*.json")): # 산림 + 건설(`const_`)
|
||||
out.update(_read(str(path), path.stat().st_mtime_ns))
|
||||
return out
|
||||
|
||||
|
||||
def work_item_key(code: str | None, pum_edition: str | None = None) -> str | None:
|
||||
"""목차 코드 → 불변 열쇠. 판을 모르면 코드가 한 판에만 있을 때만 줌."""
|
||||
if not code:
|
||||
return None
|
||||
if _STABLE_CODE.match(code):
|
||||
return code
|
||||
keys = _keys()
|
||||
if pum_edition:
|
||||
return keys.get((pum_edition, code))
|
||||
found = {key for (_, each), key in keys.items() if each == code}
|
||||
return found.pop() if len(found) == 1 else None
|
||||
|
||||
|
||||
def work_item_code_of(key: str | None, pum_edition: str | None = None) -> str | None:
|
||||
"""불변 열쇠 → 그 판의 목차 코드(되짚기). 한 곳에서만 찾아질 때만."""
|
||||
if not key:
|
||||
return None
|
||||
if _STABLE_CODE.match(key):
|
||||
return key
|
||||
found = {
|
||||
code
|
||||
for (edition, code), each in _keys().items()
|
||||
if each == key and (pum_edition is None or edition == pum_edition)
|
||||
}
|
||||
return found.pop() if len(found) == 1 else None
|
||||
|
||||
|
||||
def attach_keys(rows: Iterable[dict[str, Any]]) -> list[str]:
|
||||
"""인계 줄마다 `work_item_key` 를 더함(코드 없는 줄은 `None` — 칸은 늘 있음 · 인계 계약) ·
|
||||
코드는 있는데 열쇠를 못 찾은 코드 목록을 돌려줌."""
|
||||
missing: set[str] = set()
|
||||
for row in rows:
|
||||
code = row.get("work_item_code")
|
||||
row["work_item_key"] = work_item_key(code, row.get("pum_edition"))
|
||||
if code and row["work_item_key"] is None:
|
||||
missing.add(str(code))
|
||||
return sorted(missing)
|
||||
@@ -0,0 +1,87 @@
|
||||
"""연결고리 표(`work_item_link`)를 읽는 **한 자리** — 산림·건설 어느 품셈 품을 쓸지 **표가 정함**
|
||||
(2026-09-17 브레인 · 코덱스 크로스체크 ② 「연결고리 미소비」).
|
||||
|
||||
고시 총칙 7 「본 품셈에 명시되지 않은 품은 타 부문 표준품셈 적용 · 유사 공종은 본 품셈 우선」 —
|
||||
그 규칙은 표의 `policy`(`both_precedence` · `unconfirmed_action`)에 있고 코드는 **읽기만** 함(「산림 우선」을 코드에 안 박음).
|
||||
|
||||
확정 · both → `both_precedence` 쪽 열쇠(지금 표는 forest)
|
||||
확정 · 한쪽만 → 그 열쇠 그대로
|
||||
미판정 → **안 고름** · 사유를 붙여 막음(`unconfirmed_action: do_not_link`)
|
||||
표에 없음 → 제 품셈 그대로(품셈에 명시된 품)
|
||||
⚠ 미판정 행 가운데 열쇠가 빈 행(여러 절 묶음)은 줄에 못 닿음 — `undecided_links()` 로 목록만 드러냄.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
FOLDER = Path(__file__).resolve().parent.parent.parent / "resources" / "master_data" / "old"
|
||||
CONFIRMED = "확정"
|
||||
#: 표 `policy.both_precedence` 낱말 → 그 행의 열쇠 칸
|
||||
_KEY_COLUMN = {"forest": "forest_key", "construction": "const_key"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Choice:
|
||||
"""쓸 열쇠 · 막힌 까닭(있으면 금액을 안 세움)."""
|
||||
|
||||
key: str | None
|
||||
blocked: str = ""
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
def _read(path: str, _mtime_ns: int) -> dict[str, Any]:
|
||||
return json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _doc() -> dict[str, Any]:
|
||||
found = sorted(FOLDER.glob("2_공종_산림건설_연결_*.json")) if FOLDER.is_dir() else []
|
||||
return _read(str(found[-1]), found[-1].stat().st_mtime_ns) if found else {}
|
||||
|
||||
|
||||
def choose(key: str | None) -> Choice:
|
||||
"""열쇠 하나(FW-·CW-) → 실제로 쓸 열쇠. 미판정 행에 걸리면 안 고르고 막음."""
|
||||
if not key:
|
||||
return Choice(key)
|
||||
doc = _doc()
|
||||
policy = doc.get("policy") or {}
|
||||
rows = [
|
||||
link
|
||||
for link in doc.get("links") or []
|
||||
if key in (link.get("forest_key"), link.get("const_key"))
|
||||
]
|
||||
for link in rows:
|
||||
if link.get("status") != CONFIRMED:
|
||||
return Choice(
|
||||
None,
|
||||
"연결고리 미판정 — 산림·건설 어느 품을 쓸지 근거가 없어 고르지 않음"
|
||||
f"({link.get('evidence') or '근거 없음'})",
|
||||
)
|
||||
for link in rows:
|
||||
if link.get("branch") == "both":
|
||||
column = _KEY_COLUMN.get(str(policy.get("both_precedence") or ""))
|
||||
chosen = link.get(column) if column else None
|
||||
if not chosen:
|
||||
return Choice(
|
||||
None, "연결고리 표에 둘 다 있는 줄의 우선 품셈이 안 적혔음 — 고르지 않음"
|
||||
)
|
||||
return Choice(chosen)
|
||||
return Choice(key)
|
||||
|
||||
|
||||
def undecided_links() -> list[dict[str, str]]:
|
||||
"""미판정 행 — 열쇠가 비어 줄에 못 닿는 행도 드러냄(조용히 안 버림)."""
|
||||
return [
|
||||
{
|
||||
"branch": str(link.get("branch") or ""),
|
||||
"forest_key": str(link.get("forest_key") or ""),
|
||||
"const_key": str(link.get("const_key") or ""),
|
||||
"evidence": str(link.get("evidence") or ""),
|
||||
}
|
||||
for link in _doc().get("links") or []
|
||||
if link.get("status") != CONFIRMED
|
||||
]
|
||||
@@ -0,0 +1,70 @@
|
||||
"""9-21 제근 굴착기 크기 칸 — 2026-09-14 브레인 판정(산출 조건 칸 · 제안 0.7㎥ · 비면 사유).
|
||||
|
||||
9-21 품은 굴착기 0.2·0.7 × 임목축적 등급(소·중·밀) 여섯 갈래인데 B08 은 등급만 보내 B09 가 못 고름.
|
||||
⇒ 매핑 `variant_template` 이 크기·등급 두 입력을 한 값(「0.7·소림」)으로 엮고, 범위 별칭(FP-09-21)이
|
||||
그 값을 B09 갈래(「굴착기(무한궤도) 0.7 · 소」)로 잇는다 — 갈래 키 표기는 별칭표 한 곳만 앎.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import ( # noqa: E402
|
||||
ROOT_REMOVAL_EXCAVATOR_SIZES,
|
||||
ROOT_REMOVAL_EXCAVATOR_SUGGESTED,
|
||||
)
|
||||
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402
|
||||
from B08_Quantity.B08_Quantity_Engine_Preparation import STAND_VOLUME_CLASSES # noqa: E402
|
||||
|
||||
|
||||
def _row(**inputs) -> dict:
|
||||
summary = {"rows": [{"group": "지장목제거", "item": "뿌리뽑기", "spec": "", "unit": "㎡",
|
||||
"amount": 1000.0, "application_ratio_pct": 100.0}]} # fmt: skip
|
||||
rows = build_handoff(summary_table=summary, **inputs)["work_items"]
|
||||
return next(r for r in rows if r["work_item_code"] == "FP-09-21")
|
||||
|
||||
|
||||
def test_크기와_등급을_한_갈래_값으로_엮어_내역_금액이_섬() -> None:
|
||||
from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill
|
||||
|
||||
inputs = {"stand_volume_class": "소림", "root_removal_excavator_m3": "0.7"}
|
||||
row = _row(**inputs)
|
||||
assert row["variant_value"] == "0.7·소림" and not row["blocked_kind"], row
|
||||
summary = {"rows": [{"group": "지장목제거", "item": "뿌리뽑기", "spec": "", "unit": "㎡",
|
||||
"amount": 1000.0}]} # fmt: skip
|
||||
bill = build_bill(build_handoff(summary_table=summary, **inputs))
|
||||
line = next(r for r in bill.rows if r.code == "FP-09-21")
|
||||
assert line.price_code == "B-FP-09-21#굴착기(무한궤도)0.7·소", line.note
|
||||
assert line.amount_krw and int(line.amount_krw) > 0, line.note
|
||||
|
||||
|
||||
def test_크기가_비면_금액_없이_입력_사유_등급은_그대로_실음() -> None:
|
||||
row = _row(stand_volume_class="중림")
|
||||
assert row["variant_value"] == "중림" # 판정 Ⓑ — 등급 갈래를 잃지 않음
|
||||
assert row["blocked_kind"] == "input_missing" and "굴착기" in row["blocked_reason"], row
|
||||
assert _row()["blocked_kind"] == "input_missing"
|
||||
|
||||
|
||||
def test_선택지는_마스터_갈래_키의_크기_제안은_0_7_근거_10_12_1() -> None:
|
||||
from B09_Estimation.B09_Estimation_ResourceAxis import load_work_item_master
|
||||
|
||||
node = next(
|
||||
n for n in load_work_item_master()["work_items"] if n["work_item_code"] == "FP-09-21"
|
||||
)
|
||||
sizes = {key.split(")")[1].split("·")[0].strip() for key in node["variant_keys"]}
|
||||
assert sizes == set(ROOT_REMOVAL_EXCAVATOR_SIZES)
|
||||
value, basis = ROOT_REMOVAL_EXCAVATOR_SUGGESTED
|
||||
assert value == "0.7" and "10-12-1" in basis
|
||||
# 크기 × 등급 여섯이 모두 별칭으로 B09 갈래에 닿음
|
||||
from common_util.common_util_aliases import alias_target, load_aliases
|
||||
|
||||
rows = load_aliases("variant")
|
||||
for size in ROOT_REMOVAL_EXCAVATOR_SIZES:
|
||||
for grade in STAND_VOLUME_CLASSES:
|
||||
target = alias_target(rows, f"{size}·{grade}", "FP-09-21", "2026-01-01")
|
||||
assert target in node["variant_keys"], (size, grade, target)
|
||||
@@ -0,0 +1,103 @@
|
||||
"""연료 종류(휘발유) · 콘크리트 진동기 — 2026-09-14 브레인 661 뒤 ①②.
|
||||
|
||||
② 운전경비표 `fuel_kind` 를 조립이 안 읽어 **휘발유 기계에 경유값**이 붙고 있었음(플레이트 콤팩터 ·
|
||||
진동기 · 믹서 · 래머 · 커터) — 종류대로 그 유가(전국·시도 둘 다)로 섬.
|
||||
① 콘크리트 진동기 — 원문 8-3 (4611) 두 줄이 한 칸에 뭉쳐 규격·손료계수가 비었음 → 원문값 되살림
|
||||
(전기식 플렉시블형 ø45(0.75㎾) 4,935 · 엔진식 플렉시블형 ø45(2.6㎾) 5,101). 산림 12-15 「봉상후렉시블(45mm)
|
||||
Q=5.4㎥/hr」 은 전기·엔진을 안 적음 → 12-34-1 「콘크리트 진동기(3.5HP)」(= 2.6㎾) · 운전경비표에 엔진식만
|
||||
있는 것을 근거로 엔진식 4611-0350. 집수정 구체콘크리트 갈래가 이제 섬(㉯ ⑴ 조건이 풀림).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
from B09_Estimation.B09_Estimation_UnitPrice import cached_build, detail_of
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
DATA = ROOT / "resources" / "data_cost_input_value"
|
||||
|
||||
|
||||
def _oil(file: str, key: str) -> dict:
|
||||
return json.loads((DATA / file).read_text(encoding="utf-8"))["variables"][key]
|
||||
|
||||
|
||||
def test_휘발유_기계는_휘발유값_경유_기계는_경유값() -> None:
|
||||
book = cached_build().book
|
||||
fuels = {r.ref_code for r in book.details["X-1730-0015"] if r.ref_code.startswith("M-FUEL-")}
|
||||
assert fuels == {"M-FUEL-휘발유"}, fuels
|
||||
gasoline = Decimal(str(_oil("oil_2026-08-14.json", "oil_gasoline")["value"]))
|
||||
assert (
|
||||
book.titles["M-FUEL-휘발유"].slots[-1] == gasoline
|
||||
or gasoline in book.titles["M-FUEL-휘발유"].slots
|
||||
)
|
||||
diesel_rows = {
|
||||
r.ref_code for r in book.details["X-0201-0070"] if r.ref_code.startswith("M-FUEL-")
|
||||
}
|
||||
assert diesel_rows == {"M-FUEL-경유"}
|
||||
|
||||
|
||||
def test_시도를_고르면_휘발유도_그_시도값() -> None:
|
||||
records = _oil("oil_regional_2026-09-09.json", "oil_gasoline")["records"]
|
||||
seoul = next(Decimal(str(r["value"])) for r in records if r["sido_code"] == "01")
|
||||
book = cached_build(fuel_region="01").book
|
||||
assert seoul in book.titles["M-FUEL-휘발유"].slots
|
||||
assert "서울" in book.titles["M-FUEL-휘발유"].spec
|
||||
|
||||
|
||||
def test_기계_하나_시간당_사용료도_연료_종류대로() -> None:
|
||||
from B09_Estimation.B09_Estimation_MachineOperating import hourly_cost_of, load_fuel_price
|
||||
|
||||
gasoline, _ = load_fuel_price(kind="휘발유")
|
||||
diesel, _ = load_fuel_price()
|
||||
assert gasoline != diesel
|
||||
cost = hourly_cost_of("1730-0015") # 휘발유 1.0 L/hr · 잡품 20%
|
||||
assert cost.money.material == Decimal("1.0") * Decimal("1.2") * gasoline, cost
|
||||
|
||||
|
||||
def test_진동기_원문값_되살림() -> None:
|
||||
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
|
||||
|
||||
machines = load_machine_catalog().machines
|
||||
assert machines["4611-0350"].specification == "엔진식 플렉시블형 ø45(2.6㎾)"
|
||||
assert machines["4611-0350"].loss_coefficient_per_hour == Decimal("0.0005101")
|
||||
assert machines["4611-0075"].specification == "전기식 플렉시블형 ø45(0.75㎾)"
|
||||
assert machines["4611-0075"].loss_coefficient_per_hour == Decimal("0.0004935")
|
||||
|
||||
|
||||
def test_집수정_구체콘크리트_갈래가_엔진식_진동기로_섬() -> None:
|
||||
build = cached_build()
|
||||
rows = {
|
||||
r["ref_code"]: Decimal(r["quantity"])
|
||||
for r in detail_of(build, "B-FP-12-15#구체콘크리트")["rows"]
|
||||
if r.get("ref_code")
|
||||
}
|
||||
assert rows["X-4611-0350"] == Decimal(1) / Decimal("5.4"), rows
|
||||
left = " ".join(build.unattached.get("FP-12-15", []))
|
||||
assert "봉상후렉시블" not in left and "구체콘크리트" not in left, left
|
||||
|
||||
|
||||
def test_진동기_별칭_사유에_12_15_는_전기_엔진을_안_적음() -> None:
|
||||
from common_util.common_util_aliases import load_aliases
|
||||
|
||||
row = next(r for r in load_aliases("resource") if r["to"] == "4611-0350")
|
||||
assert row["scope"] == "FP-12-15"
|
||||
assert "전기·엔진을 안 적음" in row["basis"] and "12-34-1" in row["basis"], row
|
||||
|
||||
|
||||
def test_중기경비_장은_갈래대로_잡품과_손료계수() -> None:
|
||||
"""661 뒤처리 — `#암석` 장이 조합 16%·비암석 계수로 보이던 자리."""
|
||||
from B09_Estimation.B09_Estimation_MachineExpenseSheet import machine_expense_sheets
|
||||
|
||||
build = cached_build(dump_haul_m=("164.23",))
|
||||
sheets = {s["code"]: s for s in machine_expense_sheets(build)}
|
||||
rock, combined = sheets["X-0201-0070#암석"], sheets["X-0201-0070#조합"]
|
||||
assert rock["misc_material_percent"] == sheets["X-0201-0070"]["misc_material_percent"]
|
||||
assert combined["misc_material_percent"] == "16"
|
||||
assert rock["loss_coefficient"] == 2405.0 and sheets["X-0201-0070"]["loss_coefficient"] == 2085
|
||||
gasoline = next(s for s in sheets.values() if s["machine_code"] == "1730-0015")
|
||||
assert Decimal(gasoline["fuel_price_per_liter"]) == Decimal(
|
||||
str(_oil("oil_2026-08-14.json", "oil_gasoline")["value"])
|
||||
)
|
||||
Reference in New Issue
Block a user