feat(b09): 연결고리 표를 읽는 한 자리 — 산림·건설 어느 품을 쓸지 표가 정함(고시 총칙 7 · 표 policy both_precedence) · B09 인계 입구에서 둘 다 있는 공종은 표가 고른 열쇠·목차 코드로 · 미판정은 안 고르고 막음(link_undecided · 사유 그대로 · 금액에 안 듦) · 열쇠 빈 미판정 행도 내역서 요약에 목록 · 지금 인계 코드 57 판정 안 달라짐 = 금액 불변 · Z01 목록에 줄 구실(총칙) 표시 칸(코덱스 크로스체크 ①② · 브레인)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
This commit is contained in:
2026-09-17 16:53:22 +09:00
co-authored by Claude Opus 5
parent 337e42314c
commit 8f3a01cd96
8 changed files with 302 additions and 9 deletions
@@ -1421,6 +1421,13 @@
"visible": true,
"note": "「연결됨」 = 연결고리 표(`work_item_link`)가 확정으로 이은 줄 — 임도가 쓰는 공종. 판정은 그 표 · 감추지 않고 표시만."
},
{
"key": "axis_role",
"name_ko": "줄 구실",
"unit": "",
"visible": true,
"note": "총칙·공종 — 잣대는 `axis_policy.json`(데스크탑 서브). 총칙도 지우지 않고 표시만(원가가 값표를 읽음)."
},
{
"key": "effective_date",
"name_ko": "적용 시작일",
@@ -1491,6 +1498,13 @@
"visible": true,
"note": "「연결됨」 = 연결고리 표(`work_item_link`)가 확정으로 이은 줄 — 임도가 쓰는 공종. 판정은 그 표 · 감추지 않고 표시만."
},
{
"key": "axis_role",
"name_ko": "줄 구실",
"unit": "",
"visible": true,
"note": "총칙·공종 — 잣대는 `axis_policy.json`(데스크탑 서브). 총칙도 지우지 않고 표시만(원가가 값표를 읽음)."
},
{
"key": "effective_date",
"name_ko": "적용 시작일",
@@ -1944,7 +1958,7 @@
},
"counts": {
"merged_tables": 17,
"merged_columns": 174,
"merged_columns": 176,
"files": 41,
"tables": 100,
"columns": 532,
@@ -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)
+1
View File
@@ -146,6 +146,7 @@ def test_거르기는_자료에_있는_가름만_서버가_판정(client: TestCl
)
assert rules["total"] > 0
assert all(r["name"].startswith("공통부문 › 적용기준") for r in rules["rows"])
assert {r["axis_role"] for r in rules["rows"]} == {"총칙"} # 표시 칸 — 감추지 않고 보임
assert [f["key"] for f in _get(client, "work_item_forest", size=1)["filters"]] == [
"link",