feat(z01): 총칙 기본 감춤의 기준을 잣대로 · 목록에서는 「공종 아님 · 규정·기준」

랩탑 서브가 낸 기본 감춤(2ee6a7ed)·갈래 펴기(0ff186c1)를 받고, 판정 자리만 옮김.

- 서버에 박혀 있던 `DEFAULTS` → `axis_policy.json` 의 `list_default` 를 읽음
  (총칙 범위는 기관이 정하는 값 — 코드에 박으면 관리자가 못 고침 · 못 읽을 때만 대비값)
- 구실마다 `list_label` — 거르기 상자에서는 「총칙」이 아니라 **「공종 아님 · 규정·기준」**
  · 기본 감춤이되 **지운 것이 아니라 감춘 것**임이 드러남
- 지금 수 — 산림 전부 627 중 공종 568 · 공종 아님 59 / 건설 전부 997 중 공종 983 · 공종 아님 14

⚠ 갈래 열쇠 흠 하나 기록 — `@id` 가 `FW-00105#1.1∼1.5` 꼴로 갈래 문자열을 그대로 담음.
원문이 물결표를 둘(∼ U+223C 26 · ~ U+FF5E 8)로 쓰고 자간 공백만 다른 짝이 6쌍이라,
문자열을 손질하면 열쇠가 갈림. 잣대 `known_defects` 에 적고 수를 시험으로 못박음(갈래 335).
건설은 갈래 키가 비어 편 수 = 줄 수(997).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsGw1Dz9HmhuAxGisxmDPF
This commit is contained in:
2026-09-17 17:04:55 +09:00
co-authored by Claude Opus 5
6 changed files with 148 additions and 70 deletions
+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)),
+43 -35
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,8 @@ 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 {}
rows = []
for item in items:
if not item.get("tables"):
@@ -86,20 +88,28 @@ 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,
"effective_date": d.get("pum_edition") or d.get("effective_date"),
}
)
return rows
@@ -148,6 +158,10 @@ def notice(kind: str) -> list[str]:
#: ⚠ 「임도가 쓰는 것」 같은 쓰임 가름은 잣대가 아직 없어 안 냄(지어내지 않음).
FILTER_KEYS = ("division", "axis_role")
ALL = ""
#: 값을 안 보냈을 때 거는 값 — **잣대가 정한다**(`axis_policy.json` 의 `list_default`).
#: ⚠ 코드에 박지 않는다 — 총칙 범위는 기관이 정하는 것이라 관리자가 고칠 수 있어야 한다.
#: 잣대를 못 읽을 때만 쓰는 대비값(총칙 줄은 기본 감춤 · 「전부」를 고르면 보임 · 2026-09-17 브레인).
FALLBACK_DEFAULTS = {"axis_role": "work_item"}
def _facets(kind: str) -> dict[str, dict[str, str]]:
@@ -163,7 +177,7 @@ def _policy() -> dict[str, Any]:
def _role_names() -> dict[str, str]:
"""거르기 상자에 뜰 이름. 목록에서는 「총칙」이 아니라 공종 아님 · 규정·기준」으로 적는다."""
"""거르기 상자에 뜰 이름. 목록에서는 「총칙」이 아니라 **공종 아님**을 드러내는 이름으로 적는다."""
roles = _policy().get("roles") or {}
return {
key: str(role.get("list_label") or role.get("name_ko") or key)
@@ -171,31 +185,28 @@ def _role_names() -> dict[str, str]:
}
def _default_of(key: str) -> str:
"""이 가름의 기본값 — **잣대가 정한다**(화면도 서버도 스스로 가르지 않음).
총칙 줄은 표가 붙어 목록에 섞여 뜨지만 공종이 아니라 규정·기준이다. 기본은 안 보이되
상자를 고르면 보인다 — ⚠ **지운 것이 아니라 감춘 것**이다(2026-09-17 브레인).
"""
def _defaults() -> dict[str, str]:
"""안 고른 가름에 걸 값 — 잣대의 `list_default`. 못 읽으면 대비값."""
wanted = _policy().get("list_default") or {}
return str(wanted.get("value") or ALL) if wanted.get("filter") == key else ALL
key = str(wanted.get("filter") or "")
return {key: str(wanted.get("value") or ALL)} if key else dict(FALLBACK_DEFAULTS)
def filters(kind: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""거르기 상자 — 값이 둘 이상 갈리는 가름만. 기본은 **잣대**가 정한다(`list_default`)."""
"""거르기 상자 — 값이 둘 이상 갈리는 가름만 · 기본은 **잣대**(`list_default`)가 정한다 — 그 값이 표에 있을 때만."""
facets = _facets(kind)
labels = {"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:
continue
label, names = labels[key]
default = _default_of(key)
default = _defaults().get(key, ALL)
out.append(
{
"key": key,
@@ -211,16 +222,13 @@ def filters(kind: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
def apply_filters(
kind: str, rows: list[dict[str, Any]], picked: dict[str, str]
) -> list[dict[str, Any]]:
"""고른 값으로 거른다. **안 고른 가름은 잣대의 기본값**을 쓴다.
⚠ 기본값을 서버가 매기는 까닭 — 화면만 기본값을 알면 「상자엔 공종이라 떠 있는데 목록엔
총칙이 섞여 있는」 어긋남이 난다. 「전부」는 빈 값을 **적어 보내면** 된다.
"""
facets = _facets(kind)
# 안 보낸 가름은 상자 기본값으로 — 상자에 보이는 값과 걸린 값이 같게(찾기 결과가 아니라 표 전체로 정함)
defaults = {f["key"]: f["default"] for f in filters(kind, base_rows(kind))}
for key in FILTER_KEYS:
value = picked[key] if key in picked else _default_of(key)
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
@@ -231,7 +239,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),
},
}
@@ -1389,10 +1389,11 @@
"note": "뿌리부터 전체 경로(「토공 › 토사깍기 › 기계」) — 잎 이름만으론 겹침."
},
{
"key": "variant_keys",
"name_ko": "갈래",
"key": "variant",
"name_ko": "갈래",
"unit": "",
"visible": true
"visible": true,
"note": "갈래마다 한 줄(일위대가가 갈래마다 다름) — 줄 열쇠는 공종 열쇠#갈래."
},
{
"key": "pum_tables",
@@ -1452,10 +1453,11 @@
"note": "부문부터 전체 경로(「공통부문 › 토공사 › 굴착 › 굴착(인력/토사)」) — 부문마다 장 번호가 1부터라 부문이 뿌리."
},
{
"key": "variant_keys",
"name_ko": "갈래",
"key": "variant",
"name_ko": "갈래",
"unit": "",
"visible": true
"visible": true,
"note": "갈래마다 한 줄(일위대가가 갈래마다 다름) — 줄 열쇠는 공종 열쇠#갈래."
},
{
"key": "pum_tables",
@@ -67,7 +67,10 @@
"group": 82,
"general_provision": 78,
"empty": 13
}
},
"known_defects": [
"갈래 문자열이 그대로 줄 열쇠가 됨(`@id` = `FW-00105#1.11.5`) — 품셈 원문이 물결표를 두 가지로 쓰고(∼ U+223C · ~ U+FF5E) 자간 공백만 다른 짝도 있어, 문자열이 손질되면 열쇠가 갈린다. ⬜ 갈래도 장부에 올릴지 판정 대기(2026-09-17 데스크탑 서브)."
]
},
"const": {
"name_ko": "건설 공종",
@@ -252,6 +252,25 @@ def test_건설_총칙_뿌리가_실제로_붙음() -> None:
assert 기계["axis_role"] != "general_provision"
def test_갈래_문자열이_열쇠라_흔들림을_못박음(master: dict) -> None:
"""⚠ 갈래가 줄 열쇠에 그대로 들어간다(`@id` = `FW-00105#1.11.5`).
품셈 원문이 물결표를 두 가지로 쓰고 자간 공백도 들쭉날쭉이라, 문자열을 손질하면 **열쇠가 갈린다.**
지금 몇 개가 그런지 못박아 둔다 — 늘면 손질이 들어간 것이고, 줄면 열쇠가 바뀐 것이다.
"""
import collections
keys = [k for it in master["work_items"] for k in (it.get("variant_keys") or [])]
assert len(keys) == 335
tilde = collections.Counter(ch for k in keys for ch in k if ch in "∼~")
assert tilde == {"": 26, "": 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_어느_구실도_지우지_않음() -> None:
"""브레인 승인(2026-09-17) — 「덜어내기」가 아니라 「가름만」."""
import json as _json
+66 -27
View File
@@ -38,17 +38,22 @@ 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)
# 2026-09-17 목록 기본이 **공종만**(잣대 `list_default`) — 뜨는 줄 306, 총칙 59 는 감춰져 있다.
# `stats` 는 쪽 나누기·거르기와 무관한 **전체** 셈이라 365 그대로다.
assert table["total"] == 306 and table["stats"] == {"work_items": 365, "units": 627}
table = _get(client, "work_item_forest", size=500, axis_role="") # 「전부」 — 총칙까지
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}) == 306 # 뜨는 줄 = 공종만
# 잎 이름 겹침 — 경로로 풀림. 뜨는 줄만큼 이름도 다 달라야 한다(겹치면 사람이 못 고름).
assert len({r["name"] for r in rows}) == 306
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["@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"] == "토공 토사깍기 기계"
@@ -56,21 +61,22 @@ 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:
"""건설(2026-09-17 뽑음) — 부문이 뿌리라 경로가 부문부터 · 열쇠 CW- · 계산 규칙은 전부 choose_one."""
table = _get(client, "work_item_const", size=500)
# 기본이 공종만이라 뜨는 줄 983 · 전체 997(총칙 14 감춤)
assert table["total"] == 983 and table["stats"]["work_items"] == 997
table = _get(client, "work_item_const", size=500, axis_role="")
assert table["total"] == table["stats"]["work_items"] > 900
rows = table["rows"]
assert all(r["@id"].startswith("CW-") for r in rows)
assert all(r["name"].split(" ")[0].endswith("부문") for r in rows)
@@ -103,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}
@@ -111,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"]
@@ -120,17 +127,12 @@ def test_불변_열쇠가_오면_id_로_씀_자료_파일은_판_id_로_가림(
def test_거르기는_자료에_있는_가름만_서버가_판정(client: TestClient) -> None:
"""화면(서브 b890fba0)이 `filters` 를 받으면 상자를 세우고 고른 값만 보냄 — 판정은 서버 한 곳.
건설 부문 다섯 · 구실(이름은 axis_policy.json). 임도가 쓰는 잣대가 없어 ."""
const = _get(client, "work_item_const", size=1)
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"}
division = by_key["division"]
assert division["default"] == ""
# 거르기 상자의 셈은 **전체** 기준이다 — 「전부 997」. 지금 뜨는 줄(`total`)과는 다르다.
assert division["options"][0] == {
"value": "",
"label": "전부",
"rows": const["stats"]["work_items"],
}
assert division["options"][0] == {"value": "", "label": "전부", "rows": const["total"]}
assert [o["value"] for o in division["options"][1:]] == [
"공통부문",
"토목부문",
@@ -138,14 +140,14 @@ def test_거르기는_자료에_있는_가름만_서버가_판정(client: TestCl
"기계설비부문",
"유지관리부문",
]
assert sum(o["rows"] for o in division["options"][1:]) == const["stats"]["work_items"]
# 2026-09-17 목록에서는 「총칙」이 아니라 **공종이 아님**을 드러내는 이름으로 적는다(브레인).
assert sum(o["rows"] for o in division["options"][1:]) == const["total"]
# 2026-09-17 목록에서는 「총칙」이 아니라 **공종이 아님**을 드러내는 이름(잣대 `list_label`).
assert {o["value"]: o["label"] for o in by_key["axis_role"]["options"]}[
"general_provision"
] == "공종 아님 · 규정·기준"
assert by_key["axis_role"]["default"] == "work_item" # 기본 감춤 — 지운 것이 아니라 감춘 것
assert by_key["axis_role"]["default"] == "work_item" # 감춘 것이지 지운 것이 아
civil = _get(client, "work_item_const", size=500, division="토목부문")
civil = _get(client, "work_item_const", size=500, division="토목부문", axis_role="")
assert civil["total"] == next(
o["rows"] for o in division["options"] if o["value"] == "토목부문"
)
@@ -158,3 +160,40 @@ def test_거르기는_자료에_있는_가름만_서버가_판정(client: TestCl
assert [f["key"] for f in _get(client, "work_item_forest", size=1)["filters"]] == ["axis_role"]
assert _get(client, "labor", size=1, division="토목부문")["total"] == 261 # 다른 표는 안 거름
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"):
shown = _get(client, kind, size=500)
role = next(f for f in shown["filters"] if f["key"] == "axis_role")
assert role["default"] == "work_item"
counts = {o["value"]: o["rows"] for o in role["options"]}
assert counts["general_provision"] > 0
# 기본 = 공종만 · 찾기 결과로 기본이 흔들리지 않음(총칙만 걸리는 낱말도 기본은 감춤)
assert shown["total"] == counts["work_item"]
assert (
_get(client, kind, size=500, q="적용기준")["total"]
< _get(client, kind, size=500, q="적용기준", axis_role="")["total"]
)
# 「전부」(빈 값을 보냄) → 총칙까지
assert _get(client, kind, size=1, axis_role="")["total"] == counts[""]
assert (
_get(client, kind, size=1, axis_role="general_provision")["total"]
== (counts["general_provision"])
)