브레인 3바퀴 차례대로.
**① 잘린 진짜 계수 되살림** — 2바퀴에서 34건이 함께 잘렸던 자리. 까닭 둘이었음:
· 「> 【산출 예시】」 인용이 [주] 중간에 끼면 거기서 끊었음 → 뒤에 오는 ⑤⑥ 가산·할증이 사라짐
· 「- 4. 고철공제 : A=1㎥×0.008×…」처럼 **번호를 달고 이어지는 진짜 규칙**을 절 제목으로 봤음
⇒ 끊는 자리를 **구조**(다음 절 · 새 표)로만 두고, 그 사이는 **건너뛰되 안 끊음**.
제목은 **알맹이 없는 짧은 줄**(콜론·등호·백분율 없고 30자 이하)일 때만.
⚠ 「숫자가 있으면 제목 아님」으로는 못 가름 — 「- 3. 뿌리제거(100본당)」이 제목임(두 번 겪음)
되살아난 것 — 고철 공제 50kg/㎥ · 위험목 인력 100% 가산 · 벌채 관목 30%·조재 20% ·
집재 주행곤란·잔존목 10% · 할인할증 줄 여럿
**② 못 읽던 [주] 세 꼴** (랩탑 메인이 짚음)
· 별표로 시작하는 줄 — 이어짐 글머리에 `*` 를 넣음
· 인용 산출예시 뒤에 오는 줄 — ①에서 함께 풀림
· **긴 표에 붙은 [주]** — 줄 수 제한(48)이 긴 표를 잘랐음 → 구조로만 끊고 수는 안전망으로만(400)
⇒ 산림 notes 표 306 → 309 · 줄 1,082 → 1,154
**③ 실값인데 「참고」로 빠져 있던 표 13** → `sub_requirement`
⚠ `requirement` 로 올리지 않음 — 상위 공종이 이미 세는 몫을 두 번 셈
(F0393 은 `B09_Estimation_Euroform`, F0408·F0410 은 B08 표가 이미 직접 읽음).
`reference` 면 금액이 0원 증발 ⇒ 「값은 실값, 자리는 상위 공종 종속」 딱지를 둠(안티그래비티 권고)
보고서가 27 중 19만 가려 놓아 남은 여덟을 훑어 **F0338(콘크리트 포장 인력 · 포장공 3인·시공량 100/150㎥)**
하나를 더 찾음. 나머지 열넷은 설계 규격·조건 분류·배합비율 조견표라 `reference` 가 맞음
자동 대조 장부(`work_item_master_source_known.json`) 손질 — 고쳐진 줄은 `--prune`,
남은 어긋남은 **사유를 달아** 적음. ⚠ 남은 [주] 어긋남은 다 **마스터가 더 가진 쪽** —
고칠 자리는 마스터가 아니라 **원문 md 쪽 추출**임(인용 뒤·별표·긴 표에서 아직 끊김)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsGw1Dz9HmhuAxGisxmDPF
756 lines
41 KiB
Python
756 lines
41 KiB
Python
"""공종 마스터 — **표 한 장 읽기**: 형태·밑수·위험 깃발 (2026-09-17 뽑는 스크립트가 700줄을 넘어 떼어냄).
|
||
|
||
뽑는 스크립트(`B08_Quantity_Build_WorkItemMaster.py`)는 목차·절 붙이기·열쇠를 맡고, 표 한 장이 어떤
|
||
표인지(`pum_form`)·밑수·깃발은 **여기 한 곳**에서 가름 — 산림·건설 뽑기가 같은 판정을 씀(두 벌 금지).
|
||
옮기기만 했고 판정은 한 글자도 안 바꿈 — 옛 이름은 뽑는 스크립트가 그대로 다시 내보냄.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from typing import Any
|
||
|
||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Forms import judged_basis, judged_form
|
||
|
||
# ── 형태 판정 ────────────────────────────────────────────────────────────
|
||
# 헤더·비고 문자열에서 찾는 표지. 앞의 것이 먼저 걸린다(생산량형이 더 좁은 표현이라 우선).
|
||
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 norm(text: Any) -> str:
|
||
"""공백을 하나로 줄인 문자열. `None` 은 빈 문자열."""
|
||
return " ".join(str(text).split()) if text is not None else ""
|
||
|
||
|
||
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
|
||
|
||
|
||
#: 표 아래 `[주]` 덩어리를 어디까지 볼 것인가.
|
||
#: ⚠ **줄 수로 끊지 않는다** — 긴 표(경급 20줄 · 기계 40줄)는 [주]가 저 아래에 붙어서
|
||
#: 좁게 잡으면 **긴 표의 [주]가 통째로 안 읽힌다**(2026-09-18 랩탑 메인이 짚은 세 꼴 중 셋째).
|
||
#: 끊는 것은 **다음 절(`#`)** 과 **새 표(`|`)** 라는 구조뿐이고, 이 수는 안전망일 뿐이다.
|
||
NOTES_LOOKAHEAD = 400
|
||
_NOTE_START = ("[주]", "[ 주 ]", "[주")
|
||
#: 이어짐 글머리 — `*` 도 담는다(「* (전목) 나무베기, …」 처럼 [주] 를 풀어 적은 줄).
|
||
_NOTE_CONT = ("-", "·", "※", "*")
|
||
#: 가나다…하 열넷만. ⚠ 넓히면 「등)」·「(인)」 같은 글이 제목으로 잡힌다.
|
||
_ORDER = "가나다라마바사아자차카타파하"
|
||
#: ⚠⚠ **원문이 절 제목을 목록 줄로 적는다** — 「- 3. 노상 및 노반재료」·「- 라. 체적환산계수(f)표」.
|
||
#: [주] 이어짐 「- ②」와 같은 「-」로 시작해서, 멈춤이 「#」과 표뿐이면 제목도 남의 [주]도 끌어온다.
|
||
#: ⚠⚠ 그런데 **같은 꼴로 진짜 규칙이 이어지는 자리가 있다** — 9-8 구조물 헐기가
|
||
#: 「- 2. 철근절단 : …」·「- 4. 고철공제 : A=1㎥×0.008×…」처럼 **단계마다 번호를 달고** 적는다.
|
||
#: 꼴만 보고 자르면 **고철 공제 50kg/㎥ 가 통째로 사라진다**(2026-09-18 2바퀴 검사 34건).
|
||
#: ⇒ **알맹이 없는 제목일 때만** 자른다 — 콜론도 숫자도 없고 짧은 줄.
|
||
_LIST_HEAD_RE = re.compile(
|
||
rf"^-\s*(?:\d+\.|[{_ORDER}]\.|\(\d+\)|\([{_ORDER}]\)|[{_ORDER}]\))\s*(?P<rest>.*)$"
|
||
)
|
||
#: 제목으로 볼 최대 길이. 규칙 글은 이보다 길다.
|
||
_TITLE_MAX = 30
|
||
|
||
|
||
def is_bare_title(text: str) -> bool:
|
||
"""목록 줄이 **알맹이 없는 절 제목**인가 — 콜론·등호·숫자가 없고 짧으면 제목이다.
|
||
|
||
⚠ 잣대는 「지우는 것보다 남기는 것이 안전」이다(2026-09-18 브레인). 조금이라도 규칙 같으면 남긴다.
|
||
"""
|
||
found = _LIST_HEAD_RE.match(text)
|
||
if found is None:
|
||
return False
|
||
rest = found.group("rest").strip()
|
||
if not rest:
|
||
return True
|
||
if any(ch in rest for ch in "::=%"):
|
||
return False # 콜론·등호·백분율이 있으면 규칙이다 — 「- 4. 고철공제 : A=1㎥×…」
|
||
# ⚠ 「숫자가 있으면 제목이 아니다」로는 못 가른다 — 「- 3. 뿌리제거(100본당)」이 제목이다.
|
||
# 남는 잣대는 **길이**뿐이다. 규칙 글은 이보다 길다(2026-09-18 2·3바퀴에서 두 번 겪음).
|
||
return len(rest) <= _TITLE_MAX
|
||
|
||
|
||
#: ⚠ **쪽이 넘어가며 쪽번호와 장 제목이 [주] 줄 끝에 눌어붙은 자리** — 「…따른다.11제1장 적용기준」.
|
||
#: 「숫자 + 제N장」이 띄어쓰기 없이 붙는 꼴은 본문에 없으므로 이 자리를 잘라 낸다(건설 131줄).
|
||
PAGE_BREAK_RE = re.compile(r"\d{1,4}제\d+장.*$")
|
||
|
||
|
||
def trim_page_break(text: str) -> str:
|
||
"""줄 끝에 눌어붙은 쪽번호·장 제목을 떼어 낸다. 없으면 그대로."""
|
||
return PAGE_BREAK_RE.sub("", text).rstrip()
|
||
|
||
|
||
def notes_from_source(lines: list[str], line_no: int) -> list[str]:
|
||
"""표 바로 아래 `[주]` 글줄 목록. 없으면 빈 목록.
|
||
|
||
왜 싣나 — `[주]` 에 **금액에 바로 닿는 조건**이 적혀 있다(발파 가산 20% · 고철 공제 50kg/㎥ ·
|
||
공구손료 3% · 위험목 인력 100% 가산 …). **읽기만 싣는다 — 여기서 셈하지 않는다.**
|
||
|
||
멈추는 자리 둘뿐 — **다음 절(`#`)** 과 **새 표(`|`)**.
|
||
⚠ 그 사이에 끼는 것은 **끊지 않고 건너뛴다** — 「> 【산출 예시】」 인용과 「* (전목) …」 줄이
|
||
[주] 중간에 들어오는데, 거기서 끊으면 **뒤에 오는 ⑤⑥ 가산·할증이 사라진다**
|
||
(2026-09-18: 벌채 30%·조재 20%, 위험목 100%, 집재 주행 10% 가 그렇게 사라졌음).
|
||
⚠ **[주] 를 만나기 전에는** 알맹이 없는 제목에서 멈춘다 — 제 [주] 가 없는 표가 제목을 건너
|
||
남의 [주] 를 빌려오던 것을 막는다(F0009 가 강재류 [주]를 달고 있었음).
|
||
⚠ 한 표에 `[주]` 덩어리가 **여럿**일 수 있다(9-8 구조물 헐기는 단계마다 [주]를 단다) — 안 끊는다.
|
||
"""
|
||
out: list[str] = []
|
||
started = False
|
||
body_done = False
|
||
for index in range(line_no, min(len(lines), line_no + NOTES_LOOKAHEAD)):
|
||
text = lines[index].strip()
|
||
if text.startswith("#"):
|
||
break
|
||
if text.startswith("|"):
|
||
if started or body_done:
|
||
break # 새 표가 왔다 — 이 표의 [주] 는 여기까지
|
||
continue # 아직 이 표 본문 안이다
|
||
if not text:
|
||
continue
|
||
if not started and is_bare_title(text):
|
||
break # 제목을 건너 남의 [주] 를 빌려오지 않는다
|
||
if text.startswith(_NOTE_START):
|
||
started = True
|
||
out.append(trim_page_break(text))
|
||
continue
|
||
body_done = True # 표 본문은 끝났다(글줄이 나왔다)
|
||
if not started:
|
||
continue
|
||
if is_bare_title(text):
|
||
break # 알맹이 없는 제목 — 여기서부터는 남의 글이다
|
||
if text.startswith(_NOTE_CONT):
|
||
out.append(trim_page_break(text))
|
||
# 그 밖(「> 【산출 예시】」·「* (전목) …」)은 **끊지 않고 건너뛴다**
|
||
return out
|
||
|
||
|
||
#: ⚠ **밑수가 절 이름에만 있는 표** — 「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
|
||
|
||
|
||
#: 밑수가 될 수 있는 **세는 단위**(분모 자리).
|
||
_DENOM = "㎥|m3|㎡|m2|㏊|ha|㎞|km|m|매|본|개소|개|인|공|kg|㎏|톤|ton|주|식|대|일|시간|hr"
|
||
#: 값이 붙는 **재는 단위**(분자 자리). 이쪽이 단위꼴이어야 「몫」이지 규격이 아니다.
|
||
_NUMER = "인|공|ℓ|L|l|㎏|kg|톤|ton|m|㎝|cm|매|본|개|주|대|시간|hr|㎥|m3|㎡|m2"
|
||
|
||
#: ⚠ **표머리에 「당」 없이 몫으로만 적힌 밑수** — `인/ha` · `ℓ/일,대` · `m/본` (명세 꼴 B).
|
||
#: 본문 쪽은 `SOURCE_BASIS_RATIO_RE` 가 이미 보는데 **표머리는 안 보고 있었다.**
|
||
#: ⚠ 좁게 잡는다 — **양쪽이 다 단위꼴일 때만**. 안 그러면 `(무한궤도,0.7㎥)` 같은
|
||
#: 규격 칸이 밑수로 둔갑한다(옛날에 실제로 다섯 건 걸렸다).
|
||
HEADER_RATIO_RE = re.compile(
|
||
rf"(?:^|[((\s])(?:단위\s*[::]\s*)?(?:{_NUMER})\s*/\s*([\d,]*\.?\d*)\s*({_DENOM})\s*당?(?:$|[,))\s])"
|
||
)
|
||
#: `재료비(1km 소요량기준)` 꼴 — 「당」 대신 「소요량기준」으로 적은 표머리.
|
||
HEADER_BASIS_NOTE_RE = re.compile(rf"([\d,]*\.?\d*)\s*({_DENOM})\s*소요량\s*기준")
|
||
|
||
|
||
#: 분모가 둘인 몫 — `ℓ/일,대` · `인/㎥·일`. 밑수가 하나로 안 정해진다.
|
||
COMPOUND_DENOM_RE = re.compile(rf"/\s*[\d,]*\.?\d*\s*(?:{_DENOM})\s*[,,·]\s*(?:{_DENOM})")
|
||
|
||
|
||
def basis_from_header(cell: str) -> tuple[float | None, str | None]:
|
||
"""표머리 한 칸에서 몫꼴 밑수를 읽는다. 못 읽으면 `(None, None)`.
|
||
|
||
⚠ 분모가 밑수다 — `인/ha` 는 「1ha당 몇 인」이라 밑수는 `ha` 다. 분자(인)를 밑수로
|
||
읽으면 뜻이 뒤집힌다.
|
||
"""
|
||
# ⚠ **분모가 둘인 몫은 거절한다** — `ℓ/일,대` 는 「하루에 한 대당」이라 밑수가 둘이다.
|
||
# 한쪽(일)만 적으면 대수를 안 곱해 조용히 적게 선다. 반쪽 진실보다 「미확보」가 정직하다.
|
||
if COMPOUND_DENOM_RE.search(cell):
|
||
return None, None
|
||
for pattern in (HEADER_RATIO_RE, HEADER_BASIS_NOTE_RE):
|
||
if m := pattern.search(cell):
|
||
raw = (m.group(1) or "").replace(",", "")
|
||
unit = m.group(2)
|
||
if not person_basis_ok(raw, unit):
|
||
continue
|
||
try:
|
||
return (float(raw) if raw else 1.0), unit
|
||
except ValueError:
|
||
return 1.0, unit
|
||
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)
|
||
# 「당」이 없는 몫꼴은 **표머리에서만** 본다(줄 값에는 규격이 섞여 있다).
|
||
# ⚠ 표머리가 **서로 다른 분모**를 말하면 거절한다 — 건설 8장 기계경비표가 한 표 안에서
|
||
# `주연료 (ℓ/hr)` 와 `조종원 (인/일)` 을 함께 적는다. 먼저 걸린 것을 고르면 반은 틀린다.
|
||
found = {
|
||
basis_from_header(cell)
|
||
for cell in (norm(h) for h in table.get("headers", []) or [])
|
||
if cell
|
||
}
|
||
found = {hit for hit in found if hit[1]}
|
||
if len(found) == 1:
|
||
return found.pop()
|
||
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 new_report() -> dict[str, Any]:
|
||
"""표 읽기 셈 — 밑수 확보·묶음 기준 수 · 형태 못 정한 표 · 밑수 못 찾은 표.
|
||
|
||
⚠ 밑수를 못 찾은 표 목록은 「곱하면 안 되는 줄」을 받는 쪽이 가릴 수 있게 낸다 —
|
||
빈칸으로 두면 「1단위당」으로 오해되어 곱셈이 10배·100배 틀린다.
|
||
"""
|
||
return {"basis_found": 0, "basis_grouped": 0, "basis_missing": [], "undetermined": []}
|
||
|
||
|
||
def table_entry(
|
||
table: dict[str, Any],
|
||
section: str,
|
||
number: str | None,
|
||
chapter: str | None,
|
||
source_lines: list[str],
|
||
node_name: str | None,
|
||
report: dict[str, Any],
|
||
) -> dict[str, Any]:
|
||
"""표 한 장 → 공종 마스터 표 칸(형태·밑수·깃발). 산림·건설 한 벌 — 셈은 `report` 에 쌓음.
|
||
|
||
`chapter` 는 형태 판정의 「1장 = 적용기준」 가름 · `node_name` 은 절 이름이 곧 밑수인 표(4-2-2 「1,000㎡당」).
|
||
"""
|
||
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 = basis_source = None
|
||
# 사람이 원문에서 확인한 밑수가 가장 앞이다 — **머리가 소실돼 자동으로는 못 읽는 표**만 해당.
|
||
if judged := judged_basis(table["table_id"], number):
|
||
basis_qty, basis_unit, basis_source = judged[0], judged[1], judged[2]
|
||
if basis_unit is None and source_lines:
|
||
basis_qty, basis_unit = basis_from_source(source_lines, int(table.get("line") or 0))
|
||
elif basis_unit is None:
|
||
basis_source = None
|
||
if basis_qty is None and basis_unit is None:
|
||
basis_qty, basis_unit = detect_basis(table)
|
||
basis_source = "표 안" if basis_unit else None
|
||
elif basis_source is None:
|
||
basis_source = "본문"
|
||
# 본문·표 안에 없을 때만 **절 이름**을 본다(4-2-2 「1,000㎡당」) — 이름 전체가 밑수일 때만.
|
||
if basis_unit is None and node_name is not None:
|
||
name_qty, name_unit = basis_from_name(node_name)
|
||
if name_unit:
|
||
basis_qty, basis_unit, basis_source = name_qty, name_unit, "절 이름"
|
||
if basis_unit:
|
||
report["basis_found"] += 1
|
||
if basis_quantity_is_grouped(basis_qty):
|
||
report["basis_grouped"] += 1
|
||
elif form in ("requirement", "productivity") and not crew_table(table):
|
||
# 참조·계수표는 곱할 값이 아니므로 목록에 넣지 않는다 — 잡음이 되면 안 본다.
|
||
# 작업조 표도 뺌 — 밑수가 시공량 열(1단위당 = 인원 ÷ 시공량 · B09 CrewOutput)이라
|
||
# 「N㎡당」 문구가 없어도 곱셈이 안 틀림(12-38-3 설치·해체가 여기 걸려 막혀 있었음 · 2026-09-14 301).
|
||
report["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": [],
|
||
"notes": notes_from_source(source_lines, int(table.get("line") or 0)),
|
||
"condition_note": [norm(h) for h in table.get("headers", []) if norm(h)],
|
||
"raw_row": table.get("rows", []), # 원문 셀 — B09 자원 축이 읽는다.
|
||
}
|
||
if form == "undetermined":
|
||
report["undetermined"].append(
|
||
{
|
||
"pum_table_id": table["table_id"],
|
||
"section": section,
|
||
"headers": entry["condition_note"],
|
||
"first_rows": table.get("rows", [])[:2],
|
||
"reason": why,
|
||
}
|
||
)
|
||
return entry
|