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,509 @@
"""B09 원가계산 — **사람이 모양을 읽어 둔 표** (자원 축 보조 · 2026-09-14 ⑤ 브레인 판정 Ⓐ~Ⓔ).
9-19-1 토사면 고르기(고시 2025-82 원문 L5407~5436)는 한 절에 표가 둘이고 둘 다 일반 길에 안 맞음.
F0288 1. 절토면 고르기 자원 이름이 **첫 자료 줄**(「보통인부 (인)」 …) · 규격은 [주]① 에만
· 「·」 = 그 토질엔 그 자원 없음
F0289 2. 성토면 고르기 병합 첫 칸(시공) 탓에 둘째 줄이 한 칸 앞당겨 옴
F0336 12-4 합판거푸집 기준수량(1회) × 사용횟수별 비율 — 재료 줄은 재료 %, 인력 줄은 노무비 %
(원문 L6187 · 2026-09-14 브레인 ㉮ — 비율 셈이 없어 막아 뒀던 표)
⚠ 일반 보정으로 넓히지 않음 — 밀린 줄 고쳐 읽기는 목재틀흙막이 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,),
# 기계시공 「굴착기 | 0.6㎥」 — 원문이 형식을 안 적음. 카탈로그 0.6 은 무한궤도·타이어
# 둘이라 형식마다 갈래 · 고르기는 B08 칸(제안 무한궤도 — 영월 「06M3 B/H」 · 브레인 ②).
"form_split": {"굴착기": ("무한궤도", "타이어")},
"why": "원문 L5430 「2. 성토면 고르기 (단위: 10㎡당)」 — 시공 칸 병합으로 둘째 줄 앞당김",
},
"F0336": {
"code": "FP-12-04",
"shape": "use_count",
"prefix": "합판거푸집",
"why": "원문 L6187 「기준수량(1회사용) · 사용횟수별기준수량에대한 비율(%) 재료별·노무비」",
},
"F0353": {
"code": "FP-12-15",
"shape": "remark_labor",
"prefix": "집수정",
# 구체콘크리트는 바로 아래 다짐기 줄과 한 갈래 — 다짐기가 안 풀리면 갈래를 안 세움(⑴).
"needs_machine": {"구체콘크리트": "다짐:봉상후렉시블(45mm)"},
"why": "원문 L6460 「구체·버림콘크리트 ㎥ — 비고 칸 콘크리트공·보통인부 인/㎥」",
},
# 12-15 와 같은 모양 셋 — 같은 봉상후렉시블 줄 하나가 셋을 막고 있었음(2026-09-14 브레인 · 672 다음).
"F0350": {
"code": "FP-12-12",
"shape": "remark_labor",
"prefix": "날개벽",
"needs_machine": {"콘크리트": "다짐:봉상후렉시블(45mm)"},
"why": "원문 L6419 12-12 「콘크리트(레미콘) ㎥ — 비고 칸 콘크리트공·보통인부 인/㎥」",
},
"F0351": {
"code": "FP-12-13",
"shape": "remark_labor",
"prefix": "면벽",
"needs_machine": {"콘크리트": "다짐:봉상후렉시블(45mm)"},
"why": "원문 L6436 12-13 「콘크리트(레미콘) ㎥ — 비고 칸 콘크리트공·보통인부 인/㎥」",
},
"F0354": {
"code": "FP-12-16",
"shape": "remark_labor",
"prefix": "맨홀",
# 칸이 하나 밀려 비고가 끝 칸이 아님(「구체콘크리트 | 철근 | ㎥ | | 비고 | 」).
"needs_machine": {"구체콘크리트": "봉상후렉시블(45mm)"},
"why": "원문 L6474 12-16 「구체·버림콘크리트 ㎥ — 비고 칸 콘크리트공·보통인부 인/㎥」",
},
# 12-34-1 — 머리 「(단위: 개소당)」 이나 인력 0.17·0.29 가 12-16 맨홀 ㎥당과 같고 기계가 Q ㎥/hr
# ⇒ ㎥당으로 읽음 · 「봉상후렉시블 대 2」 는 엔진식 진동기(엔진+플렉시블 한 대)의 봉(2026-09-14 브레인).
"F0385": {
"code": "FP-12-34-01",
"shape": "per_m3_rows",
"prefix": "콘크리트 타설",
# 엔진식 진동기(건설품셈 8-3 (4611) 엔진+플렉시블 한 대)의 봉 — 「진동기(3.5HP) 대 2」 로 셈.
"same_machine": ("봉상후렉시블(45mm)",),
"why": "원문 L6839 12-34-1 「인력 콘크리트공·보통인부 인 · 기계 대 (Q=5.4㎥/hr)」",
},
# 시간 줄 표 — 장비·인력이 다 「h」(2026-09-15 브레인 ㉡-3). 굴착기 형식을 원문이 안 적어 두 갈래 · 제안 없음(①).
"F0450": {
"code": "FP-14-01",
"shape": "hour_rows",
"prefix": "입목뿌리 밑막이",
"forms": (("무한궤도", "0201-0080"), ("타이어", "0211-0080")),
# 원문 「우드그랩 부착」 ↔ 카탈로그 「부착용 집게 0.6∼0.8」 — 이름이 다름(②).
"attachment": "7206-0070",
"why": "원문 L7798 14-1 「0.8㎥ 굴착기(우드그랩 부착) h · 보통인부 h」(10본당)",
},
"F0451": {
"code": "FP-14-02",
"shape": "hour_rows",
"prefix": "근주이식",
"forms": (("무한궤도", "0201-0080"), ("타이어", "0211-0080")),
"why": "원문 L7809 14-2 「0.8㎥ 굴착기 h · 보통인부 h」(10본당)",
},
# 도구 줄 표 — 「공법 | (갈래) | 사용도구 | 단위 | 소요인력 | 인력구분」 · 이름이 끝 칸(2026-09-15 브레인 ②).
# `variants` 면 단위 칸 앞 마지막 칸이 갈래 · 「인/100본」 줄(묘목찾기)은 본수 칸이 들 때 붙음(`_Brushcutting`).
"F0155": {
"code": "FP-06-02-02",
"shape": "tool_rows",
"prefix": "줄베기",
"variants": True,
"why": "원문 6-2-2 「묘목찾기 낫 인/100본 · 줄베기 본수 구간 예취기 인/ha · 인력구분」",
},
"F0156": {
"code": "FP-06-02-03",
"shape": "tool_rows",
"prefix": "모두베기",
"variants": True,
"why": "원문 6-2-3 「묘목찾기 낫 인/100본 · 모두베기 조림 경과 예취기 인/ha · 인력구분」",
},
"F0158": {
"code": "FP-06-04-01",
"shape": "tool_rows",
"prefix": "덩굴걷기",
"why": "원문 6-4-1 「기계작업 예취기 인/ha 3.30 특별인부」",
},
"F0159": {
"code": "FP-06-04-02",
"shape": "tool_rows",
"prefix": "덩굴 약제 살포처리",
"why": "원문 6-4-2 「약제살포 배부식분무기 인/ha · 작업보조 인/ha · 인력구분」",
},
}
#: 도구 줄 표의 단위 칸 — 「인/ha」 는 공종 단위 ha 로 곧장 · 「인/100본」 은 본수 칸이 들어야 섬.
_RE_TOOL_UNIT = re.compile(r"^인/(ha|100본)$")
#: 총칙 L530 「본 품셈에서 제시된 품은 일일 작업시간 8시간을 기준」 — 인력 시간 ÷ 8 = 인.
HOURS_PER_DAY = Decimal(8)
_RE_SPEC_FIRST_MACHINE = re.compile(r"^\d+(?:\.\d+)?㎥굴착기")
#: 줄 첫 칸이 분류 딱지인 표(12-34-1 「자재 | 콘크리트(레미콘)」) — 이름은 다음 칸.
_ROW_CATEGORIES = ("자재", "인력", "기계")
#: 자원이 아닌 머리 줄(12-04 「횟수별 | 재료별(%) | 노무비(%)」).
_HEADER_ROWS = ("횟수별", "구분")
UNREAD_REASON = "판정표가 안 읽은 줄 — 이 일위대가에 안 넣음(자동 · 2026-09-14)"
def _loose(text: str) -> str:
"""겹침 비교용 — 빈칸·괄호·가운뎃점·쉼표를 뺌(「적사(굴착기 0.7㎥)」 ↔ 「적사 굴착기 0.7㎥」)."""
return re.sub(r"[\s()·,:]", "", str(text))
def _unread_rows(code, table_id, judged, rows, staged) -> list:
"""읽힌 줄 밖의 줄을 「못 붙은 줄」 로 — 표를 넣을 때마다 손으로 사유를 안 달아도 안 샘.
㉠ 빼는 것: 읽힌 줄(`raw_row_index`) · 이미 못 맞춤으로 선 이름 · 다른 갈래가 쓴 기계 줄
(`needs_machine`·`same_machine`) · 자원 머리(`header_row` 첫 줄 · 「횟수별」) · 빈 줄
㉡ 손 사유(`known_gap_note`)가 이미 적은 이름은 안 올림 — 같은 말이 두 번 안 뜨게
"""
from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note
read = {item.raw_row_index for item in staged if isinstance(item, ResourceRow)}
taken = {_loose(item.cell) for item in staged if isinstance(item, UnmatchedRow)}
taken |= {_loose(name) for name in judged.get("needs_machine", {}).values()}
taken |= {_loose(name) for name in judged.get("same_machine", ())}
hand = _loose(known_gap_note(code))
unread: list = []
for index, cells in enumerate(rows):
cells = [c for c in cells]
if index in read or not any(cells) or (judged["shape"] == "header_row" and index == 0):
continue
name = cells[1] if cells[0] in _ROW_CATEGORIES and len(cells) > 1 else cells[0]
key = _loose(name)
if not key or key in _HEADER_ROWS or key in taken or key in hand:
continue
if key == "비고":
name = f"비고 — {' '.join(' '.join(cells[1:]).split())[:40]}"
unread.append(UnmatchedRow(code, table_id, " ".join(name.split()), UNREAD_REASON))
taken.add(key)
return unread
#: 비고 칸 인력 — 「콘크리트공0.24인/㎥, 보통인부 0.42인/㎥」.
_RE_REMARK_LABOR = re.compile(r"([가-힣]+)\s*(\d+(?:\.\d+)?)\s*인/㎥")
_RE_Q = re.compile(r"Q\s*=\s*(\d+(?:\.\d+)?)")
#: 비율 줄 — 「1회사용시 2회사용시 …」 칸.
_RE_USE_COUNT = re.compile(r"(\d+)회사용시")
#: 값으로 안 읽는 줄 — 사용고재 평가기준(원문이 셈을 안 줌 · 사유는 `KNOWN_GAPS`) · 비고.
_NOT_RESOURCE_ROWS = ("사용고재평가기준", "비고", "횟수별")
#: 「보통인부 (인)」 의 단위 꼬리 — 규격이 아님.
_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
# 비고·Q 가 ㎥당을 적는 모양은 표 머리 밑수를 안 씀(12-12 날개벽은 「개소당」 머리조차 없음).
if basis_quantity in (None, 0) and judged["shape"] not in (
"remark_labor",
"per_m3_rows",
"tool_rows", # 줄마다 단위 칸(인/ha)이 밑수
):
return block("판정 표에 밑수가 없습니다")
if judged["shape"] == "header_row":
staged = _header_row(code, table, judged, rows, catalog, basis_quantity, unit)
elif judged["shape"] == "use_count":
staged = _use_count(code, table, rows, catalog, basis_quantity, unit)
elif judged["shape"] == "remark_labor":
staged = _remark_labor(code, table, judged, rows, catalog)
elif judged["shape"] == "hour_rows":
staged = _hour_rows(code, table, judged, rows, catalog, basis_quantity)
elif judged["shape"] == "per_m3_rows":
staged = _per_m3_rows(code, table, judged, rows, catalog)
elif judged["shape"] == "tool_rows":
staged = _tool_rows(code, table, judged, rows, catalog)
else:
staged = _merged_first(code, table, judged, rows, catalog, basis_quantity, unit)
if isinstance(staged, str):
return block(f"{staged} — 판정({judged['why']})과 칸이 달라 안 읽음")
staged = [*staged, *_unread_rows(code, table_id, judged, rows, staged)]
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 [])]
)
side = (size,) if size else ()
forms = judged.get("form_split", {}).get(name)
for form in forms or (None,):
entry = _entry(catalog, f"{name}({form})" if form else name, code, side)
if entry is None:
# 그 갈래만 안 섬(자원이 하나뿐) — 사유는 못 맞춘 줄로.
label = f"{name} {size}".strip()
reason = unmatched_reason(catalog, name)
staged.append(UnmatchedRow(code, str(table.get("pum_table_id", "")), label, reason))
continue
named = f"{variant} · {form}" if form else variant
staged.append(_row(code, table, entry, amount / basis, unit, index, named))
return staged
def _use_count(code, table, rows, catalog, basis, unit) -> list | str:
"""[이름, 단위, 기준수량, …] + 비율 줄 하나 → 사용횟수마다 갈래(재료 % · 노무비 %)."""
ratio_row = next((cells for cells in rows if _RE_USE_COUNT.search(" ".join(cells))), None)
if ratio_row is None:
return "사용횟수 비율 줄"
counts = _RE_USE_COUNT.findall(" ".join(ratio_row))
number = re.compile(r"\d+(?:\.\d+)?")
material = [Decimal(x) for x in number.findall(ratio_row[4] if len(ratio_row) > 4 else "")]
labor = [Decimal(x) for x in number.findall(ratio_row[5] if len(ratio_row) > 5 else "")]
if not counts or not (len(counts) == len(material) == len(labor)):
return f"비율 칸 수(횟수 {len(counts)} · 재료 {len(material)} · 노무비 {len(labor)})"
staged: list = []
for index, cells in enumerate(rows):
name = "".join(cells[0].split()) if cells else ""
base = parse_amount(cells[2]) if len(cells) > 2 else None
if not name or name in _NOT_RESOURCE_ROWS or base is None:
continue
entry = _entry(catalog, cells[0], code)
if entry is None:
reason = unmatched_reason(catalog, cells[0])
staged.append(UnmatchedRow(code, str(table.get("pum_table_id", "")), cells[0], reason))
continue
ratios = labor if entry.kind == "labor" else material
for count, ratio in zip(counts, ratios):
amount = base * ratio / Decimal(100) / basis
staged.append(_row(code, table, entry, amount, unit, index, f"{count}"))
return staged
def _remark_labor(code, table, judged, rows, catalog) -> list | str:
"""㎥ 줄 비고 칸의 인력(인/㎥)으로 갈래 — 다짐기가 딸린 갈래는 그 기계가 풀려야 세움."""
table_id = str(table.get("pum_table_id", ""))
by_name = {"".join(cells[0].split()): cells for cells in rows if cells}
staged: list = []
for index, cells in enumerate(rows):
labors = _RE_REMARK_LABOR.findall(" ".join(cells[3:])) # 비고가 끝 칸이 아닌 표(12-16)
if len(cells) < 3 or cells[2] != "" or not labors:
continue
variant = cells[0].split("(")[0].strip()
pieces = []
for name, amount in labors:
entry = _entry(catalog, name, code)
if entry is None:
return f"{variant} 인력 「{name}"
pieces.append((entry, Decimal(amount)))
machine_name = judged.get("needs_machine", {}).get(variant)
if machine_name:
machine_cells = by_name.get("".join(machine_name.split())) or []
found_q = _RE_Q.search(" ".join(machine_cells))
entry = _entry(catalog, machine_name, code) if machine_cells else None
if entry is None or found_q is None:
reason = unmatched_reason(catalog, machine_name)
staged.append(UnmatchedRow(code, table_id, machine_name, reason))
why = (
f"다짐기 「{machine_name}」 가 안 풀려 갈래를 안 세움 — 인력만이면 조립 줄이"
" 조용히 싸짐(2026-09-14 ㉯ ⑴)"
)
staged.append(UnmatchedRow(code, table_id, variant, why))
continue
pieces.append((entry, Decimal(1) / Decimal(found_q.group(1))))
for entry, amount in pieces:
staged.append(_row(code, table, entry, amount, "", index, variant))
return staged
def _per_m3_rows(code, table, judged, rows, catalog) -> list | str:
"""「인」 칸 앞 이름 · 뒤 수(인/㎥) · 「대」 칸 앞 이름 · 뒤 대수 ÷ Q — 칸이 밀린 줄도 단위 칸으로 찾음."""
staged: list = []
for index, cells in enumerate(rows):
unit = next((i for i, c in enumerate(cells) if c in ("", "") and i > 0), None)
if unit is None:
continue
name = cells[unit - 1]
if "".join(name.split()) in judged.get("same_machine", {}):
continue # 같은 기계 두 번 안 셈 — 까닭은 공종 사유 한 줄(`KnownGaps`)이 화면에 보임
amount = next((parse_amount(c) for c in cells[unit + 1 :] if parse_amount(c)), None)
entry = _entry(catalog, name, code)
if amount is None or entry is None:
return f"{name}"
if cells[unit] == "":
found_q = _RE_Q.search(" ".join(cells))
if found_q is None:
return f"{name} Q"
amount = amount / Decimal(found_q.group(1))
staged.append(_row(code, table, entry, amount, "", index, ""))
return staged
def _hour_rows(code, table, judged, rows, catalog, basis_quantity) -> list | str:
"""장비·인력이 다 시간인 표 — 장비 h 그대로 · 인력 h ÷ 8 · ÷ 밑수 · 굴착기 형식마다 갈래."""
entries = {entry.code: entry for entry in catalog.entries}
unit = str(table.get("basis_unit") or "")
machine: tuple[int, Decimal] | None = None
labor: list = []
for index, cells in enumerate(rows):
if len(cells) < 3 or "".join(cells[1].split()) != "h":
continue
hours = parse_amount(cells[2])
if hours is None:
return f"{cells[0]} 시간"
if _RE_SPEC_FIRST_MACHINE.match("".join(cells[0].split())):
machine = (index, hours)
continue
entry = _entry(catalog, cells[0], code)
if entry is None:
return f"{cells[0]}"
labor.append((index, entry, hours / HOURS_PER_DAY))
if machine is None:
return "장비 줄"
staged: list = []
for form, machine_code in judged["forms"]:
codes = [machine_code, *([judged["attachment"]] if judged.get("attachment") else [])]
if any(c not in entries for c in codes):
return f"{form} 기종 코드"
for machine_code_ in codes:
staged.append(
_row(
code,
table,
entries[machine_code_],
machine[1] / basis_quantity,
unit,
machine[0],
form,
)
)
for index, entry, amount in labor:
staged.append(_row(code, table, entry, amount / basis_quantity, unit, index, form))
return staged
#: 「인/100본」 줄 사유 — 본수 칸이 들면 `_Brushcutting` 이 걷고 붙임.
PER_HUNDRED_REASON = "조림목 본수(본/ha) 칸이 들어야 붙음 — 단위가 인/100본"
def _tool_rows(code, table, judged, rows, catalog) -> list | str:
"""단위 칸(인/ha · 인/100본)을 찾아 뒤 칸 = 값 · 그 뒤 = 인력 · `variants` 면 단위 앞 앞 칸이 갈래.
⚠ 「인/100본」 줄은 여기서 안 셈 — 본수가 설계 입력이라 자원 축(프로젝트 밖)에선 모름 → 못 붙은 줄로.
"""
staged: list = []
per_ha = 0
for index, cells in enumerate(rows):
unit = next(
(i for i, c in enumerate(cells) if _RE_TOOL_UNIT.match("".join(c.split()))), None
)
if unit is None or unit < 1 or len(cells) < unit + 3:
continue
amount = parse_amount(cells[unit + 1])
entry = _entry(catalog, cells[unit + 2], code)
if amount is None or entry is None:
return f"{cells[0]}"
if "".join(cells[unit].split()) == "인/100본":
staged.append(
UnmatchedRow(code, str(table.get("pum_table_id", "")), cells[0], PER_HUNDRED_REASON)
)
continue
variant = ""
if judged.get("variants"):
if unit < 2:
return f"{index}째 줄 갈래 칸"
variant = cells[unit - 2]
staged.append(_row(code, table, entry, amount, "ha", index, variant))
per_ha += 1
return staged if per_ha else "인/ha 줄"