· 손료 = 자재가 × 표 수량 곧장(패널 0.089 · 내부 패널 0.003매/㎡) + 부자재 24/52/79% · 소모자재 5%(주자재비 = 패널 + 내부 패널) — 봉화 2024 호표 재료비 보통 4,500 · 간단 3,697 과 일치 · 임대료 = 「유로폼 임대료 ㎡당」 한 줄 · 부자재 % 는 원문이 안 적어 안 걺 · 둘 다 · 한쪽만 반쯤 · 아무것도 — 안 세우고 사유(부모 12-38 까지 그대로) · 12-38-1 잔존율은 안 곱함 — 표 수량이 이미 12회·25% 몫으로 보임(우리 역산 · 차 2.5% 는 추정이라 비고에 밝힘) · 이중계상 ③ 가드에서 12-38-02 만 뺌(패널이 자재총괄에 줄 없음) · 유로폼이 자재총괄로 가면 빨강 시험 · 합산형 부모 — 갈래 벌이 같으면 여러 단계 갈래끼리 합산(12-38 = 사용수량 + 설치·해체) · 작업조 표 7 은 밑수 없음 목록에서 뺌(1단위당 = 인원 ÷ 시공량) — 12-38-3 설치·해체가 막혀 있던 자리 · 마스터 재생성 — 12-38 · 12-38-02 갈래 키만 바뀜 기본 조립 제목 금액 변화 0 · 프로젝트 내역 변화 0 · 잴 시험 일곱 빨강→초록 · 끄기 넷 빨강 · 전체 시험 1677 + 318 통과 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
838 lines
42 KiB
Python
838 lines
42 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 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<u1>㎥|m3|㎡|m2|㏊|ha|㎞|km|m|매|본|개소|개|인|공|kg|㎏|톤|ton|주|식|대)\s*당?"
|
||
r"|([\d,]*\.?\d*)\s*(?P<u2>㎥|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") and not crew_table(table):
|
||
# 참조·계수표는 곱할 값이 아니므로 목록에 넣지 않는다 — 잡음이 되면 안 본다.
|
||
# 작업조 표도 뺌 — 밑수가 시공량 열(1단위당 = 인원 ÷ 시공량 · B09 CrewOutput)이라
|
||
# 「N㎡당」 문구가 없어도 곱셈이 안 틀림(12-38-3 설치·해체가 여기 걸려 막혀 있었음 · 2026-09-14 301).
|
||
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()
|