Files
Aislo/Z01_MasterData/Z01_MasterData_WorkItems.py
T
eomsangdonandClaude Opus 5 76f70b675f 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
2026-09-17 17:04:55 +09:00

246 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Z01 공종 축 — 산림·건설 두 kind 를 기초단가와 **같은 계약**으로(2026-09-17 브레인 · PLAN_공종축 8-4).
줄 = **품셈 표가 붙은 마디**(`axis_policy.json` `flat_list_filter: pum_tables > 0` — 잎으로 거르면 부모에 붙은 표가 안 뜸) ·
갈래(`variant_keys`)마다 한 줄로 폄 — 줄 수 = 편 수(`stats.units`) · 공종 수 = `stats.work_items`.
이름 = 뿌리부터 전체 경로(「토공 › 토사깍기 › 기계」 · 건설은 부문부터) — 잎 이름만으론 겹침.
계산 규칙 = 표 수준 `rules`(부모 마디 choose_one · sum_steps) + 줄 칸 `rule`·`step_weight`
— 빠지면 발파암(절취 0.1 + 깍기 0.9 + 집토 1) 금액이 틀어짐.
`@id` = 불변 열쇠(FW-·CW-) — 없는 줄은 목차 코드로 서고 알림에 드러냄.
⚠ 읽기만 — 고칠 칸 없음(원문 값 풀기 8-7 뒤). 원본 `work_item_code`(FP-)는 B08·B09 가 씀 — 안 건드림.
"""
from __future__ import annotations
import json
from functools import lru_cache
from pathlib import Path
from typing import Any
from Z01_MasterData import Z01_MasterData_Tables as tables
GROUP = "work_item"
#: kind → 원본 품셈 dataset_id — 공종 축 파일 `dataset_version.dataset_id` 로 가림(파일 이름에 기대지 않음)
SOURCE_OF = {
"work_item_forest": "pum_forest",
"work_item_const": "pum_const",
} # 이름표 `work_item_axis` 와 같은 이름
FOLDER = tables.RESOURCES / "data_work_item_master"
#: 불변 열쇠 칸 — ⚠ 데스크탑 서브 자료가 오면 칸 이름을 맞출 것(여기 한 곳)
KEY_FIELD = "work_item_key"
PATH_JOIN = " "
_LOCKED = "공종 축 뼈대 — 품셈 원문으로만 바뀜(값 고치기는 원문 값 풀기 뒤)"
@lru_cache(maxsize=8)
def _read(path: str, _mtime_ns: int) -> dict[str, Any]:
return json.loads(Path(path).read_text(encoding="utf-8"))
def doc(kind: str) -> tuple[dict[str, Any], str] | None:
"""그 품셈의 공종 축 끝 판(문서 · 파일 이름) — 없으면 None(건설은 아직 없음)."""
found = []
for path in sorted(FOLDER.glob("*work_item_master_*.json")): # 건설은 `const_` 앞꼬리
d = _read(str(path), path.stat().st_mtime_ns)
if (d.get("dataset_version") or {}).get("dataset_id") == SOURCE_OF[kind]:
found.append((d, path.name))
return max(found, key=lambda each: each[0].get("effective_date") or "", default=None)
#: 자료가 있는 품셈만 kind 로 섬 — 빈 상자는 이름만 뜨고 안 열림 · 건설은 자료가 오면 서버 재시작 뒤 저절로
KINDS = tuple(kind for kind in SOURCE_OF if doc(kind))
def _key(item: dict[str, Any]) -> str:
return item.get(KEY_FIELD) or item["work_item_code"]
def _tree(d: dict[str, Any]) -> tuple[list[dict], dict[str, dict], dict[str, list[dict]]]:
items = d.get("work_items") or []
children: dict[str, list[dict]] = {}
for item in items:
if item.get("parent_code"):
children.setdefault(item["parent_code"], []).append(item)
return items, {i["work_item_code"]: i for i in items}, children
def _path(item: dict[str, Any] | None, by: dict[str, dict]) -> str:
names = []
while item:
names.append(item["name"])
item = by.get(item.get("parent_code"))
return PATH_JOIN.join(reversed(names))
def base_rows(kind: str) -> list[dict[str, Any]]:
found = doc(kind)
if found is None:
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"):
continue
parent = by.get(item.get("parent_code")) or {}
weight = next(
(s["weight"] for s in parent.get("steps") or [] if s["code"] == item["work_item_code"]),
None,
)
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
def rules(kind: str) -> list[dict[str, Any]]:
"""부모 마디 계산 규칙 — choose_one(아래 중 하나) · sum_steps(아래를 무게대로 더해야 한 단위)."""
found = doc(kind)
if found is None:
return []
items, by, children = _tree(found[0])
out = []
for item in items:
mode = item.get("parent_mode")
if not mode:
continue
rule = {
"@id": _key(item),
"work_item_code": item["work_item_code"],
"name": _path(item, by),
"mode": mode,
"children": [_key(c) for c in children.get(item["work_item_code"], [])],
}
if mode == "sum_steps":
rule["steps"] = [
{"@id": _key(by[s["code"]]), "weight": s["weight"]} for s in item["steps"]
]
rule["basis"] = item.get("steps_basis")
out.append(rule)
return out
def spec(columns: list[str]) -> dict[str, Any]:
return {"editable": [], "locked": dict.fromkeys(columns, _LOCKED), "formula": {}}
def notice(kind: str) -> list[str]:
lines = ["공종 축은 읽기만 — 고칠 칸 없음(품셈 원문 값 풀기 뒤에 열림)"]
found = doc(kind)
if found and any(KEY_FIELD not in i for i in found[0].get("work_items") or []):
lines.append(
"불변 열쇠(FW-·CW-) 아직 없음 — 목차 코드(FP-)를 열쇠로 씀 · 품셈 개정으로 번호가 밀리면 딴 줄을 가리킬 수 있음"
)
return lines
#: 거르기 — **자료에 있는 가름만**(건설 부문 · 줄 구실). 어느 줄을 거를지는 서버 한 곳 · 화면은 고른 값만 보냄.
#: ⚠ 「임도가 쓰는 것」 같은 쓰임 가름은 잣대가 아직 없어 안 냄(지어내지 않음).
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]]:
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}
def _policy() -> dict[str, Any]:
"""줄 구실 잣대 — `axis_policy.json`(데스크탑 서브) 한 곳에서."""
path = FOLDER / "axis_policy.json"
return _read(str(path), path.stat().st_mtime_ns) if path.is_file() else {}
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)
for key, role in roles.items()
}
def _defaults() -> dict[str, str]:
"""안 고른 가름에 걸 값 — 잣대의 `list_default`. 못 읽으면 대비값."""
wanted = _policy().get("list_default") or {}
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`)가 정한다 — 그 값이 표에 있을 때만."""
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["@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 = _defaults().get(key, ALL)
out.append(
{
"key": key,
"label": label,
"options": [{"value": ALL, "label": "전부", "rows": len(rows)}]
+ [{"value": v, "label": names.get(v, v), "rows": counts[v]} for v in values],
"default": default if default in values else ALL,
}
)
return out
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.get(key, defaults.get(key, ALL))
if value != ALL:
rows = [r for r in rows if facets.get(r["@key"], {}).get(key) == value]
return rows
def extra(kind: str) -> dict[str, Any]:
"""표 수준 덧붙임 — 계산 규칙 · 줄 수 · 갈래 편 수(쪽 나누기와 무관하게 전체)."""
rows = base_rows(kind)
return {
"filters": filters(kind, rows),
"rules": rules(kind),
"stats": {
"work_items": len({r["@key"] for r in rows}),
"units": len(rows),
},
}