diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index 8511073f..28456d41 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -275,6 +275,8 @@ export function putStandardSheetSpec( back_len_cm: string | null; face_slope_ratio: string | null; foundation: string | null; + stone_coeff_basis: string | null; + fill_concrete_mpa: string | null; }, ): Promise<{ status: string; revision: number; changed: number; notes: string[] }> { return requestJson(`/projects/${projectId}/standard-sheets/spec`, { diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardFigure.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardFigure.py index a1ee36a8..92d55f35 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardFigure.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardFigure.py @@ -12,7 +12,10 @@ 직접 읽는다. 치수를 여기서 다시 적으면 **그림과 표가 갈린다**(CLAUDE.md 5장). 상부 두께 = 뒷길이 + 0.30 하부 두께 = 상부 + 0.30 × (H − 1.0) - 터파기 폭 = 평균두께 + 0.2 되메우기 두께 = 0.2 + 터파기 폭 = 평균두께 + 0.2 기초 0.5×0.9 (기초유) · 0.1×0.7 (기초버림) + + ⚠ 터파기 치수는 **`common_util_excavation` 한 벌**을 읽는다 — 횡단도가 쓰는 그 상수다. + 여기서 다시 적으면 두 도면이 다른 터파기를 그린다. ⚠ **뒷길이가 두께에 들어간다**(확정 2차 ②, 실무 구조물도 식). 뒷길이가 다르면 벽이 두꺼워지고 그림도 그만큼 넓어진다 — 예전 식(0.45+0.10H / 0.45+0.40H)은 뒷길이를 @@ -35,6 +38,13 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( polyline_entity, ) from B08_Quantity.B08_Quantity_Engine_UnitQuantity import STONE_MASONRY, face_slope_ratio +from common_util.common_util_excavation import ( + WALL_BLINDING_DEPTH_M, + WALL_BLINDING_WIDTH_M, + WALL_FOUNDATION_DEPTH_M, + WALL_FOUNDATION_WIDTH_M, + WALL_TRENCH_CLEARANCE_M, +) #: 그림 축척 — 1/25(1m = 40㎜). 표(줄 높이 9㎜) 위에 얹어도 한 면에 드는 크기다. SCALE_MM_PER_M = 40.0 @@ -135,8 +145,15 @@ def build_figure( points = section_points(height_m, slope, back_cm) top_t, bottom_t = wall_thickness(height_m, back_cm) average_t = (top_t + bottom_t) / 2.0 - dig_width = average_t + STONE_MASONRY["excavation_extra_m"] - backfill_t = STONE_MASONRY["backfill_thickness_m"] + dig_width = average_t + WALL_TRENCH_CLEARANCE_M + # 기초 몫 — 「기초유 / 기초버림」이 폭·깊이를 가른다(정본 탭 제목). + foundation = str((sheet.get("options") or {}).get("foundation") or "") + if foundation == "기초유": + base_w, base_d = WALL_FOUNDATION_WIDTH_M, WALL_FOUNDATION_DEPTH_M + elif foundation == "기초버림": + base_w, base_d = WALL_BLINDING_WIDTH_M, WALL_BLINDING_DEPTH_M + else: + base_w = base_d = 0.0 # 안 정한 장은 기초를 안 그린다 — 지어내지 않는다. entities: list[dict[str, Any]] = [] wall = polyline_entity( @@ -156,16 +173,16 @@ def build_figure( if dig is not None: entities.append(dig) - # 되메우기 두께 선 — 밑면에서 0.2m 위. 값이 어디서 오는지 눈에 보이게. - entities.append( - _line_entity( - f"{drawing_id}:fig:backfill", - mm((0.0, backfill_t)), - mm((dig_width, backfill_t)), + # 기초 — 벽 밑에 놓이는 칸. 「안 정함」이면 안 그린다. + if base_d > 0: + base = polyline_entity( + f"{drawing_id}:fig:base", + [mm(p) for p in ((0.0, 0.0), (0.0, -base_d), (base_w, -base_d), (base_w, 0.0))], layer_id, guide_color, ) - ) + if base is not None: + entities.append(base) # 물구멍 — 벽을 가로지르는 짧은 선 하나. 개소 간격은 글자로 적는다(면적당이라 그림에 못 씀). weep_y = height_m * 0.5 @@ -189,7 +206,13 @@ def build_figure( (dig_width + 0.16, height_m * 0.92), "left", ), - (f"되메우기 {backfill_t:g} m", (dig_width + 0.16, backfill_t), "left"), + ( + f"기초 {base_w:g} × {base_d:g} m ({foundation})" + if base_d > 0 + else "기초 — 안 정함(기초유/기초버림)", + (dig_width + 0.16, -base_d / 2.0 if base_d else 0.0), + "left", + ), ( f"물구멍 — {STONE_MASONRY['weep_hole_area_m2']:g}㎡당 1개소", (slope * weep_y + top_t + 0.16, weep_y), @@ -243,5 +266,5 @@ def build_figure( ) ) - used_mm = (height_m + 0.95) * scale + used_mm = (height_m + 0.95 + base_d) * scale return entities, used_mm diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Standard_Edit.py b/B07_DesignDetail/B07_DesignDetail_Engine_Standard_Edit.py index 67bc203d..6cb0e327 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Standard_Edit.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Standard_Edit.py @@ -31,6 +31,8 @@ EDITABLE_KEYS: tuple[str, ...] = ( "back_len_cm", "face_slope_ratio", "foundation", + "stone_coeff_basis", + "fill_concrete_mpa", ) #: 기초 갈래 — 정본 xls 탭 제목 그대로(`04.구조도(기슭막이).xls`). @@ -51,8 +53,17 @@ FIELD_LABELS: dict[str, str] = { "back_len_cm": "뒷길이", "face_slope_ratio": "전면 기울기", "foundation": "기초", + "stone_coeff_basis": "야면석 계수", + "fill_concrete_mpa": "채움 강도", } +#: 야면석 계수를 어느 열에서 읽나 — 확정 ⑨ 「품셈 열이 기본, 사용자가 고를 수 있게」. +STONE_COEFF_CHOICES: tuple[str, ...] = ("품셈", "실무 관행") + +#: 채움 콘크리트 강도(MPa) — 확정 2차 ⑩ 「기본 210, 고를 수 있게」. +#: 180 은 국가기준 하한(돌쌓기 전용), 210 은 콘크리트 구조물 몸체 쪽 기준. +FILL_CONCRETE_CHOICES: tuple[str, ...] = ("180", "210") + def _clean_slope(value: Any) -> tuple[float | None, str | None]: """전면 기울기 — `(값, 안내)`. 비면 `(None, None)` 이고 그것이 「자동」의 뜻이다.""" @@ -116,6 +127,20 @@ def clean_spec(spec: dict[str, Any]) -> tuple[dict[str, Any], list[str]]: f"(있는 것: {' · '.join(FOUNDATION_CHOICES)})." ) + for key, choices in ( + ("stone_coeff_basis", STONE_COEFF_CHOICES), + ("fill_concrete_mpa", FILL_CONCRETE_CHOICES), + ): + if key not in spec: + continue + raw = spec.get(key) + cleaned[key] = str(raw) if raw not in (None, "") else None + if cleaned[key] and cleaned[key] not in choices: + notes.append( + f"{FIELD_LABELS[key]} 「{cleaned[key]}」는 없는 갈래입니다 " + f"(있는 것: {' · '.join(choices)})." + ) + if "face_slope_ratio" in spec: ratio, note = _clean_slope(spec.get("face_slope_ratio")) cleaned["face_slope_ratio"] = ratio diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Standard.py b/B07_DesignDetail/B07_DesignDetail_Router_Standard.py index 86c64b71..cae52cd7 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Standard.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Standard.py @@ -71,6 +71,8 @@ class StandardSheetSpecRequest(BaseModel): back_len_cm: int | str | None = None face_slope_ratio: float | str | None = None foundation: str | None = None + stone_coeff_basis: str | None = None + fill_concrete_mpa: str | None = None @router.put("/{project_id}/standard-sheets/spec") diff --git a/B07_DesignDetail/B07_DesignDetail_UI_StandardSpec.ts b/B07_DesignDetail/B07_DesignDetail_UI_StandardSpec.ts index 0badf6ce..aff74c48 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_StandardSpec.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_StandardSpec.ts @@ -17,6 +17,10 @@ const SUPPLIES = ["채집", "구입"] as const; const BACK_LENGTHS = ["25", "30", "35", "45", "55", "60", "75"] as const; /** 정본 xls 탭 제목 그대로 — 터파기 기초 몫 0.45 대 0.07 을 가르는 축. */ const FOUNDATIONS = ["기초유", "기초버림"] as const; +/** 확정 ⑨ — 품셈 열이 기본, 실무 관행(깬돌 열)으로 바꿀 수 있게. */ +const COEFF_BASES = ["품셈", "실무 관행"] as const; +/** 확정 2차 ⑩ — 기본 210. 180 은 국가기준 하한(돌쌓기 전용). */ +const FILL_MPA = ["180", "210"] as const; /** 표준도 장 하나 — 서버가 낸 것 중 이 폼이 쓰는 것만. */ export interface StandardSheetSpec { @@ -34,6 +38,8 @@ export interface StandardSpecResult { back_len_cm: string | null; face_slope_ratio: string | null; foundation: string | null; + stone_coeff_basis: string | null; + fill_concrete_mpa: string | null; } /** 이 종류가 돌쌓기 계열인가 — 옹벽·집수정에는 이 칸들이 뜻이 없다. */ @@ -110,6 +116,8 @@ export function buildStandardSpecPanel( const supply = select(SUPPLIES, options.stone_supply, "— 안 정함(기본 채집) —"); const back = select(BACK_LENGTHS, options.back_len_cm, "— 안 정함 —"); const foundation = select(FOUNDATIONS, options.foundation, "— 안 정함 —"); + const coeff = select(COEFF_BASES, options.stone_coeff_basis, "— 안 정함(품셈) —"); + const mpa = select(FILL_MPA, options.fill_concrete_mpa, "— 안 정함(210) —"); const slope = document.createElement("input"); slope.className = "b07-spec__input"; @@ -128,6 +136,8 @@ export function buildStandardSpecPanel( slope, judgedSlope ? `비우면 자동 — 지금 판정값 1:${judgedSlope}` : "비우면 자동으로 판정합니다.", ), + field("야면석 계수", coeff, "비우면 품셈 열을 씁니다."), + field("채움 강도 (MPa)", mpa, "비우면 210. 180 은 국가기준 하한입니다."), ); const notes = document.createElement("ul"); @@ -163,6 +173,8 @@ export function buildStandardSpecPanel( back_len_cm: back.value || null, face_slope_ratio: slope.value.trim() || null, foundation: foundation.value || null, + stone_coeff_basis: coeff.value || null, + fill_concrete_mpa: mpa.value || null, }), ); } catch (error) { diff --git a/B09_Estimation/B09_Estimation_ResourceAxis.py b/B09_Estimation/B09_Estimation_ResourceAxis.py index 2455f881..5e0cf8f9 100644 --- a/B09_Estimation/B09_Estimation_ResourceAxis.py +++ b/B09_Estimation/B09_Estimation_ResourceAxis.py @@ -370,6 +370,14 @@ def match_table( if match_crew_table(node, table, catalog, result, table.get("basis_unit") or ""): return + # ⚠ **축이 셋인 표도 형태 판정보다 먼저 가른다.** 값 한 칸에 세로축 값이 여럿 + # 뭉쳐 있어 행-자원으로도 열-자원으로도 안 읽히고, 콘테이너형 가설건축물(11-1)은 + # `reference` 로 찍혀 형태 필터에 먼저 걸려 버려지고 있었다(확정 ⑬ 이 걸린 표). + from B09_Estimation.B09_Estimation_ResourceAxis_ThreeAxis import match_three_axis_table + + if match_three_axis_table(node, table, catalog, result): + return + form = table.get("pum_form", "") if form in NON_WORK_ITEM_FORMS or form in UNUSABLE_FORMS or form not in USABLE_FORMS: result.skipped_forms[form] = result.skipped_forms.get(form, 0) + 1 diff --git a/B09_Estimation/B09_Estimation_ResourceAxis_ThreeAxis.py b/B09_Estimation/B09_Estimation_ResourceAxis_ThreeAxis.py new file mode 100644 index 00000000..49895b4e --- /dev/null +++ b/B09_Estimation/B09_Estimation_ResourceAxis_ThreeAxis.py @@ -0,0 +1,297 @@ +"""B09 원가계산 — **축이 셋인 표** 읽기 (자원 축 보조, 2026-09-09). + +품셈에는 한 표 안에 **가로축 · 세로축 · 자원**이 함께 든 모양이 있다. 값 한 칸에 +세로축 값 여러 개가 **공백으로 뭉쳐** 들어 있어, 행-자원으로도 열-자원으로도 안 읽힌다. + + 11-1. 콘테이너형 가설건축물 ← 확정 ⑬ 이 걸려 있던 표 + | 길이 폭 | 3M | 6M | … | 비고 | + | | 비계공 특별인부 | 비계공 특별인부 | … | | + | 2.4M 3.0M 3.5M 4.8M 6.0M | 0.29 0.33 … | 0.14 0.17 … | … | + + 13-5-1. 돌붙임(인력) ← 덤으로 같이 서는 표 + | 구 분 | 메 붙 임 | 찰 붙 임 | + | 종 별 | 깬돌 | 깬잡석 | 야면석 | 깬돌 | 깬잡석 | 야면석 | + | 뒷길이(㎝) | 석공 | 보통인부 | … | + | 25 30 35 … | 0.15 0.22 … | … | + +**둘 다 지금 한 줄도 안 서고 있었다** — 하나는 `reference` 로 걸러졌고(F0325), 하나는 +「뭉친 자원 줄의 이름을 못 풀었습니다」로 버려졌다(F0416). + +읽는 법 — **맨 아랫줄이 값이고, 그 위가 자원 이름이고, 더 위가 묶음 이름**이다. + + ① 값줄 첫 칸을 쪼갠다 → 세로축 값 N 개 (「2.4M 3.0M …」 → 5 개) + ② 값줄 나머지 칸은 저마다 **N 개의 숫자**를 들고 있어야 한다 — 아니면 통째로 버린다 + ③ 값줄 바로 위가 자원 이름 줄, 그 위(들)가 묶음 이름 줄 + ④ 칸 i 의 j 번째 숫자 = (묶음 라벨 … · 세로축 j 번째 값) 갈래의 자원 i 소요량 + +⚠ **자리를 짐작해 맞추지 않는다.** 칸 수·숫자 개수가 딱 나뉘지 않으면 **한 줄도 세우지 +않고** `unmatched` 로 보낸다. 뭉친 값 표는 한 칸만 밀려도 **다른 규격의 품**이 붙는다. + +⚠ **「-」 는 값이 없는 것**이다. 0 으로 때우지 않고 그 갈래만 건너뛴다 +(품셈 13-5-1 야면석 70㎝ 자리가 그렇다 — 그 규격이 없다는 뜻이다). +""" + +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, +) + +#: 값 칸으로 인정하는 글자 — 숫자와 「-」(없음)뿐이다. +_NUMBER = re.compile(r"^\d+(?:\.\d+)?$") +_ABSENT = ("-", "-", "‐", "–", "—", "ㆍ", "·") + +#: 묶음 이름 자리에서 뺄 말. 「비고」 열은 값이 아니라 설명이다. +_NOTE_LABELS = ("비고", "적요", "참고") + +#: 세로축 이름이 안 적힌 표를 위한 자리표시 — **지어낸 이름을 쓰지 않는다.** +_UNNAMED_AXIS = "구분" + + +def _clean(text: Any) -> str: + return " ".join(str(text or "").split()) + + +def _tokens(cell: Any) -> list[str]: + return _clean(cell).split() + + +def _is_value_cell(cell: Any, count: int) -> bool: + """숫자(또는 「-」)만 `count` 개 든 칸인가.""" + parts = _tokens(cell) + if len(parts) != count: + return False + return all(_NUMBER.match(p) or p in _ABSENT for p in parts) + + +def _axis_values(cell: Any) -> list[str]: + """세로축 값들. 「2.4M 3.0M …」·「25 30 35 …」처럼 한 칸에 뭉쳐 있다.""" + parts = _tokens(cell) + if len(parts) < 2: + return [] + # 값 축이어야 한다 — 이름이 뭉친 줄(자원 이름 여럿)을 값으로 오해하면 안 된다. + if not all(re.match(r"^\d", p) for p in parts): + return [] + return parts + + +def _labelled_cells(row: list[Any], width: int) -> list[str] | None: + """줄에서 **값 칸에 대응하는 칸들**만 골라 낸다. 못 고르면 `None`. + + ⚠ 줄머리(축 이름) 칸이 **있는 줄과 없는 줄이 섞여 있다** — 11-1 의 자원 줄은 + 「비계공」으로 바로 시작하고, 13-5-1 의 자원 줄은 「뒷길이 (㎝)」로 시작한다. + 앞칸을 무조건 버리면 자원 하나가 통째로 사라진다(11-1 이 그래서 7 대 8 로 어긋났다). + 그래서 **있는 그대로 세어 보고, 안 맞으면 앞칸 하나를 줄머리로 보고 다시 센다.** + """ + cells = [_clean(cell) for cell in row if _clean(cell)] + cells = [cell for cell in cells if cell not in _NOTE_LABELS] + if not cells: + return None + if len(cells) == width or (width and len(cells) % width == 0): + return cells + if len(cells) - 1 == width or (width and (len(cells) - 1) % width == 0): + return cells[1:] + return None + + +def _group_labels(row: list[Any], width: int, prefix: str = "") -> list[str] | None: + """묶음 줄 하나를 **열 개수만큼** 펼친다. 딱 나뉘지 않으면 `None`. + + 「메 붙 임 | 찰 붙 임」이 열 열두 개를 반씩 먹는 모양을 여기서 편다. + + ⚠ **묶음 줄은 첫 칸을 먼저 떼고 센다** — 그 자리는 축 이름(「구 분」·「종 별」· + 「길이 폭」)이지 묶음이 아니다. 안 떼면 「구 분」이 묶음 하나로 서서 **메·찰이 + 통째로 사라진다**(2026-09-09 실측). 떼고도 안 나뉘면 그때 통째로 세어 본다. + """ + labels = [_clean(cell) for cell in row if _clean(cell)] + labels = [label for label in labels if label not in _NOTE_LABELS] + for candidate in (labels[1:], labels): + if candidate and width % len(candidate) == 0: + span = width // len(candidate) + spread: list[str] = [] + for label in candidate: + spread.extend([f"{prefix} {label}".strip() if prefix else label] * span) + return spread + return None + + +def _axis_name(rows: list[list[Any]], header: list[Any], resource_row_index: int) -> str: + """세로축 이름 — 값줄 바로 위 첫 칸(「뒷길이 (㎝)」)이 먼저다. + + 그 자리가 자원 이름이면(11-1 은 「비계공」이 온다) 표 머리 첫 칸의 **끝 낱말**을 쓴다 + (「길이 폭」의 「폭」 — 가로축 이름이 앞, 세로축 이름이 뒤인 품셈 표 머리 관례). + """ + if resource_row_index > 0: + candidate = _clean(rows[resource_row_index][0]) + if candidate and not _tokens(candidate)[0].isdigit(): + return candidate # 「뒷길이 (㎝)」 — 단위는 값 옆으로 옮겨 붙인다 + names = _split_axis_names(header[0] if header else "") + if names: + return names[1] + return _UNNAMED_AXIS + + +def _split_axis_names(cell: Any) -> tuple[str, str] | None: + """모서리 칸이 **축 이름 둘**인가 — 「길이 폭」이면 (길이, 폭), 「구 분」이면 아니다. + + ⚠ 품셈 표 머리에는 **자간을 벌린 한 낱말**이 흔하다(「구 분」·「종 별」·「종 류」). + 낱말 하나를 축 둘로 읽으면 갈래 이름이 「구 메 붙 임」처럼 망가진다(2026-09-09 실측). + 가르는 자리는 **글자 수**다 — 벌려 쓴 낱말은 토막이 모두 한 글자다. + """ + parts = _tokens(cell) + if len(parts) != 2: + return None + if all(len(part) == 1 for part in parts): + return None + return parts[0], parts[1] + + +def _axis_label(axis_name: str, value: str) -> str: + """「뒷길이 (㎝)」 + 「25」 → 「뒷길이 25㎝」. 이름에 딸린 단위를 값 옆으로 옮긴다.""" + match = re.match(r"^(.*?)\s*[((]\s*([^))]+?)\s*[))]\s*$", axis_name) + if match: + return f"{match.group(1).strip()} {value}{match.group(2).strip()}" + return f"{axis_name} {value}" + + +def match_three_axis_table( + node: dict[str, Any], + table: dict[str, Any], + catalog: ResourceCatalog, + result: AxisResult, +) -> bool: + """축이 셋인 표를 읽는다. 그런 표가 아니면 `False` — 원래 길로 보낸다. + + ⚠ 형태만 보고 가른다. **공종 코드를 박아 두지 않는다** — 품셈이 개정되면 + 표 번호가 움직이므로 코드로 잡으면 조용히 놓친다. + """ + raw_rows = [row for row in (table.get("raw_row") or []) if isinstance(row, list)] + if len(raw_rows) < 2: + return False + header = list(table.get("condition_note") or []) + + data_row = raw_rows[-1] + axis_values = _axis_values(data_row[0] if data_row else "") + if not axis_values: + return False + + # 값 칸 — 세로축 값 개수만큼 숫자를 든 칸만 값으로 본다. + # ⚠ **꼬리의 설명 칸은 떼어 낸다** — 「비고」 열에 「H=2.6M 기준 용도: 사무실, 창고」 + # 같은 글이 온다(11-1). 그 칸까지 값으로 세면 표 전체를 못 읽는다. 다만 **떼는 것은 + # 꼬리뿐**이다 — 가운데가 값이 아니면 자리를 단정할 수 없으므로 통째로 버린다. + cells = [cell for cell in data_row[1:] if _clean(cell)] + while cells and not _is_value_cell(cells[-1], len(axis_values)): + cells.pop() + value_cells = cells + if len(value_cells) < 2 or not all(_is_value_cell(c, len(axis_values)) for c in value_cells): + return False + + # 자원 이름 줄 — 값줄 바로 위. 빈 칸은 표 끝의 여백이라 버린다. + resource_row = raw_rows[-2] + names = _labelled_cells(resource_row, len(value_cells)) or [] + if len(names) != len(value_cells): + result.unmatched.append( + UnmatchedRow( + work_item_code=node.get("work_item_code", ""), + pum_table_id=str(table.get("pum_table_id", "")), + cell=" | ".join(_clean(c) for c in resource_row), + reason=( + f"축이 셋인 표인데 자원 이름 {len(names)} 개와 값 칸 {len(value_cells)} 개가 " + "맞지 않습니다 — 자리를 단정할 수 없어 한 줄도 세우지 않았습니다." + ), + ) + ) + return True + + # 묶음 줄 — 표 머리(condition_note)와 자원 줄 위의 raw_row 들. 위에서 아래 차례로 쌓는다. + group_rows: list[list[Any]] = [] + if header: + group_rows.append(header) + group_rows.extend(raw_rows[: len(raw_rows) - 2]) + + # 가로축 이름 — 「길이 폭」의 앞 낱말. 열 라벨이 「3M」뿐이라 이름이 없으면 + # 갈래가 「3M」으로만 남아 무엇의 3M 인지 안 보인다. + head_names = _split_axis_names(header[0] if header else "") + column_axis = head_names[0] if head_names else "" + + spreads: list[list[str]] = [] + for index, row in enumerate(group_rows): + prefix = column_axis if (index == 0 and header and row is header) else "" + spread = _group_labels(row, len(value_cells), prefix) + if spread is None: + result.unmatched.append( + UnmatchedRow( + work_item_code=node.get("work_item_code", ""), + pum_table_id=str(table.get("pum_table_id", "")), + cell=" | ".join(_clean(c) for c in row), + reason=( + "축이 셋인 표인데 묶음 이름이 열 개수로 딱 나뉘지 않습니다 — " + "짐작해 맞추지 않고 한 줄도 세우지 않았습니다." + ), + ) + ) + return True + spreads.append(spread) + + axis_name = _axis_name(raw_rows, header, len(raw_rows) - 2) + unit = table.get("basis_unit") or "" + form = str(table.get("pum_form", "")) + work_item_code = node.get("work_item_code", "") + table_id = str(table.get("pum_table_id", "")) + + # ⚠ **자원 줄이 정말 자원 줄인지 먼저 본다.** 축이 넷인 표(10-6-3 기타 임업자재)는 + # 값줄 바로 위가 **단위 줄**(「인/㎥」·「인/100속」)이라 자원 이름이 하나도 안 풀린다. + # 그런 표는 **내 표가 아니다** — 못 맞춤에 적지 않고 원래 길로 돌려보낸다. + resolved = [catalog.resolve(*split_name_and_spec(cell)) for cell in names] + if not any(entry is not None for entry in resolved): + return False + + made = 0 + for column, (name_cell, value_cell) in enumerate(zip(names, value_cells)): + entry = resolved[column] + if entry is None: + result.unmatched.append( + UnmatchedRow( + work_item_code=work_item_code, + pum_table_id=table_id, + cell=name_cell, + reason="자원 이름을 카탈로그에서 못 찾았습니다 — 0 으로 때우지 않습니다.", + ) + ) + continue + labels = [spread[column] for spread in spreads] + for index, token in enumerate(_tokens(value_cell)): + if token in _ABSENT: + # 「-」 는 **그 규격이 없다는 뜻** — 0 으로 세우면 공짜 공종이 된다. + continue + amount = parse_amount(token) + if amount is None: + continue + variant = " · ".join([*labels, _axis_label(axis_name, axis_values[index])]) + 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=len(raw_rows) - 1, + variant=variant, + ) + ) + made += 1 + return made > 0