"""공종 마스터 — **갈래 키(`variant_key`)를 표 읽기가 실제로 가른 대로** 싣는다 (명세 14장 정정). 종전 `variant_axis` 는 표 첫 열 값 24개만 담아, 단가표 갈래 제목 294 중 86 만 맞았다 (2026-09-13 브레인 실측). 머리행 갈래(벌목 「5m이상~8m미만」) · 두 단 머리 · 첫 열이 자원 이름인 표를 못 가렸다. ⚠ 표 모양을 여기서 **다시 판정하지 않는다** — 첫 열 · 열이 자원 · 뭉친 이름+갈래 열 · 축 셋 · 두 단 머리 · 작업조 · 공정 합산 · 고르기형 등 모양 가르기는 B09 표 읽기에 이미 있다. 두 벌로 짜면 한쪽만 고쳐진다. 그 읽기를 **메모리의 이 마스터**에 돌려 표마다 가른 갈래 이름을 원문 표기 그대로 싣는다(키 비교는 받는 쪽이 `normalize_variant_key` 로). ⚠ 못 가른 표는 **빈칸** — 자원을 못 맞춘 표 · 계수 · 참조 · 미판정 표에 갈래를 지어내지 않는다. 공종마다 `variant_keys` — 표들의 갈래 + 원문이 정했으나 자원 줄로 안 서는 갈래 (불도저 운반 공식 갈래 · 합산형 단계 잎의 암질 행 · 9-4-1 [주]① 평균 · 유로폼 12-38-2 부자재 요율 머리) · 합산형 부모는 단계 갈래. """ from __future__ import annotations from typing import Any def _add(labels: list[str], seen: set[str], label: str, normalize: Any) -> None: key = normalize(label) if key and key not in seen: seen.add(key) labels.append(label) def attach_variant_keys(nodes: list[dict[str, Any]], edition: str) -> None: """표마다 `variant_key`, 공종마다 `variant_keys` 를 채운다(자리에서).""" from B09_Estimation.B09_Estimation_Euroform import CODE as EUROFORM_CODE from B09_Estimation.B09_Estimation_Euroform import RATE_TABLE as EUROFORM_RATE_TABLE from B09_Estimation.B09_Estimation_Euroform import rates as euroform_rates from B09_Estimation.B09_Estimation_MachineProductivity_Dozer import extract_dozer_factors from B09_Estimation.B09_Estimation_ParentSteps import AVERAGE_BASIS, AVERAGE_VARIANT, rock_rows from B09_Estimation.B09_Estimation_ResourceAxis import build_resource_axis from B09_Estimation.B09_Estimation_ResourceAxis_Sources import load_combined_catalog from B09_Estimation.B09_Estimation_UnitPrice import normalize_variant_key as normalize axis = build_resource_axis( {"effective_date": edition, "work_items": nodes}, load_combined_catalog() ) by_table: dict[tuple[str, str], tuple[list[str], set[str]]] = {} for row in axis.rows: if row.variant: labels, seen = by_table.setdefault((row.work_item_code, row.pum_table_id), ([], set())) _add(labels, seen, row.variant, normalize) steps = { str(step["code"]) for node in nodes if node.get("parent_mode") == "sum_steps" for step in node.get("steps") or [] } by_code: dict[str, list[str]] = {} for node in nodes: code = str(node.get("work_item_code") or "") keys: list[str] = [] seen: set[str] = set() for table in node.get("tables") or []: labels, table_seen = by_table.get((code, str(table.get("pum_table_id"))), ([], set())) found = extract_dozer_factors(code, table) for label in found if isinstance(found, dict) else {}: _add(labels, table_seen, label, normalize) if code in steps: for rock, _, _ in rock_rows({"tables": [table]}): _add(labels, table_seen, rock, normalize) if code == EUROFORM_CODE and table.get("pum_table_id") == EUROFORM_RATE_TABLE: for head in euroform_rates({"tables": [table]}): # 부자재 요율 머리 갈래 _add(labels, table_seen, head, normalize) table["variant_key"] = labels for label in labels: _add(keys, seen, label, normalize) if code in AVERAGE_BASIS and keys: _add(keys, seen, AVERAGE_VARIANT, normalize) by_code[code] = keys for node in nodes: code = str(node.get("work_item_code") or "") keys = list(by_code.get(code) or []) if node.get("parent_mode") == "sum_steps": seen = {normalize(k) for k in keys} for step in node.get("steps") or []: for label in by_code.get(str(step["code"])) or []: _add(keys, seen, label, normalize) node["variant_keys"] = keys