Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
479 lines
21 KiB
Python
479 lines
21 KiB
Python
"""산림사업 표준품셈 476표 → 공종 마스터 정규화 (B08 일감 1번 · 공종 축).
|
||
|
||
무엇을 만드나
|
||
`resources/data_work_item_master/work_item_master_<effective_date>.json` — 공종 계층 + 표 귀속.
|
||
같은 폴더에 `form_undetermined_*.json`(형태 판정 실패분)과 `_manifest.json` 을 함께 낸다.
|
||
|
||
왜 이렇게 나누나 (PLAN 8-7 담당 경계)
|
||
이 파일은 **공종 축만** 만든다. 자원 축(`resource_kind`·`resource_code`·`amount`)은
|
||
단가표를 아는 B09 가 뒤 패스로 채운다. 그래서 각 표의 **원문 셀(`raw_row`)을 그대로 실어
|
||
보낸다** — 버리면 B09 가 476표를 다시 열어야 한다.
|
||
|
||
공종의 정체 = 품셈의 **절 번호**다
|
||
품셈은 「9-3-1. 인력」처럼 절 제목이 공종이고, 표의 행은 그 공종의 **조건별 변형**
|
||
(토질·암종·규격)이다. 그래서 계층·정렬은 목차표(`F0001`)에서 나오고, 표는 그 절에 붙는다.
|
||
|
||
⚠ 최대 함정 — `pum_form` (PLAN 8-6)
|
||
같은 숫자라도 뜻이 반대다.
|
||
productivity(생산량형) : 「㎥/hr」·「㎥/1인/1일」 → 품 = 1 ÷ 값
|
||
requirement(소요량형) : 「100㎥당 x인」 → 품 = 값 ÷ 밑수
|
||
태그가 없으면 뒤집힌 값이 조용히 들어간다. **판정 못 한 표는 빈칸으로 두지 않고
|
||
`form_undetermined` 목록으로 뽑아** 사람이 보게 한다.
|
||
|
||
실행
|
||
./venv/Scripts/python.exe B08_Quantity/B08_Quantity_Build_WorkItemMaster.py
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import difflib
|
||
import hashlib
|
||
import json
|
||
import re
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
# 표 한 장 읽기(형태·밑수·깃발)는 `_Table` — 옛 이름은 여기서도 그대로 부름(시험·다른 파일이 씀).
|
||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Table import ( # noqa: F401
|
||
REFERENCE_MARKS,
|
||
basis_from_name,
|
||
basis_from_source,
|
||
basis_quantity_is_grouped,
|
||
capacity_formula_pending,
|
||
crew_table,
|
||
detect_basis,
|
||
detect_form,
|
||
expression_cells,
|
||
formula_rows,
|
||
new_report,
|
||
norm,
|
||
person_basis_ok,
|
||
resource_shares,
|
||
spaced_names,
|
||
special_glyphs,
|
||
table_entry,
|
||
)
|
||
|
||
ROOT = Path(__file__).resolve().parent.parent
|
||
SOURCE = ROOT / "resources" / "data_cost_input_value" / "pum_forest_2026.json"
|
||
OUT_DIR = ROOT / "resources" / "data_work_item_master"
|
||
#: 불변 열쇠 장부 — **판이 붙지 않는다.** 판마다 새로 짓는 산출물이 아니라 대대로 잇는 장부다.
|
||
KEY_REGISTRY = OUT_DIR / "work_item_keys.json"
|
||
|
||
SCHEMA_VERSION = "1.0"
|
||
CODE_PREFIX = "FP" # Forest Pumsem — 공종코드 접두. 품셈 절 번호를 그대로 싣는다.
|
||
|
||
|
||
def sha256_of(path: Path) -> str:
|
||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||
|
||
|
||
def parse_toc(rows: list[list[str]]) -> list[dict[str, Any]]:
|
||
"""목차표(F0001) → 계층 목록. 장(제n장)·절(n-n)·항(n-n-n)·목(n-n-n-n)."""
|
||
nodes: list[dict[str, Any]] = []
|
||
order = 0
|
||
for raw in rows:
|
||
cells = [norm(c) for c in raw]
|
||
cells = [c for c in cells if c]
|
||
if not cells:
|
||
continue
|
||
key = cells[0]
|
||
name = cells[1] if len(cells) > 1 else ""
|
||
if m := re.fullmatch(r"제(\d+)장", key):
|
||
number = m.group(1)
|
||
elif re.fullmatch(r"\d+(?:-\d+){1,3}", key):
|
||
number = key
|
||
else:
|
||
continue # 부록 등 — 공종 계층이 아니다.
|
||
order += 256 # STmate 의 SORTCODE 관례(256 간격) — 중간 삽입 여유.
|
||
nodes.append({"number": number, "name": name, "sort_order": order, "tables": []})
|
||
_set_number(nodes[-1], number)
|
||
return nodes
|
||
|
||
|
||
def _set_number(node: dict[str, Any], number: str) -> None:
|
||
"""번호에서 코드·단계·윗줄을 셈 — 목차를 읽을 때와 본문 번호로 바로잡을 때 한 벌."""
|
||
parts = number.split("-")
|
||
node.update(
|
||
{
|
||
"work_item_code": f"{CODE_PREFIX}-" + "-".join(p.zfill(2) for p in parts),
|
||
"number": number,
|
||
"level": len(parts),
|
||
"parent_code": (
|
||
f"{CODE_PREFIX}-" + "-".join(p.zfill(2) for p in parts[:-1])
|
||
if len(parts) > 1
|
||
else None
|
||
),
|
||
}
|
||
)
|
||
|
||
|
||
#: 본문 절 제목 — 「12-17-3. 무근진동기 제외」 꼴(번호 + 마침표 + 이름)만. 문장 조각은 안 봄.
|
||
_BODY_TITLE_RE = re.compile(r"^\s*(\d+(?:-\d+){0,3})\.\s+(.+)$")
|
||
|
||
|
||
def renumber_from_body(
|
||
nodes: list[dict[str, Any]], tables: list[dict[str, Any]]
|
||
) -> list[dict[str, str]]:
|
||
"""⚠ **목차 오기**를 본문 절 번호로 바로잡음 — 원문이 또렷하면 원문(2026-09-17 코덱스 원문 대조 · 브레인).
|
||
|
||
품셈 목차가 「12-17-2 무근진동기 제외」(본문 12-17-3) · 「12-24-1 지수판 설치」(본문 12-27-1) 로
|
||
**같은 번호를 두 번** 적었음. 목차만 믿으면 뒤 줄이 앞 표를 덮어써 이름과 표가 어긋난 줄이 서고
|
||
진짜 표(F0364·F0375)가 orphan 으로 사라짐 — 재료·노무·장비가 있는 원가 공종이라 금액이 빠짐.
|
||
⚠ 겹친 번호의 줄이고 · 본문에 같은 이름 제목이 있고 · 그 본문 번호가 목차에 없을 때만 고침.
|
||
셋 중 하나라도 아니면 그대로 둠(겹친 채 남아 `toc_duplicate_rows` 와 시험이 드러냄).
|
||
"""
|
||
body: dict[str, set[str]] = {}
|
||
for table in tables:
|
||
if found := _BODY_TITLE_RE.match(norm(table.get("section"))):
|
||
body.setdefault("".join(found.group(2).split()), set()).add(found.group(1))
|
||
count: dict[str, int] = {}
|
||
for node in nodes:
|
||
count[node["number"]] = count.get(node["number"], 0) + 1
|
||
fixed = []
|
||
for node in nodes:
|
||
numbers = body.get("".join(node["name"].split()), set())
|
||
if count[node["number"]] < 2 or node["number"] in numbers:
|
||
continue
|
||
candidates = sorted(numbers - set(count))
|
||
if len(candidates) != 1:
|
||
continue
|
||
printed = node["number"]
|
||
_set_number(node, candidates[0])
|
||
count[printed] -= 1
|
||
count[node["number"]] = 1
|
||
fixed.append({"name": node["name"], "toc_number": printed, "body_number": node["number"]})
|
||
return fixed
|
||
|
||
|
||
#: ⚠ [주] 속 **인용** — 「‘13-3. 기초다짐 및 뒤채움’ 항을 적용한다」(13-4-1 [주]④ · 13-5-2 [주]⑤).
|
||
#: 「번호. 이름」 꼴이라 제목으로 읽혀 표 셋(F0405·F0406·F0418)이 13-3 에 붙고 없는 갈래 3줄이 섰음
|
||
#: (2026-09-17 서브 훑기 · 브레인). 닫는 따옴표 뒤에 「항·을·를」이 오면 인용.
|
||
_CITATION_RE = re.compile(r"[’”]\s*(?:항|을|를)")
|
||
|
||
|
||
def _is_section_title(section: str, by_number: dict[str, dict[str, Any]]) -> bool:
|
||
"""진짜 절 제목인가 — 「번호. 이름」(인용 아님), 또는 마침표 없이 번호 뒤 글이 목차 절 이름과 같음."""
|
||
if re.match(r"^\s*\d+(?:-\d+){0,3}\.\s", section):
|
||
return not _CITATION_RE.search(section)
|
||
number = section_number(section)
|
||
node = by_number.get(number) if number else None
|
||
if node is None:
|
||
return False
|
||
tail = section.strip()[len(number) :]
|
||
return "".join(tail.split()) == "".join(str(node.get("name") or "").split())
|
||
|
||
|
||
#: 목차 이름과 본문 제목이 **글자만** 다른 자리(「깍기」·「깎기」 · 「㎝」·「cm」)를 같게 봄 — 이름 닮음 잴 때만.
|
||
_NAME_GLYPHS = (("㎝", "cm"), ("․", "·"), ("ㆍ", "·"), ("~", "~"), ("∼", "~"), ("깎", "깍"))
|
||
#: 이름 닮음 아래턱 — 부록 사례(「4-1. 소나무재선충병방제」 ↔ 목차 4-1 수확베기)는 0.09 이하 ·
|
||
#: 진짜 제목의 글자 차이(「비탈면 고르기」 ↔ 「비탈면 면고르기」 0.96 · 「면벽」 ↔ 「면벅」 0.5)는 0.34 이상(2026-09-17 전수).
|
||
NAME_LIKENESS_FLOOR = 0.3
|
||
|
||
|
||
def _plain(name: str) -> str:
|
||
text = "".join(str(name or "").split())
|
||
for glyph, same in _NAME_GLYPHS:
|
||
text = text.replace(glyph, same)
|
||
return text
|
||
|
||
|
||
def title_names_node(section: str, node: dict[str, Any]) -> bool:
|
||
"""「번호. 이름」 제목의 이름이 그 번호 목차 줄 이름을 가리키는가 — 번호만 겹친 **딴 문서 표**를 거름.
|
||
|
||
⚠ 부록 사례 표 일곱(F0455·F0456·F0472~F0476)이 본문 절 번호와 겹쳐 그 절에 붙어
|
||
없는 공종 줄(4-1 수확베기 ← 소나무재선충병방제)이 섰음(2026-09-17 브레인 헛단위). 못 붙인 표로 목록에 남김.
|
||
"""
|
||
found = re.match(r"^\s*\d+(?:-\d+){0,3}\.\s+(.+)$", section)
|
||
if found is None:
|
||
return True # 마침표 없는 제목은 `_is_section_title` 이 이미 목차 이름과 같은지 봤음
|
||
title, name = _plain(found.group(1)), _plain(node.get("name"))
|
||
if title.startswith(name) or name.startswith(title):
|
||
return True
|
||
return difflib.SequenceMatcher(None, title, name).ratio() >= NAME_LIKENESS_FLOOR
|
||
|
||
|
||
def section_number(section: str) -> str | None:
|
||
"""`"9-3-1. 인력"` → `"9-3-1"`. 번호가 없으면 `None`."""
|
||
m = re.match(r"^\s*(\d+(?:-\d+){0,3})[.\s]", section + " ")
|
||
return m.group(1) if m else None
|
||
|
||
|
||
def build() -> tuple[
|
||
dict[str, Any],
|
||
list[dict[str, Any]],
|
||
list[dict[str, Any]],
|
||
tuple[dict[str, Any], list[dict[str, str]]],
|
||
]:
|
||
data = json.loads(SOURCE.read_text(encoding="utf-8"))
|
||
# 사람 판정 선언(표 형태·부모 모양)은 절 번호로 적혀 판에 묶임 — 판이 다르면 멈춤(명세 17장).
|
||
from common_util.common_util_pum_edition import require_code_edition
|
||
|
||
require_code_edition(data["effective_date"], "공종 마스터 빌드 — 표 형태·부모 모양 판정")
|
||
tables = data["variables"]["pum"]["tables"]
|
||
# 원문을 함께 연다 — 밑수가 표 밖(본문)에 있기 때문이다. 못 열면 밑수 없이 간다.
|
||
source_lines: list[str] = []
|
||
for entry in data.get("sources") or []:
|
||
candidate = ROOT / str(entry.get("path") or "")
|
||
if candidate.is_file():
|
||
source_lines = candidate.read_text(encoding="utf-8").splitlines()
|
||
break
|
||
toc_table = next(t for t in tables if t["table_id"] == "F0001")
|
||
nodes = parse_toc(toc_table["rows"])
|
||
toc_corrections = renumber_from_body(nodes, tables)
|
||
by_number = {n["number"]: n for n in nodes}
|
||
|
||
attached = 0
|
||
report = new_report()
|
||
orphans: list[dict[str, Any]] = []
|
||
|
||
last_section = ""
|
||
for table in tables:
|
||
if table["table_id"] == "F0001":
|
||
continue # 목차 자신은 공종이 아니다.
|
||
section = norm(table.get("section"))
|
||
# ⚠ 원본 자료가 [주] 문장 조각을 절 제목으로 읽은 표가 있음 — 「12-2 표면 마무리를 따른다.」
|
||
# (12-17-1 펌프카 타설 [주]) 가 표 7장을 12-2 에 붙여 가짜 갈래가 섰음(2026-09-14).
|
||
# 절 제목은 「번호. 이름」 이거나(마침표 없는 원문 제목 「7-15-2 숲가꾸기, …」 도 있음)
|
||
# 번호 뒤 글이 **목차의 그 절 이름과 같음** — 둘 다 아니면 문장 조각이라 앞 절을 이어받음.
|
||
if _is_section_title(section, by_number):
|
||
last_section = section
|
||
elif last_section:
|
||
section = last_section
|
||
number = section_number(section)
|
||
chapter = number.split("-")[0] if number else None
|
||
node = by_number.get(number) if number else None
|
||
if node is not None and not title_names_node(section, node):
|
||
node = None # 번호만 겹친 딴 문서 표(부록 사례) — 못 붙인 표 목록으로
|
||
entry = table_entry(
|
||
table, section, number, chapter, source_lines, node and node["name"], report
|
||
)
|
||
if node is None:
|
||
orphans.append({"pum_table_id": table["table_id"], "section": section})
|
||
continue
|
||
node["tables"].append(entry)
|
||
attached += 1
|
||
|
||
# 부모 모양(명세 2장) — 기본 choose_one, 합산형은 사람이 적은 것만.
|
||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Parents import apply_parent_modes
|
||
|
||
apply_parent_modes(nodes)
|
||
# 갈래 키(명세 14장 정정) — 표 모양 가르기는 B09 표 읽기 한 벌을 그대로 돌려 씀.
|
||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Variants import attach_variant_keys
|
||
|
||
attach_variant_keys(nodes, data["effective_date"])
|
||
return finish(
|
||
data,
|
||
source=SOURCE,
|
||
dataset_id="work_item_master_forest",
|
||
nodes=nodes,
|
||
tables_total=len(tables) - 1,
|
||
attached=attached,
|
||
orphans=orphans,
|
||
report=report,
|
||
registry_path=KEY_REGISTRY,
|
||
toc_corrections=toc_corrections,
|
||
)
|
||
|
||
|
||
def finish(
|
||
data: dict[str, Any],
|
||
*,
|
||
source: Path,
|
||
dataset_id: str,
|
||
nodes: list[dict[str, Any]],
|
||
tables_total: int,
|
||
attached: int,
|
||
orphans: list[dict[str, Any]],
|
||
report: dict[str, Any],
|
||
registry_path: Path,
|
||
toc_corrections: list[dict[str, str]],
|
||
key_prefix: str = "FW",
|
||
general_roots: tuple[str, ...] | None = None,
|
||
policy: dict[str, Any] | None = None,
|
||
) -> tuple[
|
||
dict[str, Any],
|
||
list[dict[str, Any]],
|
||
list[dict[str, Any]],
|
||
tuple[dict[str, Any], list[dict[str, str]]],
|
||
]:
|
||
"""열쇠·목차 칸을 얹고 산출 한 벌을 짬 — 산림·건설 같은 꼴(`_Const` 도 이리 옴)."""
|
||
# 불변 열쇠(`FW-00001`) — 목차 번호를 열쇠에서 칸으로 내린다(2026-09-17 브레인 ①).
|
||
# ⚠ 장부에 없는 목차 코드는 **새 열쇠**를 받는다. 짐작으로 옛 열쇠를 물려주지 않는다.
|
||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Keys import (
|
||
assign_keys,
|
||
decorate,
|
||
load_registry,
|
||
toc_edition,
|
||
)
|
||
|
||
edition = toc_edition(data.get("sources"), data["effective_date"])
|
||
registry = load_registry(registry_path, key_prefix)
|
||
newly_issued, toc_duplicates = assign_keys(nodes, registry, edition=edition)
|
||
decorate(nodes, edition=edition, roots=general_roots) # None = 산림 잣대(`axis_policy.json`)
|
||
role_counts: dict[str, int] = {}
|
||
for node in nodes:
|
||
role_counts[node["axis_role"]] = role_counts.get(node["axis_role"], 0) + 1
|
||
|
||
src_meta = {
|
||
"dataset_id": data["dataset_id"],
|
||
"effective_date": data["effective_date"],
|
||
"sha256": sha256_of(source),
|
||
"file": source.name,
|
||
}
|
||
return (
|
||
{
|
||
"schema_version": SCHEMA_VERSION,
|
||
"dataset_id": dataset_id,
|
||
"effective_date": data["effective_date"],
|
||
# 공종 코드(`FP-*` 절 번호)가 묶인 품셈 판 — 자원 축·인계 줄이 이 값을 싣는다.
|
||
"pum_edition": data["effective_date"],
|
||
# 목차 번호가 묶인 고시 판. `work_item_code`(=`toc_code`)는 **이 판의 목차 번호**다.
|
||
"toc_edition": edition,
|
||
"generated_at": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
|
||
"dataset_version": src_meta,
|
||
"policy": {
|
||
"axis": "work_item_only",
|
||
"resource_axis_owner": "B09",
|
||
"no_invented_values": True,
|
||
"raw_row_preserved": True,
|
||
# 줄의 열쇠는 `work_item_key`(`FW-…`)다. `work_item_code`·`toc_number` 는
|
||
# **그 판의 목차 번호**라 개정 때 밀린다 — 열쇠로 쓰지 말 것.
|
||
"row_key": "work_item_key",
|
||
"toc_is_column_not_key": True,
|
||
**(policy or {}),
|
||
},
|
||
"stats": {
|
||
"toc_nodes": len(nodes),
|
||
"tables_total": tables_total,
|
||
"tables_attached": attached,
|
||
"tables_orphan": len(orphans),
|
||
"form_undetermined": len(report["undetermined"]),
|
||
"basis_found": report["basis_found"],
|
||
"basis_missing": len(report["basis_missing"]),
|
||
"basis_grouped": report["basis_grouped"],
|
||
# 줄의 구실 — 공종(`work_item`) · 묶는 마디(`group`) · 총칙(`general_provision`) ·
|
||
# 표도 아래도 없는 잎(`empty`). 화면·조합은 공종 축만 보면 된다.
|
||
"axis_roles": role_counts,
|
||
"keys_newly_issued": len(newly_issued),
|
||
"toc_duplicate_rows": len(toc_duplicates),
|
||
"toc_corrections": len(toc_corrections),
|
||
},
|
||
# 목차 오기를 본문 절 번호로 바로잡은 줄 — 목차에 적힌 번호(`toc_number`)도 남김.
|
||
"toc_corrections": toc_corrections,
|
||
# ⚠ 품셈 목차가 **같은 번호를 두 번 적은 자리**. 목차 번호를 열쇠로 쓰면 이 줄들이
|
||
# 서로 덮는다 — 열쇠를 따로 두는 까닭의 실물이다.
|
||
"toc_duplicate_rows": toc_duplicates,
|
||
"orphan_tables": orphans,
|
||
"work_items": nodes,
|
||
},
|
||
report["undetermined"],
|
||
report["basis_missing"],
|
||
(registry, newly_issued),
|
||
)
|
||
|
||
|
||
def main() -> None:
|
||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Const import (
|
||
CONST_KEY_REGISTRY,
|
||
FILE_TAG,
|
||
build_const,
|
||
)
|
||
|
||
write_outputs(build(), KEY_REGISTRY, "")
|
||
write_outputs(build_const(), CONST_KEY_REGISTRY, f"{FILE_TAG}_")
|
||
|
||
|
||
def write_outputs(result: tuple, registry_path: Path, tag: str) -> None:
|
||
"""산출 넷(마스터·미판정·밑수 미확보·판 지문) + 장부. `tag` — 건설은 `const_`(산림 파일과 안 부딪힘)."""
|
||
master, undetermined, basis_missing, (registry, newly_issued) = result
|
||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Keys import save_registry
|
||
|
||
save_registry(registry_path, registry)
|
||
date = master["effective_date"]
|
||
master_path = OUT_DIR / f"{tag}work_item_master_{date}.json"
|
||
undet_path = OUT_DIR / f"{tag}form_undetermined_{date}.json"
|
||
basis_path = OUT_DIR / f"{tag}basis_missing_{date}.json"
|
||
|
||
master_path.write_text(json.dumps(master, ensure_ascii=False, indent=1), encoding="utf-8")
|
||
undet_path.write_text(
|
||
json.dumps(
|
||
{
|
||
"schema_version": SCHEMA_VERSION,
|
||
"dataset_id": "work_item_master_form_undetermined",
|
||
"effective_date": date,
|
||
"note": "형태를 못 정한 표. 사람이 보고 productivity/requirement/coefficient 로 확정할 것.",
|
||
"items": undetermined,
|
||
},
|
||
ensure_ascii=False,
|
||
indent=1,
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
|
||
basis_path.write_text(
|
||
json.dumps(
|
||
{
|
||
"schema_version": SCHEMA_VERSION,
|
||
"dataset_id": "work_item_master_basis_missing",
|
||
"effective_date": date,
|
||
"note": (
|
||
"밑수(「10㎡당」 같은 기준 수량)를 못 찾은 표. **1 단위당으로 단정하지 말 것** — "
|
||
"곱셈이 10배·100배 틀린다. 값을 곱해야 하는 형태(requirement·productivity)만 담는다."
|
||
),
|
||
"items": basis_missing,
|
||
},
|
||
ensure_ascii=False,
|
||
indent=1,
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
|
||
manifest = {
|
||
"schema_version": SCHEMA_VERSION,
|
||
"dataset_id": "data_work_item_master_manifest",
|
||
"generated_at": master["generated_at"],
|
||
"built_by": "B08_Quantity/B08_Quantity_Build_WorkItemMaster.py",
|
||
"source": master["dataset_version"],
|
||
"files": [
|
||
{
|
||
"file": p.name,
|
||
"sha256": sha256_of(p),
|
||
"size_bytes": p.stat().st_size,
|
||
}
|
||
for p in (master_path, undet_path, basis_path)
|
||
],
|
||
}
|
||
(OUT_DIR / (f"_manifest_{tag.rstrip('_')}.json" if tag else "_manifest.json")).write_text(
|
||
json.dumps(manifest, ensure_ascii=False, indent=1), encoding="utf-8"
|
||
)
|
||
|
||
s = master["stats"]
|
||
print(f"── {master['dataset_id']}")
|
||
print(f"목차 계층 {s['toc_nodes']}")
|
||
print(
|
||
f"표 귀속 {s['tables_attached']} / {s['tables_total']} (미귀속 {s['tables_orphan']})"
|
||
)
|
||
print(f"형태 미판정 {s['form_undetermined']}")
|
||
print(f"밑수 확보 {s['basis_found']} (묶음 기준 {s['basis_grouped']})")
|
||
print(f"밑수 미확보 {s['basis_missing']} → {basis_path.name}")
|
||
roles = s["axis_roles"]
|
||
print(
|
||
"줄 구실 공종 {} · 묶는 마디 {} · 총칙 {} · 빈 잎 {}".format(
|
||
roles.get("work_item", 0),
|
||
roles.get("group", 0),
|
||
roles.get("general_provision", 0),
|
||
roles.get("empty", 0),
|
||
)
|
||
)
|
||
print(f"열쇠 새로 발급 {len(newly_issued)} (장부 {registry_path.name})")
|
||
if s["toc_duplicate_rows"]:
|
||
print(f"목차 번호 겹침 {s['toc_duplicate_rows']} (열쇠는 안 겹침)")
|
||
print(f"산출 {master_path.relative_to(ROOT)}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|