Merge remote-tracking branch 'origin/dev' into CODEX

This commit is contained in:
2026-09-17 17:15:02 +09:00
10 changed files with 506 additions and 54 deletions
@@ -47,6 +47,7 @@ _ZERO = Decimal(0)
# ⬇ 인계 읽기는 `_Input` 으로 옮겼다(2026-09-15 700줄 분리) — 쓰던 이름이 그대로 살게 다시 내보낸다.
from B09_Estimation.B09_Estimation_BillOfQuantities_Input import ( # noqa: E402,F401
BLOCKED_LINK_UNDECIDED,
BLOCKED_UNCONFIRMED,
SUPPLY_OWNER,
SUPPLY_UNKNOWN,
@@ -56,6 +57,7 @@ from B09_Estimation.B09_Estimation_BillOfQuantities_Input import ( # noqa: E402
_decimal,
parse_handoff,
)
from common_util.common_util_work_item_link import undecided_links # noqa: E402
#: 총 절취량으로 셀 공종 — 9-3 토사깎기 · 9-4 암절취 · 9-5 발파암(인계 대응표 「흙깎기」 셋).
#: ⚠ 판에 묶인 절 번호임(명세 17장) — 판이 바뀌면 대응표와 함께 고칠 것.
@@ -75,6 +77,7 @@ _BLOCKED_LABELS = {
"input_missing": "입력이 필요합니다",
"unit_data_missing": "원단위가 없습니다(우리가 만들 것)",
"formula_missing": "전개식이 없습니다(우리가 만들 것)",
BLOCKED_LINK_UNDECIDED: "연결고리 미판정 — 금액에 안 들어감",
}
@@ -353,6 +356,18 @@ def build_bill(
}
)
continue
if item.blocked_kind == BLOCKED_LINK_UNDECIDED:
# 산림·건설 어느 품을 쓸지 연결고리 표가 미판정 — 근거 없이 고르지 않음(브레인 · 코덱스 ②).
result.missing.append(
{
"name": item.display_name,
"unit": item.unit,
"quantity": str(item.quantity),
"reason": f"{_BLOCKED_LABELS[BLOCKED_LINK_UNDECIDED]}{item.blocked_reason}",
"blocked_kind": BLOCKED_LINK_UNDECIDED,
}
)
continue
if (item.composite_parts or item.composite_not_ready) and not item.work_item_code:
# 묶음 줄 — 품셈에 그 공종이 없어 **조각을 합쳐** 한 줄로 세운다
# (옹벽 = 타설 + 거푸집 + 철근 + 잡석). 「코드 없음」으로 세면 안 된다.
@@ -577,6 +592,8 @@ def bill_summary(result: BillResult) -> dict[str, Any]:
"direct_labor_krw": str(result.direct_labor_krw),
"direct_expense_krw": str(result.direct_expense_krw),
"notes": result.notes,
# 연결고리 표 미판정 행 — 열쇠가 비어 줄에 못 닿는 것도 드러냄(조용히 안 버림)
"link_undecided": undecided_links(),
}
@@ -14,7 +14,11 @@ from dataclasses import dataclass
from decimal import Decimal
from typing import Any
from common_util.common_util_work_item_key import work_item_key
from common_util.common_util_work_item_key import work_item_code_of, work_item_key
from common_util.common_util_work_item_link import choose
#: 연결고리 표 미판정 — 산림·건설 어느 품을 쓸지 근거가 없어 **안 고른** 줄(금액에 안 듦).
BLOCKED_LINK_UNDECIDED = "link_undecided"
_ZERO = Decimal(0)
@@ -121,6 +125,23 @@ def _decimal(value: Any, default: Decimal | None = _ZERO) -> Decimal | None:
return Decimal(str(value))
def _linked(row: dict[str, Any]) -> dict[str, Any]:
"""열쇠 길목 + **연결고리 표** — 산림·건설 어느 품을 쓸지 표가 정함(고시 총칙 7 · 표 `policy`).
둘 다 있는 공종이면 표가 적은 우선 품셈 열쇠·목차 코드로 바꿈 · 미판정이면 막음(사유 그대로).
⚠ 이미 B08 이 막아 보낸 줄은 그 까닭을 덮지 않음.
"""
edition = row.get("pum_edition") or None
key = row.get("work_item_key") or work_item_key(row.get("work_item_code"), edition)
choice = choose(key)
out = {"work_item_key": key or "", "work_item_code": row.get("work_item_code")}
if choice.blocked and not row.get("blocked_reason"):
out.update(blocked_reason=choice.blocked, blocked_kind=BLOCKED_LINK_UNDECIDED)
elif choice.key and choice.key != key:
out.update(work_item_key=choice.key, work_item_code=work_item_code_of(choice.key, edition))
return out
def parse_handoff(payload: dict[str, Any]) -> tuple[list[HandoffWorkItem], list[HandoffMaterial]]:
"""인계 응답을 우리 자료형으로 옮긴다. **모르는 칸을 채우지 않는다.**"""
if "work_items" not in payload or "materials" not in payload:
@@ -128,7 +149,7 @@ def parse_handoff(payload: dict[str, Any]) -> tuple[list[HandoffWorkItem], list[
work_items = [
HandoffWorkItem(
work_item_code=row.get("work_item_code"),
work_item_code=linked["work_item_code"],
name=row.get("name", ""),
spec=row.get("spec") or "",
unit=row.get("unit") or "",
@@ -147,19 +168,18 @@ def parse_handoff(payload: dict[str, Any]) -> tuple[list[HandoffWorkItem], list[
composite_parts=tuple(row.get("composite_parts") or ()),
composite_not_ready=tuple(row.get("composite_not_ready") or ()),
structure_kind=row.get("structure_kind") or "",
blocked_reason=row.get("blocked_reason") or "",
blocked_kind=row.get("blocked_kind") or "",
blocked_reason=row.get("blocked_reason") or linked.get("blocked_reason") or "",
blocked_kind=row.get("blocked_kind") or linked.get("blocked_kind") or "",
variant_axis=row.get("variant_axis") or "",
variant_value=str(row.get("variant_value") or ""),
spec_class=row.get("spec_class") or "",
spec_class_basis=row.get("spec_class_basis") or "",
pum_edition=str(row.get("pum_edition") or ""),
# 옛 인계(열쇠 없음)도 같은 길목으로 채움
work_item_key=row.get("work_item_key")
or work_item_key(row.get("work_item_code"), row.get("pum_edition") or None)
or "",
# 옛 인계(열쇠 없음)도 같은 길목으로 채움 · 연결고리 표가 고른 열쇠
work_item_key=linked["work_item_key"],
)
for row in payload["work_items"]
for linked in (_linked(row),)
]
materials = [
HandoffMaterial(
+8 -1
View File
@@ -427,14 +427,21 @@ def kind_label(kind: str) -> str:
return named.get("name_ko") or kind
def group_label(group: str) -> str:
"""상자 이름 — 이름표 `base_price_groups[갈래].name_ko` 에서만(없으면 영문 그대로 · 화면은 `group_label` 을 읽음)."""
named = (tables.load_labels().get("base_price_groups") or {}).get(group) or {}
return named.get("name_ko") or group
def kinds() -> list[dict[str, Any]]:
"""화면이 상자를 세울 목록 — **서버가 냄**(화면이 제 코드에 들면 새 kind 가 조용히 안 뜸).
갈래 · kind · 한글 이름(이름표) · 줄 수.
갈래 · 갈래 한글 이름 · kind · 한글 이름(이름표) · 줄 수.
"""
return [
{
"group": kind_group(kind),
"group_label": group_label(kind_group(kind)),
"kind": kind,
"label": kind_label(kind),
"rows": len(rows(kind)),
+76 -26
View File
@@ -1,7 +1,7 @@
"""Z01 공종 축 — 산림·건설 두 kind 를 기초단가와 **같은 계약**으로(2026-09-17 브레인 · PLAN_공종축 8-4).
줄 = **품셈 표가 붙은 마디**(`axis_policy.json` `flat_list_filter: pum_tables > 0` — 잎으로 거르면 부모에 붙은 표가 안 뜸) ·
갈래(`variant_keys`)는 칸으로(편 수 = `stats.units`).
갈래(`variant_keys`)마다 한 줄로 폄 — 줄 수 = 편 수(`stats.units`) · 공종 수 = `stats.work_items`.
이름 = 뿌리부터 전체 경로(「토공 › 토사깍기 › 기계」 · 건설은 부문부터) — 잎 이름만으론 겹침.
계산 규칙 = 표 수준 `rules`(부모 마디 choose_one · sum_steps) + 줄 칸 `rule`·`step_weight`
— 빠지면 발파암(절취 0.1 + 깍기 0.9 + 집토 1) 금액이 틀어짐.
@@ -77,6 +77,9 @@ def base_rows(kind: str) -> list[dict[str, Any]]:
return []
d, name = found
items, by, children = _tree(d)
# 계산 규칙 이름 — 이름표 `value_overrides["work_item_master::<방식>"]`(「아래를 단계로 다 더함」) · 없으면 영문
named = tables.load_labels().get("value_overrides") or {}
linked, roles = linked_keys(), _role_names()
rows = []
for item in items:
if not item.get("tables"):
@@ -86,23 +89,57 @@ def base_rows(kind: str) -> list[dict[str, Any]]:
(s["weight"] for s in parent.get("steps") or [] if s["code"] == item["work_item_code"]),
None,
)
rows.append(
{
"@id": _key(item),
"@source": name,
"work_item_code": item["work_item_code"], # 목차 코드 — 열쇠 아님, 칸으로만
"number": item["number"],
"name": _path(item, by),
"variant_keys": item.get("variant_keys") or [],
"pum_tables": [t["pum_table_id"] for t in item["tables"]],
"rule": parent.get("parent_mode"),
"step_weight": weight,
"effective_date": d.get("pum_edition") or d.get("effective_date"),
}
)
key = _key(item)
# 갈래마다 한 줄 — 일위대가가 갈래마다 달라 집는 단위가 갈래임(교목굴취 나무높이 9 · 2026-09-17 브레인 ③).
# 열쇠 = 공종 열쇠#갈래 키(B08·B09 `코드#갈래` 꼴) · 갈래 키 글자는 마스터 것 그대로(조립 두 벌 금지).
for variant in item.get("variant_keys") or [None]:
rows.append(
{
"@id": f"{key}#{variant}" if variant else key,
"@key": key, # 공종 열쇠 — 거르기·줄 수는 공종 단위로
"@source": name,
"work_item_code": item["work_item_code"], # 목차 코드 — 열쇠 아님, 칸으로만
"number": item["number"],
"name": _path(item, by),
"variant": variant,
"pum_tables": [t["pum_table_id"] for t in item["tables"]],
"rule": (named.get(f"work_item_master::{parent.get('parent_mode')}") or {}).get(
"name_ko"
)
or parent.get("parent_mode"),
"step_weight": weight,
# 「임도가 쓰는 것」 — 연결고리 표가 확정으로 이은 공종(판정은 그 표 · 감추지 않고 표시만)
"link": LINKED if key in linked else "",
# 줄 구실(총칙·공종) — 이름은 axis_policy.json · 표시 칸(거르기 상자로 고름)
"axis_role": roles.get(str(item.get("axis_role") or ""), ""),
"effective_date": d.get("pum_edition") or d.get("effective_date"),
}
)
return rows
LINK_FOLDER = tables.RESOURCES / "data_work_item_link"
LINKED = "연결됨"
def linked_keys() -> frozenset[str]:
"""연결고리 표(`work_item_link_*.json` 끝 판 · 코덱스)가 **확정**으로 이은 열쇠(산림·건설).
⚠ 목록을 코드에 안 둠 — 판정은 그 표 · 미판정은 안 이음(표의 `unconfirmed_action: do_not_link`).
"""
found = sorted(LINK_FOLDER.glob("work_item_link_*.json")) if LINK_FOLDER.is_dir() else []
if not found:
return frozenset()
links = _read(str(found[-1]), found[-1].stat().st_mtime_ns).get("links") or []
return frozenset(
key
for link in links
if link.get("status") == "확정"
for key in (link.get("forest_key"), link.get("const_key"))
if key
)
def rules(kind: str) -> list[dict[str, Any]]:
"""부모 마디 계산 규칙 — choose_one(아래 중 하나) · sum_steps(아래를 무게대로 더해야 한 단위)."""
found = doc(kind)
@@ -144,9 +181,10 @@ def notice(kind: str) -> list[str]:
return lines
#: 거르기 — **자료에 있는 가름만**(건설 부문 · 줄 구실). 어느 줄을 거를지는 서버 한 곳 · 화면은 고른 값만 보냄.
#: ⚠ 「임도가 쓰는 것」 같은 쓰임 가름은 잣대가 아직 없어 안 냄(지어내지 않음).
FILTER_KEYS = ("division", "axis_role")
#: 거르기 — **자료에 있는 가름만**(건설 부문 · 줄 구실 · 연결고리 표). 어느 줄을 거를지는 서버 한 곳 ·
#: 화면은 고른 값만 보냄 · 연결은 기본 「전부」(감추지 않음 — 「어디에 쓸지 모르니 건설 전체」 · 브레인) ·
#: 줄 구실만 기본 「공종」(총칙 기본 감춤 · 서브 2ee6a7ed · 브레인).
FILTER_KEYS = ("link", "division", "axis_role")
ALL = ""
#: 값을 안 보냈을 때 거는 값 — 총칙 줄은 기본 감춤 · 「전부」를 고르면 보임(2026-09-17 브레인)
DEFAULTS = {"axis_role": "work_item"}
@@ -155,7 +193,15 @@ DEFAULTS = {"axis_role": "work_item"}
def _facets(kind: str) -> dict[str, dict[str, str]]:
found = doc(kind)
items = found[0].get("work_items") or [] if found else []
return {_key(i): {key: str(i.get(key) or "") for key in FILTER_KEYS} for i in items}
linked = linked_keys()
return {
_key(i): {
"link": LINKED if _key(i) in linked else ALL,
"division": str(i.get("division") or ""),
"axis_role": str(i.get("axis_role") or ""),
}
for i in items
}
def _role_names() -> dict[str, str]:
@@ -168,17 +214,21 @@ def _role_names() -> dict[str, str]:
def filters(kind: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""거르기 상자 — 이 둘 이상 갈리는 가름만 · 기본은 `DEFAULTS`(그 값이 표에 있을 때) 아니면 「전부」."""
"""거르기 상자 — 이 둘 이상으로 갈리는 가름만 · 기본은 `DEFAULTS`(그 값이 표에 있을 때) 아니면 「전부」."""
facets = _facets(kind)
labels = {"division": ("부문", {}), "axis_role": ("줄 구실", _role_names())}
labels = {
"link": ("연결", {LINKED: "연결된 것만"}),
"division": ("부문", {}),
"axis_role": ("줄 구실", _role_names()),
}
out = []
for key in FILTER_KEYS:
counts: dict[str, int] = {}
for row in rows:
value = facets.get(row["@id"], {}).get(key, ALL)
value = facets.get(row["@key"], {}).get(key, ALL)
counts[value] = counts.get(value, 0) + 1
values = [v for v in counts if v != ALL]
if len(values) < 2:
if len(counts) < 2: # 다 같은 값이면 거를 것이 없음(연결은 「연결됨」 · 빈칸 둘로 갈림)
continue
label, names = labels[key]
default = DEFAULTS.get(key, ALL)
@@ -203,7 +253,7 @@ def apply_filters(
for key in FILTER_KEYS:
value = picked.get(key, defaults.get(key, ALL))
if value != ALL:
rows = [r for r in rows if facets.get(r["@id"], {}).get(key) == value]
rows = [r for r in rows if facets.get(r["@key"], {}).get(key) == value]
return rows
@@ -214,7 +264,7 @@ def extra(kind: str) -> dict[str, Any]:
"filters": filters(kind, rows),
"rules": rules(kind),
"stats": {
"work_items": len(rows),
"units": sum(max(1, len(r["variant_keys"])) for r in rows),
"work_items": len({r["@key"] for r in rows}),
"units": len(rows),
},
}
+14
View File
@@ -56,6 +56,20 @@ def work_item_key(code: str | None, pum_edition: str | None = None) -> str | Non
return found.pop() if len(found) == 1 else None
def work_item_code_of(key: str | None, pum_edition: str | None = None) -> str | None:
"""불변 열쇠 → 그 판의 목차 코드(되짚기). 한 곳에서만 찾아질 때만."""
if not key:
return None
if _STABLE_CODE.match(key):
return key
found = {
code
for (edition, code), each in _keys().items()
if each == key and (pum_edition is None or edition == pum_edition)
}
return found.pop() if len(found) == 1 else None
def attach_keys(rows: Iterable[dict[str, Any]]) -> list[str]:
"""인계 줄마다 `work_item_key` 를 더함(코드 없는 줄은 `None` — 칸은 늘 있음 · 인계 계약) ·
코드는 있는데 열쇠를 못 찾은 코드 목록을 돌려줌."""
+87
View File
@@ -0,0 +1,87 @@
"""연결고리 표(`work_item_link`)를 읽는 **한 자리** — 산림·건설 어느 품셈 품을 쓸지 **표가 정함**
(2026-09-17 브레인 · 코덱스 크로스체크 ② 「연결고리 미소비」).
고시 총칙 7 「본 품셈에 명시되지 않은 품은 타 부문 표준품셈 적용 · 유사 공종은 본 품셈 우선」 —
그 규칙은 표의 `policy`(`both_precedence` · `unconfirmed_action`)에 있고 코드는 **읽기만** 함(「산림 우선」을 코드에 안 박음).
확정 · both → `both_precedence` 쪽 열쇠(지금 표는 forest)
확정 · 한쪽만 → 그 열쇠 그대로
미판정 → **안 고름** · 사유를 붙여 막음(`unconfirmed_action: do_not_link`)
표에 없음 → 제 품셈 그대로(품셈에 명시된 품)
⚠ 미판정 행 가운데 열쇠가 빈 행(여러 절 묶음)은 줄에 못 닿음 — `undecided_links()` 로 목록만 드러냄.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from typing import Any
FOLDER = Path(__file__).resolve().parent.parent / "resources" / "data_work_item_link"
CONFIRMED = "확정"
#: 표 `policy.both_precedence` 낱말 → 그 행의 열쇠 칸
_KEY_COLUMN = {"forest": "forest_key", "construction": "const_key"}
@dataclass(frozen=True)
class Choice:
"""쓸 열쇠 · 막힌 까닭(있으면 금액을 안 세움)."""
key: str | None
blocked: str = ""
@lru_cache(maxsize=4)
def _read(path: str, _mtime_ns: int) -> dict[str, Any]:
return json.loads(Path(path).read_text(encoding="utf-8"))
def _doc() -> dict[str, Any]:
found = sorted(FOLDER.glob("work_item_link_*.json")) if FOLDER.is_dir() else []
return _read(str(found[-1]), found[-1].stat().st_mtime_ns) if found else {}
def choose(key: str | None) -> Choice:
"""열쇠 하나(FW-·CW-) → 실제로 쓸 열쇠. 미판정 행에 걸리면 안 고르고 막음."""
if not key:
return Choice(key)
doc = _doc()
policy = doc.get("policy") or {}
rows = [
link
for link in doc.get("links") or []
if key in (link.get("forest_key"), link.get("const_key"))
]
for link in rows:
if link.get("status") != CONFIRMED:
return Choice(
None,
"연결고리 미판정 — 산림·건설 어느 품을 쓸지 근거가 없어 고르지 않음"
f"({link.get('evidence') or '근거 없음'})",
)
for link in rows:
if link.get("branch") == "both":
column = _KEY_COLUMN.get(str(policy.get("both_precedence") or ""))
chosen = link.get(column) if column else None
if not chosen:
return Choice(
None, "연결고리 표에 둘 다 있는 줄의 우선 품셈이 안 적혔음 — 고르지 않음"
)
return Choice(chosen)
return Choice(key)
def undecided_links() -> list[dict[str, str]]:
"""미판정 행 — 열쇠가 비어 줄에 못 닿는 행도 드러냄(조용히 안 버림)."""
return [
{
"branch": str(link.get("branch") or ""),
"forest_key": str(link.get("forest_key") or ""),
"const_key": str(link.get("const_key") or ""),
"evidence": str(link.get("evidence") or ""),
}
for link in _doc().get("links") or []
if link.get("status") != CONFIRMED
]
@@ -1389,10 +1389,11 @@
"note": "뿌리부터 전체 경로(「토공 › 토사깍기 › 기계」) — 잎 이름만으론 겹침."
},
{
"key": "variant_keys",
"name_ko": "갈래",
"key": "variant",
"name_ko": "갈래",
"unit": "",
"visible": true
"visible": true,
"note": "갈래마다 한 줄(일위대가가 갈래마다 다름) — 줄 열쇠는 공종 열쇠#갈래."
},
{
"key": "pum_tables",
@@ -1414,6 +1415,20 @@
"visible": true,
"note": "`sum_steps` 일 때만 — 발파암 절취 0.1 · 깍기 0.9 · 집토 1."
},
{
"key": "link",
"name_ko": "연결",
"unit": "",
"visible": true,
"note": "「연결됨」 = 연결고리 표(`work_item_link`)가 확정으로 이은 줄 — 임도가 쓰는 공종. 판정은 그 표 · 감추지 않고 표시만."
},
{
"key": "axis_role",
"name_ko": "줄 구실",
"unit": "",
"visible": true,
"note": "총칙·공종 — 잣대는 `axis_policy.json`(데스크탑 서브). 총칙도 지우지 않고 표시만(원가가 값표를 읽음)."
},
{
"key": "effective_date",
"name_ko": "적용 시작일",
@@ -1452,10 +1467,11 @@
"note": "부문부터 전체 경로(「공통부문 › 토공사 › 굴착 › 굴착(인력/토사)」) — 부문마다 장 번호가 1부터라 부문이 뿌리."
},
{
"key": "variant_keys",
"name_ko": "갈래",
"key": "variant",
"name_ko": "갈래",
"unit": "",
"visible": true
"visible": true,
"note": "갈래마다 한 줄(일위대가가 갈래마다 다름) — 줄 열쇠는 공종 열쇠#갈래."
},
{
"key": "pum_tables",
@@ -1477,6 +1493,20 @@
"visible": true,
"note": "`sum_steps` 일 때만 — 건설은 아직 합산형 선언 없음."
},
{
"key": "link",
"name_ko": "연결",
"unit": "",
"visible": true,
"note": "「연결됨」 = 연결고리 표(`work_item_link`)가 확정으로 이은 줄 — 임도가 쓰는 공종. 판정은 그 표 · 감추지 않고 표시만."
},
{
"key": "axis_role",
"name_ko": "줄 구실",
"unit": "",
"visible": true,
"note": "총칙·공종 — 잣대는 `axis_policy.json`(데스크탑 서브). 총칙도 지우지 않고 표시만(원가가 값표를 읽음)."
},
{
"key": "effective_date",
"name_ko": "적용 시작일",
@@ -1930,7 +1960,7 @@
},
"counts": {
"merged_tables": 17,
"merged_columns": 172,
"merged_columns": 176,
"files": 41,
"tables": 100,
"columns": 532,
@@ -137,3 +137,27 @@ def test_금액_불변_열쇠가_있든_없든_내역서가_한_원도_안_다
]
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,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)
+77 -12
View File
@@ -38,15 +38,21 @@ def _get(client: TestClient, kind: str, **params) -> dict:
def test_산림_평평한_잎_목록과_계산_규칙(client: TestClient) -> None:
listed = client.get("/api/master-data/base-prices").json()["kinds"]
forest = next(i for i in listed if i["kind"] == "work_item_forest")
assert forest["group"] == "work_item" and forest["rows"] == 365
assert forest["group"] == "work_item" and forest["rows"] == 627 # 갈래마다 한 줄
# 상자 이름은 이름표 것 — 영문 키(work_item)로 뜨던 자리(2026-09-17 브레인 ①)
assert {i["group"]: i["group_label"] for i in listed} == {
"base_price": "기초단가",
"pumsem_basis": "품셈 기준",
"work_item": "공종 축",
}
table = _get(client, "work_item_forest", size=500, axis_role="") # 「전부」 — 총칙까지
assert table["total"] == 365 and table["stats"] == {"work_items": 365, "units": 627}
assert table["total"] == 627 and table["stats"] == {"work_items": 365, "units": 627}
assert table["editable"] == [] and set(table["locked"]) == set(table["sortable"])
rows = table["rows"]
assert len({r["@id"] for r in rows}) == 365
rows = table["rows"] + _get(client, "work_item_forest", size=500, page=2, axis_role="")["rows"]
assert len({r["@id"] for r in rows}) == 627
assert (
len({r["name"] for r in rows}) == 365
len({r["@key"] for r in rows}) == len({r["name"] for r in rows}) == 365
) # 잎 이름 겹침 — 경로로 풀림 · 목차 오기 바로잡아 358 → 360(12-17-2 · 12-24-1)
by_code = {r["work_item_code"]: r for r in rows}
assert by_code["FP-09-03-02"]["name"] == "토공 토사깍기 기계"
@@ -55,14 +61,16 @@ def test_산림_평평한_잎_목록과_계산_규칙(client: TestClient) -> Non
assert modes.count("choose_one") == 91 and modes.count("sum_steps") == 3
blast = next(r for r in table["rules"] if r["work_item_code"] == "FP-09-05")
assert [s["weight"] for s in blast["steps"]] == ["0.1", "0.9", "1"]
assert by_code["FP-09-05-02"]["rule"] == "sum_steps"
# 계산 규칙 칸은 이름표 이름 — 「sum_steps」 영문으로는 더해야 하는 줄로 안 읽힘(브레인 ②)
assert by_code["FP-09-05-02"]["rule"] == "아래를 단계로 다 더함"
assert by_code["FP-09-03-02"]["rule"] == "아래 하나를 고름"
assert by_code["FP-09-05-02"]["step_weight"] == "0.9"
# 2026-09-17 FW- 가 옴(데스크탑 서브) — 알림이 사라지는 것이 곧 열쇠가 섰다는 증거.
assert all(r["@id"].startswith("FW-") for r in rows) # 불변 열쇠(데스크탑 서브 7dd2c515)
assert not any("불변 열쇠" in line for line in table["notice"])
found = _get(client, "work_item_forest", q="발파암 ")
assert found["total"] == 3
assert found["total"] == 5 # 절취 · 깍기(연암·보통암·경암) · 집토
def test_건설_공종_축도_같은_길(client: TestClient) -> None:
@@ -101,7 +109,8 @@ def test_불변_열쇠가_오면_id_로_씀_자료_파일은_판_id_로_가림(
monkeypatch.setattr(work_items, "FOLDER", folder)
table = _get(client, "work_item_const")
assert [r["@id"] for r in table["rows"]] == ["CW-00002"]
assert [r["@id"] for r in table["rows"]] == ["CW-00002#갑", "CW-00002#을"] # 갈래마다 한 줄
assert {r["@key"] for r in table["rows"]} == {"CW-00002"}
assert table["rows"][0]["work_item_code"] == "CP-01-01" # 목차 코드는 칸으로 남음
assert table["rows"][0]["name"] == "토공 인력"
assert table["stats"] == {"work_items": 1, "units": 2}
@@ -109,7 +118,7 @@ def test_불변_열쇠가_오면_id_로_씀_자료_파일은_판_id_로_가림(
assert _get(client, "work_item_forest")["total"] == 0 # 산림 파일은 이 폴더에 없음
res = client.put(
"/api/master-data/base-prices/work_item_const/CW-00002",
"/api/master-data/base-prices/work_item_const/CW-00002%23갑",
json={"values": {"name": "바꿈"}},
)
assert res.status_code == 400 and "공종 축 뼈대" in res.json()["detail"]
@@ -117,10 +126,10 @@ def test_불변_열쇠가_오면_id_로_씀_자료_파일은_판_id_로_가림(
def test_거르기는_자료에_있는_가름만_서버가_판정(client: TestClient) -> None:
"""화면(서브 b890fba0)이 `filters` 를 받으면 상자를 세우고 고른 값만 보냄 — 판정은 서버 한 곳.
건설 부문 다섯 · 구실(이름은 axis_policy.json). 임도가 쓰는 잣대가 없어 ."""
건설 부문 다섯 · 구실(이름은 axis_policy.json) · 연결(연결고리 )."""
const = _get(client, "work_item_const", size=1, axis_role="")
by_key = {f["key"]: f for f in const["filters"]}
assert set(by_key) == {"division", "axis_role"}
assert set(by_key) == {"link", "division", "axis_role"}
division = by_key["division"]
assert division["default"] == ""
assert division["options"][0] == {"value": "", "label": "전부", "rows": const["total"]}
@@ -146,11 +155,67 @@ 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"]] == ["axis_role"]
assert [f["key"] for f in _get(client, "work_item_forest", size=1)["filters"]] == [
"link",
"axis_role",
]
assert _get(client, "labor", size=1, division="토목부문")["total"] == 261 # 다른 표는 안 거름
def test_연결고리_표가_이은_줄에_연결됨_감추지_않고_표시만(client: TestClient) -> None:
"""브레인 2026-09-17 — 「임도가 쓰는 것」 = 연결고리 표(work_item_link)가 **확정**으로 이은 줄.
기본은 보임 · 표시 연결됨 · 연결된 것만 상자 하나 · 판정은 (코드에 목록 없음)."""
from Z01_MasterData import Z01_MasterData_WorkItems as work_items
link_doc = json.loads(
sorted(work_items.LINK_FOLDER.glob("work_item_link_*.json"))[-1].read_text(encoding="utf-8")
)
confirmed = {
key
for link in link_doc["links"]
if link["status"] == "확정"
for key in (link["forest_key"], link["const_key"])
if key
}
unconfirmed = {
key
for link in link_doc["links"]
if link["status"] != "확정"
for key in (link["forest_key"], link["const_key"])
if key
} - confirmed
for kind, prefix in (("work_item_const", "CW-"), ("work_item_forest", "FW-")):
every = _get(client, kind, size=500, axis_role="")
rows = every["rows"] + _get(client, kind, size=500, page=2, axis_role="")["rows"]
assert len(rows) == every["total"] # 연결 거르기 기본은 「전부」 — 다 보임
marked = {r["@key"] for r in rows if r["link"] == "연결됨"} # 갈래 줄은 공종 열쇠로
listed = {r["@key"] for r in rows} # 표가 안 붙은 묶는 마디(CW-01329 따위)는 목록 밖
assert marked == {k for k in confirmed if k.startswith(prefix) and k in listed}
assert marked # 연결이 늘면 저절로 늘어남 — 지금도 한 줄 이상
assert not marked & unconfirmed
only = _get(client, kind, size=500, link="연결됨", axis_role="")
assert {r["@key"] for r in only["rows"]} == marked
box = next(f for f in every["filters"] if f["key"] == "link")
assert box["default"] == "" and box["options"][1]["rows"] == only["total"]
def test_갈래마다_한_줄이라_편_하나를_집는다(client: TestClient) -> None:
"""2026-09-17 브레인 ③ — 일위대가는 갈래마다 다름. 교목굴취(나무높이) 한 줄이면 어느 높이 값인지 못 고름."""
rows = _get(client, "work_item_forest", size=50, q="교목굴취(나무높이)")["rows"]
assert [r["variant"] for r in rows] == [
"1.0 이하", "1.11.5", "1.62.0", "2.12.5", "2.63.0",
"3.13.5", "3.64.0", "4.14.5", "4.65.0",
] # fmt: skip
assert rows[1]["@id"] == "FW-00105#1.11.5" and rows[1]["@key"] == "FW-00105"
# 갈래 없는 공종은 한 줄 · 열쇠에 # 없음
plain = _get(client, "work_item_forest", q="교목굴취(근원직경)")["rows"]
assert [(r["@id"], r["variant"]) for r in plain] == [("FW-00106", None)]
# 갈래 이름으로도 찾힘
assert _get(client, "work_item_forest", q="2.12.5")["total"] >= 1
def test_총칙_줄은_기본_감춤_전부를_고르면_보임(client: TestClient) -> None:
"""2026-09-17 브레인 — 값을 안 보내면 상자 기본값(공종)으로 거름 · 화면은 그 기본값을 상자에 보임."""
for kind in ("work_item_forest", "work_item_const"):