feat(b09): 9-19-1 토사면 고르기 두 표를 갈래로 읽음 — 절토면 토질 6 · 성토면 인력 2(기계 굴착기 0.6㎥ 는 무한궤도·타이어 둘이라 규격 미정) · [주]① 규격은 범위 별칭 · 옆 칸이 규격을 적으면 별칭이 안 덮음 · 성토면 밀린 줄은 사람 판정표로만 · [주]②③ 는 비고(브레인 판정 Ⓐ~Ⓔ)

섞인 본표 B-FP-09-19-01(보통인부 한 줄 2,925원) 걷힘 · 일위대가 나머지 452 제목 금액 변화 0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
This commit is contained in:
2026-09-14 14:19:08 +09:00
co-authored by Claude Opus 5
parent 42d5566dc9
commit ae52a92753
6 changed files with 309 additions and 14 deletions
+6 -6
View File
@@ -44,13 +44,13 @@ KNOWN_GAPS: dict[str, tuple[str, str]] = {
"ⓘ 커플링밴드는 값이 표에 없지만 [주]① 이 「**1EA/6m**」를 줍니다 — 자재가 서면 "
"m 당 1/6 EA 로 셀 수 있습니다(다만 「필요시 별도 산정」이라 조건이 하나 더 붙습니다).",
),
# 2026-09-14 브레인 — 조용히 틀린 값이 이미 서 있어 **표 모양 본작업보다 사유를 먼저** 닮.
# 2026-09-14 ⑤ 판정 Ⓓ·Ⓔ — 두 표를 갈래로 읽은 뒤 남는 [주] 둘. 값을 짓지 않고 말만 둠.
"FP-09-19-01": (
"값 불완전",
"⚠ 이 값은 **불완전**합니다 — 원문 9-19-1 은 표 둘"
"(① 절토면 고르기: 토질 6갈래 × 보통인부·공기압축기·소형브레이커·굴착기, 10㎡당"
" · ② 성토면 고르기: 인력 2갈래·기계 1갈래)인데 지금은 두 표가 섞여 보통인부 한 줄만"
" 섰습니다. 토질·절토/성토 갈래 읽기 전에는 이 값을 쓰지 말 것.",
"원문 [주]",
"ⓘ 절토면 암 갈래(연암·보통암ㆍ경암): [주]③ 「소형브레이커 조작 인력품은 착암공으로 한다」"
" — 표에 착암공 수량이 없어 줄을 안 세움(보통인부는 표 그대로). "
"ⓘ 절토면 풍화암: [주]② 「소형브레이커를 사용할 시는 연암 고르기 품을 **준용할 수 있다**」"
" — 선택지라 따로 갈래를 안 만듦(브레이커를 쓰면 연암 갈래를 고를).",
),
"FP-12-25": (
"운반거리 미정",
@@ -440,6 +440,11 @@ def match_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 표).
# 「구 분 | 콘크리트공(인) | 보통인부(인)」처럼 **열이 자원**이고 행은 규격 갈래
# (무근·철근·소형구조물)다. 행을 자원으로 읽는 길로 보내면 통째로 못 맞춘다 —
@@ -575,8 +580,8 @@ def match_table(
# 지우고 있었음). 카탈로그에 있는 이름은 **정의상 자원**이다.
# 범위 별칭 — 그 공종 범위 안에서만 카탈로그 쪽 이름으로 바꾼다(「화약공」 → 화약취급공).
# 별칭이 코드를 적었으면 그 항목으로 곧장(규격이 조인 키인 기계가 다시 흐려지지 않게).
entry = scoped_alias_entry(catalog, name_cell, node["work_item_code"])
name_cell = apply_scoped_alias(catalog, name_cell, node["work_item_code"])
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 is_non_resource_label(name_cell):
@@ -107,14 +107,30 @@ def load_scoped_aliases() -> list[dict[str, str]]:
return load_aliases("resource")
#: 옆 칸 규격 — 「0.6㎥」·「10.3㎥/min」. 값「0.09」·식「(2.45+3.05)/2/10㎡」·단위「hr」 는 아님.
_RE_SIDE_SPEC = re.compile(r"^\d+(?:\.\d+)?(?:㎥|m3|㎥/min|㎥/분|ton|톤|㎾|kW|㎜|mm|㎝|cm|인치)$")
def _writes_spec(side_cells: list[str] | tuple[str, ...]) -> bool:
"""옆 칸이 규격을 적었나 — 이름이 같아도 별칭이 그 규격을 덮으면 안 됨."""
return any(_RE_SIDE_SPEC.match(_normalize(cell)) for cell in side_cells)
def scoped_alias_entry(
catalog: ResourceCatalog, name_cell: str, work_item_code: str
catalog: ResourceCatalog,
name_cell: str,
work_item_code: str,
side_cells: list[str] | tuple[str, ...] = (),
) -> CatalogEntry | None:
"""범위 별칭이 가리키는 **항목 그 자체**(코드로 곧장) — 없으면 `None`.
⚠ 이름만 돌려주면 규격이 조인 키인 기계(소형브레이커 공압식 넷)는 다시 「규격 미정」 으로
흐려짐 — 별칭이 코드를 적었으면 그 코드가 곧 답(2026-09-14 · 9-19-3 소형브레이커).
⚠ 별칭은 **빈 곳을 채움** — 옆 칸이 규격을 적었으면 안 덮음(같은 9-19-1 성토면 「굴착기 |
0.6㎥」 가 절토면 [주]① 0.7 로 덮이던 자리 · 2026-09-14 판정 Ⓐ).
"""
if _writes_spec(side_cells):
return None
wanted = _normalize(name_cell)
for row in catalog.scoped_aliases:
if _normalize(row["from"]) != wanted or not in_scope(work_item_code, row["scope"]):
@@ -123,8 +139,15 @@ def scoped_alias_entry(
return None
def apply_scoped_alias(catalog: ResourceCatalog, name_cell: str, work_item_code: str) -> str:
def apply_scoped_alias(
catalog: ResourceCatalog,
name_cell: str,
work_item_code: str,
side_cells: list[str] | tuple[str, ...] = (),
) -> str:
"""범위 안이면 카탈로그 쪽 **이름**으로 바꾼다. 범위 밖이거나 대상 코드가 없으면 원문 그대로."""
if _writes_spec(side_cells):
return name_cell # 이름만 갈면 옆 칸 규격으로 대상 계열(무한궤도)이 조용히 골라짐
wanted = _normalize(name_cell)
for row in catalog.scoped_aliases:
if _normalize(row["from"]) != wanted or not in_scope(work_item_code, row["scope"]):
@@ -0,0 +1,171 @@
"""B09 원가계산 — **사람이 모양을 읽어 둔 표** (자원 축 보조 · 2026-09-14 ⑤ 브레인 판정 Ⓐ~Ⓔ).
9-19-1 토사면 고르기(고시 2025-82 원문 L5407~5436)는 한 절에 표가 둘이고 둘 다 일반 길에 안 맞음.
F0288 1. 절토면 고르기 자원 이름이 **첫 자료 줄**(「보통인부 (인)」 …) · 규격은 [주]① 에만
· 「·」 = 그 토질엔 그 자원 없음
F0289 2. 성토면 고르기 병합 첫 칸(시공) 탓에 둘째 줄이 한 칸 앞당겨 옴
⚠ 일반 보정으로 넓히지 않음 — 밀린 줄 고쳐 읽기는 목재틀흙막이 503만원 전례(`_Transposed` 머리말).
**적어 둔 표만** 읽고, 칸이 판정과 다르면 한 줄도 안 세우고 막음.
⚠ 규격은 범위 별칭(`data_aliases` scope FP-09-19-01 · [주]①)이 코드로 이음 — 칸이 규격을 적었으면
별칭이 안 덮음(`scoped_alias_entry`). 성토면 굴착기 0.6㎥ 는 무한궤도·타이어 둘이라 규격 미정(Ⓑ).
"""
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,
_resolve_cell,
parse_amount,
)
from B09_Estimation.B09_Estimation_ResourceAxis_Join import scoped_alias_entry, unmatched_reason
#: 표 번호 → (공종 · 모양 · 갈래 앞말 · 판정 근거). 여기 없는 표는 이 길로 안 읽음.
JUDGED_TABLES: dict[str, dict[str, Any]] = {
"F0288": {
"code": "FP-09-19-01",
"shape": "header_row",
"prefix": "절토면",
"why": "원문 L5414 「1. 절토면 고르기 (단위: 10㎡당)」 — 자원 머리가 첫 자료 줄",
},
"F0289": {
"code": "FP-09-19-01",
"shape": "merged_first",
"prefix": "성토면",
# 원문 L5430 표 — 「인력시공」 칸이 두 줄 병합이라 둘째 줄(모래 또는 사질토)이 앞당겨 옴.
"merged_rows": (1,),
"why": "원문 L5430 「2. 성토면 고르기 (단위: 10㎡당)」 — 시공 칸 병합으로 둘째 줄 앞당김",
},
}
#: 「보통인부 (인)」 의 단위 꼬리 — 규격이 아님.
_UNIT_TAIL = re.compile(r"\s*[(](?:인|시간)[)]\s*$")
_ABSENT = frozenset({"·", "", "-", "", ""})
def _entry(catalog: ResourceCatalog, name_cell: str, code: str, side: tuple[str, ...] = ()):
return scoped_alias_entry(catalog, name_cell, code, side) or _resolve_cell(
catalog, name_cell, [name_cell, *side]
)
def _row(
code: str, table: dict[str, Any], entry, amount: Decimal, unit: str, index: int, variant: str
):
return ResourceRow(
work_item_code=code,
pum_table_id=str(table.get("pum_table_id", "")),
pum_form=str(table.get("pum_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,
)
def match_judged_table(
node: dict[str, Any],
table: dict[str, Any],
catalog: ResourceCatalog,
result: AxisResult,
basis_quantity: Decimal | None,
unit: str,
) -> bool:
"""적어 둔 표를 판정대로 읽음. 그 표가 아니면 `False`."""
code = str(node.get("work_item_code") or "")
table_id = str(table.get("pum_table_id", ""))
judged = JUDGED_TABLES.get(table_id)
if judged is None or judged["code"] != code:
return False
rows = [[str(c).strip() for c in row] for row in (table.get("raw_row") or [])]
def block(why: str) -> bool:
result.unmatched.append(UnmatchedRow(code, table_id, judged["prefix"], why))
result.partial_items[code] = why
return True
if basis_quantity in (None, 0):
return block("판정 표에 밑수가 없습니다")
if judged["shape"] == "header_row":
staged = _header_row(code, table, judged, rows, catalog, basis_quantity, unit)
else:
staged = _merged_first(code, table, judged, rows, catalog, basis_quantity, unit)
if isinstance(staged, str):
return block(f"{staged} — 판정({judged['why']})과 칸이 달라 안 읽음")
for item in staged:
if isinstance(item, UnmatchedRow):
result.unmatched.append(item)
else:
result.rows.append(item)
return True
def _header_row(code, table, judged, rows, catalog, basis, unit) -> list | str:
"""첫 줄 = 자원 머리 · 이후 줄 = [토질, 값 …]. 머리 자원을 하나라도 못 풀면 표째 막음."""
names = [_UNIT_TAIL.sub("", cell) for cell in (rows[0] if rows else []) if cell]
entries = [_entry(catalog, name, code) for name in names]
if len(names) < 2 or not all(entries):
missing = [name for name, entry in zip(names, entries) if entry is None]
return f"자원 머리 {missing or names} 을 못 풂"
staged: list = []
for index, cells in enumerate(rows[1:], start=1):
values, tail = cells[1 : 1 + len(names)], cells[1 + len(names) :]
readable = all(v in _ABSENT or parse_amount(v) is not None for v in values)
if not cells[0] or len(values) != len(names) or any(tail) or not readable:
return f"{index}째 줄 「{cells[0] if cells else ''}」 값 칸"
variant = f"{judged['prefix']} · {cells[0]}"
for value, entry in zip(values, entries):
if value in _ABSENT:
continue # 그 토질엔 이 자원이 안 듦
staged.append(
_row(code, table, entry, parse_amount(value) / basis, unit, index, variant)
)
return staged
def _merged_first(code, table, judged, rows, catalog, basis, unit) -> list | str:
"""[시공, 토질, 구분, 규격, 단위, 수량] — 병합 줄은 앞 줄 시공을 이어받아 한 칸 되돌림."""
width = len(table.get("condition_note") or [])
if width != 6:
return f"머리 {width}"
lines: list[list[str]] = []
for index, cells in enumerate(rows):
if index in judged["merged_rows"]:
if not lines or cells[-1]:
return f"{index}째 줄 병합 칸"
cells = [lines[-1][0], *cells[:-1]]
lines.append(cells)
methods = [cells[0] for cells in lines]
staged: list = []
for index, cells in enumerate(lines):
if len(cells) != width:
return f"{index}째 줄 칸 {len(cells)}"
method, soil, name, size, _unit_word, value = cells
amount = parse_amount(value)
if not method or not name or amount is None:
return f"{index}째 줄 「{method}」 값 칸"
short = method.removesuffix("시공")
variant = " · ".join(
[judged["prefix"], short, *([soil] if methods.count(method) > 1 else [])]
)
entry = _entry(catalog, name, code, (size,) if size else ())
if entry is None:
# 그 갈래만 안 섬(자원이 하나뿐) — 사유는 못 맞춘 줄로(Ⓑ 굴착기 0.6㎥ 형식 둘).
label = f"{name} {size}".strip()
reason = unmatched_reason(catalog, name)
staged.append(UnmatchedRow(code, str(table.get("pum_table_id", "")), label, reason))
continue
staged.append(_row(code, table, entry, amount / basis, unit, index, variant))
return staged
@@ -19,6 +19,30 @@
"pum_edition": "2026-01-01",
"basis": "9-19-3 비탈면 고르기(발파암) 표가 규격을 안 적음 — 그 값 「(2.45+3.05)/2/10㎡」 은 9-19-1 토사면 고르기 표(연암 2.45·보통암+경암 3.05)에서 왔고 9-19-1 [주]① 「소형브레이커는 1㎥/분」 → 소형브레이커(공압식) 1.0㎥/min(9-19-3 ← 9-19-1 표 ← [주]①, 값의 출처가 규격을 정함). 2026-09-14 브레인 승인"
},
{
"axis": "resource",
"from": "공기압축기",
"to": "5205-0035",
"scope": "FP-09-19-01",
"pum_edition": "2026-01-01",
"basis": "9-19-1 토사면 고르기 절토면 표(원문 L5414)가 자원 머리에 규격을 안 적음 — [주]① 「공기압축기는 3.5㎥/분, 소형브레이커는 1㎥/분, 굴착기는 0.7㎥를 기준한 것이다」 → 공기압축기(이동식) 3.5. 칸이 규격을 적은 줄(성토면 굴착기 0.6㎥)은 안 덮음. 2026-09-14 브레인 승인(⑤ Ⓐ)"
},
{
"axis": "resource",
"from": "소형브레이커",
"to": "5210-0010",
"scope": "FP-09-19-01",
"pum_edition": "2026-01-01",
"basis": "9-19-1 토사면 고르기 절토면 표(원문 L5414)가 자원 머리에 규격을 안 적음 — [주]① 「공기압축기는 3.5㎥/분, 소형브레이커는 1㎥/분, 굴착기는 0.7㎥를 기준한 것이다」 → 소형브레이커(공압식) 1.0㎥/min. 칸이 규격을 적은 줄(성토면 굴착기 0.6㎥)은 안 덮음. 2026-09-14 브레인 승인(⑤ Ⓐ)"
},
{
"axis": "resource",
"from": "굴착기",
"to": "0201-0070",
"scope": "FP-09-19-01",
"pum_edition": "2026-01-01",
"basis": "9-19-1 토사면 고르기 절토면 표(원문 L5414)가 자원 머리에 규격을 안 적음 — [주]① 「공기압축기는 3.5㎥/분, 소형브레이커는 1㎥/분, 굴착기는 0.7㎥를 기준한 것이다」 → 굴착기(무한궤도) 0.7(카탈로그 0.7 은 무한궤도뿐). 칸이 규격을 적은 줄(성토면 굴착기 0.6㎥)은 안 덮음. 2026-09-14 브레인 승인(⑤ Ⓐ)"
},
{
"axis": "variant",
"from": "리핑암",
+76 -4
View File
@@ -61,13 +61,85 @@ def test_비탈면_고르기_발파암_소형브레이커는_1_0_규격으로_
def test_토사면_고르기는_두_표가_섞여_값이_불완전하다고_먼저_말함() -> None:
"""⑤ 사유 먼저(2026-09-14 브레인) — 원문은 절토면·성토면 두 표인데 지금은 섞여 보통인부 한 줄만
(조용히 틀린 값). 표 모양 본작업 전에 누가 그 값을 믿고 쓰지 않게 비고·본표에 뜸."""
"""⑤ 사유 먼저(2026-09-14 브레인) — 원문은 절토면·성토면 두 표인데 섞여 보통인부 한 줄만
섰었음(조용히 틀린 값). ⑤ 본작업(같은 날)으로 두 표를 갈래로 읽어 「불완전」 사유와 섞인
본표(B-FP-09-19-01)가 함께 걷힘 — 갈래 없는 섞인 값이 다시 서면 빨강."""
assert "불완전" not in known_gap_note("FP-09-19-01")
assert "B-FP-09-19-01" not in cached_build().book.titles
#: ⑤ 9-19-1 원문 L5414~5436 — (단위: 10㎡당)이라 ㎡당 = 표값 ÷ 10.
CUT = "B-FP-09-19-01#절토면·" # 갈래 키는 공백을 지움
FILL = "B-FP-09-19-01#성토면·"
def _per_m2(code: str) -> dict[str, float]:
"""자원 줄(비율 줄 「%」 뺌) → ㎡당 수량."""
from B09_Estimation.B09_Estimation_UnitPrice import detail_of
rows = detail_of(cached_build(), code)["rows"]
return {str(r["ref_code"]): float(r["quantity"]) for r in rows if r.get("unit") != "%"}
def test_토사면_고르기_절토면은_토질_여섯_갈래로_규격까지_이어_섬() -> None:
"""[주]① 공기압축기 3.5㎥/분 · 소형브레이커 1㎥/분 · 굴착기 0.7㎥ — 범위 별칭이 코드로 이음
(카탈로그 0.7 은 무한궤도뿐). 「·」 는 그 갈래에 없음(칸 자리만 지킴)."""
titles = cached_build().book.titles
cut = sorted(code for code in titles if code.startswith(CUT))
assert len(cut) == 6, cut
sand = _per_m2(CUT + "모래ㆍ사질토ㆍ점토ㆍ점질토")
assert sand == {"1002": 0.005, "X-0201-0070": 0.015}, sand
soft_rock = _per_m2(CUT + "연암")
assert soft_rock == {"1002": 0.046, "X-5210-0010": 0.245}, soft_rock
# 공기압축기 3.5 는 코드까지 이어지나 기계 단가 층(손료) 미확보 — 9-19-3 과 같은 종전 자리
from B09_Estimation.B09_Estimation_ResourceAxis import (
build_resource_axis,
load_combined_catalog,
)
axis = build_resource_axis(load_work_item_master(), load_combined_catalog())
rock = {
(r.resource_code, float(r.amount))
for r in axis.rows
if r.work_item_code == "FP-09-19-01" and r.variant == "절토면 · 연암"
}
assert rock == {("1002", 0.046), ("5205-0035", 0.125), ("5210-0010", 0.245)}, rock
def test_토사면_고르기_성토면_인력_두_갈래_기계는_규격_미정으로_막힘() -> None:
"""Ⓒ 둘째 줄이 병합 칸 탓에 한 칸 밀려 옴 — 사람 판정표로만 읽음(일반 보정 금지 · 목재틀흙막이
503만원 전례). Ⓑ 굴착기 0.6㎥ 는 무한궤도·타이어 둘이라 안 고름 — 그 갈래는 안 섬."""
build = cached_build()
assert _per_m2(FILL + "인력·점토또는점질토") == {"1002": 0.019}
assert _per_m2(FILL + "인력·모래또는사질토") == {"1002": 0.017}
assert not [code for code in build.book.titles if code.startswith(FILL + "기계")]
left = build.unattached.get("FP-09-19-01", [])
assert any("굴착기" in label for label in left), left
def test_범위_별칭은_칸이_규격을_적었으면_덮지_않음() -> None:
"""Ⓐ 별칭은 빈 곳을 채우는 것 — 같은 공종 성토면 「굴착기 0.6㎥」 가 0.7 로 덮이면 안 됨."""
from B09_Estimation.B09_Estimation_ResourceAxis import load_combined_catalog
from B09_Estimation.B09_Estimation_ResourceAxis_Join import (
apply_scoped_alias,
scoped_alias_entry,
)
catalog = load_combined_catalog()
assert scoped_alias_entry(catalog, "굴착기", "FP-09-19-01").code == "0201-0070"
side = ["0.6㎥", "시간", "0.09"]
assert scoped_alias_entry(catalog, "굴착기", "FP-09-19-01", side) is None
# 이름만 갈아도 옆 칸 0.6 으로 무한궤도가 조용히 골라짐 — 이름도 그대로
assert apply_scoped_alias(catalog, "굴착기", "FP-09-19-01", side) == "굴착기"
# 값·식 칸은 규격이 아님 — 9-19-3 소형브레이커 줄(값 0.275 · 식)은 그대로 이음
value = ["hr", "0.275", "(2.45+3.05)/2/10㎡"]
assert scoped_alias_entry(catalog, "소형브레이커", "FP-09-19-03", value).code == "5210-0010"
def test_토사면_고르기_원문_주는_값을_안_짓고_비고로() -> None:
"""Ⓓ [주]③ 착암공은 수량이 없어 줄을 안 지음 · Ⓔ [주]② 풍화암 연암 준용은 선택이라 갈래 아님."""
note = known_gap_note("FP-09-19-01")
assert "절토면" in note and "성토면" in note and "불완전" in note
assert "불완전" in detail_of(cached_build(), "B-FP-09-19-01")["known_gap_note"]
assert "착암공" in note and "준용" in note, note
def test_제근은_빌린_밑수가_확정이라_갈래가_서면_내역_금액이_섬() -> None: