Files
Aislo/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py
T
eomsangdonandClaude Opus 5 857a93f08c feat(B08): 지장목제거를 두 줄로 가름 (확정 5차 2번) + 작업 갈래를 지반 갈래로 안 읽음
- 실무(영월 1.9)가 한 면적에 두 작업을 얹음 — 뿌리뽑기 @475 · 잡관목제거 @882 가
  같은 11,035㎡. ⚠ 이중계상이 아니라 서식이 그러함(사유에 적음)
- 잡관목제거는 품셈에 이름이 없음(실무 D00033 별도 단가) — 공종 보류(확정 5차 3번)라
  코드 없이 서고 사유가 붙음
- ⚠ 곁들여 잡은 것: `item` 칸을 **늘 지반 갈래로 읽고 있었음** — 지장목제거의 작업 갈래에
  「시공법 미지정으로 공종을 못 고름」이라는 틀린 사유가 붙었음. 갈래로 읽는 공종을
  GROUND_SPLIT_GROUPS 로 한정하고 나머지는 규격 칸으로 보냄
- tmp/tests/test_b08_tree_removal_split.py 신설(5건)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 12:12:30 +09:00

454 lines
23 KiB
Python

"""공종 매핑과 갈래표 — **어느 품셈 공종에 잇나** (`Engine_Handoff` 에서 갈라냄).
⚠ **왜 갈랐나** — `Engine_Handoff.py` 가 1,279줄로 700줄 제한의 두 배였다(2026-09-08).
**인계 계약(줄의 모양)은 하나도 안 바뀐다** — 파일만 가른다. 부르는 쪽은 종전대로
`B08_Quantity_Engine_Handoff` 에서 그대로 가져다 쓴다(그쪽이 다시 내보낸다).
여기 있는 것 — 매핑표 읽기 · `WorkItemMapping` · 갈래표(돌쌓기·철근·유로폼·목재공작물) ·
묶음 전개 · 줄의 출처·막힘 갈래 이름.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
DATASET_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_work_item_mapping"
DATASET_PREFIX = "work_item_mapping_"
#: 철근 갈래표 — **품셈 12-3 [주]① 원문**이 구조물 예시로 갈라 둔 것이라 사람이 고르는 값이
#: 아니다(거푸집 사용횟수 1-7-1 과 같은 자리). 원문 예시에 안 걸리면 지어내지 않는다.
REBAR_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_rebar"
REBAR_PREFIX = "rebar_complexity_"
#: 돌쌓기 규격 갈래표 — 저장 제원 값으로 자동 판정한다(사람이 고르는 값이 아니다).
MASONRY_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_masonry"
MASONRY_PREFIX = "masonry_class_"
#: 목재공작물 구조 갈래표 — 품셈 13-13-1 [주]③ 이 **재료 구성**으로 가른다.
TIMBER_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_timber"
TIMBER_PREFIX = "timber_structure_class_"
def load_timber_table(path: Path | None = None) -> dict[str, Any]:
"""목재공작물 갈래표. 파일이 없으면 빈 표 — 갈래가 안 붙고 그대로 드러난다."""
target = path
if target is None:
files = sorted(TIMBER_DIR.glob(TIMBER_PREFIX + "*.json")) if TIMBER_DIR.is_dir() else []
target = files[-1] if files else None
if target is None or not target.is_file():
return {}
return json.loads(target.read_text(encoding="utf-8"))
def timber_class(type_id: str, table: dict[str, Any] | None = None) -> tuple[str | None, str, bool]:
"""(갈래, 근거, 잠정인가). **잠정이면 그 사실을 숨기지 않는다.**
⚠ 갈래를 고르되 **드러낸다** — 임의로 고르고 조용히 넘어가면 미결을 숨기는 것이다.
밑수 1㎥ 는 **목재 채적**이라 「1㎥에 건축목공 17인」이 말이 된다(원문 [주]③).
"""
found = table if table is not None else load_timber_table()
for row in (found or {}).get("type_map") or []:
if row.get("type_id") == type_id and row.get("class"):
basis = f"품셈 13-13-1 [주]③ 「{row.get('matched')}」"
if row.get("provisional"):
basis += f" · ⚠ 잠정 — {row.get('compare', '')}"
return str(row["class"]), basis, bool(row.get("provisional"))
return None, f"품셈 13-13-1 [주]③ 예시에 없는 공작물({type_id}) — 임의로 고르지 않음", False
def load_masonry_table(path: Path | None = None) -> dict[str, Any]:
"""돌쌓기 갈래표. 파일이 없으면 빈 표 — 갈래가 안 붙고 그대로 드러난다."""
target = path
if target is None:
files = sorted(MASONRY_DIR.glob(MASONRY_PREFIX + "*.json")) if MASONRY_DIR.is_dir() else []
target = files[-1] if files else None
if target is None or not target.is_file():
return {}
return json.loads(target.read_text(encoding="utf-8"))
def masonry_class(
options: dict[str, Any], table: dict[str, Any] | None = None
) -> tuple[str | None, str]:
"""돌쌓기 갈래 — 저장 뒷길이로 **「…㎝ 이하」 구간**을 고른다.
⚠ 저장 선택지(25·30·35·45·55·60·75)와 단가 갈래(35·55·75 이하)는 축이 다르다.
**저장값 이상인 첫 경계**를 고르는 것이 「이하」 구간의 뜻이다.
"""
found = table if table is not None else load_masonry_table()
spec = (found or {}).get("back_length") or {}
from B08_Quantity.B08_Quantity_Wording import option_missing
option_key = str(spec.get("option_key") or "back_len_cm")
raw = options.get(option_key)
if raw is None:
# ⚠ 「없다」만 말하지 않는다 — **어디서 채우면 단가가 붙는지**까지.
# 이름은 부르는 쪽(`unmatched`)이 이미 앞에 붙이므로 여기서는 칸 이름만 말한다.
return None, option_missing(option_key) + " (단가 갈래를 못 고름)"
try:
value = float(raw)
except (TypeError, ValueError):
return None, f"뒷길이 값을 못 읽음({raw!r})"
for row in spec.get("classes") or []:
if value <= float(row["max_cm"]):
return str(row["key"]), f"뒷길이 {value:g}㎝ → 품셈 13-4 「{row['key']}」 구간"
return None, f"뒷길이 {value:g}㎝ 를 덮는 갈래가 표에 없음"
def normalize_kind_key(label: str) -> str:
"""갈래 키 — **내부 공백만** 지운다 (2026-09-07 두 창 확정).
원문 표는 「보 통」처럼 자간 공백이 들어 있어 그대로 쓰면 양쪽이 안 맞는다.
⚠ **다른 글자는 손대지 않는다** — 정규화를 넓히면 오늘 아홉 번 겪은 그 병을
여기서 새로 만든다. 원문 문구는 버리지 않고 `label` 로 함께 싣는다.
"""
return "".join(str(label).split())
#: 줄이 어디서 왔나 — 되짚을 때 쓴다.
ORIGIN_EARTHWORK = "earthwork"
ORIGIN_STRUCTURE = "structure"
ORIGIN_SLOPE = "slope"
ORIGIN_HAUL = "haul"
ORIGIN_PREPARATION = "preparation"
ORIGIN_PIPE = "pipe"
#: 암 시공법 → 매핑표의 지반 이름. 품셈이 **긁어내기와 터뜨리기를 다른 공종**으로 두기 때문에
#: 갈래 이름(연암·보통암…)만으로는 공종을 못 고른다(2026-09-07 일감 9 실서버에서 드러남).
METHOD_TO_GROUND = {"ripping": "리핑암", "blasting": "발파암"}
NOTE_METHOD_MISSING = "시공법 미지정으로 공종을 못 고름"
#: 철근으로 보는 성분 이름 조각. **정확한 낱말이 아니라 앞머리**로 본다 —
#: 「이형철근 D13」·「원형철근」처럼 규격이 뒤에 붙기 때문이다. `철근콘크리트`는 성분 이름이
#: 아니라 공종 이름이라 성분 목록에는 안 온다.
REBAR_PREFIXES = ("이형철근", "원형철근", "철근")
#: 줄이 왜 막혔나 — **받는 쪽이 「사용자가 입력하면 풀리는 것」과 「우리가 만들어야 하는 것」을
#: 화면에서 갈라야** 한다(2026-09-07 3자 확정). 8-27 표에서 이미 가른 그 축이다.
BLOCKED_INPUT_MISSING = "input_missing" # 저장 제원 칸이 비어 있음 — 입력하면 풀림
BLOCKED_UNIT_DATA_MISSING = "unit_data_missing" # 원단위·표준 물량 자료가 없음
BLOCKED_FORMULA_MISSING = "formula_missing" # 수량 산출식 자체가 없음
#: 사면 계열 — 토공집계에 함께 실리지만 출처가 사면적이다.
#: ⚠ `item` 칸이 **지반 갈래**인 공종 — 그 밖의 공종에서 `item` 은 **작업 갈래**다
#: (지장목제거의 「뿌리뽑기·잡관목제거」). 갈래로 읽으면 「시공법 미지정」이라는 **틀린 사유**가
#: 붙는다(2026-09-09 실측). 정의처는 `EarthworkSummary` 이고 여기서 그대로 가져다 쓴다.
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import ( # noqa: E402
GROUND_SPLIT_GROUPS as GROUND_SPLIT_GROUPS,
)
SLOPE_GROUPS = frozenset({"성토면다짐", "초류종자살포", "지장목제거", "층따기", "면고르기"})
#: 집계 합계 줄 — 내역 줄이 아니라 검산용이다.
SUBTOTAL_GROUPS = frozenset({"보정량계"})
#: ⚠⚠ **토공집계표에도 운반 줄이 있다 — 그런데 내역 줄은 운반표 쪽이다**(2026-09-09 실측).
#: 집계표는 실무 토적집계 모양이라 「무대·도자운반·덤프운반」을 함께 싣는데, 인계본에는
#: 운반표(`_haul_rows`, FP-10-11·FP-10-12)가 **같은 물량으로 또 실렸다** — 같은 운반이
#: 두 줄이었다(실측: 도자 17.389·61.130, 덤프 37.511·122.417 이 두 축에 각각).
#: ⇒ 집계 쪽은 **값은 내되 내역 줄이 아니다**. 빼지 않는 까닭은 검산(무대+도자+덤프 = 총
#: 운반토량)이 그 값을 쓰기 때문이다 — 무대를 그렇게 둔 것과 같은 규칙이다.
HAUL_SUMMARY_GROUPS = frozenset({"무대(종방향유용토)", "도자운반", "덤프운반"})
NOTE_HAUL_IN_SUMMARY = (
"집계 값 — 내역 줄은 운반표 쪽(FP-10-11·FP-10-12)이 세움. 여기서 또 세우면 같은 운반이"
" 두 줄이 됨(검산용으로만 실림)"
)
def _latest_dataset_path(directory: Path | None = None) -> Path | None:
folder = directory or DATASET_DIR
if not folder.is_dir():
return None
files = sorted(folder.glob(DATASET_PREFIX + "*.json"))
return files[-1] if files else None
#: ⚠ **공종 수량 단위가 아닌 것** — 품셈 절 머리에 섞여 있는 모양들이다(2026-09-08 B09 전수).
#: `(㎥/1대, 1일)` 는 **시공량**이라 「1대가 하루에 몇 ㎥」이고, 밑수(「1㎥ 에 얼마」)의
#: **역수**다. 그대로 밑수로 받으면 그 공종이 조용히 뒤집힌 단위를 갖는다.
#: `(일당)`·`(조)`·`(인)` 은 **품의 단위**이지 공종 단위가 아니다.
NON_QUANTITY_UNITS = frozenset({"일당", "조", "인", "일", "대", "인당"})
def is_quantity_unit(unit: str) -> bool:
"""그 글자가 **공종 수량의 밑수 단위**로 쓸 수 있는가.
⚠ **좁게 본다** — 애매하면 안 받는다. 안 받으면 대조가 없을 뿐이고, 잘못 받으면
**틀린 단위로 검사를 통과시킨다**(그쪽이 더 나쁘다).
"""
text = str(unit or "").strip()
if not text or text in NON_QUANTITY_UNITS:
return False
# 「㎥/1대, 1일」처럼 나눗셈·쉼표가 있으면 시공량이거나 밑수가 둘이다.
return "/" not in text and "," not in text
@dataclass
class WorkItemMapping:
"""수량 줄 → 공종 마스터 코드. 못 찾으면 `None` 을 돌려주고 부른 쪽이 목록에 남긴다."""
effective_date: str = ""
earthwork: list[dict[str, Any]] = field(default_factory=list)
haul: list[dict[str, Any]] = field(default_factory=list)
structure: list[dict[str, Any]] = field(default_factory=list)
pending_user: dict[str, Any] = field(default_factory=dict)
composite: dict[str, Any] = field(default_factory=dict)
concrete_placing: dict[str, Any] = field(default_factory=dict)
unit_conversion: dict[str, Any] = field(default_factory=dict)
#: 배수관 — 관종별 공종·연장 키. 관 정본은 `pipe_points.json` 이다.
pipe: dict[str, Any] = field(default_factory=dict)
#: 갈래 이름 별칭 — 우리 「리핑암」 ↔ 일위대가 「파쇄암」처럼 **같은 것을 다른 이름**으로
#: 부르는 자리. ⚠ 갈래 이름 자체를 갈지 않는다(흙깎기 매핑이 그 이름으로 서 있다).
ground_aliases: dict[str, Any] = field(default_factory=dict)
def declared_units(self) -> dict[str, str]:
"""공종코드 → **매핑이 원문에서 읽어 적은 밑수 단위**. 적힌 줄만 낸다.
⚠ 마스터가 못 채운 자리를 메우는 값이다(층따기 9-18 처럼 공식 [주]로만 단위가
밝혀지는 공종). 여기 적을 때는 **어느 원문 줄에서 읽었는지**(`basis_source`)를
함께 남길 것 — 근거 없는 단위가 대조의 기준이 되면 안 된다.
"""
found: dict[str, str] = {}
for row in (*self.earthwork, *self.haul, *self.structure):
code, unit = row.get("work_item_code"), row.get("basis_unit")
if code and unit and is_quantity_unit(str(unit)):
found[str(code)] = str(unit)
return found
def for_earthwork(self, group: str, ground: str | None) -> dict[str, Any] | None: # noqa: D401
"""공종+지반유형으로 찾는다. 지반이 갈리지 않는 공종은 `ground` 없는 줄이 받는다."""
exact = [
row
for row in self.earthwork
if row.get("group") == group and row.get("ground") == ground
]
if exact:
return exact[0]
# 지반을 안 가르는 공종(성토·층따기 등)은 `ground` 칸이 없는 줄로 맞춘다.
loose = [row for row in self.earthwork if row.get("group") == group and "ground" not in row]
return loose[0] if loose else None
def for_haul(self, equipment: str) -> dict[str, Any] | None:
for row in self.haul:
if row.get("equipment") == equipment:
return row
return None
def for_structure(self, type_id: str) -> dict[str, Any] | None:
for row in self.structure:
if row.get("type_id") == type_id:
return row
return None
def composite_for(self, type_id: str) -> dict[str, Any] | None:
"""품셈에 그 이름의 공종이 없어 **여러 공종을 묶는** 자리인가.
빈 코드로 두면 「매핑을 못 찾은 줄」과 구별이 안 된다 — 묶음을 적어 구별한다.
"""
for row in self.composite.get("items") or []:
if row.get("type_id") == type_id:
return row
return None
def load_mapping(path: Path | None = None) -> WorkItemMapping:
"""매핑표를 읽는다. 파일이 없으면 **빈 표** — 전 줄이 `unmatched` 로 드러난다."""
target = path or _latest_dataset_path()
if target is None or not target.is_file():
return WorkItemMapping()
payload = json.loads(target.read_text(encoding="utf-8"))
return WorkItemMapping(
effective_date=str(payload.get("effective_date") or ""),
earthwork=list(payload.get("earthwork") or []),
haul=list(payload.get("haul") or []),
structure=list(payload.get("structure") or []),
pending_user=payload.get("pending_user") or {},
composite=payload.get("composite") or {},
concrete_placing=payload.get("concrete_placing") or {},
pipe=payload.get("pipe") or {},
unit_conversion=(payload.get("composite") or {}).get("unit_conversion") or {},
ground_aliases=payload.get("ground_aliases") or {},
)
def composite_quantities(
structure: dict[str, Any],
composite: dict[str, Any],
mapping: WorkItemMapping,
) -> tuple[list[dict[str, Any]], list[str]]:
"""묶음 조각마다 **부위별 수량**을 채운다. (조각 목록, 못 채운 사유).
⚠ 조각은 **원단위 성분 이름으로** 찾는다. 이름이 어긋나면 물량이 조용히 0 이 되므로
못 찾으면 그 조각을 `not_ready` 로 남기고 사유를 적는다 — 0 을 적지 않는다.
⚠ **단위를 반드시 맞춘다.** 철근 단가는 `원/ton` 인데 원단위는 `㎏` 이다.
안 맞추면 **1000배 틀린다** — 밑수에서 겪은 것과 같은 자리다.
"""
# ⚠ 원단위 자체가 없으면 조각을 늘어놓지 않는다 — 같은 사유가 다섯 번 반복되면
# **진짜 사유가 묻힌다**(화면에서 실제로 그렇게 보였다). 한 줄로 말한다.
if not (structure.get("components") or []):
note = "; ".join(structure.get("notes") or []) or "구조물 원단위가 없음"
return [], [{"code": None, "reason": note}]
amounts: dict[str, tuple[float, str]] = {}
for component in structure.get("components") or []:
name = str(component.get("name") or "").strip()
amounts[name] = (float(component.get("amount") or 0.0), str(component.get("unit") or ""))
kg_to_ton = float((mapping.unit_conversion or {}).get("kg_to_ton") or 0.001)
parts: list[dict[str, Any]] = []
missing: list[str] = []
for spec in composite.get("parts") or []:
if not isinstance(spec, dict): # 옛 모양(코드 문자열)은 그대로 흘린다
parts.append({"code": str(spec)})
continue
sources = list(spec.get("from_components") or [])
found = [name for name in sources if name in amounts]
total = sum(amounts[name][0] for name in found)
if spec.get("unit_from") == "kg" and spec.get("unit") == "ton":
total *= kg_to_ton
kinds = {
component.get("basis_kind")
for component in structure.get("components") or []
if str(component.get("name") or "").strip() in found
}
suffix = spec.get("kind_suffix")
entry: dict[str, Any] = {
"code": spec.get("code"),
"name": spec.get("name"),
"unit": spec.get("unit"),
"quantity": total if found else None,
# 조각마다 근거를 단다 — 치수 전개와 관측값이 한 묶음에 섞인다.
"basis_kind": next(iter(kinds)) if len(kinds) == 1 else (sorted(kinds) or None),
"from_components": sources,
}
if spec.get("incomplete_note"):
# ⚠ 물량은 섰으나 **일부 몫이 빠진** 조각 — 「못 채움」과 달리 값은 있다.
# 화면·인계 둘 다 그 사실을 알아야 「다 섰다」로 오해하지 않는다.
entry["incomplete_note"] = spec["incomplete_note"]
if suffix == "euroform_type":
kind, why = euroform_type(str(structure.get("type_id") or ""))
entry["kind"] = normalize_kind_key(kind) if kind else None
entry["kind_label"] = kind # 원문 문구 그대로
entry["kind_basis"] = why
if kind:
entry["code"] = f"{spec.get('code')}#{normalize_kind_key(kind)}"
else:
entry["not_ready"] = True
entry["why"] = why
missing.append({"code": spec.get("code"), "reason": why})
if suffix == "rebar_complexity":
# 갈래는 원문이 정한다 — 화면·인계에 이름과 근거를 함께 실어 사람이 검증하게 한다.
complexity, why = rebar_complexity(
str(structure.get("type_id") or ""), structure.get("options") or {}
)
entry["kind"] = normalize_kind_key(complexity) if complexity else None
entry["kind_label"] = complexity # 원문 문구 그대로(자간 공백 포함)
entry["kind_basis"] = why
if complexity:
entry["code"] = f"{spec.get('code')}#{normalize_kind_key(complexity)}"
else:
entry["not_ready"] = True
entry["why"] = why
missing.append({"code": spec.get("code"), "reason": why})
if spec.get("not_ready") or not found:
entry["not_ready"] = True
entry["why"] = str(spec.get("why") or "원단위에 해당 성분이 없음")
# ⚠ 「단가 없음」과 「물량 없음」을 받는 쪽이 갈라야 하므로 **구조로** 낸다.
missing.append({"code": spec.get("code"), "reason": entry["why"]})
parts.append(entry)
return parts, missing
def structure_kind(structure: dict[str, Any]) -> str:
"""콘크리트 구조물 종류 — **원단위에 철근이 있나 없나로 판정한다.**
사람이 고르는 값이 아니다(2026-09-07 3자 확정). 옹벽 관측 원단위에 `D13`·`D16` 이
실려 있으므로 철근구조물로 자동으로 선다. 소형구조물 판정 기준은 아직 없다.
"""
for component in structure.get("components") or []:
name = str(component.get("name") or "").strip()
if any(name.startswith(prefix) for prefix in REBAR_PREFIXES):
return "철근구조물"
return "무근구조물"
def placing_code(mapping: WorkItemMapping, method: str | None) -> tuple[str | None, bool]:
"""(타설 공종코드, 기본값을 쓴 것인가). 모르는 방식이면 기본으로 떨어지되 그 사실을 알린다."""
table = mapping.concrete_placing or {}
codes = table.get("method_codes") or {}
default = str(table.get("default_method") or "")
if method in codes:
return codes[method], False
return codes.get(default), True
def load_rebar_table(path: Path | None = None) -> dict[str, Any]:
"""철근 갈래표를 읽는다. 파일이 없으면 **빈 표** — 전부 「갈래 미확보」로 드러난다."""
target = path
if target is None:
files = sorted(REBAR_DIR.glob(REBAR_PREFIX + "*.json")) if REBAR_DIR.is_dir() else []
target = files[-1] if files else None
if target is None or not target.is_file():
return {}
return json.loads(target.read_text(encoding="utf-8"))
def rebar_complexity(
type_id: str, options: dict[str, Any], table: dict[str, Any] | None = None
) -> tuple[str | None, str]:
"""(철근 갈래, 근거). **원문 예시에 걸리는 것만** 정하고 안 걸리면 `(None, 사유)`.
품셈 12-3 [주]① — 「간단: 측구·간단한 기초·**중력식 옹벽** / 보통: 수문·**반중력식 옹벽**·
교대 / 복잡: 교량 슬래브·암거·우물통·**부벽식 옹벽** / 매우복잡: 구주식 교대·교각…」.
사람에게 묻지 않는다 — **판정할 수 있는 것을 물으면 그것이 곧 미결이 된다.**
"""
found = table if table is not None else load_rebar_table()
form = options.get("form")
fallback: dict[str, Any] | None = None
for row in found.get("form_map") or []:
if row.get("type_id") != type_id:
continue
if "form" not in row:
fallback = row
continue
if row.get("form") == form:
if row.get("class"):
return str(row["class"]), f"품셈 12-3 [주]① 「{row.get('matched')}」"
return None, str(row.get("why") or "원문 예시에 없음")
if fallback and fallback.get("class"):
return str(fallback["class"]), f"품셈 12-3 [주]① 「{fallback.get('matched')}」"
from B08_Quantity.B08_Quantity_Wording import type_label
detail = f"({form})" if form else "(형식이 아직 입력되지 않음)"
return None, (
f"{type_label(type_id)} {detail} 는 품셈 12-3 [주]① 예시에 없어 "
"철근 갈래를 정하지 못했습니다 — 임의로 고르지 않습니다"
)
def euroform_type(type_id: str, table: dict[str, Any] | None = None) -> tuple[str | None, str]:
"""유로폼 설치·해체 유형 — **품셈 12-38-3 [주]④ 원문**이 시설 예시로 갈라 둔다.
「보통: 측구, 수로, **옹벽**, 일반적인 벽체, 박스」. 거푸집 사용횟수(1-7-1)·철근 갈래
(12-3 [주]①)에 이어 **네 번째** 같은 자리다 — 미결로 올리기 전에 원문부터 뒤진다.
⚠ 12-38-1 「사용횟수」와 헷갈리지 말 것 — 그쪽은 유로폼(강재)의 **잔존율**이고
1-7-1 의 소모성 거푸집 전용 횟수와도 다른 자리다.
"""
from B08_Quantity.B08_Quantity_Engine_Formwork import load_formwork_table
found = table if table is not None else load_formwork_table().euroform_type
for row in (found or {}).get("type_map") or []:
if row.get("type_id") == type_id and row.get("class"):
return str(row["class"]), f"품셈 12-38-3 [주]④ 「{row.get('matched')}」"
from B08_Quantity.B08_Quantity_Wording import type_label
return None, (
f"{type_label(type_id)} 는 품셈 12-38-3 [주]④ 예시에 없어 유로폼 유형을 "
"정하지 못했습니다 — 임의로 고르지 않습니다"
)