Files
Aislo/B08_Quantity/B08_Quantity_Build_WorkItemMaster_Const.py
T

310 lines
14 KiB
Python

"""건설공사 표준품셈 2,192표 → **건설 공종 축**(`CW-00001`) (2026-09-17 브레인 8-2).
산림(`B08_Quantity_Build_WorkItemMaster.py`)과 같은 칸·같은 판정을 쓰고, 다른 셋만 여기 둠.
① 목차표가 없음 — 합본 원문(`2026년_건설공사_표준품셈.md`) 머리 「목 차」 글줄에서 계층을 읽음.
한 장이 한 줄로 이어져 있고 **쪽 번호가 다음 번호에 붙어 있음**(「31-1-1 목적」 = 3쪽 + 1-1-1)
⇒ 장 번호로 시작하는 자리까지 앞 숫자를 한 자씩 떼며 가름.
② 부문 다섯이 장 번호를 **각자 1부터** 셈 — 공통 1장 적용기준 ≠ 토목 1장 도로포장.
코드에 부문을 넣음(`CP-02-01-03` = 토목 1-3) · 뿌리 마디가 부문.
③ 표를 절에 붙이는 번호는 **본문 절 제목**에서 읽음(목차 번호를 믿지 않음 · 브레인 2026-09-17).
원천 `section` 칸은 다른 절 인용(「8-2-3 굴착기’를 적용하여」)·쪽 번호(「85-3 말뚝」)가 섞여
못 씀 → 표가 놓인 원문 줄에서 위로 거슬러 **번호 뒤 글이 목차 이름과 같은** 제목을 찾음.
장 파일 경계가 쪽 기준이라(원문 머리 경고) 앞뒤 장 제목도 받고, 파일 첫머리 표는 앞 장 파일 꼬리까지 봄.
⚠ 못 붙인 표는 조용히 버리지 않고 `orphan_tables` 목록.
⚠ 총칙·묶는 마디 덜어내기 안 함(가름만) — 총칙 뿌리는 `axis_policy.json` 건설 축(데스크탑 서브 잣대) · 미확정 둘은 안 넣음.
⚠ 갈래 키 안 붙임 — B09 표 읽기가 산림 표로만 검증됨(빈칸 = 못 가름).
"""
from __future__ import annotations
import bisect
import json
import re
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parent.parent
SOURCE = ROOT / "resources" / "data_cost_input_value" / "pum_const_2026.json"
#: 건설 열쇠 장부 — 산림 장부(`work_item_keys.json`)와 일련번호를 안 섞음
CONST_KEY_REGISTRY = ROOT / "resources" / "data_work_item_master" / "work_item_keys_const.json"
KEY_PREFIX = "CW" # Construction Work item
BOOK_NAME = "2026년_건설공사_표준품셈.md"
CODE_PREFIX = "CP" # Construction Pumsem — 목차 코드
DATASET_ID = "work_item_master_const"
FILE_TAG = "const" # 산출 파일 이름 **앞**에 붙임 — ⚠ 뒤에 붙이면 `work_item_master_*` 로 산림을 고르는 쪽(B08 밑수 대조 · B09 근거표)이 건설을 집음
DIVISIONS = ("공통부문", "토목부문", "건축부문", "기계설비부문", "유지관리부문")
_LEADER = re.compile(r"·{2,}")
_TOC_PAGE_HEAD = re.compile(r"\d*목차") # 목차 쪽 머리(「19목차」)가 줄 가운데 끼어 있음
_NUMBER = re.compile(r"\d+(?:-\d+)+")
#: 이름 바로 뒤에 이 글자가 오면 **인용**이지 제목이 아님 — 「3-2-4 터파기(기계)’를 참고하여」 ·
#: 「6-2-2 현장가공, 6-2-3 현장조립’을」 · 「1-4-6 작업제한/작업시간제한’」 · 「[주] ④’」.
#: ⚠ 제목 뒤에 본문이 바로 붙는 꼴(「8-1-4 시공능력 산정 기본식Q=…」)이 많아 허용 목록으로는 못 가름.
_QUOTED_NEXT = "’‘”“'\"/,[]~」』"
def _squeeze(text: Any) -> str:
return "".join(str(text or "").split())
def toc_text(lines: list[str]) -> dict[str, str]:
"""부문 → 그 부문 목차 글(장 줄을 이어 붙임). 목차 뒤 첫 딴 줄에서 멈춤."""
out: dict[str, str] = {}
division = None
started = False
for line in lines:
text = line.strip()
if not started:
started = _squeeze(text) == "목차"
continue
if not text:
continue
if text in DIVISIONS:
division = text
out[division] = ""
elif division and re.match(r"제\d+장", text):
out[division] += text
elif out:
break
return out
def _number_at(text: str, chapter: str, *, after_name: bool) -> int | None:
"""`text` 안에서 장 번호로 시작하는 절 번호 자리. 앞은 쪽 번호(숫자)뿐이어야 함.
`after_name` — 장 머리(「적용기준3」 + 1-1)처럼 앞에 이름 + 쪽 번호가 올 때.
"""
for index in range(len(text)):
head = text[:index]
if after_name:
if not re.fullmatch(r".*\D\d+", head):
continue
elif head and not head.isdigit():
break
if re.match(rf"{chapter}(?:-\d+)+", text[index:]):
return index
return None
def toc_entries(text: str) -> list[tuple[str, str, str]]:
"""부문 목차 글 → `(장, 번호, 이름)` 차례. 장 줄은 번호 자리에 장 번호."""
out: list[tuple[str, str, str]] = []
chapter = None
for segment in _LEADER.split(_TOC_PAGE_HEAD.sub("", text)):
segment = segment.strip()
head = re.search(r"제(\d+)장", segment)
if head:
chapter = head.group(1)
rest = segment[head.end() :]
at = _number_at(rest, chapter, after_name=True)
if at is None:
raise ValueError(f"목차 장 머리를 못 가름: {segment[:40]}")
out.append((chapter, chapter, re.sub(r"\d+$", "", rest[:at]).strip()))
segment = rest[at:]
if chapter is None or not segment or segment.isdigit():
continue
at = _number_at(segment, chapter, after_name=False)
if at is None:
raise ValueError(f"목차 줄을 못 가름: {segment[:40]}")
number = re.match(rf"{chapter}(?:-\d+)+", segment[at:]).group(0)
out.append((chapter, number, segment[at + len(number) :].strip()))
return out
def _code(root: str, number: str) -> str:
return f"{root}-" + "-".join(part.zfill(2) for part in number.split("-"))
def _node(
code: str, number: str, name: str, level: int, parent: str | None, order: int, division: str
) -> dict[str, Any]:
return {
"work_item_code": code,
"number": number,
"name": name,
"level": level,
"parent_code": parent,
"sort_order": order,
"tables": [],
"division": division,
}
def toc_nodes(lines: list[str]) -> list[dict[str, Any]]:
"""목차 → 산림과 같은 꼴 마디(부문 뿌리 + 장 + 절 + 항). ⚠ 번호가 겹치거나 윗줄이 없으면 멈춤."""
nodes: list[dict[str, Any]] = []
order = 0
for division, text in toc_text(lines).items():
root = f"{CODE_PREFIX}-{DIVISIONS.index(division) + 1:02d}"
order += 256
nodes.append(_node(root, division, division, 1, None, order, division))
for _, number, name in toc_entries(text):
parts = number.split("-")
parent = _code(root, "-".join(parts[:-1])) if len(parts) > 1 else root
order += 256
nodes.append(
_node(_code(root, number), number, name, len(parts) + 1, parent, order, division)
)
codes = [n["work_item_code"] for n in nodes]
if len(set(codes)) != len(codes):
raise ValueError("건설 목차 번호가 겹침 — 본문 대조 필요")
missing = {n["parent_code"] for n in nodes if n["parent_code"]} - set(codes)
if missing:
raise ValueError(f"건설 목차 윗줄이 없음: {sorted(missing)[:5]}")
return nodes
def file_place(source_file: str) -> tuple[str, int]:
"""`…/02_토목부문/제5장_강구조공사.md` → `("토목부문", 5)`."""
parts = Path(source_file).parts
return parts[-2].split("_", 1)[1], int(re.match(r"제(\d+)장", parts[-1]).group(1))
def titles_in(text: str, division: str, chapters: set[str], names: dict) -> list[str]:
"""한 줄 안의 **본문 절 제목** 번호들(차례대로). 번호 뒤 글이 목차 이름과 같아야 제목."""
found = []
for match in _NUMBER.finditer(text):
raw = match.group(0)
for cut in range(len(raw.split("-")[0])): # 붙은 쪽 번호를 한 자씩 뗌(「85-3」 → 5-3)
number = raw[cut:]
name = names.get((division, number))
if number.split("-")[0] not in chapters or name is None:
continue
tail, key = _squeeze(text[match.end() :]), _squeeze(name)
after = tail[len(key) : len(key) + 1]
if tail.startswith(key) and not (after and after in _QUOTED_NEXT):
found.append(number)
break
return found
class SectionFinder:
"""표 → 본문 절 번호. 파일마다 「제목이 있는 줄 · 그 번호」 를 한 번만 셈."""
def __init__(self, nodes: list[dict[str, Any]], source_files: list[str]):
self.names = {(n["division"], n["number"]): n["name"] for n in nodes if n["level"] > 1}
self.marks: dict[str, tuple[list[int], list[str]]] = {}
ordered = sorted(set(source_files), key=file_place)
self.previous = {
after: before
for before, after in zip(ordered, ordered[1:])
if file_place(before)[0] == file_place(after)[0]
}
def _marks(self, source_file: str) -> tuple[list[int], list[str]]:
if source_file not in self.marks:
division, chapter = file_place(source_file)
chapters = {str(chapter - 1), str(chapter), str(chapter + 1)}
lines = (ROOT / source_file).read_text(encoding="utf-8").splitlines()
at, numbers = [], []
for index, line in enumerate(lines):
found = titles_in(line, division, chapters, self.names)
if found:
at.append(index)
numbers.append(found[-1])
self.marks[source_file] = (at, numbers)
return self.marks[source_file]
def number(self, table: dict[str, Any]) -> str | None:
at, numbers = self._marks(table["source_file"])
index = bisect.bisect_left(at, int(table.get("line") or 0) - 1) # 표 줄보다 위
if index:
return numbers[index - 1]
previous = self.previous.get(table["source_file"])
if previous: # 파일 첫머리 표 — 제목은 앞 장 파일 꼬리에 있음
_, numbers = self._marks(previous)
return numbers[-1] if numbers else None
return None
def general_roots() -> tuple[str, ...]:
"""총칙 뿌리 — `axis_policy.json` 건설 축의 장 파일 이름을 목차 코드로(「01_공통부문/제1장_적용기준.md」 → `CP-01-01`).
⚠ 미확정(`general_provision_pending` · 제8장 건설기계 · 유지관리 제1장)은 안 넣음 — 판정이 오면 자료만 고치면 됨.
"""
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Keys import general_provision_roots
roots = []
for chapter_file in general_provision_roots("const"):
division, chapter = file_place(chapter_file)
roots.append(_code(f"{CODE_PREFIX}-{DIVISIONS.index(division) + 1:02d}", str(chapter)))
return tuple(roots)
def book_lines(data: dict[str, Any]) -> list[str]:
folder = (ROOT / str(data["sources"][0]["path"])).parents[1]
return (folder / BOOK_NAME).read_text(encoding="utf-8").splitlines()
def build_const() -> tuple:
"""건설 공종 축 한 벌 — 산림 `build()` 와 같은 꼴(마스터 · 미판정 · 밑수 미확보 · (장부, 새 열쇠))."""
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import finish
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Parents import apply_parent_modes
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Table import new_report, norm, table_entry
data = json.loads(SOURCE.read_text(encoding="utf-8"))
tables = data["variables"]["pum"]["tables"]
nodes = toc_nodes(book_lines(data))
by_place = {(n["division"], n["number"]): n for n in nodes if n["level"] > 1}
finder = SectionFinder(nodes, [t["source_file"] for t in tables])
report = new_report()
attached = 0
orphans: list[dict[str, Any]] = []
lines_of: dict[str, list[str]] = {}
for table in tables:
division, _ = file_place(table["source_file"])
number = finder.number(table)
node = by_place.get((division, number))
if table["source_file"] not in lines_of:
lines_of[table["source_file"]] = (
(ROOT / table["source_file"]).read_text(encoding="utf-8").splitlines()
)
# 형태 판정의 「1장 = 적용기준」 은 공통부문 1장만 — 다른 부문 1장은 도로포장·철골·배관 따위
chapter = "1" if division == DIVISIONS[0] and number and number.startswith("1-") else None
section = f"{number} {node['name']}" if node else norm(table.get("section"))
entry = table_entry(
table,
section,
number,
chapter,
lines_of[table["source_file"]],
node and node["name"],
report,
)
if node is None:
orphans.append(
{
"pum_table_id": table["table_id"],
"section": norm(table.get("section")),
"source_file": table["source_file"],
}
)
continue
node["tables"].append(entry)
attached += 1
apply_parent_modes(nodes, {}) # 합산형은 사람이 적은 것만 — 건설은 아직 없음(전부 choose_one)
for node in nodes:
node["variant_keys"] = [] # 못 가름 — B09 표 읽기가 산림 표로만 검증됨
return finish(
data,
source=SOURCE,
dataset_id=DATASET_ID,
nodes=nodes,
tables_total=len(tables),
attached=attached,
orphans=orphans,
report=report,
registry_path=CONST_KEY_REGISTRY,
toc_corrections=[],
key_prefix=KEY_PREFIX,
general_roots=general_roots(),
policy={
"divisions": list(DIVISIONS),
"toc_source": BOOK_NAME + " 머리 「목 차」",
"section_from_body": True,
"variant_keys_attached": False,
"general_provision_roots": list(general_roots()),
},
)