"""산림사업 표준품셈 476표 → 공종 마스터 정규화 (B08 일감 1번 · 공종 축). 무엇을 만드나 `resources/data_work_item_master/work_item_master_.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 hashlib import json import re from datetime import datetime, timezone from pathlib import Path from typing import Any from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Forms import judged_form 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" SCHEMA_VERSION = "1.0" CODE_PREFIX = "FP" # Forest Pumsem — 공종코드 접두. 품셈 절 번호를 그대로 싣는다. # ── 형태 판정 ──────────────────────────────────────────────────────────── # 헤더·비고 문자열에서 찾는 표지. 앞의 것이 먼저 걸린다(생산량형이 더 좁은 표현이라 우선). PRODUCTIVITY_MARKS = ( "㎥/hr", "m3/hr", "㎡/hr", "본/hr", "/1인/1일", "/인/1일", "인/1일", "작업능력", "ha당 평균 작업량", "ha당 집재재적", ) REQUIREMENT_MARKS = ( "소요인력", "소요량", "당 주입량", "단위수량", "수 량", "수량", "인/km", "인/ha", "㏊당", "ha당", "당 소요", ) # 값이 공종 품이 아니라 계산식 파라미터인 표. 공종으로 세우지 않는다. COEFFICIENT_MARKS = ("손료계수", "시간당손료계수", "기계손료") # 기계 시공능력 공식(Q = 3600·qo·K·f·E / Cm)의 파라미터 기호. 이 기호만 있는 표는 계수표다. COEFFICIENT_ROW_KEYS = {"K", "f", "E", "Cm", "㎝(sec)", "q", "qo", "Q", "V", "L"} # 품셈 1장(적용기준)·할증·공제·단위 표준 — 공종이 아니라 **기준표**다. # ⚠ **「단 위」를 뺐다** (2026-09-07). 원래 겨냥은 품셈 1-2-2 「단위 표준」 표였는데, # **1장은 chapter 규칙이 이미 참조로 거르므로** 이 표지는 넓기만 했다. 그 탓에 # **41건이 「헤더 '단 위'」 하나로 참조가 되어 버려지고 있었고**, 그 안에 # **돌쌓기(장비) 13-4-5·13-4-2**(우리 매핑이 실제로 쓰는 코드) · 12-2 표면 마무리 · # 9-19-1 면고르기 · 9-20 뿌리다듬기 · 13-8 막돌쌓기 같은 **명백한 공종**이 섞여 있었다. # 「단위 열이 있다」는 소요량표의 흔한 모양이지 참조표의 표지가 아니다. REFERENCE_MARKS = ("할증률", "할인", "공제율", "적재량", "지위") # 값 대신 「어디를 따르라」고만 적은 표. 품이 아니므로 공종으로 세우지 않는다. REFERENCE_CELL_MARKS = ("별도계상", "구역화물", "적용한다", "따른다", "준용") # 품셈이 실어 둔 **빈 단가산출서 서식** — 값이 없는 예시 양식이다(2026-09-08 ㉙, 서브가 찾음). # 첫 줄이 「위치 및 임·소반」·「위치 및 면적」이고 다음 줄이 「구분 | 작업량 | 단위품 | # 소요품 | 단가」인 모양. 채워 넣으라고 둔 칸이라 **자원도 값도 없다.** # ⚠ 이것을 공종으로 세우면 「값이 있는데 안 서는 자리」로 오해된다 — 애초에 값이 없다. # 서브가 그 표들 탓에 못 맞춘 자원이 882 → 497 로 부풀어 있었다. # ⚠ **두 조건을 다 만족할 때만** 본다 — 「구분」·「단가」는 정상 표에도 흔한 낱말이라 # 하나만 보면 정상 표를 지운다(오늘 그 병을 열한 번 겪었다). #: 표지는 **머리글에 그대로 적혀 있다** — 「ha당 참나무시들음병방제 **단가산출서(예시)**」. #: 낱말 하나로 충분히 좁다(정상 표 머리글에 「단가산출서」가 올 일이 없다). BLANK_FORM_MARK = "단가산출서" #: 머리글이 비어 있는 판을 대비한 보조 표지 — **두 조건을 다 만족할 때만** 본다. BLANK_FORM_HEAD_MARKS = ("위치및임소반", "위치및면적") BLANK_FORM_BODY_MARKS = ("단위품", "소요품") # 직종이 값의 주인인 표 = 소요량형. `보통인부(인)`·`콘크리트공(인)` 처럼 `(인)` 이 붙는다. # ⚠ 원문에 `인 부` 처럼 낱말 사이 공백이 있어, 비교 전에 공백을 모두 지운다(`squeeze`). OCCUPATION_RE = re.compile( r"\((?:인|조|인/일|인·일)\)|인부|기능공|운전사|특별인부|보통인부|콘크리트공|철근공|石工|석공|목공|용접공" ) # 재료 소요량표의 값 단위. 직종이 없어도 이 단위가 값 열에 오면 소요량형이다. MATERIAL_UNIT_MARKS = ("(kg)", "(㎏)", "(개)", "(매)", "(본)", "(ℓ)", "(L)", "(㎥)", "(㎡)", "(m)") # ⚠ **첫 칸이 분류 딱지이고 이름이 둘째 칸인 표** (2026-09-07 서브 창이 자기 파싱에서 잡은 모양). # `['자재', 'PVC 지수판(200×5)', 'm', '1.04']` 처럼 첫 칸이 값의 이름이 아니라 갈래다. # 그런 표는 직종이 넷째·다섯째 행에 있어 **첫 세 행만 보는 판정에 안 걸렸고**, 12장 임도 # 구조물 표 9건이 통째로 `undetermined` 로 빠져 있었다. 딱지를 알아보고 다시 본다. CLASSIFICATION_TAGS = frozenset( { "자재", "재료", "재료비", "자재비", "잡재료", "장비", "기계", "인력", "노무", "노무비", "인건비", "설치비", "경비", "공구손료", } ) # 딱지 가운데 **품이 붙는 쪽**. 이 딱지가 있으면 그 표는 소요량형이다. RESOURCE_TAGS = frozenset({"자재", "재료", "잡재료", "장비", "기계", "인력", "노무", "공구손료"}) # 값이 아니라 **다른 값의 몇 %** 라고만 적은 표. 품이 아니므로 참조로 둔다. RATIO_DIRECTIVE_MARKS = ("재료비의", "주재료비의", "회기준") def sha256_of(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() def norm(text: Any) -> str: """공백을 하나로 줄인 문자열. `None` 은 빈 문자열.""" return " ".join(str(text).split()) if text is not None else "" 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 간격) — 중간 삽입 여유. parts = number.split("-") nodes.append( { "work_item_code": f"{CODE_PREFIX}-" + "-".join(p.zfill(2) for p in parts), "number": number, "name": name, "level": len(parts), "parent_code": ( f"{CODE_PREFIX}-" + "-".join(p.zfill(2) for p in parts[:-1]) if len(parts) > 1 else None ), "sort_order": order, "tables": [], } ) return nodes 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 True 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()) 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 detect_form(table: dict[str, Any], chapter: str | None) -> tuple[str, str]: """`(pum_form, 근거 문구)`. 판정 못 하면 `("undetermined", 이유)`. 순서가 뜻을 가진다 — 좁은 표지부터 본다. 계수·기준표를 먼저 걸러 내야 「작업능력」 같은 흔한 낱말이 기준표를 공종으로 오인하지 않는다. """ hay = " ".join(norm(h) for h in table.get("headers", [])) keys = [norm(r[0]) for r in table.get("rows", []) if r] head_rows = [norm(c) for r in table.get("rows", [])[:3] for c in r] first_rows = " ".join(head_rows) both = f"{hay} || {first_rows}" squeezed = both.replace(" ", "") # `인 부` → `인부`. 원문 자간 공백을 지운 뒤 비교한다. for mark in COEFFICIENT_MARKS: if mark in hay: return "coefficient", f"헤더 '{mark}'" if keys and set(keys) <= COEFFICIENT_ROW_KEYS: return "coefficient", f"행 키가 시공능력 공식 기호뿐 {sorted(set(keys))}" if keys and all(re.fullmatch(r"[a-zA-Zqf][₀-₉0-9]?", k) for k in keys): return "coefficient", f"행 키가 기호뿐 {sorted(set(keys))}" # 1장은 적용기준 장 자체다 — 공종이 아니라 기준표로 둔다(PLAN 8-14 「목록은 규정에서」). if chapter == "1": return "reference", "품셈 제1장(적용기준)" for mark in REFERENCE_MARKS: if mark in hay: return "reference", f"헤더 '{mark}'" for mark in REFERENCE_CELL_MARKS: if mark in squeezed: return "reference", f"'{mark}' — 값이 아니라 참조 지시" # 빈 단가산출서 서식 — 머리글 표지가 정본, 머리 두 줄은 보조. if BLANK_FORM_MARK in hay.replace(" ", ""): return "reference", f"'{BLANK_FORM_MARK}' — 채워 넣으라고 둔 빈 서식" head2 = "".join( norm(str(c)) for row in (table.get("rows") or table.get("raw_row") or [])[:2] for c in row ) for mark in ("․", "·", "ㆍ", " "): head2 = head2.replace(mark, "") if any(m in head2 for m in BLANK_FORM_HEAD_MARKS) and any( m in head2 for m in BLANK_FORM_BODY_MARKS ): return "reference", "품셈이 실어 둔 빈 단가산출서 서식 — 채워 넣으라고 둔 칸" # ⚠ 생산량형을 **직종보다 먼저** 본다. 「작업능력(㎥/hr)」 표의 비고란에 흔히 # 「보통인부 1인/일」이 붙어 있어, 직종을 먼저 보면 생산량형이 소요량형으로 뒤집힌다. # 그 뒤집힘이 곧 PLAN 8-6 이 경고한 「값이 조용히 반대로 들어가는」 사고다. for mark in PRODUCTIVITY_MARKS: if mark in hay: return "productivity", f"헤더 '{mark}'" # 직종이 값의 주인이면 소요량형이다 — 「보통인부(인) 0.16」 은 ㎥당 품이다. if OCCUPATION_RE.search(squeezed): return "requirement", "직종 표기((인)·인부·공)" for mark in MATERIAL_UNIT_MARKS: if mark in hay: return "requirement", f"값 단위 '{mark}'" for mark in PRODUCTIVITY_MARKS: if mark in both: return "productivity", f"본문 '{mark}'" for mark in REQUIREMENT_MARKS: if mark in both: return "requirement", f"'{mark}'" # ⚠ 첫 칸이 분류 딱지인 표 — **맨 마지막에 본다.** 앞의 판정을 흔들지 않으려는 자리다. tags = [norm(row[0]).replace(" ", "") for row in table.get("rows", []) if row] if any(tag in CLASSIFICATION_TAGS for tag in tags): # ⚠ **소요량을 먼저 본다.** 비율 지시를 먼저 보면 `잡재료비(재료비의) 5 %` 한 줄 때문에 # 강관동바리(내관 0.38본·형틀목공 0.07인) 같은 **멀쩡한 소요량표가 참조로 넘어간다** # — 만들다 실제로 걸린 자리다. # 값이 실제로 있는 표만 공종으로 세운다. 이름만 있고 수치가 없으면 판정하지 않는다. has_number = any( re.fullmatch(r"[\d,]+(?:\.\d+)?", norm(c)) for r in table.get("rows", []) for c in r ) if has_number and any(tag in RESOURCE_TAGS for tag in tags): return "requirement", f"분류 딱지 {sorted({t for t in tags if t in RESOURCE_TAGS})}" body = " ".join(norm(c) for r in table.get("rows", []) for c in r).replace(" ", "") for mark in RATIO_DIRECTIVE_MARKS: if mark in body: return "reference", f"분류 딱지 표의 '{mark}' — 값이 아니라 비율 지시" return "undetermined", "헤더·첫 행에 단위·밑수·직종 표지 없음" BASIS_RE = re.compile(r"(\d[\d,.]*)\s*(㎥|m3|㎡|m2|㏊|ha|km|㎞|m|인|본|개|kg|㎏|톤|ton)\s*당") # ⚠ **밑수는 표 안이 아니라 표 바로 위 본문에 있다** (2026-09-07 서브 창 제보로 파고 확인). # `### 12-2. 표면 마무리` 다음 줄에 `(단위: ㎡당)` 이 오는 식이다. 표만 보면 못 찾고, # 못 찾은 채로 두면 「10㎡당」 표를 1㎡당으로 알아 **곱셈이 10배 틀린다**. # 앞서 확인한 「밑수가 **밀렸나**」와는 다른 물음이다 — 이번은 「**아예 안 적혔나**」다. # ⚠ **「당」 또는 「단위:」 가 있어야 밑수다.** 둘 다 없으면 규격일 뿐이다 — # 만들다 실제로 걸렸다: `(무한궤도,0.7㎥)` 를 「0.7㎥당」으로 읽어 5건이 잘못 잡혔다. #: ⚠ `(단위: 인/㎡당)` 꼴 — **분모가 밑수**다. 값의 단위(인)를 밑수로 읽으면 뜻이 뒤집힌다. #: ⚠ 「단위:」 없이 `(인/100m당)` 으로만 적은 표가 22 곳이다(조림 5장·단끊기 5-16-1 등, 2026-09-13). SOURCE_BASIS_RATIO_RE = re.compile( r"[((]\s*(?:단위\s*[::]\s*)?[^))/::]+/\s*([\d,]*\.?\d*)\s*" r"(㎥|m3|㎡|m2|㏊|ha|㎞|km|m|매|본|개소|개|인|공|kg|㎏|톤|ton|주|식|대)\s*당\s*[))]" ) SOURCE_BASIS_RE = re.compile( r"[((]\s*(?:" r"단위\s*[::]\s*([\d,]*\.?\d*)\s*(?P㎥|m3|㎡|m2|㏊|ha|㎞|km|m|매|본|개소|개|인|공|kg|㎏|톤|ton|주|식|대)\s*당?" r"|([\d,]*\.?\d*)\s*(?P㎥|m3|㎡|m2|㏊|ha|㎞|km|m|매|본|개소|개|인|공|kg|㎏|톤|ton|주|식|대)\s*당" r")\s*[))]" ) #: 표 위로 몇 줄까지 거슬러 볼 것인가. 더 올라가면 **앞 표의 밑수**를 잘못 물어 온다. SOURCE_LOOKBACK = 6 #: 표 **아래 [주]** 에만 밑수가 적힌 자리 — 「목재의 손율은 **1개소 사용당** 50%로 한다」. #: ⚠ **아주 좁게 잡는다.** 「N개소 **사용** 당」이라는 표기는 품셈 전문에 **딱 두 곳**뿐이고 #: (11-2 비탈 규준틀 · 11-3 수평 규준틀) 둘 다 개소로 세는 표다. 「개소당 평균면적」 같은 #: 흔한 말은 「사용」이 없어 안 걸린다 — 넓히면 조림 2장이 통째로 걸린다(실측 19건). #: ⚠ 뜻으로도 개소가 맞다 — [주]② 가 「본 품은 …한 **비탈규준틀**의 제작·도색·가설·철거를 #: 포함한 것」이라 **규준틀 하나**를 말하고, [주]① 이 「20m마다 **설치**」라 센다. SOURCE_BASIS_NOTE_RE = re.compile(r"([\d,]*\.?\d*)\s*개소\s*사용\s*당") #: 표 아래로 몇 줄까지 볼 것인가. [주] 는 표 바로 밑에 붙는다. SOURCE_NOTE_LOOKAHEAD = 8 #: ⚠⚠ **「인」은 품의 단위이지 공종 밑수가 아니다**(2026-09-09 원문 대조로 넷이 오독으로 드러남). #: 8-6-2 드론방제·8-6-3 지상방제 「(단위 : 인)」 — 그 「인」은 **소요인력**이고 공종 밑수는 #: ha 다. 13-2-4 야면석 채집 「(단위: 인 당)」 — 표 안이 **㎡당·㎥당** 두 줄이다. #: ⇒ **원문에 「<숫자>인당」이 그대로 있을 때만** 밑수로 인정한다. #: ⚠ **넓게 「인」을 통째로 버리면 2-2-5 천공기가 사라진다** — 그 표는 셀 안에 「천공인부 #: **1인당** 1대」로 숫자가 붙어 있다. 그래서 「숫자가 붙었나」로 좁게 가른다. PERSON_UNITS = {"인", "공"} def person_basis_ok(raw_quantity: str | None, unit: str | None) -> bool: """「인」 계열 밑수를 인정할 것인가 — **숫자가 붙어 있을 때만** 참.""" if unit not in PERSON_UNITS: return True return bool((raw_quantity or "").strip()) def basis_from_source(lines: list[str], line_no: int) -> tuple[float | None, str | None]: """표 바로 위 본문에서 밑수를 읽는다. 못 찾으면 `(None, None)` — 1 로 단정하지 않는다. ⚠ 위로 거슬러 보되 **다른 표를 만나면 멈춘다**. 앞 표의 밑수를 물어 오면 조용히 틀린다. """ start = max(0, line_no - 1 - SOURCE_LOOKBACK) for index in range(line_no - 2, start - 1, -1): if index < 0 or index >= len(lines): continue text = lines[index].strip() if text.startswith("|"): break # 앞 표에 닿았다 — 그 위는 남의 밑수다 if m := SOURCE_BASIS_RATIO_RE.search(text): raw = (m.group(1) or "").replace(",", "") if not person_basis_ok(raw, m.group(2)): continue # 「인」에 숫자가 안 붙었다 — 품의 단위이지 밑수가 아니다 try: quantity = float(raw) if raw else 1.0 except ValueError: quantity = 1.0 return quantity, m.group(2) if m := SOURCE_BASIS_RE.search(text): unit = m.group("u1") or m.group("u2") raw = (m.group(1) if m.group("u1") else m.group(3)) or "" raw = raw.replace(",", "") if not person_basis_ok(raw, unit): continue # 위와 같음 — 「(단위 : 인)」·「(단위: 인 당)」은 밑수가 아니다 try: quantity = float(raw) if raw else 1.0 except ValueError: quantity = 1.0 return quantity, unit # 표 **아래 [주]** 도 본다 — 위에 아무것도 없을 때만. 규준틀 둘이 그 자리다. # ⚠ 다음 표(`|`)나 다음 절(`###`)을 만나면 멈춘다 — 남의 [주]를 물어 오면 조용히 틀린다. for index in range(line_no, min(len(lines), line_no + SOURCE_NOTE_LOOKAHEAD)): text = lines[index].strip() if text.startswith("###"): break if m := SOURCE_BASIS_NOTE_RE.search(text): raw = (m.group(1) or "").replace(",", "") try: quantity = float(raw) if raw else 1.0 except ValueError: quantity = 1.0 return quantity, "개소" return None, None #: ⚠ **밑수가 절 이름에만 있는 표** — 「4-2-2. 1,000㎡당」·「4-2-1. 100본당」(2026-09-13 판정). #: 본문·표 안 어디에도 「(단위: …)」가 없어 밑수가 비어 있었다(B09 가 막아 금액은 안 섰음). #: ⚠ **이름 전체가 「수 + 단위 + 당」일 때만** 뽑는다 — 「나무주사(100본당)」처럼 섞인 것은 #: 본문이 이미 주고, 「자재의 1회당 표준 운반량」 같은 설명문은 밑수가 아니다. 짐작하지 않는다. NAME_BASIS_RE = re.compile( r"^([\d,]+(?:\.\d+)?)\s*(㎥|㎡|㏊|ha|㎞|km|m|매|본|개소|개|주|kg|㎏|톤|ton)\s*당$" ) def basis_from_name(name: str) -> tuple[float | None, str | None]: """절 이름이 곧 밑수인 경우만 `(수, 단위)`. 아니면 `(None, None)`.""" found = NAME_BASIS_RE.match(norm(name)) if found is None: return None, None try: return float(found.group(1).replace(",", "")), found.group(2) except ValueError: return None, None def detect_basis(table: dict[str, Any]) -> tuple[float | None, str | None]: """「100㎥당」 같은 밑수. 없으면 `(None, None)` — 단위당 1 로 단정하지 않는다. ⚠⚠ **칸 하나 안에서만 본다**(2026-09-09). 여러 칸을 이어 붙여 보면 **앞 칸의 값**과 **뒤 칸의 단위**가 붙어 없는 밑수가 생긴다 — 13-2-4 야면석 채집이 그랬다: 행 ① 인 부 | ㎡당 | 0.11 | 0.17 | 0.22 | 0.28 | **0.36** 행 ② **㎥당** | 0.60 | … 이어 붙이면 「0.36 ㎥당」이 되어 **밑수 0.36㎥** 라는 값이 선다(원문에 없는 값). ⚠ 그 표는 **㎡당·㎥당 두 줄**이라 애초에 하나로 못 정한다 — 「미확보」가 정직하다. ⚠ **「인」은 여기서도 숫자가 붙어야 인정한다** — `BASIS_RE` 가 숫자를 요구하므로 2-2-5 「천공인부 **1인당** 1대」는 그대로 서고 「(단위 : 인)」은 안 선다. """ cells = [norm(h) for h in table.get("headers", []) or []] cells += [norm(c) for r in (table.get("rows", []) or [])[:2] for c in r] for cell in cells: if not cell: continue if m := BASIS_RE.search(cell): if not person_basis_ok(m.group(1), m.group(2)): continue try: return float(m.group(1).replace(",", "")), m.group(2) except ValueError: return None, m.group(2) return None, None # ⚠ **딱지가 비율을 달고 오는 표** — `인력(10%)` · `장비(90%)` (2026-09-07 서브 창 제보로 확인). # 그 표는 소요량형이면서 **장비 몫이 시공능력 공식**(Q = 3600·q·K·f·E ÷ Cm)이라 # **인력 10 % 만 값으로 서 있다.** 형태 한 낱말(`requirement`)로만 적으면 받는 쪽이 # 그 단가를 전량에 곱해 **내역서가 9할 싸게** 선다. 그래서 **몫과 미완 여부를 따로 싣는다.** SHARE_TAG_RE = re.compile( r"^(자재|재료|재료비|자재비|잡재료|장비|기계|인력|노무|노무비|인건비|경비|공구손료)" r"\s*[((]\s*(\d+(?:\.\d+)?)\s*[%%]\s*[))]$" ) #: 시공능력 공식 파라미터. 이 기호가 있으면 그 몫은 **아직 조립이 안 된 것**이다. CAPACITY_SYMBOLS = {"K", "k", "f", "E", "Cm", "㎝(sec)", "q", "qo", "Q"} # ⚠ **값 자리에 숫자가 아니라 식이 적힌 칸** (2026-09-07 서브 창 제보로 확인). # `0.2 × 30%`(기초잡석 소할) · `(0.2+0.26)/2` · `1/1.3` 처럼 계산이 그대로 적혀 있다. # 형태 판정은 통과하는데 **값이 숫자로 안 읽혀 그 성분이 조용히 빠진다** — # `partial_ratio` 와 같은 병인데 배분율 딱지가 없어 아무 검사에도 안 걸렸다. # ⚠ 여기서 **식을 계산하지 않는다.** 뜻을 잘못 읽으면 조용히 틀리므로 **드러내기만** 한다. _PLAIN_NUMBER_RE = re.compile(r"^[\d,]+(?:\.\d+)?$") _CALC_CELL_RE = re.compile(r"^[\d,.\s()×xX*/÷+\-%]+$") _CALC_MAX_LEN = 22 def expression_cells(table: dict[str, Any]) -> list[str]: """값 자리에 식이 적힌 칸 목록. 없으면 빈 목록.""" found: list[str] = [] for row in table.get("rows", []): for cell in row: text = norm(cell) if not text or len(text) > _CALC_MAX_LEN or _PLAIN_NUMBER_RE.match(text): continue if ( _CALC_CELL_RE.match(text) and re.search(r"\d", text) and re.search(r"[×xX*/÷+]", text) ): found.append(text) # 순서를 지키되 중복은 지운다 — 같은 식이 여러 줄에 반복된다. seen: list[str] = [] for item in found: if item not in seen: seen.append(item) return seen # ⚠ **작업조 표** — 「형틀목공 4인 + 보통인부 1인 / 시공량 25·35·40㎡」처럼 # **인원과 시공량**으로 적힌 표다. 그 「4」는 소요량이 아니라 **작업조 인원**이라 # 행-자원으로 그냥 읽으면 **35배 부푼다**(유로폼 12-38-3 이 그 표다). # ⚠ 값을 바꾸지 않고 **표시만** 한다 — 나누는 것은 받는 쪽 몫이다. CREW_QUANTITY_MARKS = ("시공량", "작업량", "1일작업량") # ⚠ **이름에 자간 공백이 든 기종·직종** — 같은 표 묶음 안에서도 표기가 갈린다 # (`13-6-1` 은 「굴 삭 기 (무한궤도)」, 바로 옆 `13-6-2` 는 「굴착기 (무한궤도)」). # 받는 쪽이 이름으로 자원을 찾으므로 **표기가 갈리면 그 줄이 통째로 빠진다.** # ⚠ **여기서 이름을 고치지 않는다** — 정규화는 값을 살리지만 **잘못된 줄도 함께 살린다** # (서브 창 실례: 이름 정규화 직후 버킷계수 `K` 를 소요량으로 읽어 시간당 사용료가 이중). # 깃발만 실어 받는 쪽이 대조하게 한다. _SPACED_NAME_RE = re.compile(r"^(?=.*\S\s\S)[가-힣](?:\s+[가-힣])+") #: 시공능력 공식 파라미터가 값 자리에 온 줄 — **소요량이 아니다.** 그냥 읽으면 이중계상. _FORMULA_KEYS = frozenset({"K", "k", "f", "E", "Cm", "q", "qo", "Q", "㎝(sec)"}) #: 자원 줄임을 알리는 단위 칸. 이것과 수치가 함께 있어야 자원으로 본다. _RESOURCE_UNITS = frozenset( {"인", "h", "hr", "시간", "대", "매", "본", "개", "kg", "㎏", "㎥", "㎡", "m", "ℓ", "톤", "ton"} ) def spaced_names(table: dict[str, Any]) -> list[str]: """자간 공백이 든 **자원 이름**. 표기가 갈리는 자리를 드러낸다. ⚠ **좁게 잡는다.** 「단 위」·「모 래」 같은 머리글·재료명까지 걸면 101건이 되어 목록이 잡음이 되고, 잡음이 되면 아무도 안 본다(오늘 아홉 번 겪은 병). **그 줄에 단위 칸과 수치가 함께 있는 것**만 자원 줄로 본다. """ found: list[str] = [] for row in table.get("rows", []): if not row: continue name = norm(row[0]) if not name or not _SPACED_NAME_RE.match(name): continue rest = [norm(cell) for cell in row[1:]] has_unit = any(cell in _RESOURCE_UNITS for cell in rest) has_number = any(_PLAIN_NUMBER_RE.match(cell) for cell in rest if cell) if has_unit and has_number and name not in found: found.append(name) return found def formula_rows(table: dict[str, Any]) -> list[str]: """값 자리에 시공능력 공식 기호가 온 줄. 자원으로 세면 이중계상이다.""" found: list[str] = [] for row in table.get("rows", []): for cell in row: text = norm(cell) if text in _FORMULA_KEYS and text not in found: found.append(text) return found # ⚠ **규격 표기에 쓰이는 특수문자** — 원문이 한 종류로 안 쓴다(2026-09-07 실측). # 물결표만 셋이다: `∼`(U+223C) 662회 · `~`(U+FF5E) 459회 · `~`(U+007E) 2회. # 곱셈표도 `×`(U+00D7) 97회 · `x`(U+0078) 4회로 갈린다. # **두 창이 각자 갈래 키를 조립하면 글자 하나로 영영 안 맞는다** — 그래서 우리는 # 키를 조립하지 않고 **저장 원본값만** 보낸다(인계 계약). 여기서는 **표시만** 한다. # ⚠ 값을 고치지 않는다 — 원문 표기를 흡수하는 것은 **원문을 읽는 쪽**의 몫이다. SPECIAL_GLYPHS = { "∼": "∼(U+223C)", "~": "~(U+FF5E)", "~": "~(U+007E)", "×": "×(U+00D7)", "x": "x(U+0078)", "–": "–(U+2013)", "‐": "‐(U+2010)", } def special_glyphs(table: dict[str, Any]) -> list[str]: """규격 표기에 쓰인 특수문자 종류. 갈래 키를 맞출 때 대조할 자리.""" text = " ".join(norm(cell) for row in table.get("rows", []) for cell in row) return sorted({SPECIAL_GLYPHS[ch] for ch in text if ch in SPECIAL_GLYPHS}) def crew_table(table: dict[str, Any]) -> bool: """작업조 + 시공량으로 적힌 표인가.""" hay = " ".join(norm(h) for h in table.get("headers", [])) body = " ".join(norm(c) for row in table.get("rows", []) for c in row) squeezed = (hay + " " + body).replace(" ", "") if not any(mark in squeezed for mark in CREW_QUANTITY_MARKS): return False return bool(OCCUPATION_RE.search(squeezed)) def resource_shares(table: dict[str, Any]) -> dict[str, float]: """`{인력: 10.0, 장비: 90.0}` — 딱지에 붙은 몫. 없으면 빈 칸.""" shares: dict[str, float] = {} for row in table.get("rows", []): if not row: continue if m := SHARE_TAG_RE.match(norm(row[0])): shares[m.group(1)] = float(m.group(2)) return shares def capacity_formula_pending(table: dict[str, Any]) -> bool: """장비 몫이 **시공능력 공식**으로만 적혀 있어 아직 값이 안 된 상태인가.""" keys = {norm(row[0]) for row in table.get("rows", []) if row} cells = {norm(c) for row in table.get("rows", []) for c in row} return bool((keys | cells) & CAPACITY_SYMBOLS) def basis_quantity_is_grouped(quantity: float | None) -> bool: """「10㎡당」처럼 **묶음 기준**인가. 1 이 아니면 곱셈이 그만큼 갈린다.""" return quantity is not None and abs(quantity - 1.0) > 1e-9 def build() -> dict[str, Any]: 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"]) by_number = {n["number"]: n for n in nodes} attached = 0 # ⚠ 밑수를 못 찾은 표 목록. 「곱하면 안 되는 줄」을 받는 쪽이 가릴 수 있게 낸다 — # 빈칸으로 두면 「1단위당」으로 오해되어 곱셈이 10배·100배 틀린다. basis_missing: list[dict[str, Any]] = [] basis_found = 0 basis_grouped = 0 orphans: list[dict[str, Any]] = [] undetermined: 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 form, why = detect_form(table, chapter) # 사람이 가른 형태가 자동 판정을 이긴다 — 표마다 까닭 한 줄(미판정 표 판정, 2026-09-13). form, why = judged_form(table["table_id"], number) or (form, why) # ⚠ **본문이 정본이다.** 표 안을 긁는 쪽은 보조 — 비고에 적힌 다른 기준 # (「10㎡당」 같은 참고 문구)을 그 표의 밑수로 잘못 물어 온다. # 실제로 13-3-1 이 본문 `(단위: ㎥당)` 인데 표 안 긁기가 `10㎡` 를 물어 왔다. basis_qty = basis_unit = None if source_lines: basis_qty, basis_unit = basis_from_source(source_lines, int(table.get("line") or 0)) if basis_qty is None and basis_unit is None: basis_qty, basis_unit = detect_basis(table) basis_source = "표 안" if basis_unit else None else: basis_source = "본문" # 본문·표 안에 없을 때만 **절 이름**을 본다(4-2-2 「1,000㎡당」) — 이름 전체가 밑수일 때만. if basis_unit is None and number in by_number: name_qty, name_unit = basis_from_name(by_number[number]["name"]) if name_unit: basis_qty, basis_unit, basis_source = name_qty, name_unit, "절 이름" if basis_unit: basis_found += 1 if basis_quantity_is_grouped(basis_qty): basis_grouped += 1 elif form in ("requirement", "productivity"): # 참조·계수표는 곱할 값이 아니므로 목록에 넣지 않는다 — 잡음이 되면 안 본다. basis_missing.append( { "pum_table_id": table["table_id"], "section": norm(table.get("section")), "pum_form": form, "line": table.get("line"), } ) shares = resource_shares(table) # ⚠ 「값이 일부만 선 표」를 정상으로 흘려보내지 않는다. **몫이 적혀 있으면 부분값**으로 # 본다 — 관측한 25건 모두 장비 몫이 시공능력 공식으로만 적혀 있어 값이 아니었고, # 공식 기호가 첫 표에만 있고 이어지는 표는 그것을 물려받는 모양이라 # 「기호가 있는 표만」으로 세면 절반을 놓친다(9-13-2 가 그 경우). # ⚠ 이 깃발은 **표시일 뿐 값을 지우지 않는다** — 넓게 잡아도 정상 값이 안 사라진다. partial = bool(shares) entry = { "pum_table_id": table["table_id"], "section": section, "source_line": table.get("line"), "pum_form": form, "form_basis": why, "basis_quantity": basis_qty, "basis_unit": basis_unit, # 밑수를 어디서 읽었나 — 본문(정본) / 표 안(보조). 없으면 None. "basis_source": basis_source, "resource_shares": shares, "partial_ratio": partial, # ⚠ 값 자리에 식이 적힌 칸 — 그 성분은 숫자로 안 읽히므로 **금액을 만들면 안 된다**. "expression_cells": expression_cells(table), # ⚠ 작업조 표 — 「4」가 소요량이 아니라 인원이다. 그냥 읽으면 35배 부푼다. "crew_table": crew_table(table), # ⚠ 이름 표기가 갈리는 줄 — 받는 쪽이 이름으로 찾으면 통째로 빠진다. "spaced_names": spaced_names(table), # ⚠ 공식 기호 줄 — 소요량이 아니다. 자원으로 세면 이중계상. "formula_rows": formula_rows(table), # ⚠ 규격 표기 특수문자 — 갈래 키를 맞출 때 대조할 자리(값은 안 고침). "special_glyphs": special_glyphs(table), # 공식 기호가 이 표에 직접 있는가 — 없으면 앞 표에서 물려받는 모양이다. "capacity_formula_here": capacity_formula_pending(table), # 갈래 키 — 부모 모양을 단 뒤 표 읽기가 가른 대로 채움(`_Variants`). 못 가르면 빈칸. "variant_key": [], "condition_note": [norm(h) for h in table.get("headers", []) if norm(h)], "raw_row": table.get("rows", []), # 원문 셀 — B09 자원 축이 읽는다. } if form == "undetermined": undetermined.append( { "pum_table_id": table["table_id"], "section": section, "headers": entry["condition_note"], "first_rows": table.get("rows", [])[:2], "reason": why, } ) node = by_number.get(number) if number else None 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"]) 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": "work_item_master_forest", "effective_date": data["effective_date"], # 공종 코드(`FP-*` 절 번호)가 묶인 품셈 판 — 자원 축·인계 줄이 이 값을 싣는다. "pum_edition": data["effective_date"], "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, }, "stats": { "toc_nodes": len(nodes), "tables_total": len(tables) - 1, "tables_attached": attached, "tables_orphan": len(orphans), "form_undetermined": len(undetermined), "basis_found": basis_found, "basis_missing": len(basis_missing), "basis_grouped": basis_grouped, }, "orphan_tables": orphans, "work_items": nodes, }, undetermined, basis_missing, ) def main() -> None: master, undetermined, basis_missing = build() OUT_DIR.mkdir(parents=True, exist_ok=True) date = master["effective_date"] master_path = OUT_DIR / f"work_item_master_{date}.json" undet_path = OUT_DIR / f"form_undetermined_{date}.json" basis_path = OUT_DIR / f"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 / "_manifest.json").write_text( json.dumps(manifest, ensure_ascii=False, indent=1), encoding="utf-8" ) s = master["stats"] 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}") print(f"산출 {master_path.relative_to(ROOT)}") if __name__ == "__main__": main()