Files
Aislo/B08_Quantity/B08_Quantity_Build_WorkItemMaster.py
T
eomsangdonandClaude Opus 5 a4c7c8c8cd feat(B08): 이름 표기 갈림·공식 기호 깃발 + 물빼기 파이프 이중계상 규칙
서브 창 제보 교차 확인. 값을 바꾸지 않고 깃발만 실음.

- `spaced_names` 20건 — 「굴 삭 기 (무한궤도)」처럼 자간 공백이 든 자원 이름.
  같은 표 묶음 안에서도 표기가 갈림(13-6-1 은 공백, 바로 옆 13-6-2 는 없음).
  받는 쪽이 이름으로 찾으면 그 줄이 통째로 빠짐.
  ⚠ 여기서 이름을 고치지 않음 — 정규화는 값을 살리지만 잘못된 줄도 함께 살림
  (서브 실례: 정규화 직후 버킷계수 K 를 소요량으로 읽어 사용료가 이중).
  ⚠ 처음 101건으로 넓게 잡혔던 것을 **단위 칸 + 수치가 함께 있는 자원 줄**로
  좁혀 20건. 짝 시험(「단 위」·「모 래」는 안 걸림)을 같이 둠.
- `formula_rows` 24건 — 값 자리에 K·f·E·Cm 가 온 줄. 자원으로 세면 이중계상.

㉥ 물빼기 파이프 이중계상 — 품셈 13-6-2·13-7-2 [주]③ 이 제잡비 **윗단** 값에
「물빼기 파이프 설치에 관계되는 노무비, 재료비를 포함한다」고 함. 우리 원단위가
물구멍을 자재로 내므로 그 쪽과 겹침.
- **우리 선택: 물구멍을 자재로 세우고 제잡비는 아랫단(미설치)** 을 씀. 까닭은
  자재 줄로 세우면 규격·수량이 눈에 보이고 되짚을 수 있기 때문. 데이터와 코드
  양쪽에 적음.
- ⚠ 지금 쓰는 13-4 계열에는 **제잡비 행 자체가 없어 겹치지 않음**(전수 확인).
  이 규칙은 13-6·13-7 을 쓰게 될 때 걸림.

관측값이 원문과 맞는 첫 사례 기록 — 채움콘크리트 0.2㎥/㎡ 가 품셈 13-6-2 [주]⑩
「뒤채움콘크리트량은 0.2㎥ 기준」과 일치.

갈래 키 정규화 — `code` 는 내부 공백만 제거(`FP-12-03#보통`), 원문 문구는
`kind_label` 로 함께 실음. 다른 글자는 안 건드림(괄호·기호 유지 짝 시험).

검증 — 품셈 43건 통과, 전체 611 passed, tsc 오류 0.

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

669 lines
31 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장(적용기준)·할증·공제·단위 표준 — 공종이 아니라 **기준표**다.
# ⚠ **「단 위」를 뺐다** (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 = ("별도계상", "구역화물", "적용한다", "따른다", "준용")
# 직종이 값의 주인인 표 = 소요량형. `보통인부(인)`·`콘크리트공(인)` 처럼 `(인)` 이 붙는다.
# ⚠ 원문에 `인 부` 처럼 낱말 사이 공백이 있어, 비교 전에 공백을 모두 지운다(`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"}
# ⚠ **값 자리에 숫자가 아니라 식이 적힌 칸** (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
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 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,
# ⚠ 값 자리에 식이 적힌 칸 — 그 성분은 숫자로 안 읽히므로 **금액을 만들면 안 된다**.
"expression_cells": expression_cells(table),
# ⚠ 작업조 표 — 「4」가 소요량이 아니라 인원이다. 그냥 읽으면 35배 부푼다.
"crew_table": crew_table(table),
# ⚠ 이름 표기가 갈리는 줄 — 받는 쪽이 이름으로 찾으면 통째로 빠진다.
"spaced_names": spaced_names(table),
# ⚠ 공식 기호 줄 — 소요량이 아니다. 자원으로 세면 이중계상.
"formula_rows": formula_rows(table),
# 공식 기호가 이 표에 직접 있는가 — 없으면 앞 표에서 물려받는 모양이다.
"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()