Files
Aislo/B08_Quantity/B08_Quantity_Build_WorkItemMaster.py
T
eomsangdonandClaude Opus 5 c357381355 fix(B08): 품셈 밑수를 원문 본문에서 읽음 — 곱셈 10배·100배 오차 자리
서브 창 제보(기준 단위 없이 도는 단가 125 중 122)의 뿌리. 밑수는 표 안이 아니라
**표 바로 위 본문**에 있음 — `### 12-2. 표면 마무리` 다음 줄의 `(단위: ㎡당)`.
표만 보면 못 찾고, 못 찾은 채 두면 「10㎡당」 표를 1㎡당으로 알아 곱셈이 10배 틀림.

- 원문 md 를 함께 열어 표 위 6줄에서 밑수를 읽음. **본문이 정본, 표 안 긁기는 보조** —
  13-3-1 이 본문 `(단위: ㎥당)` 인데 표 안 긁기가 비고의 `10㎡` 를 물어 오던 자리.
- `(단위: 인/㎡당)` 꼴은 **분모가 밑수** — 값의 단위(인)를 밑수로 읽으면 뜻이 뒤집힘.
- ⚠ 「당」도 「단위:」도 없으면 밑수가 아님. 처음에 조건을 느슨히 두어
  `(무한궤도,0.7㎥)` 를 「0.7㎥당」으로 읽어 5건이 잘못 잡혔음 — 조여서 고침.
- 위로 거슬러 보되 **다른 표에 닿으면 멈춤** — 앞 표의 밑수를 물어 오면 조용히 틀림.

밑수 확보 13 → 183 (그중 묶음 기준 36건: 떼채취 100㎡ · 큰돌쌓기 10㎡ ·
뒷채움 100㎡ · 묘목 1000본 · 헬기방제 160ha 등). 못 찾은 144건은
`basis_missing_2026-01-01.json` 으로 냄 — 곱해야 하는 형태만 담아 잡음을 줄임.
빈칸으로 두면 1단위당으로 오해되므로 목록으로 드러냄.

앞서 확인한 「밑수가 밀렸나」와는 다른 물음이었음 — 이번은 「아예 안 적혔나」.

검증 — 품셈 30건 통과, 전체 575 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 01:02:21 +09:00

558 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""산림사업 표준품셈 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
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장(적용기준)·할증·공제·단위 표준 — 공종이 아니라 **기준표**다.
REFERENCE_MARKS = ("할증률", "할인", "공제율", "적재량", "단 위", "지위")
# 값 대신 「어디를 따르라」고만 적은 표. 품이 아니므로 공종으로 세우지 않는다.
REFERENCE_CELL_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 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}' — 값이 아니라 참조 지시"
# ⚠ 생산량형을 **직종보다 먼저** 본다. 「작업능력(㎥/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건이 잘못 잡혔다.
#: ⚠ `(단위: 인/㎡당)` 꼴 — **분모가 밑수**다. 값의 단위(인)를 밑수로 읽으면 뜻이 뒤집힌다.
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
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(",", "")
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(",", "")
try:
quantity = float(raw) if raw else 1.0
except ValueError:
quantity = 1.0
return quantity, unit
return None, None
def detect_basis(table: dict[str, Any]) -> tuple[float | None, str | None]:
"""「100㎥당」 같은 밑수. 없으면 `(None, None)` — 단위당 1 로 단정하지 않는다."""
hay = " ".join(norm(h) for h in table.get("headers", []))
hay += " " + " ".join(norm(c) for r in table.get("rows", [])[:2] for c in r)
if m := BASIS_RE.search(hay):
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"}
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 variant_axis(table: dict[str, Any]) -> list[str]:
"""행이 갈리는 축 — 표의 첫 열 값들(토질·암종·규격). 값 열은 뺀다."""
seen: list[str] = []
for row in table.get("rows", []):
key = norm(row[0]) if row else ""
if key and key not in seen:
seen.append(key)
return seen[:24]
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"))
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]] = []
for table in tables:
if table["table_id"] == "F0001":
continue # 목차 자신은 공종이 아니다.
section = norm(table.get("section"))
number = section_number(section)
chapter = number.split("-")[0] if number else None
form, why = detect_form(table, chapter)
# ⚠ **본문이 정본이다.** 표 안을 긁는 쪽은 보조 — 비고에 적힌 다른 기준
# (「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 = "본문"
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,
# 공식 기호가 이 표에 직접 있는가 — 없으면 앞 표에서 물려받는 모양이다.
"capacity_formula_here": capacity_formula_pending(table),
"variant_key": variant_axis(table),
"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
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"],
"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()