Merge remote-tracking branch 'origin/sub_desktop_1' into main_laptop_1

This commit is contained in:
2026-09-08 16:09:34 +09:00
4 changed files with 333 additions and 0 deletions
@@ -379,6 +379,14 @@ def match_table(
basis_quantity = None if basis is None else Decimal(str(basis))
unit = table.get("basis_unit") or ""
# ⚠ **한 표에 밑수가 둘인 표가 있다** — 「㎡당 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
# ⚠ **자원이 열 머리에 오는 표가 따로 있다** (2026-09-08 발견, 39 표).
# 「구 분 | 콘크리트공(인) | 보통인부(인)」처럼 **열이 자원**이고 행은 규격 갈래
# (무근·철근·소형구조물)다. 행을 자원으로 읽는 길로 보내면 통째로 못 맞춘다 —
@@ -0,0 +1,151 @@
"""B09 자원 축 — **한 표에 밑수 단위가 둘인 표** (2026-09-08 발견).
품셈에는 같은 품을 **㎡당과 ㎥당 두 벌**로 주는 표가 있다.
13-2-2 막돌 채집 (단위: ㎥당)
보통인부 | ㎡당 | 0.17
| ㎥당 | 0.64
13-2-4 야면석 채집(인력) 뒷길이(㎝) 25 35 45 55 60
인부 | ㎡당 0.11 0.17 0.22 0.28 0.36
| ㎥당 0.60 0.64 0.67 0.70 0.80
⚠ **행-자원으로 읽으면 앞줄만 잡고 뒷줄을 버린다.** 실제로 막돌 채집이 **㎡당 0.17 을
쓰면서 단위는 ㎥** 로 서 있었다(㎥당은 0.64 — **3.8 배**). 야면석은 열이 다섯이라 아예
안 섰다. 둘 다 조용히 틀린 자리다.
**그래서 밑수마다 따로 세운다.** 갈래 이름에 밑수를 넣고(`#45㎝·㎥당`), 그 갈래의
**단위를 그 밑수로** 준다 — 그러면 내역서의 단위 불일치 검사가 **엉뚱한 밑수를 걸러 준다**.
⚠ **밑수 표기가 하나뿐인 표는 건드리지 않는다.** 그런 표는 여태 하던 길이 맞고,
넓게 잡으면 멀쩡한 표까지 갈래가 둘로 쪼개진다.
"""
from __future__ import annotations
import re
from decimal import Decimal
from typing import Any
from B09_Estimation.B09_Estimation_ResourceAxis import (
AxisResult,
ResourceCatalog,
ResourceRow,
UnmatchedRow,
parse_amount,
split_name_and_spec,
)
#: 「㎡당」·「㎥당」처럼 **밑수를 말하는 칸**. 앞에 수가 붙으면(「10㎡당」) 여기가 아니라
#: 밑수(basis_quantity) 자리라 건드리지 않는다.
_RE_BASIS = re.compile(r"^(㎡|㎥|㏊|m|㎝|개|본|주|ton|톤|kg|㎏)\s*당$")
#: 밑수 표기 → 그 갈래가 갖는 단위.
_BASIS_UNIT = {"": "ha", "": "ton", "": "kg"}
def _tight(text: str) -> str:
return "".join(str(text or "").split())
def _basis_of(cell: str) -> str | None:
"""그 칸이 밑수 표기면 단위, 아니면 `None`."""
found = _RE_BASIS.match(_tight(cell))
if not found:
return None
unit = found.group(1)
return _BASIS_UNIT.get(unit, unit)
def _column_labels(table: dict[str, Any]) -> list[str]:
"""열 머리가 갈래인 표의 갈래 이름들 — 「25 35 45 55 60」. 없으면 빈 목록."""
headers = [_tight(c) for c in (table.get("condition_note") or [])]
labels = [cell for cell in headers[1:] if cell and parse_amount(cell) is not None]
return labels
def match_unit_basis_table(
node: dict[str, Any],
table: dict[str, Any],
catalog: ResourceCatalog,
result: AxisResult,
) -> bool:
"""밑수가 둘 이상인 표를 밑수별 갈래로 편다. 그런 표가 아니면 `False`."""
rows = [[str(c).strip() for c in row] for row in (table.get("raw_row") or [])]
if not rows:
return False
# 밑수 표기가 **둘 이상** 있어야 이 길이다 — 하나뿐이면 여태 하던 길이 맞다.
seen_basis = {b for row in rows for cell in row if (b := _basis_of(cell))}
if len(seen_basis) < 2:
return False
work_item_code = str(node.get("work_item_code", ""))
table_id = str(table.get("pum_table_id", ""))
form = str(table.get("pum_form", ""))
labels = _column_labels(table)
entry = None
made = 0
for index, cells in enumerate(rows):
basis_at = next((i for i, cell in enumerate(cells) if _basis_of(cell)), None)
if basis_at is None:
continue
if basis_at > 0:
# 이름이 앞에 붙은 줄 — 「인 부 | ㎡당 | 0.11 | …」. 이름을 여기서 갱신한다.
found = _resolve(catalog, cells[0])
if found is None:
result.unmatched.append(
UnmatchedRow(
work_item_code=work_item_code,
pum_table_id=table_id,
cell=cells[0][:30],
reason="밑수 둘인 표인데 자원 이름을 못 풀었습니다",
)
)
return True # 다른 길로 보내지 않는다 — 앞줄만 잡고 뒷줄을 버리게 된다
entry = found
if entry is None:
continue
unit = _basis_of(cells[basis_at])
values = [parse_amount(cell) for cell in cells[basis_at + 1 :]]
values = [v for v in values if v is not None]
if not values:
continue
for position, amount in enumerate(values):
if labels and position >= len(labels):
break # 비고 칸까지 값으로 읽지 않는다
label = labels[position] if labels else ""
# ⚠ 갈래 이름에 **밑수를 함께** 적는다 — 안 적으면 ㎡당과 ㎥당이 같은 갈래로
# 뭉쳐 하나가 덮인다(막돌 채집이 그렇게 ㎡당 값을 ㎥ 단위로 달고 있었다).
variant = f"{label}㎝·{unit}" if label else f"{unit}"
result.rows.append(
ResourceRow(
work_item_code=work_item_code,
pum_table_id=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,
variant=variant,
)
)
made += 1
return made > 0
def _resolve(catalog: ResourceCatalog, name_cell: str):
name, spec = split_name_and_spec(name_cell)
entry = catalog.resolve(name, spec)
if entry is not None:
return entry
if spec:
return None
found = catalog.by_name(name)
return found[0] if len(found) == 1 else None
@@ -55,6 +55,7 @@ from B09_Estimation.B09_Estimation_ResourceAxis import (
load_labor_catalog,
load_work_item_master,
)
from B09_Estimation.B09_Estimation_WorkItemUnit import unit_of as work_item_unit
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
_RE_EMPHASIS = re.compile(r"\*\*(.+?)\*\*")
@@ -390,6 +391,11 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
break
unit = next((r.amount_unit for r in rows if r.amount_unit), "")
if not unit:
# ⚠ 단위가 없으면 **내역서의 단위 불일치 검사가 못 걸린다** — 층따기가
# 「㎡ 수량 × ㎥당 단가」로 4,102,708원을 내고 있었다. 품셈 원문에 적힌 것만
# 채우고(「(단위: ㎥당)」·「Q= ㎥/시간」), 없으면 비워 둔다.
unit = work_item_unit(work_item_code) or ""
base_name = names.get(work_item_code) or work_item_code
build.book.add_title(
PriceTitle(
@@ -0,0 +1,168 @@
"""B09 — **공종 단가의 기준 단위**를 품셈 원문에서 읽는다 (2026-09-08).
**왜 있는가** — 일위대가 211개 중 **73개가 단위를 못 달고** 있었다. 단위가 없으면
내역서의 **단위 불일치 검사가 못 걸린다** — 실제로 층따기가 「㎡ 수량 × ㎥당 단가」로
**4,102,708원**을 내고 있었다(돌쌓기가 「m × ㎡당」이던 것과 같은 병).
단위가 적힌 자리는 원문에 **둘**이다.
① 절 머리 아래 한 줄 — 「### 13-4-5. 찰쌓기(장비)」 다음의 「(단위: ㎡당)」
② 공식형 표의 [주] — 「Q1=3600×q×K×f×E/㎝= **㎥/시간**」 (층따기 9-18)
⚠ **「인 당」·「일 당」은 공종 단위가 아니다.** 그것은 **품의 단위**(사람·날)라, 공종
단위로 쓰면 「몇 인짜리 공종」이라는 뜻이 된다. 그래서 **물리 수량 단위만** 받는다.
⚠ **없으면 비워 둔다.** 마스터 `basis_unit` 도 대개 비어 있고(73개 중 대부분 `None`),
못 찾은 것을 짐작으로 채우면 **틀린 단위로 검사를 통과**시켜 오히려 더 나쁘다.
「미확보」로 두면 화면이 「기준 단위가 표에 없습니다」로 드러낸다.
"""
from __future__ import annotations
import os
import re
from functools import lru_cache
#: 산림사업 표준품셈 원문 (FP-* 공종의 출처).
_FOREST_SPEC = (
"resources",
"knowledge",
"original",
"행정규칙",
"임도 품셈 적용기준 (현 산림사업 표준품셈)",
"첨부",
"(산림청고시 제2025-82호) 산림사업 표준품셈.md",
)
#: **공종 수량 단위로 인정하는 것.** 여기 없는 표기(인·일·회·시간)는 품의 단위이지
#: 공종의 단위가 아니다 — 받으면 「몇 인짜리 공종」이 된다.
_QUANTITY_UNITS = frozenset(
{
"",
"",
"m",
"",
"",
"개소",
"",
"",
"ton",
"",
"",
"ha",
"kg",
"",
"",
"",
"",
}
)
_RE_HEADING = re.compile(r"^#{2,4}\s*(\d+(?:-\d+)*)\s*\.\s*(.*)$")
#: 「(단위: ㎡당)」·「(단위 : 100㎡당)」. 앞에 붙은 수는 밑수라 여기서는 버린다.
_RE_UNIT_LINE = re.compile(r"^\(\s*단위\s*[:]\s*(.+?)\s*\)\s*$")
#: 「단위:」를 빼고 **괄호만** 적는 절이 있다 — 「(㎡당)」·「(인/100㎡당)」·「(1,000본당)」.
#: 씨앗뿜어붙이기 5-24 가 그 모양이라 통째로 안 읽히고 있었다(2026-09-08).
#:
#: ⚠ **앞의 「인/」은 품의 단위이고 뒤가 공종 단위다** — 「인/㎡당」은 「㎡당 몇 인」이라
#: 공종 단위가 ㎡ 다. 그래서 그것만 건너뛴다.
#: ⚠ **「㎥/1대, 1일」·「대/ton」 같은 것은 안 받는다** — 그건 소요량이 아니라
#: **시공량**(1대가 하루에 몇 ㎥)이라, 공종 단위로 읽으면 뜻이 뒤집힌다.
#: 쉼표가 들어간 것(「1ha당, 100본당」)도 밑수가 둘이라 안 받는다.
_RE_BARE_UNIT_LINE = re.compile(r"^\(\s*(?:인\s*/\s*)?(?:[\d.,]+\s*)?([^\s/,()]+?)\s*당?\s*\)\s*$")
#: 공식이 스스로 밝히는 결과 단위 — 「= ㎥/시간」·「㎥/hr」.
#: ⚠ 등호와 단위 사이에 **수가 끼는 표기**가 있다 — 「A = 77.7 ㎡/시간」(9-17-1 비탈면 다짐).
#: 그 수를 건너뛰지 않으면 그 절이 통째로 안 읽힌다.
_RE_FORMULA_UNIT = re.compile(r"=\s*(?:[\d.,]+\s*)?([㎡㎥m]+)\s*/\s*(?:시간|hr|h)\b")
def _project_root() -> str:
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def _clean_unit(text: str) -> str | None:
"""「100㎡당」 → 「㎡」. 공종 단위가 아니면 `None`."""
tight = "".join(str(text).split())
tight = re.sub(r"^\d+(?:,\d{3})*(?:\.\d+)?", "", tight) # 밑수 수량은 뗀다
tight = tight[:-1] if tight.endswith("") else tight
return tight if tight in _QUANTITY_UNITS else None
@lru_cache(maxsize=1)
def section_units() -> dict[str, str]:
"""절 번호 → 공종 단위. **원문에 적힌 것만** 담는다."""
path = os.path.join(_project_root(), *_FOREST_SPEC)
try:
lines = open(path, encoding="utf-8").read().splitlines()
except OSError:
return {}
units: dict[str, str] = {}
section: str | None = None
for raw in lines:
line = raw.strip()
heading = _RE_HEADING.match(line)
if heading:
section = heading.group(1)
# ⚠ 절 제목 자체가 식을 품는 경우가 있다 — 「9-17-1. 비탈면 다짐 : A = 77.7 ㎡/시간」.
found = _RE_FORMULA_UNIT.search(heading.group(2))
if found and section not in units:
units[section] = found.group(1)
continue
if section is None or section in units:
continue
unit_line = _RE_UNIT_LINE.match(line) or _RE_BARE_UNIT_LINE.match(line)
if unit_line:
cleaned = _clean_unit(unit_line.group(1))
if cleaned:
units[section] = cleaned
continue
found = _RE_FORMULA_UNIT.search(line)
if found:
units[section] = found.group(1)
return units
def section_of(work_item_code: str) -> str:
"""공종 코드 → 절 번호. `FP-13-04-05` → `13-4-5`."""
parts = str(work_item_code or "").split("-")
if not parts or parts[0] != "FP":
return ""
numbers = []
for part in parts[1:]:
if not part.isdigit():
return ""
numbers.append(str(int(part)))
return "-".join(numbers)
def unit_of(work_item_code: str) -> str | None:
"""그 공종 단가의 기준 단위. **원문에 없으면 `None`** — 짐작으로 채우지 않는다."""
section = section_of(work_item_code)
if not section:
return None
units = section_units()
# 하위 절에 안 적혀 있으면 **한 단계 위 절**을 본다 — 「13-4-5」가 없으면 「13-4」.
# ⚠ 두 단계 위까지는 안 올라간다. 장(13) 전체는 공종이 섞여 단위가 하나가 아니다.
if section in units:
return units[section]
# ⚠ **상위 절은 스스로 단위를 안 적고 하위 절만 적는 경우가 많다** — 「9-5. 발파암」은
# 빈 제목이고 9-5-1·9-5-2·9-5-3 이 각각 ㎥ 를 밝힌다(암절취 9-4·노체 9-16·덤프운반
# 10-12 도 같다). **하위가 모두 같은 단위일 때만** 그 단위를 상위에 준다 —
# 갈리면 안 준다(한 단위로 뭉뚱그리면 어느 하위가 틀렸는지 못 가린다).
#
# ⚠ **둘 이상이 말할 때만 듣는다.** 하나만 말한 것을 상위에 주면 장(章) 전체가
# 그 하나의 단위가 된다 — 실제로 「13. 돌공사」가 하위 한 절 때문에 **ton** 으로
# 나왔다(2026-09-08). 그래서 ㉠ 절 번호에 하이픈이 있고(장 자체가 아니고)
# ㉡ **말한 하위가 둘 이상**이며 ㉢ 그 값이 하나로 모일 때만 준다.
declared = [
value
for key, value in units.items()
if key.startswith(f"{section}-") and key.count("-") == section.count("-") + 1
]
if "-" in section and len(declared) >= 2 and len(set(declared)) == 1:
return declared[0]
parent = section.rsplit("-", 1)[0]
return units.get(parent) if parent != section and "-" in section else None