refactor(B08,B09): 폴더째 old_code 로 옮기고 빈 화면 둘만 남김 (PLAN 7-3)
B08_Quantity 86 · B09_Estimation 111 파일을 old_code/ 로 옮김(지우지 않음). 화면은 메뉴·주소·단계 막대만 남은 빈 틀 둘 — main.py 라우터 13 개는 끊음. B07 이 빌려 쓰던 비탈 길이·면적은 필요한 함수만 B07_DesignDetail_Engine_SlopeGeometry 로 옮겨 적음(면적 적분·노면 면적·측점 묶음은 안 옮김) · 시험 하나를 새로 둠. B07 구조물도 조립(Cad_StandardSheet)은 2026-09-13 에 이미 도면 목록에서 빠져 부르는 곳이 없어 old_code 로 같이 보냄 — 구조물 그림은 되살리지 않음. B06 구조물 몫 조회는 빈 값으로 두어 화면이 그대로 서게 함. B08·B09 를 부르던 시험 25 개도 old_code/resources/tester 로 옮김. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""구조물 폼의 **`empty_means` select 는 첫 보기를 박지 않는다** (2026-09-13).
|
||||
|
||||
2026-08-17 지시로 select 에는 빈 보기를 두지 않았다 — 「첫 보기가 곧 기본값」이기 때문이다.
|
||||
그런데 2026-09-09 에 셋째 갈래가 생겼다: **비워 두는 것이 뜻인 칸**(`empty_means`). 이 칸까지
|
||||
첫 보기를 박으니 **엔진 기준값과 다른 값이 조용히 저장**됐다.
|
||||
|
||||
실측 사고 — `fill_concrete_mpa` 는 등록부 기본이 없고 「비우면 계산 쪽 기준 강도」인데 폼이
|
||||
「180」을 박아 저장했다. 엔진 기준은 확정 ⑩ 의 **210** 이라 채움콘크리트 규격·단가가 갈렸다.
|
||||
|
||||
⚠ 기본값이 **있는** 칸은 그대로 첫 보기를 쓴다 — 2026-08-17 지시가 그 자리다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
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_UnitQuantity import build_table # noqa: E402
|
||||
|
||||
PANEL = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Panel.ts").read_text(encoding="utf-8")
|
||||
#: 2026-09-14 칸 만들기가 `_Structures_Fields.optionControl` 로 옮겨감(브레인 판정 ①② — 고르기 칸은
|
||||
#: 늘 빈 보기 · 기본값은 제안으로만).
|
||||
FIELDS = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Fields.ts").read_text(encoding="utf-8")
|
||||
API = (ROOT / "B05_Profile" / "B05_Profile_Api_Structures.ts").read_text(encoding="utf-8")
|
||||
COMMIT = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Panel_Commit.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def _registry_options(type_id: str) -> dict[str, dict]:
|
||||
payload = json.loads(
|
||||
(ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8")
|
||||
)
|
||||
item = next(entry for entry in payload["types"] if entry["type_id"] == type_id)
|
||||
return {option["key"]: option for option in item["options"]}
|
||||
|
||||
|
||||
def test_폼이_empty_means_를_보고_빈_보기를_둔다() -> None:
|
||||
"""소스 검사 — 고르기 칸은 **늘** 빈 보기를 두고, 초기값은 저장된 값뿐(첫 보기를 안 박음)."""
|
||||
assert "optionControl(option, values[option.key])" in PANEL
|
||||
assert 'select([["", blank], ...option.choices' in FIELDS
|
||||
# 초기값은 저장된 값만 — 첫 보기·기본값으로 채우지 않음.
|
||||
assert "element.value = value;" in FIELDS
|
||||
assert "option.choices[0]" not in PANEL
|
||||
# 클라이언트 옵션 타입에 칸이 있어야 서버 응답의 `empty_means` 가 살아 온다.
|
||||
assert "empty_means?: string | null;" in API
|
||||
# 빈 칸은 아예 안 실린다 — 이 규칙이 있어야 「안 정함」이 정본에 안 박힌다.
|
||||
assert "if (input.isEmpty()) return;" in COMMIT
|
||||
|
||||
|
||||
def test_기본값_있는_칸도_빈_보기에_제안으로만_보인다() -> None:
|
||||
"""⭐ 2026-09-14 브레인 판정으로 뒤집힘 — 기본값이 **있어도** 칸에 채우지 않고 빈 보기 이름에
|
||||
「제안 …」으로만 보임(옛 2026-08-17 「첫 보기가 곧 기본값」 걷음). 저장 한 번에 기본값이
|
||||
확정으로 굳던 뿌리였음."""
|
||||
options = _registry_options("masonry_wet")
|
||||
# 버림 콘크리트 — empty_means 가 있고 기본이 「넣음」.
|
||||
assert options["blinding_concrete"]["empty_means"]
|
||||
assert options["blinding_concrete"]["default"] == "넣음"
|
||||
# 채움 강도·야면석 계수 — 기본이 없어 빈 보기가 서야 하는 칸.
|
||||
for key in ("fill_concrete_mpa", "stone_coeff_basis"):
|
||||
assert options[key]["empty_means"], key
|
||||
assert options[key]["default"] is None, key
|
||||
assert options[key]["required"] is False, key
|
||||
assert "`— 안 정함 (제안 ${suggested}) —`" in FIELDS
|
||||
|
||||
|
||||
def _wall(**options) -> dict:
|
||||
return {
|
||||
"structure_id": "w",
|
||||
"type_id": "masonry_wet",
|
||||
"name": "돌쌓기(찰)",
|
||||
"start_m": 0.0,
|
||||
"end_m": 10.0,
|
||||
"length_m": 10.0,
|
||||
"height_m": 2.5,
|
||||
"options": {
|
||||
"height_m": 2.5,
|
||||
"length_m": 10.0,
|
||||
"back_len_cm": 45,
|
||||
"stone_kind": "깬돌",
|
||||
"foundation": "기초유",
|
||||
**options,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _fill_row(**options) -> dict:
|
||||
table = build_table([_wall(**options)], {"masonry_wet": "돌쌓기(찰)"}, {})
|
||||
rows = table["structures"][0]["components"]
|
||||
return next(row for row in rows if row["name"] == "채움콘크리트")
|
||||
|
||||
|
||||
def test_칸을_비우면_강도가_확정_210_으로_선다() -> None:
|
||||
"""⚠ 폼이 180 을 박던 그 자리 — 비면 **엔진 기준값**이 서고 그 사실이 근거에 남는다."""
|
||||
row = _fill_row()
|
||||
assert row["spec"] == "210"
|
||||
assert "안 정해 기본값" in row["basis"]
|
||||
|
||||
|
||||
def test_고른_값은_그대로_이긴다() -> None:
|
||||
row = _fill_row(fill_concrete_mpa="180")
|
||||
assert row["spec"] == "180"
|
||||
assert "저장 제원에서 고른 값" in row["basis"]
|
||||
@@ -0,0 +1,110 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""「칸은 있는데 표가 안 읽음」 — 등록부 칸 옆에 「아직 표에 안 쓰임」 (2026-09-14 브레인 차례 ①).
|
||||
|
||||
사용자가 값을 넣어도 수량·금액이 아무것도 안 바뀌는 칸. 사유를 표에만 두지 말고 칸 옆에도 보임
|
||||
(브레인 판정 ③ 「B」). 표시가 참말인지 **값을 바꿔 돌려 보고** 지킴 — 표가 그 칸을 읽기 시작하면
|
||||
여기서 깨지고, 그때 표시를 걷음(표시가 거짓말로 남지 않게).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map # noqa: E402
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402
|
||||
|
||||
#: 종류 → (표가 서는 기준 제원, 안 읽는 칸). 2026-09-14 값을 바꿔 전수 대조한 결과.
|
||||
#: ⚠ 기초(foundation)·높이는 인계 구조물터파기 심도 구간이 읽어 뺌 · 단 수·올림·이동은 횡단도 칸이라 뺌.
|
||||
MARKED: dict[str, tuple[dict, list[str]]] = {
|
||||
"erosion_check": (
|
||||
{"height_m": 2.0, "top_length_m": 6.0, "bottom_length_m": 4.0, "form": "돌"},
|
||||
["thickness_top_m", "thickness_bottom_m", "stone_supply", "stone_coeff_basis"],
|
||||
),
|
||||
"bed_sill": (
|
||||
{"area_m2": 10.0, "form": "돌붙임(찰)"},
|
||||
["stone_supply", "stone_coeff_basis", "fill_concrete_mpa", "face_slope_ratio"],
|
||||
),
|
||||
"boulder_masonry": (
|
||||
{"height_m": 2.5, "length_m": 10.0, "stone_cm": "60~80", "bond": "찰쌓기"},
|
||||
[
|
||||
"stone_supply",
|
||||
"stone_coeff_basis",
|
||||
"fill_concrete_mpa",
|
||||
"weep_hole_diameter_mm",
|
||||
"weep_hole_area_m2",
|
||||
],
|
||||
),
|
||||
"soil_guard": (
|
||||
{"height_m": 1.0, "length_m": 10.0, "form": "떼"},
|
||||
[
|
||||
"thickness_top_m",
|
||||
"thickness_bottom_m",
|
||||
"back_len_cm",
|
||||
"stone_kind",
|
||||
"stone_supply",
|
||||
"stone_coeff_basis",
|
||||
"fill_concrete_mpa",
|
||||
"face_slope_ratio",
|
||||
"weep_hole_diameter_mm",
|
||||
"weep_hole_area_m2",
|
||||
],
|
||||
),
|
||||
}
|
||||
|
||||
FIELDS = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Fields.ts").read_text(encoding="utf-8")
|
||||
PANEL = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Panel.ts").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _snapshot(type_id: str, options: dict) -> str:
|
||||
structure = {
|
||||
"structure_id": "p",
|
||||
"type_id": type_id,
|
||||
"start_m": 0.0,
|
||||
"end_m": 10.0,
|
||||
"chainage_m": 5.0,
|
||||
"options": options,
|
||||
}
|
||||
table = build_table([structure], {}, {5.0: "both_fill"}, None, 0.2)
|
||||
# 저장 제원을 그대로 되돌려 싣는 칸(`options`)은 뺌 — 값이 **표에 닿았는지**만 봄.
|
||||
for row in table["structures"]:
|
||||
row.pop("options", None)
|
||||
return json.dumps(table, ensure_ascii=False, sort_keys=True, default=str)
|
||||
|
||||
|
||||
def _probe_values(option) -> list:
|
||||
if option.choices:
|
||||
return list(option.choices)
|
||||
return [0.73, 1.37] if option.input == "number" else ["x"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("type_id", sorted(MARKED))
|
||||
def test_등록부에_표시가_있다(type_id: str) -> None:
|
||||
options = {option.key: option for option in structure_type_map()[type_id].options}
|
||||
for key in MARKED[type_id][1]:
|
||||
note = options[key].not_in_table
|
||||
assert note and "아직 표에 안 쓰임" in note, f"{type_id}.{key}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("type_id", sorted(MARKED))
|
||||
def test_표시한_칸은_정말_안_읽힌다(type_id: str) -> None:
|
||||
base, keys = MARKED[type_id]
|
||||
options = {option.key: option for option in structure_type_map()[type_id].options}
|
||||
reference = _snapshot(type_id, dict(base))
|
||||
assert '"components": []' not in reference, "기준 제원으로 표가 안 섬 — 시험이 무의미"
|
||||
for key in keys:
|
||||
for value in _probe_values(options[key]):
|
||||
assert _snapshot(type_id, {**base, key: value}) == reference, (
|
||||
f"{type_id}.{key}={value!r} 로 표가 바뀜 — 이제 읽힘, 표시를 걷을 것"
|
||||
)
|
||||
|
||||
|
||||
def test_폼_칸_이름에_붙는다() -> None:
|
||||
assert "not_in_table" in FIELDS and "not_in_table" in PANEL
|
||||
@@ -0,0 +1,60 @@
|
||||
"""등록부 칸 채우기 A2·A3 (2026-09-14) — 표가 읽는 칸이 등록부에 없어 영영 안 서던 자리.
|
||||
|
||||
A2 개거(점 배치) — 연장 칸 · A3 물넘이포장(관 지점 시설) — 두께·노폭 방향 길이 칸.
|
||||
⚠ 옛 저장본(칸 없음)은 안 깨지고 **사유가 보임** — 「값이 없으면 표가 안 선다」가 아니라 「사유를 보인다」.
|
||||
"""
|
||||
|
||||
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 B05_Profile.B05_Profile_Structures_Repository import _validate_types # noqa: E402
|
||||
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance, structure_type_map # noqa: E402
|
||||
from B08_Quantity.B08_Quantity_Engine_Pipe import facility_structures # noqa: E402
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402
|
||||
|
||||
|
||||
def _ditch(**options) -> dict:
|
||||
return build_table(
|
||||
[{"structure_id": "d", "type_id": "open_ditch", "chainage_m": 10.0, "options": options}]
|
||||
)["structures"][0]
|
||||
|
||||
|
||||
def test_개거_연장_칸이_등록부에_서고_구조물_목록에_저장된다() -> None:
|
||||
keys = {option.key for option in structure_type_map()["open_ditch"].options}
|
||||
assert "length_m" in keys
|
||||
item = StructureInstance(
|
||||
type_id="open_ditch", placement="point", chainage_m=10.0, options={"length_m": 12}
|
||||
)
|
||||
_validate_types([item]) # 등록부에 없는 칸이면 여기서 거절됨
|
||||
|
||||
|
||||
def test_개거_연장이_있으면_표가_서고_없으면_어디서_적는지_사유() -> None:
|
||||
assert _ditch(length_m=12)["components"]
|
||||
old = _ditch() # 옛 저장본 — 칸이 없음
|
||||
assert not old["components"]
|
||||
assert old["notes"][0].startswith("개거(겉도랑) 연장(m)이(가) 아직 입력되지 않았습니다")
|
||||
|
||||
|
||||
def _ford(**options) -> dict:
|
||||
point = {"facility": "ford_pavement", "chainage_m": 30.0, "options": options}
|
||||
return build_table(facility_structures([point]))["structures"][0]
|
||||
|
||||
|
||||
def test_물넘이포장_두께_길이_칸이_있으면_면적으로_선다() -> None:
|
||||
keys = {option.key: option for option in structure_type_map()["ford_pavement"].options}
|
||||
assert keys["thickness_cm"].phase == "detail" and keys["length_m"].phase == "detail"
|
||||
row = _ford(ford_width_m=5, thickness_cm=20, length_m=8)
|
||||
assert row["components"] and (row["billing_unit"], row["billing_quantity"]) == ("㎡", 40.0)
|
||||
|
||||
|
||||
def test_물넘이포장_칸이_비면_무엇을_적을지_사유() -> None:
|
||||
old = _ford(ford_width_m=5)
|
||||
assert not old["components"] and "포장 두께(㎝)" in old["notes"][0]
|
||||
no_length = _ford(ford_width_m=5, thickness_cm=20)
|
||||
assert not no_length["components"] and "포장 길이" in no_length["notes"][0]
|
||||
@@ -0,0 +1,41 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""옹벽 높이 제안값 2.0 — A4 (2026-09-14 브레인 차례 ③).
|
||||
|
||||
등록부 옹벽 높이 기본(=폼의 회색 제안값)이 2.5 였는데 관측 원단위 자료는 반중력식 H=2.0 한 벌뿐 →
|
||||
[제안값 넣기]를 누르면 표가 안 서는 값이 들어가 「원단위 미확보」 사유만 뜸. 자료가 있는 2.0 으로 내림.
|
||||
⚠ 기본값은 여전히 **제안**일 뿐 — 칸에 채우지 않음(폼 판정 ①②).
|
||||
"""
|
||||
|
||||
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 B05_Profile.B05_Profile_Structures_Schema import structure_type_map # noqa: E402
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import expand # noqa: E402
|
||||
from B08_Quantity.B08_Quantity_Engine_ObservedUnit import load_observed_table # noqa: E402
|
||||
|
||||
|
||||
def _height_default() -> float:
|
||||
options = {option.key: option for option in structure_type_map()["retaining_wall"].options}
|
||||
return options["height_m"].default
|
||||
|
||||
|
||||
def test_옹벽_높이_제안값은_자료가_있는_2_0() -> None:
|
||||
assert _height_default() == 2.0
|
||||
|
||||
|
||||
def test_제안값을_넣으면_표가_선다() -> None:
|
||||
structure = {
|
||||
"structure_id": "rw",
|
||||
"type_id": "retaining_wall",
|
||||
"start_m": 0.0,
|
||||
"end_m": 10.0,
|
||||
"options": {"form": "반중력식", "height_m": _height_default(), "length_m": 10.0},
|
||||
}
|
||||
result = expand(structure, {"retaining_wall": "옹벽"}, load_observed_table())
|
||||
assert result.components, result.notes
|
||||
@@ -0,0 +1,57 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""독립 기슭막이 치수 한 벌 — B05 칸 밑 근거와 B08 사유가 **같은 파일**을 읽음 (2026-09-14 브레인 판정).
|
||||
|
||||
한 자리 = `resources/data_masonry/수량_사방기슭막이치수_*.json`. TS 는 판 이름을 import 로 박으므로 판이
|
||||
바뀌면 import 도 옮겨야 함 — 여기서 최신 판과 대조. 쪽·줄 번호가 사유에 그대로 실리는지도 봄.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
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_Pipe import ( # noqa: E402
|
||||
own_height_unconfirmed,
|
||||
own_revetment_basis,
|
||||
)
|
||||
|
||||
DATA_DIR = ROOT / "resources" / "master_data" / "ref"
|
||||
FIELDS = (ROOT / "B05_Profile" / "B05_Profile_UI_Drainage_Facility_Fields.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
CHROME = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Card_Chrome.ts").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_TS_가_최신_판을_읽는다() -> None:
|
||||
latest = sorted(DATA_DIR.glob("수량_사방기슭막이치수_*.json"))[-1].name
|
||||
imported = re.search(r'from "\.\./resources/master_data/ref/(수량_사방기슭막이치수_[^"]+)"', FIELDS)
|
||||
assert imported and imported.group(1) == latest
|
||||
|
||||
|
||||
def test_TS_가_쓰는_칸이_파일에_있다() -> None:
|
||||
latest = sorted(DATA_DIR.glob("수량_사방기슭막이치수_*.json"))[-1]
|
||||
data = json.loads(latest.read_text(encoding="utf-8"))
|
||||
items = data["items"]
|
||||
assert data["scope"] and "사방(계류) 교본 기준" in data["scope"]
|
||||
assert items["height"]["rule"] == "계획홍수위 + 0.5~0.7m"
|
||||
assert items["face_slope"]["standard_ratio"] == [0.3, 0.5]
|
||||
assert items["crown_thickness"]["standard_m"] == [0.3, 0.5]
|
||||
assert "L7164" in items["backfill_pebble"]["source"]
|
||||
|
||||
|
||||
def test_B08_사유가_같은_근거를_쪽_줄_번호로() -> None:
|
||||
basis = own_revetment_basis()
|
||||
for ref in ("2-나:141", "3-가:181", "2-나:129", "3-가:151", "2-나:143", "L7164~7171"):
|
||||
assert ref in basis, ref
|
||||
assert "2-나:141" in own_height_unconfirmed()
|
||||
|
||||
|
||||
def test_B05_높이_칸은_제안_없이_설계자_입력() -> None:
|
||||
assert 'height: "",' in FIELDS and '"설계자 입력"' in FIELDS
|
||||
assert "기슭막이 높이 없음 — 근입만 그림" in CHROME
|
||||
@@ -0,0 +1,102 @@
|
||||
"""횡단도(B06)가 그리는 돌쌓기 벽 폭과 수량(B08)이 세는 벽 두께가 **같은 값인가** — 대조 시험.
|
||||
|
||||
왜 (2026-09-14 브레인 차례 「두께 차이」) — 같은 다단 벽을 두 곳이 다른 두께로 봄:
|
||||
B06 `REVET_THICKNESS_M` 0.45(울진 뒷길이 관측 · 표시용) → 윗폭 1.5×0.45 · 밑폭 윗폭 + 기울기×H
|
||||
B08 `wall_thickness` 실무 구조물도 식 → 상부 뒷길이 + 0.30 · 하부 상부 + 0.30×(H−1)
|
||||
어느 쪽으로 맞출지는 아직 안 정함 — 그래서 `xfail(strict)`: 지금은 빨강이 **나야** 하고,
|
||||
두 값이 맞춰지면 통과로 뒤집혀 이 표시를 걷으라고 알림.
|
||||
실제 화면 코드를 컴파일해 Node 로 돌림(`test_b06_extra_shift_floor` 와 같은 방식).
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_StoneSpec import ( # noqa: E402
|
||||
_back_length,
|
||||
wall_thickness,
|
||||
)
|
||||
|
||||
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||
MODULE = PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Cross_Culvert_Extra.ts"
|
||||
FORM = "돌쌓기(메)"
|
||||
LEAN = 0.35 # 품셈 13-4-4 [주]⑪ 메쌓기 성토 ~3m
|
||||
|
||||
_RUNNER = """
|
||||
const { buildOutletExtras } = require(process.argv[3]);
|
||||
const { writeFileSync } = require("node:fs");
|
||||
const groundAt = (offset) => 95 - offset / 2;
|
||||
const result = buildOutletExtras({
|
||||
start: { offset: 0, elevation: 100 },
|
||||
startBottomElevation: 100,
|
||||
outward: 1,
|
||||
groundAt,
|
||||
limitOffset: 60,
|
||||
adjusts: [{ x: 0, d: null, h: 2.0, m: "%(form)s" }],
|
||||
leanFor: () => %(lean)s,
|
||||
});
|
||||
const wall = result.walls[0];
|
||||
const [bottomBack, topBack, topFront, bottomFront] = wall.points;
|
||||
writeFileSync(process.argv[2], JSON.stringify({
|
||||
pureHeight: wall.height + 0.5,
|
||||
top: Math.abs(topFront.offset - topBack.offset),
|
||||
bottom: Math.abs(bottomFront.offset - bottomBack.offset),
|
||||
}));
|
||||
"""
|
||||
|
||||
|
||||
def _drawn(tmp_path: Path) -> dict:
|
||||
out = tmp_path / "out"
|
||||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
[
|
||||
"node",
|
||||
str(TSC),
|
||||
"--ignoreConfig",
|
||||
"--target",
|
||||
"es2022",
|
||||
"--module",
|
||||
"commonjs",
|
||||
"--skipLibCheck",
|
||||
"--outDir",
|
||||
str(out),
|
||||
str(MODULE),
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
check=False,
|
||||
capture_output=True,
|
||||
)
|
||||
compiled = next(out.rglob("B06_Section_UI_Cross_Culvert_Extra.js"), None)
|
||||
assert compiled is not None, "화면 코드가 JS 로 안 나옴 — tsc 실패"
|
||||
(out / "runner.cjs").write_text(_RUNNER % {"form": FORM, "lean": LEAN}, encoding="utf-8")
|
||||
result = tmp_path / "result.json"
|
||||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(out / "runner.cjs"), str(result), str(compiled)],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return json.loads(result.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||||
@pytest.mark.xfail(strict=True, reason="B06 0.45 표시값 ↔ B08 구조물도 식 — 맞출 쪽 판정 대기")
|
||||
def test_횡단도_벽_폭과_수량_벽_두께가_같다(tmp_path: Path) -> None:
|
||||
drawn = _drawn(tmp_path)
|
||||
height = drawn["pureHeight"]
|
||||
options = {"form": FORM}
|
||||
back_cm = _back_length(options, wet=False, height_m=height)
|
||||
top, bottom, basis = wall_thickness(options, back_cm=back_cm, height_m=height)
|
||||
assert (round(drawn["top"], 2), round(drawn["bottom"], 2)) == (
|
||||
round(top, 2),
|
||||
round(bottom, 2),
|
||||
), (
|
||||
f"H {height:.2f} — 횡단도 윗폭 {drawn['top']:.3f} · 밑폭 {drawn['bottom']:.3f}"
|
||||
f" ↔ 수량 {basis}"
|
||||
)
|
||||
@@ -0,0 +1,142 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""B06 벽 전면 기울기도 **품셈 표준경사 표**를 읽음 — B5 (2026-09-14 브레인 판정 「양쪽 다 이 표를」).
|
||||
|
||||
앞서 B06 횡단도는 벽(관 유입·유출 · 독립 기슭막이 · 다단 추가 · C군 벽)을 **1:0.3 붙박이**로 그렸고
|
||||
B08 은 품셈 13-4-4 [주]⑪ 표(원문 L7185~7191)로 셈 — 메쌓기 성토 H2.5 에서 1:0.3 ↔ 1:0.35 로 갈림.
|
||||
|
||||
재는 것(브레인: 고치기 전 코드에서 빨강부터)
|
||||
① 네 갈래 — 직고 구간 × 성토/절토 × 메/찰 (+ 사용자 칸 `face_slope_ratio`·`face_role`, 못 가르면 종전 0.3)
|
||||
TS 짝 `common_util_masonry_slope.ts` 을 실제로 돌려 파이썬 `face_slope_ratio` 와 한 칸씩 대조.
|
||||
② 기하가 그 값을 씀 — 벽 모양 파일에서 붙박이 `REVET_LEAN_RATIO` 곱이 사라지고 벽마다 기울기를 받음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
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_UnitQuantity_StoneSpec import ( # noqa: E402
|
||||
face_slope_ratio,
|
||||
load_slope_table,
|
||||
)
|
||||
from common_util.common_util_structure_face_role import structure_face_role_of # noqa: E402
|
||||
|
||||
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||
TS_FILE = ROOT / "common_util" / "common_util_masonry_slope.ts"
|
||||
|
||||
FORMS = ("돌쌓기(찰)", "돌쌓기(메)", "콘크리트")
|
||||
HEIGHTS = (1.0, 1.5, 2.5, 3.0, 4.0, 6.0, 7.5)
|
||||
MODES = ("left_cut", "right_cut", "both_cut", "both_fill", None)
|
||||
SIDES = (None, "자동(성토 쪽)", "좌", "우", "양쪽")
|
||||
OVERRIDES = ({}, {"face_role": "절토"}, {"face_slope_ratio": 0.5})
|
||||
|
||||
_RUNNER = """
|
||||
const { readFileSync, writeFileSync } = require("node:fs");
|
||||
const { wallLeanRatio } = require("./common_util_masonry_slope.js");
|
||||
const input = JSON.parse(readFileSync(process.argv[2], "utf8"));
|
||||
const out = input.cases.map((c) => wallLeanRatio(input.table, c));
|
||||
writeFileSync(process.argv[3], JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
def _cases() -> list[dict]:
|
||||
cases = []
|
||||
for form, height, mode, side, extra in itertools.product(
|
||||
FORMS, HEIGHTS, MODES, SIDES, OVERRIDES
|
||||
):
|
||||
cases.append(
|
||||
{"form": form, "height_m": height, "section_mode": mode, "side": side, **extra}
|
||||
)
|
||||
return cases
|
||||
|
||||
|
||||
def _python(case: dict) -> float:
|
||||
"""B08 이 그 벽을 셀 때의 기울기 — 돌쌓기 형태만 표, 나머지는 종전 0.3."""
|
||||
wet = {"돌쌓기(찰)": True, "돌쌓기(메)": False}.get(case["form"])
|
||||
if wet is None:
|
||||
return 0.3
|
||||
options = {k: case[k] for k in ("side", "face_role", "face_slope_ratio") if k in case}
|
||||
face, reason = structure_face_role_of(case["section_mode"], options)
|
||||
ratio, _basis = face_slope_ratio(
|
||||
options, wet=wet, height_m=case["height_m"], face=face, face_reason=reason
|
||||
)
|
||||
return ratio
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None or not TSC.is_file(), reason="node·tsc 없음")
|
||||
def test_네_갈래_표를_TS_가_파이썬과_같게_읽는다(tmp_path: Path) -> None:
|
||||
assert TS_FILE.is_file(), "TS 짝이 아직 없음 — B06 이 표를 안 읽는다"
|
||||
out = tmp_path / "js"
|
||||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
[
|
||||
"node",
|
||||
str(TSC),
|
||||
str(TS_FILE),
|
||||
"--outDir",
|
||||
str(out),
|
||||
"--module",
|
||||
"commonjs",
|
||||
"--target",
|
||||
"es2022",
|
||||
"--ignoreConfig",
|
||||
],
|
||||
cwd=str(ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
(out / "runner.cjs").write_text(_RUNNER, encoding="utf-8")
|
||||
cases = _cases()
|
||||
payload = tmp_path / "in.json"
|
||||
result = tmp_path / "out.json"
|
||||
payload.write_text(
|
||||
json.dumps({"table": load_slope_table(), "cases": cases}, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
subprocess.run( # noqa: S603
|
||||
["node", str(out / "runner.cjs"), str(payload), str(result)],
|
||||
cwd=str(ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
got = json.loads(result.read_text(encoding="utf-8"))
|
||||
wrong = [
|
||||
(case, ts, _python(case))
|
||||
for case, ts in zip(cases, got, strict=True)
|
||||
if abs(ts - _python(case)) > 1e-9
|
||||
]
|
||||
assert not wrong, wrong[:5]
|
||||
# 갈래가 실제로 갈렸는지 — 한 값만 나와 같아지는 것을 막음(메 성토 H2.5 0.35 · 찰 절토 H1.0 0.2)
|
||||
assert {round(v, 2) for v in got} >= {0.2, 0.25, 0.3, 0.35, 0.4, 0.45, 0.5}
|
||||
|
||||
|
||||
GEOMETRY = [
|
||||
"B06_Section_UI_Cross_Culvert_Geom.ts",
|
||||
"B06_Section_UI_Cross_Wall.ts",
|
||||
"B06_Section_UI_Cross_Culvert_Extra.ts",
|
||||
"B06_Section_UI_Cross_Culvert_Solve.ts",
|
||||
]
|
||||
|
||||
|
||||
def test_횡단도가_최신_표준경사_판을_읽는다() -> None:
|
||||
const = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Lean.ts").read_text(encoding="utf-8")
|
||||
latest = sorted((ROOT / "resources" / "master_data" / "old").glob("3_품셈_산림_돌쌓기경사_*.json"))[-1].name
|
||||
imported = re.search(r'from "\.\./resources/master_data/old/(3_품셈_산림_돌쌓기경사_[^"]+)"', const)
|
||||
assert imported and imported.group(1) == latest
|
||||
|
||||
|
||||
def test_벽_모양이_붙박이_기울기를_안_곱한다() -> None:
|
||||
"""벽 모양 파일마다 `REVET_LEAN_RATIO *` 곱이 남아 있으면 그 벽은 아직 1:0.3 붙박이."""
|
||||
for name in GEOMETRY:
|
||||
source = (ROOT / "B06_Section" / name).read_text(encoding="utf-8")
|
||||
assert not re.search(r"REVET_LEAN_RATIO\s*[*/]", source), name
|
||||
@@ -0,0 +1,28 @@
|
||||
"""표준도 장 나눔 — **자리 칸(앞뒤 걸침)은 장을 가르지 않는다** (2026-09-13).
|
||||
|
||||
장 나눔 축은 「제원 조합」이다(PLAN 4-3). 구간 규약의 `before_m`·`after_m` 는 기준 측점에서
|
||||
앞뒤로 얼마나 걸치나일 뿐이라, 제원이 같은 두 기가 이 칸 때문에 두 장이 되면 안 된다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureSheet import sheet_key # noqa: E402
|
||||
|
||||
|
||||
def _stone(**options) -> dict:
|
||||
return {"type_id": "masonry_wet", "height_m": 2.0, "options": {"back_len_cm": 45, **options}}
|
||||
|
||||
|
||||
def test_앞뒤_걸침이_달라도_같은_장() -> None:
|
||||
assert sheet_key(_stone(before_m=5, after_m=5)) == sheet_key(_stone(before_m=0, after_m=12))
|
||||
assert sheet_key(_stone(outlet_revet_before_m=3)) == sheet_key(_stone())
|
||||
|
||||
|
||||
def test_제원이_다르면_여전히_장이_갈린다() -> None:
|
||||
assert sheet_key(_stone(before_m=5)) != sheet_key(_stone(before_m=5, back_len_cm=55))
|
||||
@@ -0,0 +1,83 @@
|
||||
"""표준도 — 그림이 **안 서는 까닭을 화면에 드러낸다** (2026-09-09 사용자 지시).
|
||||
|
||||
빈 자리를 그냥 두면 사용자가 「고장인가」로 읽는다. 장마다 **왜 그림이 없는지와 무엇을
|
||||
받아야 서는지**를 표 위에 한 줄 적는다.
|
||||
|
||||
⚠ **큰돌쌓기를 그림 대상에서 뺐다** — 그림은 두께를 **뒷길이**에서 내는데 큰돌쌓기는 규격이
|
||||
**직경**이라 영영 못 그렸다(품셈 13-6 직경 ↔ 13-4 뒷길이). 식은 만들지 않는다 —
|
||||
「직경에서 두께를 내는 법」은 도메인 판단이라 사용자 몫이다.
|
||||
⚠ 그림 대상과 **기울기 판정 대상은 다른 목록**이다 — 한 벌로 묶었더니 큰돌쌓기를 그림에서
|
||||
뺄 때 **장 제목의 「1:0.3」까지 사라졌다**(화면 실측에서 잡음).
|
||||
"""
|
||||
|
||||
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 B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardSheet import ( # noqa: E402
|
||||
build_standard_drawing,
|
||||
)
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureFigure import ( # noqa: E402
|
||||
FIGURE_TYPE_IDS,
|
||||
figure_reason,
|
||||
)
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureSheet import ( # noqa: E402
|
||||
SLOPE_TYPE_IDS,
|
||||
sheet_title,
|
||||
)
|
||||
|
||||
|
||||
def _sheet(type_id: str, **options) -> dict:
|
||||
return {
|
||||
"key": type_id,
|
||||
"title": type_id,
|
||||
"type_id": type_id,
|
||||
"height_m": 2.5,
|
||||
"options": {"height_m": 2.5, **options},
|
||||
"rows": [{"name": "콘크리트", "unit": "㎥", "amount": 1.0, "basis": "시험"}],
|
||||
"member_count": 1,
|
||||
"unit_label": "m당",
|
||||
}
|
||||
|
||||
|
||||
def test_뒷길이가_없어도_표처럼_기본값으로_서니_까닭이_없다() -> None:
|
||||
"""2026-09-14 브레인 판정 — 표가 기본 45㎝ 로 서면 그림도 섬(까닭은 정말 못 그리는 장만)."""
|
||||
assert figure_reason(_sheet("masonry_wet")) is None
|
||||
|
||||
|
||||
def test_뒷길이가_있으면_까닭이_없다() -> None:
|
||||
assert figure_reason(_sheet("masonry_dry", back_len_cm=35)) is None
|
||||
|
||||
|
||||
def test_큰돌쌓기는_그림_대상에서_빠지고_까닭이_적힌다() -> None:
|
||||
assert "boulder_masonry" not in FIGURE_TYPE_IDS
|
||||
reason = figure_reason(_sheet("boulder_masonry", stone_cm="60~80"))
|
||||
assert reason is not None and "직경" in reason
|
||||
|
||||
|
||||
def test_기울기_판정은_큰돌쌓기에도_남는다() -> None:
|
||||
"""그림 대상 목록으로 판정하면 장 제목에서 「1:0.3」이 사라진다 — 목록을 갈라 둔 자리."""
|
||||
assert "boulder_masonry" in SLOPE_TYPE_IDS
|
||||
title = sheet_title(
|
||||
{"name": "큰돌쌓기", "type_id": "boulder_masonry", "height_m": 2.5, "options": {}}
|
||||
)
|
||||
assert "1:0.3" in title
|
||||
|
||||
|
||||
def test_까닭이_도면에_한_줄로_실린다() -> None:
|
||||
drawing = build_standard_drawing(
|
||||
"standard_sheet_1",
|
||||
"표준도 1장",
|
||||
{"sheets": [_sheet("retaining_wall")], "structure_count": 1},
|
||||
)
|
||||
texts = [
|
||||
(entity.get("shapeData") or {}).get("label", "")
|
||||
for entity in drawing["entities"]
|
||||
if entity.get("type") == "Text"
|
||||
]
|
||||
assert any("그림 없음" in text for text in texts), texts
|
||||
@@ -0,0 +1,95 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""표준도(구조물도) 하단표에 **칸이 서는지** — 날개벽·집수정·옹벽 (계획서 4-13).
|
||||
|
||||
4-13 표의 「⚠ 칸만 오면 됨」 줄을 닫는 자리다. 하단표는 B08 전개를 접기만 하므로
|
||||
(`build_standard_sheets`), **관측 원단위가 들어온 종류는 저절로 선다**. 반대로 자료가
|
||||
없는 규격은 줄이 0개이고 **사유만** 뜬다 — 값을 지어내지 않는 것이 확정이다(확정 5차 3번).
|
||||
|
||||
⚠ 이 시험은 수량값을 검산하지 않는다(그것은 `test_b08_wing_wall` · `test_b08_observed_unit`
|
||||
몫이다). 여기서 보는 것은 **표준도 하단표까지 값이 실려 오는가**다.
|
||||
"""
|
||||
|
||||
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_StructureSheet import ( # noqa: E402
|
||||
build_standard_sheets,
|
||||
)
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402
|
||||
|
||||
|
||||
def _sheets(structures: list[dict], names: dict[str, str]) -> dict[str, dict]:
|
||||
payload = build_standard_sheets(build_table(structures, names, {}), {})
|
||||
return {sheet["type_id"]: sheet for sheet in payload["sheets"]}
|
||||
|
||||
|
||||
def _배수관(**options) -> dict:
|
||||
return {
|
||||
"structure_id": "p1",
|
||||
"type_id": "pipe",
|
||||
"name": "배수관 1",
|
||||
"start_m": 60.0,
|
||||
"end_m": 68.0,
|
||||
"length_m": 8.0,
|
||||
"options": {"pipe_diameter_mm": "800", "station": 60.0, **options},
|
||||
}
|
||||
|
||||
|
||||
def _옹벽(form: str, height_m: float) -> dict:
|
||||
return {
|
||||
"structure_id": "w1",
|
||||
"type_id": "retaining_wall",
|
||||
"name": "옹벽",
|
||||
"start_m": 100.0,
|
||||
"end_m": 110.0,
|
||||
"length_m": 10.0,
|
||||
"height_m": height_m,
|
||||
"options": {"form": form, "height_m": height_m, "length_m": 10.0, "station": 100.0},
|
||||
}
|
||||
|
||||
|
||||
def test_날개벽_집수정_칸이_선다() -> None:
|
||||
"""관에 형식을 채우면 **딸린 두 장**이 저절로 서고 줄마다 단위당 값이 붙는다."""
|
||||
sheets = _sheets(
|
||||
[
|
||||
_배수관(
|
||||
inlet_basin_form="돌집수정 ㄷ형",
|
||||
inlet_basin_material="콘크리트",
|
||||
wing_wall_type="C-TYPE",
|
||||
)
|
||||
],
|
||||
{"pipe": "배수관"},
|
||||
)
|
||||
for type_id in ("pipe_inlet_basin", "pipe_wing_wall"):
|
||||
sheet = sheets.get(type_id)
|
||||
assert sheet is not None, f"{type_id} 장이 안 섰다"
|
||||
# 개소당 — 관측 원단위가 개소 기준이라 연장으로 접지 않는다(4-1: 단위를 통일하지 않는다).
|
||||
assert sheet["billing_unit"] == "개소"
|
||||
assert sheet["rows"], f"{type_id} 하단표가 비었다"
|
||||
# 「값이 없다」를 0 으로 때우지 않는다 — 단위당을 못 낸 줄이 있으면 이름으로 드러난다.
|
||||
assert sheet["unpriced_rows"] == []
|
||||
assert all(row["amount"] > 0 for row in sheet["rows"])
|
||||
|
||||
|
||||
def test_형식을_안_고르면_장이_안_선다() -> None:
|
||||
"""안 놓은 것과 같다 — 빈 장을 만들지 않는다(`ATTACHMENTS` 문턱)."""
|
||||
sheets = _sheets([_배수관()], {"pipe": "배수관"})
|
||||
assert "pipe_inlet_basin" not in sheets
|
||||
assert "pipe_wing_wall" not in sheets
|
||||
|
||||
|
||||
def test_옹벽은_자료가_있는_규격만_선다() -> None:
|
||||
"""반중력식 H=2.0 은 소광리 「옹벽2.0」에서 왔고, 식생옹벽블럭은 **자료가 없다**."""
|
||||
있음 = _sheets([_옹벽("반중력식", 2.0)], {"retaining_wall": "옹벽"})["retaining_wall"]
|
||||
assert 있음["rows"], "관측 원단위가 있는데 하단표가 비었다"
|
||||
assert 있음["unpriced_rows"] == []
|
||||
|
||||
없음 = _sheets([_옹벽("식생옹벽블럭", 2.0)], {"retaining_wall": "옹벽"})["retaining_wall"]
|
||||
assert 없음["rows"] == [], "자료 없는 규격에 값이 지어졌다"
|
||||
assert any("자료에 없습니다" in note for note in 없음["notes"]), 없음["notes"]
|
||||
@@ -0,0 +1,117 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""표준도 제원 — 버림 콘크리트 칸(확정 8-2·14)과 물빼기 그림 주석(확정 5차 작은 것 2).
|
||||
|
||||
**버림 콘크리트** — 셈은 이미 「기본은 넣고, 「안 넣음」이면 뺀다」로 서 있었는데
|
||||
(`wants_blinding`) **끄는 칸이 화면에 없었다**. 등록부 · 표준도 입력 · 폼 세 자리를 함께
|
||||
이어 놓은 것을 굳힌다.
|
||||
⚠ **문구가 폴리시다** — 폼이 보내는 말이 `wants_blinding` 이 「빼기」로 읽는 말과 한 글자라도
|
||||
다르면 골라도 안 빠진다. 그래서 선택지 문구를 **엔진에 직접 물어** 확인한다.
|
||||
|
||||
**물빼기 주석** — 그림이 기본 상수를 적고 있어, 구조물별로 지름·면적을 고쳐도 **표는 바뀌고
|
||||
그림 글자만 옛 값**으로 남았다(확정 10 「구조물별로 다르게도 가능」과 어긋남).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
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_StructureFigure import ( # noqa: E402
|
||||
build_figure,
|
||||
)
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureSheet_Edit import ( # noqa: E402
|
||||
BLINDING_CHOICES,
|
||||
EDITABLE_KEYS,
|
||||
clean_spec,
|
||||
)
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import wants_blinding # noqa: E402
|
||||
|
||||
#: 버림이 서는 종류 — 돌쌓기 식(`stone_masonry`)·큰돌쌓기를 타는 것들.
|
||||
BLINDING_TYPES = ("masonry_wet", "masonry_dry", "boulder_masonry", "revetment")
|
||||
|
||||
|
||||
def _registry() -> dict[str, dict]:
|
||||
payload = json.loads(
|
||||
(ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8")
|
||||
)
|
||||
return {item["type_id"]: item for item in payload["types"]}
|
||||
|
||||
|
||||
def test_등록부에_버림_칸이_있다() -> None:
|
||||
"""칸이 없으면 저장소가 그 제원을 거절한다(`drop_unregistered` 의 그 자리)."""
|
||||
registry = _registry()
|
||||
for type_id in BLINDING_TYPES:
|
||||
options = {option["key"]: option for option in registry[type_id]["options"]}
|
||||
field = options.get("blinding_concrete")
|
||||
assert field is not None, f"{type_id} 등록부에 버림 칸이 없다"
|
||||
assert field["choices"] == list(BLINDING_CHOICES)
|
||||
# 기본은 **넣음** — 확정 ⑭. 빈 칸도 넣음이지만 화면에 보여야 한다.
|
||||
assert field["default"] == "넣음"
|
||||
assert field["phase"] == "detail"
|
||||
|
||||
|
||||
def test_표준도가_버림_칸을_받는다() -> None:
|
||||
assert "blinding_concrete" in EDITABLE_KEYS
|
||||
spec, notes = clean_spec({"blinding_concrete": "안 넣음"})
|
||||
assert spec == {"blinding_concrete": "안 넣음"} and notes == []
|
||||
# 빈 값은 「정한 적 없음」 — 키를 지운다(그러면 기본인 「넣음」으로 돈다).
|
||||
spec, _ = clean_spec({"blinding_concrete": ""})
|
||||
assert spec == {"blinding_concrete": None}
|
||||
# 없는 갈래는 막지 않고 **알린다**(표준도 입력의 규칙).
|
||||
_spec, notes = clean_spec({"blinding_concrete": "빼기도"})
|
||||
assert notes and "버림 콘크리트" in notes[0]
|
||||
|
||||
|
||||
def test_선택지_문구가_엔진이_읽는_말과_같다() -> None:
|
||||
"""⚠ 이 시험이 없으면 「제외」처럼 다른 말로 바뀌어도 아무 데서도 안 걸린다."""
|
||||
넣음, 안넣음 = BLINDING_CHOICES
|
||||
assert wants_blinding({"blinding_concrete": 넣음}) is True
|
||||
assert wants_blinding({"blinding_concrete": 안넣음}) is False
|
||||
assert wants_blinding({}) is True # 정한 적 없음 = 넣음
|
||||
|
||||
|
||||
def _figure_labels(**options) -> list[str]:
|
||||
sheet = {
|
||||
"key": "masonry_wet",
|
||||
"title": "돌쌓기(찰)",
|
||||
"type_id": "masonry_wet",
|
||||
"height_m": 2.0,
|
||||
"options": {"height_m": 2.0, "back_len_cm": 45, **options},
|
||||
}
|
||||
return [shape["text"] for shape in build_figure(sheet) or [] if shape["kind"] == "text"]
|
||||
|
||||
|
||||
def test_물빼기_주석이_구조물_값을_쓴다() -> None:
|
||||
labels = _figure_labels(weep_hole_diameter_mm=75, weep_hole_area_m2=3)
|
||||
물구멍 = [text for text in labels if "물구멍" in text]
|
||||
assert 물구멍, labels
|
||||
assert "Ø75" in 물구멍[0] and "3㎡당" in 물구멍[0], 물구멍[0]
|
||||
|
||||
|
||||
def test_안_정하면_국가기준_기본이_뜬다() -> None:
|
||||
물구멍 = [text for text in _figure_labels() if "물구멍" in text]
|
||||
assert 물구멍 and "Ø50" in 물구멍[0] and "2㎡당" in 물구멍[0], 물구멍
|
||||
|
||||
|
||||
def test_제원_칸이_구조물도_탭_표_옆에_있다() -> None:
|
||||
"""확정 6 ㉮ 「한 곳에서 정하고 표는 비추기만」.
|
||||
|
||||
ⓘ 2026-09-13 구조물도가 B08 탭으로 옮겨(PLAN 3장 ④) 표가 CAD 밖 HTML 로 서면서 폼이
|
||||
**표 바로 옆**에 붙음 — 옛 B07 [여기서 고치기] 단추(표가 CAD 안이라 필요했던 것)는 걷어냄.
|
||||
"""
|
||||
page = (ROOT / "B07_DesignDetail" / "B07_DesignDetail_UI_Page.ts").read_text(encoding="utf-8")
|
||||
assert "specJumpButton" not in page, "B07 에 표준도 제원 칸 흔적이 남았다"
|
||||
sheet = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "buildStandardSpecPanel(" in sheet
|
||||
# 폼에 버림 칸이 섰다 — 끄는 자리가 화면에 있다(확정 8-2).
|
||||
form = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_Spec.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "blinding_concrete" in form
|
||||
@@ -0,0 +1,59 @@
|
||||
"""10-A — 우리가 정한 SW 규칙을 화면에 드러내기(B05·B09 몫) · 2026-09-14 사용자 확정 · PLAN 12장.
|
||||
|
||||
번호는 PLAN 10장 「✅ 사용자 확정 — SW 규칙」 줄 차례. 문구 못박을 셋(①②③)은 글자 그대로 봄.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_11_일반관리비_문구_못박음() -> None:
|
||||
from B09_Estimation.B09_Estimation_CostSheet import FIELD_HINTS
|
||||
|
||||
assert FIELD_HINTS["overhead_class"] == (
|
||||
"임도가 어느 쪽인지 규정이 없어 (주)공사 기본 · 칸에서 바꿀 수 있음"
|
||||
)
|
||||
|
||||
|
||||
def test_13_모르타르_배합_교차_참조_문구() -> None:
|
||||
from B09_Estimation.B09_Estimation_WorkItems_AX import MORTAR_MIX, WORK_ITEMS
|
||||
|
||||
assert WORK_ITEMS[MORTAR_MIX]["basis"].startswith(
|
||||
"산림품셈에 배합 절이 없어 건설품셈 [건축] 9-1-1 을 씀(교차 참조)"
|
||||
)
|
||||
|
||||
|
||||
def test_15_폐기물처리비는_법정경비_밑수에_안_넣음() -> None:
|
||||
from B09_Estimation.B09_Estimation_CostSheet import FIELD_HINTS
|
||||
|
||||
assert "법정경비 밑수(직접공사비·노무비)에는 안 넣음" in FIELD_HINTS["waste_placement"]
|
||||
|
||||
|
||||
def test_17_옹벽_높이_기본값_문구와_칸_밑_근거() -> None:
|
||||
types = json.loads(_read("B05_Profile/B05_Profile_Structure_Types.json"))
|
||||
wall = next(t for t in types["types"] if t["type_id"] == "retaining_wall")
|
||||
height = next(o for o in wall["options"] if o["key"] == "height_m")
|
||||
assert height["default_basis"] == "기본값 · 소광리 도면 H=2.0 · 바꿀 수 있음"
|
||||
# 칸 밑 근거 한 줄 — 등록부 폼이 `default_basis` 를 칸 밑에 그림
|
||||
assert "field(label, input, option.default_basis)" in _read(
|
||||
"B05_Profile/B05_Profile_UI_Structures_Panel.ts"
|
||||
)
|
||||
|
||||
|
||||
def test_19_시설_저장은_폼에_없는_칸을_그대로_둠() -> None:
|
||||
assert "폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠" in _read(
|
||||
"B05_Profile/B05_Profile_UI_Drainage_Facility.ts"
|
||||
)
|
||||
|
||||
|
||||
def test_20_막힌_사유는_아래_단계_것을_그대로() -> None:
|
||||
assert 'hint(L("B09_Sheet_Missing_Passed"))' in _read(
|
||||
"B09_Estimation/B09_Estimation_UI_Tab_Bill.ts"
|
||||
)
|
||||
assert "그대로 옮김 — 여기서 새로 짓지 않음" in _read("ui_template/ui_template_locale_b3.ts")
|
||||
@@ -0,0 +1,77 @@
|
||||
"""별칭표 한 벌 (2026-09-13 축 C 명세 5장·17장 · `common_util_aliases`).
|
||||
|
||||
지키는 것
|
||||
① 정본 파일의 모든 줄에 scope·pum_edition 이 있다
|
||||
② 별칭은 **범위 안·같은 판**에서만 옮긴다 — 「리핑암 → 파쇄암」은 10-11 에서만
|
||||
③ 양방향 조회 — 품셈 이름으로도 우리 이름을 찾는다
|
||||
④ scope 없음 · 모르는 축 · 겹친 범위의 두 대상은 읽을 때 오류
|
||||
⑤ B09 갈래 고르기와 B08 인계본이 **같은 파일**을 읽는다
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from B08_Quantity.B08_Quantity_Engine_Handoff import load_mapping
|
||||
from common_util.common_util_aliases import (
|
||||
ALIAS_FILE,
|
||||
AliasError,
|
||||
alias_target,
|
||||
load_aliases,
|
||||
parse_aliases,
|
||||
)
|
||||
|
||||
ROW = {
|
||||
"axis": "variant",
|
||||
"from": "리핑암",
|
||||
"to": "파쇄암",
|
||||
"scope": "FP-10-11",
|
||||
"pum_edition": "2026-01-01",
|
||||
}
|
||||
|
||||
|
||||
def test_정본_파일_모든_줄에_범위와_판():
|
||||
rows = parse_aliases(json.loads(ALIAS_FILE.read_text(encoding="utf-8"))["aliases"])
|
||||
assert rows
|
||||
assert all(row["scope"].startswith("FP-") and row["pum_edition"] for row in rows)
|
||||
|
||||
|
||||
def test_범위_안_같은_판에서만_옮긴다():
|
||||
rows = load_aliases("variant")
|
||||
assert alias_target(rows, "리핑암", "FP-10-11", "2026-01-01") == "파쇄암"
|
||||
assert alias_target(rows, "리 핑 암", "FP-10-12-02", "2026-01-01") == "암절취"
|
||||
assert alias_target(rows, "리핑암", "FP-09-19-02", "2026-01-01") is None
|
||||
assert alias_target(rows, "리핑암", "FP-10-11", "2027-01-01") is None
|
||||
assert alias_target(rows, "발파암", "FP-10-11", "2026-01-01") is None
|
||||
|
||||
|
||||
def test_양방향_조회():
|
||||
rows = load_aliases("variant")
|
||||
assert alias_target(rows, "파쇄암", "FP-10-11", "2026-01-01", reverse=True) == "리핑암"
|
||||
assert alias_target(rows, "파쇄암", "FP-10-12", "2026-01-01", reverse=True) is None
|
||||
|
||||
|
||||
def test_규약_어기면_읽을_때_오류():
|
||||
with pytest.raises(AliasError):
|
||||
parse_aliases([{**ROW, "scope": ""}])
|
||||
with pytest.raises(AliasError):
|
||||
parse_aliases([{**ROW, "axis": "ground"}])
|
||||
with pytest.raises(AliasError):
|
||||
parse_aliases([ROW, {**ROW, "to": "암절취", "scope": "FP-10"}])
|
||||
# 범위가 안 겹치거나 판이 다르면 같은 이름이 다른 곳으로 가도 된다
|
||||
assert len(parse_aliases([ROW, {**ROW, "to": "암절취", "scope": "FP-10-12"}])) == 2
|
||||
assert len(parse_aliases([ROW, {**ROW, "to": "암절취", "pum_edition": "2027-01-01"}])) == 2
|
||||
|
||||
|
||||
def test_B08_인계본은_범위째_싣는다():
|
||||
view = load_mapping().ground_aliases["aliases"]["리핑암"]
|
||||
assert view["scopes"] == {"파쇄암": "FP-10-11", "암절취": "FP-10-12"}
|
||||
|
||||
|
||||
def test_B09_갈래_고르기가_별칭표를_읽는다():
|
||||
from B09_Estimation.B09_Estimation_UnitPrice import find_variant_code
|
||||
|
||||
assert find_variant_code("FP-10-11", "리핑암") == "B-FP-10-11#파쇄암"
|
||||
assert find_variant_code("FP-10-11", "발파암") == "B-FP-10-11#발파암"
|
||||
@@ -0,0 +1,152 @@
|
||||
"""이중계상 경계를 축으로 (명세 6장 · 2026-09-13).
|
||||
|
||||
지키는 것 — 갈 곳(`destination`) 칸으로 판정하고 B09 내역서가 멈춘다
|
||||
① 구조물 터파기·되메우기는 토공집계로만 — 다른 공종 줄이 또 집거나, earthwork 밖으로 가면 오류
|
||||
② 배합 성분(시멘트·모래·자갈)이 자재총괄에 뜨면 오류
|
||||
③ 할증은 자재총괄 한 번 — 원단위표가 붙였거나 · 자재 합계가 순수량 × (1+율) 과 다르거나 ·
|
||||
할증 포함 재료량을 준 공종에 자재가 일위대가 재료비로 붙으면 오류
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff, load_mapping
|
||||
from B08_Quantity.B08_Quantity_Engine_Handoff_Boundaries import verify_double_count_boundaries
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import unit_price_table
|
||||
from B09_Estimation.B09_Estimation_BillOfQuantities import HandoffMaterial, build_bill
|
||||
from B09_Estimation.B09_Estimation_Guards import (
|
||||
DoubleCountError,
|
||||
check_material_surcharge_once,
|
||||
check_materials_before_surcharge,
|
||||
)
|
||||
from B09_Estimation.B09_Estimation_PriceBook import PriceBook, PriceDetail, PriceKind, PriceTitle
|
||||
|
||||
|
||||
def _structure(type_id: str, *components: tuple[str, str, float]) -> dict:
|
||||
return {
|
||||
"type_id": type_id,
|
||||
"name": type_id,
|
||||
"components": [
|
||||
{"name": name, "unit": "㎥", "amount": amount, "destination": destination}
|
||||
for name, destination, amount in components
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_경계를_지킨_구조물은_어긴_자리가_없다() -> None:
|
||||
table = {
|
||||
"structures": [
|
||||
_structure(
|
||||
"masonry_wet",
|
||||
("돌쌓기", "unit_price", 26.1),
|
||||
("터파기", "earthwork", 12.0),
|
||||
("되메우기", "earthwork", 4.0),
|
||||
("채집석", "haul_deduction", 3.0),
|
||||
("입적", "reference", 9.0),
|
||||
)
|
||||
]
|
||||
}
|
||||
assert verify_double_count_boundaries(table, {"rows": []}, load_mapping()) == []
|
||||
|
||||
|
||||
def test_토공으로_가는_성분을_공종_줄이_또_집으면_오류() -> None:
|
||||
mapping = load_mapping()
|
||||
# 옹벽 묶음 조각 「기초잡석」이 토공으로 가는 성분을 집음 — 같은 물량이 두 공종에 붙음
|
||||
wall = _structure("retaining_wall", ("기초잡석", "earthwork", 0.3))
|
||||
# 돌쌓기 줄이 세는 성분이 토공으로 감
|
||||
masonry = _structure("masonry_wet", ("돌쌓기", "earthwork", 26.1))
|
||||
# 터파기가 자재로 감 — 토공집계로만 가야 함
|
||||
loose = _structure("masonry_dry", ("터파기", "material", 5.0))
|
||||
found = verify_double_count_boundaries(
|
||||
{"structures": [wall, masonry, loose]}, {"rows": []}, mapping
|
||||
)
|
||||
assert any("묶음 조각" in f and "기초잡석" in f for f in found)
|
||||
assert any("구조물 줄" in f and "돌쌓기" in f for f in found)
|
||||
assert any("「터파기」 갈 곳이 material" in f for f in found)
|
||||
|
||||
|
||||
def test_배합_성분이_자재총괄에_뜨거나_원단위표가_할증을_붙이면_오류() -> None:
|
||||
found = verify_double_count_boundaries(
|
||||
{"structures": [], "surcharge_applied": True},
|
||||
{"rows": [{"name": "시멘트"}, {"name": "막자갈"}]},
|
||||
load_mapping(),
|
||||
)
|
||||
assert any(f.startswith("② ") and "시멘트" in f for f in found)
|
||||
assert not any("막자갈" in f for f in found) # 뒤채움 재료 — 정확히 같은 이름만 봄
|
||||
assert any(f.startswith("③ ") for f in found)
|
||||
|
||||
|
||||
def test_내역서는_어긴_자리가_있으면_멈춘다() -> None:
|
||||
handoff = build_handoff(
|
||||
summary_table={"rows": [{"group": "흙깎기", "item": "토사", "unit": "㎥", "amount": 1.0}]}
|
||||
)
|
||||
assert handoff["double_count_violations"] == []
|
||||
handoff["double_count_violations"] = ["① 시험 — 같은 물량이 두 자리에 붙음"]
|
||||
with pytest.raises(DoubleCountError):
|
||||
build_bill(handoff)
|
||||
|
||||
|
||||
def _material(total: str, note: str = "", pct: str | None = "10") -> HandoffMaterial:
|
||||
return HandoffMaterial(
|
||||
material_name="고임돌",
|
||||
spec="",
|
||||
unit="㎥",
|
||||
net_amount=Decimal("100"),
|
||||
total_amount=Decimal(total),
|
||||
supply_type="contractor_supplied",
|
||||
surcharge_pct=None if pct is None else Decimal(pct),
|
||||
surcharge_note=note,
|
||||
)
|
||||
|
||||
|
||||
def test_자재_합계는_할증_한_번() -> None:
|
||||
check_material_surcharge_once([_material("110"), _material("100", pct=None)])
|
||||
check_material_surcharge_once([_material("100", note="품셈에 할증 포함 — 중복 적용 안 함")])
|
||||
with pytest.raises(DoubleCountError):
|
||||
check_material_surcharge_once([_material("121")]) # 두 번 붙음
|
||||
|
||||
|
||||
def test_할증_포함_재료량_공종에_자재가_재료비로_붙으면_오류() -> None:
|
||||
book = PriceBook()
|
||||
# 유로폼 12-38-02 는 가드에서 뺐음(2026-09-14 301 ④ · 자재총괄에 줄 없음) — 돌망태 사각형으로 잼
|
||||
book.add_title(PriceTitle(code="M-시험", kind=PriceKind.MATERIAL, name="철망태", unit="㎥"))
|
||||
book.add_title(PriceTitle(code="B-FP-13-11-04", kind=PriceKind.UNIT_PRICE, name="사각형"))
|
||||
book.add_detail(PriceDetail("B-FP-13-11-04", "M-시험", Decimal("1.0")))
|
||||
# 금액을 세우지 않고도 걸림 — 줄이 붙는 순간이 어긴 자리
|
||||
with pytest.raises(DoubleCountError):
|
||||
check_materials_before_surcharge(book)
|
||||
|
||||
|
||||
def test_양식_호표가_품은_기초잡석을_모음_줄이_또_세면_오류() -> None:
|
||||
"""④ 검증 프로젝트 찰쌓기 실화 — 호표가 품은 기초잡석이 인계 줄로도 나가 8.3㎥(실제 3.5㎥)."""
|
||||
table = {
|
||||
"structures": [
|
||||
{**_structure("masonry_wet", ("기초잡석", "unit_price", 4.8)), "structure_id": "s1"},
|
||||
{**_structure("masonry_dry", ("기초잡석", "unit_price", 3.5)), "structure_id": "s2"},
|
||||
]
|
||||
}
|
||||
priced = [{"structure_ids": ["s1"], "covered": ["기초잡석", "돌쌓기", "모르터"]}]
|
||||
mapping = load_mapping()
|
||||
|
||||
def rubble(quantity: float) -> list[dict]:
|
||||
return [{"name": "기초잡석", "quantity": quantity, "composite_parts": None}]
|
||||
|
||||
ok = verify_double_count_boundaries(table, {"rows": []}, mapping, priced, rubble(3.5))
|
||||
assert not [f for f in ok if f.startswith("④")]
|
||||
doubled = verify_double_count_boundaries(table, {"rows": []}, mapping, priced, rubble(8.3))
|
||||
assert any(f.startswith("④ 「기초잡석」") and "4.8" in f for f in doubled)
|
||||
# 호표가 없으면 8.3 은 정상 — 판정은 물량 보존이라 빌더의 건너뛰기를 따라 짜지 않음
|
||||
plain = verify_double_count_boundaries(table, {"rows": []}, mapping, [], rubble(8.3))
|
||||
assert not [f for f in plain if f.startswith("④")]
|
||||
|
||||
|
||||
def test_구조물도_일위대가는_토공으로_가는_줄을_안_넣는다() -> None:
|
||||
template = {"code": "AX-ST-00000001", "unit_price": {"rows": [{"seq": 1, "from_row": 12}]}}
|
||||
sheet = {"rows": [{"no": 12, "unit": "㎥", "unit_amount": 1.2, "destination": "earthwork"}]}
|
||||
book = SimpleNamespace()
|
||||
table = unit_price_table(template, sheet, book, lambda code, value: None)
|
||||
assert table["rows"][0]["reason"].startswith("이중계상") and table["complete"] is False
|
||||
@@ -0,0 +1,160 @@
|
||||
"""건설 표 모양 판정·밑수 읽기 — 2026-09-18 브레인 일감(랩탑 메인).
|
||||
|
||||
못박는 것
|
||||
- ⚠ **산림은 건설 층에 닿지 않음** — 건설 규칙은 산림 잣대가 못 가른 표에서만 돌고, 산림 미판정은 0.
|
||||
- 건설 꼴 여섯(기계 손료표 · 율·계수 · 값 없는 설명표 · 건설 직종 · 단위 칸 · 공식 기호)을 가름.
|
||||
- **못 가리면 미판정** — 지어내지 않음.
|
||||
- 밑수 읽기: 건설 꼴 `(용접개소당)`·`(세면기 개당)`·`(m당 : 관길이기준)`·`(일당)` 를 읽되
|
||||
⚠ `(1ha당, 100본당)` 처럼 **둘이면 안 고름** · `공㎥당` 은 `㎥` 로 안 깎임 · 「회」는 밑수가 아님.
|
||||
- ⚠ **작업조 표에는 「일」 밑수를 안 붙임**(c3da333e — 붙이면 유로폼 호표가 사라짐).
|
||||
금액 불변은 `test_work_item_key_gate.py` 원가계산서 시험(직접공사비 5,983,724)이 잼.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_FormsConst import ( # noqa: E402
|
||||
judged_const_form,
|
||||
)
|
||||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Table import ( # noqa: E402
|
||||
basis_from_source,
|
||||
crew_table,
|
||||
detect_form,
|
||||
new_report,
|
||||
table_entry,
|
||||
)
|
||||
|
||||
FOREST = ROOT / "resources/data_cost_input_value/pum_forest_2026.json"
|
||||
MASTER = ROOT / "resources/data_work_item_master"
|
||||
|
||||
|
||||
def table(headers: list[str], rows: list[list[str]]) -> dict:
|
||||
return {"table_id": "T0001", "section": "시험", "line": 3, "headers": headers, "rows": rows}
|
||||
|
||||
|
||||
# ── 산림을 흔들지 않음 ────────────────────────────────────────────────────
|
||||
def test_산림_표는_건설_층에_닿지_않음() -> None:
|
||||
"""산림 표는 산림 잣대에서 이미 갈림 — 건설 규칙이 산림 값을 바꿀 수 없음(마스터 산출로 잼)."""
|
||||
master = json.loads((MASTER / "work_item_master_2026-01-01.json").read_text(encoding="utf-8"))
|
||||
reached = [
|
||||
t["pum_table_id"]
|
||||
for w in master["work_items"]
|
||||
for t in w.get("tables", [])
|
||||
if str(t.get("form_basis", "")).startswith("건설 꼴")
|
||||
]
|
||||
assert reached == [], f"산림 표가 건설 층까지 내려감: {reached[:5]}"
|
||||
|
||||
|
||||
def test_산림_마스터는_형태_미판정이_없음() -> None:
|
||||
master = json.loads((MASTER / "work_item_master_2026-01-01.json").read_text(encoding="utf-8"))
|
||||
assert master["stats"]["form_undetermined"] == 0
|
||||
|
||||
|
||||
# ── 건설 꼴 가름 ─────────────────────────────────────────────────────────
|
||||
def test_기계_손료표는_계수() -> None:
|
||||
got = judged_const_form(
|
||||
table(
|
||||
["분류 번호", "규격 (ton)", "내용 시간", "상각 비율", "시 간 당(10-7)"],
|
||||
[["0101-0007", "7", "12,000", "0.9", "1,811"]],
|
||||
)
|
||||
)
|
||||
assert got and got[0] == "coefficient"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"headers, rows",
|
||||
[
|
||||
(
|
||||
["사용기간별 구 분", "3개월 (%)", "2개년초과 평균손율 (%)"],
|
||||
[["철 물", "30", "85"]],
|
||||
), # 손율
|
||||
(["지형종류", "시가지"], [["계수", "0.58"]]), # 보정계수
|
||||
(["설치간격", "0.6m이하"], [["요 율", "17%"]]), # 요율
|
||||
],
|
||||
)
|
||||
def test_율_계수표는_계수(headers: list[str], rows: list[list[str]]) -> None:
|
||||
got = judged_const_form(table(headers, rows))
|
||||
assert got and got[0] == "coefficient"
|
||||
|
||||
|
||||
def test_값이_없는_설명표는_참조() -> None:
|
||||
got = judged_const_form(
|
||||
table(["노 임 구 분", "산 정 식"], [["노임합계", "P+PO"], ["기본노임", "P"]])
|
||||
)
|
||||
assert got == ("reference", "값(숫자) 칸이 없음 — 설명·서식 표")
|
||||
|
||||
|
||||
def test_건설_직종이_있으면_소요량() -> None:
|
||||
got = judged_const_form(
|
||||
table(["구 분", "특급 기술자", "고급 기술자"], [["작업계획", "0.5", "1.1"]])
|
||||
)
|
||||
assert got and got[0] == "requirement"
|
||||
|
||||
|
||||
def test_비고의_산출식은_계수로_안_뒤집음() -> None:
|
||||
"""인력품 표의 비고에 산출식이 있어도 **품 표**다(9-5-4 수치지도)."""
|
||||
got = judged_const_form(
|
||||
table(
|
||||
["구 분", "고급 기술자", "비 고"],
|
||||
[["자동독취", "0.016인", "4매×20분/60분/8시간=0.166일"]],
|
||||
)
|
||||
)
|
||||
assert got and got[0] == "requirement"
|
||||
|
||||
|
||||
def test_단위_칸과_값이_한_줄이면_소요량() -> None:
|
||||
got = judged_const_form(table(["구 분", "단 위", "브라켓형"], [["비 계 공", "인", "1.40"]]))
|
||||
assert got and got[0] == "requirement"
|
||||
|
||||
|
||||
def test_줄_이름이_공식_기호뿐이면_계수() -> None:
|
||||
got = judged_const_form(table(["구 분", "R.C.D", "올케이싱"], [["β", "1.14", "1.08"]]))
|
||||
assert got and got[0] == "coefficient"
|
||||
|
||||
|
||||
def test_못_가리면_미판정으로_남김() -> None:
|
||||
"""지역·월별 숫자만 있는 표 — 무엇의 값인지 원문으로 못 가름."""
|
||||
assert judged_const_form(table(["지역별", "1월", "2월"], [["춘천", "7", "5"]])) is None
|
||||
assert detect_form(table(["지역별", "1월"], [["춘천", "7"]]), None)[0] == "undetermined"
|
||||
|
||||
|
||||
# ── 밑수 읽기 ────────────────────────────────────────────────────────────
|
||||
@pytest.mark.parametrize(
|
||||
"line, want",
|
||||
[
|
||||
("1-2-1 용접접합('93년 보완)(용접개소당)", (1.0, "개소")),
|
||||
("7-1-5 카운터형 세면기 설치(분리형)('26년 보완)(세면기 개당)", (1.0, "개")),
|
||||
("- 1. 이중보온관 부설(m당 : 관길이기준)", (1.0, "m")),
|
||||
("1-3-10 플륨관 해체('22년 보완)(일당)", (1.0, "일")),
|
||||
("6-8-1 주철제 게이트 제수밸브 부설 및 접합('23년 보완)(기당)", (1.0, "기")),
|
||||
("1-3-1 데크플레이트 가스절단('18년 보완)(절단길이 10m당)", (10.0, "m")),
|
||||
("### 12-19. 강관비계 (단위: 공㎥당)", (1.0, "공㎥")),
|
||||
("### 6-2-1. 둘레베기 (1ha당, 100본당)", (None, None)), # 밑수가 둘 — 안 고름
|
||||
("### 10-4. 중기운반 (단위: 회당)", (None, None)), # 「회」는 밑수 단위가 아님
|
||||
],
|
||||
)
|
||||
def test_건설_꼴_밑수_읽기(line: str, want: tuple) -> None:
|
||||
assert basis_from_source([line, "", "| 머리 |"], 3) == want
|
||||
|
||||
|
||||
def test_작업조_표에는_일_밑수를_안_붙임() -> None:
|
||||
"""원문이 `(일당)` 이라도 작업조 표면 밑수를 비움 — 밑수는 시공량 열."""
|
||||
crew = table(
|
||||
["구 분", "단 위", "수 량", "시 공 량 (㎡)"],
|
||||
[["형틀목공 보통인부", "인 인", "4 1", "25"]],
|
||||
)
|
||||
assert crew_table(crew)
|
||||
lines = ["### 12-38-3 설치 및 해체 (일당)", "", "| 머리 |"]
|
||||
entry = table_entry(crew, "12-38-3", "12-38-3", "12", lines, None, new_report())
|
||||
assert (entry["basis_quantity"], entry["basis_unit"]) == (None, None)
|
||||
plain = table(["구 분", "단 위", "수 량"], [["보통인부", "인", "1.4"]])
|
||||
entry = table_entry(plain, "2-1", "2-1", "2", lines, None, new_report())
|
||||
assert (entry["basis_quantity"], entry["basis_unit"]) == (1.0, "일")
|
||||
@@ -0,0 +1,72 @@
|
||||
"""도쟈 한계거리 칸 (PLAN 5장 · 2026-09-13 판정 「도자 60 m 확정 — 설계 조건이라 칸으로」).
|
||||
|
||||
지키는 것
|
||||
① 안 넣으면 정본 60 m · 넣으면 유토곡선 장비 경계가 그 값 · 종무대 20 m 는 규정이라 안 바뀜
|
||||
② 화면이 기본값과 근거(근거 셋 일치)를 함께 받음
|
||||
③ 종무대 이하 값은 저장에서 막음(도쟈 몫이 사라짐) · null 로 기본값 되돌림
|
||||
④ B06 유토곡선 계산 문맥이 프로젝트 값을 실음
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import B08_Quantity.B08_Quantity_Router_Earthwork as earthwork_router # noqa: E402
|
||||
from B06_Section.B06_Section_Server_Calc_Prebuild import _mass_haul_context # noqa: E402
|
||||
from common_util.common_util_project_settings import ( # noqa: E402
|
||||
haul_equipment_limits,
|
||||
haul_limit_choice,
|
||||
load_settings,
|
||||
)
|
||||
|
||||
PROJECT_ID = "55555555-5555-5555-5555-555555555555"
|
||||
|
||||
|
||||
def test_기본은_60m_넣으면_그_값_종무대는_그대로() -> None:
|
||||
assert haul_equipment_limits({}) == [("free_haul", 20.0), ("dozer", 60.0), ("dump_truck", None)]
|
||||
assert dict(haul_equipment_limits({"dozer_haul_limit_m": 70}))["dozer"] == 70.0
|
||||
assert dict(haul_equipment_limits({"dozer_haul_limit_m": 15}))["dozer"] == 60.0 # 종무대 이하
|
||||
choice = haul_limit_choice({"dozer_haul_limit_m": 70})
|
||||
assert (choice["value"], choice["default"], choice["chosen"]) == (70.0, 60.0, True)
|
||||
assert "8-1-1" in choice["basis"] and "EARTH.DAT" in choice["basis"]
|
||||
assert "1-2-7" in choice["free_haul_basis"]
|
||||
|
||||
|
||||
def test_유토곡선_문맥이_프로젝트_경계를_싣는다() -> None:
|
||||
limits = haul_equipment_limits({"dozer_haul_limit_m": 80})
|
||||
context = _mass_haul_context(None, None, limits)
|
||||
assert {row["key"]: row["max_distance_m"] for row in context["haul_equipment_limits"]} == {
|
||||
"free_haul": 20.0,
|
||||
"dozer": 80.0,
|
||||
"dump_truck": None,
|
||||
}
|
||||
assert _mass_haul_context()["haul_equipment_limits"][1]["max_distance_m"] == 60.0
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
|
||||
async def fake_run(func, *args):
|
||||
return "project"
|
||||
|
||||
monkeypatch.setattr(earthwork_router, "run_with_connection", fake_run)
|
||||
monkeypatch.setattr(earthwork_router, "resolve_stored_project_path", lambda _p: str(tmp_path))
|
||||
app = FastAPI()
|
||||
app.include_router(earthwork_router.router)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_저장은_종무대_이하를_막고_null_로_되돌린다(client: TestClient, tmp_path: Path) -> None:
|
||||
url = f"/api/projects/{PROJECT_ID}/quantity/settings"
|
||||
assert client.put(url, json={"dozer_haul_limit_m": 20}).status_code == 400
|
||||
assert client.put(url, json={"dozer_haul_limit_m": 70}).status_code == 200
|
||||
assert load_settings(tmp_path)["quantity"]["dozer_haul_limit_m"] == 70
|
||||
assert client.put(url, json={"dozer_haul_limit_m": None}).status_code == 200
|
||||
assert load_settings(tmp_path)["quantity"]["dozer_haul_limit_m"] is None
|
||||
@@ -0,0 +1,128 @@
|
||||
"""운반 물량의 **상태** — 내역서 수량은 자연상태다 (2026-09-09).
|
||||
|
||||
「운반거리의 산정 시에 모든 수량은 다짐상태로 환산하여 계산하고, 내역서에 적용하는
|
||||
수량은 자연상태로 한다」(설계실무 요령 — `config_system_design` 5-4-3 인용문).
|
||||
|
||||
⚠ 이 시험이 잠그는 것은 **방향**이다. 곱하면 토사가 0.9배가 되어 뒤집힌다.
|
||||
⚠ `L`(1.3·1.35·1.625)을 쓰지 않는다는 것도 함께 잠근다 — 품셈이 `f = 1/L` 을 스스로 곱한다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import ( # noqa: E402
|
||||
SummaryInput,
|
||||
build_table as build_summary,
|
||||
)
|
||||
from B08_Quantity.B08_Quantity_Engine_HaulSummary import ( # noqa: E402
|
||||
build_table,
|
||||
natural_m3,
|
||||
summary_input_rows,
|
||||
)
|
||||
from config.config_system_design import EARTHWORK_CONVERSION_FACTORS # noqa: E402
|
||||
|
||||
_PLAN = {
|
||||
"blocks": [
|
||||
{
|
||||
"bands": [
|
||||
{
|
||||
"equipment": "dozer",
|
||||
"haul_distance_m": 40.0,
|
||||
"haul_from_m": 0.0,
|
||||
"haul_to_m": 40.0,
|
||||
"ea_m3": 90.0,
|
||||
"rr_m3": 115.0,
|
||||
"br_m3": 0.0,
|
||||
},
|
||||
{
|
||||
"equipment": "dump_truck",
|
||||
"haul_distance_m": 300.0,
|
||||
"haul_from_m": 0.0,
|
||||
"haul_to_m": 300.0,
|
||||
"ea_m3": 0.0,
|
||||
"rr_m3": 0.0,
|
||||
"br_m3": 130.0,
|
||||
},
|
||||
]
|
||||
}
|
||||
],
|
||||
"transfers": [],
|
||||
}
|
||||
|
||||
|
||||
def _row(table: dict, equipment: str, ground: str) -> dict:
|
||||
return next(
|
||||
row for row in table["rows"] if row["equipment"] == equipment and row["ground"] == ground
|
||||
)
|
||||
|
||||
|
||||
def test_나누기다_곱하기가_아니다() -> None:
|
||||
for ground, kind in (("토사", "soil"), ("리핑암", "ripping_rock"), ("발파암", "blasting_rock")):
|
||||
factor = EARTHWORK_CONVERSION_FACTORS[kind]["compacted"]
|
||||
assert natural_m3(100.0, ground) == pytest.approx(100.0 / factor)
|
||||
# 방향이 뒤집히면 이 줄이 잡는다.
|
||||
assert natural_m3(100.0, ground) != pytest.approx(100.0 * factor)
|
||||
# 토사는 늘고(÷0.9) 암은 준다(÷1.15·÷1.30) — 부호가 갈래마다 다르다.
|
||||
assert natural_m3(100.0, "토사") > 100.0
|
||||
assert natural_m3(100.0, "리핑암") < 100.0
|
||||
assert natural_m3(100.0, "발파암") < 100.0
|
||||
|
||||
|
||||
def test_L_은_쓰지_않는다() -> None:
|
||||
"""품셈 10-11·10-12 가 `f = 1/L` 을 스스로 곱하므로 우리가 또 들면 두 번 환산이다."""
|
||||
for ground, loose in (("토사", 1.3), ("리핑암", 1.35), ("발파암", 1.625)):
|
||||
assert natural_m3(100.0, ground) != pytest.approx(100.0 / loose)
|
||||
|
||||
|
||||
def test_갈래를_모르면_환산하지_않는다() -> None:
|
||||
assert natural_m3(100.0, "지반모름") is None
|
||||
assert natural_m3(100.0, "") is None
|
||||
|
||||
|
||||
def test_네_줄이_두_상태를_함께_낸다() -> None:
|
||||
table = build_table(_PLAN)
|
||||
dozer_soil = _row(table, "dozer", "토사")
|
||||
assert dozer_soil["volume_m3"] == pytest.approx(90.0)
|
||||
assert dozer_soil["volume_basis"] == "compacted"
|
||||
assert dozer_soil["natural_m3"] == pytest.approx(100.0) # 90 ÷ 0.90
|
||||
assert dozer_soil["conversion_c"] == pytest.approx(0.90)
|
||||
assert _row(table, "dozer", "리핑암")["natural_m3"] == pytest.approx(100.0) # 115 ÷ 1.15
|
||||
assert _row(table, "dump_truck", "발파암")["natural_m3"] == pytest.approx(100.0) # 130 ÷ 1.30
|
||||
# 거리는 다짐 기준 그대로 — 환산이 거리를 건드리면 안 된다.
|
||||
assert dozer_soil["average_distance_m"] == pytest.approx(40.0)
|
||||
|
||||
|
||||
def test_집계표는_자연상태로_싣는다() -> None:
|
||||
haul = build_table(_PLAN)
|
||||
summary = build_summary(
|
||||
SummaryInput(
|
||||
earthwork_totals={},
|
||||
slope_totals={},
|
||||
haul_rows=summary_input_rows(haul),
|
||||
rock_classes=[],
|
||||
rock_ratios_pct={},
|
||||
application_ratios={},
|
||||
)
|
||||
)
|
||||
rows = [row for row in summary["rows"] if row["group"] in ("도자운반", "덤프운반")]
|
||||
assert rows, "운반 줄이 서야 한다"
|
||||
for row in rows:
|
||||
assert row["amount"] == pytest.approx(100.0), row
|
||||
assert "자연상태 환산" in row["note"]
|
||||
|
||||
|
||||
def test_검산은_다짐상태끼리_한다() -> None:
|
||||
"""환산값으로 검산하면 늘 어긋난다 — 계획이 다짐이기 때문이다."""
|
||||
from B08_Quantity.B08_Quantity_Engine_HaulSummary import check_against_plan
|
||||
|
||||
plan = dict(_PLAN, hauled_m3=335.0, transferred_m3=0.0)
|
||||
check = check_against_plan(build_table(plan), plan)
|
||||
assert check.difference_m3 == pytest.approx(0.0)
|
||||
@@ -0,0 +1,52 @@
|
||||
"""품셈 판 `pum_edition` — 자원·공종 쪽 (명세 17장 규칙 3·4 · 2026-09-13).
|
||||
|
||||
지키는 것
|
||||
① 공종 마스터·자원 축 줄·인계 줄이 모두 자기가 본 판을 싣는다
|
||||
② 판이 다른 인계 줄은 내역서에서 값을 안 쓰고 「확인 대기」로 선다
|
||||
③ 코드에 박힌 절 번호의 판과 마스터 판이 다르면 조립을 멈춘다
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff
|
||||
from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill
|
||||
from B09_Estimation.B09_Estimation_ResourceAxis import build_resource_axis, load_work_item_master
|
||||
from B09_Estimation.B09_Estimation_ResourceAxis_Sources import load_combined_catalog
|
||||
from common_util.common_util_pum_edition import (
|
||||
CODE_PUM_EDITION,
|
||||
PumEditionError,
|
||||
require_code_edition,
|
||||
)
|
||||
|
||||
|
||||
def test_마스터_자원_인계가_모두_판을_싣는다() -> None:
|
||||
master = load_work_item_master()
|
||||
assert master["pum_edition"] == master["effective_date"] == CODE_PUM_EDITION
|
||||
axis = build_resource_axis(master, load_combined_catalog())
|
||||
assert axis.rows and {row.as_dict()["pum_edition"] for row in axis.rows} == {CODE_PUM_EDITION}
|
||||
handoff = build_handoff(
|
||||
summary_table={"rows": [{"group": "흙깎기", "item": "토사", "unit": "㎥", "amount": 5.0}]}
|
||||
)
|
||||
assert handoff["pum_edition"] == CODE_PUM_EDITION
|
||||
assert all(row["pum_edition"] == CODE_PUM_EDITION for row in handoff["work_items"])
|
||||
|
||||
|
||||
def test_판이_다른_인계_줄은_값을_안_쓰고_확인_대기() -> None:
|
||||
handoff = build_handoff(
|
||||
summary_table={"rows": [{"group": "흙깎기", "item": "토사", "unit": "㎥", "amount": 5.0}]}
|
||||
)
|
||||
same = build_bill(handoff)
|
||||
assert any(row.code and row.amount_krw for row in same.rows if not row.is_group)
|
||||
for row in handoff["work_items"]:
|
||||
row["pum_edition"] = "2027-01-01"
|
||||
moved = build_bill(handoff)
|
||||
assert not [row for row in moved.rows if not row.is_group]
|
||||
assert any(m.get("blocked_kind") == "edition_waiting" for m in moved.missing)
|
||||
|
||||
|
||||
def test_코드에_박힌_절_번호는_판이_다르면_멈춘다() -> None:
|
||||
require_code_edition(CODE_PUM_EDITION, "시험")
|
||||
with pytest.raises(PumEditionError):
|
||||
require_code_edition("2027-01-01", "시험")
|
||||
@@ -0,0 +1,94 @@
|
||||
"""기슭막이 등록부 칸 둘 — 뒷길이·돌 종류 (2026-09-09).
|
||||
|
||||
⚠⚠ 왜 있나 — 칸이 없어 `_back_length` 가 **조용히 45㎝ 기본값으로 돌고** 있었다.
|
||||
같은 자리에서 예전에 **키 이름 어긋남 사고**(`stone_back_length_cm` 를 읽어 저장값이
|
||||
영영 안 닿음)가 있었으므로 **이름과 글자를 대조해 잠근다.**
|
||||
⚠ 기본값을 두지 않는다 — 값이 없으면 「기본값으로 섰음」이 드러나야 한다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402
|
||||
BACK_LENGTH_KEYS,
|
||||
STONE_KIND_OPTION,
|
||||
load_stone_kind_table,
|
||||
)
|
||||
from common_util.common_util_drainage_pipes import ( # noqa: E402
|
||||
PipePoint,
|
||||
load_pipe_points,
|
||||
save_pipe_points,
|
||||
)
|
||||
|
||||
REGISTRY = json.loads(
|
||||
(PROJECT_ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
|
||||
def _options(type_id: str) -> dict[str, dict]:
|
||||
entry = next(item for item in REGISTRY["types"] if item["type_id"] == type_id)
|
||||
return {option["key"]: option for option in entry["options"]}
|
||||
|
||||
|
||||
def test_기슭막이에_칸_둘이_있다() -> None:
|
||||
options = _options("revetment")
|
||||
assert "back_len_cm" in options, "뒷길이 칸이 없으면 45㎝ 기본값으로 조용히 돈다"
|
||||
assert STONE_KIND_OPTION in options
|
||||
|
||||
|
||||
def test_엔진이_읽는_이름과_같다() -> None:
|
||||
"""이름이 어긋나면 저장값이 영영 안 닿는다 — 예전 사고 그대로."""
|
||||
options = _options("revetment")
|
||||
assert BACK_LENGTH_KEYS[0] in options
|
||||
assert STONE_KIND_OPTION in options
|
||||
|
||||
|
||||
def test_고를_수_있는_값이_품셈표와_글자까지_같다() -> None:
|
||||
options = _options("revetment")
|
||||
table = load_stone_kind_table()
|
||||
assert options["back_len_cm"]["choices"] == [str(value) for value in table["back_lengths_cm"]]
|
||||
kinds = [key for key in (table.get("backfill_ratio_of_back_length") or {}) if key != "note"]
|
||||
assert options[STONE_KIND_OPTION]["choices"] == kinds
|
||||
|
||||
|
||||
def test_기본값을_두지_않는다() -> None:
|
||||
options = _options("revetment")
|
||||
assert options["back_len_cm"]["default"] is None
|
||||
assert options[STONE_KIND_OPTION]["default"] is None
|
||||
|
||||
|
||||
def test_돌쌓기와_같은_값을_쓴다() -> None:
|
||||
"""돌쌓기(찰·메)와 갈래가 갈리면 같은 돌인데 수량이 달라진다."""
|
||||
for type_id in ("masonry_wet", "masonry_dry"):
|
||||
other = _options(type_id)
|
||||
revet = _options("revetment")
|
||||
assert other["back_len_cm"]["choices"] == revet["back_len_cm"]["choices"]
|
||||
assert other[STONE_KIND_OPTION]["choices"] == revet[STONE_KIND_OPTION]["choices"]
|
||||
|
||||
|
||||
def test_저장했다_되읽으면_그대로_있다() -> None:
|
||||
"""정본 파일을 실제로 오간다 — 관 지점 `options` 에 담겨 나가고 들어온다.
|
||||
|
||||
⚠ 저장 경로는 `storage/` 아래여야 한다(경로 가드). 시험용 자리를 쓰고 지운다.
|
||||
"""
|
||||
stored = "storage/tmp/test_revet_fields"
|
||||
target = PROJECT_ROOT / stored
|
||||
try:
|
||||
point = PipePoint(chainage_m=85.05, source="spacing")
|
||||
point.options = {"back_len_cm": "55", "stone_kind": "견치돌", "facility": "revetment"}
|
||||
assert save_pipe_points(stored, "sig-1", [point]) == 1
|
||||
|
||||
loaded = load_pipe_points(stored, "sig-1")
|
||||
assert loaded is not None and len(loaded) == 1
|
||||
assert loaded[0].options["back_len_cm"] == "55"
|
||||
assert loaded[0].options["stone_kind"] == "견치돌"
|
||||
finally:
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
@@ -0,0 +1,163 @@
|
||||
"""공종 불변 열쇠 **길목** — PLAN_공종축 8-6 이행(2026-09-17 브레인 · 코덱스 조사).
|
||||
|
||||
잣대 = **금액 불변.** 열쇠를 더하는 일이지 값을 바꾸는 일이 아님 —
|
||||
같은 인계로 세운 내역서가 열쇠가 있든(새 인계) 없든(옛 인계) **한 원도** 안 달라야 함.
|
||||
길목은 경계 두 곳뿐(B08 인계 출구 · B09 인계 입구) · `work_item_code` 는 목차 번호로 그대로 남음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
from functools import lru_cache
|
||||
|
||||
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff
|
||||
from B08_Quantity.B08_Quantity_Engine_MaterialSummary import build_table as build_material_table
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table
|
||||
from B09_Estimation.B09_Estimation_BillOfQuantities import bill_summary, build_bill
|
||||
from B09_Estimation.B09_Estimation_BillOfQuantities_Input import parse_handoff
|
||||
from B09_Estimation.B09_Estimation_UnitPrice import build_unit_prices
|
||||
from common_util.common_util_work_item_key import MASTER_DIR, attach_keys, work_item_key
|
||||
|
||||
MAPPING = MASTER_DIR.parent / "data_work_item_mapping" / "work_item_mapping_2026-01-01.json"
|
||||
|
||||
|
||||
def _strip_keys(value):
|
||||
"""옛 인계 — 길목이 더한 칸을 뗌."""
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
k: _strip_keys(v)
|
||||
for k, v in value.items()
|
||||
if k not in ("work_item_key", "unkeyed_work_item_codes")
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [_strip_keys(v) for v in value]
|
||||
return value
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _handoff() -> dict:
|
||||
"""토공·운반·구조물·자재가 다 선 인계 — 금액이 실제로 붙는 줄이 여럿."""
|
||||
unit = build_unit_table(
|
||||
[
|
||||
{
|
||||
"structure_id": "s1",
|
||||
"type_id": "masonry_wet",
|
||||
"start_m": 35.0,
|
||||
"end_m": 45.0,
|
||||
"options": {"height_m": 1.5, "length_m": 10.0},
|
||||
}
|
||||
]
|
||||
)
|
||||
rows = [
|
||||
{"group": "흙깎기", "item": "토사", "spec": "", "unit": "㎥", "amount": 1234.5},
|
||||
{"group": "측구터파기", "item": "토사", "spec": "", "unit": "㎥", "amount": 77.0},
|
||||
{"group": "층따기", "item": "", "spec": "", "unit": "㎥", "amount": 12.0},
|
||||
]
|
||||
haul = {
|
||||
"rows": [
|
||||
{
|
||||
"equipment": "dump_truck",
|
||||
"ground": "토사",
|
||||
"volume_m3": 500.0,
|
||||
"average_distance_m": 1200.0,
|
||||
"in_bill": True,
|
||||
}
|
||||
]
|
||||
}
|
||||
return build_handoff(
|
||||
summary_table={"rows": rows},
|
||||
haul_table=haul,
|
||||
unit_quantity_table=unit,
|
||||
material_table=build_material_table(unit),
|
||||
)
|
||||
|
||||
|
||||
def test_목차_코드는_불변_열쇠로_난수_코드는_그대로() -> None:
|
||||
forest = json.loads(
|
||||
(MASTER_DIR / "work_item_master_2026-01-01.json").read_text(encoding="utf-8")
|
||||
)
|
||||
for item in forest["work_items"]:
|
||||
assert work_item_key(item["work_item_code"], forest["pum_edition"]) == item["work_item_key"]
|
||||
assert work_item_key("FP-09-03-02") == work_item_key("FP-09-03-02", "2026-01-01")
|
||||
assert re.fullmatch(r"CW-\d{5}", work_item_key("CP-01-03-02-01") or "")
|
||||
assert work_item_key("AX-WK-c0842a0d") == "AX-WK-c0842a0d" # 난수 8자리 — 이미 불변
|
||||
assert work_item_key("AX-ST-0123abcd") == "AX-ST-0123abcd"
|
||||
assert work_item_key("FP-09-03-02", "1999-01-01") is None # 판이 다르면 안 줌
|
||||
assert work_item_key("FP-99-99") is None and work_item_key(None) is None
|
||||
|
||||
|
||||
def test_매핑의_코드가_다_열쇠를_받음() -> None:
|
||||
"""B08 이 인계에 싣는 코드의 샘 — 하나라도 못 받으면 그 줄은 열쇠 없이 감."""
|
||||
text = MAPPING.read_text(encoding="utf-8")
|
||||
codes = set(re.findall(r'"((?:FP|CP|AX-WK|AX-ST)-[0-9A-Za-z-]+)"', text))
|
||||
assert len(codes) > 50
|
||||
assert [c for c in sorted(codes) if work_item_key(c, "2026-01-01") is None] == []
|
||||
|
||||
|
||||
def test_B08_인계_출구에서_열쇠가_붙고_목차_코드는_그대로() -> None:
|
||||
handoff = _handoff()
|
||||
coded = [row for row in handoff["work_items"] if row.get("work_item_code")]
|
||||
assert len(coded) >= 5
|
||||
for row in coded:
|
||||
assert row["work_item_key"] == work_item_key(row["work_item_code"], row["pum_edition"])
|
||||
assert row["work_item_code"].startswith(("FP-", "AX-")) # 사람이 보는 번호는 남음
|
||||
assert handoff["unkeyed_work_item_codes"] == []
|
||||
assert all("work_item_key" not in row for row in handoff["materials"]) # 자재는 공종 축 아님
|
||||
|
||||
|
||||
def test_못_찾은_코드는_지어내지_않고_목록() -> None:
|
||||
rows = [
|
||||
{"work_item_code": "FP-99-99"},
|
||||
{"work_item_code": None},
|
||||
{"work_item_code": "FP-09-18"},
|
||||
]
|
||||
assert attach_keys(rows) == ["FP-99-99"]
|
||||
assert rows[0]["work_item_key"] is None and rows[1]["work_item_key"] is None
|
||||
assert rows[2]["work_item_key"].startswith("FW-")
|
||||
|
||||
|
||||
def test_B09_입구는_옛_인계에도_같은_열쇠() -> None:
|
||||
new, _ = parse_handoff(_handoff())
|
||||
old, _ = parse_handoff(_strip_keys(copy.deepcopy(_handoff())))
|
||||
assert [w.work_item_key for w in old] == [w.work_item_key for w in new]
|
||||
assert any(w.work_item_key.startswith("FW-") for w in new)
|
||||
|
||||
|
||||
def test_금액_불변_열쇠가_있든_없든_내역서가_한_원도_안_다름() -> None:
|
||||
"""⭐ 이번 일감의 잣대(브레인) — 새 인계(열쇠 있음)와 옛 인계(열쇠 없음)로 세운 내역서가 같음."""
|
||||
build = build_unit_prices()
|
||||
new = build_bill(copy.deepcopy(_handoff()), build=build)
|
||||
old = build_bill(_strip_keys(copy.deepcopy(_handoff())), build=build)
|
||||
assert bill_summary(new) == bill_summary(old)
|
||||
assert [row.as_dict() for row in new.rows] == [row.as_dict() for row in old.rows]
|
||||
assert [row.as_dict() for row in new.material_rows] == [
|
||||
row.as_dict() for row in old.material_rows
|
||||
]
|
||||
priced = [row for row in new.rows if not row.is_group and row.amount_krw]
|
||||
assert len(priced) >= 3 and new.body_total_krw > 0 # 금액이 실제로 선 줄로 잼
|
||||
|
||||
|
||||
#: ⭐ 이행 **전** 코드(4bfbf98f^ · 길목 없음)로 위 `_handoff()` 를 세워 뜬 금액 — 2026-09-17 랩탑 메인.
|
||||
#: 이행 뒤 코드도 내역서 줄 18·성분·원가계산서 지문까지 한 글자 같았음(옛 두 파일을 따로 얹어 맞댐).
|
||||
#: ⚠ 단가 자료(노임·기계·자재·요율)가 바뀌면 이 수도 바뀜 — 그땐 길목 탓이 아닌지 먼저 볼 것
|
||||
#: (열쇠 있든 없든 같은지 = 위 시험) · 길목 탓이면 고칠 것은 코드, 아니면 다시 뜸.
|
||||
PRE_MIGRATION_KRW = {
|
||||
"material_cost": "1158206",
|
||||
"labor_cost": "4400597",
|
||||
"expense": "1991430",
|
||||
"direct_construction_cost": "5983724",
|
||||
"net_construction_cost": "7550233",
|
||||
"total_cost": "9203637",
|
||||
"grand_total": "10124000",
|
||||
}
|
||||
|
||||
|
||||
def test_금액_불변_이행_전_코드로_뜬_원가계산서와_한_원도_같음() -> None:
|
||||
from B09_Estimation.B09_Estimation_BillOfQuantities import cost_input_from_bill
|
||||
from B09_Estimation.B09_Estimation_Engine_Cost import calculate_cost
|
||||
|
||||
bill = build_bill(copy.deepcopy(_handoff()), build=build_unit_prices())
|
||||
totals = calculate_cost(cost_input_from_bill(bill)).totals
|
||||
assert {key: str(totals[key]) for key in PRE_MIGRATION_KRW} == PRE_MIGRATION_KRW
|
||||
@@ -0,0 +1,632 @@
|
||||
"""공종 축 불변 열쇠 검사 — 2026-09-17 브레인 ①.
|
||||
|
||||
여기서 못박는 것은 하나다. **금액이 움직이면 안 된다.**
|
||||
열쇠를 얹는 일은 값을 바꾸는 일이 아니므로, 새 칸을 떼어내면 옛 벌과 **한 글자도 달라선 안 된다.**
|
||||
그 자리를 지키는 것이 `test_옛_값이_한_글자도_안_바뀜` 의 지문(sha256)이다.
|
||||
|
||||
나머지는 열쇠의 약속 — 안 겹침·꼴·두 번 지어도 같음 · **짐작으로 안 이음** ·
|
||||
계층(`parent_mode`)이 그대로 있음 · 총칙 2장을 지우지 않았음(B09 연료·손료가 읽는다).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import OUT_DIR # noqa: E402
|
||||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Keys import ( # noqa: E402
|
||||
assign_keys,
|
||||
axis_role,
|
||||
empty_registry,
|
||||
assign_variant_keys,
|
||||
format_key,
|
||||
general_provision_roots,
|
||||
load_policy,
|
||||
path_names,
|
||||
toc_edition,
|
||||
variant_display,
|
||||
variant_match,
|
||||
toc_slot,
|
||||
)
|
||||
|
||||
MASTER = OUT_DIR / "work_item_master_2026-01-01.json"
|
||||
|
||||
#: 열쇠를 얹기 **전** 벌(`work_items`)의 지문. 2026-09-17 데스크탑_서브가 뜬 값.
|
||||
#: ⚠ 이 값이 흔들리면 값이 움직였다는 뜻이다 — 지문을 고치지 말고 **원인을 먼저 찾을 것.**
|
||||
#: 2026-09-17 두 번 갱신 — 둘 다 **더하기만**(덮어쓴 것 0 · 사라진 것 0):
|
||||
#: ① 표머리 몫꼴(`인/ha`·`인/100kg`)에서 밑수 18개를 새로 읽음
|
||||
#: ② 형태 미판정 14표를 사람 판정으로 가름(안티그래비티 원문 대조) · 그중 5표는 밑수도 확인
|
||||
#: ③ 원문 전수 대조 60건 중 **원문 괄호가 분명한 16표**의 밑수를 채움
|
||||
#: ⚠ 그 가운데 **하나(F0430)만 값이 바뀜** — 원문이 「1일당」인데 「개」로 서 있던 왜곡을 고침
|
||||
#: ④ 표 아래 `[주]` 글을 `notes` 칸으로 실음(순수 더하기 — 원문 셀·밑수·형태 다 그대로)
|
||||
#: ⑤ 그 `[주]` 추출의 오염을 걷어냄 — 원문이 절 제목을 목록 줄로 적어 제목·다음 절 [주]까지
|
||||
#: 끌어오고 있었음(산림 표 320 → 306 · 건설 1,761 → 1,106)
|
||||
#: ⑥ ⚠ 그 걷어내기가 **진짜 계수 34건까지 잘랐던 것**을 되살림(고철 공제·위험목 100% 가산 …)
|
||||
#: ⑦ 실값인데 「참고」로 빠져 있던 13표를 `sub_requirement` 로 — 값은 실값, 자리는 상위 공종 종속
|
||||
#: ⑨ 원천 벌을 다시 뽑아 지음 — 밑수 238 → 242 · ⚠ 5-26-1·2·3 밑수를 사람 판정 100㎡ → 원문 992㎡ 로 물림
|
||||
#: ⑧ 못 읽던 [주] 세 꼴을 읽게 함 — 별표 줄 · 인용 산출예시 뒤 줄 · **긴 표의 [주]**(줄 수 제한 없앰)
|
||||
#: ⇒ 곱하면 안 되던 줄이 곱할 수 있게 된 것이지, 있던 값이 바뀐 것이 아니다.
|
||||
#: 2026-09-17 랩탑 메인 — 목차 오기 둘을 본문 번호로 바로잡아(브레인 지시) **네 줄만** 바뀌어 새로 뜸
|
||||
#: (옛 f8b8fba1…). 네 줄 모습은 `test_목차_오기는_본문_번호로_바로잡고_열쇠는_그대로` 가 잼 ·
|
||||
#: 미판정·밑수 목록 파일은 한 글자도 안 바뀜.
|
||||
#: 2026-09-17 랩탑 메인 둘째 — 헛단위 뺌(인용을 제목으로 읽은 13-3 표 셋 · 부록 사례 번호 겹침 일곱)으로 다시 뜸
|
||||
#: (옛 be88f730…). 바뀐 줄은 13-3·4-1·4-2·2-1·2-2·3-1·3-2·3-3·13-4-1·13-5-2 의 표 귀속뿐 —
|
||||
#: `test_work_item_master_toc` 가 자리를 잼 · 단가표 대조는 그 파일 머리.
|
||||
#: 2026-09-18 랩탑 메인 — **밑수 읽기를 넓혀** 원문에 있던 두 자리를 더 읽어 다시 뜸(옛 803898a3…).
|
||||
#: 2-2-4 배부식분무기 「(덩굴 약제처리, ha당)」 · 9-11-1 「(단위: 일당)」 — 둘 다 원문 그대로다.
|
||||
#: ⚠ 건설 표 판정층은 **산림이 미판정일 때만** 도므로 산림 형태는 한 글자도 안 바뀌었고,
|
||||
#: 원가계산서(직접공사비 5,983,724)도 그대로다 — 값이 움직인 것이 아니라 **못 읽던 밑수를 읽은 것**이다.
|
||||
#: 2026-09-18 합침(랩탑 메인 · 데스크탑 서브 9f27d619 와 한 점) — 위 넓힘 + 데스크탑 서브의
|
||||
#: F0046·F0068 밑수 되돌림·F0076 형태 판정·원천 벌 재추출을 한 벌로 다시 뽑아 지문이 또 뜸
|
||||
#: (옛 837eb8aa… · 옛 6f223c69…). 양쪽 다 **원문을 더 읽은 것**이고 금액은 그대로다.
|
||||
BASELINE_WORK_ITEMS_SHA = "7919a44eab4ecf9a09edb39f0c4b48f5576caa8c5d491fe6d3e5fb89ba655436"
|
||||
|
||||
#: 열쇠 작업이 새로 얹은 칸. 지문을 잴 때만 떼어낸다.
|
||||
ADDED_FIELDS = (
|
||||
"work_item_key",
|
||||
"toc_code",
|
||||
"toc_number",
|
||||
"toc_edition",
|
||||
"path_name",
|
||||
"axis_role",
|
||||
"variants",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def master() -> dict:
|
||||
return json.loads(MASTER.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _fingerprint(items: list[dict]) -> str:
|
||||
stripped = [{k: v for k, v in it.items() if k not in ADDED_FIELDS} for it in items]
|
||||
blob = json.dumps(stripped, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(blob.encode()).hexdigest()
|
||||
|
||||
|
||||
def test_옛_값이_한_글자도_안_바뀜(master: dict) -> None:
|
||||
"""금액을 낳는 값(표·밑수·형태)이 그대로인가 — 새 칸만 떼고 지문을 맞댄다."""
|
||||
assert _fingerprint(master["work_items"]) == BASELINE_WORK_ITEMS_SHA
|
||||
|
||||
|
||||
def test_모든_줄에_열쇠가_있고_안_겹침(master: dict) -> None:
|
||||
items = master["work_items"]
|
||||
keys = [it["work_item_key"] for it in items]
|
||||
assert all(re.fullmatch(r"FW-\d{5}", k) for k in keys)
|
||||
assert len(set(keys)) == len(items)
|
||||
|
||||
|
||||
def test_목차_번호는_칸으로만_남음(master: dict) -> None:
|
||||
"""`FP-…` 는 지우지 않고 「그 판의 목차 번호」 칸으로 남았는가 — 164 파일이 아직 쓴다."""
|
||||
assert master["policy"]["row_key"] == "work_item_key"
|
||||
assert master["policy"]["toc_is_column_not_key"] is True
|
||||
assert master["toc_edition"] == "산림청고시제2025-82호"
|
||||
for it in master["work_items"]:
|
||||
assert it["toc_code"] == it["work_item_code"]
|
||||
assert it["toc_number"] == it["number"]
|
||||
assert it["toc_edition"] == master["toc_edition"]
|
||||
|
||||
|
||||
def test_장부가_있으면_같은_열쇠가_다시_나옴(master: dict) -> None:
|
||||
"""두 번 지어도 같은가 — 장부를 그대로 두고 다시 발급해 본다."""
|
||||
registry = json.loads((OUT_DIR / "work_item_keys.json").read_text(encoding="utf-8"))
|
||||
before = copy.deepcopy(registry)
|
||||
nodes = [
|
||||
{
|
||||
"work_item_code": it["work_item_code"],
|
||||
"number": it["number"],
|
||||
"name": it["name"],
|
||||
"sort_order": it["sort_order"],
|
||||
}
|
||||
for it in master["work_items"]
|
||||
]
|
||||
newly, _ = assign_keys(nodes, registry, edition=master["toc_edition"])
|
||||
assert newly == [] # 다시 발급하지 않는다
|
||||
assert registry["next_serial"] == before["next_serial"]
|
||||
assert [n["work_item_key"] for n in nodes] == [
|
||||
it["work_item_key"] for it in master["work_items"]
|
||||
]
|
||||
|
||||
|
||||
def test_장부에_없는_코드는_새_열쇠를_받음() -> None:
|
||||
"""⚠ 짐작으로 안 이음 — 이름이 같아도 옛 열쇠를 물려주지 않고 새로 내고 목록에 싣는다."""
|
||||
registry = empty_registry()
|
||||
old = [{"work_item_code": "FP-09-03", "number": "9-3", "name": "토사깍기", "sort_order": 256}]
|
||||
assign_keys(old, registry, edition="산림청고시제2025-82호")
|
||||
# 새 판에서 번호가 밀린 같은 이름의 절
|
||||
new = [{"work_item_code": "FP-09-04", "number": "9-4", "name": "토사깍기", "sort_order": 256}]
|
||||
newly, _ = assign_keys(new, registry, edition="산림청고시제9999-99호")
|
||||
assert new[0]["work_item_key"] != old[0]["work_item_key"]
|
||||
assert [n["work_item_key"] for n in newly] == [new[0]["work_item_key"]]
|
||||
|
||||
|
||||
def test_열쇠_차례는_목차_차례() -> None:
|
||||
registry = empty_registry()
|
||||
nodes = [
|
||||
{"work_item_code": "FP-02", "number": "2", "name": "둘", "sort_order": 512},
|
||||
{"work_item_code": "FP-01", "number": "1", "name": "하나", "sort_order": 256},
|
||||
]
|
||||
assign_keys(nodes, registry, edition="e")
|
||||
assert nodes[1]["work_item_key"] == format_key(1)
|
||||
assert nodes[0]["work_item_key"] == format_key(2)
|
||||
|
||||
|
||||
def test_목차_오기는_본문_번호로_바로잡고_열쇠는_그대로(master: dict) -> None:
|
||||
"""⚠ 품셈 목차가 12-17-2·12-24-1 을 두 번 적었음 — 본문은 12-17-3 무근진동기 제외 · 12-27-1 지수판 설치
|
||||
(2026-09-17 코덱스 원문 대조). 목차를 믿으면 뒤 줄이 앞 표를 덮어써 혼성 줄이 서고 F0364·F0375 가 사라짐.
|
||||
열쇠는 같은 공종이라 그대로(장부에 손으로 이음 · `relinked`)."""
|
||||
assert master["toc_duplicate_rows"] == []
|
||||
assert [(c["toc_number"], c["body_number"]) for c in master["toc_corrections"]] == [
|
||||
("12-17-2", "12-17-3"),
|
||||
("12-24-1", "12-27-1"),
|
||||
]
|
||||
by_key = {it["work_item_key"]: it for it in master["work_items"]}
|
||||
for key, number, name, tables in (
|
||||
("FW-00387", "12-17-2", "철근, 펌프카 0-15m", ["F0363"]),
|
||||
("FW-00388", "12-17-3", "무근진동기 제외", ["F0364"]),
|
||||
("FW-00396", "12-24-1", "뒷채움", ["F0371"]),
|
||||
("FW-00401", "12-27-1", "지수판 설치", ["F0375"]),
|
||||
):
|
||||
it = by_key[key]
|
||||
assert (it["number"], it["name"], [t["pum_table_id"] for t in it["tables"]]) == (
|
||||
number,
|
||||
name,
|
||||
tables,
|
||||
)
|
||||
assert not {o["pum_table_id"] for o in master["orphan_tables"]} & {"F0364", "F0375"}
|
||||
|
||||
|
||||
def test_슬롯_이름() -> None:
|
||||
assert toc_slot("FP-12-17-02", 1) == "FP-12-17-02"
|
||||
assert toc_slot("FP-12-17-02", 2) == "FP-12-17-02#2"
|
||||
|
||||
|
||||
def test_잎_경로_이름이_안_겹침(master: dict) -> None:
|
||||
"""「인력」 3곳 · 「수확」 6곳처럼 겹치던 잎 이름이 경로 이름으로 갈리는가."""
|
||||
items = master["work_items"]
|
||||
has_kid = {it.get("parent_code") for it in items}
|
||||
leaves = [it for it in items if it["work_item_code"] not in has_kid and it["tables"]]
|
||||
names = [it["name"] for it in leaves]
|
||||
paths = [it["path_name"] for it in leaves]
|
||||
assert len(set(names)) < len(names) # 이름만으로는 겹친다 — 경로가 필요한 까닭
|
||||
assert len(set(paths)) == len(paths)
|
||||
사람 = next(it for it in items if it["work_item_code"] == "FP-09-03-01")
|
||||
assert 사람["path_name"] == "토공 › 토사깍기 › 인력"
|
||||
|
||||
|
||||
def test_계층과_계산_규칙은_그대로(master: dict) -> None:
|
||||
"""`parent_mode` 가 계산 규칙을 지닌다 — 묶는 마디를 지우지 않았는가."""
|
||||
items = master["work_items"]
|
||||
modes = [it.get("parent_mode") for it in items]
|
||||
assert modes.count("choose_one") == 91
|
||||
assert modes.count("sum_steps") == 3
|
||||
발파암 = next(it for it in items if it["work_item_code"] == "FP-09-05")
|
||||
assert [s["code"] for s in 발파암["steps"]] == ["FP-09-05-01", "FP-09-05-02", "FP-09-05-03"]
|
||||
|
||||
|
||||
def test_총칙은_지우지_않고_구실로_가름(master: dict) -> None:
|
||||
"""⚠ `B09_Estimation_Consumables` 가 총칙 2장 표를 읽는다 — 지우면 연료·손료가 사라진다."""
|
||||
by_code = {it["work_item_code"]: it for it in master["work_items"]}
|
||||
for code, table_id in (
|
||||
("FP-02-01-01", "F0042"), # 체인톱 보통휘발유
|
||||
("FP-02-02-01", "F0064"), # 체인톱 손료계수
|
||||
("FP-02-02-06", "F0069"), # 양수기 손료계수
|
||||
):
|
||||
node = by_code[code]
|
||||
assert node["axis_role"] == "general_provision"
|
||||
assert table_id in [t["pum_table_id"] for t in node["tables"]]
|
||||
roles = master["stats"]["axis_roles"]
|
||||
assert sum(roles.values()) == len(master["work_items"])
|
||||
assert roles["general_provision"] == 78
|
||||
|
||||
|
||||
def test_구실_가름_규칙() -> None:
|
||||
assert (
|
||||
axis_role({"work_item_code": "FP-01-02", "tables": []}, has_children=True)
|
||||
== "general_provision"
|
||||
)
|
||||
assert (
|
||||
axis_role({"work_item_code": "FP-09-03-01", "tables": [{}]}, has_children=False)
|
||||
== "work_item"
|
||||
)
|
||||
assert axis_role({"work_item_code": "FP-09-03", "tables": []}, has_children=True) == "group"
|
||||
assert axis_role({"work_item_code": "FP-09-01", "tables": []}, has_children=False) == "empty"
|
||||
|
||||
|
||||
def test_총칙_범위는_코드가_아니라_자료가_정함() -> None:
|
||||
"""⚠ 뿌리를 코드에 박으면 관리자가 못 고친다 — `axis_policy.json` 이 정본."""
|
||||
assert general_provision_roots("forest") == ("FP-01", "FP-02")
|
||||
const = load_policy("const")
|
||||
assert const["key_prefix"] == "CW"
|
||||
assert const["settled"] is True # 2026-09-17 안티그래비티 조사 · 브레인 확정
|
||||
# 건설 뿌리는 **장 파일 이름**으로 적는다 — 뽑는 코드가 목차 코드로 바꿔 준다(랩탑 메인 `file_place`).
|
||||
# ⚠ 바뀐 코드에는 부문 자리가 들어간다(`CP-01-01`) — 부문마다 장 번호가 1부터 다시 시작해서다.
|
||||
assert const["general_provision_roots"] == [
|
||||
"01_공통부문/제1장_적용기준.md",
|
||||
"01_공통부문/제8장_건설기계.md", # 산림 FP-02(소요재료·기계손료)와 짝
|
||||
]
|
||||
# 유지관리 제1장 「공 통」은 총칙이 아니라 **공종** — 미확정 목록이 비었다
|
||||
assert const["general_provision_pending"] == []
|
||||
# 뿌리를 바꿔 주면 구실도 따라 바뀐다 — 잣대가 자료에 있다는 증거
|
||||
assert (
|
||||
axis_role({"work_item_code": "FP-09-03", "tables": []}, has_children=True, roots=("FP-09",))
|
||||
== "general_provision"
|
||||
)
|
||||
|
||||
|
||||
def test_건설_총칙_뿌리가_실제로_붙음() -> None:
|
||||
"""잣대가 건설 산출에 닿았는가 — 뿌리 아래 37줄이 `general_provision` 으로 섰다."""
|
||||
doc = json.loads(
|
||||
(OUT_DIR / "const_work_item_master_2026-01-01.json").read_text(encoding="utf-8")
|
||||
)
|
||||
roles = doc["stats"]["axis_roles"]
|
||||
assert roles["general_provision"] == 112 # 적용기준 37 + 건설기계 75
|
||||
assert sum(roles.values()) == len(doc["work_items"]) == 1367
|
||||
for root, count in (("CP-01-01", 37), ("CP-01-08", 75)):
|
||||
under = [
|
||||
it
|
||||
for it in doc["work_items"]
|
||||
if it["work_item_code"] == root or it["work_item_code"].startswith(root + "-")
|
||||
]
|
||||
assert len(under) == count
|
||||
assert {it["axis_role"] for it in under} == {"general_provision"}
|
||||
# 유지관리 제1장 「공 통」은 공종으로 남는다(총칙 아님)
|
||||
공통 = next(it for it in doc["work_items"] if it["work_item_code"] == "CP-05-01")
|
||||
assert 공통["axis_role"] != "general_provision"
|
||||
|
||||
|
||||
def test_갈래에도_불변_열쇠가_있다(master: dict) -> None:
|
||||
"""갈래 열쇠는 `FW-00105#01` — **글자가 아니라 번호**다(2026-09-17 브레인).
|
||||
|
||||
글자를 열쇠에 담으면 원문 자간·물결표가 다듬어지는 날 열쇠가 갈린다. 공종 열쇠와 같은 병이다.
|
||||
"""
|
||||
items = master["work_items"]
|
||||
keys, pairs = [], []
|
||||
for it in items:
|
||||
for v in it.get("variants") or []:
|
||||
assert re.fullmatch(r"\d{2}", v["variant_key"]), v
|
||||
keys.append(f"{it['work_item_key']}#{v['variant_key']}")
|
||||
pairs.append((v["name"], v["name_clean"]))
|
||||
# 2026-09-18 합침 332 → 337. 다른 창이 원문 md 의 뭉친 표를 줄마다 풀자 읽는 길이 한동안
|
||||
# 못 따라가 268 까지 떨어졌다 — **풀린 줄을 도로 뭉쳐 읽게** 고쳐 되돌렸고(축 셋 · 뭉친 줄),
|
||||
# 덤으로 6-7-1 교목 시비 다섯이 새로 섰다. 사라진 갈래 0.
|
||||
assert len(keys) == 337 # 갈래 수
|
||||
assert len(set(keys)) == len(keys) # 겹침 0
|
||||
# 한 공종 안에서 번호는 **오름차순 · 안 겹침**. ⚠ 1부터 이어 붙지는 않는다 —
|
||||
# 갈래가 사라지면 그 번호는 **비워 둔 채 다시 안 쓴다**(옛 일위대가가 가리키던 자리).
|
||||
# 2026-09-18 11-1 콘테이너가 01~20 을 잃고 21~25 를 받은 것이 그 자리다.
|
||||
for it in items:
|
||||
got = [v["variant_key"] for v in it.get("variants") or []]
|
||||
assert got == sorted(set(got)), it["work_item_key"]
|
||||
# 이름 칸 둘 — 원문은 손대지 않고, 다듬은 것이 따로
|
||||
raw = [it.get("variant_keys") or [] for it in items]
|
||||
assert [n for it in items for n in (it.get("variant_keys") or [])] == [p[0] for p in pairs]
|
||||
assert sum(1 for a, b in pairs if a != b) == 70 # 자간·물결표가 다듬어진 줄
|
||||
assert raw # 원문 목록은 그대로 남아 있다
|
||||
|
||||
|
||||
def test_갈래_열쇠는_글자가_바뀌어도_안_흔들린다() -> None:
|
||||
"""맞대는 꼴이 공백·물결표를 흡수한다 — 이름 다듬기가 들어와도 번호가 그대로다."""
|
||||
reg = empty_registry()
|
||||
node = {
|
||||
"work_item_key": "FW-00105",
|
||||
"sort_order": 1,
|
||||
"variant_keys": ["1.1∼1.5", "간 단"],
|
||||
}
|
||||
assign_variant_keys([node], reg)
|
||||
before = [v["variant_key"] for v in node["variants"]]
|
||||
# 원문을 다듬어도(물결표 바꿈 · 자간 정리) 같은 번호가 나와야 한다
|
||||
node2 = {"work_item_key": "FW-00105", "sort_order": 1, "variant_keys": ["1.1~1.5", "간 단"]}
|
||||
newly = assign_variant_keys([node2], reg)
|
||||
assert [v["variant_key"] for v in node2["variants"]] == before
|
||||
assert newly == [] # 새 번호를 내지 않는다
|
||||
assert variant_match("간 단") == variant_match("간 단") == "간단"
|
||||
assert variant_display("직경 60㎝이상 ~80㎝미만") == "직경 60㎝이상 ∼80㎝미만"
|
||||
|
||||
|
||||
def test_갈래_문자열이_열쇠라_흔들림을_못박음(master: dict) -> None:
|
||||
"""⚠ 갈래가 줄 열쇠에 그대로 들어간다(`@id` = `FW-00105#1.1∼1.5`).
|
||||
|
||||
품셈 원문이 물결표를 두 가지로 쓰고 자간 공백도 들쭉날쭉이라, 문자열을 손질하면 **열쇠가 갈린다.**
|
||||
지금 몇 개가 그런지 못박아 둔다 — 늘면 손질이 들어간 것이고, 줄면 열쇠가 바뀐 것이다.
|
||||
"""
|
||||
import collections
|
||||
|
||||
keys = [k for it in master["work_items"] for k in (it.get("variant_keys") or [])]
|
||||
assert len(keys) == 337 # 2026-09-18 합침 — 6-7-1 교목 시비 다섯이 새로 섬(332 → 337)
|
||||
tilde = collections.Counter(ch for k in keys for ch in k if ch in "∼~")
|
||||
# 2026-09-18 합침 26 → 30 — 새로 선 6-7-1 교목 시비 다섯 가운데 넷이 「∼」를 쓴다
|
||||
assert tilde == {"∼": 30, "~": 8}, "물결표 쓰임이 바뀜 — 열쇠가 갈렸는지 볼 것"
|
||||
squeezed = collections.defaultdict(set)
|
||||
for k in keys:
|
||||
squeezed["".join(k.split()).replace("~", "∼")].add(k)
|
||||
messy = {n: v for n, v in squeezed.items() if len(v) > 1}
|
||||
assert len(messy) == 6, f"공백·물결표만 다른 짝이 {len(messy)} — 6 이던 것"
|
||||
|
||||
|
||||
def test_산림_형태_미판정이_없다(master: dict) -> None:
|
||||
"""2026-09-17 안티그래비티 원문 대조로 마지막 14표를 가름 — 미판정이 0 이 됐다.
|
||||
|
||||
⚠ 형태가 뒤집히면 값이 20배쯤 조용히 틀린다. 미판정으로 남겨 두는 편이 안전했으나,
|
||||
14표 모두 **금액 대상**이라 비워 두면 그 공종이 통째로 안 선다.
|
||||
"""
|
||||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Forms import BASIS_JUDGMENTS, judged_form
|
||||
|
||||
forms = [t["pum_form"] for it in master["work_items"] for t in it["tables"]]
|
||||
assert "undetermined" not in forms
|
||||
assert master["stats"]["form_undetermined"] == 0
|
||||
# 열넷이 실제로 사람 판정으로 섰는가
|
||||
가른것 = {
|
||||
"F0140": "requirement", "F0141": "requirement", "F0142": "requirement",
|
||||
"F0195": "productivity", "F0248": "coefficient", "F0322": "requirement",
|
||||
"F0323": "requirement", "F0333": "requirement", "F0346": "requirement",
|
||||
"F0169": "productivity", "F0304": "requirement", "F0356": "productivity",
|
||||
"F0257": "coefficient", "F0349": "requirement",
|
||||
} # fmt: skip
|
||||
by_id = {t["pum_table_id"]: t for it in master["work_items"] for t in it["tables"]}
|
||||
for table_id, form in 가른것.items():
|
||||
assert by_id[table_id]["pum_form"] == form, table_id
|
||||
assert by_id[table_id]["form_basis"].startswith("사람 판정"), table_id
|
||||
# 미판정 열넷 가운데 머리가 소실돼 자동으로는 못 읽는 표의 밑수를 손으로 적었다
|
||||
# (그 뒤 원문 전수 대조로 다른 표들도 들어와 `BASIS_JUDGMENTS` 는 더 넓다)
|
||||
assert {"F0140", "F0322", "F0333", "F0346"} <= set(BASIS_JUDGMENTS)
|
||||
assert by_id["F0140"]["basis_quantity"] == 992.0 and by_id["F0140"]["basis_unit"] == "㎡"
|
||||
assert by_id["F0322"]["basis_quantity"] == 1000.0 and by_id["F0322"]["basis_unit"] == "본"
|
||||
assert judged_form("F0140", "5-26-1")[0] == "requirement"
|
||||
|
||||
|
||||
def test_표_아래_주석이_실린다(master: dict) -> None:
|
||||
"""`[주]` 에 **금액에 바로 닿는 조건**이 적혀 있는데 마스터가 그 글을 안 싣고 있었다.
|
||||
|
||||
발파 가산 20% · 고철 공제 50kg/㎥ · 공구손료 3% · 고소 감속 9% — 받는 쪽이 볼 길이 없었다
|
||||
(2026-09-17 안티그래비티 전수 대조 ④). ⚠ **읽기만 싣는다 — 여기서 셈하지 않는다.**
|
||||
"""
|
||||
by_id = {t["pum_table_id"]: t for it in master["work_items"] for t in it["tables"]}
|
||||
쓰인것 = [t for t in by_id.values() if t.get("notes")]
|
||||
assert len(쓰인것) == 309
|
||||
발파 = " ".join(by_id["F0245"]["notes"])
|
||||
assert "인건비의 20%를 가산" in 발파
|
||||
유로폼 = " ".join(by_id["F0395"]["notes"])
|
||||
assert "인력품의 3%" in 유로폼
|
||||
# ⚠ **남의 글을 물어 오지 않는다**(2026-09-17 랩탑 서브 전수 확인으로 드러난 오염을 고침)
|
||||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Table import (
|
||||
is_bare_title,
|
||||
trim_page_break,
|
||||
)
|
||||
|
||||
줄 = [n for t in 쓰인것 for n in t["notes"]]
|
||||
assert all(not n.startswith("#") for n in 줄)
|
||||
# 원문이 절 제목을 목록 줄로 적는다 — 「- 3. 노상 및 노반재료」·「- 라. 체적환산계수(f)표」
|
||||
assert not [n for n in 줄 if is_bare_title(n)]
|
||||
assert is_bare_title("- 3. 노상 및 노반재료")
|
||||
assert not is_bare_title("- ② 장비는 무한궤도 굴착기(0.7㎥)를 적용한다.")
|
||||
# 쪽이 넘어가며 줄 **끝에** 눌어붙은 쪽번호·장 제목도 뗀다
|
||||
assert trim_page_break("- ⑥ 적재운반 적하는 1인을 기준으로 한다.15제1장 적용기준") == (
|
||||
"- ⑥ 적재운반 적하는 1인을 기준으로 한다."
|
||||
)
|
||||
# 제 [주] 가 없는 표가 제목 건너 남의 [주] 를 빌려오지 않는다(F0009 는 강재류 것을 빌려왔었다)
|
||||
by_id2 = {t["pum_table_id"]: t for it in master["work_items"] for t in it["tables"]}
|
||||
assert not by_id2["F0009"].get("notes")
|
||||
|
||||
|
||||
def test_잘려_나갔던_진짜_계수가_살아_있다(master: dict) -> None:
|
||||
"""⚠ [주] 오염을 걷다가 **있어야 할 계수까지 잘렸던** 자리(2026-09-18 2바퀴 검사 34건).
|
||||
|
||||
까닭 둘 — 「> 【산출 예시】」 인용이 [주] 중간에 끼면 거기서 끊었고,
|
||||
「- 4. 고철공제 : A=…」처럼 **번호를 달고 이어지는 진짜 규칙**을 절 제목으로 보고 잘랐다.
|
||||
⇒ 잣대는 **지우는 것보다 남기는 것** — 알맹이(콜론·숫자) 없는 짧은 줄만 제목으로 본다.
|
||||
"""
|
||||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Table import is_bare_title
|
||||
|
||||
by_id = {t["pum_table_id"]: t for it in master["work_items"] for t in it["tables"]}
|
||||
for table_id, 낱말 in (
|
||||
("F0252", "고철공제"), # 50kg/㎥ 공제
|
||||
("F0088", "산림병해충"), # 인력 제거 시 보통인부 100% 가산
|
||||
("F0086", "관목류"), # 보통인부 30% 할증
|
||||
("F0191", "잔존목"), # 주행·잔존목 할증
|
||||
):
|
||||
assert 낱말 in " ".join(by_id[table_id]["notes"]), table_id
|
||||
# 알맹이 없는 제목만 자른다 — 규칙을 담은 번호 줄은 남긴다
|
||||
assert is_bare_title("- 3. 노상 및 노반재료")
|
||||
assert is_bare_title("- 라. 체적환산계수(f)표")
|
||||
assert not is_bare_title("- 4. 고철공제 : A=1㎥×0.008×7,850kg/㎥×80%")
|
||||
assert not is_bare_title("- 2. 철근절단 : 철근콘크리트(T=30㎝ 미만)품을 적용한다.")
|
||||
|
||||
|
||||
def test_실값인데_참고로_빠져_있던_표(master: dict) -> None:
|
||||
"""⚠ `requirement` 로 올리지 않는다 — 상위 공종이 이미 세는 몫을 두 번 세게 된다.
|
||||
|
||||
`reference` 로 두면 금액이 0원으로 증발하고, `requirement` 면 이중 계상된다.
|
||||
그래서 `sub_requirement`(값은 실값 · 자리는 상위 공종 종속)로 둔다(2026-09-18 안티그래비티 권고).
|
||||
"""
|
||||
by_id = {t["pum_table_id"]: t for it in master["work_items"] for t in it["tables"]}
|
||||
실값 = [k for k, t in by_id.items() if t["pum_form"] == "sub_requirement"]
|
||||
assert len(실값) == 13
|
||||
assert {"F0369", "F0386", "F0393", "F0408", "F0410"} <= set(실값)
|
||||
# 설계 치수·조건 분류표 일곱은 그대로 참고다
|
||||
for table_id in ("F0396", "F0400", "F0406", "F0411", "F0412", "F0413", "F0446"):
|
||||
assert by_id[table_id]["pum_form"] == "reference", table_id
|
||||
|
||||
|
||||
def test_사람_판정은_원문이_나오면_물러난다(master: dict) -> None:
|
||||
"""⚠ 5-26-1·2·3 — 앞서 100㎡ 로 메워 둔 밑수를 **원문 「(인/992㎡당)」** 으로 물렸다.
|
||||
|
||||
머리가 소실돼 원문을 못 읽던 때 흔한 밑수로 메운 값이었다. 이 고리의 잣대는 「원문이 정본」이므로
|
||||
사람 판정이 물러난다(2026-09-18 브레인 · 코덱스가 PDF 에서 되살린 줄).
|
||||
⚠ 100 과 992 는 열 배 가까이 달라 **금액이 크게 갈리는 자리**다 — 되돌릴 때도 근거를 보고 할 것.
|
||||
"""
|
||||
by_id = {t["pum_table_id"]: t for it in master["work_items"] for t in it["tables"]}
|
||||
for table_id in ("F0140", "F0141", "F0142"):
|
||||
assert by_id[table_id]["basis_quantity"] == 992.0, table_id
|
||||
assert by_id[table_id]["basis_unit"] == "㎡", table_id
|
||||
assert "992" in by_id[table_id]["basis_source"], table_id
|
||||
|
||||
|
||||
def test_밑수_미확보를_둘로_가른다(master: dict) -> None:
|
||||
"""⚠ 「원문 부재」는 **채울 것이 아니다** — 없는 값을 지어내면 안 된다.
|
||||
|
||||
미확보 87 중 **75 는 원문에 밑수가 아예 없고**, **12 만 원문이 적었는데 우리가 못 읽은 것**이다
|
||||
(2026-09-18 안티그래비티 3바퀴 · 브레인). 목록에서 갈라 두면 다음 바퀴에 75 가 또
|
||||
「할 일」로 세어지지 않는다.
|
||||
"""
|
||||
import json as _json
|
||||
from pathlib import Path as _Path
|
||||
|
||||
stats = master["stats"]
|
||||
assert stats["basis_missing"] == 79
|
||||
# 2026-09-18 합침 70 → 71 → 78 — **거절 자리를 「채울 것 아님」 쪽으로 옮겼다.**
|
||||
# 원문이 적은 것이 「(인/일)」·「㎥/hr」·「회당」이면 그것은 값의 단위이지 밑수가 아니다.
|
||||
assert stats["basis_absent_in_source"] == 78
|
||||
# 2026-09-18 「당」 없이 몫으로만 적힌 자리(「(인/100㎡)」·「(대/ton)」)를 가리게 해 다섯을 더 채움.
|
||||
# 미판독 12 → 9 → 10(가림이 넓어져 새로 드러난 것 포함) · 확보 243 → 251.
|
||||
# ⚠ 남은 아홉은 **거절 자리**다 — 「(단위 : 인)」 셋(2026-09-09 오독 판정) ·
|
||||
# 「㎥/1인/1일」·「1ha당, 100본당」·「1대 1조, 시간당」·「인 당」은 분모가 둘 이상이다.
|
||||
# 2026-09-18 10 → 8 — **표 머리 칸까지 읽게 하자** 8-6-2·8-6-3 드론방제 「ha당」 둘이 채워짐
|
||||
# 2026-09-18 8 → 1 — 거절 자리(빗금 단위·회당·밑수 둘)를 갈라내니 **진짜 할 일은 하나**다
|
||||
# (F0237 「1대 1조, 시간당」 — 무엇이 밑수인지 사람이 봐야 함).
|
||||
assert stats["basis_unparsed"] == 1
|
||||
assert stats["basis_absent_in_source"] + stats["basis_unparsed"] == stats["basis_missing"]
|
||||
items = _json.loads(
|
||||
_Path("resources/data_work_item_master/basis_missing_2026-01-01.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)["items"]
|
||||
assert all(i["why"] for i in items) # 사유 없는 줄이 없어야 한다
|
||||
# 「공㎥」 처럼 단위 목록에 없던 자리는 사람 판정으로 채웠다 — 목록을 넓히면 규격이 밑수가 된다
|
||||
by_id = {t["pum_table_id"]: t for it in master["work_items"] for t in it["tables"]}
|
||||
assert by_id["F0366"]["basis_unit"] == "공㎥"
|
||||
for table_id in ("F0050", "F0052", "F0224", "F0090"):
|
||||
assert by_id[table_id]["basis_quantity"] == 1.0 and by_id[table_id]["basis_unit"] == "ha"
|
||||
# 「당」 글자 없이 몫으로만 적힌 자리도 읽는다 — 「(인/100㎡)」·「(대/ton)」
|
||||
assert by_id["F0089"]["basis_quantity"] == 100.0 and by_id["F0089"]["basis_unit"] == "㎡"
|
||||
assert by_id["F0191"]["basis_unit"] == "톤"
|
||||
|
||||
|
||||
def test_밑수가_아닌_글을_밑수로_안_읽는다(master: dict) -> None:
|
||||
"""⚠ 비고의 「1인당」은 **기계 한 대에 붙는 인원** 말이지 공종 밑수가 아니다.
|
||||
|
||||
2026-09-18 안티그래비티 PDF 대조 — 「인은 밑수가 아니다」는 2026-09-09 판정이 옳았고
|
||||
**파서가 틀린 것**이었다(제근 9-21 때와 반대 — 그때는 판정이 물러났다).
|
||||
⇒ 사람 판정으로 **비워 두고**, 미확보 목록에 「원문 부재」 사유를 달아 다음 바퀴에 또 안 세어지게 한다.
|
||||
"""
|
||||
import json as _json
|
||||
from pathlib import Path as _Path
|
||||
|
||||
by_id = {t["pum_table_id"]: t for it in master["work_items"] for t in it["tables"]}
|
||||
for table_id in ("F0046", "F0068"):
|
||||
assert by_id[table_id]["basis_quantity"] is None, table_id
|
||||
assert by_id[table_id]["basis_unit"] is None, table_id
|
||||
items = {
|
||||
i["pum_table_id"]: i
|
||||
for i in _json.loads(
|
||||
_Path("resources/data_work_item_master/basis_missing_2026-01-01.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)["items"]
|
||||
}
|
||||
assert items["F0046"]["why"].startswith("원문 부재") # 집계가 이 낱말로 가른다
|
||||
assert "F0068" not in items # 계수표라 애초에 목록에 안 든다
|
||||
# 3-2 작업로 선정은 목적물 1km 가 **정당한 밑수**다 — 값은 소요인력이라 소요량형
|
||||
assert by_id["F0076"]["pum_form"] == "requirement"
|
||||
assert by_id["F0076"]["basis_quantity"] == 1.0 and by_id["F0076"]["basis_unit"] == "km"
|
||||
|
||||
|
||||
def test_원문_대조_밑수_판정(master: dict) -> None:
|
||||
"""원문 md ↔ 마스터 전수 대조(안티그래비티 02544795)에서 나온 밑수를 반영했는가.
|
||||
|
||||
⚠ **보고가 적은 단위를 그대로 믿지 않는다.** 보고는 「인」을 밑수로 적은 자리가 셋 있었고
|
||||
그 자리(8-6-2 드론방제·8-6-3 지상방제)는 2026-09-09 에 오독으로 판정된 바로 그 표다.
|
||||
원문 줄을 열어 괄호 글을 확인한 것만, 그것도 **분모가 하나일 때만** 싣는다.
|
||||
"""
|
||||
by_id = {t["pum_table_id"]: t for it in master["work_items"] for t in it["tables"]}
|
||||
for table_id, quantity, unit in (
|
||||
("F0082", 1.0, "ha"), # (단위 : 인/ha)
|
||||
("F0311", 1.0, "기"), # (단위: 1기당)
|
||||
("F0424", 1.0, "㎡"), # (단위: 인/㎡)
|
||||
("F0430", 1.0, "일"), # ⚠ 왜곡 고침 — 「개」로 서 있었음
|
||||
):
|
||||
assert by_id[table_id]["basis_quantity"] == quantity, table_id
|
||||
assert by_id[table_id]["basis_unit"] == unit, table_id
|
||||
# 「인」은 밑수가 아니다 — 보고가 그렇게 적었어도 안 넣는다
|
||||
for table_id in ("F0228", "F0229", "F0230"):
|
||||
assert by_id[table_id]["basis_unit"] != "인", table_id
|
||||
# ⚠ 3-2 작업로 선정은 2026-09-18 PDF 대조로 **목적물 1km 가 정당**함이 확인됐다(반쪽 아님)
|
||||
assert by_id["F0076"]["basis_unit"] == "km"
|
||||
# ⚠ **작업조 표는 밑수를 비워 둔다** — 원문이 「(단위: 일 당)」이라도 넣지 않는다.
|
||||
# 밑수가 시공량 열이라 「1일」을 박으면 그 길이 막혀 호표가 통째로 사라진다
|
||||
# (2026-09-17 유로폼 12-38-3 에서 실제로 사라져 드러남).
|
||||
for table_id in ("F0339", "F0340", "F0341", "F0395", "F0449"):
|
||||
assert by_id[table_id]["crew_table"] is True, table_id
|
||||
assert by_id[table_id]["basis_unit"] is None, table_id
|
||||
|
||||
|
||||
def test_표머리_몫꼴_밑수_읽기() -> None:
|
||||
"""「당」 없이 몫으로만 적힌 표머리에서 밑수를 읽는다 — 분모가 밑수다(명세 꼴 B).
|
||||
|
||||
⚠ 반쪽만 읽느니 「미확보」가 낫다 — 분모가 둘이거나(`ℓ/일,대`) 표머리끼리 어긋나면 거절한다.
|
||||
"""
|
||||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Table import basis_from_header, detect_basis
|
||||
|
||||
assert basis_from_header("(단위 : 인/ha)") == (1.0, "ha")
|
||||
assert basis_from_header("소요인력 (인/100kg)") == (100.0, "kg")
|
||||
assert basis_from_header("소요인력(인/본당)") == (1.0, "본")
|
||||
assert basis_from_header("재료비(1km 소요량기준)") == (1.0, "km")
|
||||
assert basis_from_header("주연료 (ℓ/일,대)") == (None, None) # 분모가 둘 — 거절
|
||||
assert basis_from_header("기계명(주재료)") == (None, None) # 몫이 아님
|
||||
# 표머리끼리 어긋나면 표 전체를 미확보로 둔다(건설 8장 기계경비표)
|
||||
표 = {"headers": ["분류번호", "주연료 (ℓ/hr)", "조종원 (인/일)"], "rows": []}
|
||||
assert detect_basis(표) == (None, None)
|
||||
# 하나로 모이면 읽는다
|
||||
표2 = {"headers": ["구 분", "정리산물(㎥/ha)", "비고"], "rows": []}
|
||||
assert detect_basis(표2) == (1.0, "ha")
|
||||
|
||||
|
||||
def test_밑수_채움은_더하기만_함(master: dict) -> None:
|
||||
"""⚠ 이미 있던 밑수를 덮지 않는다 — 덮으면 금액이 조용히 움직인다.
|
||||
|
||||
표 안 칸(`BASIS_RE`)이 먼저고, 표머리 몫꼴은 **그 뒤**에만 본다.
|
||||
"""
|
||||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Table import detect_basis
|
||||
|
||||
표 = {"headers": ["(단위 : 인/ha)", "100㎡당"], "rows": []}
|
||||
assert detect_basis(표) == (100.0, "㎡") # 「당」이 붙은 쪽이 이긴다
|
||||
|
||||
|
||||
def test_어느_구실도_지우지_않음() -> None:
|
||||
"""브레인 승인(2026-09-17) — 「덜어내기」가 아니라 「가름만」."""
|
||||
import json as _json
|
||||
from pathlib import Path as _Path
|
||||
|
||||
policy = _json.loads(
|
||||
_Path("resources/data_work_item_master/axis_policy.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert policy["policy"]["never_delete"] is True
|
||||
assert policy["policy"]["flat_list_filter"] == "pum_tables > 0"
|
||||
assert all(role["keep"] for role in policy["roles"].values())
|
||||
assert list(policy["roles"]) == ["work_item", "group", "general_provision", "empty"]
|
||||
|
||||
|
||||
def test_경로_이름_잇기() -> None:
|
||||
nodes = [
|
||||
{"work_item_code": "A", "parent_code": None, "name": "토공"},
|
||||
{"work_item_code": "A-1", "parent_code": "A", "name": "토사깍기"},
|
||||
{"work_item_code": "A-1-1", "parent_code": "A-1", "name": "인력"},
|
||||
]
|
||||
assert path_names(nodes)[2] == "토공 › 토사깍기 › 인력"
|
||||
|
||||
|
||||
def test_고시_번호를_원문_경로에서_읽음() -> None:
|
||||
sources = [
|
||||
{"path": "resources/knowledge/original/…/(산림청고시 제2025-82호) 산림사업 표준품셈.md"}
|
||||
]
|
||||
assert toc_edition(sources, "2026-01-01") == "산림청고시제2025-82호"
|
||||
assert toc_edition([], "2026-01-01") == "2026-01-01"
|
||||
@@ -0,0 +1,138 @@
|
||||
"""연결고리 표 소비 — 산림·건설 어느 품을 쓸지 **표가 정함** (2026-09-17 브레인 · 코덱스 크로스체크 ②).
|
||||
|
||||
고시 총칙 7 「유사 공종은 본 품셈 우선」 이 규칙으로만 있고 아무도 안 읽던 자리. 못박는 것:
|
||||
① 둘 다 있는 공종은 표의 `both_precedence` 쪽 — 표를 바꾸면 고르는 쪽도 바뀜(코드에 안 박힘)
|
||||
② 미판정은 안 고르고 막음 · 사유 그대로 · 금액에 안 듦
|
||||
③ 지금 인계 코드는 한 줄도 판정이 안 달라짐 = **금액 불변**(이행 전 금액은 `test_work_item_key_gate` 가 못박음)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from B09_Estimation.B09_Estimation_BillOfQuantities import bill_summary, build_bill
|
||||
from B09_Estimation.B09_Estimation_BillOfQuantities_Input import (
|
||||
BLOCKED_LINK_UNDECIDED,
|
||||
parse_handoff,
|
||||
)
|
||||
from B09_Estimation.B09_Estimation_UnitPrice import build_unit_prices
|
||||
from common_util import common_util_work_item_link as link
|
||||
from common_util.common_util_work_item_key import MASTER_DIR, work_item_code_of, work_item_key
|
||||
|
||||
MAPPING = MASTER_DIR.parent / "data_work_item_mapping" / "work_item_mapping_2026-01-01.json"
|
||||
|
||||
|
||||
def _table(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, links: list, precedence="forest"
|
||||
) -> None:
|
||||
folder = tmp_path / "data_work_item_link"
|
||||
folder.mkdir(parents=True)
|
||||
doc = {
|
||||
"policy": {"both_precedence": precedence, "unconfirmed_action": "do_not_link"},
|
||||
"links": links,
|
||||
}
|
||||
(folder / "work_item_link_2026-01-01.json").write_text(
|
||||
json.dumps(doc, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
monkeypatch.setattr(link, "FOLDER", folder)
|
||||
|
||||
|
||||
def test_지금_인계_코드는_판정이_안_달라짐_금액_불변() -> None:
|
||||
codes = set(
|
||||
re.findall(r'"((?:FP|AX-WK|AX-ST)-[0-9A-Za-z-]+)"', MAPPING.read_text(encoding="utf-8"))
|
||||
)
|
||||
changed = []
|
||||
for code in sorted(codes):
|
||||
key = work_item_key(code, "2026-01-01")
|
||||
choice = link.choose(key)
|
||||
if choice.key != key or choice.blocked:
|
||||
changed.append((code, key, choice))
|
||||
assert changed == [] # 달라지면 그동안 잘못 고르고 있었다는 뜻 — 브레인에 먼저 알릴 것
|
||||
|
||||
|
||||
def test_둘_다_있으면_표가_적은_쪽() -> None:
|
||||
"""실제 표 — 산림 9-7-1 무근콘크리트 깨기 ↔ 건설 8-2-13 대형브레이커(E 0.35 ↔ 0.45)."""
|
||||
assert link.choose("CW-00330").key == "FW-00260"
|
||||
assert link.choose("FW-00260").key == "FW-00260"
|
||||
assert link.choose("CW-00844").key == "CW-00844" # 건설에만(모르타르 배합) — 그대로
|
||||
assert link.choose("FW-00249").key == "FW-00249" # 표에 없음 — 제 품셈
|
||||
|
||||
|
||||
def test_우선_품셈은_코드가_아니라_표가_정함(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
rows = [{"forest_key": "FW-00260", "const_key": "CW-00330", "branch": "both", "status": "확정"}]
|
||||
_table(tmp_path, monkeypatch, rows, precedence="construction")
|
||||
assert link.choose("FW-00260").key == "CW-00330"
|
||||
_table(tmp_path / "b", monkeypatch, rows, precedence="")
|
||||
assert link.choose("FW-00260").key is None and link.choose("FW-00260").blocked
|
||||
|
||||
|
||||
def test_미판정은_안_고르고_막음_금액에_안_듦(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
payload = {
|
||||
"work_items": [
|
||||
{"work_item_code": "FP-09-03-02", "name": "토사깍기", "unit": "㎥", "quantity": 100},
|
||||
{"work_item_code": "FP-09-12-01", "name": "측구터파기", "unit": "㎥", "quantity": 7},
|
||||
],
|
||||
"materials": [],
|
||||
}
|
||||
build = build_unit_prices()
|
||||
before = build_bill(json.loads(json.dumps(payload)), build=build)
|
||||
ditch = work_item_key("FP-09-12-01", "2026-01-01")
|
||||
rows = [
|
||||
{
|
||||
"forest_key": ditch,
|
||||
"const_key": "",
|
||||
"branch": "both",
|
||||
"status": "미판정",
|
||||
"evidence": "시험 — 측구 터파기 건설 대응 미확인",
|
||||
}
|
||||
]
|
||||
_table(tmp_path, monkeypatch, rows)
|
||||
items, _ = parse_handoff(json.loads(json.dumps(payload)))
|
||||
assert (
|
||||
items[1].blocked_kind == BLOCKED_LINK_UNDECIDED and "시험 — 측구" in items[1].blocked_reason
|
||||
)
|
||||
after = build_bill(json.loads(json.dumps(payload)), build=build)
|
||||
blocked = [m for m in after.missing if m.get("blocked_kind") == BLOCKED_LINK_UNDECIDED]
|
||||
assert [m["name"] for m in blocked] == ["측구터파기"]
|
||||
ditch_amount = sum(
|
||||
r.amount_krw or 0 for r in before.rows if not r.is_group and r.name == "측구터파기"
|
||||
)
|
||||
assert ditch_amount > 0 and after.body_total_krw == before.body_total_krw - ditch_amount
|
||||
assert bill_summary(after)["link_undecided"][0]["evidence"].startswith("시험")
|
||||
|
||||
|
||||
def test_건설_열쇠로_온_줄은_표가_고른_산림_열쇠와_목차_코드로() -> None:
|
||||
const_code = work_item_code_of("CW-00330")
|
||||
assert const_code and const_code.startswith("CP-")
|
||||
items, _ = parse_handoff(
|
||||
{
|
||||
"work_items": [
|
||||
{
|
||||
"work_item_code": const_code,
|
||||
"name": "대형브레이커",
|
||||
"unit": "㎥",
|
||||
"quantity": 1,
|
||||
"pum_edition": "2026-01-01",
|
||||
}
|
||||
],
|
||||
"materials": [],
|
||||
}
|
||||
)
|
||||
assert (items[0].work_item_key, items[0].work_item_code) == (
|
||||
"FW-00260",
|
||||
work_item_code_of("FW-00260"),
|
||||
)
|
||||
assert items[0].work_item_code.startswith("FP-")
|
||||
|
||||
|
||||
def test_미판정_행은_열쇠가_비어도_목록으로_드러남() -> None:
|
||||
undecided = link.undecided_links()
|
||||
assert undecided and all(row["evidence"] for row in undecided)
|
||||
@@ -0,0 +1,196 @@
|
||||
"""건설 공종 축(`CW-00001`) 뽑기 — 2026-09-17 브레인 8-2.
|
||||
|
||||
못박는 것
|
||||
① 목차 글줄 가르기 — 쪽 번호가 다음 번호에 붙은 꼴(「131-2-6」 = 13쪽 + 1-2-6) · 부문마다 장 번호 1부터
|
||||
② 표는 **본문 절 제목**으로 붙음 — 인용(「3-2-4 터파기(기계)’를 참고하여」)은 제목이 아님
|
||||
③ 목차 오기 없음 — 연혁 표시(「('20년 보완)」)가 붙은 본문 제목이 모두 목차 번호·이름과 같음
|
||||
(산림에서 목차가 번호를 잘못 적어 표가 딴 줄에 붙은 병 · 건설도 같은 함정)
|
||||
④ 못 붙인 표는 목록으로 · 열쇠 CW- 안 겹침 · 다시 뽑아도 같은 열쇠
|
||||
⑤ 산출 파일 = 지금 코드로 뽑은 것(산림도 같이 — 두 뽑기가 표 읽기 한 벌을 나눠 씀 · 금액 불변)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from B08_Quantity import B08_Quantity_Build_WorkItemMaster as forest
|
||||
from B08_Quantity import B08_Quantity_Build_WorkItemMaster_Const as const
|
||||
|
||||
OUT = forest.OUT_DIR
|
||||
CONST_MASTER = OUT / "const_work_item_master_2026-01-01.json"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def built() -> tuple:
|
||||
return const.build_const()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def master() -> dict:
|
||||
return json.loads(CONST_MASTER.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _same_except_time(fresh: dict, saved: dict) -> None:
|
||||
fresh = json.loads(json.dumps(fresh, ensure_ascii=False))
|
||||
for doc in (fresh, saved):
|
||||
doc.pop("generated_at")
|
||||
doc["stats"].pop("keys_newly_issued") # 첫 뽑기만 발급 · 다시 뽑으면 0
|
||||
doc["stats"].pop("variant_keys_newly_issued", None) # 갈래 열쇠도 같음
|
||||
assert fresh == saved
|
||||
|
||||
|
||||
def test_목차_글줄_가르기_쪽_번호가_붙은_꼴() -> None:
|
||||
text = "제1장적용기준31-1 일반사항····31-1-1 목적····131-2-6 공구 및 경장비····19제2장가설공사352-1 가설물의 한도 ····35"
|
||||
assert const.toc_entries(text) == [
|
||||
("1", "1", "적용기준"),
|
||||
("1", "1-1", "일반사항"),
|
||||
("1", "1-1-1", "목적"),
|
||||
("1", "1-2-6", "공구 및 경장비"),
|
||||
("2", "2", "가설공사"),
|
||||
("2", "2-1", "가설물의 한도"),
|
||||
]
|
||||
assert const.toc_entries("제11장칠공사61111-1 공통사항····611")[1] == ("11", "11-1", "공통사항")
|
||||
|
||||
|
||||
def test_인용은_제목이_아님() -> None:
|
||||
names = {("공통부문", "3-2-4"): "터파기(기계)", ("공통부문", "3-2-5"): "되메우기"}
|
||||
chapters = {"2", "3", "4"}
|
||||
assert (
|
||||
const.titles_in("’3-2-4 터파기(기계)’를 참고하여 적용한다.", "공통부문", chapters, names)
|
||||
== []
|
||||
)
|
||||
assert const.titles_in("…경우3-2-4 터파기(기계)('25년 신설)", "공통부문", chapters, names) == [
|
||||
"3-2-4"
|
||||
]
|
||||
assert const.titles_in("853-2-5 되메우기Q = n·q", "공통부문", chapters, names) == ["3-2-5"]
|
||||
|
||||
|
||||
def test_목차_부문_다섯_장_마흔다섯(built: tuple) -> None:
|
||||
items = built[0]["work_items"]
|
||||
roots = [it for it in items if it["parent_code"] is None]
|
||||
assert [r["name"] for r in roots] == list(const.DIVISIONS)
|
||||
assert sum(1 for it in items if it["level"] == 2) == 45
|
||||
codes = [it["work_item_code"] for it in items]
|
||||
assert len(set(codes)) == len(codes)
|
||||
|
||||
|
||||
def test_표는_다_붙고_못_붙인_표는_목록(built: tuple) -> None:
|
||||
master = built[0]
|
||||
stats = master["stats"]
|
||||
# 2026-09-18 2,192 → 2,202 — 코덱스가 제8장 손료표 열(C2193~C2202)을 되살림
|
||||
# 2026-09-18 합침 2,202 → 2,270 → 2,272 — 랩탑 서브·안티그래비티·코덱스가 원문 md 의
|
||||
# 뭉친 줄·표를 잇달아 풂(8-2 시공능력 · 8-4-9 해상기계 …)
|
||||
# 2026-09-18 2,275 → 2,279 — 51d0615a 8-2-23~25 파일 해머 표 넷(C2278~C2281) 떼어 세움
|
||||
assert stats["tables_total"] == 2279
|
||||
assert stats["tables_attached"] + len(master["orphan_tables"]) == stats["tables_total"]
|
||||
assert stats["tables_orphan"] == len(master["orphan_tables"]) == 0
|
||||
|
||||
|
||||
def test_목차_오기_없음_연혁_붙은_본문_제목이_목차와_같음(built: tuple) -> None:
|
||||
"""본문 제목 번호가 목차에 없거나 이름이 다르면 그 절 표가 **앞 절에 조용히 붙음** — 빨강."""
|
||||
items = built[0]["work_items"]
|
||||
names = {(it["division"], it["number"]): it["name"] for it in items if it["level"] > 1}
|
||||
titled = re.compile(r"(\d+(?:-\d+)+)\s*([^’'\[\]()]{1,40}?)\s*\(\s*['‘’]\d\d")
|
||||
data = json.loads(const.SOURCE.read_text(encoding="utf-8"))
|
||||
files = sorted({t["source_file"] for t in data["variables"]["pum"]["tables"]})
|
||||
seen, bad = 0, []
|
||||
for source_file in files:
|
||||
division, chapter = const.file_place(source_file)
|
||||
chapters = {str(chapter - 1), str(chapter), str(chapter + 1)}
|
||||
for line in (const.ROOT / source_file).read_text(encoding="utf-8").splitlines():
|
||||
for found in titled.finditer(line):
|
||||
raw, name = found.group(1), "".join(found.group(2).split())
|
||||
number = next(
|
||||
(
|
||||
raw[cut:]
|
||||
for cut in range(len(raw.split("-")[0]))
|
||||
if raw[cut:].split("-")[0] in chapters
|
||||
),
|
||||
None,
|
||||
)
|
||||
if number is None:
|
||||
continue
|
||||
seen += 1
|
||||
toc = "".join(names.get((division, number), "").split())
|
||||
if not toc or not (name.startswith(toc) or toc.startswith(name)):
|
||||
bad.append(
|
||||
f"{division} {number} 「{found.group(2)}」 목차 「{names.get((division, number))}」"
|
||||
)
|
||||
assert seen > 600
|
||||
assert not bad, bad
|
||||
|
||||
|
||||
def test_열쇠_CW_안_겹침_다시_뽑아도_같음(built: tuple, master: dict) -> None:
|
||||
keys = [it["work_item_key"] for it in master["work_items"]]
|
||||
assert all(re.fullmatch(r"CW-\d{5}", k) for k in keys)
|
||||
assert len(set(keys)) == len(keys)
|
||||
assert built[3][1] == [] # 장부가 있으니 새로 안 냄
|
||||
assert [it["work_item_key"] for it in built[0]["work_items"]] == keys
|
||||
|
||||
|
||||
def test_산출_파일이_지금_코드로_뽑은_것(built: tuple, master: dict) -> None:
|
||||
_same_except_time(built[0], master)
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="품셈 md 확정 단계 — 옛 사슬 동결 · 원천 벌 json 줄바꿈 지문 · 단가 연결 때 되살림")
|
||||
def test_산림_산출도_그대로_금액_불변() -> None:
|
||||
"""표 읽기를 떼어내고(`_Table`) 건설과 나눠 써도 산림 산출은 한 글자도 안 바뀜."""
|
||||
saved = json.loads((OUT / "work_item_master_2026-01-01.json").read_text(encoding="utf-8"))
|
||||
master, undetermined, basis_missing, _ = forest.build()
|
||||
_same_except_time(master, saved)
|
||||
for name, fresh in (("form_undetermined", undetermined), ("basis_missing", basis_missing)):
|
||||
saved_items = json.loads((OUT / f"{name}_2026-01-01.json").read_text(encoding="utf-8"))[
|
||||
"items"
|
||||
]
|
||||
assert json.loads(json.dumps(fresh, ensure_ascii=False)) == saved_items
|
||||
|
||||
|
||||
def test_산림을_고르는_쪽이_건설_파일을_안_집음() -> None:
|
||||
"""⚠ `work_item_master_*` 로 끝 파일을 고르는 자리가 있음(B08 밑수 대조 · B09 근거표) —
|
||||
건설 산출을 `work_item_master_const_…` 로 두면 그쪽이 건설을 집어 밑수 대조가 통째로 빠졌음(2026-09-17)."""
|
||||
from B08_Quantity.B08_Quantity_Engine_BasisUnit import load_master
|
||||
|
||||
assert load_master()["dataset_id"] == "work_item_master_forest"
|
||||
assert not list(OUT.glob("work_item_master_*const*.json"))
|
||||
|
||||
|
||||
def test_건설_갈래도_이름을_다듬어도_같은_열쇠(master: dict) -> None:
|
||||
"""④ 건설 갈래 펴기(2026-09-17) — 갈래 열쇠는 번호(`CW-00063#01`) · 자간·물결표를 손질해도 새로 안 남."""
|
||||
import copy
|
||||
|
||||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Keys import (
|
||||
assign_variant_keys,
|
||||
load_registry,
|
||||
)
|
||||
|
||||
items = master["work_items"]
|
||||
spread = [it for it in items if it.get("variants")]
|
||||
assert len(spread) > 100 and sum(len(it["variants"]) for it in spread) > 400
|
||||
registry = load_registry(const.CONST_KEY_REGISTRY, const.KEY_PREFIX)
|
||||
nodes = copy.deepcopy(spread)
|
||||
for node in nodes: # 사람이 이름을 다듬은 날 — 자간 한 칸 · 물결표 다른 벌
|
||||
node["variant_keys"] = [
|
||||
" ".join(k.split()).replace("∼", "~") for k in node["variant_keys"]
|
||||
]
|
||||
assert assign_variant_keys(nodes, registry) == [] # 새 번호 0
|
||||
for before, after in zip(spread, nodes):
|
||||
assert [v["variant_key"] for v in after["variants"]] == [
|
||||
v["variant_key"] for v in before["variants"]
|
||||
]
|
||||
|
||||
|
||||
def test_자간_다듬기는_진짜_띄어쓰기를_안_붙임() -> None:
|
||||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Keys import tidy_spacing
|
||||
|
||||
assert tidy_spacing("측 량") == "측량"
|
||||
assert tidy_spacing("평 규 준 틀") == "평규준틀"
|
||||
assert tidy_spacing("굴 삭 기 (무한궤도)") == "굴삭기 (무한궤도)"
|
||||
assert tidy_spacing("메 붙 임 · 깬 돌 · 뒷길이 25㎝") == "메붙임 · 깬돌 · 뒷길이 25㎝"
|
||||
assert tidy_spacing("토목부문 › 측 량 › 기준점 측량") == "토목부문 › 측량 › 기준점 측량"
|
||||
assert (
|
||||
tidy_spacing("그 외 자재의 운반품셈") == "그 외 자재의 운반품셈"
|
||||
) # 한 글자 낱말 둘은 진짜 띄어쓰기
|
||||
assert tidy_spacing("호박돌 및 야면석 · 뒷길이 25㎝") == "호박돌 및 야면석 · 뒷길이 25㎝"
|
||||
@@ -0,0 +1,100 @@
|
||||
"""공종 축 — **이름과 붙은 표의 절이 어긋나면 빨강** (2026-09-17 브레인 · 코덱스 원문 대조).
|
||||
|
||||
이 병은 한 번 나면 아무도 못 알아챔: 품셈 목차가 같은 번호를 두 번 적자(12-17-2 · 12-24-1)
|
||||
뒤 줄(「무근진동기 제외」)이 앞 표(F0363 「철근, 펌프카 0-15m」)를 물고, 진짜 표(F0364)는 orphan 으로 사라졌음.
|
||||
표 번호로는 안 잡힘(번호는 같았음) — **표 절 제목의 이름이 다른 줄 이름과 똑같은데 그 줄이 아닌 데 붙음**이 그 꼴.
|
||||
⚠ 이름 글자만 조금 다른 자리(목차 「깍기」 · 본문 「깎기」)는 판정하지 않음 — 번호·이름이 딴 줄을 가리킬 때만.
|
||||
⚠ 2026-09-17 둘째 — **헛단위**(목록에 없는 공종이 섬 · 서브 훑기 · 브레인):
|
||||
· [주] 인용 「‘13-3. 기초다짐 및 뒤채움’ 항을 적용한다」를 제목으로 읽어 표 셋(F0405·F0406·F0418)이 13-3 에 붙음
|
||||
· 부록 사례 표 일곱(F0455·F0456·F0472~F0476)이 본문 절 번호와 겹쳐 4-1·4-2 따위에 붙음
|
||||
⇒ 인용은 제목이 아님 · 번호만 겹치고 이름이 딴판이면 못 붙인 표 목록으로.
|
||||
단가표 대조(677 제목 전후) — 헛단위 B-FP-13-03 셋이 빠지고 **B-FP-13-05-02 가 메붙임+찰붙임 합으로 약 두 배**
|
||||
(쓰는 곳 0 · `KnownGaps` 에 「쓰면 안 됨」 표시 · 갈래 가르기 대기). 나머지 단가 한 원도 안 바뀜.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
MASTER = ROOT / "resources" / "data_work_item_master" / "work_item_master_2026-01-01.json"
|
||||
_TITLE = re.compile(r"^\s*(\d+(?:-\d+){0,3})\.\s+(.+)$")
|
||||
|
||||
|
||||
def _squeeze(text: str) -> str:
|
||||
return "".join(str(text).split())
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def master() -> dict:
|
||||
return json.loads(MASTER.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_목차_번호가_안_겹침(master: dict) -> None:
|
||||
numbers = [it["number"] for it in master["work_items"]]
|
||||
assert len(set(numbers)) == len(numbers)
|
||||
|
||||
|
||||
def test_표_절_제목이_다른_줄을_가리키지_않음(master: dict) -> None:
|
||||
items = master["work_items"]
|
||||
by_name: dict[str, set[str]] = {} # 이름 → 그 이름 줄의 열쇠(번호가 겹친 줄도 갈림)
|
||||
for it in items:
|
||||
by_name.setdefault(_squeeze(it["name"]), set()).add(it["work_item_key"])
|
||||
bad = []
|
||||
for it in items:
|
||||
for table in it["tables"]:
|
||||
title = _TITLE.match(table["section"])
|
||||
if title is None:
|
||||
continue
|
||||
number, name = title.group(1), _squeeze(title.group(2))
|
||||
others = by_name.get(name, set()) - {it["work_item_key"]}
|
||||
if number != it["number"] or (others and name != _squeeze(it["name"])):
|
||||
bad.append(
|
||||
f"{table['pum_table_id']} 「{table['section']}」 → {it['number']} {it['name']}"
|
||||
)
|
||||
assert not bad, bad
|
||||
|
||||
|
||||
def test_사라진_표는_목록으로_남음(master: dict) -> None:
|
||||
"""orphan 을 조용히 버리지 않음 — 목록 수와 집계가 같아야 함."""
|
||||
assert master["stats"]["tables_orphan"] == len(master["orphan_tables"])
|
||||
assert (
|
||||
master["stats"]["tables_attached"] + len(master["orphan_tables"])
|
||||
== master["stats"]["tables_total"]
|
||||
)
|
||||
|
||||
|
||||
APPENDIX = {"F0455", "F0456", "F0472", "F0473", "F0474", "F0475", "F0476"}
|
||||
|
||||
|
||||
def test_인용은_제목이_아니라_표가_제_절에_붙음(master: dict) -> None:
|
||||
by_code = {it["work_item_code"]: it for it in master["work_items"]}
|
||||
tables = lambda code: [t["pum_table_id"] for t in by_code[code]["tables"]] # noqa: E731
|
||||
assert tables("FP-13-03") == [] # 헛단위 — 표 없는 묶는 마디로
|
||||
assert {"F0405", "F0406"} <= set(tables("FP-13-04-01"))
|
||||
assert "F0418" in tables("FP-13-05-02")
|
||||
|
||||
|
||||
def test_부록_사례_표는_번호가_겹쳐도_안_붙고_목록으로(master: dict) -> None:
|
||||
attached = {t["pum_table_id"] for it in master["work_items"] for t in it["tables"]}
|
||||
assert not attached & APPENDIX
|
||||
assert APPENDIX <= {o["pum_table_id"] for o in master["orphan_tables"]}
|
||||
by_code = {it["work_item_code"]: it for it in master["work_items"]}
|
||||
assert by_code["FP-04-01"]["tables"] == [] and by_code["FP-04-02"]["tables"] == []
|
||||
|
||||
|
||||
def test_이름_닮음_잣대() -> None:
|
||||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import _is_section_title, title_names_node
|
||||
|
||||
assert not _is_section_title("13-3. 기초다짐 및 뒤채움’ 항을 적용한다.", {})
|
||||
assert _is_section_title(
|
||||
"2-9. 어린나무가꾸기, (1) 치수림단계 (제거대상 피복도 ‘소’, 가지치기)", {}
|
||||
)
|
||||
assert not title_names_node("4-1. 소나무재선충병방제", {"name": "수확베기"})
|
||||
assert title_names_node("9-19-2. 비탈면 고르기(암절취)", {"name": "비탈면 면고르기(암절취)"})
|
||||
assert title_names_node("12-13. 면벽", {"name": "면벅"})
|
||||
assert title_names_node("9-7-1. T=30㎝ 미만", {"name": "T=30cm 미만"})
|
||||
@@ -0,0 +1,69 @@
|
||||
"""공종 마스터 갈래 키 `variant_key` — 표 읽기가 가른 대로 (명세 14장 정정 · 2026-09-13).
|
||||
|
||||
지키는 것
|
||||
① 짝 시험 — 단가표의 `B-<코드>#<갈래>` 제목은 **전부** 그 공종의 마스터 갈래 키에 있다
|
||||
(종전 첫 열 24개 방식은 294 중 86). 표 읽기를 고치고 마스터를 안 다시 뽑으면 여기서 걸림
|
||||
② 머리행 갈래(벌목)·뭉친 이름 표(찰쌓기 장비)는 머리행에서 · 첫 열이 자원 이름인 표는 빈칸
|
||||
③ 합산형 부모는 단계 갈래를 물려받음(흙깎기 리핑암 9-4 = 암질 + [주]① 평균)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from B09_Estimation.B09_Estimation_ResourceAxis import load_work_item_master
|
||||
from B09_Estimation.B09_Estimation_UnitPrice import cached_build, normalize_variant_key
|
||||
|
||||
|
||||
def _nodes() -> dict[str, dict]:
|
||||
return {node["work_item_code"]: node for node in load_work_item_master()["work_items"]}
|
||||
|
||||
|
||||
def test_단가표_갈래_제목은_전부_마스터_갈래_키에_있다() -> None:
|
||||
"""갈래를 **어디서 왔나**로 가름(2026-09-14 브레인 판정) — 표에서 온 갈래만 마스터에 있어야 함.
|
||||
|
||||
식이 낸 갈래(덤프 운반·적재 10-12)는 마스터가 낼 수 없어 검사 대상이 아니되
|
||||
`formula_variants` 에 「식이 냈다」가 적혀 있어야 함(아래 짝 시험).
|
||||
"""
|
||||
nodes = _nodes()
|
||||
missing = []
|
||||
build = cached_build(dump_haul_m=("164.23",))
|
||||
for code in build.book.titles:
|
||||
if not code.startswith("B-") or "#" not in code:
|
||||
continue
|
||||
if code in build.formula_variants:
|
||||
continue
|
||||
work_item, variant = code[2:].split("#", 1)
|
||||
keys = {
|
||||
normalize_variant_key(k) for k in nodes.get(work_item, {}).get("variant_keys") or []
|
||||
}
|
||||
if variant not in keys:
|
||||
missing.append(code)
|
||||
assert not missing, f"마스터를 다시 뽑을 것(B08_Quantity_Build_WorkItemMaster): {missing[:5]}"
|
||||
|
||||
|
||||
def test_식이_낸_갈래는_출처가_적히고_마스터엔_없다() -> None:
|
||||
build = cached_build(dump_haul_m=("164.23",))
|
||||
formula = build.formula_variants
|
||||
assert "B-FP-10-12-01#적재" in formula and "B-FP-10-12-02#L164.23m" in formula
|
||||
assert all(code in build.book.titles and why for code, why in formula.items())
|
||||
nodes = _nodes()
|
||||
for code in formula:
|
||||
work_item, variant = code[2:].split("#", 1)
|
||||
keys = {normalize_variant_key(k) for k in nodes[work_item].get("variant_keys") or []}
|
||||
assert variant not in keys # 마스터에 있으면 「표에서 옴」이라 여기 적을 까닭이 없음
|
||||
|
||||
|
||||
def test_표_모양대로_갈래를_가르고_못_가르면_빈칸() -> None:
|
||||
nodes = _nodes()
|
||||
felling = nodes["FP-04-02-02"]["tables"][0]["variant_key"]
|
||||
assert "5m이상~8m미만" in felling # 머리행 갈래 — 첫 열(자원 이름)이 아님
|
||||
assert nodes["FP-13-04-05"]["tables"][0]["variant_key"] == [
|
||||
"35cm 이하",
|
||||
"55cm 이하",
|
||||
"75cm 이하",
|
||||
]
|
||||
finishing = next(t for t in nodes["FP-12-02"]["tables"] if t["pum_table_id"] == "F0334")
|
||||
assert finishing["variant_key"] == [] # 첫 열이 자원 이름(미장공)인 표
|
||||
|
||||
|
||||
def test_합산형_부모는_단계_갈래를_물려받는다() -> None:
|
||||
assert _nodes()["FP-09-04"]["variant_keys"] == ["연암", "보통암", "경암", "평균"]
|
||||
Reference in New Issue
Block a user