refactor(B08,B09): 폴더째 old_code 로 옮기고 빈 화면 둘만 남김 (PLAN 7-3)

B08_Quantity 86 · B09_Estimation 111 파일을 old_code/ 로 옮김(지우지 않음).
화면은 메뉴·주소·단계 막대만 남은 빈 틀 둘 — main.py 라우터 13 개는 끊음.
B07 이 빌려 쓰던 비탈 길이·면적은 필요한 함수만 B07_DesignDetail_Engine_SlopeGeometry
로 옮겨 적음(면적 적분·노면 면적·측점 묶음은 안 옮김) · 시험 하나를 새로 둠.
B07 구조물도 조립(Cad_StandardSheet)은 2026-09-13 에 이미 도면 목록에서 빠져
부르는 곳이 없어 old_code 로 같이 보냄 — 구조물 그림은 되살리지 않음.
B06 구조물 몫 조회는 빈 값으로 두어 화면이 그대로 서게 함.
B08·B09 를 부르던 시험 25 개도 old_code/resources/tester 로 옮김.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ
This commit is contained in:
2026-09-22 12:27:45 +09:00
co-authored by Claude Opus 5
parent 185f800d23
commit 8472fc9f40
231 changed files with 1617 additions and 1216 deletions
@@ -0,0 +1,664 @@
"""B09 원가계산 — 자원 축 매칭 (PLAN 8-6 · 9-3).
메인 창이 낸 **공종 축**(`resources/data_work_item_master/`)을 **읽기만** 하고, 그 위에
**자원 축**(`resource_kind`·`resource_code`·`resource_spec`·`amount`·`amount_unit`)을
붙여 **별도 파일**로 낸다. 원본은 고치지 않는다 — 메인이 품셈을 다시 돌리면 덮이므로
그 안에 섞으면 사라진다.
지켜야 할 것
1. **`pum_form` 을 먼저 본다.** `productivity`(생산량형) = **1 ÷ 값**,
`requirement`(소요량형) = **값 ÷ basis_quantity**. ⚠ **뒤집으면 20배 틀린다.**
직종 이름부터 보면 「작업능력(㎥/hr)」 표의 비고란 「보통인부 1인/일」에 끌려
생산량형을 소요량형으로 읽는다(메인이 실제로 한 번 뒤집혔다가 잡은 자리).
2. **`coefficient` · `reference` 는 공종이 아니다.** 일위대가 항목으로 세우지 않는다.
`undetermined` 는 **값을 쓰지 않는다.**
3. **규격(`resource_spec`)이 없으면 매칭 성공으로 치지 않는다.** 「굴착기」와
「굴착기 0.7㎥」는 단가가 다르다 — 이름만 맞추면 조용히 틀린 단가가 붙는다.
4. **못 맞춘 것은 빈칸이 아니라 `unmatched` 목록**으로 낸다.
5. **자재는 할증 전 값**이다 (PLAN 8-7 ㉠). 할증은 자재총괄 한 곳뿐이다.
"""
from __future__ import annotations
import json
import os
import re
from dataclasses import dataclass, field, replace
from decimal import Decimal, InvalidOperation
from typing import Any
# 셀 거르기 · 물결표 목록 · 셀 정규화 — 700줄 제한으로 `_Labels` 에 둠(순수 분리 2026-09-14).
# 바깥 파일이 여기서 가져가는 이름은 그대로 다시 내보낸다.
from B09_Estimation.B09_Estimation_ResourceAxis_Labels import (
_RE_RANGE_CELL, # noqa: F401
RANGE_DASH_CLASS, # noqa: F401
RANGE_DASHES, # noqa: F401
_normalize,
is_non_resource_label,
)
#: 자원 축을 붙일 수 있는 표 형태. 나머지는 값을 쓰지 않는다.
USABLE_FORMS = frozenset({"productivity", "requirement"})
#: 공종이 아닌 표 — 일위대가 항목으로 세우지 않는다.
NON_WORK_ITEM_FORMS = frozenset({"coefficient", "reference"})
#: 형태 판정이 안 된 표 — 값을 쓰지 않는다.
UNUSABLE_FORMS = frozenset({"undetermined"})
_MASTER_SUBPATH = ("resources", "master_data", "old")
_CATALOG_SUBPATH = ("resources", "master_data", "old")
#: 규격이 이름 안에 붙어 있는 흔한 모양 — 「굴착기(0.7㎥)」·「덤프트럭 15톤」.
_RE_SPEC = re.compile(r"[(]([^)]+)[)]|(\d+(?:\.\d+)?\s*(?:톤|ton|㎥|m3|㎡|㎜|mm|HP|kW))")
_RE_NUMBER = re.compile(r"^-?\d+(?:,\d{3})*(?:\.\d+)?$")
#: 첫 칸이 **분류 딱지**이고 이름이 둘째 칸에 오는 표가 있다.
#: 예 — `['자재', '종 자', '', 'kg', '0.025']` · `['장비', '종자살포기', …]`.
#: 이 표를 첫 칸만 보고 읽으면 **자재·장비가 통째로 빠진다**(2026-09-07 실측 —
#: 씨앗뿜어붙이기에서 종자·비료·피복제·침식안정제·색소·장비 3종이 다 빠지고
#: 보통인부 한 줄만 남았다).
_GROUP_LABELS = ("자재", "장비", "인력", "노무", "재료", "기계")
class ResourceAxisError(ValueError):
"""자원 축을 붙일 수 없는 경우. 조용히 넘기지 않는다."""
def _project_root() -> str:
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def _read_json(*parts: str) -> dict[str, Any]:
with open(os.path.join(_project_root(), *parts), encoding="utf-8") as handle:
return json.load(handle)
@dataclass(frozen=True)
class CatalogEntry:
"""단가 카탈로그 한 줄 — 매칭 대상."""
code: str
name: str
kind: str
spec: str = ""
#: ⚠ **규격이 조인 키인 항목**(`AR-` 자원 목록) — 후보가 하나여도 규격이 같아야 고른다
#: (명세 §11).
strict_spec: bool = False
@dataclass
class ResourceCatalog:
"""이름 → 코드. **같은 이름에 규격이 여럿이면 규격 없이는 못 고른다.**"""
entries: list[CatalogEntry] = field(default_factory=list)
aliases: dict[str, str] = field(default_factory=dict)
#: 범위 별칭 `{from, to, scope, pum_edition}` — 그 공종 범위 안에서만 쓴다(명세 5장).
scoped_aliases: list[dict[str, str]] = field(default_factory=list)
#: 이름 → 항목 색인. 자재까지 붙으면 7,700건이 넘어 매번 훑으면 느리다.
_index: dict[str, list[CatalogEntry]] | None = None
#: 괄호 앞 이름 → 항목 색인(형식 계열). `_Join.family_members` 가 채운다.
_family: dict[str, list[CatalogEntry]] | None = None
def by_name(self, name: str) -> list[CatalogEntry]:
if self._index is None:
index: dict[str, list[CatalogEntry]] = {}
for entry in self.entries:
index.setdefault(_normalize(entry.name), []).append(entry)
self._index = index
return self._index.get(_normalize(name), [])
def resolve(self, name: str, spec: str) -> CatalogEntry | None:
"""이름(+규격)으로 한 줄을 고른다. 못 고르면 None — 0 으로 안 때운다."""
found = self.by_name(name)
if not found:
return None
if any(entry.strict_spec for entry in found):
return pick_by_spec(found, spec) # 한 건뿐이어도 규격이 안 맞으면 None(판정 Ⓑ)
if len(found) == 1:
return found[0]
# 이름이 여럿이면 규격이 있어야 고를 수 있다.
if not spec:
return None
narrowed = [e for e in found if _normalize(e.spec) == _normalize(spec)]
return narrowed[0] if len(narrowed) == 1 else None
@dataclass
class ResourceRow:
"""자원 축 한 줄 — 공종(표) 하나에 붙는 자원 하나."""
work_item_code: str
pum_table_id: str
pum_form: str
resource_kind: str
resource_code: str
resource_name: str
resource_spec: str
amount: Decimal
amount_unit: str
raw_row_index: int
#: 조건 시공 시 쓰는 대안값 — 「1.04(1.17)」의 1.17 (품셈 13-6-1 [주]②).
#: **기본값은 `amount`(괄호 밖)** 이고 이 값은 화면에 「시공 시 다름」으로 보인다.
alternative_amount: Decimal | None = None
#: 규격 갈래 — 열이 자원인 표에서 행 이름(「무근구조물」). 없으면 빈 문자열.
#: **갈래마다 품이 다르므로 한 일위대가로 뭉치지 않는다.**
variant: str = ""
#: 분류 딱지가 달고 온 배분율 — 「인력(10%)」이면 `10`. 없으면 `None`.
#: ⚠ **이 값을 안 보면 단가가 조용히 틀린다** — 인력 몫 원단위를 전량에 곱하게 된다
#: (2026-09-08 실측: 측구터파기 39,575.6원/㎥ 이 인력 10 % 몫만이었다).
group_ratio_pct: Decimal | None = None
#: 이 줄을 읽어 낸 품셈 판 — `pum_table_id`·`work_item_code` 가 판에 묶임(명세 17장).
pum_edition: str = ""
def as_dict(self) -> dict[str, Any]:
return {
"work_item_code": self.work_item_code,
"pum_table_id": self.pum_table_id,
"pum_form": self.pum_form,
"resource_kind": self.resource_kind,
"resource_code": self.resource_code,
"resource_name": self.resource_name,
"resource_spec": self.resource_spec,
"amount": str(self.amount),
"amount_unit": self.amount_unit,
"raw_row_index": self.raw_row_index,
"variant": self.variant,
"alternative_amount": (
None if self.alternative_amount is None else str(self.alternative_amount)
),
"group_ratio_pct": None if self.group_ratio_pct is None else str(self.group_ratio_pct),
"pum_edition": self.pum_edition,
}
@dataclass
class UnmatchedRow:
"""못 맞춘 것 — **빈칸으로 두지 않고 여기 모은다**."""
work_item_code: str
pum_table_id: str
cell: str
reason: str
def as_dict(self) -> dict[str, Any]:
return {
"work_item_code": self.work_item_code,
"pum_table_id": self.pum_table_id,
"cell": self.cell,
"reason": self.reason,
}
@dataclass
class AxisResult:
rows: list[ResourceRow] = field(default_factory=list)
unmatched: list[UnmatchedRow] = field(default_factory=list)
skipped_forms: dict[str, int] = field(default_factory=dict)
#: 제잡비 비율(%) — `{공종코드: (윗단, 아랫단)}`. 윗단은 물빼기 파이프 설치,
#: 아랫단은 미설치 (품셈 13-6-2 [주]③). 값이 하나뿐이면 둘이 같다.
overhead_ratio: dict = field(default_factory=dict)
#: 자원은 알아봤는데 **값을 못 읽은** 줄이 있는 공종 — 그 단가는 「일부만 선 것」이다.
#: 기초잡석 12-25 가 `소할(30%) | 할석공(인) | 0.2 × 30%` 를 못 읽어 부설다짐만으로
#: 107,145 원이 서고 있었다(2026-09-08). **부분 성공이 가장 위험하다.**
partial_items: dict[str, str] = field(default_factory=dict)
def _tidy_resource_name(cell: str) -> str:
"""이름 표기를 카탈로그 쪽으로 맞춘다 — **뜻을 바꾸지 않는 표기 차이만.**
① 이름 안 공백 제거 (「굴 삭 기 (무한궤도)」 → 「굴삭기(무한궤도)」)
② 같은 기종의 다른 이름 (「굴삭기」·「유압식백호우」 → 「굴착기」)
⚠ 규격은 안 건드린다 — 규격을 맞추려 들면 엉뚱한 기종이 붙는다.
"""
from B09_Estimation.B09_Estimation_MachineProductivity import MACHINE_NAME_ALIASES
text = str(cell)
head, sep, tail = text.partition("(")
tight = "".join(head.split())
for wrong, right in MACHINE_NAME_ALIASES.items():
if tight == wrong:
tight = right
break
return tight + sep + tail
#: 장비 줄의 단위 — 시간·대수로 센다. 자재는 kg·매·㎥ 로 센다.
_MACHINE_UNITS = ("시간", "hr", "h", "", "시 간")
#: 조용히 빠지던 기계·연료 줄의 사유(2026-09-15 ㉠ — 인력만으로 싸게 서던 7공종).
SILENT_MACHINE = "기계·연료 줄이 안 붙음 — 이 공종 단가는 일부만 섬(인력만으로 싸게 서지 않게 막음)"
#: 규격이 이름 앞에 온 장비 칸 — 「0.8㎥ 굴착기」.
_RE_SPEC_FIRST = re.compile(r"^\d+(?:\.\d+)?(?:㎥|m3|ton|톤|㎾|kW)\s*[가-힣]{2,}")
#: 연료·기관 줄 이름 — 값이 비었거나 식이라도 기계 몫(연료비 · 휘발유 · 경유 · 엔진 · 기관).
_RE_FUEL_OR_ENGINE = re.compile(r"연료|휘발유|경유|엔진|기관|양수기|발전기|원동기")
_POWER_UNITS = ("kW", "", "HP", "PS", "마력")
def _silent_machine_row(name_cell: str, value_cells: list[str], has_digit: bool) -> bool:
"""값을 못 읽고 이름도 안 풀린 줄이 **기계·연료 몫**인가 — 머리 줄·설명 줄은 아님."""
name = _normalize(name_cell)
if not name or is_non_resource_label(name_cell) and not _RE_FUEL_OR_ENGINE.search(name):
return False
if _RE_FUEL_OR_ENGINE.search(name):
return True
joined = " ".join(value_cells)
return has_digit and (
_is_machine_like_row(value_cells) or any(unit in joined for unit in _POWER_UNITS)
)
def _is_machine_like_row(cells: list[str]) -> bool:
"""그 줄이 **장비 몫**인가 — 단위 칸이 시간·대수인지로 본다."""
for cell in cells:
text = _normalize(cell)
if text and any(text == unit or text.replace(" ", "") == unit for unit in _MACHINE_UNITS):
return True
return False
def _resolve_cell(catalog: ResourceCatalog, name_cell: str, cells: list[str]):
"""셀 하나를 카탈로그 한 줄로 푼다 — 세 가지 모양을 차례로 시도한다.
① 셀 전체가 곧 이름 (「보통인부」)
② 기종 셀 (「굴착기(무한궤도, 0.7㎥)」 → 이름 + 규격)
③ 규격이 **옆 칸**에 있는 표 (「굴착기 (무한궤도)」 | 「0.7㎥」)
"""
# ⚠ **이름 안 공백·표기 차이를 먼저 없앤다.** 품셈은 같은 기종을 「굴 삭 기」·
# 「굴착기」·「유압식백호우」로 섞어 적는다(2026-09-08: 메쌓기 13-6-1 의
# 「굴 삭 기 (무한궤도)」가 안 붙어 그 공종 장비 몫이 통째로 빠졌다).
name_cell = _tidy_resource_name(name_cell)
machine_name, machine_spec = parse_machine_cell(name_cell)
plain_name, plain_spec = split_name_and_spec(name_cell)
for name, spec in ((machine_name, machine_spec), (plain_name, plain_spec)):
if not name:
continue
entry = catalog.resolve(name, spec)
if entry is not None:
return entry
# 이름은 맞는데 규격이 없어 못 고른 경우 — 옆 칸에서 규격을 찾는다.
for name in (machine_name, plain_name):
found = catalog.by_name(name)
strict = any(entry.strict_spec for entry in found)
if len(found) <= 1 and not strict:
continue
# 조인 키 항목은 **글자 규격**(「복합비료」·「∅200mm」)도 본다 — 기존 항목 길은 그대로.
for candidate in (
*spec_candidates(cells[1:]),
*(text_spec_candidates(cells[1:]) if strict else ()),
):
entry = catalog.resolve(name, candidate)
if entry is not None:
return entry
# 이름이 **형식 하나뿐인 계열**이면 규격으로 고른다(「공기압축기(3.5㎥/min)」 → 이동식 3.5).
return resolve_family(catalog, name_cell, cells)
#: 공종 단위로 인정하지 않는 말 — **품의 단위**(사람·날)이지 물리 수량이 아니다.
_NOT_A_WORK_ITEM_UNIT = frozenset({"", "인당", "", "일당", "인/일"})
def match_table(
node: dict[str, Any],
table: dict[str, Any],
catalog: ResourceCatalog,
result: AxisResult,
) -> None:
"""표 하나에 자원 축을 붙인다. 값이 안 서면 `unmatched` 로 보낸다."""
# 예시 서식(「ha당 …단가산출서(예시)」)은 **B08 이 마스터 원천에서 거른다**(8건).
# 두 곳에서 같은 것을 거르면 **나중에 한쪽만 고쳐진다** — 원천이 이긴다.
# ⚠ 짝 시험은 남겨 둔다(예시 서식이 자원으로 안 서는지) — 원천이 바뀌면 그것이 알려 준다.
from B09_Estimation.B09_Estimation_CrewOutput import match_crew_table
# ⚠ **작업조 표는 형태 판정보다 먼저 가른다.** 「형틀목공 4인 / 시공량 35㎡」 표는
# 마스터에서 `reference` 로 찍혀 형태 필터에 먼저 걸려 버려지고 있었다(유로폼 12-38-3).
# 그 표는 모양이 스스로를 말한다 — 시공량 열 + 작업조 줄이 있으면 그것이다.
# 행-자원으로 읽으면 **인원 4를 소요량 4로** 오해해 35배 부푼다.
if match_crew_table(node, table, catalog, result, table.get("basis_unit") or ""):
return
# ⚠ **축이 셋인 표도 형태 판정보다 먼저 가른다.** 값 한 칸에 세로축 값이 여럿
# 뭉쳐 있어 행-자원으로도 열-자원으로도 안 읽히고, 콘테이너형 가설건축물(11-1)은
# `reference` 로 찍혀 형태 필터에 먼저 걸려 버려지고 있었다(확정 ⑬ 이 걸린 표).
from B09_Estimation.B09_Estimation_ResourceAxis_ThreeAxis import match_three_axis_table
if match_three_axis_table(node, table, catalog, result):
return
# ⚠ **「둘 중 하나를 고르는」 장비 블록 표도 먼저 가른다.** 그냥 읽으면 블록을 다 더해
# 장비 두 대·인부 두 몫이 서서 대략 두 배가 된다(2026-09-09 제근 128,039원).
from B09_Estimation.B09_Estimation_ResourceAxis_ChooseOne import (
match_choose_one_machine_table,
)
if match_choose_one_machine_table(node, table, catalog, result):
return
# ⚠ **규격이 열로 선 표**(관부설 12-11)도 여기서 가른다 — 값이 「0.62/2.5」 같은
# 나눗셈이라 보통 길로는 한 줄도 안 선다.
from B09_Estimation.B09_Estimation_ResourceAxis_ChooseOne import match_spec_column_table
if match_spec_column_table(node, table, catalog, result):
return
form = table.get("pum_form", "")
if form in NON_WORK_ITEM_FORMS or form in UNUSABLE_FORMS or form not in USABLE_FORMS:
result.skipped_forms[form] = result.skipped_forms.get(form, 0) + 1
return
basis = table.get("basis_quantity")
basis_quantity = None if basis is None else Decimal(str(basis))
unit = table.get("basis_unit") or ""
# ⚠ **「인」은 공종 단위가 아니다** — 사람 수다. 마스터가 본문·표에서 「인」을 밑수로
# 읽어 온 자리가 있고(드론방제·지상방제·야면석 채집·휘발유), 그대로 두면 「몇 인짜리
# 공종」이라는 뜻이 되어 **단위 불일치 검사가 엉뚱하게 통과**한다.
# ⚠ 원문 고치기는 마스터 쪽 몫(2026-09-09 데스크탑 메인) — 여기서는 **받는 쪽에서 막는다.**
# 짝 규칙은 `B09_Estimation_WorkItemUnit` 머리말에 이미 적어 둔 것과 같다.
if unit.strip() in _NOT_A_WORK_ITEM_UNIT:
unit = ""
# ⚠ **한 표에 밑수가 둘인 표가 있다** — 「㎡당 0.17 / ㎥당 0.64」(채집 13-2 계열).
# 행-자원으로 읽으면 **앞줄만 잡고 뒷줄을 버린다** — 막돌 채집이 ㎡당 값을 ㎥ 단위로
# 달고 있었다(3.8 배 차이). 형태 판정보다 먼저 가른다.
from B09_Estimation.B09_Estimation_ResourceAxis_UnitBasis import match_unit_basis_table
if match_unit_basis_table(node, table, catalog, result):
return
# 사람이 모양을 읽어 둔 표(9-19-1 절토면·성토면 · 2026-09-14 판정) — 적어 둔 표만.
from B09_Estimation.B09_Estimation_ResourceAxis_JudgedTable import match_judged_table
if match_judged_table(node, table, catalog, result, basis_quantity, unit):
return
# ⚠ **자원이 열 머리에 오는 표가 따로 있다** (2026-09-08 발견, 39 표).
# 「구 분 | 콘크리트공(인) | 보통인부(인)」처럼 **열이 자원**이고 행은 규격 갈래
# (무근·철근·소형구조물)다. 행을 자원으로 읽는 길로 보내면 통째로 못 맞춘다 —
# 콘크리트 타설(12-1)이 그래서 하나도 안 서고 있었다.
# 공정 줄을 더해 한 품이 되는 표(단끊기 5-16-1) — 사람이 적은 공종만. 아래 길은 표째 버린다.
from B09_Estimation.B09_Estimation_ResourceAxis_ProcessSum import match_process_sum_table
if match_process_sum_table(node, table, catalog, result, basis_quantity, unit):
return
# 머리가 두 단으로 병합된 표(인력 돌쌓기 13-4-1·13-4-4) — hwpx 병합으로 확인한 공종만.
from B09_Estimation.B09_Estimation_ResourceAxis_TwoLevelHeader import (
match_two_level_header_table,
)
if match_two_level_header_table(node, table, catalog, result, basis_quantity, unit):
return
from B09_Estimation.B09_Estimation_ResourceAxis_Transposed import match_transposed_table
if match_transposed_table(node, table, catalog, result, basis_quantity, unit):
return
# 「석공 보통인부 | 0.09 0.05 | …」처럼 **이름도 값도 뭉쳐 오고 열이 갈래**인 표
# (돌쌓기 13-4 계열). 행-자원으로는 첫 이름조차 안 풀린다.
from B09_Estimation.B09_Estimation_ResourceAxis_Transposed import match_packed_rows
if match_packed_rows(node, table, catalog, result, basis_quantity, unit):
return
# ⚠ **묶음 배분율은 다음 줄로 이어진다.** 품셈 표는 묶음 머리를 **병합해** 적는다 —
# 「인력(10%) | 할석공 2.0」 다음 줄이 「보통인부 1.0」이라 그 줄엔 딱지가 없다.
# 이어 주지 않으면 그 줄만 **100%로 서서** 조용히 열 배가 된다(2026-09-09 실측:
# 구조물터파기 보통인부가 0.1 대신 1.0 으로 서 단가가 224,305원/㎥ 이었다).
carried_ratio: Decimal | None = None
for index, row in enumerate(table.get("raw_row", [])):
cells = [str(c) for c in row]
if not cells:
continue
# 첫 칸이 분류 딱지(「자재」·「장비」)면 **이름은 둘째 칸**이다.
#
# ⚠ 딱지 목록만으로는 모자란다 — 첫 칸이 **공정 이름**인 표가 따로 있다
# (기초잡석 12-25: `소할(30%) | 할석공(인) | 0.2 × 30%`). 목록에 없는 말이라
# 통째로 못 맞추고 있었다. 그래서 **딱지 목록에 없더라도 첫 칸이 자원으로 안 풀리고
# 둘째 칸이 풀리면** 이름을 둘째 칸에서 읽는다 — 판정을 낱말이 아니라
# **풀리는지**로 한다. 배분율 꼬리표(「(30%)」)는 어느 쪽이든 첫 칸에서 읽는다.
name_cell = cells[0]
value_cells = cells[1:]
group_ratio = None
if len(cells) > 1 and (
_group_label_of(name_cell) is not None
or (
_resolve_cell(catalog, name_cell, cells) is None
and _resolve_cell(catalog, cells[1], cells[1:]) is not None
)
):
group_ratio = _group_ratio_of(name_cell)
name_cell = cells[1]
value_cells = cells[2:]
# 새 묶음 머리를 만났다 — 여기서부터 이 배분율이 이어진다(없으면 끊는다).
carried_ratio = group_ratio
else:
group_ratio = carried_ratio
# 제잡비 비율 줄 — 자원이 아니라 **노무비에 붙는 경비율**이다(품셈 [주]③).
if "제잡비" in _normalize(name_cell):
ratios = [
parse_amount(_normalize(token))
for cell in value_cells
for token in _RE_ALTERNATIVE.sub(r"", _normalize(cell)).split(" ")
]
ratios = [x for x in ratios if x is not None]
if ratios:
upper = ratios[0]
lower = ratios[1] if len(ratios) > 1 else ratios[0]
result.overhead_ratio[node["work_item_code"]] = (upper, lower)
continue
# ⚠ **공식 계수를 단 줄은 자원 줄이 아니다.** 「유압식백호우 … | k | 0.9」 처럼
# 같은 줄에 버킷계수가 붙어 오는데, 그 0.9 를 소요량으로 읽으면 **시간당 사용료가
# 0.9시간분** 붙어 이중이 된다(2026-09-08: 이름 표기를 맞추자 측구터파기에
# 「굴착기 0.81」 줄이 새로 생겨 발견). 그 줄은 시공능력 공식 쪽에서 쓴다.
if any(_normalize(c).lower() in ("k", "f", "e") for c in value_cells):
continue
# 숫자 셀이 없는 행은 자원 줄이 아니다(제목·설명 행) — 목록에 안 올린다.
alternative: Decimal | None = None
amount_cell = None
for cell in value_cells:
pair = parse_amount_pair(cell)
if pair is not None:
amount_cell, alternative = pair
break
if amount_cell is None:
# 「0.2 × 30%」 꼴은 값과 배분율이 한 칸에 있다 — 읽었으면 딱지 배분율은 버린다.
expression = next(
(
parse_amount_expression(c)
for c in value_cells
if parse_amount_expression(c) is not None
),
None,
)
if expression is not None:
amount_cell = expression
group_ratio = None # ⚠ 이미 값 안에 들어 있다 — 또 곱하면 두 번이다
if amount_cell is None:
# ⚠ **이름은 자원인데 값을 못 읽은 줄**은 다르다 — 그 공종 단가는 성분이
# 빠진 채 서게 된다. 조용히 넘기지 않고 「일부만 섬」으로 표시한다.
# ⚠ **숫자가 아예 없는 줄은 머리 줄**이다 — 자원 이름만 나열된 줄
# (「특별인부 | 벌목부 | 보통인부」). 그것까지 「못 읽은 값」으로 세면
# 정상 공종이 무더기로 막힌다(2026-09-08: 28건 중 대부분이 이 오탐이었다).
# 숫자가 **있는데** 못 읽은 줄만 성분 빠짐으로 본다.
has_digit = any(ch.isdigit() for cell in value_cells for ch in cell)
if (
has_digit
and _resolve_cell(catalog, name_cell, [name_cell, *value_cells]) is not None
):
result.partial_items[node.get("work_item_code", "")] = (
f"{name_cell} 줄의 값을 못 읽었습니다"
)
result.unmatched.append(
UnmatchedRow(
work_item_code=node.get("work_item_code", ""),
pum_table_id=str(table.get("pum_table_id", "")),
cell=" | ".join(cells[:3]),
reason="자원은 알아봤으나 값을 못 읽었습니다 — 단가가 일부만 섭니다.",
)
)
elif _silent_machine_row(name_cell, value_cells, has_digit):
# 이름도 값도 못 읽은 기계·연료 줄 — 조용히 넘기면 인력만으로 싸게 섬(2026-09-15 ㉠).
code = node.get("work_item_code", "")
result.partial_items[code] = (
f"{_normalize(name_cell)[:20]} (기계·연료 줄)이 안 붙음"
)
result.unmatched.append(
UnmatchedRow(
code, str(table.get("pum_table_id", "")), name_cell, SILENT_MACHINE
)
)
continue
# ⚠ **카탈로그 조회를 먼저 한다.** 이름 필터를 앞에 두면 필터가 넓을 때
# 정상 자원이 조용히 사라진다(2026-09-07 실측 — 부분일치 필터가 매칭 14건을
# 지우고 있었음). 카탈로그에 있는 이름은 **정의상 자원**이다.
# 범위 별칭 — 그 공종 범위 안에서만 카탈로그 쪽 이름으로 바꾼다(「화약공」 → 화약취급공).
# 별칭이 코드를 적었으면 그 항목으로 곧장(규격이 조인 키인 기계가 다시 흐려지지 않게).
entry = scoped_alias_entry(catalog, name_cell, node["work_item_code"], value_cells)
name_cell = apply_scoped_alias(catalog, name_cell, node["work_item_code"], value_cells)
entry = entry or _resolve_cell(catalog, name_cell, [name_cell, *value_cells])
if entry is None:
if _RE_SPEC_FIRST.match(_normalize(name_cell)) and _is_machine_like_row(value_cells):
# 「0.8㎥ 굴착기 | h | 0.25」 — 규격이 이름 앞이라 머리글로 걸러지던 장비 줄(14-2 · ㉠).
result.partial_items[node["work_item_code"]] = (
f"{_normalize(name_cell)[:20]} (기계·연료 줄)이 안 붙음"
)
result.unmatched.append(
UnmatchedRow(
node["work_item_code"], table["pum_table_id"], name_cell, SILENT_MACHINE
)
)
continue
if is_non_resource_label(name_cell):
continue # 머리글·소계 — 못 맞춘 목록에도 안 올린다
# 「규격 미정 — 후보 N」 · 「같은 이름 여럿」 · 「카탈로그에 없는 이름」을 가른다.
reason = unmatched_reason(catalog, name_cell)
result.unmatched.append(
UnmatchedRow(node["work_item_code"], table["pum_table_id"], name_cell, reason)
)
# ⚠ **시간·대수로 세는 줄은 장비 몫**이다 — 못 맞추면 그 공종 단가가
# 노무만으로 서서 조용히 싸진다(2026-09-08: 초본류 시비가 「트럭(2.5t)
# 2.6시간」을 빼고 33.1원/㎡ 로 섰다). 자재 줄(kg·매)은 이미 알려진
# 미결이라 막지 않고 드러내기만 한다.
if _is_machine_like_row(value_cells):
result.partial_items[node["work_item_code"]] = (
f"{_normalize(name_cell)[:20]} (장비 줄)을 못 맞췄습니다"
)
continue
try:
amount = convert_amount(amount_cell, pum_form=form, basis_quantity=basis_quantity)
except ResourceAxisError as error:
result.unmatched.append(
UnmatchedRow(node["work_item_code"], table["pum_table_id"], name_cell, str(error))
)
continue
result.rows.append(
ResourceRow(
work_item_code=node["work_item_code"],
pum_table_id=table["pum_table_id"],
pum_form=form,
resource_kind=entry.kind,
resource_code=entry.code,
resource_name=entry.name,
resource_spec=entry.spec,
amount=amount,
amount_unit=unit,
raw_row_index=index,
group_ratio_pct=group_ratio,
alternative_amount=alternative,
)
)
#: 분류 딱지가 **비율을 달고 오는** 모양 — 「인력(10%)」·「장비(90%)」.
#: 2026-09-08 실측: 측구터파기(FP-09-12-01) 표가 이 모양이라 딱지 판정이 빗나가
#: **보통인부 0.23인이 통째로 빠지고 있었다**(그 공종의 자원 줄이 0 개였다).
#: 괄호 안이 **숫자·%·소수점뿐일 때만** 떼어 낸다 — 「보통인부(인)」 같은 단위 표기는
#: 떼면 안 되므로 넓게 잡지 않는다.
#: 「1.04(1.17)」 — 괄호 밖이 기본, 괄호 안이 **조건 시공 시** 값.
#: 근거: 품셈 13-6-1 [주]② 「흡출방지재를 시공하는 경우는 ( )의 값을 적용한다」
#: (13-6-2·13-6-3·13-7-1·13-7-2 도 같은 [주]).
#: ⚠ **기본은 괄호 밖** — 방지재 시공 여부가 설계 조건에 아직 없다(사용자 확정 대기).
#: 괄호 값을 쓰려면 그 조건이 들어와야 하므로, 지금은 값과 함께 **대안값을 남겨** 둔다.
_RE_ALTERNATIVE = re.compile(r"^(\d+(?:\.\d+)?)\s*[(](\d+(?:\.\d+)?)[)]$")
_RE_RATIO_SUFFIX = re.compile(r"[(][\d.\s]*%?[)]$")
def _group_label_of(cell: str) -> str | None:
"""첫 칸이 분류 딱지면 그 딱지를, 아니면 `None` 을 돌려준다.
⚠ 딱지 목록은 **정확 일치**를 유지한다(부분일치가 정상 자원을 지운 전례 —
`_NON_RESOURCE_WORDS` 주석). 비율 꼬리표만 떼고 다시 정확 일치로 본다.
"""
text = _normalize(cell)
if text in _GROUP_LABELS:
return text
stripped = _normalize(_RE_RATIO_SUFFIX.sub("", text))
return stripped if stripped in _GROUP_LABELS else None
def _group_ratio_of(cell: str) -> Decimal | None:
"""분류 딱지에 붙은 배분율. 「인력(10%)」 → `10`, 「자재」 → `None`."""
found = _RE_RATIO_SUFFIX.search(_normalize(cell))
if found is None:
return None
digits = found.group(0).strip("()()%").strip()
return Decimal(digits) if digits else None
def build_resource_axis(master: dict[str, Any], catalog: ResourceCatalog) -> AxisResult:
"""공종 축 전체를 훑어 자원 축을 만든다."""
# ⚠ 별칭 범위(`FP-*`)는 **품셈 판에 묶인다** — 판이 다른 줄은 쓰지 않는다(명세 17장).
edition = str(master.get("effective_date", ""))
if any(row.get("pum_edition") != edition for row in catalog.scoped_aliases):
kept = [row for row in catalog.scoped_aliases if row.get("pum_edition") == edition]
catalog = replace(catalog, scoped_aliases=kept, _index=None, _family=None)
result = AxisResult()
for node in master.get("work_items", []):
for table in node.get("tables", []):
match_table(node, table, catalog, result)
for row in result.rows:
row.pum_edition = edition
return result
# 조사용 덤프(`write_resource_axis`)는 700줄 제한으로 `_Dump` 파일로 옮겼다(2026-09-13).
# ⚠ 그 파일이 쓰는 JSON 은 **정본이 아니다** — 정본은 매번 메모리에서 새로 돈 값이다.
# 카탈로그 적재·셀 파싱은 700줄 제한으로 `_Sources` 파일로 옮겼다.
# 가르는 금은 「자료를 읽어 들이는가 / 표를 자원 축으로 접는가」다.
from B09_Estimation.B09_Estimation_ResourceAxis_Sources import ( # noqa: E402
convert_amount,
load_combined_catalog,
load_labor_catalog,
load_machine_catalog_entries,
load_material_catalog_entries,
load_work_item_master,
parse_amount,
parse_amount_expression,
parse_amount_pair,
parse_machine_cell,
spec_candidates,
split_name_and_spec,
)
# 조인 키 규칙(규격 · 형식 계열 · 범위 별칭)은 `_Join` 에 둔다(2026-09-13 축 C 1장).
from B09_Estimation.B09_Estimation_ResourceAxis_Join import ( # noqa: E402
apply_scoped_alias,
pick_by_spec,
scoped_alias_entry,
resolve_family,
text_spec_candidates,
unmatched_reason,
)