"""건설공사 표준품셈 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 표 읽기로 가름(못 가른 공종은 빈칸) · 갈래 불변 열쇠는 장부(`CW-00001#01`). """ 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) # 갈래 — 산림과 **같은 표 읽기 한 벌**(B09 `build_resource_axis`)로 가름 · 못 가른 공종은 빈칸. # 갈래 불변 열쇠(`CW-00001#01`)는 `finish` 가 장부로 줌(데스크탑 서브 · 2026-09-17 브레인 ④). 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=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": True, # 산림과 같은 표 읽기 — 못 가른 공종은 빈칸 "general_provision_roots": list(general_roots()), }, )