"""B07 횡단 수량 산출표 — 납품 양식 표 작도와 편집값 역추출. 표는 도면 좌표(종이 mm) 기준이다. 값 셀 id는 uuid5(NS, "{drawing_id}:qtable:{key}")로 정해져 있어, 사용자가 CAD에서 고친 숫자를 그대로 되읽을 수 있다. """ from typing import Any from uuid import uuid5 from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( _ENTITY_NS, CROSS_TABLE_LAYER_ID, TABLE_LABEL_COLOR, TABLE_LINE_COLOR, TABLE_VALUE_COLOR, _format, table_entity, ) # 횡단 수량 산출표 (납품 양식). 본문 6행 × 3그룹. # 좌: 깍기(토사/암석)·측구(토사/암석)·쌓기·층따기 # 중: 면고르기(성토/절토)·지장목제거(성토/절토)·표토제거(성토/절토) # 우: 편책·성토파종·절토살포·제근·(공란)·노면다짐 _CROSS_HEADER_KEYS: tuple[tuple[str, str], ...] = ( ("지반고", "ground"), ("계획고", "planned"), ("성토고", "fill"), ("절토고", "cut"), ) # 좌측 그룹: (그룹 라벨 or None, 하위 라벨 or None, 값 키 or None) — 행 순서대로. _CROSS_LEFT_ROWS: tuple[tuple[str | None, str | None, str | None], ...] = ( ("깍기", "토사", "cut_soil"), (None, "암석", "cut_rock"), ("측구", "토사", "ditch_soil"), (None, "암석", "ditch_rock"), ("쌓기", None, "embankment"), ("층따기", None, "benching"), ) _CROSS_MIDDLE_ROWS: tuple[tuple[str | None, str, str], ...] = ( ("면고르기", "성토", "grading_fill"), (None, "절토", "grading_cut"), ("지장목제거", "성토", "tree_removal_fill"), (None, "절토", "tree_removal_cut"), ("표토제거", "성토", "topsoil_fill"), (None, "절토", "topsoil_cut"), ) _CROSS_RIGHT_ROWS: tuple[tuple[str | None, str | None], ...] = ( ("편책", "fence"), ("성토파종", "fill_seeding"), ("절토살포", "cut_spraying"), ("제근", "grubbing"), (None, None), ("노면다짐", "road_compaction"), ) QUANTITY_VALUE_KEYS: tuple[str, ...] = ( "ground", "planned", "fill", "cut", "cut_soil", "cut_rock", "ditch_soil", "ditch_rock", "embankment", "benching", "grading_fill", "grading_cut", "tree_removal_fill", "tree_removal_cut", "topsoil_fill", "topsoil_cut", "fence", "fill_seeding", "cut_spraying", "grubbing", "road_compaction", ) # 표 치수는 종이 밀리미터다 (도면 좌표 = mm). _CROSS_TABLE_WIDTH = 108.0 _CROSS_TABLE_ROW_HEIGHT = 5.0 _CROSS_TABLE_FONT = 2.0 # 열 경계 비율 (좌: 그룹/하위/값/여백, 중: 그룹/하위/값/여백, 우: 라벨/값/여백) _CROSS_COLUMN_WEIGHTS: tuple[float, ...] = (1.0, 1.7, 2.1, 1.0, 1.9, 1.4, 2.1, 1.0, 2.6, 2.1, 1.0) def _cross_column_edges(width: float, center_x: float = 0.0) -> list[float]: total = sum(_CROSS_COLUMN_WEIGHTS) left = center_x - width / 2.0 edges = [left] accumulated = 0.0 for weight in _CROSS_COLUMN_WEIGHTS: accumulated += weight edges.append(left + width * accumulated / total) return edges def _union_edges(*edge_lists: list[float], tol: float = 1e-6) -> list[float]: """여러 격자의 열 경계를 하나로 합친다 (같은 자리는 한 번만).""" merged: list[float] = [] for edges in edge_lists: for x in edges: if not any(abs(x - m) <= tol for m in merged): merged.append(x) merged.sort() return merged def _span(edges: list[float], x_from: float, x_to: float) -> tuple[int, int]: """합친 격자에서 [x_from, x_to] 구간이 차지하는 (시작 열, 열 수).""" start = min(range(len(edges)), key=lambda k: abs(edges[k] - x_from)) stop = min(range(len(edges)), key=lambda k: abs(edges[k] - x_to)) return start, max(1, stop - start) def _cross_table_entities( drawing_id: str, quantity_table: dict[str, float | None], table_top: float, title_label: str, center_x: float = 0.0, ) -> list[dict[str, Any]]: """횡단 수량 산출표(납품 양식)를 표 객체 하나로 만든다. 머리행은 폭을 8등분하고 본문은 11열 가중치를 쓴다 — 격자가 서로 다르므로 두 경계를 합친 열로 표를 만들고 병합으로 원래 칸을 되살린다. 병합 자리에서 선을 끊는 일은 표가 스스로 하므로, 예전처럼 가로선을 손으로 끊지 않는다. """ width = _CROSS_TABLE_WIDTH row_h = _CROSS_TABLE_ROW_HEIGHT font = _CROSS_TABLE_FONT left = center_x - width / 2.0 right = center_x + width / 2.0 body_edges = _cross_column_edges(width, center_x) header_cell = width / 8.0 header_edges = [left + header_cell * index for index in range(9)] edges = _union_edges(body_edges, header_edges) column_widths = [edges[i + 1] - edges[i] for i in range(len(edges) - 1)] body_rows = len(_CROSS_LEFT_ROWS) rows = body_rows + 2 # 제목행 + 머리행 + 본문 cells: list[list[dict[str, Any] | None]] = [[None] * len(column_widths) for _ in range(rows)] def put( row: int, x_from: float, x_to: float, text: str, color: str, *, align: str = "center", font_size: float | None = None, row_span: int = 1, key: str | None = None, ) -> None: column, col_span = _span(edges, x_from, x_to) cell: dict[str, Any] = {"text": text, "color": color, "align": align} if col_span > 1: cell["colSpan"] = col_span if row_span > 1: cell["rowSpan"] = row_span if font_size is not None: cell["fontSize"] = font_size if key is not None: # 사용자가 CAD에서 고친 값을 되읽을 때 쓰는 이름 (extract_quantity_table) cell["key"] = key cells[row][column] = cell def value_of(key: str) -> str: value = quantity_table.get(key) return _format(value) if isinstance(value, (int, float)) else "-" # ── 제목행 (No.측점) — 전체 병합 put(0, left, right, title_label, TABLE_LABEL_COLOR, align="left", font_size=font * 1.15) # ── 머리행 (지반고/계획고/성토고/절토고) — 폭 8등분 for pair_index, (label, key) in enumerate(_CROSS_HEADER_KEYS): x0 = left + header_cell * pair_index * 2 put(1, x0, x0 + header_cell, label, TABLE_LABEL_COLOR) put(1, x0 + header_cell, x0 + header_cell * 2, value_of(key), TABLE_VALUE_COLOR, key=key) # ── 본문 6행 (좌/중/우 그룹) for row_index in range(body_rows): row = row_index + 2 group_l, sub_l, key_l = _CROSS_LEFT_ROWS[row_index] if group_l is not None and sub_l is not None: # 그룹 라벨은 아래 행까지 두 행 병합 put(row, body_edges[0], body_edges[1], group_l, TABLE_LABEL_COLOR, row_span=2) elif group_l is not None: # 하위 라벨이 없으면 그룹 열과 하위 열을 합친다 put(row, body_edges[0], body_edges[2], group_l, TABLE_LABEL_COLOR) if sub_l is not None: put(row, body_edges[1], body_edges[2], sub_l, TABLE_LABEL_COLOR) if key_l is not None: put(row, body_edges[2], body_edges[3], value_of(key_l), TABLE_VALUE_COLOR, key=key_l) group_m, sub_m, key_m = _CROSS_MIDDLE_ROWS[row_index] if group_m is not None: put(row, body_edges[4], body_edges[5], group_m, TABLE_LABEL_COLOR, row_span=2) put(row, body_edges[5], body_edges[6], sub_m, TABLE_LABEL_COLOR) put(row, body_edges[6], body_edges[7], value_of(key_m), TABLE_VALUE_COLOR, key=key_m) label_r, key_r = _CROSS_RIGHT_ROWS[row_index] if label_r is not None: put(row, body_edges[8], body_edges[9], label_r, TABLE_LABEL_COLOR) if key_r is not None: put(row, body_edges[9], body_edges[10], value_of(key_r), TABLE_VALUE_COLOR, key=key_r) return [ table_entity( f"{drawing_id}:qtable", (left, table_top), column_widths, [row_h] * rows, cells, CROSS_TABLE_LAYER_ID, TABLE_LINE_COLOR, font, TABLE_VALUE_COLOR, ) ] def _table_values_from_cells( entities: list[Any], drawing_id: str | None = None ) -> dict[str, float | None]: """표 객체의 칸에서 값을 읽는다. 칸의 key가 어느 수량인지 알려 준다. **한 도면에 표가 여럿이면 그 측점 것만 읽는다**(장은 측점 4~6개를 담는다). 표 엔티티 id 는 `uuid5(f"{도면id}:qtable")` 이라 측점을 되짚을 수 있다. 이 걸름이 없던 동안 장 확정이 **모든 측점에 마지막 표 값을 넣었다**(2026-09-03 실측: 4개 측점이 전부 지반고 837.21 — 도면에는 840.87·840.33·836.45·837.21 로 제대로 그려져 있었다). id 로 못 찾으면(옛 도면) 종전처럼 전부 훑는다. """ wanted = str(uuid5(_ENTITY_NS, f"{drawing_id}:qtable")) if drawing_id else None if wanted is not None and not any( isinstance(entity, dict) and entity.get("id") == wanted for entity in entities ): wanted = None table: dict[str, float | None] = {} for entity in entities: if not isinstance(entity, dict) or entity.get("type") != "Table": continue if wanted is not None and entity.get("id") != wanted: continue shape = entity.get("shapeData") rows = shape.get("cells") if isinstance(shape, dict) else None if not isinstance(rows, list): continue for row in rows: if not isinstance(row, list): continue for cell in row: if not isinstance(cell, dict): continue key = cell.get("key") if key not in QUANTITY_VALUE_KEYS: continue try: table[str(key)] = float(str(cell.get("text")).replace(",", "")) except (TypeError, ValueError): table[str(key)] = None return table def _table_values_from_texts(drawing_id: str, entities: list[Any]) -> dict[str, float | None]: """표를 객체로 바꾸기 전에 저장된 도면 — 값 Text의 결정적 id로 읽는다.""" id_to_key = { str(uuid5(_ENTITY_NS, f"{drawing_id}:qtable:{key}")): key for key in QUANTITY_VALUE_KEYS } table: dict[str, float | None] = {} for entity in entities: if not isinstance(entity, dict): continue key = id_to_key.get(str(entity.get("id"))) if key is None: continue shape = entity.get("shapeData") label = shape.get("label") if isinstance(shape, dict) else None try: table[key] = float(str(label).replace(",", "")) except (TypeError, ValueError): table[key] = None return table def extract_quantity_table( drawing_id: str, drawing: dict[str, Any] ) -> dict[str, float | None] | None: """확정 도면 JSON에서 수량 산출표 값을 역추출한다. 표 객체의 칸에 실린 key를 먼저 읽고, 없으면 옛 방식(값 Text의 결정적 id)으로 읽는다. 둘 다 없으면 None을 반환해 호출부가 요청 본문 quantity_table 폴백을 쓰게 한다. """ entities = drawing.get("entities") if not isinstance(entities, list): return None table = _table_values_from_cells(entities, drawing_id) if not table: table = _table_values_from_texts(drawing_id, entities) if not table: return None for key in QUANTITY_VALUE_KEYS: table.setdefault(key, None) ground = table.get("ground") planned = table.get("planned") if isinstance(ground, (int, float)) and isinstance(planned, (int, float)): table["cut"] = max(ground - planned, 0.0) table["fill"] = max(planned - ground, 0.0) return table def cross_table_width() -> float: """수량 산출표 가로 폭(mm).""" return _CROSS_TABLE_WIDTH def cross_table_height() -> float: """수량 산출표 세로 높이(mm) — 머리 2행 + 본문 6행.""" return _CROSS_TABLE_ROW_HEIGHT * (len(_CROSS_LEFT_ROWS) + 2)